diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..258d777e --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: raiseCatError diff --git a/.github/actions/linux-shells/action.yml b/.github/actions/linux-shells/action.yml new file mode 100644 index 00000000..fb0f1abe --- /dev/null +++ b/.github/actions/linux-shells/action.yml @@ -0,0 +1,30 @@ +name: Linux shell prerequisites +description: Install missing shell/build tools and secure system completion paths +runs: + using: composite + steps: + - shell: bash + run: | + # Ubuntu images supply native build tools; probe rather than reinstall. + packages=() + command -v zsh >/dev/null || packages+=(zsh) + command -v fish >/dev/null || packages+=(fish) + command -v g++ >/dev/null || packages+=(g++) + command -v make >/dev/null || packages+=(make) + command -v python3 >/dev/null || packages+=(python3) + if (( ${#packages[@]} )); then + sudo apt-get update + sudo apt-get install -y "${packages[@]}" + fi + 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' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1881c72f..ca532e20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: [master, dev] pull_request: - branches: [master, dev] workflow_dispatch: permissions: @@ -15,43 +14,184 @@ concurrency: cancel-in-progress: true jobs: - verify: - name: Verify - runs-on: macos-latest + changes: + name: Detect code changes + runs-on: ubuntu-24.04 + outputs: + code: ${{ steps.changes.outputs.code }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: changes + env: + EVENT: ${{ github.event_name }} + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + shell: bash + run: | + # Never path-skip the workflow: the final CI check must always exist. + # Push/manual runs always collect comprehensive evidence. + if [[ "$EVENT" != pull_request ]]; then + echo 'code=true' >> "$GITHUB_OUTPUT" + else + git diff --check "$BASE" "$HEAD" + git diff --no-renames --name-only -z "$BASE" "$HEAD" > /tmp/changed-files + python3 - <<'PYTHON' >> "$GITHUB_OUTPUT" + from pathlib import Path + files = Path('/tmp/changed-files').read_bytes().split(b'\0') + docs_only = all(f.startswith(b'docs/') or f.endswith(b'.md') for f in files if f) + print('code=' + str(not docs_only).lower()) + PYTHON + fi + + quality: + name: Quality (Node 26) + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 26.x + cache: npm + - run: npm ci + - run: npm run build + - run: npm run typecheck:bench + - run: npm run test:fast + - run: git diff --check + + tests: + name: Tests (${{ matrix.os }}, ${{ matrix.shard }}/2) + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ${{ matrix.os }} timeout-minutes: 10 strategy: + fail-fast: false matrix: - node-version: [22.x, 26.x] - + os: [macos-latest, ubuntu-24.04] + shard: [1, 2] steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Build - run: npm run build - - - name: Typecheck - run: npm run typecheck + node-version: 26.x + cache: npm + - uses: ./.github/actions/linux-shells + if: runner.os == 'Linux' + - run: npm ci + # Built-launcher tests must execute rather than silently skip on fresh runners. + - run: npm run build + - run: npm test -- --shard=${{ matrix.shard }}/2 --test-timeout=120000 + - name: Process leak check + if: always() + run: bash scripts/check-process-leaks.sh - - name: Run tests - run: npm test + node22: + name: Node 22 compatibility + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: npm + - uses: ./.github/actions/linux-shells + - run: npm ci + - run: npm run build + - run: npm run test:node22 -- --test-timeout=120000 + - name: Process leak check + if: always() + run: bash scripts/check-process-leaks.sh - - name: Verify working tree clean - run: git diff --check + timings: + name: Timing smoke (${{ matrix.os }}, Node 26) + if: github.event_name != 'pull_request' + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-24.04] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 26.x + cache: npm + - uses: ./.github/actions/linux-shells + if: runner.os == 'Linux' + - run: npm ci + - run: npm run bench:smoke + - name: Process leak check + if: always() + run: bash scripts/check-process-leaks.sh + fedora: + name: Linux portability subset (Fedora) + if: github.event_name != 'pull_request' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + container: fedora:42 + steps: + - name: Install system prerequisites + run: dnf install -y git zsh fish gcc-c++ make python3 which lsof procps-ng + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: npm + - run: npm ci + - run: npm run build + - run: npm run test:fedora -- --test-timeout=120000 - name: Process leak check + if: always() + run: bash scripts/check-process-leaks.sh + + status: + name: CI + if: always() + needs: [changes, quality, tests, node22, timings, fedora] + runs-on: ubuntu-24.04 + steps: + - name: Require all applicable gates + env: + RESULTS: ${{ toJSON(needs) }} run: | - if ps -axo pid,ppid,pgid,command | grep -E '[z]sh.*nmsh-semantic|[n]msh-semantic|viewportSyntax' | grep -v grep; then - echo "::error::NMSh helper or test processes leaked!" - exit 1 - fi - echo "No processes leaked." + python3 - <<'PYTHON' + import json, os + jobs = json.loads(os.environ['RESULTS']) + code = jobs['changes']['outputs'].get('code') + required = ['changes'] + if code == 'true': + required += ['quality', 'tests', 'node22'] + elif code != 'false': + raise SystemExit('Missing change classification') + if '${{ github.event_name }}' != 'pull_request': + required += ['timings', 'fedora'] + failed = [name for name in required if jobs[name]['result'] != 'success'] + if failed: + raise SystemExit('Failed or skipped required gates: ' + ', '.join(failed)) + print('All applicable CI gates passed') + PYTHON + + # Preserve the exact check names required by the existing master ruleset. + required-checks: + name: Verify (${{ matrix.node-version }}) + needs: status + if: always() + runs-on: ubuntu-24.04 + strategy: + matrix: + node-version: [22.x, 26.x] + steps: + - name: Require aggregate CI success + env: + RESULT: ${{ needs.status.result }} + run: test "$RESULT" = success diff --git a/.github/workflows/homebrew-tap.yml b/.github/workflows/homebrew-tap.yml new file mode 100644 index 00000000..cbd34de2 --- /dev/null +++ b/.github/workflows/homebrew-tap.yml @@ -0,0 +1,68 @@ +name: Homebrew tap + +# After a stable GitHub release is published, propose the matching formula +# update to raiseCatError/homebrew-tap as a pull request (reviewable; the +# tap's own CI installs and tests it). Never runs for prereleases, dev pushes +# or pull requests. +# +# Needs one secret, HOMEBREW_TAP_TOKEN: a fine-grained token limited to the +# raiseCatError/homebrew-tap repository with Contents and Pull requests write. + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + formula: + # Distribution is not configured yet. Enable only in the separate Homebrew setup pass. + if: ${{ false }} + runs-on: ubuntu-latest + steps: + - name: Release facts + id: release + env: + TAG: ${{ github.event.release.tag_name }} + run: | + # Strict stable semver only: the value later reaches a URL and a sed expression. + printf '%s' "$TAG" | grep -Eqx 'v[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}' || { echo "Not a stable version tag."; exit 1; } + version="${TAG#v}" + url="https://github.com/${GITHUB_REPOSITORY}/archive/refs/tags/${TAG}.tar.gz" + curl -fsSL --retry 3 -o release.tar.gz "$url" + sha256="$(sha256sum release.tar.gz | cut -d' ' -f1)" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "url=$url" >> "$GITHUB_OUTPUT" + echo "sha256=$sha256" >> "$GITHUB_OUTPUT" + + - name: Check out the tap + uses: actions/checkout@v4 + with: + repository: raiseCatError/homebrew-tap + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + path: tap + + - name: Propose the formula update + working-directory: tap + env: + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + VERSION: ${{ steps.release.outputs.version }} + URL: ${{ steps.release.outputs.url }} + SHA256: ${{ steps.release.outputs.sha256 }} + run: | + formula=Formula/nmsh.rb + if grep -q "^ url \"$URL\"$" "$formula"; then + # A published archive is immutable: never re-point the same version at different bytes. + grep -q "^ sha256 \"$SHA256\"$" "$formula" || { echo "The formula already names $URL with a different sha256. Publish a new patch release instead."; exit 1; } + echo "Formula already at $VERSION."; exit 0 + fi + sed -i -E "s|^ url \".*\"$| url \"$URL\"|; s|^ sha256 \".*\"$| sha256 \"$SHA256\"|" "$formula" + git diff --stat + branch="nmsh-$VERSION" + git switch -c "$branch" + git -c user.name=J -c user.email=315733358+raiseCatError@users.noreply.github.com commit -am "nmsh $VERSION" + git push origin "$branch" + gh pr create --title "nmsh $VERSION" --body "Release: https://github.com/raiseCatError/notMyShell/releases/tag/v$VERSION + Archive: $URL + sha256: $SHA256" diff --git a/.gitignore b/.gitignore index ba34fb68..b0939c4b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ dist/ node_modules/ .DS_Store +*.swp diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 00000000..2e510aff --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 00000000..31d57672 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,171 @@ +# the name by which the project can be referenced within Serena/when chatting with the LLM. +project_name: "notMyShell" + +# list of language servers to start when using the LSP backend; choose from: +# ada al angular ansible bash +# bsl clojure cpp cpp_ccls crystal +# csharp csharp_omnisharp cue dart deno +# elixir elm erlang fortran fsharp +# gdscript gleam go groovy haskell +# haxe hlsl html java json +# julia julia_fatou kotlin latex lean4 +# lua luau markdown matlab msl +# nextflow nix ocaml pascal perl +# php php_phpactor php_phpantom powershell python +# python_basedpyright python_jedi python_pyrefly python_ty qml +# r rego ruby ruby_solargraph rust +# scala scss solidity svelte swift +# systemverilog terraform toml typescript typescript_vts +# vue wolfram yaml zig +# (This list may be outdated; generated with scripts/print_language_list.py; +# For the current list, see values of the LanguageServerId enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) +# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) +# - For Free Pascal/Lazarus, use pascal +# - External Python adapters may add further registered IDs; install the adapter package first +# and then use its ID here, for example: example +# Special requirements: +# Some language servers require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple language servers, the first language server that supports a given file will be used for that file. +# The first language server is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +language_servers: +- typescript + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# optional shell command to run before the language backend (LSP or JetBrains) is initialised. +# the command runs in the project root directory and is only executed if the project is trusted +# (see trusted_project_path_patterns in the global configuration). +# serena waits for the command to exit: a non-zero exit code is logged as an error but does not +# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety +# backstop for non-terminating commands; on expiry the process is killed and activation continues. +# example: activation_command: "npx nx run-many -t build" +activation_command: + +# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). +# must be a positive number. +activation_command_timeout: 180.0 + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# The settings are considered only if the project is trusted (see global configuration to define trusted projects). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings +ls_specific_settings: {} + +# list of workspace folder paths (LSP backend only). +# These folders will be used to build up Serena's symbol index. +# Paths must be within the project root and should thus be relative to the project root. +# Furthermore, the paths should not be filtered by ignore settings. +# Default setting: The entire project root folder (".") is considered. +# In (large) monorepos, this can be used to index only subfolders of the project root, e.g. +# ls_workspace_folders: +# - "./subproject1" +# - "./subproject2" +ls_workspace_folders: +- "." + +# list of additional workspace folder paths for cross-package reference support. +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries, but these folders are not indexed by Serena, +# i.e. the respective symbols will not be found using Serena's symbol search tools. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +ls_additional_workspace_folders: [] + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases. +# Example: +# ignored_paths: +# - "examples/**" +# - ".worktrees/**" +# - "**/bin/**" +# - "**/obj/**" +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +fixed_tools: [] + +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. +# This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +default_modes: + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] diff --git a/AGENTS.md b/AGENTS.md index 721a8de3..4bf94c51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,12 +1,12 @@ # NMSh Agent Guide ## What NMSh is -notMyShell (NMSh) is a terminal frontend that operates over a persistent, real zsh session. Instead of replacing the shell or running commands as isolated subprocesses, NMSh orchestrates a hidden pseudo-terminal (PTY) running zsh. It captures input via a fixed editor, highlights it semantically, and sends it to the real shell. +notMyShell (NMSh) is a terminal frontend that operates over a persistent, real shell session (zsh by default, Bash 4.4+ or Fish through the ShellAdapter). Instead of replacing the shell or running commands as isolated subprocesses, NMSh orchestrates a hidden pseudo-terminal (PTY) running that shell. It captures input in its own composer, highlights it semantically, and sends it to the real shell. ## Core invariants - NMSh is a frontend over a persistent real shell. - Do not replace ShellSession with command-by-command spawning. -- Preserve real zsh state between commands. +- Preserve real shell state between commands (zsh, Bash and Fish alike). - Raw PTY stdout/stderr must remain raw/presentation-safe. - Do not semantically recolor arbitrary PTY output. - NMSh-owned submitted command lines may have semantic highlighting. @@ -29,9 +29,12 @@ notMyShell (NMSh) is a terminal frontend that operates over a persistent, real z - **history viewport**: Scrollable past commands and raw PTY output - **autocomplete/suggestions**: Real-time completion hints below the input - **live activity**: Real-time animation and elapsed time for running commands -- **context/prompt**: Evaluated from the shell and displayed on the bottom editor -- **persistent editor**: The fixed input box at the bottom of the screen -- **separator**: A visual divider between output and the editor +- **context/prompt**: Evaluated from the shell; NMSh Native or an external provider (Starship, Oh My Posh, Powerlevel10k), or None +- **composer**: The persistent editor, docked Bottom or Top, or in Flow after the newest output; one-line or two-line +- **composer edges**: Optional divider rows around the composer (one shared edge renderer); NMSh-owned accessories such as Keep Awake use a free edge and never touch prompt content +- **frontend chrome**: Status Strip, notices, find bar and accessory rows are planned by `src/app/screenPlan.ts`, never written to the transcript + +`src/app/screenPlan.ts` is the single geometry source per frame: render, hit testing, cursor, viewport and PTY sizing all read the same plan. NMSh uses a FOLLOW mode during execution, pinning the output viewport to the bottom. During historical inspection, it enters DETACHED mode. @@ -50,18 +53,42 @@ As the user types, partial input is tokenized. Known executables, aliases, and b Submitted commands retain their semantic presentation in the NMSh output history. The styling (e.g. lavender for known commands, red for unknown commands) persists even after the command completes. ## Shell compatibility -Current: zsh-first -Future: The ShellAdapter architecture (detailed in ROADMAP.md) is designed to eventually support Bash, Fish, Nushell, and pwsh. Multi-shell support does not exist yet. +Released (0.16.0): a real ShellAdapter with zsh, Fish and Bash 4.4+ backends ([docs/architecture/shell-adapter.md](docs/architecture/shell-adapter.md)). Nushell and PowerShell remain future. Changes must keep all three backends working. + +## Safety boundaries +- Shell config, framework code and parseable tool configs are executable: never source, merge or silently edit them. Offered edits are exact diffs behind a confirmation. +- Theme imports and dotfiles are data: bounded parsers, no includes, templates, network or repository code execution; dotfiles exact copies fail closed. +- NMSh changes only what its ownership ledger proves it wrote; Keep Awake signals only its own verified process. +- Installs are typed argv shown before confirmation; special installers are never run by NMSh. +- Chroma never reaches generated external artifacts. + +## Current surfaces (unreleased beyond v0.16.0) +Theme Studio (`/theme`), Theme Bridge (`/theme-bridge`), `/providers`, `/tools` with filesystem-detected shell frameworks, `/configure`, `/tmux` Config Studio, `/integrations`, `/dotfiles`, the Oh My Posh provider, and Keep Awake (`/caffeinate`, `/awake`, `/zoomies`, with composer-edge, Status Strip, idle-reminder and screensaver presentation). See CHANGELOG.md → Unreleased. ## Testing / verification -Canonical verification commands: +During implementation, run focused affected tests. Use `npm run verify:fast` for +ordinary iteration (build, an explicit core test subset, and diff checks); it is +not the final gate. Before pushing a meaningful checkpoint, run `npm run verify` +(build, the full canonical suite, and diff checks). Build already checks the +source TypeScript; `npm run typecheck` remains available for direct use. + +For release-sensitive changes run `npm run verify:release`, which adds benchmark +script typechecking and bounded timing smoke. These commands reuse local +node_modules; use `npm ci` for clean CI/release environments. Batch coherent +changes and avoid pushing tiny or known-broken edits to use Actions as a test +runner. GitHub CI provides independent platform verification, not a replacement +for local checks. See [development verification](docs/development-verification.md) +for sharding, platform gates and exact-release evidence requirements. + ```bash -npm run build -npm run typecheck -npm test -git diff --check +npm run verify:fast +npm run verify +npm run verify:release +npm run demos # re-record README/docs media with VHS (scripts/demos/README.md) ``` +Do not hardcode test totals in docs; they change with every slice. + When writing tests involving `TerminalApp`, you must carefully tear down child processes and temp ZDOTDIRs: ```typescript app['stop'](0); @@ -76,10 +103,13 @@ app['session'].kill(); - prefer localized changes - do not casually rewrite the renderer or PTY architecture - no fabricated manual verification +- commits and PRs carry no AI attribution and no AI co-author trailers - distinguish automated verification from human GUI/runtime validation ## Planning and GitHub tracking +For the #305 Context Engine program, follow [the Context Engine agent protocol](docs/development/context-engine-agent-protocol.md) and its canonical design before editing. + GitHub is the durable source of truth for what NMSh is building, what comes next, and why. For substantial implementation work: @@ -91,10 +121,12 @@ For substantial implementation work: 5. **Only close work requiring human validation after that validation occurs** Key references: +- [ARCHITECTURE.md](ARCHITECTURE.md) — the human-readable architecture overview (runtime and data flows, repository map); deeper detail in `docs/architecture/` and `docs/design/` - [ROADMAP.md](ROADMAP.md) — product direction and issue index - [GitHub Issues](https://github.com/raiseCatError/notMyShell/issues) — actionable work -- [v0.7.0 Release](https://github.com/raiseCatError/notMyShell/releases/tag/v0.7.0) — current stable release -- [#132 Flow / Classic composer](https://github.com/raiseCatError/notMyShell/issues/132) — next planned direction (see [ROADMAP.md](ROADMAP.md)) +- [v0.16.0 Release](https://github.com/raiseCatError/notMyShell/releases/tag/v0.16.0) — current stable release +- [PR #303](https://github.com/raiseCatError/notMyShell/pull/303) — the merged cumulative v0.16 release +- [PR #315](https://github.com/raiseCatError/notMyShell/pull/315) — open development PR for the work after v0.16.0 (not released; the package version stays 0.16.0) - [GitHub Project](https://github.com/users/raiseCatError/projects/1) — live development status board - [docs/architecture/terminal-stack.md](docs/architecture/terminal-stack.md) — terminology and stack model - [docs/design/structured-execution.md](docs/design/structured-execution.md) — v0.2.0 design decisions @@ -128,12 +160,9 @@ When given a task such as "work on the next Ready NMSh issue", follow this workf 9. **Add or update automated tests for behavior changes** where appropriate. -10. **Run the canonical verification suite:** +10. **Run canonical local verification before pushing a coherent checkpoint:** ```bash - npm run build - npm run typecheck - npm test - git diff --check + npm run verify ``` 11. **Commit and push the feature branch.** @@ -331,10 +360,7 @@ Cloud agents must detect their actual environment. Do not assume a cloud VM is m The project requires Node >=22. Prefer `npm ci` then run supported canonical verification: ```bash -npm run build -npm run typecheck -npm test -git diff --check +npm run verify ``` GitHub CI remains an integration gate. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..a9f63357 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,399 @@ +# notMyShell Architecture + +## 1. The 30-second explanation + +notMyShell (NMSh) is a terminal frontend. It is not a shell: your real zsh, Bash, or Fish still interprets commands and runs programs. It is not a terminal emulator: Ghostty, Terminal.app, Kitty, or another terminal application still draws the window, fonts, and terminal cells. + +NMSh sits between them and owns the interaction around commands: the composer, suggestions, history presentation, command feedback, session navigation, panels, themes, and integrations. + +```text +Terminal emulator: window, fonts, terminal protocols + ↕ +NMSh frontend: editor, transcript, panels, presentation + ↕ session client +Session owner: usually a separate local NMSh service + ↕ pseudo-terminal (PTY) + Real zsh / Bash / Fish + ↕ + Programs / OS +``` + +The shell stays alive between commands. `cd`, exported variables, aliases, functions, and jobs therefore belong to a real continuing shell, rather than to a simulation assembled by NMSh. This document describes the current implementation, including development features; it is not a statement that every feature described has been released. + +## 2. What NMSh is made from + +The application is **TypeScript running on Node.js**, built into JavaScript for its command-line launcher. Node supplies process management, streams, filesystem access, events, and local sockets. TypeScript makes the contracts between shell events, settings, panels, and actions explicit. + +**node-pty** creates a pseudo-terminal: a communication channel that looks like a terminal to the shell and its programs. Ordinary pipes would not provide the same interactive behavior, terminal sizing, and job control. + +The frontend draws using **ANSI and related terminal escape sequences**. These are byte instructions for colors, cursor movement, screen modes, hyperlinks, and input reporting. NMSh has its own renderer and screen planner rather than a general web UI framework. **string-width** measures terminal cell widths so wide characters and decorative glyphs do not break wrapping and cursor placement. + +Small **shell bootstrap scripts** load the user's normal shell environment, suppress competing shell prompt/editor UI, and report command lifecycle events. Local JSON stores settings and transcripts. **smol-toml**, **yaml**, and **fast-xml-parser** support bounded data imports and reviewed configuration formats; they do not make executable configuration safe to run. + +The main coordinator is `TerminalApp`. It connects input, shell events, output state, panels, settings, and rendering. The editor, shell transport, transcript, and domain controllers provide more focused responsibilities around that coordinator. + +## 3. The terminal + shell relationship + +The outer terminal translates physical keys into bytes and displays bytes NMSh writes. NMSh reads those keys and usually edits its own command buffer. The real shell runs inside an inner PTY. + +The object that owns that PTY is `ShellSession`. It launches one persistent interactive shell, writes submissions, forwards interrupts, resizes the PTY, and separates authenticated lifecycle messages from program output. Normally a separate `SessionService` owns `ShellSession`; an in-process fallback can own it inside the frontend. + +```text +Normal command: +keys → NMSh editor → shell PTY → program +screen ← NMSh transcript ← PTY output + +Interactive/fullscreen program: +keys ─────────────────→ shell PTY → program +screen ←─────────────── program's terminal output + NMSh presentation suspended +``` + +The shell remains authoritative for parsing, expansion, pipes, redirections, working directory, and jobs. NMSh owns the editable text and its presentation. It does not turn each command into a separate Node subprocess. + +For fullscreen and interactive programs, NMSh switches to **passthrough**: it suspends its screen, gives the program the full terminal dimensions, and forwards input and output. A known-command heuristic can select this immediately; output classification can also detect terminal behavior that requires it. Terminal modes are tracked so attachment and return to the composer can restore the appropriate state. + +Typing `caffeinate -i` invokes a program through the shell and occupies that shell until it finishes. `/zoomies` invokes NMSh's separate Keep Awake controller. It can keep an OS assertion alive without occupying the managed shell. + +## 4. What happens when I type a command? + +Consider `npm test`: + +1. **Keystrokes arrive.** The key decoder turns terminal bytes into editing actions. NMSh's `CommandEditor` keeps the command text and cursor position. +2. **Presentation updates.** A synchronous lexical highlighter identifies words, strings, operators, and other tokens. Background command classification supplies facts such as whether `npm` is an executable, alias, or builtin. Suggestions and completion services offer candidates without executing the unfinished command. +3. **Enter submits.** `TerminalApp` reads the actual editor text and checks for an NMSh slash action. For a shell command, it creates a command block, captures historical context, starts activity feedback, and sends the text through the session client to `ShellSession`. +4. **The real shell executes.** Its bootstrap reports a command-start marker. The shell resolves `npm`, performs its own parsing, and launches the program in its continuing environment. +5. **Output returns through the PTY.** Shell protocol decoding removes NMSh lifecycle messages. Program output flows into the output buffer, where terminal formatting and progress updates are represented for the transcript. A session service also retains sequenced events for recovery and reattachment. +6. **The shell becomes ready again.** A marker reports exit status and working directory. NMSh completes the block, records elapsed time, updates history and context, and refreshes the prompt and suggestions. + +```text +key → editor → highlight/suggestions → submit → session client + → persistent shell → program → PTY output → transcript + → shell readiness marker → completed block + next prompt +``` + +The command's displayed colors never become part of the submitted text. A command can have rich NMSh presentation while the shell receives ordinary command text. + +## 5. The composer + +The **composer** is the whole input area: editable text, optional prompt/context, suggestions, and surrounding edges. The editor inside it supports multiline input and owns editing rather than delegating to the shell's line editor. + +The composer can be docked at **Bottom**, docked at **Top**, or placed in **Flow** after the newest transcript output. A one-line layout places context alongside input; a two-line layout separates them. Long input still wraps and can contain explicit newlines. Prompt None currently uses the one-line geometry. + +Prompt/context describes the shell and project; editable input is the command being composed. Dividers and borders are presentation around those regions. Accessories such as Keep Awake can occupy an available edge, an adjacent row, or trailing input space according to the screen plan. They do not become prompt-provider content or editable text. + +Each frame has one geometry plan, produced by `src/app/screenPlan.ts`. Rendering, cursor placement, wrapping, mouse hit testing, viewport height, panel placement, and PTY sizing use this shared plan. That prevents a border or accessory from shifting the cursor while another subsystem still assumes the old coordinates. + +## 6. Prompts and providers + +The prompt supplies context; NMSh supplies the composer that contains it. Current choices are: + +| Choice | Where context comes from | +|---|---| +| NMSh Native | NMSh modules for directory, project, Git, toolchains, and other context | +| None | No prompt/context content | +| Starship | A bounded invocation of the installed Starship executable | +| Powerlevel10k | An isolated zsh helper loading the installed theme and its configuration | +| Oh My Posh | A bounded invocation of the installed Oh My Posh executable | + +External renderers receive shell context such as directory and exit status. Their output is parsed into prompt content that NMSh lays out. They do not receive ownership of the live shell's editor. Powerlevel10k's helper deliberately disables ZLE, zsh's interactive line editor, so it cannot compete with NMSh. + +These helpers have time/output limits and use pipes rather than attaching their UI to the host terminal. They are still trust boundaries: running an installed prompt program or loading Powerlevel10k configuration can execute that provider's code. + +### Native modules and the Context Engine + +Native context follows a shared pipeline: + +```text +trusted core capabilities → immutable facts → native modules + → Surface Router → Main Prompt / Context Rail / Right Context + → semantic segment painter → shared screen plan +``` + +Capabilities own collection and its safety policy. Facts describe values with provenance, freshness, trust and privacy metadata. Modules consume resolved values; rendering and routing do not perform discovery or launch probes. The current implementation adapts the existing shell-context snapshot into this fact model rather than replacing shell collection with a separate system. + +The Main Prompt carries identity and navigation context. Right Context is a separately anchored prompt area. The Context Rail is live native context attached to the composer, vertically or Right of Prompt; its position does not turn it into Right Context. Width fitting preserves the independent right-context anchor and drops Rail content by priority when space is scarce. Rail rows are frontend geometry, not transcript output, and the live Rail is not archived into command history. + +This is the current development implementation, not a released pack platform. Context Packs are planned as declarative data requesting known capabilities; arbitrary code, commands and repository hooks are outside that model. See [Context Modules](docs/architecture/context-modules.md) for the current module set, surface behavior and discovery boundaries. + +**Prompt None can still show a composer marker.** The marker identifies where input starts and belongs to NMSh's editor presentation. Removing context does not remove the editor's own marker or accessories. + +## 7. Output and transcript + +NMSh separates **program output** from **NMSh-authored presentation**. The output buffer, `OutputBuffer`, holds command records and parsed output lines. A transcript presenter decides how those records appear: command headers, historical context, feedback, folding, and spacing. + +Raw output is not blindly printed over the composer. `AnsiOutputParser` interprets a bounded subset of terminal behavior, including colors, carriage returns, backspaces, and hyperlinks, to retain a usable representation. Classification can choose transcript presentation, progress handling, or passthrough. This is not semantic recoloring of arbitrary stdout: the program's text and color choices remain its own. + +NMSh-authored submitted command lines can keep semantic highlighting. Folding changes which output rows are visible; it does not rerun commands or turn hidden output into a different result. Search, selection, and copy work from transcript state. `/copy` exports plain text without NMSh's ANSI chrome. + +Historical command blocks retain prompt/context snapshots from submission time. Viewing an old command should not pretend it ran in today's directory or Git state. Frontend chrome such as the Status Strip, find bar, and accessory rows stays outside the transcript. + +The viewport follows the newest output during execution (**FOLLOW**). Scrolling into history enters **DETACHED** viewport mode so new output does not pull the view away. This is a viewing state, distinct from detaching a live shell session. + +## 8. Sessions + +A **live session** is a running shell and PTY. A **saved transcript** is a record of what happened. Those have different lifetimes. + +Normally the frontend connects to a per-user local service, starting it on demand. The service owns shells independently of terminal windows, permits one controlling frontend per session, and keeps running when that frontend detaches or disappears. + +```text +create → attached ⇄ detached → shell exits / explicit termination + │ │ │ + └──── transcript journal ────────┘ + ↓ + saved transcript +``` + +Reattaching a live session reconnects to the same shell: aliases, variables, jobs, and current program remain there. The frontend restores its journal and replays later service events. Restoring an archived transcript through `/resume` replaces this window's presentation, archiving its current view first, while retaining its current live shell. It cannot resurrect the archived shell's environment or jobs. + +Retention is bounded. Unacknowledged stream events begin in memory and spill to a JSON-lines spool. Once a frontend has durably journaled events, it acknowledges their sequence numbers. Output beyond spool limits can be dropped, with truncation reported on replay. + +If the service cannot be established before session creation is confirmed, NMSh falls back to an in-process shell with a notice. That shell cannot survive frontend exit or be detached and reattached. A service crash or OS restart also does not magically preserve live processes. + +Session status combines authenticated shell lifecycle, observed terminal modes, activity emitted by programs, and the PTY's foreground-process name when available. Silence does not prove completion or a need for attention. Unknown facts remain unknown rather than being guessed from an animation or command name. + +## 9. ShellAdapter + +Shell-specific behavior lives behind a common boundary called `ShellAdapter`. Its three implementations support **zsh**, **Bash 4.4 or newer**, and **Fish**. All launch real persistent interactive shells and report the same lifecycle grammar. + +Adapters own executable selection, bootstrap files, environment setup, builtins, history parsing, and completion sources. The composer, transcript, session transport, and panels remain shared. Capabilities describe real differences: Bash completion does not provide Fish-style descriptions, for example. + +The zsh adapter uses a private bootstrap directory and disables ZLE. Bash uses a private rcfile and disables Readline editing. Fish retains its editor internally, so NMSh suppresses its prompt-to-command redraw bytes and answers necessary terminal queries. Consequently, Fish messages printed while idle at its prompt can also be suppressed. + +The interactive shell loads the user's startup configuration as part of normal shell startup. NMSh does not rewrite those files to establish its UI. Startup output is bounded and sanitized; typed input waits for readiness so it cannot accidentally answer a `read` in a startup file. Bootstrap also suppresses automatic Fastfetch startup. + +Command classification uses an isolated zsh helper, `SemanticService`, or live-name snapshots plus PATH classification for Bash/Fish. Completion helpers are separate from the execution PTY. They query trusted shell facilities without executing the partial command as a submission. + +Switching backend replaces the shell while keeping NMSh session identity, history, draft, and settings. The current implementation archives the old presentation and starts a fresh view for the new backend. It does not migrate aliases, variables, or jobs. Switching is refused while execution, startup, or known background/stopped jobs make replacement unsafe. + +## 10. Slash commands and NMSh actions + +`npm test` goes to the real shell. Recognized slash commands such as `/theme`, `/tools`, and `/zoomies` are parsed into **NMSh actions** and dispatched inside the frontend. + +These actions open panels, update settings, navigate sessions, or invoke a domain controller. They are not aliases installed in the user's shell. The slash parser returns structured action kinds instead of treating every action as a shell string. Unknown single-line slash input currently reports an unknown NMSh command rather than executing it. + +Panels are control surfaces for NMSh state and supported integrations. Where an action changes external files or installs software, its controller builds a specific reviewed operation rather than handing panel text to a shell. + +## 11. notMyUI / panel system + +**notMyUI** is the shared terminal UI vocabulary used by NMSh panels. It is implemented in this repository, primarily under `src/ui/`, rather than as an independent application framework. + +Shared primitives handle panel frames, tabs, grouped lists, selected rows, controls, colors, glyphs, and focus presentation. Domain panels hold their own state and turn keyboard or mouse interaction into actions; the coordinator dispatches those actions and integrates the panel with screen geometry. + +Rows identify selectable settings or commands. Focus determines whether navigation operates on a list, tabs, or an editing control. Settings and the command palette reuse these ideas, making different features feel related without forcing every panel into an identical widget implementation. + +## 12. Themes and appearance + +A **semantic palette** names colors by purpose: primary text, subtle text, success, warning, failure, selection, syntax roles, and so on. Native themes map those roles to actual colors. Renderers can then agree on what a warning means without each choosing a hardcoded color. + +**Theme Studio** creates, edits, imports, duplicates, and exports NMSh themes. Imported colors become normal local theme assets with stable IDs and source metadata. They do not require the original application or source file to remain installed. Imports parse supported formats as data rather than executing Lua, templates, shell snippets, or includes. + +**Chroma** adds decorative treatments, gradients, and motion to eligible NMSh surfaces. It is a presentation layer, not a replacement for the palette's underlying meaning. It does not enter generated external artifacts. + +Glyph modes choose between richer symbols and more portable alternatives. Color capability detection, `NO_COLOR`, and reduced-motion settings degrade presentation without changing command behavior. Cursor presentation, idle visuals, and UI chrome have their own settings around the shared appearance system. + +Theme Studio controls NMSh's appearance. **Theme Bridge** is the separate mechanism for extending selected colors to external tools. + +## 13. Theme Bridge + +Choosing an NMSh theme does not automatically rewrite application configuration. Theme Bridge is an explicit, initially disabled system with known target adapters. + +Its global policy can **Follow NMSh**, **Choose theme** to pin a theme, or **Manual** to use each target's saved choice. Per-target choices are Independent, Follow NMSh, and Choose theme. Global policy changes preserve the Manual choices for later restoration. + +| Current target | How colors reach it | +|---|---| +| fzf | NMSh-controlled invocation options | +| less / man | Allowlisted session environment values | +| File listing colors | GNU/BSD color variables and controlled listing behavior | +| bat | Generated theme file, reviewed cache setup, and theme environment selection | +| tmux | Generated managed configuration/colors and reviewed activation | +| Neovim / Vim | Generated colorschemes and reviewed activation hooks | +| Helix | Generated theme in its themes directory and reviewed selection | +| delta | Detected only; NMSh does not manage its Git configuration | + +Environment changes reach a persistent shell through one **environment sink**: generated files for zsh, Bash, and Fish, applied by the adapter's prompt hook. Their grammar permits allowlisted variables and quoted literals. Values take effect at the next prompt. Clearing a value restores what NMSh replaced only while the shell still contains NMSh's value. + +Generated files and inserted activation lines are recorded in an **ownership ledger**. Content hashes prove whether a file still matches what NMSh wrote. A user-edited or unrecognized artifact causes a conflict instead of being overwritten. Activation in executable configuration is an exact reviewed edit, not a generic merge of shell, Lua, or Vimscript. bat and Helix need artifacts in their own theme directories; many other artifacts live under NMSh's Theme Bridge directory. + +## 14. Tools, providers and integrations + +These terms describe different relationships: + +| Term | Meaning in NMSh | +|---|---| +| Tool | A curated external utility or detected shell-environment component | +| Provider | A selected implementation of a frontend role, such as prompt, history, picker, suggestions, or navigation | +| Integration | A supported connection to an external system, including health and ownership information | +| Configurable tool | A tool with a reviewed configuration adapter; detection alone is insufficient | +| Shell framework | Shell code/plugins loaded by startup configuration, such as Oh My Zsh or Prezto | +| Package-manager infrastructure | Software such as Homebrew that installs and manages other packages | + +`src/tools/catalog.ts` is the central curated tool registry. It records detection, categories, installation recipes, and relevant capabilities. Frameworks can be detected from filesystem evidence even when they have no executable on PATH. Detection grants no automatic installation or write authority. + +The provider-family registry connects role choices to persisted settings. The tool-configuration registry separately determines what `/configure` may manage. `/integrations` presents integration readiness and repair/setup actions, including Theme Bridge health. + +Homebrew is package-manager infrastructure: NMSh uses it for known install recipes and package facts. It is not simply another interchangeable prompt or utility provider. Special installers and framework setup have explicit flows and boundaries rather than being treated as ordinary package names. + +## 15. Dotfiles + +The dotfiles surface discovers recognized configuration in a local directory or checkout, including plain, Git, GNU Stow-style, and chezmoi layouts. An explicit remote flow can obtain a checkout before scanning. Repository content remains untrusted data. + +Discovery is bounded by entry count, depth, and file size. Recognition uses the tool-configuration registry. Symlinks are shown without being followed; scripts and templates are identified without being run or rendered. + +The review plan distinguishes supported field imports, exact copies, and inspect-only files. tmux imports supported options and bindings into NMSh's own model. Exact copying requires a registry-specific validator: being valid TOML or JSON alone is insufficient. Executable configurations remain inspect-only in this workflow. + +Changes are reviewed before application. Copy plans check whether the destination changed since review and back up existing content. NMSh does not execute repository installers, hooks, Make targets, Stow operations, chezmoi scripts, or arbitrary configuration code to discover what a repository means. + +## 16. Keep Awake + +`/caffeinate`, `/awake`, and `/zoomies` are three names for one NMSh-owned feature. A controller creates an OS power assertion in a detached background process, independently of the shell PTY. + +Backends use Apple's fixed-path `caffeinate` on macOS, `systemd-inhibit` around an NMSh wait helper on supported Linux systems, and an execution-state helper on Windows when its prerequisite is available. Capabilities differ: the Linux backend does not claim display inhibition. This backend's Windows code does not imply native Windows shell/frontend support. + +NMSh records the PID, launch arguments, ownership token where applicable, mode, and timing. Before stopping an assertion it verifies that the live process matches its owned record, rather than killing any process named `caffeinate` or trusting a reused PID. Stop sends termination to that verified process and only escalates while ownership still matches. + +The assertion can outlive an NMSh window. Duration and explicit stop control its lifetime. Presentation can use a composer accessory, Status Strip, idle reminder, or screensaver state; those are views of the controller's state, not text sent to the shell. + +## 17. Host / terminal integration + +Terminal integration uses optional escape-sequence protocols. **OSC** means Operating System Command, a terminal protocol family; these messages are not shell commands. + +- **OSC 7** tells a host the current directory, helping it open new tabs or panes in the right place. +- **OSC 8** attaches hyperlink targets to displayed text. +- **OSC 133** marks prompt, input, command-start, and command-end zones for cooperating terminals or multiplexers. +- **Private OSC 777 NMSh messages** carry shell readiness and execution events with a per-session token. NMSh validates that token before treating output as lifecycle evidence. + +Public host markers are derived from NMSh's authenticated lifecycle. NMSh does not depend on receiving them back, and holds them while a fullscreen program owns the screen. + +Capability detection combines passive host profiles and active probes. Ghostty, Kitty, WezTerm, iTerm2, and Windows Terminal have specific hints; Terminal.app receives conservative baseline behavior plus directory signaling. Windows Terminal detection can describe a WSL host without implying native Windows execution support. + +Keyboard, mouse, synchronized drawing, hyperlinks, colors, and graphics degrade independently. Direct preference/keyboard configuration integration is currently Ghostty-specific. tmux and other multiplexers hide outer-host hints, so NMSh does not assume that an outer terminal's graphics or keyboard features work unchanged inside a pane. + +## 18. tmux + +tmux is an independent multiplexer that owns sessions, windows, panes, borders, and its status line. NMSh can run inside a pane, or a shell command can launch tmux through passthrough. + +Theme Bridge supplies colors. **Config Studio**, opened through `/tmux`, manages supported options, keys, a status layout, and an optional pane frontend. NMSh stores a typed model and generates one managed configuration file that combines its settings and applicable Bridge colors. The user's config includes that file through a reviewed activation step. + +The optional **NMSh pane frontend** changes tmux's `default-command` for new panes/windows without an explicit command. It does not change `default-shell`, which still identifies the shell tmux uses to launch commands. Existing panes retain their current processes. The generated launcher guards against recursively starting NMSh inside NMSh's managed shell. + +The prompt inside a pane belongs to the program running there; tmux's status appearance is a different owner. Config Studio imports only supported literal settings and bindings, leaving dynamic commands and unsupported configuration outside its model. + +## 19. Ask + +`/btw` opens **Ask NMSh**; `/ask` remains a compatibility alias for the same feature. Ask resolves plain-English requests into NMSh's existing capabilities. Its first path is deterministic: intents, command knowledge, project facts, and supported actions work without a model. + +Results are typed answers, proposals, choices, unsupported requests, or refusals. Actions identify specific operations such as opening a panel, attaching a session, changing a setting, running a fixed command, or applying a verified edit plan. Existing controllers perform those operations. + +Optional **local understanding** can help interpret requests when enabled. Auto uses a model when deterministic interpretation is unclear or ambiguous; Always prefers model assistance while still using deterministic action builders. A separate local model service manages runtime work. Model assistance chooses from a bounded capability inventory and produces validated structured interpretations. NMSh then resolves those interpretations against known facts and its deterministic policies; a failed model leaves the deterministic result available. + +Model text is not directly executed as a shell command or filesystem operation. Configuration, mutation, and installation require confirmation of the exact action in Ask; destructive suggestions are Copy/Insert only. Copying or inserting a suggested command does not execute it. File plans and supported configuration still use their normal path, hash, and ownership checks. + +## 20. Configuration and persistence + +Let `` mean the directory selected by the shared path resolver: + +- An absolute `$XDG_CONFIG_HOME/nmsh` when `XDG_CONFIG_HOME` is set. +- Otherwise `~/Library/Application Support/notMyShell` on macOS. +- Otherwise `~/.config/nmsh`. + +| State | Location / owner | +|---|---| +| Settings, provider choices, theme assets, Bridge policy | `/config.json` | +| Saved transcripts and summary metadata | `/sessions/` | +| Bridge environment files and ownership ledger | `/theme-bridge/` | +| Most generated Bridge artifacts | Subdirectories of `theme-bridge/`; bat/Helix themes use their tool directories | +| tmux's typed configuration model | `/tools/tmux.json` | +| Keep Awake process record | `/keep-awake.json` | +| Live service sockets and stream spools | Private runtime directory, separate from configuration | + +On Linux, a verified private `$XDG_RUNTIME_DIR/nmsh` is preferred for live runtime data. Otherwise NMSh uses a private per-user temporary directory; `NMSH_RUNTIME_DIR` can override it. Socket names distinguish protocol versions. Live shell environment stays in memory, not in the transcript store as a recoverable shell image. + +Configuration loading normalizes data, supplies defaults, and migrates older shapes. Theme-library normalization maintains stable references and a compatibility mirror of the active custom theme. Saving settings uses staged replacement and preserves unrelated fields; unreadable or malformed existing settings are refused for saving rather than silently overwritten. + +## 21. Security boundaries + +The central distinction is between **data NMSh can validate** and **code an external system executes**. Normal interactive shell startup and explicitly selected providers execute trusted user-installed code. Import and discovery workflows do not receive that same authority. + +Entering a repository must never itself execute arbitrary repository-controlled code through NMSh context discovery. Context metadata is read as bounded data; curated core probes own executable selection and policy. Normal user-configured shell startup and explicitly chosen external providers remain separate trust boundaries. + +Theme imports use bounded parsers without templates, includes, or code execution. Arbitrary configuration is not generically rewritten. Supported installs and helper operations use fixed executables and structured argument arrays rather than interpolating requests into shell strings. Helpers have time/output bounds appropriate to their role. + +Generated-file hashes and ownership records protect external artifacts; process verification protects stop/kill operations. A discovered file, tool, or PID is not proof that NMSh owns it. Raw PTY output retains program formatting through a controlled presentation path, and unauthenticated output cannot complete an NMSh command by impersonating its private protocol. + +These boundaries do not make arbitrary shell commands or providers harmless. They constrain NMSh's own authority. See [SECURITY.md](SECURITY.md) for the security policy and detailed trust boundaries. + +## 22. Repository map + +| If you want to understand… | Start here | +|---|---| +| Startup and frontend orchestration | `src/index.ts`, `src/app/` | +| Screen layout and terminal drawing | `src/app/screenPlan.ts`, `src/terminal/` | +| Editing, highlighting, suggestions | `src/input/`, `src/suggestions/` | +| PTY, adapters, shell knowledge and completion | `src/shell/` | +| Live service, sockets, replay and attachment | `src/session/` | +| Saved transcripts and resume UI | `src/sessions/` | +| Output parsing, folding, selection and presentation | `src/output/` | +| Context facts, trusted collection and surface routing | `src/context/`, `src/prompt/configuration.ts`, `src/prompt/railLayout.ts` | +| Native/external prompts and shared settings | `src/prompt/`, `src/configuration/` | +| Panels and shared UI primitives | `src/ui/` plus feature-specific panel modules | +| Theme Studio, palettes and decoration | `src/appearance/`, `src/chroma/`, `src/motion/`, `src/cursor/`, `src/idle/` | +| External colors and ownership | `src/themeBridge/` | +| Curated tools, configuration and packages | `src/tools/`, `src/providers/`, `src/packages/` | +| Dotfiles and Keep Awake | `src/dotfiles/`, `src/keepAwake/` | +| Slash actions, Ask and optional local models | `src/commands/`, `src/ask/`, `src/understanding/` | +| Host capabilities and passthrough policy | `src/host/`, `src/presentation/`, `src/passthrough/` | +| Agent-session integration and managed tasks | `src/agents/`, `src/tasks/` | +| Automated behavior evidence | `tests/`, verification scripts in `scripts/` | + +## 23. A few end-to-end examples + +### Running `git status` + +`editor text → semantic command presentation → submission block → session client → real shell → Git → PTY output → parsed transcript → readiness/status marker → completed block and refreshed context` + +Git chooses its output; NMSh supplies the surrounding command history and feedback. + +### Opening `/theme` + +`slash parser → Theme Studio state → shared panel primitives + screen plan → edit/select local theme → normalized settings save → NMSh surfaces repaint` + +An enabled Follow NMSh Bridge policy can then synchronize supported external targets through their own adapters. + +### Starting `/zoomies display` + +`slash parser → Keep Awake controller → backend capability check → fixed OS launch → ownership record → accessory/status presentation` + +On macOS this creates a display assertion outside the shell. A backend without display support reports that limitation instead of pretending it worked. + +### Detaching and resuming a session + +`/detach → journal checkpoint → frontend disconnect → service retains shell + output events → nmsh --attach → same shell attachment → journal restore + event replay → composer or active-program passthrough` + +`/resume` also offers saved transcripts. Selecting an archive restores its presentation into the current window without restoring its old shell, as described in section 8. + +### Applying a Theme Bridge target + +`enable Bridge + choose Neovim Follow NMSh → resolve semantic palette → validate generated colorscheme → check artifact ownership → write artifact + ledger → review activation diff → apply verified hook` + +Later theme changes can regenerate the unchanged owned artifact. User modifications cause a conflict. The editor must load/reload the colorscheme for its display to change. + +## 24. Deeper reading + +These documents expand particular areas. Older design and research records explain decisions at their time; current code remains authoritative. + +- [Shell adapters](docs/architecture/shell-adapter.md): backend contracts, differences, switching, and ordinary-shell handoff. +- [Terminal stack](docs/architecture/terminal-stack.md): layer terminology; its zsh-only and bottom-editor wording predates the current adapters/layouts. +- [Terminal hosts](docs/architecture/terminal-host.md) and [multiplexer interoperability](docs/architecture/multiplexer-interop.md): capability boundaries and nested-terminal behavior. +- [notMyUI](docs/architecture/notmyui.md) and [TUI primitives](docs/architecture/tui-primitives-v012.md): common controls, surfaces, and focus. +- [Context Modules](docs/architecture/context-modules.md): capabilities, facts, native routing, Context Rail and pack direction. +- [Prompt customization](docs/architecture/prompt-customization.md): Native context, shape, and appearance choices. +- [Supported tool configuration](docs/architecture/supported-tool-configuration.md): configuration classes, adapters, and ownership. +- [Theme Bridge design](docs/design/theme-bridge.md): imports, target behavior, environment sink, and generated-file safety. +- [Chroma and UI chrome](docs/design/chroma-and-ui-chrome.md): how decoration and semantic presentation fit together. +- [Structured execution](docs/design/structured-execution.md) and [session interaction](docs/design/session-interaction-ux.md): command blocks and viewport interaction decisions. +- [Session journal](docs/design/session-journal-v0.4.md) and [sessions, agents, and intelligence](docs/design/v016-sessions-agents-intelligence.md): persistence and later session capabilities. +- [Shell UX and Ask](docs/design/v016-shell-ux-ask.md): typed assistance and shell interaction policies. +- [Accessibility baseline](docs/accessibility/baseline.md): color, motion, glyph, keyboard, and host considerations. +- [SECURITY.md](SECURITY.md): trust model and reporting policy. +- [CONTRIBUTING.md](CONTRIBUTING.md): development setup, validation, and contribution workflow. diff --git a/CHANGELOG.md b/CHANGELOG.md index 71b0d780..ee71e83b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,102 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### UI consistency and showcase +- Shared bold accent focus labels across menus, settings, pickers and setup; `/tools` keeps explicit checked markers when focus moves, including without color. +- Consistent Chroma preview state and base-color guidance; `/syntax` exposes the shared theme families and variants, with previews and cache updates for custom colors and accents. +- First launch opens Setup Cat. Apply completes onboarding even without appearance edits; presets and completed onboarding bypass discovery. +- The current-shell module leads the default Native prompt; the former untouched default migrates, while customized module ordering is preserved. +- Larger opaque demo recordings with neutral welcome identity, expanded feature/screensaver coverage, two architecture diagrams and a standalone silent motion promo. + +### Themes, Theme Bridge and host cooperation +- **Prompt provider None**: composer only (no prompt row, modules or right prompt; the input marker stays) while editing, suggestions, syntax colors, history, themes and Theme Bridge keep working; commands submitted under None store no prompt snapshot. +- **Historical prompt** Full / Compact / Minimal / Off (`/transcript`, Settings, Setup); presentation only over the unchanged stored snapshot. +- **Native theme library**: any number of Custom and Imported themes with stable ids (bounded to 64); the single custom theme migrates into it and stays active. Imported is provenance only: imported themes are ordinary Native themes you can edit, rename, duplicate, export, select and pin. +- **Theme Studio** (`/theme`): Built-in · Imported · Custom · Import tabs, one editor and the real Native preview for every theme. Settings → Theme and `/setup appearance` select Built-in, Imported and Custom themes directly. +- **Imports**: NMSh Theme JSON, Base16, Base24, Windows Terminal, Oh My Posh (JSON, YAML, TOML; static colors only), Kitty, Ghostty (allowlist), iTerm2 `.itermcolors` (no XML entities) and WezTerm TOML (Lua refused). Data only, previewed with mapping and loss disclosure before saving; exports never include local source paths. +- **Theme Bridge** (`/theme-bridge`, opt-in, default Off): one switch plus **Apply themes** Manual / Follow NMSh / Choose theme. Under Manual each tool is Independent / Follow NMSh / Choose theme; under a global policy per-tool rows are view-only and the Manual choices are kept for later. One persistent panel with inline rows (Esc collapses before it closes), grouped by capability. fzf launched by NMSh, less/man termcap colors and **File listing colors** (GNU `ls`/`gls` via LS_COLORS with vivid when installed, BSD/macOS `ls` via CLICOLOR/LSCOLORS) through an NMSh-owned shell environment applied by the zsh, Bash and Fish adapters at the next prompt; generated tmux, Neovim, Vim, Helix and bat themes (bat: a real `.tmTheme`, a reviewed `bat cache --build` verified with `bat --list-themes`, `BAT_THEME` through the environment) with an ownership ledger, staged validated writes, a typed tmux reload and exact includes added only after review. delta is shown and not editable. "Remove managed setup" removes NMSh's files and includes; "Set Independent" only stops applying. No rc file, terminal or editor theme, or git config is changed. +- Theme Studio: local **Preview Chroma** (default Off, never saved) and **Duplicate current** into Custom (` - Custom`). Chroma is reachable from `/prompt`, `/appearance` and Setup (Appearance and Prompt, with a Setup-local preview toggle). +- **Host semantics**: OSC 7 working directory and OSC 133 command zones derived from NMSh's command lifecycle on capable hosts and tmux; NMSh-authored OSC 8 links in `/help` and dev-server task rows, kept separate from program links. +- `/appearance` is a compact launcher: Theme Studio, Prompt, Cursor & effects, UI chrome, Chroma, Motion, Theme Bridge and host window. + +### Providers, tools and integrations + +- **Keep Awake** (`/caffeinate`, `/awake`, `/zoomies`; one surface and state): Idle, Display, System and All, optional `30m`/`2h` timeouts, `status` and `stop`. Backends are detected, not assumed: Apple `/usr/bin/caffeinate` (fixed flags; System needs AC power), `systemd-inhibit` with `idle`/`sleep` only around an NMSh-owned wait helper (Display is reported unsupported on Linux), and Windows `SetThreadExecutionState` from a fixed hidden PowerShell helper (no away mode, no `powercfg`). The assertion is a detached process that outlives the window; ownership is a random token plus the exact command line, so an unverifiable record is cleared and nothing is killed. Changing mode asks first (default No) and starts the new assertion before releasing the old. +- Keep Awake **presentation**: while active, `Awake · ` is NMSh composer chrome (never prompt or provider output). Placement **Composer edge** (default) uses a plain top divider, else the bottom divider when a header prompt owns the top edge, else one row next to the composer; **Above composer** and **Input row** (only when it is completely safe; editor width, caret and hit testing account for it) are explicit choices, and a fallback never rewrites the setting. Both edges render through one shared edge renderer, so animated Chroma dividers keep it. An enabled **Status Strip** always includes it (narrowing before it drops); after 30 s without NMSh input an **idle reminder** adds the time and a muted `/zoomies stop`; the **screensaver** shows a small positioned status (default Bottom left). Display is Text, Icon or Icon + text. Off shows nothing. The panel gains Duration and these settings; Ask answers status questions and plans timed, change and stop requests through the same controller. +- Fixed: `/caffeinate`, `/awake` or `/zoomies` without arguments opened a panel that was never drawn, so the composer looked occupied until Ctrl+C (the Mise panel had the same problem). A start or stop from the panel now returns to the composer at once; the assertion was and remains an NMSh-owned background process, never a shell command. + +- **Shell frameworks and prompt engines:** `/tools` detects tools by an explicit strategy (executable or a registered filesystem detector), so Oh My Zsh, Powerlevel10k, Prezto, Zim, zinit and Antidote appear with factual status (`Installed · Zsh framework · used by Zsh only` under Bash/Fish). Detection grants no install, configuration or provider authority. Oh My Zsh has a guided install that keeps `.zshrc` and a `.zshrc.pre-oh-my-zsh` comparison with a reviewed restore; NMSh never runs its installer. Powerlevel10k is a `/tools` item that routes to the existing provider and `p10k configure` flow. **Oh My Posh** is a new Prompt provider (`oh-my-posh print primary`, argv only, no TTY, bounded, cancellable; Native fallback when it fails) and a curated `/tools` install (homebrew/core); its config can be imported into Theme Studio as static colors. Ask understands these requests; dotfiles treats `.p10k.zsh`, Oh My Zsh themes/plugins and Oh My Posh configs as inspect-only. +- `/providers` is one inline panel: each family with its status (● Active, ✓ Selected · fallback, Available, Missing · Enter to install); Enter selects immediately or installs after a confirmation (default No). Shortcuts `/picker` (`/pickers`), `/suggestions`, `/navigation`, `/welcome`, `/history-provider` and `/providers ` open it focused. +- Pickers follow the composer: with the composer at the bottom the query sits at the bottom and results above it (NMSh Native and fzf); at the top the query is at the top. +- **Tool Configuration** (`/configure`, `/tmux`): one first-party registry says which tools NMSh can configure (tmux, Starship), which it themes, and which are inspect-only (shell rc files, Neovim/Vim config). Detection never grants write authority. +- **tmux Config Studio** (`/tmux`): General settings from a documented catalog, keymaps and prefix with conflict display, a Status Studio with a live preview (status modules never run shell), an optional NMSh pane frontend (`default-command` that runs a fixed `/bin/sh` program with the NMSh path passed as quoted argv data, never as shell text, and falls back to your login shell inside NMSh; `default-shell` untouched), and import of a supported subset of an existing tmux.conf (`if-shell`, `run-shell`, `source-file` and `#()` are never followed). Each value shows where it comes from. Everything goes into one NMSh-managed tmux file, included once after review. +- `/integrations`: health of every managed integration (current, missing, stale, conflict) with Review all / Apply all (default No); after one-time activation, managed files update automatically when the theme changes. +- `/dotfiles [path or Git URL]`: plain, Git, GNU Stow and chezmoi sources. Remote sources are cloned only after confirmation (depth 1, no submodules, hooks disabled). Nothing in the repository is run, templates are not rendered, tmux imports supported fields only, Starship/Helix/bat and all executable configs are inspect-only (parseable config can still run commands, so nothing is copied without an explicit per-tool safety validator, and none exists today), conflicting values default to your current ones, and one combined review (default No) precedes any change. The repository is never modified. +- Ask maps tmux, provider, Theme Bridge, integrations and dotfiles requests onto these typed actions only, behind its final Yes/No. +- Commands: `/motion`, `/chrome`, `/glyphs` (`/glyph`), `/composer` (alias of `/layout`), `/strip` (`/status-strip`), `/configure`, `/tmux`, `/integrations`, `/dotfiles`. `/help` groups commands by area (Appearance, Composer & transcript, Providers, Tools & integration) and the palette lists each surface once with a readable label. Individual settings are not commands. + +### Contextual tools +- `/tools` Discover shows a conservative **Relevant here** group from cheap local facts (Git repository, shell scripts, JavaScript/Node, Python, Go, Rust, container files, Kubernetes files or kubeconfig) and each tool's declared relevance. Only missing tools appear; nothing is executed, crawled or sent anywhere. +- Typed package-manager plans for Homebrew, APT, DNF, pacman and zypper (WSL uses the distribution's manager). Tools without a verified package name stay manual. Non-root plans are explicit `sudo -n` argv; nothing elevates silently. +- Bulk install: Space selects missing installable tools, Enter reviews a tool → manager → package plan, one confirmation (default No), per-tool results. +- Tool details separate Installed, selected in NMSh and **Active in this shell** (zoxide, Atuin, fzf), from the running shell's name snapshot for all three shells; rc files are never read. + +### Compatibility +- Windows Terminal (seen from WSL), iTerm2 and WezTerm host facts and new-window launchers behind the host boundary; deterministic multiplexer/`TERM=dumb` degradation tests for every profile. Physical validation in these hosts is not claimed. +- Linux: `xdg-open`/`wslview` open helper, clipboard fallback-order tests, Fish installed in Ubuntu CI and a Fedora portability job. +- Regression coverage that raw job-control, EOF and arrow bytes reach full-screen programs. + +### Screensavers +- Four screen-based savers join the idle visuals: **Black Hole**, **Fireworks**, **Circletastic** and **raiseCatError**, plus **Random** (switches only after a full loop). They animate the current screen's own text as presentation only (transcript, PTY, history and journals are untouched), keep the host background, dismiss on the first input (which is consumed), stop on resize, and never start automatically under Reduced Motion. Still off by default (Idle visuals: Never); new **Run while busy** setting never overrides passthrough or fullscreen programs. The earlier scene is now labelled Night Fireworks. + +### Polish +- Shell Environment (Status and `nmsh doctor`) starts with the shell backing the session (never inferred from `$SHELL`), says "none · plain zsh/bash/fish" when there is no framework, and scopes framework and plugin rows to that shell; other shells' environments are listed separately. +- Screensavers: the capture keeps authored backgrounds and readable glyph colors (no black-on-dark chrome), Circletastic forms a few small circles completely before it stabilizes, rotates, accelerates and explodes (all at once or staggered, keeping ring momentum), and raiseCatError now uses the NMSh cat sprite, roams the whole screen, overlaps text freely, meows, and sometimes sits on a purely visual fake keyboard (never reaching the editor or shell). +- Any exact command in the curated `/tools` catalog (not only Recommended ones) is recognized when missing; the prompt names the package when it differs (`tldr` is provided by tealdeer). TLDR (tealdeer) is now Recommended; Ask uses only its local cache (`--no-auto-update`) and says when examples are unavailable. + +### Polish (this pass) +- `/tools`: the selected row is the shared selected band (the active tab's treatment): full width, bold, readable on the band, reverse video under `NO_COLOR`. +- Prompt None wording: the input marker stays (it always did); help, the prompt picker and Setup now say so. + +### Docs and demos +- [ARCHITECTURE.md](ARCHITECTURE.md): a plain-language overview of how NMSh works, linked from the README, CONTRIBUTING, AGENTS and llms.txt. +- README rewritten around what NMSh is, a short hero clip, a visual tour and the safety model; stale zsh-only, fixed-bottom, bat and release claims corrected. +- Reproducible visual docs: `npm run demos` renders the README and [demo gallery](docs/demos.md) clips from committed VHS tapes in `scripts/demos/` against a disposable demo home (no user config, no network, the inert Keep Awake backend). It replaces the old asciinema/tmux recorder. A small Vespyr divider is generated from the real sprite. + +### Updates and sessions +- **Automatic updates** (Automatic / Notify only / Off, Daily or Weekly). New installs default to Automatic / Daily; saved Daily/Weekly checks migrate to Notify only and Off stays Off. Automatic prepares a verified stable release only where `/update apply`'s own checks pass, with the same build verification and rollback; the running session keeps its version. Status shows Running version, Latest, Mode and State. +- Detached sessions can be ended from the startup picker with `X` and confirmation; the transcript is archived and stays in `/resume`. + +## [0.16.0] - 2026-10-04 + +Sessions, Agents & Portability: a cumulative release of the work formerly planned as v0.8–v0.15, together with v0.16. + +### Sessions & agents +- Persistent live shells, detach/reattach, session presets and a clearer `/resume` and `/sessions` viewer with factual state, age and duration. +- Short-lived cross-session notices, sticky attention, and familiar session names and accents. +- `/agents` local activity counts, durations and heatmaps; `/ai` managed and observed agent sessions. Activity tracking can be disabled or reset and does not store prompts or output. + +### Shells & portability +- A shared ShellAdapter for zsh, Fish and Bash 4.4+; `/shell` switches the current session and Settings chooses the default for new sessions. +- Linux/WSL groundwork, platform diagnostics, Linux notifications through `notify-send`, host capability profiles and conservative presentation fallbacks. Physical Linux/WSL and additional-host validation remains follow-up work. +- Settings export/import, launcher uninstall with data retained by default, and provenance-aware tool removal. + +### Ask, completion & developer tooling +- Ask explains NMSh capabilities locally, with optional Qwen Local Understanding through `/llm`; model downloads require explicit confirmation. +- Structured completion with live shell knowledge, a bundled Fig + Carapace static catalog, provenance, custom declarative specs and richer completion descriptions. +- Context-ranked history with agent/source filters; optional Atuin, zoxide, fzf and Television providers; command inspection and conservative correction suggestions. +- Curated `/tools`, supported Starship configuration, consent-based mise project/task awareness, project workflows and `/watch` scheduled commands. + +### Appearance & interaction +- `/appearance`, richer prompt customization, Chroma treatments and bounded effects; Clean and Rich motion with independent intensity and speed. +- Portable cursor effects and capability-gated Ghostty/Kitty integration with previews and clear fallback behavior. +- Expanded Setup Cat, grouped Config and Status, idle visual galleries and completion UI polish. + +### Transcript, safety & fixes +- `/find`, presentation-only `/filter`, transcript selection, block actions, OSC 8 links, `/open` references and `/open-diff` editor integration. +- Capability-gated images with a text fallback; deterministic paste classification, preview and scrollable review; `/doctor` read-only diagnostics and failure explanations. +- Hardened session recovery, shell helper isolation, terminal modes, completion cancellation and configuration preservation; fixes to divider colors, slash-command history and presentation lifecycles. + ## [0.7.0] - 2026-10-01 UI Foundation & Customization: a shared internal UI toolkit (notMyUI), Chroma color roles, reduced-presentation modes, Markdown-authored help, and Settings v2. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 69ed0c12..e4be6415 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,10 +17,11 @@ npm run build Before starting work, check: +- **[ARCHITECTURE.md](ARCHITECTURE.md)** — how the pieces fit together; start here before changing a subsystem - **[ROADMAP.md](ROADMAP.md)** — product direction and what is planned - **[GitHub Issues](https://github.com/raiseCatError/notMyShell/issues)** — concrete actionable work; acceptance criteria in each issue are authoritative -- **[v0.7.0 Release](https://github.com/raiseCatError/notMyShell/releases/tag/v0.7.0)** — current stable release -- **[#132 Flow / Classic composer](https://github.com/raiseCatError/notMyShell/issues/132)** — next planned direction (see [ROADMAP.md](ROADMAP.md)) +- **[v0.16.0 Release](https://github.com/raiseCatError/notMyShell/releases/tag/v0.16.0)** — current stable release +- **[PR #315](https://github.com/raiseCatError/notMyShell/pull/315)** — current development after v0.16.0 (unreleased; see [ROADMAP.md](ROADMAP.md)) - **GitHub Project** — [NMSh Development](https://github.com/users/raiseCatError/projects/1) — live development status board ## Branch Model @@ -38,13 +39,24 @@ Then submit a Pull Request from `feature/example` to `dev`. ## Canonical verification -Before submitting a pull request, ensure that your changes pass the canonical verification suite: +During implementation, run focused affected tests. Use `npm run verify:fast` for +ordinary iteration (build, an explicit core test subset, and diff checks); it is +not the final gate. Before pushing a meaningful checkpoint, run `npm run verify` +(build, the full canonical suite, and diff checks). Build already checks the +source TypeScript; `npm run typecheck` remains available for direct use. + +For release-sensitive changes run `npm run verify:release`, which adds benchmark +script typechecking and bounded timing smoke. These commands reuse local +node_modules; use `npm ci` for clean CI/release environments. Batch coherent +changes and avoid pushing tiny or known-broken edits to use Actions as a test +runner. GitHub CI provides independent platform verification, not a replacement +for local checks. See [development verification](docs/development-verification.md) +for sharding, platform gates and exact-release evidence requirements. ```bash -npm run build -npm run typecheck -npm test -git diff --check +npm run verify:fast +npm run verify +npm run verify:release ``` *Note: When writing tests involving `TerminalApp`, you must carefully tear down child processes and temp ZDOTDIRs using `app['stop'](0)` and `app['session'].kill()` to prevent zombie processes.* @@ -82,3 +94,7 @@ When opening a Pull Request: - **Reproducibility**: Keep generated/demo assets reproducible using the scripts in `scripts/`. Commits do not need to follow an excessively strict convention (e.g. Conventional Commits), but clear, descriptive messages are preferred. + +## Visual docs and demos + +README and [docs/demos.md](docs/demos.md) media come from committed VHS tapes in [`scripts/demos/`](scripts/demos/README.md). If a change alters what a clip shows, re-record it with `npm run demos` (or `npm run demos -- `) and commit the tape and the regenerated asset together. Recordings run against a disposable demo home and never read your own NMSh config, shell rc files or history. diff --git a/README.md b/README.md index cfde1e5c..26f82029 100644 --- a/README.md +++ b/README.md @@ -1,170 +1,130 @@
- NMSh Logo + Vespyr beside the NMSh / notMyShell logo

A terminal frontend for your real shell.

License - macOS - Node.js - zsh + macOS, Linux (beta), WSL 2 + Node.js 22+ + zsh, Bash, Fish CI


-**notMyShell (NMSh)** is a terminal frontend for a real persistent zsh session. It adds a persistent bottom input editor, semantic syntax highlighting, autocomplete, scrollable history, and richer command feedback while preserving normal shell state, aliases, functions, environment, and PTY behavior. +**notMyShell (NMSh)** runs your real zsh, Bash or Fish in a persistent session and gives it a better front end: a composer that stays put, semantic highlighting, a readable transcript, live command feedback, sessions that survive closing the window, and themes that can reach the tools you use. -Working on NMSh? See [AGENTS.md](AGENTS.md). +Current stable release: [v0.16.0 — Sessions, Agents & Portability](https://github.com/raiseCatError/notMyShell/releases/tag/v0.16.0). The `master` branch is the released state; newer work (themes, Theme Bridge, tool configuration, shell frameworks, Keep Awake) is in development and listed under *Unreleased* in the [changelog](CHANGELOG.md). The Context Engine and Context Rail are being developed on `feature/305-context-engine`. -## Visual demo +## See notMyShell in motion -
- NMSh Demo -

NMSh showing semantic highlighting, the pinned input bar, and live activity feedback.

-
+https://github.com/user-attachments/assets/7f2cbe74-ad86-4d13-bbe3-04e334cff81b -
-
- - Divider - -
-
+[Watch the 4K reel fullscreen → YouTube](https://youtu.be/zIzq_88R5OI) · [View the X post → X](https://x.com/raiseCatError/status/2107187652181303325?s=20) -## What is NMSh? +Explore the [demo gallery](docs/demos.md) by feature. -NMSh is **NOT** a replacement shell implementation, and it is **NOT** a terminal emulator. +## What NMSh is (and is not) -It is a frontend that wraps your real zsh environment. NMSh owns the prompt, multiline input editor, syntax highlighting, and history presentation. Real zsh owns the parsing, command execution, aliases, and environment variables. +NMSh is a **frontend**. Your shell stays underneath and does what it always did: parsing, execution, aliases, functions, environment, job control. NMSh owns what you see and type: the composer and editor, completion and suggestions, history and transcript, prompt and layout, sessions, themes and Chroma, tool panels, and local guidance. Fullscreen and raw interactive terminal applications use the passthrough path; their input and display remain with the real program.
- NMSh Architecture + Terminal host → NMSh frontend → ShellAdapter → your real shell
-## Why NMSh? +- **Not a shell.** It does not reimplement zsh, Bash or Fish; it runs them. +- **Not a terminal emulator.** Keep Ghostty, Terminal.app, VS Code, Zed or whatever you use. +- **Not a prompt theme.** The Native prompt is optional; Starship, Oh My Posh or Powerlevel10k can supply the prompt instead, or none at all. +- **Not an AI terminal.** `/btw` (legacy alias `/ask`) maps plain requests onto typed NMSh actions locally, with an optional local model; nothing needs an account. -NMSh provides a richer interactive frontend without throwing away the proven robustness of a real shell parser. It brings a Claude Code-like interaction model to your daily shell: +## Highlights -- **Fixed bottom input:** A stable workspace that never jumps around. -- **Scrollable history:** Output history that doesn't disappear when you edit. -- **Rich editor:** True multiline input that acts like a text editor. -- **Preserved semantics:** Your real shell aliases, functions, and pipelines still work. +- **A composer that stays where you want it** — Bottom, Top or Flow (right after the newest output), one-line or two-line, with true multiline editing. +- **Semantic highlighting** — commands, builtins, aliases and functions are classified against your real shell as you type; partial input is never executed. +- **A transcript you can use** — command blocks with status and timing, folding for long output, `/find` and `/filter`, plain-text `/copy`. +- **Live sessions** — closing a window detaches the shell instead of killing it; running commands keep going. `/resume` or `nmsh --attach` brings them back. +- **Prompt providers** — NMSh Native (Powerline, Soft, Minimal, Outline styles), Starship, Oh My Posh, Powerlevel10k or None. +- **Theme Studio and Theme Bridge** — built-in, imported and custom themes in `/theme`; opt-in `/theme-bridge` carries the active theme to fzf, less/man, file listings, bat, tmux, Vim, Neovim and Helix through files NMSh owns and you review. +- **Curated tools** — `/tools` finds, explains and (on request) installs a short list of shell tools; `/providers` picks the picker, history, navigation and suggestion providers; `/tmux` and `/dotfiles` import settings safely. +- **Shell frameworks, handled carefully** — Oh My Zsh, Powerlevel10k, Prezto, Zim, zinit and Antidote are detected; shell config is treated as code, never as harmless data. +- **Keep Awake** — `/zoomies` (also `/caffeinate`, `/awake`) keeps the machine or display awake through the OS's own mechanism and shows that it is on, quietly. +- **Personality, optional** — Chroma color treatments, motion, screensavers and Vespyr, the NMSh cat. All of it respects Reduced Motion, Safe glyphs and `NO_COLOR`. -
- - Divider - + Vespyr, the NMSh cat, sitting on a divider
-
-## Features - -### Shell -- Real zsh execution and parsing -- Real aliases, functions, and environment -- `zoxide` integration -- Completion bridge using real zsh completion data - -### Editor -- Multiline input and selection -- Predictive ghost suggestions (NMSh Native: fuzzy, frecency, directory, and sequence ranking; optional Deja provider): → accepts, Alt+→ accepts a word, Ctrl+N / Ctrl+P show alternatives, Esc dismisses -- **Semantic syntax highlighting** (differentiates executables, builtins, aliases, and functions instantly) - -### Prompt -- NMSh Native prompt (default) with Lavender Native, Brand / Semantic, Cool First, Warm First, and Grayscale themes -- Independent Start / Connector / Connector fade / Gap / End geometry (wedge, flat, rounded, slanted, and fading outer edges), icons On/Off, and a module manager: `/prompt` → Main Prompt -- Rich Git state (staged, modified, untracked, conflicts, ahead/behind/diverged, operations, clean) with its own Enabled, Colors (Semantic default, Follow theme, Grayscale), Geometry, and Connector fade settings: `/prompt` → Rich Git -- Right-side prompt context: any module can sit left or right (`/prompt` → Modules, `P`); the right side mirrors its geometry to face left by default (`M`) and is the first thing to go on narrow terminals -- Show-on-command modules: Kubernetes and Docker context (and optionally toolchains) appear only while a relevant command such as `kubectl` or `docker` is typed; typed text is never executed to decide -- Width-aware path shortening keeps the repository name and current directory whole while abbreviating parents as the terminal narrows -- Terminal glyph style (Nerd Font or Safe/ASCII) is chosen on first run and can be changed in `/config` (Glyph style) or previewed under `/settings` → Settings → Glyph style. Existing v0.3 configurations keep Nerd Font styling; `NMSH_ICONS=nerd|safe` overrides the saved choice for the current process. -- Optional Starship or Powerlevel10k prompt providers; NMSh keeps the editor. The Starship module editor changes only reviewed settings, with a backup of an existing config. -- Native prompt styles: Powerline, Soft, Minimal, Outline (`/prompt` → Style) -- One-line or two-line composer layouts - -### Interface -- Command lifecycle rows with activity animation and nested Node TAP activity -- Scrollable history with muted snapshots of each command's prompt; tune dividers and history colors with `/transcript` -- Output folding (Config → Output folding: Off / Smart / Always): long output collapses to its first and last lines around `› N lines hidden · Ctrl+O`; Smart keeps failures and useful output expanded; `/copy` and `/resume` always keep the full output -- Composer position Bottom, Top, or Flow (Config → Composer position). Flow places the prompt and input right after the newest output, like a conventional terminal, and they scroll with it. Combine any position with Normal or Chat transcript presentation (Config → Transcript presentation). `/layout` (also Config → Layout) previews every combination with sample content before you choose. -- Welcome providers: Vespyr (default), Fastfetch, Neofetch (legacy, if installed), or None (`/settings` → Welcome) -- Command palette: `/palette`, F1, or Ctrl+Shift+P (Cmd+Shift+P where the terminal reports it) to search NMSh commands, settings, and actions -- Sticky command headers keep the current command visible while scrolling -- **Live sessions:** closing a window detaches its shell instead of ending it, and running commands keep going. Come back through the startup prompt (Config → Sessions: Ask, Always or Never), `/resume` (LIVE sessions with their status, above archived transcripts), or `nmsh --attach ` (`nmsh --sessions` lists them). `exit`, Ctrl+D and `/zsh` end a session. -- NMSh checkpoints the local presentation session during use; `/clear` starts a fresh view and `/resume` browses retained sessions without rewinding live zsh state -- `/zsh` hands off to an ordinary interactive zsh -- Rich paste atoms for large multiline pastes -- `/copy` and `/copy N` for instant clipboard access -- `/history` interactive search -- `/version`, `/appearance`, and `/keyboard` integrations -- `/settings` (alias `/config`) edits NMSh preferences and `/status` shows runtime status, while direct commands such as `/prompt` and `/transcript` remain available - -### Interactive Apps -- Safe passthrough yielding for full-screen applications like `fzf`, `vim`, `nano`, and `less`. +## Native modules and the Context Engine -
-
- - Divider - -
-
+The current engineering focus is NMSh's native module ecosystem: trusted capabilities resolve contextual facts, modules turn those facts into presentation, and a Surface Router places them in the Main Prompt, Context Rail or Right Context. -## Syntax Highlighting +The development branch implements fact metadata, native module routing and the Context Rail. Installable, declarative Context Packs are upcoming; they are not an executable plugin API. Entering a repository must never execute arbitrary repository-controlled code through context discovery. -Highlighting is entirely NMSh-native and non-blocking. A fast lexical layer tokenizes the input, while an asynchronous semantic bridge queries your real zsh environment to classify command tokens. +Read the [Context Modules guide](docs/architecture/context-modules.md) for the implemented surfaces, discovery boundaries and pack direction, or the [roadmap](ROADMAP.md#current-engineering-focus--native-modules-and-context-engine) for what remains. -NMSh safely queries metadata (`whence -w`) and never executes partially typed input. +## Visual tour -`/syntax` (also under `/settings` → Syntax) turns highlighting on or off and picks its colors: follow the prompt theme (default; with Starship or Powerlevel10k this means the saved NMSh Native palette), choose any Native theme independently, or Grayscale, which keeps categories apart through lightness, weight, and underline. Live preview rows show the result before saving. Submitted commands keep the look they were entered with; raw command output is never recolored and `/copy` stays plain text. +A workspace that stays readable. A palette that feels like yours. Explore the full recordings by chapter, from first setup to returning to a running session. -
- - Syntax highlighting demo - -
+
+Composer and live feedback walkthrough -
- - Divider - + NMSh: typing a highlighted command, running it with live activity, then switching the theme from /theme +

Typing with semantic highlighting, live command feedback, and a theme change from /theme. Recorded from the real binary with VHS.

-
-## Shell Compatibility +
+ +

+ Preview: Theme Studio with live colors and custom themes +

-NMSh currently boots a real, controlled zsh instance. +

Your palette — Theme Studio

-**What works naturally:** -- Aliases, functions, PATH, and environment variables -- `zoxide` integration, pipelines, redirects, and external commands +| Explore | What you’ll see | +| --- | --- | +| [Customization](docs/demos.md#customization) | Setup Cat, composer layouts, prompt styles, theme families and syntax previews | +| [Color and motion](docs/demos.md#color-and-motion) | Chroma gradients, live feedback, every screensaver, Vespyr and raiseCatError | +| [Tools and shells](docs/demos.md#tools-and-shells) | Ask, provider choices, checked tools, Theme Bridge, Fish and real Vim | +| [Sessions](docs/demos.md#sessions) | Detach, reattach and Keep Awake | -**UI Plugin differences:** -- Foreign prompt rendering in the managed shell (Powerlevel10k, RPROMPT, ZLE prompts) is suppressed so it cannot fight NMSh. You can still choose Starship or Powerlevel10k as an NMSh prompt provider. Powerlevel10k's left prompt is rendered in an isolated helper, without its prompt character, gitstatus daemon, or right prompt. -- `zsh-autosuggestions` and `zsh-syntax-highlighting` are replaced by NMSh-native equivalents. -- Native `fzf-tab` integration is not currently supported; safe zsh completion/widget interoperability remains unresolved in [issue #52](https://github.com/raiseCatError/notMyShell/issues/52). +[Full demo gallery](docs/demos.md) · [Detailed runtime diagram](assets/readme/architecture-detailed.svg) -NMSh loads your `~/.zshrc` in a controlled sandbox to extract environment knowledge without letting UI plugins fight for terminal control. +The gallery uses wide, opaque recordings from the real NMSh build, with a disposable home and neutral demo identity. [Committed tapes](scripts/demos/README.md) reproduce every clip. -See [ROADMAP.md](ROADMAP.md) for planned shell compatibility, multi-shell adapters, and future work. +## Install -## Installation +### Homebrew -**Prerequisites:** -- macOS -- Node.js (v22+) -- zsh -- A compatible terminal host (Ghostty, macOS Terminal, VS Code) +> Homebrew support is being prepared for v0.17.0. These instructions are not available yet; use the source installation below until the published formula has passed installation, formula tests and interactive startup validation. -Clone the repository and install dependencies: +Homebrew is the recommended install method on macOS once the v0.17.0 formula is published and validated. + +```sh +brew install raiseCatError/tap/nmsh +nmsh +``` + +To uninstall the Homebrew package: + +```sh +brew uninstall nmsh +``` + +### Build from source + +You need: + +- macOS, Linux (beta: automated CI on Ubuntu and Fedora, not yet physically validated) or Windows through WSL 2 ([platforms](docs/architecture/platforms.md)) +- Node.js 22 or newer +- zsh (default), and optionally Bash 4.4+ or Fish ```sh git clone https://github.com/raiseCatError/notMyShell.git @@ -172,102 +132,105 @@ cd notMyShell npm install npm run build npm link +nmsh ``` -*(Note: Depending on your npm version, you may be prompted to allow lifecycle scripts required by `node-pty`. You can safely approve this or set `allowScripts` appropriately.)* - -After linking, run the CLI from anywhere: +npm may ask to allow `node-pty`'s install script; it is required. To start NMSh from Ghostty or another GUI terminal, use absolute paths so macOS `PATH` differences cannot break startup: -```sh -nmsh +``` +command = direct:/absolute/path/to/node /absolute/path/to/nmsh ``` -### Updating +Do not set `nmsh` as your system/login shell with `chsh`. Keep zsh, Bash or Fish as your real shell. If you want NMSh to open automatically, configure your terminal app (for example Ghostty or Zed) to launch `nmsh` instead. -`/update` checks GitHub for the latest stable release and shows current → available, a short release summary, and the exact plan. `/update apply` then installs that release. NMSh updates a source checkout of this repository only when the checkout is clean, its `origin` is this repository, the fetched release tag matches the commit GitHub reports, and moving to the tag is a fast-forward. It then runs `npm install` and `npm run build` and verifies the new build identity. If anything fails, it restores the previous commit and rebuilds it. Otherwise it explains why and prints the manual steps. It never pulls arbitrary branches, discards changes, or touches your settings, transcripts, or shell profile. Restart NMSh afterwards to use the new version. +**Updating:** For Homebrew installations, use `brew upgrade raiseCatError/tap/nmsh`; `/update` directs you to Homebrew rather than changing files in the Cellar. For source installations, `/update` shows the latest stable release and the exact plan; `/update apply` installs it into a clean official source checkout, verifies the build, and rolls back on failure. Automatic updates (Automatic / Notify only / Off) use the same checks. -Background checks are off by default; turn them on in `/settings` → Config → Update checks (Daily or Weekly). When a newer release appears, you get one quiet line per release. No credentials or telemetry are involved. +**Moving and removing:** `nmsh config export` / `nmsh config import FILE` move settings between machines (preview first, no history or secrets); for source installations, `nmsh uninstall` removes only NMSh's own launcher links (use `brew uninstall nmsh` for Homebrew packages); `nmsh doctor` prints a diagnostic for bug reports. -## Ghostty Setup +## Core commands -For the most robust startup experience in Ghostty, configure it to run NMSh using absolute paths. GUI applications on macOS sometimes have unpredictable `PATH` resolution. +Type `/` in the composer for the full list, or `/help` for everything grouped by area. The ones you will reach for most: -1. Find your absolute paths: - ```sh - command -v node - command -v nmsh - ``` +| Area | Commands | +| --- | --- | +| Settings and setup | `/settings` (`/config`), `/setup`, `/palette` (F1), `/help`, `/status` | +| Composer and prompt | `/prompt`, `/layout` (`/composer`), `/transcript`, `/syntax`, `/cursor` | +| Look and motion | `/appearance`, `/theme`, `/theme-bridge`, `/chroma`, `/motion`, `/chrome`, `/glyphs`, `/strip`, `/screensaver` | +| Tools | `/tools`, `/providers`, `/configure`, `/tmux`, `/integrations`, `/dotfiles` | +| Sessions and history | `/resume`, `/sessions`, `/history`, `/find`, `/filter`, `/copy`, `/clear` | +| Shells | `/shell` (switch zsh / Bash / Fish in place), `/zsh` (hand off to an ordinary shell) | +| Everyday extras | `/btw`, `/watch`, `/open`, `/zoomies` (`/caffeinate`, `/awake`), `/update`, `/doctor` | -2. Add the direct command to your Ghostty config (`~/.config/ghostty/config`): - ``` - command = direct:/absolute/path/to/node /absolute/path/to/nmsh - ``` +### Keep Awake -Do not instruct macOS to change your default login shell to NMSh. NMSh is a frontend; zsh remains the underlying shell. +`/caffeinate`, `/awake` and `/zoomies` are the same feature: -## Host Compatibility +```text +/zoomies open the panel (starts nothing by itself) +/zoomies display keep the display and the machine awake until stopped +/zoomies system 2h prevent system sleep for two hours +/zoomies status mode, backend, start time, timeout +/zoomies stop end it +``` -| Host | Status | Notes | -|------|--------|-------| -| Ghostty | Primary | Full integration available (`/keyboard`, `/appearance`). | -| macOS Terminal | Supported | Shift+Enter works out of the box. | -| VS Code Integrated Terminal | Supported | `Shift+Enter` may require custom `keybindings.json` forwarding. Opacity/blur controls are not applicable. | +It uses the operating system's own mechanism: Apple `caffeinate` on macOS, a systemd inhibitor on Linux (idle and sleep only; the inhibitor is not a display API, so Display is shown as unavailable there), and the `SetThreadExecutionState` API on Windows. The assertion is an NMSh-owned background process, so the prompt comes straight back; it keeps running after the window closes and ends on `stop` or its timeout. Typing `caffeinate` yourself is still an ordinary shell command. While it is active, NMSh shows `Awake · ` on a free composer edge (or a row next to the composer), in the Status Strip when that is on, and optionally on the screensaver; nothing shows while it is off. Power settings are never changed, and `/zoomies stop` only stops what NMSh can prove it started. -## Keyboard Behavior +## Compatibility -- **Enter:** Submit command -- **Ctrl+J:** Portable multiline newline fallback -- **Shift+Enter (Ghostty/macOS Terminal):** Insert a newline in the editor -- **Option+Left/Right:** Move cursor by word -- **Option+Backspace:** Delete previous word (requires Ghostty forwarding setup) -- **Ctrl+W:** Delete previous word -- **Cmd+A:** Select all input (requires Ghostty forwarding setup) -- **Cmd+Up/Down:** Jump to top/bottom of buffer (requires Ghostty forwarding setup) -- **Shift+Left/Right:** Character selection -- **PageUp/PageDown:** Scroll output history +**Shells.** zsh (default), Bash 4.4+ and Fish run behind one [ShellAdapter](docs/architecture/shell-adapter.md); the composer, transcript, sessions, prompt, themes and completion menu work over each, and `/shell` switches the current session in place. NMSh loads your startup files in a controlled bootstrap and never edits them. ZLE prompt and widget UI (Powerlevel10k's in-shell prompt, zsh-autosuggestions, zsh-syntax-highlighting) is kept off inside NMSh so it cannot fight the composer; those plugins keep working in `/zsh` and ordinary shells. Native `fzf-tab` is not supported ([#52](https://github.com/raiseCatError/notMyShell/issues/52)). -*(Note: In VS Code, Shift+Enter is often indistinguishable from Enter by default. Use Ctrl+J as a reliable multiline fallback.)* +**Shell frameworks and prompt providers.** They are different things, and NMSh treats them differently: -### /keyboard & /appearance +| | What it is | What NMSh does | +| --- | --- | --- | +| Oh My Zsh | Zsh framework | Detects it; guided install that keeps your `.zshrc` (you run the official installer); compares and can restore `.zshrc.pre-oh-my-zsh` after a backup and confirmation | +| Powerlevel10k | Zsh prompt theme | Optional prompt provider rendered in an isolated helper; `p10k configure` on request | +| Starship, Oh My Posh | Cross-shell prompt engines | Optional prompt providers run directly by NMSh, no rc changes | +| Prezto, Zim, zinit, Antidote | Zsh ecosystem tools | Detected and shown, inspect-only | -Some advanced shortcuts (like Option+Backspace, Cmd+A) are normally consumed by the terminal host before NMSh sees them. The `/keyboard` slash command installs managed, opt-in forwarding rules exclusively into your Ghostty configuration. It does not alter any macOS system keybindings. +NMSh never sources or installs framework code on its own, and never merges shell configuration. -The `/appearance` slash command provides an interactive UI to adjust Ghostty's window background opacity, blur mode, and blur radius. +**Terminals.** NMSh is host-independent. Zed, VS Code, Ghostty and Terminal.app are used daily during development, and Ghostty and Terminal.app are physically validated; Kitty, iTerm2, WezTerm and Windows Terminal (through WSL) have capability profiles covered by CI fixtures but have not had the same physical QA. Hosts differ in keyboard and mouse reporting — see [terminal host](docs/architecture/terminal-host.md) and [HostActions](docs/architecture/host-actions.md) for details, and `/keyboard` for Ghostty key forwarding (Option+Backspace, Cmd+A). -## Known Limitations +**Accessibility.** Safe/ASCII glyphs (`/glyphs`), `NO_COLOR`, 256-color terminals and Reduced Motion are first-class: meaning never depends on color or icons alone, and motion stops when you ask it to. See [accessibility](docs/accessibility/). -- **Mouse behavior:** Native mouse selection or Shift-drag behavior may feel different because NMSh enables mouse reporting. -- **ZLE widgets:** Certain complex third-party ZLE (Zsh Line Editor) widgets are not directly portable. -- **zsh grammar:** Syntax highlighting intentionally does not implement the entire, exhaustive zsh grammar; it focuses on providing fast semantic assistance for common command structures. Highlighting colors are theme-aware via `/syntax`. -- **Completion:** The completion bridge is not full parity with a configured interactive zsh, and native `fzf-tab` is not supported yet ([#52](https://github.com/raiseCatError/notMyShell/issues/52)). -- **Nested activity:** Only directly observed Node TAP v13 streams produce nested activity rows. -- **Powerlevel10k provider:** The right prompt, instant prompt, gitstatus daemon, and p10k settings defined only in `.zshrc` are not reproduced. +## Safety and ownership -## Development +- Everything runs locally. No account, no telemetry, no cloud backend ([privacy](docs/privacy.md)). +- **Your shell config is code.** NMSh does not edit rc files behind your back. The few changes it can make on request (for example a Theme Bridge include, or restoring a backed-up `.zshrc`) are shown as an exact diff and wait for your confirmation. +- **Imports are data.** Theme files are parsed with bounded data parsers; nothing is sourced, templated or fetched. `/dotfiles` never runs anything from a repository; it imports supported settings and leaves executable configs inspect-only. +- **NMSh owns what it writes, and only that.** Generated files are recorded in an ownership ledger and are replaced or removed only while they still match what NMSh wrote. Keep Awake stops only the process it can prove it started. +- **Installs are explicit.** `/tools` shows the exact package-manager command and asks first; nothing elevates silently. -```sh -npm run build -npm run typecheck -npm test -git diff --check -``` +See [SECURITY.md](SECURITY.md) to report a vulnerability. + +## Known limitations + +- The completion bridge is close to, but not full parity with, a configured interactive zsh. +- Highlighting covers common command structure, not the entire zsh grammar. +- Powerlevel10k's right prompt, instant prompt and gitstatus daemon are not reproduced by the provider. +- Theme Bridge recolors new tool instances; editors and shells already running outside NMSh are not recolored live. delta is shown but not managed (it reads bat's cache and git config, which NMSh leaves alone). Terminal title ownership (OSC 0/2) is not implemented. +- Hosts without mouse reporting scroll the transcript with PageUp/PageDown. -## Community & Documentation +## Documentation -- **Contributing** → [CONTRIBUTING.md](CONTRIBUTING.md) -- **Security** → [SECURITY.md](SECURITY.md) -- **Roadmap** → [ROADMAP.md](ROADMAP.md) -- **Changelog** → [CHANGELOG.md](CHANGELOG.md) +- [Architecture](ARCHITECTURE.md) — how NMSh works end to end, in plain language; deeper docs live under `docs/architecture/` and `docs/design/` +- [Context Modules](docs/architecture/context-modules.md) — facts, routing, Context Rail and declarative pack direction +- [Demo gallery](docs/demos.md) — every feature clip in one place +- [ShellAdapter](docs/architecture/shell-adapter.md), [platforms](docs/architecture/platforms.md), [terminal stack](docs/architecture/terminal-stack.md) +- [Themes, imports and Theme Bridge](docs/design/theme-bridge.md), [Chroma and UI chrome](docs/design/chroma-and-ui-chrome.md), [idle visuals](docs/design/idle-visuals.md) +- [Roadmap](ROADMAP.md) · [Changelog](CHANGELOG.md) · [Support](SUPPORT.md) -See [ROADMAP.md](ROADMAP.md) for future multi-shell architecture and extensibility plans. +## Development -## Security & Privacy +```sh +npm run verify:fast # build + core tests (iteration) +npm run verify # build + full suite +npm run demos # re-record the README clips with VHS (see scripts/demos/README.md) +``` -- Everything executes locally on your machine through your local shell. -- No cloud backend is required. -- No telemetry is collected. -- Shell configuration reads from your local system securely. +Contributor and agent guidance: [CONTRIBUTING.md](CONTRIBUTING.md) and [AGENTS.md](AGENTS.md). `NMSH_DETERMINISTIC=1` makes presentation repeatable for tests and recordings ([deterministic presentation](docs/testing/deterministic-presentation.md)). ## License -NMSh is licensed under the **GNU General Public License v3.0** (GPL-3.0-only). See the `LICENSE` file for details. +NMSh is licensed under the **GNU General Public License v3.0** (GPL-3.0-only). See [LICENSE](LICENSE). diff --git a/ROADMAP.md b/ROADMAP.md index 7571e782..9ef686d2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,12 +4,28 @@ | | | |---|---| -| **Current release** | [v0.7.0 — UI Foundation & Customization](https://github.com/raiseCatError/notMyShell/releases/tag/v0.7.0) | +| **Current release** | [v0.16.0 — Sessions, Agents & Portability](https://github.com/raiseCatError/notMyShell/releases/tag/v0.16.0) — released | +| **Next development milestone** | [v0.17.0 — Compatibility & Discovery](https://github.com/raiseCatError/notMyShell/milestone/15) | +| **Current engineering focus** | [#305 — Native modules / Context Engine](https://github.com/raiseCatError/notMyShell/issues/305); foundation and Context Rail implemented on `feature/305-context-engine`, unreleased | +| **In development (unreleased)** | [PR #315](https://github.com/raiseCatError/notMyShell/pull/315) — Theme Studio, Theme Bridge, tool configuration, shell frameworks, Keep Awake; see CHANGELOG → Unreleased | +| **Unscheduled** | [Future / Backlog](https://github.com/raiseCatError/notMyShell/milestone/7) | | **Development branch** | `dev` | -| **Next direction** | Not yet defined; see the Backlog below and open issues | | **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. +GitHub issues define actionable remaining work. Closed issues represent completed work, not a promise that future refinements are finished. The released package is 0.16.0; v0.17 is the next development milestone. + +## Current engineering focus — native modules and Context Engine + +The primary current direction is [#305](https://github.com/raiseCatError/notMyShell/issues/305): a native module ecosystem built around **Capability → Fact → Module → Surface Router → presentation surfaces**. The [Context Modules guide](docs/architecture/context-modules.md) explains the public model. This work is unreleased; the stable package remains v0.16.0 and the release sequence below remains the planned track. + +| State | Scope | +|---|---| +| Implemented on the feature branch | Typed fact metadata and privacy filtering; trusted bounded metadata/Git collection; the first-party module registry; Main Prompt / Right Context / Context Rail routing; Rail geometry, priority fitting and shared previews | +| Preserved | Existing Native module order, visibility and left/right placement; real zsh/Bash/Fish sessions; prompt snapshots and fullscreen passthrough | +| Upcoming | General capability scheduling and fuller fact migration; a bounded expansion of useful first-party modules; installable declarative Context Packs with validation and policy | +| Deferred | Status Strip module routing and any executable extension tier; no finalized public pack API | + +Repository entry must never grant authority to execute arbitrary repository-controlled code. Discovery remains parser-only or uses curated core probes with explicit bounds and policy. ## Released — v0.2.0 Structured Execution @@ -112,53 +128,60 @@ 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 | -## Backlog — Research and Future Features +## Released — v0.16.0 Sessions, Agents & Portability -These remain open and are not scheduled for a release. +The work formerly planned as v0.8–v0.15 ships together with v0.16 in one cumulative release. PR #303 is merged; final CI and the maintainer physical sanity pass passed. User-facing changes are in [CHANGELOG.md](CHANGELOG.md). Linux/WSL and other-host physical follow-ups remain in v0.17. -| Issue | Title | +| Area | Implemented scope | |---|---| -| [#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 | +| Command intelligence (formerly v0.8) | Structured completion menu with a bundled static catalog generated from withfig/autocomplete and carapace-bin plus the live shell source; structured `/history`; Atuin, zoxide, fzf and Television providers; command inspector; block actions; correction suggestions; completion notifications | +| Tools & workflows (formerly v0.9) | `/tools` browser and curated catalog (#306/#307), supported Starship configuration, optional mise awareness, session presets, welcome providers, Linguist language colors, VHS tapes | +| Hosts & compatibility (formerly v0.10) | TerminalHost capabilities, Terminal.app baseline, Ghostty integration, passive iTerm2/Kitty/WezTerm profiles, CLI/TUI fixtures, OSC 8 links | +| Shell intelligence (formerly v0.11) | Configured-zsh completion bridge, alias/function metadata, syntax roles, ShellAdapter research (now implemented, below) | +| Chroma & motion (formerly v0.12) | Composable Chroma treatments, bounded transient effects on a shared clock | +| Portability (formerly v0.13) | Linux baseline and CI, hardening; WSL reporting; Windows research (native Windows is no-go) | +| Sessions, agents, platform (v0.16) | Session notices (short-lived events), agent activity, `/resume` viewer, `nmsh config export/import`, `nmsh uninstall`, `nmsh doctor`, images, Linux/WSL, ShellAdapter with zsh, Fish and Bash, `/find` and `/filter`, editor bridge, `/watch`, `/doctor`, paste preview and review | +| Ask & Local Understanding | Deterministic Ask, optional local model (Auto by default, never downloads without a Yes), `/llm` | +| Appearance | `/appearance` hub; Clean and Rich motion rendering with per-rendering tuning; cursor effects with a capability matrix; Setup Cat as the complete customization entry point; grouped Config and Status | -zsh-autosuggestions and zsh-syntax-highlighting are not required plugins; NMSh provides those UI roles natively. +## Next development — v0.17 Compatibility & Discovery -## Later — Terminal Host Independence +[Milestone #15](https://github.com/raiseCatError/notMyShell/milestone/15). Code and automated coverage for `Relevant here`, package-manager plans with bulk install, activation detection, host profiles, Linux/WSL hardening, automatic update staging and startup session kill. Physical validation in iTerm2, Kitty, WezTerm, Linux and WSL 2 is tracked as a separate `needs-human-test` follow-up and does not block the release. -Keep TerminalHost abstraction and host compatibility separate from v0.3. NMSh core remains host agnostic; enhanced host capabilities must not become structural dependencies. - -| Issue | Title | +| Issue | Disposition | |---|---| -| [#10](https://github.com/raiseCatError/notMyShell/issues/10) | TerminalHost capability abstraction | -| [#11](https://github.com/raiseCatError/notMyShell/issues/11) | Make macOS Terminal the baseline host | -| [#12](https://github.com/raiseCatError/notMyShell/issues/12) | Ghostty enhanced integration (post-abstraction) | -| [#13](https://github.com/raiseCatError/notMyShell/issues/13) | Compatibility passes for iTerm2, Kitty, and WezTerm | -| [#15](https://github.com/raiseCatError/notMyShell/issues/15) | Investigate NMSh interoperability with Supacode and agent-oriented terminal hosts | +| [#9](https://github.com/raiseCatError/notMyShell/issues/9) | Implemented in v0.17 | +| [#13](https://github.com/raiseCatError/notMyShell/issues/13) | Code and fixtures in v0.17; physical pass moved to a follow-up | +| [#14](https://github.com/raiseCatError/notMyShell/issues/14) | Maintainer exercised real CLI/TUI use; deterministic suite extended; ongoing regression tracking | +| [#15](https://github.com/raiseCatError/notMyShell/issues/15) | Moved to Future / Backlog; NMSh already degrades by capability in unknown embedded hosts | +| [#18](https://github.com/raiseCatError/notMyShell/issues/18) | Code and CI in v0.17; physical Linux/WSL pass moved to a follow-up | -## Ongoing — CLI/TUI Compatibility and Polish +## Planned releases -NMSh provides the surrounding interaction and presentation layer; tools such as `gh`, zoxide, Atuin, tmux, editors, and agent CLIs keep their own interfaces. See [#14](https://github.com/raiseCatError/notMyShell/issues/14) for compatibility work and [#20](https://github.com/raiseCatError/notMyShell/issues/20) for uncategorized polish that does not already have a focused issue. +| Release | Theme | Tracker | +|---|---|---| +| v0.18.0 | Theme Bridge and semantic terminal integration | [#304](https://github.com/raiseCatError/notMyShell/issues/304), delivered in focused slices; in development on [PR #315](https://github.com/raiseCatError/notMyShell/pull/315), not released. Still deferred: terminal title ownership (OSC 0/2), delta custom styles, terminal emulator and editor base-theme takeover. | +| v0.19.0 | NMSh Native module ecosystem: current Context Engine foundation and Rail, then capability scheduling, a bounded first-party module expansion and declarative packs | [#305](https://github.com/raiseCatError/notMyShell/issues/305) | -## Longer Term — Shells and Platforms +## Backlog — future, unscheduled -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. +[Future / Backlog](https://github.com/raiseCatError/notMyShell/milestone/7). These remain open and are not scheduled for a release. | Issue | Title | |---|---| -| [#17](https://github.com/raiseCatError/notMyShell/issues/17) | Research ShellAdapter architecture for future multi-shell support | -| [#18](https://github.com/raiseCatError/notMyShell/issues/18) | Investigate Linux support | -| [#19](https://github.com/raiseCatError/notMyShell/issues/19) | Research Windows / ConPTY feasibility | +| [#304](https://github.com/raiseCatError/notMyShell/issues/304) | (planned v0.18) Theme Bridge and semantic terminal integration | +| [#296](https://github.com/raiseCatError/notMyShell/issues/296) | Hosted SSH demo (post-1.0) | +| [#20](https://github.com/raiseCatError/notMyShell/issues/20) | Ongoing polish triage index | + +zsh-autosuggestions and zsh-syntax-highlighting are not required plugins; NMSh provides those UI roles natively. Native fzf-tab interoperability is a documented non-goal (it would require ceding editor ownership). + +## Longer term — shells and platforms + +In v0.16.0 zsh, Fish and Bash 4.4+ are implemented backends behind a real [ShellAdapter](docs/architecture/shell-adapter.md). Nushell and PowerShell remain later. Linux and WSL 2 are supported by automated validation, with physical validation tracked in #18; native Windows (ConPTY) is a no-go per the [Windows feasibility research](docs/architecture/v013-windows-feasibility.md). See also [Linux foundations](docs/architecture/v013-linux-foundations.md). ## Design Principles -- NMSh is a frontend over a persistent real zsh session. +- NMSh is a frontend over a persistent real shell session (zsh, Fish or Bash through ShellAdapter). - The terminal host renders cells and interprets ANSI; NMSh is not a terminal emulator. - Raw PTY output remains recoverable and is not semantically recolored. - Fullscreen applications retain the passthrough path. diff --git a/SECURITY.md b/SECURITY.md index 4568ad23..f602c8f8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,7 @@ Security is critical for NMSh, as it executes and presents shell commands and ha ## Supported Versions -Formal stable version releases do not exist yet. Security fixes currently target the latest `master` branch (and the latest published release when releases begin). +Security fixes target the latest stable release (currently v0.16.0, the `master` branch) and the active development branch. Older releases are not patched separately; update with `/update`. ## Scope @@ -17,6 +17,18 @@ Examples of in-scope security vulnerabilities include: - Arbitrary file access - Privilege or security boundary mistakes - Unsafe configuration writes +- A path where NMSh executes content it should only read (theme imports, dotfiles repositories, tool configs) +- NMSh stopping, replacing or deleting something it cannot prove it created + +## Boundaries NMSh is designed to keep + +Reports that break any of these are in scope: + +- **Shell configuration is executable.** NMSh never sources, merges or silently edits rc files or framework code; the few edits it offers (an include line, restoring a backed-up `.zshrc`) are exact diffs applied only after confirmation. +- **Imports are data.** Theme imports use bounded data parsers (no includes, templates, Lua, entities or network). `/dotfiles` never runs repository content, clones only after confirmation (no submodules, hooks disabled), and fails closed when a copy cannot be verified exactly. +- **Ownership.** Generated files are tracked in an ownership ledger and replaced or removed only while their content still matches; Keep Awake signals only a process whose token and exact command line match its record. +- **Installers.** Tool installs are typed package-manager argv shown before confirmation; nothing elevates silently. Special installers (Oh My Zsh) are never run by NMSh. +- **External prompt providers** (Starship, Oh My Posh, Powerlevel10k) run as bounded, non-interactive helpers with argv only; their output is treated as untrusted display text. ## Reporting a Vulnerability diff --git a/SUPPORT.md b/SUPPORT.md index d0c8e551..fe1277cb 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -3,7 +3,7 @@ If you need help with NMSh, please follow the guidelines below to ensure your request reaches the right place. ## Bug Reports -If you have encountered a bug, panic, or reproducible crash, please open a **[Bug Report](../../issues/new?template=bug_report.yml)** via GitHub Issues. Make sure to fill out the requested template so we can reliably reproduce the problem. +If you have encountered a bug, panic, or reproducible crash, please open a **[Bug Report](../../issues/new?template=bug_report.yml)** via GitHub Issues. Make sure to fill out the requested template so we can reliably reproduce the problem. Including the output of `nmsh doctor` (or `/doctor`) helps; it contains no history, prompts or secrets. ## Feature Requests If you have an idea for a new feature or improvement, please open a **[Feature Request](../../issues/new?template=feature_request.yml)** via GitHub Issues. diff --git a/assets/brand/nmsh-lockup.svg b/assets/brand/nmsh-lockup.svg new file mode 100644 index 00000000..1964d6b1 --- /dev/null +++ b/assets/brand/nmsh-lockup.svg @@ -0,0 +1,62 @@ + + Vespyr and notMyShell + The NMSh cat sits to the left of the NMSh and notMyShell wordmark, centered vertically. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/completion/catalog-index.json b/assets/completion/catalog-index.json new file mode 100644 index 00000000..7106ec38 --- /dev/null +++ b/assets/completion/catalog-index.json @@ -0,0 +1 @@ +{"version":1,"roots":{"7z":"7z","@commercelayer/cli":"@commercelayer/cli","@forge/cli":"@forge/cli","R":"R","Rscript":"Rscript","StepZen":"StepZen","acpi":"acpi","acpid":"acpid","act":"act","actool":"actool","adb":"adb","adr":"adr","afconvert":"afconvert","afinfo":"afinfo","afplay":"afplay","aftman":"aftman","ag":"ag","age":"age","agg":"agg","agrippa":"agrippa","agvtool":"agvtool","agy":"agy","airflow":"airflow","airport":"airport","aliases":"aliases","alsamixer":"alsamixer","amplify":"amplify","ampx":"ampx","anchor":"anchor","ansible":"ansible","ansible-config":"ansible-config","ansible-doc":"ansible-doc","ansible-galaxy":"ansible-galaxy","ansible-lint":"ansible-lint","ansible-playbook":"ansible-playbook","ant":"ant","apfsctl":"apfsctl","apk":"apk","aplay":"aplay","apropos":"apropos","apt":"apt","apt-cache":"apt-cache","apt-get":"apt-get","ar":"ar","arch":"arch","archiveutil":"archiveutil","archlinux-java":"archlinux-java","arduino-cli":"arduino-cli","arecord":"arecord","argo":"argo","aria2c":"aria2c","arp":"arp","artisan":"artisan","asar":"asar","asciinema":"asciinema","asdf":"asdf","asr":"asr","assetutil":"assetutil","assimp":"assimp","astro":"astro","at":"at","atlas":"atlas","atos":"atos","atuin":"atuin","authchanger":"authchanger","autocannon":"autocannon","autoconf":"autoconf","autojump":"autojump","automator":"automator","automount":"automount","auval":"auval","avconvert":"avconvert","avdmanager":"avdmanager","avmediainfo":"avmediainfo","awk":"awk","aws":"aws","aws-vault":"aws-vault","awsume":"awsume","az":"az","babel":"babel","banner":"banner","baobab":"baobab","barnard59":"barnard59","base32":"base32","base64":"base64","basename":"basename","basenc":"basenc","bash":"bash","bash-language-server":"bash-language-server","bat":"bat","batdiff":"batdiff","batgrep":"batgrep","batman":"batman","bats":"bats","bazel":"bazel","bc":"bc","bcd":"bcd","benthos":"benthos","bit":"bit","black":"black","bless":"bless","blitz":"blitz","bloop":"bloop","bluetoothctl":"bluetoothctl","blueutil":"blueutil","bosh":"bosh","bputil":"bputil","brctl":"brctl","brew":"brew","broot":"broot","brotli":"brotli","browser-sync":"browser-sync","bru":"bru","btop":"btop","build-storybook":"build-storybook","buildctl":"buildctl","bun":"bun","bundle":"bundle","bunx":"bunx","but":"but","bw":"bw","bwdc":"bwdc","bws":"bws","c++":"c++","caffeinate":"caffeinate","cal":"cal","calibre":"calibre","cap":"cap","capacitor":"capacitor","capslock":"capslock","cargo":"cargo","cargo-clippy":"cargo-clippy","cargo-fmt":"cargo-fmt","cargo-rm":"cargo-rm","cargo-set-version":"cargo-set-version","cargo-upgrade":"cargo-upgrade","cargo-watch":"cargo-watch","cat":"cat","cci":"cci","cd":"cd","cdk":"cdk","cdk8s":"cdk8s","cf":"cf","cfdisk":"cfdisk","charm":"charm","chcpu":"chcpu","chdman":"chdman","checkov":"checkov","cheese":"cheese","chezmoi":"chezmoi","chflags":"chflags","chgrp":"chgrp","chmod":"chmod","chown":"chown","chpass":"chpass","chpasswd":"chpasswd","chroma":"chroma","chromium":"chromium","chroot":"chroot","chsh":"chsh","cicada":"cicada","circleci":"circleci","cksum":"cksum","clamav-config":"clamav-config","clamav-milter":"clamav-milter","clambc":"clambc","clamconf":"clamconf","clamd":"clamd","clamdscan":"clamdscan","clamdtop":"clamdtop","clamonacc":"clamonacc","clamscan":"clamscan","clamsubmit":"clamsubmit","clang":"clang","clang++":"clang++","clear":"clear","cliff-jumper":"cliff-jumper","clilol":"clilol","clion":"clion","clojure":"clojure","cloudflared":"cloudflared","cmake":"cmake","cmus":"cmus","coda":"coda","code":"code","code-insiders":"code-insiders","codecov":"codecov","codesign":"codesign","comm":"comm","command":"command","compare":"compare","composer":"composer","composite":"composite","compress":"compress","compression_tool":"compression_tool","conda":"conda","conda-content-trust":"conda-content-trust","conda-env":"conda-env","conky":"conky","consul":"consul","copilot":"copilot","copyfile":"copyfile","copypath":"copypath","cordova":"cordova","coredumpctl":"coredumpctl","cosign":"cosign","cot":"cot","cover":"cover","cp":"cp","cpio":"cpio","create-completion-spec":"create-completion-spec","create-next-app":"create-next-app","create-nx-workspace":"create-nx-workspace","create-react-app":"create-react-app","create-react-native-app":"create-react-native-app","create-redwood-app":"create-redwood-app","create-remix":"create-remix","create-t3-app":"create-t3-app","create-video":"create-video","create-vite":"create-vite","create-web3-frontend":"create-web3-frontend","createinstallmedia":"createinstallmedia","croc":"croc","crontab":"crontab","crush":"crush","csdx":"csdx","csplit":"csplit","csreq":"csreq","csrutil":"csrutil","csview":"csview","cupsaccept":"cupsaccept","cupsctl":"cupsctl","cupsdisable":"cupsdisable","cupsenable":"cupsenable","cupsfilter":"cupsfilter","cupsreject":"cupsreject","cura":"cura","curl":"curl","cut":"cut","cw":"cw","d2":"d2","dagger":"dagger","dapr":"dapr","darktable":"darktable","darktable-cli":"darktable-cli","dart":"dart","datagrip":"datagrip","dataspell":"dataspell","date":"date","dateseq":"dateseq","datree":"datree","dbt":"dbt","dc":"dc","dcli":"dcli","dd":"dd","ddev":"ddev","ddosify":"ddosify","deadcode":"deadcode","defaultbrowser":"defaultbrowser","defaults":"defaults","degit":"degit","delta":"delta","deno":"deno","deployctl":"deployctl","deta":"deta","devbox":"devbox","devenv":"devenv","df":"df","dfc":"dfc","dict":"dict","diff":"diff","diff3":"diff3","diffstat":"diffstat","dig":"dig","dir":"dir","dircolors":"dircolors","direnv":"direnv","dirname":"dirname","diskutil":"diskutil","ditto":"ditto","dive":"dive","django-admin":"django-admin","dlv":"dlv","dmenu":"dmenu","dmesg":"dmesg","dms":"dms","dnctl":"dnctl","dnf":"dnf","dnf5":"dnf5","dngconverter":"dngconverter","dnsmasq":"dnsmasq","do":"do","do-release-upgrade":"do-release-upgrade","doas":"doas","docker":"docker","docker-buildx":"docker-buildx","docker-compose":"docker-compose","docker-scan":"docker-scan","dockerd":"dockerd","doctl":"doctl","dog":"dog","doggo":"doggo","doing":"doing","doppler":"doppler","dos2unix":"dos2unix","dot_clean":"dot_clean","dotenv":"dotenv","dotenv-vault":"dotenv-vault","dotnet":"dotnet","dotslash":"dotslash","downgrade":"downgrade","dpkg":"dpkg","dprint":"dprint","drift":"drift","drush":"drush","drutil":"drutil","dscacheutil":"dscacheutil","dscl":"dscl","dseditgroup":"dseditgroup","dsenableroot":"dsenableroot","dsmemberutil":"dsmemberutil","dtm":"dtm","dtrace":"dtrace","dtruss":"dtruss","du":"du","dust":"dust","dyld_info":"dyld_info","dyld_usage":"dyld_usage","eas":"eas","eb":"eb","ebook-convert":"ebook-convert","echo":"echo","egrep":"egrep","electron":"electron","eleventy":"eleventy","elif":"elif","elixir":"elixir","elm":"elm","elm-format":"elm-format","elm-json":"elm-json","elm-review":"elm-review","else":"else","elvish":"elvish","emacs":"emacs","enapter":"enapter","encore":"encore","env":"env","envchain":"envchain","envsubst":"envsubst","eopkg":"eopkg","esbuild":"esbuild","eslint":"eslint","eslogger":"eslogger","exa":"exa","exec":"exec","exercism":"exercism","expand":"expand","expr":"expr","expressots":"expressots","eza":"eza","faas-cli":"faas-cli","factor":"factor","fakechroot":"fakechroot","fakeroot":"fakeroot","fastfetch":"fastfetch","fastlane":"fastlane","fastly":"fastly","fc-cache":"fc-cache","fc-cat":"fc-cat","fc-conflist":"fc-conflist","fc-list":"fc-list","fd":"fd","fddist":"fddist","fdesetup":"fdesetup","fdisk":"fdisk","feh":"feh","ffmpeg":"ffmpeg","ffplay":"ffplay","ffprobe":"ffprobe","fgrep":"fgrep","fig":"fig","figterm":"figterm","file":"file","fileicon":"fileicon","fileproviderctl":"fileproviderctl","fin":"fin","find":"find","finger":"finger","firebase":"firebase","firefox":"firefox","firmwarepasswd":"firmwarepasswd","fish":"fish","fisher":"fisher","fix":"fix","flatpak":"flatpak","flutter":"flutter","fly":"fly","flyctl":"flyctl","fmt":"fmt","fnm":"fnm","fold":"fold","fontrestore":"fontrestore","foot":"foot","footprint":"footprint","for":"for","forc":"forc","fping":"fping","free":"free","freeze":"freeze","fs_usage":"fs_usage","fsck":"fsck","fsck_apfs":"fsck_apfs","fsck_hfs":"fsck_hfs","fstyp":"fstyp","ftp":"ftp","ftpd":"ftpd","fury":"fury","fuser":"fuser","fvm":"fvm","fzf":"fzf","fzf-tmux":"fzf-tmux","g++":"g++","ganache-cli":"ganache-cli","gatsby":"gatsby","gcc":"gcc","gcloud":"gcloud","gdb":"gdb","gdown":"gdown","gdu":"gdu","gem":"gem","get-env":"get-env","gftp":"gftp","gh":"gh","gh-copilot":"gh-copilot","gh-dash":"gh-dash","ghostty":"ghostty","ghq":"ghq","gibo":"gibo","gimp":"gimp","git":"git","git-abort":"git-abort","git-alias":"git-alias","git-archive-file":"git-archive-file","git-authors":"git-authors","git-browse":"git-browse","git-browse-ci":"git-browse-ci","git-brv":"git-brv","git-bulk":"git-bulk","git-changelog":"git-changelog","git-clang-format":"git-clang-format","git-clear":"git-clear","git-clear-soft":"git-clear-soft","git-cliff":"git-cliff","git-coauthor":"git-coauthor","git-commits-since":"git-commits-since","git-continue":"git-continue","git-contrib":"git-contrib","git-count":"git-count","git-cp":"git-cp","git-create-branch":"git-create-branch","git-delete-branch":"git-delete-branch","git-delete-gone-branches":"git-delete-gone-branches","git-delete-merged-branches":"git-delete-merged-branches","git-delete-squashed-branches":"git-delete-squashed-branches","git-delete-submodule":"git-delete-submodule","git-delete-tag":"git-delete-tag","git-delta":"git-delta","git-effort":"git-effort","git-extras":"git-extras","git-feature":"git-feature","git-flow":"git-flow","git-force-clone":"git-force-clone","git-fork":"git-fork","git-fresh-branch":"git-fresh-branch","git-get":"git-get","git-gh-pages":"git-gh-pages","git-graft":"git-graft","git-guilt":"git-guilt","git-ignore":"git-ignore","git-ignore-io":"git-ignore-io","git-info":"git-info","git-local-commits":"git-local-commits","git-lock":"git-lock","git-locked":"git-locked","git-magic":"git-magic","git-merge-into":"git-merge-into","git-merge-repo":"git-merge-repo","git-meta":"git-meta","git-missing":"git-missing","git-mr":"git-mr","git-obliterate":"git-obliterate","git-p4":"git-p4","git-paste":"git-paste","git-pr":"git-pr","git-profile":"git-profile","git-psykorebase":"git-psykorebase","git-pull-request":"git-pull-request","git-quick-stats":"git-quick-stats","git-reauthor":"git-reauthor","git-rebase-patch":"git-rebase-patch","git-release":"git-release","git-rename-branch":"git-rename-branch","git-rename-file":"git-rename-file","git-rename-remote":"git-rename-remote","git-rename-tag":"git-rename-tag","git-repl":"git-repl","git-reset-file":"git-reset-file","git-root":"git-root","git-rscp":"git-rscp","git-scp":"git-scp","git-sed":"git-sed","git-setup":"git-setup","git-show-merged-branches":"git-show-merged-branches","git-show-tree":"git-show-tree","git-show-unmerged-branches":"git-show-unmerged-branches","git-squash":"git-squash","git-stamp":"git-stamp","git-standup":"git-standup","git-summary":"git-summary","git-svn":"git-svn","git-sync":"git-sync","git-touch":"git-touch","git-undo":"git-undo","git-unlock":"git-unlock","git-unwip":"git-unwip","git-utimes":"git-utimes","git-wip":"git-wip","github":"github","gitk":"gitk","gitui":"gitui","glab":"glab","glow":"glow","gltfjsx":"gltfjsx","gm":"gm","gnome-keyring":"gnome-keyring","gnome-keyring-daemon":"gnome-keyring-daemon","gnome-maps":"gnome-maps","gnome-terminal":"gnome-terminal","go":"go","go-carpet":"go-carpet","go-tool-asm":"go-tool-asm","go-tool-buildid":"go-tool-buildid","go-tool-cgo":"go-tool-cgo","go-tool-compile":"go-tool-compile","go-tool-covdata":"go-tool-covdata","go-tool-dist":"go-tool-dist","go-tool-doc":"go-tool-doc","go-tool-link":"go-tool-link","go-tool-mockgen":"go-tool-mockgen","go-tool-objdump":"go-tool-objdump","go-tool-pack":"go-tool-pack","gocryptfs":"gocryptfs","goctl":"goctl","gocyclo":"gocyclo","gofmt":"gofmt","goimports":"goimports","goland":"goland","golangci-lint":"golangci-lint","gonew":"gonew","google-chrome":"google-chrome","googler":"googler","gopls":"gopls","goreleaser":"goreleaser","goto":"goto","gource":"gource","goweight":"goweight","gparted":"gparted","gpasswd":"gpasswd","gpg":"gpg","gpg-agent":"gpg-agent","gpt":"gpt","gradle":"gradle","graphcdn":"graphcdn","grep":"grep","grex":"grex","grit":"grit","gron":"gron","groupadd":"groupadd","groupdel":"groupdel","groupmems":"groupmems","groupmod":"groupmod","groups":"groups","grype":"grype","gsa":"gsa","gt":"gt","gulp":"gulp","gum":"gum","gunzip":"gunzip","gzip":"gzip","halt":"halt","hardhat":"hardhat","hasura":"hasura","hb-service":"hb-service","hdiutil":"hdiutil","head":"head","heap":"heap","helix":"helix","helm":"helm","helmfile":"helmfile","helmsman":"helmsman","herd":"herd","herdr":"herdr","heroku":"heroku","hexchat":"hexchat","hexdump":"hexdump","hexo":"hexo","hidutil":"hidutil","hiutil":"hiutil","hl":"hl","homey":"homey","hop":"hop","host":"host","hostid":"hostid","hostinfo":"hostinfo","hostname":"hostname","htop":"htop","http":"http","https":"https","httpy":"httpy","hugetop":"hugetop","hugo":"hugo","hunspell":"hunspell","hurl":"hurl","hwinfo":"hwinfo","hx":"hx","hyper":"hyper","hyperfine":"hyperfine","i3":"i3","i3-scrot":"i3-scrot","i3exit":"i3exit","i3lock":"i3lock","i3status":"i3status","i3status-rs":"i3status-rs","ibtool":"ibtool","ibus":"ibus","iconutil":"iconutil","iconv":"iconv","id":"id","idea":"idea","identify":"identify","iex":"iex","if":"if","ifconfig":"ifconfig","ignite-cli":"ignite-cli","img2pdf":"img2pdf","imv":"imv","index":"index","infracost":"infracost","inkscape":"inkscape","inshellisense":"inshellisense","install":"install","install_name_tool":"install_name_tool","installer":"installer","ion":"ion","ionic":"ionic","iopattern":"iopattern","iopending":"iopending","ioreg":"ioreg","iosnoop":"iosnoop","iostat":"iostat","iotop":"iotop","ip":"ip","ipatool":"ipatool","ipconfig":"ipconfig","ipcrm":"ipcrm","ipcs":"ipcs","iperf3-darwin":"iperf3-darwin","iqtree3":"iqtree3","jar":"jar","java":"java","javac":"javac","jenv":"jenv","jest":"jest","jj":"jj","jmeter":"jmeter","join":"join","jot":"jot","journalctl":"journalctl","jq":"jq","julia":"julia","jupyter":"jupyter","just":"just","k3d":"k3d","k6":"k6","k9s":"k9s","kafkactl":"kafkactl","kak":"kak","kak-lsp":"kak-lsp","kamal":"kamal","kdestroy":"kdestroy","kdoctor":"kdoctor","kextload":"kextload","kextstat":"kextstat","kextunload":"kextunload","keytool":"keytool","kill":"kill","killall":"killall","kind":"kind","kinit":"kinit","kitten":"kitten","kitty":"kitty","klist":"klist","kmonad":"kmonad","kmutil":"kmutil","knex":"knex","kompose":"kompose","kool":"kool","kotlin":"kotlin","kotlinc":"kotlinc","kswitch":"kswitch","ktlint":"ktlint","ktutil":"ktutil","kubeadm":"kubeadm","kubectl":"kubectl","kubectx":"kubectx","kubens":"kubens","kubeseal":"kubeseal","laravel":"laravel","last":"last","lastb":"lastb","lastlog":"lastlog","lastwords":"lastwords","launchctl":"launchctl","lazygit":"lazygit","ldd":"ldd","leaf":"leaf","leaks":"leaks","lerna":"lerna","less":"less","lf":"lf","light":"light","lightdm":"lightdm","lima":"lima","limactl":"limactl","link":"link","lldb":"lldb","ln":"ln","lnav":"lnav","lncrawl":"lncrawl","locale":"locale","localectl":"localectl","locust":"locust","log":"log","logger":"logger","login":"login","logname":"logname","look":"look","lore":"lore","lp":"lp","lpass":"lpass","ls":"ls","lsappinfo":"lsappinfo","lsb_release":"lsb_release","lsblk":"lsblk","lsclocks":"lsclocks","lscpu":"lscpu","lsd":"lsd","lsfd":"lsfd","lsirq":"lsirq","lslocks":"lslocks","lslogins":"lslogins","lsmem":"lsmem","lsns":"lsns","lsof":"lsof","lsusb":"lsusb","lua":"lua","luz":"luz","lvim":"lvim","lzcat":"lzcat","lzma":"lzma","m":"m","mackup":"mackup","magento":"magento","magick":"magick","maigret":"maigret","mailsy":"mailsy","main":"main","make":"make","makepkg":"makepkg","mamba":"mamba","man":"man","marp":"marp","mas":"mas","mask":"mask","maturin":"maturin","mcomix":"mcomix","mcxquery":"mcxquery","md5":"md5","md5sum":"md5sum","mdbook":"mdbook","mdfind":"mdfind","mdimport":"mdimport","mdls":"mdls","mdutil":"mdutil","meld":"meld","melt":"melt","memory_pressure":"memory_pressure","meroxa":"meroxa","meteor":"meteor","mgnl":"mgnl","micro":"micro","mikro-orm":"mikro-orm","minectl":"minectl","minikube":"minikube","mitmproxy":"mitmproxy","mix":"mix","mkbom":"mkbom","mkcert":"mkcert","mkdir":"mkdir","mkdocs":"mkdocs","mkfifo":"mkfifo","mkfile":"mkfile","mkfs":"mkfs","mkinitcpio":"mkinitcpio","mknod":"mknod","mkswap":"mkswap","mktemp":"mktemp","mob":"mob","modinfo":"modinfo","modprobe":"modprobe","mogrify":"mogrify","molecule":"molecule","mongocli":"mongocli","mongoimport":"mongoimport","mongosh":"mongosh","montage":"montage","more":"more","mosh":"mosh","mount":"mount","mousepad":"mousepad","mpv":"mpv","mtree":"mtree","multipass":"multipass","mv":"mv","mvn":"mvn","mypy":"mypy","mysql":"mysql","n":"n","n-m3u8dl-re":"n-m3u8dl-re","nano":"nano","nc":"nc","ncal":"ncal","ncdu":"ncdu","ncu":"ncu","ndp":"ndp","neofetch":"neofetch","neomutt":"neomutt","nest":"nest","netcat":"netcat","netlify":"netlify","netstat":"netstat","nettop":"nettop","networkQuality":"networkQuality","networkquality":"networkquality","networksetup":"networksetup","newfs_apfs":"newfs_apfs","newfs_hfs":"newfs_hfs","newman":"newman","newrelic":"newrelic","next":"next","nextflow":"nextflow","nfpm":"nfpm","nfsstat":"nfsstat","ng":"ng","nginx":"nginx","ngrok":"ngrok","nh":"nh","nhost":"nhost","ni":"ni","nice":"nice","nilaway":"nilaway","nix":"nix","nix-build":"nix-build","nix-channel":"nix-channel","nix-collect-garbage":"nix-collect-garbage","nix-copy-closure":"nix-copy-closure","nix-env":"nix-env","nix-hash":"nix-hash","nix-install-package":"nix-install-package","nix-instantiate":"nix-instantiate","nix-prefetch-url":"nix-prefetch-url","nix-push":"nix-push","nix-shell":"nix-shell","nix-store":"nix-store","nixops":"nixops","nixos-build-vms":"nixos-build-vms","nixos-container":"nixos-container","nixos-generate-config":"nixos-generate-config","nixos-install":"nixos-install","nixos-option":"nixos-option","nixos-rebuild":"nixos-rebuild","nixos-version":"nixos-version","nl":"nl","nm":"nm","nmap":"nmap","nmcli":"nmcli","node":"node","nohup":"nohup","northflank":"northflank","np":"np","npm":"npm","nproc":"nproc","npx":"npx","nr":"nr","nrm":"nrm","ns":"ns","nslookup":"nslookup","ntpd":"ntpd","nu":"nu","numfmt":"numfmt","nuxi":"nuxi","nuxt":"nuxt","nvim":"nvim","nvm":"nvm","nvram":"nvram","nx":"nx","nylas":"nylas","oci":"oci","od":"od","oh-my-posh":"oh-my-posh","okta":"okta","okteto":"okteto","ollama":"ollama","omz":"omz","onboardbase":"onboardbase","op":"op","opa":"opa","open":"open","openrc":"openrc","openrc-run":"openrc-run","openrc-shutdown":"openrc-shutdown","openscad":"openscad","opensnoop":"opensnoop","openssl":"openssl","optipng":"optipng","orb":"orb","osascript":"osascript","osqueryi":"osqueryi","otool":"otool","oxlint":"oxlint","pac":"pac","packer":"packer","pacman":"pacman","pacman-conf":"pacman-conf","pacman-db-upgrade":"pacman-db-upgrade","pacman-key":"pacman-key","pacman-mirrors":"pacman-mirrors","pageres":"pageres","pagesize":"pagesize","palemoon":"palemoon","palera1n":"palera1n","pamac":"pamac","pandoc":"pandoc","paper":"paper","paru":"paru","pass":"pass","passwd":"passwd","paste":"paste","patch":"patch","pathchk":"pathchk","patool":"patool","pax":"pax","pbcopy":"pbcopy","pbpaste":"pbpaste","pcmanfm":"pcmanfm","pdfattach":"pdfattach","pdfdetach":"pdfdetach","pdffonts":"pdffonts","pdfimages":"pdfimages","pdfinfo":"pdfinfo","pdfseparate":"pdfseparate","pdfsig":"pdfsig","pdftocairo":"pdftocairo","pdftohtml":"pdftohtml","pdftoppm":"pdftoppm","pdftops":"pdftops","pdftotext":"pdftotext","pdfunite":"pdfunite","pdisk":"pdisk","pfctl":"pfctl","pg_dump":"pg_dump","pgcli":"pgcli","pgrep":"pgrep","php":"php","phpstorm":"phpstorm","phpunit":"phpunit","phpunit-watcher":"phpunit-watcher","pi":"pi","picard":"picard","pidof":"pidof","pidwait":"pidwait","pigz":"pigz","pijul":"pijul","ping":"ping","ping6":"ping6","pinky":"pinky","pip":"pip","pip3":"pip3","pipenv":"pipenv","pipx":"pipx","pixi":"pixi","pkg-config":"pkg-config","pkgbuild":"pkgbuild","pkgsite":"pkgsite","pkgutil":"pkgutil","pkill":"pkill","planter":"planter","playwright":"playwright","plockstat":"plockstat","pluginkit":"pluginkit","plutil":"plutil","pm2":"pm2","pmap":"pmap","pmset":"pmset","pngcheck":"pngcheck","pnpm":"pnpm","pnpx":"pnpx","pocketbase":"pocketbase","pod":"pod","podman":"podman","poetry":"poetry","port":"port","powermetrics":"powermetrics","poweroff":"poweroff","powertop":"powertop","pprof":"pprof","pr":"pr","pre-commit":"pre-commit","premake":"premake","present":"present","prettier":"prettier","prettybat":"prettybat","prettyping":"prettyping","printenv":"printenv","prisma":"prisma","pro":"pro","procs":"procs","productbuild":"productbuild","profiles":"profiles","progressline":"progressline","projj":"projj","pry":"pry","ps":"ps","pscale":"pscale","psql":"psql","ptx":"ptx","publish":"publish","pulumi":"pulumi","purge":"purge","pushd":"pushd","pwd":"pwd","pwdx":"pwdx","pwpolicy":"pwpolicy","pwsh":"pwsh","pycharm":"pycharm","pyenv":"pyenv","pytest":"pytest","python":"python","python3":"python3","q":"q","qemu-aarch64":"qemu-aarch64","qemu-aarch64_be":"qemu-aarch64_be","qemu-alpha":"qemu-alpha","qemu-arm":"qemu-arm","qemu-armeb":"qemu-armeb","qemu-edid":"qemu-edid","qemu-hexagon":"qemu-hexagon","qemu-hppa":"qemu-hppa","qemu-i386":"qemu-i386","qemu-img":"qemu-img","qemu-io":"qemu-io","qemu-keymap":"qemu-keymap","qemu-loongarch64":"qemu-loongarch64","qemu-m68k":"qemu-m68k","qemu-microblaze":"qemu-microblaze","qemu-microblazeel":"qemu-microblazeel","qemu-mips":"qemu-mips","qemu-mips64":"qemu-mips64","qemu-mips64el":"qemu-mips64el","qemu-mipsel":"qemu-mipsel","qemu-mipsn32":"qemu-mipsn32","qemu-mipsn32el":"qemu-mipsn32el","qemu-nbd":"qemu-nbd","qemu-or1k":"qemu-or1k","qemu-ppc":"qemu-ppc","qemu-ppc64":"qemu-ppc64","qemu-ppc64le":"qemu-ppc64le","qemu-pr-helper":"qemu-pr-helper","qemu-riscv32":"qemu-riscv32","qemu-riscv64":"qemu-riscv64","qemu-s390x":"qemu-s390x","qemu-sh4":"qemu-sh4","qemu-sh4eb":"qemu-sh4eb","qemu-sparc":"qemu-sparc","qemu-sparc32plus":"qemu-sparc32plus","qemu-sparc64":"qemu-sparc64","qemu-storage-daemon":"qemu-storage-daemon","qemu-system-aarch64":"qemu-system-aarch64","qemu-system-alpha":"qemu-system-alpha","qemu-system-arm":"qemu-system-arm","qemu-system-avr":"qemu-system-avr","qemu-system-hppa":"qemu-system-hppa","qemu-system-i386":"qemu-system-i386","qemu-system-loongarch64":"qemu-system-loongarch64","qemu-system-m68k":"qemu-system-m68k","qemu-system-microblaze":"qemu-system-microblaze","qemu-system-mips":"qemu-system-mips","qemu-system-mips64":"qemu-system-mips64","qemu-system-mips64el":"qemu-system-mips64el","qemu-system-mipsel":"qemu-system-mipsel","qemu-system-or1k":"qemu-system-or1k","qemu-system-ppc":"qemu-system-ppc","qemu-system-ppc64":"qemu-system-ppc64","qemu-system-riscv32":"qemu-system-riscv32","qemu-system-riscv64":"qemu-system-riscv64","qemu-system-rx":"qemu-system-rx","qemu-system-s390x":"qemu-system-s390x","qemu-system-sh4":"qemu-system-sh4","qemu-system-sh4eb":"qemu-system-sh4eb","qemu-system-sparc":"qemu-system-sparc","qemu-system-sparc64":"qemu-system-sparc64","qemu-system-tricore":"qemu-system-tricore","qemu-system-x86_64":"qemu-system-x86_64","qemu-system-xtensa":"qemu-system-xtensa","qemu-system-xtensaeb":"qemu-system-xtensaeb","qemu-vmsr-helper":"qemu-vmsr-helper","qemu-x86_64":"qemu-x86_64","qemu-xtensa":"qemu-xtensa","qemu-xtensaeb":"qemu-xtensaeb","qlmanage":"qlmanage","qmk":"qmk","qodana":"qodana","qpdf":"qpdf","qrencode":"qrencode","quasar":"quasar","quickmail":"quickmail","qutebrowser":"qutebrowser","rails":"rails","railway":"railway","rake":"rake","rancher":"rancher","ranger":"ranger","rbenv":"rbenv","rc-depend":"rc-depend","rc-environ":"rc-environ","rc-service":"rc-service","rc-status":"rc-status","rc-update":"rc-update","rclone":"rclone","react-native":"react-native","readlink":"readlink","realpath":"realpath","reboot":"reboot","redis-cli":"redis-cli","redwood":"redwood","remix":"remix","remotion":"remotion","rename":"rename","renice":"renice","repair_packages":"repair_packages","repeat":"repeat","restic":"restic","resume-cli":"resume-cli","rev":"rev","rg":"rg","rich":"rich","rider":"rider","rifle":"rifle","ripsecrets":"ripsecrets","rm":"rm","rmdir":"rmdir","rmmod":"rmmod","robot":"robot","rojo":"rojo","rollup":"rollup","rome":"rome","route":"route","rsync":"rsync","rubocop":"rubocop","ruby":"ruby","rubymine":"rubymine","ruff":"ruff","rugby":"rugby","run0":"run0","rush":"rush","rushx":"rushx","rust-analyzer":"rust-analyzer","rustc":"rustc","rustdoc":"rustdoc","rustrover":"rustrover","rustup":"rustup","rvm":"rvm","rwsnoop":"rwsnoop","sa":"sa","sake":"sake","sam":"sam","sample":"sample","sanity":"sanity","sapphire":"sapphire","saw":"saw","say":"say","sc_auth":"sc_auth","sc_usage":"sc_usage","scarb":"scarb","scc":"scc","scp":"scp","screen":"screen","screencapture":"screencapture","script":"script","scriptlive":"scriptlive","scriptreplay":"scriptreplay","scrot":"scrot","scutil":"scutil","sd":"sd","sdkmanager":"sdkmanager","security":"security","sed":"sed","semver":"semver","seq":"seq","sequelize":"sequelize","serie":"serie","serve":"serve","serverless":"serverless","set-env":"set-env","sfdx":"sfdx","sfltool":"sfltool","sftp":"sftp","sha1sum":"sha1sum","sha224sum":"sha224sum","sha256sum":"sha256sum","sha384sum":"sha384sum","sha512sum":"sha512sum","shadcn-ui":"shadcn-ui","sharing":"sharing","shasum":"shasum","shelve":"shelve","shopify":"shopify","shortcuts":"shortcuts","showkey":"showkey","shred":"shred","shutdown":"shutdown","sidekiq":"sidekiq","simctl":"simctl","singularity":"singularity","sips":"sips","skhd":"skhd","sl":"sl","slabtop":"slabtop","sleep":"sleep","slides":"slides","sls":"sls","smbutil":"smbutil","snaplet":"snaplet","sntp":"sntp","soft":"soft","softwareupdate":"softwareupdate","sort":"sort","source":"source","space":"space","spctl":"spctl","speedtest":"speedtest","speedtest-cli":"speedtest-cli","spindump":"spindump","splash-cli":"splash-cli","split":"split","spotify":"spotify","spring":"spring","sqlfluff":"sqlfluff","sqlite3":"sqlite3","sqlmesh":"sqlmesh","src":"src","ssh":"ssh","ssh-agent":"ssh-agent","ssh-copy-id":"ssh-copy-id","ssh-keygen":"ssh-keygen","st":"st","st2":"st2","stack":"stack","stapler":"stapler","starkli":"starkli","starship":"starship","start-stop-daemon":"start-stop-daemon","start-storybook":"start-storybook","startosinstall":"startosinstall","stat":"stat","staticcheck":"staticcheck","stdbuf":"stdbuf","steadybit":"steadybit","stencil":"stencil","stow":"stow","streamlit":"streamlit","strings":"strings","stripe":"stripe","stty":"stty","su":"su","subl":"subl","sudo":"sudo","sudoedit":"sudoedit","sudoreplay":"sudoreplay","suitecloud":"suitecloud","sulogin":"sulogin","sum":"sum","supabase":"supabase","supervise-daemon":"supervise-daemon","supervisorctl":"supervisorctl","supervisord":"supervisord","surreal":"surreal","svg-term":"svg-term","svgcleaner":"svgcleaner","svn":"svn","svokit":"svokit","svtplay-dl":"svtplay-dl","sw_vers":"sw_vers","swagger-typescript-api":"swagger-typescript-api","sway":"sway","swaybar":"swaybar","swaybg":"swaybg","swayidle":"swayidle","swaylock":"swaylock","swaymsg":"swaymsg","swaynag":"swaynag","swc":"swc","swift":"swift","swiftc":"swiftc","syft":"syft","symfony":"symfony","sync":"sync","sysadminctl":"sysadminctl","sysctl":"sysctl","sysdiagnose":"sysdiagnose","syslog":"syslog","system_profiler":"system_profiler","systemctl":"systemctl","systemd-analyze":"systemd-analyze","systemextensionsctl":"systemextensionsctl","systemsetup":"systemsetup","tac":"tac","tail":"tail","tailcall":"tailcall","tailscale":"tailscale","tailspin":"tailspin","tailwindcss":"tailwindcss","talk":"talk","tangram":"tangram","taplo":"taplo","tar":"tar","task":"task","taskinfo":"taskinfo","taskpolicy":"taskpolicy","tb":"tb","tccutil":"tccutil","tcpdump":"tcpdump","tea":"tea","tee":"tee","telnet":"telnet","templ":"templ","termux-apt-repo":"termux-apt-repo","terraform":"terraform","terraform-ls":"terraform-ls","terragrunt":"terragrunt","terramate":"terramate","tesseract":"tesseract","textutil":"textutil","tfenv":"tfenv","tfsec":"tfsec","tftp":"tftp","then":"then","tiff2icns":"tiff2icns","tiffutil":"tiffutil","tig":"tig","time":"time","timeout":"timeout","tinygo":"tinygo","tkn":"tkn","tldr":"tldr","tload":"tload","tmate":"tmate","tmutil":"tmutil","tmux":"tmux","tmuxinator":"tmuxinator","tofu":"tofu","toit.lsp":"toit.lsp","toit.pkg":"toit.pkg","tokei":"tokei","top":"top","tor-browser":"tor-browser","tor-gencert":"tor-gencert","tor-print-ed-signing-cert":"tor-print-ed-signing-cert","tor-resolve":"tor-resolve","torsocks":"torsocks","touch":"touch","tox":"tox","tr":"tr","traceroute":"traceroute","traceroute6":"traceroute6","traefik":"traefik","transmission-cli":"transmission-cli","transmission-create":"transmission-create","transmission-daemon":"transmission-daemon","transmission-edit":"transmission-edit","transmission-remote":"transmission-remote","transmission-show":"transmission-show","trap":"trap","trash":"trash","tree":"tree","trex":"trex","trimforce":"trimforce","trivy":"trivy","truffle":"truffle","truncate":"truncate","trunk":"trunk","ts":"ts","ts-node":"ts-node","tsc":"tsc","tsh":"tsh","tshark":"tshark","tsort":"tsort","tsuru":"tsuru","tsx":"tsx","tty":"tty","ttyd":"ttyd","tuist":"tuist","turbo":"turbo","twiggy":"twiggy","twilio":"twilio","typeorm":"typeorm","typos":"typos","typst":"typst","ua":"ua","ubuntu-advantage":"ubuntu-advantage","ufw":"ufw","umount":"umount","uname":"uname","unbrotli":"unbrotli","unexpand":"unexpand","uniq":"uniq","unix2dos":"unix2dos","unlink":"unlink","unlzma":"unlzma","unpigz":"unpigz","unset":"unset","unset-env":"unset-env","until":"until","unxz":"unxz","unzip":"unzip","upower":"upower","uptime":"uptime","upx":"upx","useradd":"useradd","userdel":"userdel","usermod":"usermod","users":"users","uuidgen":"uuidgen","uv":"uv","v":"v","vagrant":"vagrant","vale":"vale","valet":"valet","vapor":"vapor","vault":"vault","vdir":"vdir","vela":"vela","vercel":"vercel","vhs":"vhs","vi":"vi","viewnior":"viewnior","vim":"vim","vimr":"vimr","visudo":"visudo","vite":"vite","viu":"viu","vivid":"vivid","vlc":"vlc","vm_stat":"vm_stat","vmmap":"vmmap","vmstat":"vmstat","volta":"volta","vr":"vr","vsce":"vsce","vsdbutil":"vsdbutil","vtex":"vtex","vue":"vue","vultr-cli":"vultr-cli","w":"w","wait4path":"wait4path","wall":"wall","wasm-bindgen":"wasm-bindgen","wasm-pack":"wasm-pack","watch":"watch","watchexec":"watchexec","watchgnupg":"watchgnupg","watchman":"watchman","watson":"watson","waypoint":"waypoint","wc":"wc","wd":"wd","wdutil":"wdutil","webpack":"webpack","webstorm":"webstorm","wezterm":"wezterm","wg":"wg","wget":"wget","what":"what","whence":"whence","where":"where","whereis":"whereis","which":"which","while":"while","who":"who","whoami":"whoami","whois":"whois","wifi-password":"wifi-password","wine":"wine","wineboot":"wineboot","winepath":"winepath","wineserver":"wineserver","winetricks":"winetricks","wing":"wing","wire":"wire","wireshark":"wireshark","wishlist":"wishlist","wl-mirror":"wl-mirror","woeusb":"woeusb","wp":"wp","wrangler":"wrangler","write":"write","wrk":"wrk","wscat":"wscat","wt":"wt","xar":"xar","xargs":"xargs","xattr":"xattr","xbacklight":"xbacklight","xbps-alternatives":"xbps-alternatives","xbps-checkvers":"xbps-checkvers","xbps-create":"xbps-create","xbps-dgraph":"xbps-dgraph","xbps-digest":"xbps-digest","xbps-fbulk":"xbps-fbulk","xbps-fetch":"xbps-fetch","xbps-install":"xbps-install","xbps-pkgdb":"xbps-pkgdb","xbps-query":"xbps-query","xbps-reconfigure":"xbps-reconfigure","xbps-remove":"xbps-remove","xbps-rindex":"xbps-rindex","xbps-uchroot":"xbps-uchroot","xbps-uhelper":"xbps-uhelper","xbps-uunshare":"xbps-uunshare","xc":"xc","xclip":"xclip","xcode-select":"xcode-select","xcodebuild":"xcodebuild","xcodeproj":"xcodeproj","xcodes":"xcodes","xcrun":"xcrun","xdg-mime":"xdg-mime","xdg-open":"xdg-open","xdotool":"xdotool","xed":"xed","xh":"xh","xip":"xip","xmllint":"xmllint","xonsh":"xonsh","xprotect":"xprotect","xsltproc":"xsltproc","xxd":"xxd","xxhsum":"xxhsum","xz":"xz","xzcat":"xzcat","yalc":"yalc","yank":"yank","yarn":"yarn","yay":"yay","yes":"yes","yj":"yj","ykman":"ykman","yo":"yo","yomo":"yomo","youtube-dl":"youtube-dl","yt-dlp":"yt-dlp","z":"z","zapier-platform-cli":"zapier-platform-cli","zathura":"zathura","zcat":"zcat","zed":"zed","zellij":"zellij","zfs":"zfs","zig":"zig","zip":"zip","zipcloak":"zipcloak","zoxide":"zoxide","zpaq":"zpaq","zpool":"zpool","zprint":"zprint"},"entries":{"7z":[0,1166],"@commercelayer/cli":[1166,10578],"@forge/cli":[11744,1456],"R":[13200,1320],"Rscript":[14520,443],"StepZen":[14963,494],"acpi":[15457,469],"acpid":[15926,717],"act":[16643,1681],"actool":[18324,994],"adb":[19318,2842],"adr":[22160,308],"afconvert":[22468,898],"afinfo":[23366,212],"afplay":[23578,551],"aftman":[24129,479],"ag":[24608,2602],"age":[27210,351],"agg":[27561,726],"agrippa":[28287,1647],"agvtool":[29934,405],"agy":[30339,1471],"airflow":[31810,8158],"airport":[39968,45],"aliases":[40013,750],"alsamixer":[40763,359],"amplify":[41122,1514],"ampx":[42636,1158],"anchor":[43794,6693],"ansible":[50487,1325],"ansible-config":[51812,695],"ansible-doc":[52507,831],"ansible-galaxy":[53338,2668],"ansible-lint":[56006,1092],"ansible-playbook":[57098,1279],"ant":[58377,925],"apfsctl":[59302,161],"apk":[59463,2262],"aplay":[61725,1006],"apropos":[62731,462],"apt":[63193,1671],"apt-cache":[64864,942],"apt-get":[65806,1882],"ar":[67688,62],"arch":[67750,274],"archiveutil":[68024,80],"archlinux-java":[68104,265],"arduino-cli":[68369,4573],"arecord":[72942,79],"argo":[73021,5211],"aria2c":[78232,5629],"arp":[83861,163],"artisan":[84024,10062],"asar":[94086,286],"asciinema":[94372,1186],"asdf":[95558,968],"asr":[96526,1386],"assetutil":[97912,337],"assimp":[98249,1213],"astro":[99462,668],"at":[100130,296],"atlas":[100426,12202],"atlas help":[112628,4207],"atlas privateendpoints":[116835,1342],"atos":[118177,193],"atuin":[118370,5063],"authchanger":[123433,617],"autocannon":[124050,1777],"autoconf":[125827,405],"autojump":[126232,286],"automator":[126518,125],"automount":[126643,99],"auval":[126742,250],"avconvert":[126992,362],"avdmanager":[127354,730],"avmediainfo":[128084,131],"awk":[128215,383],"aws":[128598,98124],"aws accessanalyzer":[226722,4932],"aws account":[231654,2179],"aws acm":[233833,4727],"aws acm-pca":[238560,6242],"aws amp":[244802,4175],"aws amplify":[248977,6349],"aws amplifybackend":[255326,2121],"aws amplifyuibuilder":[257447,2359],"aws apigateway":[259806,14292],"aws apigatewayv2":[274098,10510],"aws appconfig":[284608,7270],"aws appfabric":[291878,3376],"aws appflow":[295254,4593],"aws appintegrations":[299847,2617],"aws application-autoscaling":[302464,4440],"aws application-insights":[306904,4637],"aws application-signals":[311541,3449],"aws appmesh":[314990,4411],"aws apprunner":[319401,5417],"aws appstream":[324818,12716],"aws appsync":[337534,10921],"aws apptest":[348455,1976],"aws arc-zonal-shift":[350431,3402],"aws athena":[353833,8997],"aws auditmanager":[362830,5329],"aws autoscaling":[368159,16137],"aws b2bi":[384296,4618],"aws backup":[388914,13764],"aws backup-gateway":[402678,3090],"aws batch":[405768,7713],"aws bcm-data-exports":[413481,1925],"aws bcm-pricing-calculator":[415406,3152],"aws bedrock":[418558,8546],"aws bedrock-agent":[427104,8970],"aws billingconductor":[436074,4797],"aws braket":[440871,2522],"aws budgets":[443393,3381],"aws ce":[446774,9844],"aws chatbot":[456618,4533],"aws chime":[461151,17534],"aws chime-sdk-identity":[478685,2713],"aws chime-sdk-media-pipelines":[481398,3049],"aws chime-sdk-meetings":[484447,2696],"aws chime-sdk-messaging":[487143,6138],"aws chime-sdk-voice":[493281,8029],"aws cleanrooms":[501310,7104],"aws cleanroomsml":[508414,5911],"aws cloud9":[514325,2819],"aws cloudcontrol":[517144,2595],"aws clouddirectory":[519739,7680],"aws cloudformation":[527419,25379],"aws cloudfront":[552798,12025],"aws cloudhsm":[564823,2149],"aws cloudhsmv2":[566972,3739],"aws cloudsearch":[570711,3634],"aws cloudtrail":[574345,12703],"aws cloudwatch":[587048,14173],"aws codeartifact":[601221,8528],"aws codebuild":[609749,11562],"aws codecatalyst":[621311,4392],"aws codecommit":[625703,11644],"aws codeconnections":[637347,3360],"aws codeguru-reviewer":[640707,3477],"aws codeguru-security":[644184,2805],"aws codeguruprofiler":[646989,5055],"aws codepipeline":[652044,6963],"aws codestar-connections":[659007,3319],"aws codestar-notifications":[662326,2657],"aws cognito-identity":[664983,4827],"aws cognito-idp":[669810,25102],"aws cognito-sync":[694912,2639],"aws comprehend":[697551,11319],"aws comprehendmedical":[708870,2546],"aws compute-optimizer":[711416,5893],"aws configservice":[717309,14532],"aws connect":[731841,35018],"aws connectcampaigns":[766859,1890],"aws connectcampaignsv2":[768749,2260],"aws connectcases":[771009,4643],"aws controltower":[775652,3659],"aws customer-profiles":[779311,10423],"aws databrew":[789734,6315],"aws dataexchange":[796049,3589],"aws datapipeline":[799638,5033],"aws datasync":[804671,13094],"aws datazone":[817765,14999],"aws dax":[832764,4514],"aws deadline":[837278,10522],"aws deploy":[847800,9342],"aws detective":[857142,3745],"aws devicefarm":[860887,9314],"aws devops-guru":[870201,4110],"aws directconnect":[874311,7868],"aws discovery":[882179,5095],"aws dms":[887274,20902],"aws dms wait":[908176,1634],"aws docdb":[909810,15110],"aws docdb-elastic":[924920,3609],"aws drs":[928529,6450],"aws ds":[934979,9258],"aws ds-data":[944237,2736],"aws dynamodb":[946973,15907],"aws ec2":[962880,85687],"aws ec2 wait":[1048567,5479],"aws ecr":[1054046,9087],"aws ecr-public":[1063133,4315],"aws ecs":[1067448,20418],"aws efs":[1087866,6852],"aws eks":[1094718,13111],"aws elasticache":[1107829,21011],"aws elasticbeanstalk":[1128840,9707],"aws elastictranscoder":[1138547,3990],"aws elb":[1142537,4715],"aws elbv2":[1147252,8891],"aws emr":[1156143,13439],"aws emr-containers":[1169582,3737],"aws emr-serverless":[1173319,3242],"aws entityresolution":[1176561,4746],"aws es":[1181307,6676],"aws events":[1187983,8861],"aws evidently":[1196844,7422],"aws finspace":[1204266,6971],"aws finspace-data":[1211237,4783],"aws firehose":[1216020,3394],"aws fis":[1219414,1862],"aws fms":[1221276,5288],"aws forecast":[1226564,10052],"aws frauddetector":[1236616,7954],"aws fsx":[1244570,10513],"aws gamelift":[1255083,30185],"aws geo-places":[1285268,2258],"aws geo-routes":[1287526,2774],"aws glacier":[1290300,6065],"aws globalaccelerator":[1296365,8377],"aws glue":[1304742,34683],"aws grafana":[1339425,6129],"aws greengrass":[1345554,6296],"aws greengrassv2":[1351850,4594],"aws groundstation":[1356444,4359],"aws guardduty":[1360803,9646],"aws health":[1370449,2929],"aws healthlake":[1373378,1963],"aws iam":[1375341,27841],"aws identitystore":[1403182,2942],"aws imagebuilder":[1406124,9852],"aws inspector":[1415976,4360],"aws inspector2":[1420336,5567],"aws internetmonitor":[1425903,4040],"aws iot":[1429943,38353],"aws iot1click-devices":[1468296,2033],"aws iot1click-projects":[1470329,2400],"aws iotanalytics":[1472729,5245],"aws iotdeviceadvisor":[1477974,1609],"aws iotevents":[1479583,2731],"aws iotfleetwise":[1482314,7653],"aws iotsitewise":[1489967,14013],"aws iotthingsgraph":[1503980,5084],"aws iottwinmaker":[1509064,3904],"aws iotwireless":[1512968,9318],"aws ivs":[1522286,6098],"aws ivs-realtime":[1528384,4617],"aws ivschat":[1533001,2789],"aws kafka":[1535790,4878],"aws kafkaconnect":[1540668,2360],"aws kendra":[1543028,12857],"aws keyspaces":[1555885,4482],"aws kinesis":[1560367,6986],"aws kinesis-video-archived-media":[1567353,3593],"aws kinesisanalytics":[1570946,3007],"aws kinesisanalyticsv2":[1573953,5573],"aws kinesisvideo":[1579526,5160],"aws kms":[1584686,15980],"aws lakeformation":[1600666,8966],"aws lambda":[1609632,15639],"aws launch-wizard":[1625271,2055],"aws lex-models":[1627326,8800],"aws lexv2-models":[1636126,16407],"aws license-manager":[1652533,6049],"aws license-manager-linux-subscriptions":[1658582,2017],"aws license-manager-user-subscriptions":[1660599,2563],"aws lightsail":[1663162,26093],"aws location":[1689255,11071],"aws logs":[1700326,17264],"aws lookoutequipment":[1717590,7069],"aws lookoutmetrics":[1724659,3097],"aws lookoutvision":[1727756,4500],"aws m2":[1732256,4891],"aws machinelearning":[1737147,5457],"aws machinelearning wait":[1742604,1956],"aws macie2":[1744560,9377],"aws mailmanager":[1753937,4637],"aws managedblockchain":[1758574,3978],"aws managedblockchain-query":[1762552,2495],"aws marketplace-catalog":[1765047,2941],"aws mediaconnect":[1767988,6686],"aws mediaconvert":[1774674,5733],"aws medialive":[1780407,11447],"aws medialive wait":[1791854,1145],"aws mediapackage":[1792999,2441],"aws mediapackage-vod":[1795440,1832],"aws mediapackagev2":[1797272,5138],"aws mediastore":[1802410,3210],"aws mediatailor":[1805620,5879],"aws medical-imaging":[1811499,2818],"aws memorydb":[1814317,8032],"aws mgh":[1822349,3200],"aws mgn":[1825549,6690],"aws migration-hub-refactor-spaces":[1832239,3277],"aws migrationhuborchestrator":[1835516,2840],"aws migrationhubstrategy":[1838356,3001],"aws mq":[1841357,3929],"aws mturk":[1845286,9003],"aws mwaa":[1854289,3632],"aws neptune":[1857921,19105],"aws neptune-graph":[1877026,5977],"aws neptunedata":[1883003,8450],"aws network-firewall":[1891453,6429],"aws networkflowmonitor":[1897882,3457],"aws networkmanager":[1901339,7710],"aws networkmonitor":[1909049,2414],"aws notifications":[1911463,3003],"aws oam":[1914466,2845],"aws omics":[1917311,7926],"aws omics wait":[1925237,1070],"aws opensearch":[1926307,10158],"aws opensearchserverless":[1936465,3795],"aws opsworks":[1940260,15289],"aws opsworks-cm":[1955549,6062],"aws opsworkscm":[1961611,6063],"aws organizations":[1967674,9510],"aws osis":[1977184,1779],"aws outposts":[1978963,4522],"aws panorama":[1983485,2662],"aws partnercentral-selling":[1986147,10152],"aws payment-cryptography":[1996299,4241],"aws payment-cryptography-data":[2000540,3388],"aws pca-connector-ad":[2003928,2924],"aws pca-connector-scep":[2006852,2219],"aws pcs":[2009071,3491],"aws personalize":[2012562,9498],"aws pi":[2022060,3943],"aws pinpoint":[2026003,6492],"aws pinpoint-email":[2032495,6193],"aws pinpoint-sms-voice-v2":[2038688,12723],"aws polly":[2051411,3274],"aws privatenetworks":[2054685,3283],"aws proton":[2057968,11118],"aws qapps":[2069086,3944],"aws qbusiness":[2073030,11217],"aws qconnect":[2084247,10559],"aws qldb":[2094806,3883],"aws quicksight":[2098689,28024],"aws ram":[2126713,6828],"aws rds":[2133541,39966],"aws rds create-db-cluster":[2173507,4770],"aws rds create-db-instance":[2178277,5760],"aws rds create-db-instance-read-replica":[2184037,4374],"aws rds modify-db-cluster":[2188411,4279],"aws rds modify-db-instance":[2192690,5689],"aws rds restore-db-instance-from-s3":[2198379,4227],"aws rds restore-db-instance-to-point-in-time":[2202606,4243],"aws rds wait":[2206849,2827],"aws redshift":[2209676,30778],"aws redshift wait":[2240454,2174],"aws redshift-data":[2242628,3424],"aws redshift-serverless":[2246052,8991],"aws rekognition":[2255043,17515],"aws repostspace":[2272558,2135],"aws resiliencehub":[2274693,7985],"aws resource-explorer-2":[2282678,4195],"aws resource-groups":[2286873,4452],"aws resourcegroupstaggingapi":[2291325,2895],"aws robomaker":[2294220,4868],"aws rolesanywhere":[2299088,3593],"aws route53":[2302681,13120],"aws route53-recovery-control-config":[2315801,2837],"aws route53-recovery-readiness":[2318638,3015],"aws route53domains":[2321653,5955],"aws route53profiles":[2327608,2308],"aws route53resolver":[2329916,12136],"aws rum":[2342052,3648],"aws s3":[2345700,4139],"aws s3api":[2349839,29073],"aws s3control":[2378912,12947],"aws s3tables":[2391859,2097],"aws sagemaker":[2393956,60809],"aws sagemaker-geospatial":[2454765,2517],"aws scheduler":[2457282,2600],"aws schemas":[2459882,2625],"aws sdb":[2462507,2667],"aws secretsmanager":[2465174,6860],"aws security-ir":[2472034,2940],"aws securityhub":[2474974,11423],"aws securitylake":[2486397,4853],"aws serverlessrepo":[2491250,2596],"aws service-quotas":[2493846,2514],"aws servicecatalog":[2496360,12407],"aws servicecatalog-appregistry":[2508767,3387],"aws servicediscovery":[2512154,4812],"aws ses":[2516966,10110],"aws sesv2":[2527076,11928],"aws shield":[2539004,5298],"aws signer":[2544302,4059],"aws simspaceweaver":[2548361,2286],"aws sms":[2550647,3867],"aws snow-device-management":[2554514,2087],"aws snowball":[2556601,6178],"aws sns":[2562779,7485],"aws socialmessaging":[2570264,2509],"aws sqs":[2572773,6078],"aws ssm":[2578851,34563],"aws ssm-contacts":[2613414,5395],"aws ssm-incidents":[2618809,5282],"aws ssm-quicksetup":[2624091,1750],"aws ssm-sap":[2625841,2581],"aws sso-admin":[2628422,7791],"aws stepfunctions":[2636213,8296],"aws storagegateway":[2644509,19691],"aws sts":[2664200,4182],"aws supplychain":[2668382,3550],"aws support":[2671932,3925],"aws swf":[2675857,8028],"aws synthetics":[2683885,4261],"aws taxsettings":[2688146,2060],"aws textract":[2690206,5104],"aws timestream-influxdb":[2695310,2590],"aws timestream-query":[2697900,3800],"aws timestream-write":[2701700,3106],"aws tnb":[2704806,3316],"aws transcribe":[2708122,8825],"aws transfer":[2716947,14696],"aws translate":[2731643,4032],"aws trustedadvisor":[2735675,1684],"aws verifiedpermissions":[2737359,4898],"aws voice-id":[2742257,3738],"aws vpc-lattice":[2745995,6729],"aws waf":[2752724,6646],"aws waf-regional":[2759370,6624],"aws wafv2":[2765994,8875],"aws wellarchitected":[2774869,7210],"aws wisdom":[2782079,5853],"aws workdocs":[2787932,6016],"aws workmail":[2793948,11235],"aws workspaces":[2805183,9983],"aws workspaces-thin-client":[2815166,2241],"aws workspaces-web":[2817407,5436],"aws xray":[2822843,5755],"aws-vault":[2828598,616],"awsume":[2829214,829],"az":[2830043,79408],"az acr":[2909451,11709],"az acr manifest":[2921160,1223],"az acr task":[2922383,3184],"az ad":[2925567,4058],"az ad app":[2929625,3360],"az adp":[2932985,2006],"az afd":[2934991,5840],"az afd rule":[2940831,2440],"az aks":[2943271,9578],"az aks create":[2952849,5002],"az aks nodepool":[2957851,3097],"az ams":[2960948,10685],"az ams account":[2971633,2463],"az apim":[2974096,3571],"az apim api":[2977667,3414],"az appconfig":[2981081,2878],"az appconfig feature":[2983959,1960],"az appconfig kv":[2985919,3289],"az appservice":[2989208,3702],"az arcappliance":[2992910,4501],"az arcdata dc":[2997411,3623],"az automanage configuration-profile-assignment":[3001034,1233],"az automation":[3002267,3493],"az azurestackhci":[3005760,1127],"az azurestackhci virtualmachine":[3006887,1988],"az backup":[3008875,6701],"az backup protection":[3015576,2156],"az batch":[3017732,3324],"az batch job":[3021056,3127],"az batch job-schedule":[3024183,3380],"az batch node":[3027563,2929],"az batch pool":[3030492,4994],"az batch task":[3035486,2503],"az batchai":[3037989,3705],"az billing":[3041694,3895],"az blueprint":[3045589,3088],"az boards":[3048677,2409],"az bot":[3051086,3385],"az cdn":[3054471,2770],"az cdn endpoint":[3057241,2252],"az cdn endpoint rule":[3059493,2008],"az cdn waf policy":[3061501,1432],"az cloud-service":[3062933,2634],"az cognitiveservices":[3065567,1891],"az communication":[3067458,4226],"az connectedmachine":[3071684,3563],"az connectedvmware":[3075247,1291],"az connectedvmware vm":[3076538,2503],"az connection create":[3079041,1651],"az connection update":[3080692,1132],"az consumption":[3081824,2347],"az containerapp":[3084171,9698],"az containerapp auth":[3093869,2137],"az containerapp connection create":[3096006,2041],"az containerapp connection update":[3098047,1528],"az containerapp env":[3099575,2912],"az containerapp ingress":[3102487,1666],"az containerapp job":[3104153,2664],"az cosmosdb":[3106817,8851],"az cosmosdb mongodb":[3115668,2232],"az cosmosdb postgres":[3117900,1828],"az cosmosdb postgres cluster":[3119728,2108],"az cosmosdb sql":[3121836,3494],"az databoxedge":[3125330,2687],"az databricks workspace":[3128017,3065],"az datadog":[3131082,2164],"az datafactory":[3133246,4827],"az datafactory integration-runtime":[3138073,1931],"az datamigration":[3140004,4256],"az dataprotection":[3144260,4382],"az dataprotection backup-instance":[3148642,4154],"az datashare":[3152796,3205],"az deployment":[3156001,2490],"az devcenter admin":[3158491,5092],"az devcenter dev":[3163583,1587],"az devops":[3165170,5273],"az disk":[3170443,3787],"az disk-pool":[3174230,1807],"az dla account":[3176037,2185],"az dla catalog":[3178222,2031],"az dls account":[3180253,1933],"az dls fs":[3182186,1757],"az dns-resolver":[3183943,2528],"az dt":[3186471,6371],"az dynatrace monitor":[3192842,1846],"az edgeorder":[3194688,2322],"az elastic monitor":[3197010,2208],"az elastic-san":[3199218,2507],"az eventgrid":[3201725,3481],"az eventgrid domain":[3205206,3495],"az eventgrid partner":[3208701,3707],"az eventgrid partner namespace":[3212408,2314],"az eventgrid topic":[3214722,3442],"az eventhubs":[3218164,3242],"az eventhubs eventhub":[3221406,3091],"az eventhubs namespace":[3224497,4835],"az fleet":[3229332,1955],"az footprint":[3231287,1512],"az functionapp":[3232799,5584],"az functionapp config":[3238383,3210],"az functionapp connection create":[3241593,1966],"az functionapp connection update":[3243559,1432],"az functionapp deployment":[3244991,2315],"az grafana":[3247306,3103],"az guestconfig":[3250409,1329],"az hdinsight":[3251738,4327],"az healthcareapis":[3256065,2210],"az healthcareapis workspace":[3258275,2959],"az image builder":[3261234,4351],"az iot":[3265585,4411],"az iot central":[3269996,7249],"az iot central device":[3277245,2677],"az iot device":[3279922,2828],"az iot dps":[3282750,3649],"az iot dps enrollment-group":[3286399,1926],"az iot du":[3288325,3575],"az iot du device":[3291900,2907],"az iot hub":[3294807,11740],"az iot hub device-identity":[3306547,2609],"az iot hub message-endpoint":[3309156,1709],"az k8s-configuration flux":[3310865,2170],"az k8s-extension":[3313035,1841],"az keyvault":[3314876,9197],"az keyvault key":[3324073,2927],"az kusto":[3327000,4074],"az kusto data-connection":[3331074,1969],"az lab":[3333043,2507],"az load":[3335550,3415],"az logz":[3338965,2303],"az maintenance":[3341268,1899],"az maintenance assignment":[3343167,1455],"az mariadb server":[3344622,3469],"az mesh":[3348091,1651],"az ml":[3349742,7671],"az ml computetarget":[3357413,2843],"az ml datastore":[3360256,1774],"az ml endpoint realtime":[3362030,2751],"az ml model":[3364781,4217],"az mobile-network":[3368998,3904],"az mobile-network sim":[3372902,2377],"az monitor":[3375279,10395],"az monitor app-insights":[3385674,3109],"az monitor app-insights component":[3388783,2453],"az monitor data-collection rule":[3391236,2046],"az monitor log-analytics":[3393282,2811],"az monitor log-analytics workspace":[3396093,4561],"az monitor private-link-scope":[3400654,1792],"az mysql flexible-server":[3402446,5979],"az mysql server":[3408425,4146],"az netappfiles":[3412571,3241],"az netappfiles account":[3415812,3252],"az netappfiles account backup-vault":[3419064,1958],"az netappfiles snapshot":[3421022,1597],"az netappfiles volume":[3422619,6746],"az network":[3429365,15888],"az network alb":[3445253,1522],"az network application-gateway":[3446775,9016],"az network application-gateway rewrite-rule":[3455791,1814],"az network application-gateway waf-policy":[3457605,3604],"az network cross-region-lb":[3461209,2807],"az network dns record-set":[3464016,3574],"az network express-route":[3467590,3135],"az network express-route gateway":[3470725,1861],"az network express-route peering":[3472586,2182],"az network firewall":[3474768,3547],"az network firewall policy":[3478315,2765],"az network firewall policy rule-collection-group collection":[3481080,1612],"az network front-door":[3482692,5730],"az network front-door waf-policy":[3488422,2499],"az network lb":[3490921,4545],"az network lb address-pool":[3495466,2006],"az network manager":[3497472,3804],"az network manager security-admin-config rule-collection":[3501276,1890],"az network nic":[3503166,2839],"az network nsg":[3506005,2080],"az network perimeter":[3508085,2724],"az network private-dns record-set":[3510809,2502],"az network private-endpoint":[3513311,2301],"az network public-ip":[3515612,2372],"az network vhub":[3517984,3909],"az network virtual-appliance":[3521893,2078],"az network vnet":[3523971,3430],"az network vnet-gateway":[3527401,4341],"az network vpn-connection":[3531742,2545],"az network vpn-gateway connection":[3534287,2257],"az network watcher":[3536544,4137],"az network watcher connection-monitor":[3540681,3219],"az networkcloud":[3543900,2857],"az networkcloud baremetalmachine":[3546757,1843],"az networkcloud cluster":[3548600,2593],"az networkcloud kubernetescluster":[3551193,2403],"az networkcloud virtualmachine":[3553596,2245],"az networkfabric":[3555841,5939],"az new-relic":[3561780,1685],"az nginx deployment":[3563465,1951],"az notification-hub":[3565416,3068],"az orbital":[3568484,2602],"az palo-alto cloudngfw local-rulestack":[3571086,3166],"az peering":[3574252,1713],"az pipelines":[3575965,4809],"az policy":[3580774,3404],"az postgres":[3584178,3486],"az postgres flexible-server":[3587664,6125],"az postgres server":[3593789,4065],"az providerhub":[3597854,3403],"az quantum":[3601257,2024],"az redis":[3603281,2798],"az redisenterprise":[3606079,3136],"az relay":[3609215,2608],"az repos":[3611823,2483],"az repos policy":[3614306,2243],"az reservations":[3616549,3122],"az resource":[3619671,2435],"az resource-mover move-collection":[3622106,2309],"az scvmm":[3624415,1676],"az scvmm vm":[3626091,3100],"az search":[3629191,2494],"az security":[3631685,4997],"az sentinel":[3636682,6835],"az sentinel incident":[3643517,2005],"az sentinel threat-indicator":[3645522,1787],"az servicebus":[3647309,3524],"az servicebus namespace":[3650833,3706],"az servicebus queue":[3654539,2324],"az servicebus topic subscription":[3656863,2656],"az sf":[3659519,7573],"az sf managed-service":[3667092,2223],"az sig":[3669315,4106],"az sig image-version":[3673421,3091],"az signalr":[3676512,2200],"az site-recovery":[3678712,4824],"az sphere":[3683536,2071],"az sphere device":[3685607,3845],"az spring":[3689452,9167],"az spring app":[3698619,5941],"az spring connection create":[3704560,2206],"az spring connection update":[3706766,1703],"az spring-cloud":[3708469,6019],"az spring-cloud app":[3714488,3825],"az spring-cloud connection create":[3718313,2188],"az spring-cloud connection update":[3720501,1688],"az sql":[3722189,5764],"az sql db":[3727953,7986],"az sql mi":[3735939,5713],"az sql mi-arc":[3741652,4242],"az sql midb":[3745894,3767],"az sql server":[3749661,4807],"az sql vm":[3754468,4201],"az stack-hci":[3758669,2657],"az staticwebapp":[3761326,3485],"az storage":[3764811,7784],"az storage account":[3772595,12011],"az storage blob":[3784606,13052],"az storage blob directory":[3797658,3515],"az storage container":[3801173,4849],"az storage directory":[3806022,1360],"az storage file":[3807382,4487],"az storage fs":[3811869,3936],"az storage fs directory":[3815805,2655],"az storage fs file":[3818460,2769],"az storage queue":[3821229,2137],"az storage share":[3823366,3109],"az storage table":[3826475,1838],"az storage-mover":[3828313,2886],"az storagesync":[3831199,1827],"az stream-analytics":[3833026,2926],"az stream-analytics job":[3835952,2678],"az synapse":[3838630,8496],"az synapse kusto":[3847126,2855],"az synapse kusto data-connection":[3849981,1741],"az synapse sql pool":[3851722,3016],"az synapse workspace":[3854738,2721],"az tsi":[3857459,2743],"az vm":[3860202,13670],"az vm create":[3873872,5617],"az vm host":[3879489,2323],"az vmss":[3881812,8619],"az vmss create":[3890431,6651],"az vmware":[3897082,4065],"az vmware addon":[3901147,1324],"az vmware private-cloud":[3902471,2795],"az vmware workload-network":[3905266,2982],"az webapp":[3908248,8529],"az webapp auth":[3916777,2478],"az webapp config":[3919255,4668],"az webapp connection create":[3923923,2067],"az webapp connection update":[3925990,1529],"az webapp deployment":[3927519,2205],"az webpubsub":[3929724,2213],"az workloads":[3931937,2487],"az workloads monitor":[3934424,1947],"babel":[3936371,1388],"banner":[3937759,167],"baobab":[3937926,217],"barnard59":[3938143,1057],"base32":[3939200,316],"base64":[3939516,383],"basename":[3939899,252],"basenc":[3940151,548],"bash":[3940699,572],"bash-language-server":[3941271,114],"bat":[3941385,1511],"batdiff":[3942896,289],"batgrep":[3943185,495],"batman":[3943680,65],"bats":[3943745,215],"bazel":[3943960,4110],"bazel aquery":[3948070,9616],"bazel build":[3957686,9186],"bazel canonicalize-flags":[3966872,9397],"bazel clean":[3976269,9366],"bazel config":[3985635,9251],"bazel coverage":[3994886,9424],"bazel cquery":[4004310,9901],"bazel fetch":[4014211,9573],"bazel info":[4023784,9284],"bazel mobile-install":[4033068,9749],"bazel mod":[4042817,5431],"bazel print_action":[4048248,9234],"bazel query":[4057482,5504],"bazel run":[4062986,9683],"bazel test":[4072669,9432],"bazel vendor":[4082101,9482],"bc":[4091583,299],"bcd":[4091882,228],"benthos":[4092110,2060],"bit":[4094170,12102],"black":[4106272,953],"bless":[4107225,239],"blitz":[4107464,3130],"bloop":[4110594,1298],"bluetoothctl":[4111892,698],"blueutil":[4112590,493],"bosh":[4113083,1925],"bputil":[4115008,263],"brctl":[4115271,663],"brew":[4115934,17500],"broot":[4133434,1396],"brotli":[4134830,618],"browser-sync":[4135448,1406],"bru":[4136854,400],"btop":[4137254,573],"build-storybook":[4137827,797],"buildctl":[4138624,1405],"bun":[4140029,6128],"bundle":[4146157,2639],"bunx":[4148796,362],"but":[4149158,5711],"bw":[4154869,4334],"bwdc":[4159203,977],"bws":[4160180,1282],"c++":[4161462,20891],"caffeinate":[4182353,466],"cal":[4182819,633],"calibre":[4183452,377],"cap":[4183829,679],"capacitor":[4184508,680],"capslock":[4185188,265],"cargo":[4185453,5648],"cargo-clippy":[4191101,364],"cargo-fmt":[4191465,332],"cargo-rm":[4191797,262],"cargo-set-version":[4192059,407],"cargo-upgrade":[4192466,445],"cargo-watch":[4192911,972],"cat":[4193883,638],"cci":[4194521,3144],"cd":[4197665,108],"cdk":[4197773,536],"cdk8s":[4198309,653],"cf":[4198962,4406],"cfdisk":[4203368,294],"charm":[4203662,1286],"chcpu":[4204948,201],"chdman":[4205149,1189],"checkov":[4206338,3355],"cheese":[4209693,261],"chezmoi":[4209954,2860],"chezmoi git":[4212814,30667],"chflags":[4243481,398],"chgrp":[4243879,579],"chmod":[4244458,936],"chown":[4245394,706],"chpass":[4246100,151],"chpasswd":[4246251,322],"chroma":[4246573,760],"chromium":[4247333,35],"chroot":[4247368,241],"chsh":[4247609,170],"cicada":[4247779,596],"circleci":[4248375,2839],"cksum":[4251214,743],"clamav-config":[4251957,191],"clamav-milter":[4252148,141],"clambc":[4252289,407],"clamconf":[4252696,210],"clamd":[4252906,208],"clamdscan":[4253114,705],"clamdtop":[4253819,190],"clamonacc":[4254009,595],"clamscan":[4254604,2052],"clamsubmit":[4256656,251],"clang":[4256907,21047],"clang++":[4277954,20903],"clear":[4298857,194],"cliff-jumper":[4299051,697],"clilol":[4299748,1825],"clion":[4301573,33],"clojure":[4301606,765],"cloudflared":[4302371,1906],"cloudflared tunnel":[4304277,5497],"cmake":[4309774,3950],"cmus":[4313724,217],"coda":[4313941,1134],"code":[4315075,1712],"code-insiders":[4316787,1719],"codecov":[4318506,688],"codesign":[4319194,1596],"comm":[4320790,311],"command":[4321101,87],"compare":[4321188,95],"composer":[4321283,37],"composite":[4321320,56],"compress":[4321376,153],"compression_tool":[4321529,298],"conda":[4321827,4813],"conda-content-trust":[4326640,323],"conda-env":[4326963,993],"conky":[4327956,566],"consul":[4328522,8360],"copilot":[4336882,4237],"copilot help":[4341119,4091],"copyfile":[4345210,102],"copypath":[4345312,114],"cordova":[4345426,2241],"coredumpctl":[4347667,675],"cosign":[4348342,3664],"cosign help":[4352006,2588],"cot":[4354594,280],"cover":[4354874,329],"cp":[4355203,1299],"cpio":[4356502,408],"create-completion-spec":[4356910,177],"create-next-app":[4357087,440],"create-nx-workspace":[4357527,913],"create-react-app":[4358440,432],"create-react-native-app":[4358872,384],"create-redwood-app":[4359256,337],"create-remix":[4359593,92],"create-t3-app":[4359685,573],"create-video":[4360258,65],"create-vite":[4360323,105],"create-web3-frontend":[4360428,247],"createinstallmedia":[4360675,207],"croc":[4360882,890],"crontab":[4361772,150],"crush":[4361922,1060],"csdx":[4362982,1732],"csplit":[4364714,346],"csreq":[4365060,203],"csrutil":[4365263,60],"csview":[4365323,458],"cupsaccept":[4365781,68],"cupsctl":[4365849,328],"cupsdisable":[4366177,43],"cupsenable":[4366220,43],"cupsfilter":[4366263,198],"cupsreject":[4366461,68],"cura":[4366529,300],"curl":[4366829,6269],"cut":[4373098,499],"cw":[4373597,2636],"d2":[4376233,1073],"dagger":[4377306,1215],"dapr":[4378521,4308],"darktable":[4382829,532],"darktable-cli":[4383361,375],"dart":[4383736,4939],"datagrip":[4388675,39],"dataspell":[4388714,39],"date":[4388753,767],"dateseq":[4389520,1093],"datree":[4390613,1020],"dbt":[4391633,3363],"dc":[4394996,212],"dcli":[4395208,1600],"dd":[4396808,365],"ddev":[4397173,5284],"ddosify":[4402457,626],"deadcode":[4403083,355],"defaultbrowser":[4403438,77],"defaults":[4403515,508],"degit":[4404023,207],"delta":[4404230,2507],"deno":[4406737,3644],"deployctl":[4410381,333],"deta":[4410714,1094],"devbox":[4411808,3403],"devenv":[4415211,2104],"df":[4417315,634],"dfc":[4417949,609],"dict":[4418558,633],"diff":[4419191,1450],"diff3":[4420641,441],"diffstat":[4421082,453],"dig":[4421535,647],"dir":[4422182,1561],"dircolors":[4423743,222],"direnv":[4423965,998],"dirname":[4424963,207],"diskutil":[4425170,1235],"ditto":[4426405,1941],"dive":[4428346,814],"django-admin":[4429160,123],"dlv":[4429283,1669],"dmenu":[4430952,455],"dmesg":[4431407,808],"dms":[4432215,554],"dnctl":[4432769,205],"dnf":[4432974,3803],"dnf5":[4436777,4719],"dngconverter":[4441496,707],"dnsmasq":[4442203,4653],"do":[4446856,44],"do-release-upgrade":[4446900,246],"doas":[4447146,235],"docker":[4447381,15948],"docker container":[4463329,4630],"docker service":[4467959,3171],"docker-buildx":[4471130,2268],"docker-compose":[4473398,5460],"docker-scan":[4478858,483],"dockerd":[4479341,2452],"doctl":[4481793,2037],"dog":[4483830,777],"doggo":[4484607,797],"doing":[4485404,1435],"doppler":[4486839,3500],"doppler configs":[4490339,1154],"dos2unix":[4491493,1261],"dot_clean":[4492754,234],"dotenv":[4492988,163],"dotenv-vault":[4493151,591],"dotnet":[4493742,10005],"dotnet nuget":[4503747,3744],"dotslash":[4507491,40],"downgrade":[4507531,259],"dpkg":[4507790,1063],"dprint":[4508853,1087],"drift":[4509940,608],"drush":[4510548,83],"drutil":[4510631,135],"dscacheutil":[4510766,528],"dscl":[4511294,928],"dseditgroup":[4512222,395],"dsenableroot":[4512617,119],"dsmemberutil":[4512736,145],"dtm":[4512881,996],"dtrace":[4513877,163],"dtruss":[4514040,362],"du":[4514402,1114],"dust":[4515516,796],"dyld_info":[4516312,721],"dyld_usage":[4517033,210],"eas":[4517243,3028],"eb":[4520271,1578],"ebook-convert":[4521849,177],"echo":[4522026,152],"egrep":[4522178,55],"electron":[4522233,229],"eleventy":[4522462,468],"elif":[4522930,73],"elixir":[4523003,760],"elm":[4523763,565],"elm-format":[4524328,369],"elm-json":[4524697,559],"elm-review":[4525256,1389],"else":[4526645,71],"elvish":[4526716,520],"emacs":[4527236,605],"enapter":[4527841,662],"encore":[4528503,1539],"env":[4530042,523],"envchain":[4530565,268],"envsubst":[4530833,173],"eopkg":[4531006,3539],"esbuild":[4534545,93],"eslint":[4534638,1102],"eslogger":[4535740,196],"exa":[4535936,1114],"exec":[4537050,70],"exercism":[4537120,736],"expand":[4537856,196],"expr":[4538052,107],"expressots":[4538159,275],"eza":[4538434,1773],"faas-cli":[4540207,3956],"factor":[4544163,143],"fakechroot":[4544306,312],"fakeroot":[4544618,328],"fastfetch":[4544946,95],"fastlane":[4545041,1399],"fastly":[4546440,5679],"fastly logging":[4552119,8439],"fc-cache":[4560558,303],"fc-cat":[4560861,158],"fc-conflist":[4561019,134],"fc-list":[4561153,218],"fd":[4561371,1418],"fddist":[4562789,92],"fdesetup":[4562881,943],"fdisk":[4563824,1018],"feh":[4564842,2951],"ffmpeg":[4567793,3778],"ffplay":[4571571,39],"ffprobe":[4571610,36],"fgrep":[4571646,55],"fig":[4571701,3370],"figterm":[4575071,106],"file":[4575177,2128],"fileicon":[4577305,334],"fileproviderctl":[4577639,239],"fin":[4577878,6367],"fin docker":[4584245,14194],"fin docker container":[4598439,4121],"fin docker service":[4602560,3056],"find":[4605616,2301],"finger":[4607917,237],"firebase":[4608154,4440],"firefox":[4612594,1282],"firmwarepasswd":[4613876,252],"fish":[4614128,589],"fisher":[4614717,661],"fix":[4615378,655],"flatpak":[4616033,5887],"flutter":[4621920,11935],"flutter build":[4633855,2837],"fly":[4636692,6212],"flyctl":[4642904,6212],"fmt":[4649116,831],"fnm":[4649947,1588],"fold":[4651535,388],"fontrestore":[4651923,98],"foot":[4652021,839],"footprint":[4652860,273],"for":[4653133,93],"forc":[4653226,2362],"fping":[4655588,457],"free":[4656045,432],"freeze":[4656477,623],"fs_usage":[4657100,327],"fsck":[4657427,151],"fsck_apfs":[4657578,193],"fsck_hfs":[4657771,226],"fstyp":[4657997,46],"ftp":[4658043,457],"ftpd":[4658500,508],"fury":[4659008,608],"fuser":[4659616,150],"fvm":[4659766,803],"fvm flutter":[4660569,9927],"fzf":[4670496,3057],"fzf-tmux":[4673553,1712],"g++":[4675265,18880],"ganache-cli":[4694145,1546],"gatsby":[4695691,976],"gcc":[4696667,19003],"gcloud":[4715670,7215],"gcloud access-context-manager cloud-bindings":[4722885,2596],"gcloud access-context-manager levels":[4725481,3380],"gcloud access-context-manager perimeters":[4728861,3748],"gcloud access-context-manager perimeters dry-run":[4732609,3744],"gcloud access-context-manager policies":[4736353,2589],"gcloud active-directory domains":[4738942,3792],"gcloud active-directory domains trusts":[4742734,2566],"gcloud active-directory operations":[4745300,2299],"gcloud ai-platform":[4747599,3530],"gcloud ai-platform jobs":[4751129,3040],"gcloud ai-platform jobs submit":[4754169,5043],"gcloud ai-platform models":[4759212,4015],"gcloud ai-platform operations":[4763227,2577],"gcloud ai-platform versions":[4765804,4194],"gcloud anthos":[4769998,2404],"gcloud apigee":[4772402,3293],"gcloud apigee apis":[4775695,2801],"gcloud apigee products":[4778496,4160],"gcloud app":[4782656,4432],"gcloud app domain-mappings":[4787088,2706],"gcloud app firewall-rules":[4789794,2804],"gcloud app instances":[4792598,2924],"gcloud app operations":[4795522,2320],"gcloud app services":[4797842,3047],"gcloud app ssl-certificates":[4800889,2654],"gcloud app versions":[4803543,2808],"gcloud artifacts":[4806351,3147],"gcloud artifacts docker images":[4809498,2797],"gcloud artifacts docker tags":[4812295,2496],"gcloud artifacts print-settings":[4814791,2066],"gcloud artifacts repositories":[4816857,4083],"gcloud artifacts tags":[4820940,2513],"gcloud asset":[4823453,5476],"gcloud asset feeds":[4828929,2933],"gcloud auth":[4831862,4008],"gcloud auth application-default":[4835870,2479],"gcloud bigtable":[4838349,2411],"gcloud bigtable app-profiles":[4840760,2673],"gcloud bigtable backups":[4843433,3879],"gcloud bigtable clusters":[4847312,2650],"gcloud bigtable instances":[4849962,3681],"gcloud bigtable instances tables":[4853643,3358],"gcloud builds":[4857001,4035],"gcloud cloud-shell":[4861036,2465],"gcloud components":[4863501,2679],"gcloud components repositories":[4866180,2263],"gcloud composer environments":[4868443,6068],"gcloud composer environments storage dags":[4874511,2352],"gcloud composer environments storage data":[4876863,2341],"gcloud composer environments storage plugins":[4879204,2346],"gcloud composer operations":[4881550,2482],"gcloud compute":[4884032,10442],"gcloud compute addresses":[4894474,3595],"gcloud compute backend-buckets":[4898069,3361],"gcloud compute backend-services":[4901430,11593],"gcloud compute commitments":[4913023,3935],"gcloud compute diagnose":[4916958,3514],"gcloud compute disks":[4920472,7740],"gcloud compute external-vpn-gateways":[4928212,2931],"gcloud compute firewall-rules":[4931143,4333],"gcloud compute forwarding-rules":[4935476,6199],"gcloud compute health-checks":[4941675,2903],"gcloud compute health-checks create":[4944578,3557],"gcloud compute health-checks update":[4948135,3611],"gcloud compute http-health-checks":[4951746,3355],"gcloud compute https-health-checks":[4955101,3352],"gcloud compute images":[4958453,8358],"gcloud compute instance-groups":[4966811,3161],"gcloud compute instance-groups managed":[4969972,7876],"gcloud compute instance-groups managed instance-configs":[4977848,3241],"gcloud compute instance-groups managed rolling-action":[4981089,2606],"gcloud compute instance-groups unmanaged":[4983695,3364],"gcloud compute instance-templates":[4987059,3565],"gcloud compute instance-templates create":[4990624,6569],"gcloud compute instance-templates create-with-container":[4997193,6002],"gcloud compute instances":[5003195,16463],"gcloud compute instances create":[5019658,6955],"gcloud compute instances create-with-container":[5026613,6455],"gcloud compute interconnects":[5033068,3545],"gcloud compute interconnects attachments":[5036613,3633],"gcloud compute interconnects attachments provider":[5040246,2757],"gcloud compute network-endpoint-groups":[5043003,4105],"gcloud compute networks":[5047108,3468],"gcloud compute networks peerings":[5050576,3034],"gcloud compute networks subnets":[5053610,6714],"gcloud compute networks vpc-access":[5060324,2546],"gcloud compute networks vpc-access connectors":[5062870,2805],"gcloud compute os-config patch-deployments":[5065675,2593],"gcloud compute os-config patch-jobs":[5068268,4795],"gcloud compute os-login ssh-keys":[5073063,2664],"gcloud compute packet-mirrorings":[5075727,4033],"gcloud compute project-info":[5079760,2689],"gcloud compute reservations":[5082449,4335],"gcloud compute resource-policies":[5086784,2864],"gcloud compute resource-policies create":[5089648,3073],"gcloud compute routers":[5092721,6071],"gcloud compute routers nats":[5098792,3195],"gcloud compute routes":[5101987,3526],"gcloud compute security-policies":[5105513,2846],"gcloud compute security-policies rules":[5108359,2346],"gcloud compute shared-vpc":[5110705,2610],"gcloud compute shared-vpc associated-projects":[5113315,2394],"gcloud compute snapshots":[5115709,3960],"gcloud compute sole-tenancy":[5119669,2472],"gcloud compute sole-tenancy node-groups":[5122141,4307],"gcloud compute sole-tenancy node-templates":[5126448,3834],"gcloud compute ssl-certificates":[5130282,3047],"gcloud compute ssl-policies":[5133329,2981],"gcloud compute target-grpc-proxies":[5136310,2879],"gcloud compute target-http-proxies":[5139189,3407],"gcloud compute target-https-proxies":[5142596,4385],"gcloud compute target-instances":[5146981,2889],"gcloud compute target-pools":[5149870,4311],"gcloud compute target-ssl-proxies":[5154181,2935],"gcloud compute target-tcp-proxies":[5157116,2660],"gcloud compute target-vpn-gateways":[5159776,2876],"gcloud compute tpus":[5162652,3668],"gcloud compute url-maps":[5166320,4880],"gcloud compute vpn-gateways":[5171200,3314],"gcloud compute vpn-tunnels":[5174514,3739],"gcloud config":[5178253,2725],"gcloud config configurations":[5180978,2364],"gcloud container":[5183342,2793],"gcloud container binauthz":[5186135,2952],"gcloud container binauthz attestors":[5189087,3110],"gcloud container binauthz attestors public-keys":[5192197,2566],"gcloud container binauthz policy":[5194763,2762],"gcloud container clusters":[5197525,3201],"gcloud container clusters create":[5200726,9492],"gcloud container clusters update":[5210218,6427],"gcloud container hub memberships":[5216645,3903],"gcloud container images":[5220548,2822],"gcloud container node-pools":[5223370,6175],"gcloud container operations":[5229545,2384],"gcloud data-catalog entries":[5231929,3685],"gcloud data-catalog entry-groups":[5235614,3153],"gcloud data-catalog tag-templates":[5238767,3204],"gcloud data-catalog tag-templates fields":[5241971,2507],"gcloud data-catalog tags":[5244478,2783],"gcloud data-catalog taxonomies":[5247261,3104],"gcloud data-catalog taxonomies policy-tags":[5250365,2942],"gcloud dataflow":[5253307,2888],"gcloud dataflow flex-template":[5256195,3194],"gcloud dataflow jobs":[5259389,3567],"gcloud dataproc autoscaling-policies":[5262956,2941],"gcloud dataproc clusters":[5265897,3991],"gcloud dataproc clusters create":[5269888,5142],"gcloud dataproc jobs":[5275030,3341],"gcloud dataproc jobs submit":[5278371,3710],"gcloud dataproc operations":[5282081,2927],"gcloud dataproc workflow-templates":[5285008,3622],"gcloud dataproc workflow-templates add-job":[5288630,3611],"gcloud dataproc workflow-templates set-managed-cluster":[5292241,4939],"gcloud datastore":[5297180,2560],"gcloud datastore indexes":[5299740,2498],"gcloud datastore operations":[5302238,2407],"gcloud debug":[5304645,2518],"gcloud debug logpoints":[5307163,3233],"gcloud debug snapshots":[5310396,3308],"gcloud deployment-manager":[5313704,2721],"gcloud deployment-manager deployments":[5316425,3720],"gcloud deployment-manager operations":[5320145,2382],"gcloud dns":[5322527,2644],"gcloud dns managed-zones":[5325171,3802],"gcloud dns policies":[5328973,2749],"gcloud dns record-sets":[5331722,3074],"gcloud dns record-sets transaction":[5334796,2732],"gcloud endpoints":[5337528,2408],"gcloud endpoints operations":[5339936,2416],"gcloud endpoints services":[5342352,3382],"gcloud filestore":[5345734,2870],"gcloud filestore instances":[5348604,3316],"gcloud firebase test":[5351920,2361],"gcloud firebase test android":[5354281,5992],"gcloud firebase test ios":[5360273,4369],"gcloud firestore":[5364642,2439],"gcloud firestore indexes composite":[5367081,2621],"gcloud firestore indexes fields":[5369702,2666],"gcloud firestore operations":[5372368,2423],"gcloud functions":[5374791,6486],"gcloud game":[5381277,2652],"gcloud game servers clusters":[5383929,3185],"gcloud game servers configs":[5387114,2845],"gcloud game servers deployments":[5389959,3458],"gcloud game servers realms":[5393417,3070],"gcloud healthcare":[5396487,2447],"gcloud healthcare datasets":[5398934,4324],"gcloud healthcare dicom-stores":[5403258,4827],"gcloud healthcare fhir-stores":[5408085,5541],"gcloud healthcare hl7v2-stores":[5413626,3788],"gcloud iam roles":[5417414,3051],"gcloud iam service-accounts":[5420465,4107],"gcloud iam service-accounts keys":[5424572,2902],"gcloud iap":[5427474,2113],"gcloud iap web":[5429587,3200],"gcloud identity groups":[5432787,3071],"gcloud identity groups memberships":[5435858,2958],"gcloud iot devices":[5438816,4197],"gcloud iot devices configs":[5443013,2547],"gcloud iot devices credentials":[5445560,2740],"gcloud iot devices gateways":[5448300,2498],"gcloud iot registries":[5450798,3353],"gcloud iot registries credentials":[5454151,2507],"gcloud kms":[5456658,3066],"gcloud kms import-jobs":[5459724,3257],"gcloud kms keyrings":[5462981,3219],"gcloud kms keys":[5466200,4917],"gcloud kms keys versions":[5471117,3514],"gcloud logging":[5474631,3060],"gcloud logging metrics":[5477691,2394],"gcloud logging sinks":[5480085,2581],"gcloud meta":[5482666,5750],"gcloud meta apis":[5488416,2756],"gcloud meta apis collections":[5491172,2271],"gcloud meta apis messages":[5493443,2330],"gcloud meta apis methods":[5495773,2346],"gcloud meta cache":[5498119,3217],"gcloud ml language":[5501336,2534],"gcloud ml speech":[5503870,3062],"gcloud ml video":[5506932,2713],"gcloud ml vision":[5509645,3003],"gcloud ml-engine":[5512648,3533],"gcloud ml-engine jobs":[5516181,3041],"gcloud ml-engine jobs submit":[5519222,5043],"gcloud ml-engine models":[5524265,4015],"gcloud ml-engine operations":[5528280,2577],"gcloud ml-engine versions":[5530857,4194],"gcloud monitoring dashboards":[5535051,2511],"gcloud network-management":[5537562,2325],"gcloud network-management connectivity-tests":[5539887,3221],"gcloud organizations":[5543108,3134],"gcloud projects":[5546242,4239],"gcloud pubsub lite-subscriptions":[5550481,2697],"gcloud pubsub lite-topics":[5553178,2973],"gcloud pubsub snapshots":[5556151,2691],"gcloud pubsub subscriptions":[5558842,6210],"gcloud pubsub topics":[5565052,4367],"gcloud recommender insights":[5569419,2488],"gcloud recommender recommendations":[5571907,2635],"gcloud redis":[5574542,2566],"gcloud redis instances":[5577108,4815],"gcloud redis operations":[5581923,2385],"gcloud resource-manager folders":[5584308,3981],"gcloud resource-manager org-policies":[5588289,2800],"gcloud run":[5591089,4891],"gcloud run configurations":[5595980,2748],"gcloud run domain-mappings":[5598728,2936],"gcloud run revisions":[5601664,2839],"gcloud run routes":[5604503,2743],"gcloud run services":[5607246,6104],"gcloud scc":[5613350,2392],"gcloud scc assets":[5615742,4287],"gcloud scc findings":[5620029,4664],"gcloud scc notifications":[5624693,2724],"gcloud scheduler jobs":[5627417,2542],"gcloud scheduler jobs create":[5629959,3818],"gcloud scheduler jobs update":[5633777,4065],"gcloud secrets":[5637842,4441],"gcloud secrets versions":[5642283,2629],"gcloud services":[5644912,2842],"gcloud services vpc-peerings":[5647754,2265],"gcloud source":[5650019,2265],"gcloud source repos":[5652284,3212],"gcloud spanner":[5655496,2385],"gcloud spanner backups":[5657881,3467],"gcloud spanner databases":[5661348,4423],"gcloud spanner instances":[5665771,3279],"gcloud spanner operations":[5669050,2601],"gcloud spanner rows":[5671651,2035],"gcloud sql":[5673686,3192],"gcloud sql backups":[5676878,2743],"gcloud sql databases":[5679621,2723],"gcloud sql export":[5682344,2363],"gcloud sql import":[5684707,2447],"gcloud sql instances":[5687154,10083],"gcloud sql operations":[5697237,2455],"gcloud sql ssl client-certs":[5699692,2533],"gcloud sql ssl-certs":[5702225,2553],"gcloud sql users":[5704778,2689],"gcloud tasks":[5707467,3829],"gcloud tasks queues":[5711296,5082],"gcloud topic":[5716378,2566],"gdb":[5718944,876],"gdown":[5719820,524],"gdu":[5720344,1273],"gem":[5721617,5033],"get-env":[5726650,47],"gftp":[5726697,172],"gh":[5726869,19283],"gh-copilot":[5746152,333],"gh-dash":[5746485,215],"ghostty":[5746700,1444],"ghq":[5748144,665],"gibo":[5748809,294],"gimp":[5749103,1172],"git":[5750275,66387],"git-abort":[5816662,123],"git-alias":[5816785,79],"git-archive-file":[5816864,114],"git-authors":[5816978,122],"git-browse":[5817100,108],"git-browse-ci":[5817208,112],"git-brv":[5817320,102],"git-bulk":[5817422,269],"git-changelog":[5817691,317],"git-clang-format":[5818008,394],"git-clear":[5818402,123],"git-clear-soft":[5818525,81],"git-cliff":[5818606,658],"git-coauthor":[5819264,84],"git-commits-since":[5819348,109],"git-continue":[5819457,98],"git-contrib":[5819555,87],"git-count":[5819642,101],"git-cp":[5819743,87],"git-create-branch":[5819830,164],"git-delete-branch":[5819994,83],"git-delete-gone-branches":[5820077,174],"git-delete-merged-branches":[5820251,98],"git-delete-squashed-branches":[5820349,145],"git-delete-submodule":[5820494,71],"git-delete-tag":[5820565,80],"git-delta":[5820645,91],"git-effort":[5820736,141],"git-extras":[5820877,128],"git-feature":[5821005,222],"git-flow":[5821227,462],"git-force-clone":[5821689,142],"git-fork":[5821831,74],"git-fresh-branch":[5821905,83],"git-get":[5821988,97],"git-gh-pages":[5822085,84],"git-graft":[5822169,101],"git-guilt":[5822270,172],"git-ignore":[5822442,162],"git-ignore-io":[5822604,254],"git-info":[5822858,176],"git-local-commits":[5823034,79],"git-lock":[5823113,90],"git-locked":[5823203,73],"git-magic":[5823276,178],"git-merge-into":[5823454,135],"git-merge-repo":[5823589,119],"git-meta":[5823708,1348],"git-missing":[5825056,97],"git-mr":[5825153,104],"git-obliterate":[5825257,116],"git-p4":[5825373,1145],"git-paste":[5826518,71],"git-pr":[5826589,139],"git-profile":[5826728,171],"git-psykorebase":[5826899,172],"git-pull-request":[5827071,94],"git-quick-stats":[5827165,568],"git-reauthor":[5827733,253],"git-rebase-patch":[5827986,92],"git-release":[5828078,272],"git-rename-branch":[5828350,90],"git-rename-file":[5828440,105],"git-rename-remote":[5828545,92],"git-rename-tag":[5828637,86],"git-repl":[5828723,51],"git-reset-file":[5828774,92],"git-root":[5828866,123],"git-rscp":[5828989,97],"git-scp":[5829086,109],"git-sed":[5829195,131],"git-setup":[5829326,130],"git-show-merged-branches":[5829456,98],"git-show-tree":[5829554,114],"git-show-unmerged-branches":[5829668,101],"git-squash":[5829769,136],"git-stamp":[5829905,142],"git-standup":[5830047,368],"git-summary":[5830415,219],"git-svn":[5830634,2466],"git-sync":[5833100,135],"git-touch":[5833235,82],"git-undo":[5833317,142],"git-unlock":[5833459,91],"git-unwip":[5833550,111],"git-utimes":[5833661,111],"git-wip":[5833772,113],"github":[5833885,253],"gitk":[5834138,573],"gitui":[5834711,275],"glab":[5834986,21165],"glow":[5856151,603],"gltfjsx":[5856754,369],"gm":[5857123,5469],"gnome-keyring":[5862592,138],"gnome-keyring-daemon":[5862730,395],"gnome-maps":[5863125,261],"gnome-terminal":[5863386,330],"go":[5863716,6254],"go-carpet":[5869970,286],"go-tool-asm":[5870256,521],"go-tool-buildid":[5870777,114],"go-tool-cgo":[5870891,611],"go-tool-compile":[5871502,1538],"go-tool-covdata":[5873040,636],"go-tool-dist":[5873676,681],"go-tool-doc":[5874357,298],"go-tool-link":[5874655,1360],"go-tool-mockgen":[5876015,668],"go-tool-objdump":[5876683,163],"go-tool-pack":[5876846,82],"gocryptfs":[5876928,606],"goctl":[5877534,2553],"gocyclo":[5880087,211],"gofmt":[5880298,241],"goimports":[5880539,374],"goland":[5880913,35],"golangci-lint":[5880948,2154],"gonew":[5883102,71],"google-chrome":[5883173,453],"googler":[5883626,1157],"gopls":[5884783,604],"goreleaser":[5885387,1421],"goto":[5886808,293],"gource":[5887101,3378],"goweight":[5890479,164],"gparted":[5890643,75],"gpasswd":[5890718,275],"gpg":[5890993,4212],"gpg-agent":[5895205,947],"gpt":[5896152,614],"gradle":[5896766,4327],"graphcdn":[5901093,316],"grep":[5901409,3214],"grex":[5904623,721],"grit":[5905344,1412],"gron":[5906756,368],"groupadd":[5907124,347],"groupdel":[5907471,203],"groupmems":[5907674,241],"groupmod":[5907915,364],"groups":[5908279,109],"grype":[5908388,1037],"gsa":[5909425,554],"gt":[5909979,4183],"gulp":[5914162,524],"gum":[5914686,5636],"gunzip":[5920322,415],"gzip":[5920737,493],"halt":[5921230,249],"hardhat":[5921479,713],"hasura":[5922192,2768],"hb-service":[5924960,512],"hdiutil":[5925472,926],"head":[5926398,285],"heap":[5926683,206],"helix":[5926889,402],"helm":[5927291,6657],"helmfile":[5933948,1457],"helmsman":[5935405,1135],"herd":[5936540,1476],"herdr":[5938016,3063],"heroku":[5941079,15340],"hexchat":[5956419,401],"hexdump":[5956820,423],"hexo":[5957243,1615],"hidutil":[5958858,306],"hiutil":[5959164,317],"hl":[5959481,1571],"homey":[5961052,783],"hop":[5961835,800],"host":[5962635,379],"hostid":[5963014,124],"hostinfo":[5963138,37],"hostname":[5963175,339],"htop":[5963514,652],"http":[5964166,3089],"https":[5967255,3091],"httpy":[5970346,368],"hugetop":[5970714,204],"hugo":[5970918,4526],"hunspell":[5975444,555],"hurl":[5975999,2128],"hwinfo":[5978127,681],"hx":[5978808,253],"hyper":[5979061,340],"hyperfine":[5979401,1518],"i3":[5980919,382],"i3-scrot":[5981301,135],"i3exit":[5981436,107],"i3lock":[5981543,445],"i3status":[5981988,134],"i3status-rs":[5982122,239],"ibtool":[5982361,1236],"ibus":[5983597,250],"iconutil":[5983847,157],"iconv":[5984004,346],"id":[5984350,649],"idea":[5984999,769],"identify":[5985768,79],"iex":[5985847,857],"if":[5986704,81],"ifconfig":[5986785,204],"ignite-cli":[5986989,369],"img2pdf":[5987358,1077],"imv":[5988435,410],"index":[5988845,11311],"index databases":[6000156,2127],"infracost":[6002283,2525],"inkscape":[6004808,2149],"inshellisense":[6006957,479],"install":[6007436,1247],"install_name_tool":[6008683,187],"installer":[6008870,360],"ion":[6009230,321],"ionic":[6009551,4423],"iopattern":[6013974,117],"iopending":[6014091,114],"ioreg":[6014205,454],"iosnoop":[6014659,241],"iostat":[6014900,238],"iotop":[6015138,208],"ip":[6015346,2503],"ipatool":[6017849,685],"ipconfig":[6018534,47],"ipcrm":[6018581,162],"ipcs":[6018743,254],"iperf3-darwin":[6018997,121],"iqtree3":[6019118,4681],"jar":[6023799,757],"java":[6024556,1495],"javac":[6026051,1106],"jenv":[6027157,1178],"jest":[6028335,2222],"jj":[6030557,8548],"jmeter":[6039105,1083],"join":[6040188,799],"jot":[6040987,182],"journalctl":[6041169,1575],"jq":[6042744,1454],"julia":[6044198,1755],"jupyter":[6045953,1081],"just":[6047034,1736],"k3d":[6048770,3597],"k6":[6052367,2488],"k9s":[6054855,968],"kafkactl":[6055823,2713],"kak":[6058536,477],"kak-lsp":[6059013,391],"kamal":[6059404,48],"kdestroy":[6059452,181],"kdoctor":[6059633,219],"kextload":[6059852,92],"kextstat":[6059944,142],"kextunload":[6060086,92],"keytool":[6060178,1304],"kill":[6061482,396],"killall":[6061878,618],"kind":[6062496,1004],"kinit":[6063500,316],"kitten":[6063816,12680],"kitten @":[6076496,4128],"kitty":[6080624,1957],"klist":[6082581,399],"kmonad":[6082980,175],"kmutil":[6083155,1743],"knex":[6084898,643],"kompose":[6085541,1251],"kool":[6086792,1359],"kotlin":[6088151,419],"kotlinc":[6088570,1760],"kswitch":[6090330,137],"ktlint":[6090467,661],"ktutil":[6091128,69],"kubeadm":[6091197,3980],"kubeadm init phase":[6095177,2683],"kubeadm join":[6097860,1633],"kubeadm upgrade":[6099493,1775],"kubectl":[6101268,16725],"kubectl create":[6117993,4195],"kubectl set":[6122188,2151],"kubectx":[6124339,152],"kubens":[6124491,123],"kubeseal":[6124614,2120],"laravel":[6126734,644],"last":[6127378,505],"lastb":[6127883,60],"lastlog":[6127943,290],"lastwords":[6128233,54],"launchctl":[6128287,2198],"lazygit":[6130485,518],"ldd":[6131003,209],"leaf":[6131212,617],"leaks":[6131829,385],"lerna":[6132214,3775],"less":[6135989,5667],"lf":[6141656,302],"light":[6141958,364],"lightdm":[6142322,315],"lima":[6142637,124],"limactl":[6142761,966],"link":[6143727,123],"lldb":[6143850,155],"ln":[6144005,685],"lnav":[6144690,513],"lncrawl":[6145203,838],"locale":[6146041,228],"localectl":[6146269,391],"locust":[6146660,1833],"log":[6148493,1267],"logger":[6149760,345],"login":[6150105,168],"logname":[6150273,113],"look":[6150386,143],"lore":[6150529,7858],"lp":[6158387,872],"lpass":[6159259,2139],"ls":[6161398,3029],"lsappinfo":[6164427,1004],"lsb_release":[6165431,304],"lsblk":[6165735,1154],"lsclocks":[6166889,321],"lscpu":[6167210,605],"lsd":[6167815,1133],"lsfd":[6168948,536],"lsirq":[6169484,351],"lslocks":[6169835,358],"lslogins":[6170193,727],"lsmem":[6170920,398],"lsns":[6171318,452],"lsof":[6171770,1568],"lsusb":[6173338,257],"lua":[6173595,197],"luz":[6173792,265],"lvim":[6174057,712],"lzcat":[6174769,59],"lzma":[6174828,56],"m":[6174884,3385],"mackup":[6178269,331],"magento":[6178600,48],"magick":[6178648,121],"maigret":[6178769,1625],"mailsy":[6180394,246],"main":[6180640,453],"make":[6181093,1203],"makepkg":[6182296,1058],"mamba":[6183354,3989],"man":[6187343,1286],"marp":[6188629,890],"mas":[6189519,889],"mask":[6190408,16],"maturin":[6190424,1741],"mcomix":[6192165,328],"mcxquery":[6192493,290],"md5":[6192783,205],"md5sum":[6192988,394],"mdbook":[6193382,773],"mdfind":[6194155,596],"mdimport":[6194751,417],"mdls":[6195168,769],"mdutil":[6195937,307],"meld":[6196244,379],"melt":[6196623,312],"memory_pressure":[6196935,159],"meroxa":[6197094,1859],"meteor":[6198953,4779],"meteor npm":[6203732,5644],"mgnl":[6209376,1414],"micro":[6210790,3021],"mikro-orm":[6213811,945],"minectl":[6214756,1078],"minikube":[6215834,7215],"mitmproxy":[6223049,1258],"mix":[6224307,777],"mkbom":[6225084,160],"mkcert":[6225244,364],"mkdir":[6225608,287],"mkdocs":[6225895,1030],"mkfifo":[6226925,329],"mkfile":[6227254,81],"mkfs":[6227335,165],"mkinitcpio":[6227500,834],"mknod":[6228334,565],"mkswap":[6228899,434],"mktemp":[6229333,315],"mob":[6229648,585],"modinfo":[6230233,380],"modprobe":[6230613,758],"mogrify":[6231371,99],"molecule":[6231470,1353],"mongocli":[6232823,792],"mongocli atlas":[6233615,10094],"mongocli atlas privateendpoints":[6243709,1342],"mongocli cm":[6245051,6592],"mongocli help":[6251643,3534],"mongocli help atlas":[6255177,3324],"mongocli iam":[6258501,2148],"mongocli om":[6260649,7288],"mongocli om admin backup":[6267937,1563],"mongoimport":[6269500,2058],"mongosh":[6271558,1694],"montage":[6273252,75],"more":[6273327,506],"mosh":[6273833,772],"mount":[6274605,1042],"mousepad":[6275647,549],"mpv":[6276196,26760],"mtree":[6302956,544],"multipass":[6303500,1288],"mv":[6304788,616],"mvn":[6305404,1344],"mypy":[6306748,3372],"mysql":[6310120,2935],"n":[6313055,1270],"n-m3u8dl-re":[6314325,1962],"nano":[6316287,1675],"nc":[6317962,1373],"ncal":[6319335,791],"ncdu":[6320126,477],"ncu":[6320603,2359],"ndp":[6322962,313],"neofetch":[6323275,2123],"neomutt":[6325398,887],"nest":[6326285,976],"netcat":[6327261,660],"netlify":[6327921,3659],"netstat":[6331580,452],"nettop":[6332032,229],"networkQuality":[6332261,228],"networkquality":[6332489,522],"networksetup":[6333011,4336],"newfs_apfs":[6337347,505],"newfs_hfs":[6337852,236],"newman":[6338088,1269],"newrelic":[6339357,4337],"next":[6343694,473],"nextflow":[6344167,3205],"nfpm":[6347372,533],"nfsstat":[6347905,277],"ng":[6348182,4683],"nginx":[6352865,705],"ngrok":[6353570,1162],"nh":[6354732,1063],"nhost":[6355795,347],"ni":[6356142,336],"nice":[6356478,177],"nilaway":[6356655,482],"nix":[6357137,6558],"nix-build":[6363695,269],"nix-channel":[6363964,254],"nix-collect-garbage":[6364218,250],"nix-copy-closure":[6364468,354],"nix-env":[6364822,787],"nix-hash":[6365609,318],"nix-install-package":[6365927,213],"nix-instantiate":[6366140,484],"nix-prefetch-url":[6366624,332],"nix-push":[6366956,364],"nix-shell":[6367320,347],"nix-store":[6367667,1308],"nixops":[6368975,1416],"nixos-build-vms":[6370391,193],"nixos-container":[6370584,749],"nixos-generate-config":[6371333,292],"nixos-install":[6371625,471],"nixos-option":[6372096,277],"nixos-rebuild":[6372373,1257],"nixos-version":[6373630,191],"nl":[6373821,870],"nm":[6374691,232],"nmap":[6374923,467],"nmcli":[6375390,1467],"node":[6376857,4717],"nohup":[6381574,128],"northflank":[6381702,5366],"northflank get":[6387068,2612],"northflank get job":[6389680,1737],"northflank get service":[6391417,1605],"northflank update":[6393022,1314],"np":[6394336,493],"npm":[6394829,10081],"nproc":[6404910,173],"npx":[6405083,886],"nr":[6405969,115],"nrm":[6406084,286],"ns":[6406370,5347],"nslookup":[6411717,77],"ntpd":[6411794,694],"nu":[6412488,1234],"numfmt":[6413722,655],"nuxi":[6414377,1249],"nuxt":[6415626,439],"nvim":[6416065,747],"nvm":[6416812,1247],"nvram":[6418059,180],"nx":[6418239,2544],"nylas":[6420783,1984],"nylas api":[6422767,3317],"oci":[6426084,1426],"od":[6427510,1049],"oh-my-posh":[6428559,1064],"okta":[6429623,659],"okteto":[6430282,2694],"okteto help":[6432976,2001],"ollama":[6434977,561],"omz":[6435538,390],"onboardbase":[6435928,875],"op":[6436803,3559],"opa":[6440362,3060],"open":[6443422,831],"openrc":[6444253,321],"openrc-run":[6444574,334],"openrc-shutdown":[6444908,400],"openscad":[6445308,740],"opensnoop":[6446048,313],"openssl":[6446361,24023],"optipng":[6470384,728],"orb":[6471112,54],"osascript":[6471166,287],"osqueryi":[6471453,4441],"otool":[6475894,321],"oxlint":[6476215,812],"pac":[6477027,10240],"pac solution":[6487267,3667],"packer":[6490934,1060],"pacman":[6491994,117],"pacman-conf":[6492111,265],"pacman-db-upgrade":[6492376,241],"pacman-key":[6492617,651],"pacman-mirrors":[6493268,738],"pageres":[6494006,679],"pagesize":[6494685,43],"palemoon":[6494728,629],"palera1n":[6495357,718],"pamac":[6496075,1310],"pandoc":[6497385,5915],"paper":[6503300,2909],"paru":[6506209,159],"pass":[6506368,4210],"pass git":[6510578,30669],"passwd":[6541247,582],"paste":[6541829,557],"patch":[6542386,1214],"pathchk":[6543600,405],"patool":[6544005,411],"pax":[6544416,647],"pbcopy":[6545063,158],"pbpaste":[6545221,202],"pcmanfm":[6545423,610],"pdfattach":[6546033,189],"pdfdetach":[6546222,297],"pdffonts":[6546519,238],"pdfimages":[6546757,451],"pdfinfo":[6547208,497],"pdfseparate":[6547705,165],"pdfsig":[6547870,906],"pdftocairo":[6548776,1177],"pdftohtml":[6549953,602],"pdftoppm":[6550555,1036],"pdftops":[6551591,946],"pdftotext":[6552537,799],"pdfunite":[6553336,128],"pdisk":[6553464,339],"pfctl":[6553803,613],"pg_dump":[6554416,1719],"pgcli":[6556135,711],"pgrep":[6556846,826],"php":[6557672,653],"phpstorm":[6558325,36],"phpunit":[6558361,1033],"phpunit-watcher":[6559394,185],"pi":[6559579,1532],"picard":[6561111,293],"pidof":[6561404,333],"pidwait":[6561737,753],"pigz":[6562490,1015],"pijul":[6563505,2752],"ping":[6566257,2716],"ping6":[6568973,498],"pinky":[6569471,318],"pip":[6569789,3062],"pip install":[6572851,12341],"pip3":[6585192,1864],"pip3 install":[6587056,11250],"pipenv":[6598306,1974],"pipenv install":[6600280,12000],"pipenv uninstall":[6612280,11683],"pipx":[6623963,1433],"pixi":[6625396,9550],"pixi global":[6634946,2193],"pixi workspace":[6637139,3376],"pkg-config":[6640515,1006],"pkgbuild":[6641521,145],"pkgsite":[6641666,382],"pkgutil":[6642048,1318],"pkill":[6643366,1129],"planter":[6644495,642],"playwright":[6645137,330],"plockstat":[6645467,308],"pluginkit":[6645775,298],"plutil":[6646073,837],"pm2":[6646910,3711],"pmap":[6650621,371],"pmset":[6650992,1961],"pngcheck":[6652953,350],"pnpm":[6653303,19725],"pnpx":[6673028,6537],"pocketbase":[6679565,683],"pod":[6680248,3126],"podman":[6683374,9569],"podman container":[6692943,4091],"poetry":[6697034,3260],"port":[6700294,2459],"powermetrics":[6702753,144],"poweroff":[6702897,39],"powertop":[6702936,405],"pprof":[6703341,1732],"pr":[6705073,850],"pre-commit":[6705923,1659],"premake":[6707582,973],"present":[6708555,276],"prettier":[6708831,1804],"prettybat":[6710635,73],"prettyping":[6710708,513],"printenv":[6711221,152],"prisma":[6711373,1912],"pro":[6713285,694],"procs":[6713979,710],"productbuild":[6714689,210],"profiles":[6714899,35],"progressline":[6714934,326],"projj":[6715260,293],"pry":[6715553,500],"ps":[6716053,925],"pscale":[6716978,2848],"psql":[6719826,2545],"ptx":[6722371,636],"publish":[6723007,206],"pulumi":[6723213,5737],"pulumi stack":[6728950,2286],"purge":[6731236,50],"pushd":[6731286,95],"pwd":[6731381,150],"pwdx":[6731531,131],"pwpolicy":[6731662,154],"pwsh":[6731816,181],"pycharm":[6731997,34],"pyenv":[6732031,723],"pytest":[6732754,3729],"python":[6736483,1411],"python3":[6737894,1321],"q":[6739215,2323],"qemu-aarch64":[6741538,60],"qemu-aarch64_be":[6741598,63],"qemu-alpha":[6741661,58],"qemu-arm":[6741719,57],"qemu-armeb":[6741776,58],"qemu-edid":[6741834,270],"qemu-hexagon":[6742104,60],"qemu-hppa":[6742164,57],"qemu-i386":[6742221,57],"qemu-img":[6742278,2408],"qemu-io":[6744686,554],"qemu-keymap":[6745240,205],"qemu-loongarch64":[6745445,64],"qemu-m68k":[6745509,57],"qemu-microblaze":[6745566,63],"qemu-microblazeel":[6745629,65],"qemu-mips":[6745694,57],"qemu-mips64":[6745751,59],"qemu-mips64el":[6745810,61],"qemu-mipsel":[6745871,59],"qemu-mipsn32":[6745930,60],"qemu-mipsn32el":[6745990,62],"qemu-nbd":[6746052,1040],"qemu-or1k":[6747092,57],"qemu-ppc":[6747149,57],"qemu-ppc64":[6747206,58],"qemu-ppc64le":[6747264,60],"qemu-pr-helper":[6747324,291],"qemu-riscv32":[6747615,60],"qemu-riscv64":[6747675,60],"qemu-s390x":[6747735,58],"qemu-sh4":[6747793,57],"qemu-sh4eb":[6747850,58],"qemu-sparc":[6747908,58],"qemu-sparc32plus":[6747966,64],"qemu-sparc64":[6748030,60],"qemu-storage-daemon":[6748090,369],"qemu-system-aarch64":[6748459,59],"qemu-system-alpha":[6748518,57],"qemu-system-arm":[6748575,56],"qemu-system-avr":[6748631,56],"qemu-system-hppa":[6748687,56],"qemu-system-i386":[6748743,56],"qemu-system-loongarch64":[6748799,63],"qemu-system-m68k":[6748862,56],"qemu-system-microblaze":[6748918,62],"qemu-system-mips":[6748980,56],"qemu-system-mips64":[6749036,58],"qemu-system-mips64el":[6749094,60],"qemu-system-mipsel":[6749154,58],"qemu-system-or1k":[6749212,56],"qemu-system-ppc":[6749268,56],"qemu-system-ppc64":[6749324,57],"qemu-system-riscv32":[6749381,59],"qemu-system-riscv64":[6749440,59],"qemu-system-rx":[6749499,54],"qemu-system-s390x":[6749553,57],"qemu-system-sh4":[6749610,56],"qemu-system-sh4eb":[6749666,57],"qemu-system-sparc":[6749723,57],"qemu-system-sparc64":[6749780,59],"qemu-system-tricore":[6749839,59],"qemu-system-x86_64":[6749898,2085],"qemu-system-xtensa":[6751983,58],"qemu-system-xtensaeb":[6752041,60],"qemu-vmsr-helper":[6752101,288],"qemu-x86_64":[6752389,732],"qemu-xtensa":[6753121,59],"qemu-xtensaeb":[6753180,61],"qlmanage":[6753241,337],"qmk":[6753578,1801],"qodana":[6755379,1589],"qpdf":[6756968,2816],"qrencode":[6759784,838],"quasar":[6760622,743],"quickmail":[6761365,523],"qutebrowser":[6761888,875],"rails":[6762763,109],"railway":[6762872,981],"rake":[6763853,503],"rancher":[6764356,1953],"ranger":[6766309,571],"rbenv":[6766880,411],"rc-depend":[6767291,357],"rc-environ":[6767648,338],"rc-service":[6767986,430],"rc-status":[6768416,400],"rc-update":[6768816,291],"rclone":[6769107,2563],"react-native":[6771670,3378],"readlink":[6775048,456],"realpath":[6775504,83],"reboot":[6775587,37],"redis-cli":[6775624,1598],"redwood":[6777222,4855],"remix":[6782077,348],"remotion":[6782425,2404],"remotion lambda":[6784829,2548],"rename":[6787377,258],"renice":[6787635,156],"repair_packages":[6787791,356],"repeat":[6788147,88],"restic":[6788235,4085],"resume-cli":[6792320,463],"rev":[6792783,42],"rg":[6792825,3552],"rich":[6796377,3800],"rider":[6800177,30],"rifle":[6800207,242],"ripsecrets":[6800449,270],"rm":[6800719,609],"rmdir":[6801328,218],"rmmod":[6801546,198],"robot":[6801744,2811],"rojo":[6804555,1148],"rollup":[6805703,1942],"rome":[6807645,1092],"route":[6808737,493],"rsync":[6809230,4192],"rubocop":[6813422,2199],"ruby":[6815621,1337],"rubymine":[6816958,39],"ruff":[6816997,2258],"ruff rule":[6819255,9727],"rugby":[6828982,1386],"run0":[6830368,663],"rush":[6831031,6073],"rushx":[6837104,182],"rust-analyzer":[6837286,1530],"rustc":[6838816,1678],"rustdoc":[6840494,2372],"rustrover":[6842866,39],"rustup":[6842905,2712],"rvm":[6845617,1213],"rwsnoop":[6846830,151],"sa":[6846981,400],"sake":[6847381,616],"sam":[6847997,3929],"sample":[6851926,180],"sanity":[6852106,3512],"sapphire":[6855618,308],"saw":[6855926,641],"say":[6856567,247],"sc_auth":[6856814,345],"sc_usage":[6857159,157],"scarb":[6857316,1358],"scc":[6858674,2150],"scp":[6860824,2076],"screen":[6862900,1253],"screencapture":[6864153,940],"script":[6865093,503],"scriptlive":[6865596,365],"scriptreplay":[6865961,419],"scrot":[6866380,885],"scutil":[6867265,469],"sd":[6867734,466],"sdkmanager":[6868200,537],"security":[6868737,4329],"sed":[6873066,1847],"semver":[6874913,364],"seq":[6875277,366],"sequelize":[6875643,107],"serie":[6875750,254],"serve":[6876004,695],"serverless":[6876699,1413],"set-env":[6878112,47],"sfdx":[6878159,12399],"sfltool":[6890558,113],"sftp":[6890671,2374],"sha1sum":[6893045,476],"sha224sum":[6893521,385],"sha256sum":[6893906,402],"sha384sum":[6894308,386],"sha512sum":[6894694,386],"shadcn-ui":[6895080,315],"sharing":[6895395,371],"shasum":[6895766,641],"shelve":[6896407,394],"shopify":[6896801,4472],"shortcuts":[6901273,586],"showkey":[6901859,232],"shred":[6902091,629],"shutdown":[6902720,246],"sidekiq":[6902966,386],"simctl":[6903352,4839],"simctl help help":[6908191,4716],"simctl help help help help":[6912907,4716],"simctl help help help help help help":[6917623,4717],"simctl help help help help help help help help":[6922340,4717],"simctl help help help help help help help help help help":[6927057,4717],"simctl help help help help help help help help help help help help":[6931774,4717],"simctl help help help help help help help help help help help help help help":[6936491,4718],"simctl help help help help help help help help help help help help help help help help":[6941209,4718],"simctl help help help help help help help help help help help help help help help help help help":[6945927,4718],"simctl help help help help help help help help help help help help help help help help help help help help":[6950645,4719],"simctl help help help help help help help help help help help help help help help help help help help help help help":[6955364,4719],"simctl help help help help help help help help help help help help help help help help help help help help help help help help":[6960083,4719],"simctl help help help help help help help help help help help help help help help help help help help help help help help help help help":[6964802,4719],"simctl help help help help help help help help help help help help help help help help help help help help help help help help help help help help":[6969521,4719],"simctl help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help":[6974240,4719],"simctl help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help":[6978959,4719],"simctl help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help":[6983678,4719],"simctl help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help":[6988397,4719],"simctl help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help":[6993116,4677],"singularity":[6997793,85],"sips":[6997878,1220],"skhd":[6999098,478],"sl":[6999576,199],"slabtop":[6999775,216],"sleep":[6999991,117],"slides":[7000108,219],"sls":[7000327,1413],"smbutil":[7001740,671],"snaplet":[7002411,2079],"sntp":[7004490,242],"soft":[7004732,146],"softwareupdate":[7004878,1050],"sort":[7005928,1455],"source":[7007383,78],"space":[7007461,716],"spctl":[7008177,632],"speedtest":[7008809,810],"speedtest-cli":[7009619,849],"spindump":[7010468,67],"splash-cli":[7010535,948],"split":[7011483,947],"spotify":[7012430,657],"spring":[7013087,880],"sqlfluff":[7013967,2005],"sqlite3":[7015972,917],"sqlmesh":[7016889,2692],"src":[7019581,1496],"ssh":[7021077,2223],"ssh-agent":[7023300,378],"ssh-copy-id":[7023678,213],"ssh-keygen":[7023891,1647],"st":[7025538,373],"st2":[7025911,6384],"stack":[7032295,4023],"stapler":[7036318,87],"starkli":[7036405,2149],"starship":[7038554,686],"start-stop-daemon":[7039240,999],"start-storybook":[7040239,727],"startosinstall":[7040966,531],"stat":[7041497,1019],"staticcheck":[7042516,466],"stdbuf":[7042982,131],"steadybit":[7043113,747],"stencil":[7043860,834],"stow":[7044694,1006],"streamlit":[7045700,373],"strings":[7046073,584],"stripe":[7046657,2940],"stripe listen":[7049597,3942],"stty":[7053539,224],"su":[7053763,692],"subl":[7054455,369],"sudo":[7054824,848],"sudoedit":[7055672,486],"sudoreplay":[7056158,412],"suitecloud":[7056570,2456],"sulogin":[7059026,207],"sum":[7059233,178],"supabase":[7059411,1844],"supervise-daemon":[7061255,896],"supervisorctl":[7062151,618],"supervisord":[7062769,687],"surreal":[7063456,1082],"svg-term":[7064538,535],"svgcleaner":[7065073,1212],"svn":[7066285,759],"svokit":[7067044,132],"svtplay-dl":[7067176,1768],"sw_vers":[7068944,183],"swagger-typescript-api":[7069127,1268],"sway":[7070395,286],"swaybar":[7070681,208],"swaybg":[7070889,257],"swayidle":[7071146,162],"swaylock":[7071308,1505],"swaymsg":[7072813,638],"swaynag":[7073451,661],"swc":[7074112,905],"swift":[7075017,6548],"swiftc":[7081565,85],"syft":[7081650,406],"symfony":[7082056,3019],"sync":[7085075,187],"sysadminctl":[7085262,560],"sysctl":[7085822,663],"sysdiagnose":[7086485,349],"syslog":[7086834,506],"system_profiler":[7087340,229],"systemctl":[7087569,3507],"systemd-analyze":[7091076,1900],"systemextensionsctl":[7092976,193],"systemsetup":[7093169,686],"tac":[7093855,242],"tail":[7094097,506],"tailcall":[7094603,377],"tailscale":[7094980,7901],"tailspin":[7102881,62],"tailwindcss":[7102943,451],"talk":[7103394,39],"tangram":[7103433,506],"taplo":[7103939,1302],"tar":[7105241,7549],"task":[7112790,1281],"taskinfo":[7114071,121],"taskpolicy":[7114192,278],"tb":[7114470,3156],"tccutil":[7117626,116],"tcpdump":[7117742,1114],"tea":[7118856,4053],"tee":[7122909,333],"telnet":[7123242,427],"templ":[7123669,573],"termux-apt-repo":[7124242,153],"terraform":[7124395,3832],"terraform-ls":[7128227,377],"terragrunt":[7128604,4236],"terragrunt workspace":[7132840,2015],"terramate":[7134855,2215],"tesseract":[7137070,469],"textutil":[7137539,239],"tfenv":[7137778,288],"tfsec":[7138066,1373],"tftp":[7139439,96],"then":[7139535,71],"tiff2icns":[7139606,101],"tiffutil":[7139707,142],"tig":[7139849,298],"time":[7140147,62],"timeout":[7140209,371],"tinygo":[7140580,879],"tkn":[7141459,5359],"tldr":[7146818,312],"tload":[7147130,172],"tmate":[7147302,300],"tmutil":[7147602,2054],"tmux":[7149656,6101],"tmuxinator":[7155757,740],"tofu":[7156497,3025],"toit.lsp":[7159522,646],"toit.pkg":[7160168,790],"tokei":[7160958,648],"top":[7161606,863],"tor-browser":[7162469,217],"tor-gencert":[7162686,362],"tor-print-ed-signing-cert":[7163048,82],"tor-resolve":[7163130,206],"torsocks":[7163336,288],"touch":[7163624,492],"tox":[7164116,1311],"tr":[7165427,866],"traceroute":[7166293,1869],"traceroute6":[7168162,329],"traefik":[7168491,101],"transmission-cli":[7168592,566],"transmission-create":[7169158,358],"transmission-daemon":[7169516,1096],"transmission-edit":[7170612,264],"transmission-remote":[7170876,2033],"transmission-show":[7172909,212],"trap":[7173121,190],"trash":[7173311,290],"tree":[7173601,1812],"trex":[7175413,643],"trimforce":[7176056,99],"trivy":[7176155,2447],"truffle":[7178602,3150],"truncate":[7181752,398],"trunk":[7182150,1395],"ts":[7183545,140],"ts-node":[7183685,774],"tsc":[7184459,5099],"tsh":[7189558,7816],"tshark":[7197374,1671],"tsort":[7199045,111],"tsuru":[7199156,2279],"tsx":[7201435,222],"tty":[7201657,169],"ttyd":[7201826,769],"tuist":[7202595,2816],"turbo":[7205411,1572],"twiggy":[7206983,409],"twilio":[7207392,106],"typeorm":[7207498,1118],"typos":[7208616,757],"typst":[7209373,1506],"ua":[7210879,693],"ubuntu-advantage":[7211572,699],"ufw":[7212271,637],"umount":[7212908,550],"uname":[7213458,244],"unbrotli":[7213702,47],"unexpand":[7213749,249],"uniq":[7213998,489],"unix2dos":[7214487,1260],"unlink":[7215747,127],"unlzma":[7215874,58],"unpigz":[7215932,49],"unset":[7215981,98],"unset-env":[7216079,50],"until":[7216129,65],"unxz":[7216194,58],"unzip":[7216252,746],"upower":[7216998,265],"uptime":[7217263,199],"upx":[7217462,1102],"useradd":[7218564,719],"userdel":[7219283,219],"usermod":[7219502,614],"users":[7220116,133],"uuidgen":[7220249,121],"uv":[7220370,11769],"uv pip":[7232139,4551],"uv tool":[7236690,2678],"v":[7239368,615],"vagrant":[7239983,3930],"vale":[7243913,497],"valet":[7244410,2815],"vapor":[7247225,661],"vault":[7247886,6220],"vault kv":[7254106,2552],"vault kv metadata":[7256658,1940],"vault namespace":[7258598,1799],"vault operator":[7260397,4601],"vault operator raft":[7264998,2520],"vault plugin":[7267518,2255],"vault policy":[7269773,1714],"vault secrets":[7271487,2674],"vault token":[7274161,3166],"vdir":[7277327,1680],"vela":[7279007,2657],"vercel":[7281664,6123],"vhs":[7287787,491],"vi":[7288278,448],"viewnior":[7288726,169],"vim":[7288895,1233],"vimr":[7290128,407],"visudo":[7290535,407],"vite":[7290942,871],"viu":[7291813,439],"vivid":[7292252,323],"vlc":[7292575,2948],"vm_stat":[7295523,107],"vmmap":[7295630,285],"vmstat":[7295915,382],"volta":[7296297,833],"vr":[7297130,282],"vsce":[7297412,1315],"vsdbutil":[7298727,198],"vtex":[7298925,3522],"vue":[7302447,1594],"vultr-cli":[7304041,2219],"w":[7306260,328],"wait4path":[7306588,69],"wall":[7306657,86],"wasm-bindgen":[7306743,925],"wasm-pack":[7307668,444],"watch":[7308112,502],"watchexec":[7308614,927],"watchgnupg":[7309541,297],"watchman":[7309838,1421],"watson":[7311259,2557],"waypoint":[7313816,4985],"wc":[7318801,381],"wd":[7319182,491],"wdutil":[7319673,328],"webpack":[7320001,17294],"webstorm":[7337295,36],"wezterm":[7337331,3319],"wg":[7340650,314],"wget":[7340964,4391],"what":[7345355,137],"whence":[7345492,411],"where":[7345903,333],"whereis":[7346236,345],"which":[7346581,439],"while":[7347020,65],"who":[7347085,631],"whoami":[7347716,112],"whois":[7347828,628],"wifi-password":[7348456,185],"wine":[7348641,90],"wineboot":[7348731,310],"winepath":[7349041,254],"wineserver":[7349295,311],"winetricks":[7349606,877],"wing":[7350483,292],"wire":[7350775,381],"wireshark":[7351156,1004],"wishlist":[7352160,196],"wl-mirror":[7352356,743],"woeusb":[7353099,434],"wp":[7353533,26617],"wp post":[7380150,3275],"wrangler":[7383425,2038],"write":[7385463,54],"wrk":[7385517,290],"wscat":[7385807,1203],"wt":[7387010,2951],"xar":[7389961,593],"xargs":[7390554,941],"xattr":[7391495,324],"xbacklight":[7391819,328],"xbps-alternatives":[7392147,418],"xbps-checkvers":[7392565,454],"xbps-create":[7393019,722],"xbps-dgraph":[7393741,388],"xbps-digest":[7394129,156],"xbps-fbulk":[7394285,225],"xbps-fetch":[7394510,209],"xbps-install":[7394719,647],"xbps-pkgdb":[7395366,395],"xbps-query":[7395761,770],"xbps-reconfigure":[7396531,370],"xbps-remove":[7396901,446],"xbps-rindex":[7397347,495],"xbps-uchroot":[7397842,288],"xbps-uhelper":[7398130,428],"xbps-uunshare":[7398558,188],"xc":[7398746,297],"xclip":[7399043,596],"xcode-select":[7399639,227],"xcodebuild":[7399866,3128],"xcodeproj":[7402994,617],"xcodes":[7403611,795],"xcrun":[7404406,7627],"xcrun simctl help":[7412033,4715],"xcrun simctl help help help":[7416748,4716],"xcrun simctl help help help help help":[7421464,4717],"xcrun simctl help help help help help help help":[7426181,4717],"xcrun simctl help help help help help help help help help":[7430898,4717],"xcrun simctl help help help help help help help help help help help":[7435615,4717],"xcrun simctl help help help help help help help help help help help help help":[7440332,4718],"xcrun simctl help help help help help help help help help help help help help help help":[7445050,4718],"xcrun simctl help help help help help help help help help help help help help help help help help":[7449768,4718],"xcrun simctl help help help help help help help help help help help help help help help help help help help":[7454486,4719],"xcrun simctl help help help help help help help help help help help help help help help help help help help help help":[7459205,4719],"xcrun simctl help help help help help help help help help help help help help help help help help help help help help help help":[7463924,4719],"xcrun simctl help help help help help help help help help help help help help help help help help help help help help help help help help":[7468643,4719],"xcrun simctl help help help help help help help help help help help help help help help help help help help help help help help help help help help":[7473362,4719],"xcrun simctl help help help help help help help help help help help help help help help help help help help help help help help help help help help help help":[7478081,4719],"xcrun simctl help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help":[7482800,4719],"xcrun simctl help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help":[7487519,4719],"xcrun simctl help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help":[7492238,4719],"xcrun simctl help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help help":[7496957,4677],"xdg-mime":[7501634,456],"xdg-open":[7502090,173],"xdotool":[7502263,2265],"xed":[7504528,474],"xh":[7505002,1657],"xip":[7506659,214],"xmllint":[7506873,1167],"xonsh":[7508040,510],"xprotect":[7508550,269],"xsltproc":[7508819,788],"xxd":[7509607,805],"xxhsum":[7510412,507],"xz":[7510919,1538],"xzcat":[7512457,59],"yalc":[7512516,835],"yank":[7513351,288],"yarn":[7513639,8596],"yay":[7522235,762],"yes":[7522997,118],"yj":[7523115,234],"ykman":[7523349,5663],"yo":[7529012,305],"yomo":[7529317,364],"youtube-dl":[7529681,5253],"yt-dlp":[7534934,5754],"z":[7540688,13],"zapier-platform-cli":[7540701,3342],"zathura":[7544043,536],"zcat":[7544579,316],"zed":[7544895,278],"zellij":[7545173,7750],"zellij action":[7552923,4673],"zfs":[7557596,2785],"zig":[7560381,7350],"zig c++":[7567731,20648],"zig cc":[7588379,20646],"zip":[7609025,639],"zipcloak":[7609664,384],"zoxide":[7610048,734],"zpaq":[7610782,146],"zpool":[7610928,2365],"zprint":[7613293,281]}} diff --git a/assets/completion/catalog.bin b/assets/completion/catalog.bin new file mode 100644 index 00000000..f06f0ba0 Binary files /dev/null and b/assets/completion/catalog.bin differ diff --git a/assets/completion/provenance.json b/assets/completion/provenance.json new file mode 100644 index 00000000..40a15a46 --- /dev/null +++ b/assets/completion/provenance.json @@ -0,0 +1,10627 @@ +{ + "version": 1, + "generator": "scripts/completion-catalog/build.mjs", + "policy": "Upstream sources are parsed as data only; no upstream JavaScript, TypeScript, Go or generator is executed. Dynamic completions (generators, callbacks, custom actions) are dropped and counted.", + "sources": [ + { + "name": "withfig/autocomplete", + "repository": "https://github.com/withfig/autocomplete", + "commit": "aef52acff84c45edde61ae610cc2c964802b9a38", + "license": "MIT", + "notice": "licenses/withfig-autocomplete-MIT.txt", + "role": "primary" + }, + { + "name": "carapace-sh/carapace-bin", + "repository": "https://github.com/carapace-sh/carapace-bin", + "commit": "c9cac841afb9ac0265ef7b35b47194756b160d8f", + "license": "MIT", + "notice": "licenses/carapace-bin-MIT.txt", + "role": "secondary (static cobra declarations only)" + } + ], + "notIncluded": { + "microsoft/inshellisense": "consumes withfig/autocomplete; counting it would double-count the same specs", + "aws/amazon-q-developer-cli": "Fig lineage; its specs are withfig/autocomplete" + }, + "fig": { + "examined": 720, + "full": 393, + "partial": 303, + "skipped": 24, + "reasons": { + "no static spec (computed or generated)": 20, + "duplicate root name": 2, + "third-party copyright header without license grant": 2 + }, + "subcommands": 53881, + "options": 279579, + "dynamic": 4307, + "loadSpecs": 825 + }, + "carapace": { + "examined": 1200, + "unique": 899, + "enriched": 204, + "unchanged": 46, + "skipped": 51, + "reasons": { + "no static rootCmd": 1, + "same command on an earlier platform": 50 + }, + "subcommandsAdded": 937, + "optionsAdded": 7795, + "descriptionsAdded": 99, + "dynamicFlags": 1309, + "skippedDirectories": { + "android": { + "count": 18, + "reason": "Android-only commands" + }, + "bash": { + "count": 46, + "reason": "shell builtins of another shell" + }, + "cmd": { + "count": 35, + "reason": "Windows cmd builtins" + }, + "elvish": { + "count": 27, + "reason": "shell builtins of another shell" + }, + "fish": { + "count": 50, + "reason": "shell builtins of another shell" + }, + "windows": { + "count": 121, + "reason": "Windows-only commands; NMSh has no native Windows backend" + }, + "zsh": { + "count": 48, + "reason": "shell builtins of another shell" + } + } + }, + "catalog": { + "roots": 1595, + "rootNames": 1595, + "subcommands": 60190, + "options": 320755, + "choices": 116839, + "rootsBySource": { + "fig": 492, + "carapace": 899, + "both": 204 + }, + "entries": 2636, + "rawBytes": 65184509, + "packedBytes": 7613574, + "largestEntry": { + "key": "aws ec2", + "rawBytes": 1042946 + } + }, + "files": { + "fig": [ + { + "file": "-.ts", + "outcome": "skipped", + "reason": "no static spec (computed or generated)" + }, + { + "file": "act.ts", + "outcome": "full", + "root": "act" + }, + { + "file": "adb.ts", + "outcome": "full", + "root": "adb" + }, + { + "file": "adr.ts", + "outcome": "full", + "root": "adr" + }, + { + "file": "afplay.ts", + "outcome": "full", + "root": "afplay" + }, + { + "file": "aftman.ts", + "outcome": "full", + "root": "aftman" + }, + { + "file": "ag.ts", + "outcome": "full", + "root": "ag" + }, + { + "file": "agrippa.ts", + "outcome": "full", + "root": "agrippa" + }, + { + "file": "airflow.ts", + "outcome": "partial", + "root": "airflow", + "dropped": 1 + }, + { + "file": "aliases.ts", + "outcome": "full", + "root": "aliases" + }, + { + "file": "amplify.ts", + "outcome": "partial", + "root": "amplify", + "dropped": 8 + }, + { + "file": "ampx.ts", + "outcome": "full", + "root": "ampx" + }, + { + "file": "ansible-config.ts", + "outcome": "full", + "root": "ansible-config" + }, + { + "file": "ansible-doc.ts", + "outcome": "partial", + "root": "ansible-doc", + "dropped": 2 + }, + { + "file": "ansible-galaxy.ts", + "outcome": "full", + "root": "ansible-galaxy" + }, + { + "file": "ansible-lint.ts", + "outcome": "full", + "root": "ansible-lint" + }, + { + "file": "ansible-playbook.ts", + "outcome": "partial", + "root": "ansible-playbook", + "dropped": 1 + }, + { + "file": "ansible.ts", + "outcome": "full", + "root": "ansible" + }, + { + "file": "ant.ts", + "outcome": "partial", + "root": "ant", + "dropped": 2 + }, + { + "file": "appwrite.ts", + "outcome": "full", + "root": "index" + }, + { + "file": "apt.ts", + "outcome": "partial", + "root": "apt", + "dropped": 30 + }, + { + "file": "arch.ts", + "outcome": "partial", + "root": "arch", + "dropped": 3 + }, + { + "file": "arduino-cli.ts", + "outcome": "partial", + "root": "arduino-cli", + "dropped": 38 + }, + { + "file": "argo.ts", + "outcome": "full", + "root": "argo" + }, + { + "file": "asar.ts", + "outcome": "full", + "root": "asar" + }, + { + "file": "asciinema.ts", + "outcome": "full", + "root": "asciinema" + }, + { + "file": "asdf.ts", + "outcome": "partial", + "root": "asdf", + "dropped": 34 + }, + { + "file": "asr.ts", + "outcome": "partial", + "root": "asr", + "dropped": 1 + }, + { + "file": "assimp.ts", + "outcome": "partial", + "root": "assimp", + "dropped": 6 + }, + { + "file": "astro.ts", + "outcome": "full", + "root": "astro" + }, + { + "file": "atlas.ts", + "outcome": "full", + "root": "atlas" + }, + { + "file": "atuin.ts", + "outcome": "partial", + "root": "atuin", + "dropped": 1 + }, + { + "file": "authchanger.ts", + "outcome": "full", + "root": "authchanger" + }, + { + "file": "autocannon.ts", + "outcome": "partial", + "root": "autocannon", + "dropped": 1 + }, + { + "file": "autojump.ts", + "outcome": "full", + "root": "autojump" + }, + { + "file": "aws-vault.ts", + "outcome": "partial", + "root": "aws-vault", + "dropped": 12 + }, + { + "file": "aws.ts", + "outcome": "partial", + "root": "aws", + "dropped": 48760 + }, + { + "file": "awsume.ts", + "outcome": "full", + "root": "awsume" + }, + { + "file": "az/index.ts", + "outcome": "full", + "root": "az" + }, + { + "file": "babel.ts", + "outcome": "full", + "root": "babel" + }, + { + "file": "banner.ts", + "outcome": "full", + "root": "banner" + }, + { + "file": "barnard59.ts", + "outcome": "full", + "root": "barnard59" + }, + { + "file": "base32.ts", + "outcome": "full", + "root": "base32" + }, + { + "file": "base64.ts", + "outcome": "full", + "root": "base64" + }, + { + "file": "basename.ts", + "outcome": "full", + "root": "basename" + }, + { + "file": "basenc.ts", + "outcome": "full", + "root": "basenc" + }, + { + "file": "bat.ts", + "outcome": "partial", + "root": "bat", + "dropped": 14 + }, + { + "file": "bazel.ts", + "outcome": "partial", + "root": "bazel", + "dropped": 6 + }, + { + "file": "bc.ts", + "outcome": "full", + "root": "bc" + }, + { + "file": "bcd.ts", + "outcome": "full", + "root": "bcd" + }, + { + "file": "bit.ts", + "outcome": "full", + "root": "bit" + }, + { + "file": "black.ts", + "outcome": "partial", + "root": "black", + "dropped": 3 + }, + { + "file": "blitz.ts", + "outcome": "partial", + "root": "blitz", + "dropped": 1 + }, + { + "file": "bosh.ts", + "outcome": "partial", + "root": "bosh", + "dropped": 3 + }, + { + "file": "br.ts", + "outcome": "skipped", + "reason": "no static spec (computed or generated)" + }, + { + "file": "brew.ts", + "outcome": "partial", + "root": "brew", + "dropped": 48 + }, + { + "file": "broot.ts", + "outcome": "full", + "root": "broot" + }, + { + "file": "browser-sync.ts", + "outcome": "partial", + "root": "browser-sync", + "dropped": 2 + }, + { + "file": "btop.ts", + "outcome": "full", + "root": "btop" + }, + { + "file": "build-storybook.ts", + "outcome": "full", + "root": "build-storybook" + }, + { + "file": "bun.ts", + "outcome": "partial", + "root": "bun", + "dropped": 38 + }, + { + "file": "bundle.ts", + "outcome": "partial", + "root": "bundle", + "dropped": 6 + }, + { + "file": "bunx.ts", + "outcome": "partial", + "root": "bunx", + "dropped": 3 + }, + { + "file": "bw.ts", + "outcome": "full", + "root": "bw" + }, + { + "file": "bwdc.ts", + "outcome": "full", + "root": "bwdc" + }, + { + "file": "bws.ts", + "outcome": "full", + "root": "bws" + }, + { + "file": "c++.ts", + "outcome": "full", + "root": "c++" + }, + { + "file": "caffeinate.ts", + "outcome": "full", + "root": "caffeinate" + }, + { + "file": "cal.ts", + "outcome": "partial", + "root": "cal", + "dropped": 1 + }, + { + "file": "cap.ts", + "outcome": "partial", + "root": "cap", + "dropped": 3 + }, + { + "file": "capacitor.ts", + "outcome": "partial", + "root": "capacitor", + "dropped": 3 + }, + { + "file": "cargo.ts", + "outcome": "skipped", + "reason": "no static spec (computed or generated)" + }, + { + "file": "cat.ts", + "outcome": "full", + "root": "cat" + }, + { + "file": "cci.ts", + "outcome": "full", + "root": "cci" + }, + { + "file": "cd.ts", + "outcome": "partial", + "root": "cd", + "dropped": 1 + }, + { + "file": "cdk.ts", + "outcome": "full", + "root": "cdk" + }, + { + "file": "cdk8s.ts", + "outcome": "full", + "root": "cdk8s" + }, + { + "file": "cf.ts", + "outcome": "partial", + "root": "cf", + "dropped": 138 + }, + { + "file": "charm.ts", + "outcome": "full", + "root": "charm" + }, + { + "file": "checkov.ts", + "outcome": "partial", + "root": "checkov", + "dropped": 2 + }, + { + "file": "chezmoi.ts", + "outcome": "partial", + "root": "chezmoi", + "dropped": 1319 + }, + { + "file": "chmod.ts", + "outcome": "full", + "root": "chmod" + }, + { + "file": "chown.ts", + "outcome": "partial", + "root": "chown", + "dropped": 2 + }, + { + "file": "chsh.ts", + "outcome": "partial", + "root": "chsh", + "dropped": 2 + }, + { + "file": "cicada.ts", + "outcome": "full", + "root": "cicada" + }, + { + "file": "circleci.ts", + "outcome": "full", + "root": "circleci" + }, + { + "file": "cl.ts", + "outcome": "full", + "root": "@commercelayer/cli" + }, + { + "file": "clang++.ts", + "outcome": "full", + "root": "clang++" + }, + { + "file": "clang.ts", + "outcome": "full", + "root": "clang" + }, + { + "file": "clear.ts", + "outcome": "full", + "root": "clear" + }, + { + "file": "cliff-jumper.ts", + "outcome": "partial", + "root": "cliff-jumper", + "dropped": 8 + }, + { + "file": "clilol.ts", + "outcome": "full", + "root": "clilol" + }, + { + "file": "clion.ts", + "outcome": "skipped", + "reason": "no static spec (computed or generated)" + }, + { + "file": "clojure.ts", + "outcome": "partial", + "root": "clojure", + "dropped": 4 + }, + { + "file": "cloudflared.ts", + "outcome": "partial", + "root": "cloudflared", + "dropped": 8 + }, + { + "file": "cmake.ts", + "outcome": "full", + "root": "cmake" + }, + { + "file": "coda.ts", + "outcome": "partial", + "root": "coda", + "dropped": 11 + }, + { + "file": "code-insiders.ts", + "outcome": "full", + "root": "code-insiders" + }, + { + "file": "code.ts", + "outcome": "full", + "root": "code" + }, + { + "file": "codesign.ts", + "outcome": "partial", + "root": "codesign", + "dropped": 2 + }, + { + "file": "command.ts", + "outcome": "full", + "root": "command" + }, + { + "file": "commercelayer.ts", + "outcome": "skipped", + "reason": "duplicate root name" + }, + { + "file": "composer.ts", + "outcome": "partial", + "root": "composer", + "dropped": 1 + }, + { + "file": "conda.ts", + "outcome": "partial", + "root": "conda", + "dropped": 16 + }, + { + "file": "copilot.ts", + "outcome": "partial", + "root": "copilot", + "dropped": 122 + }, + { + "file": "copyfile.ts", + "outcome": "full", + "root": "copyfile" + }, + { + "file": "copypath.ts", + "outcome": "full", + "root": "copypath" + }, + { + "file": "cordova.ts", + "outcome": "partial", + "root": "cordova", + "dropped": 16 + }, + { + "file": "cosign.ts", + "outcome": "full", + "root": "cosign" + }, + { + "file": "cot.ts", + "outcome": "full", + "root": "cot" + }, + { + "file": "cp.ts", + "outcome": "full", + "root": "cp" + }, + { + "file": "create-completion-spec.ts", + "outcome": "full", + "root": "create-completion-spec" + }, + { + "file": "create-next-app.ts", + "outcome": "full", + "root": "create-next-app" + }, + { + "file": "create-nx-workspace.ts", + "outcome": "partial", + "root": "create-nx-workspace", + "dropped": 26 + }, + { + "file": "create-react-app.ts", + "outcome": "full", + "root": "create-react-app" + }, + { + "file": "create-react-native-app.ts", + "outcome": "full", + "root": "create-react-native-app" + }, + { + "file": "create-redwood-app.ts", + "outcome": "full", + "root": "create-redwood-app" + }, + { + "file": "create-remix.ts", + "outcome": "full", + "root": "create-remix" + }, + { + "file": "create-t3-app.ts", + "outcome": "full", + "root": "create-t3-app" + }, + { + "file": "create-video.ts", + "outcome": "full", + "root": "create-video" + }, + { + "file": "create-vite.ts", + "outcome": "partial", + "root": "create-vite", + "dropped": 18 + }, + { + "file": "create-web3-frontend.ts", + "outcome": "full", + "root": "create-web3-frontend" + }, + { + "file": "croc.ts", + "outcome": "full", + "root": "croc" + }, + { + "file": "crontab.ts", + "outcome": "full", + "root": "crontab" + }, + { + "file": "csdx.ts", + "outcome": "full", + "root": "csdx" + }, + { + "file": "curl.ts", + "outcome": "partial", + "root": "curl", + "dropped": 1 + }, + { + "file": "cut.ts", + "outcome": "full", + "root": "cut" + }, + { + "file": "cw.ts", + "outcome": "partial", + "root": "cw", + "dropped": 1 + }, + { + "file": "dapr.ts", + "outcome": "partial", + "root": "dapr", + "dropped": 16 + }, + { + "file": "dart.ts", + "outcome": "full", + "root": "dart" + }, + { + "file": "date.ts", + "outcome": "full", + "root": "date" + }, + { + "file": "dateseq.ts", + "outcome": "full", + "root": "dateseq" + }, + { + "file": "datree.ts", + "outcome": "full", + "root": "datree" + }, + { + "file": "dbt.ts", + "outcome": "full", + "root": "dbt" + }, + { + "file": "dcli.ts", + "outcome": "partial", + "root": "dcli", + "dropped": 4 + }, + { + "file": "dd.ts", + "outcome": "partial", + "root": "dd", + "dropped": 2 + }, + { + "file": "ddev.ts", + "outcome": "full", + "root": "ddev" + }, + { + "file": "ddosify.ts", + "outcome": "full", + "root": "ddosify" + }, + { + "file": "defaultbrowser.ts", + "outcome": "partial", + "root": "defaultbrowser", + "dropped": 2 + }, + { + "file": "defaults.ts", + "outcome": "partial", + "root": "defaults", + "dropped": 10 + }, + { + "file": "degit.ts", + "outcome": "partial", + "root": "degit", + "dropped": 3 + }, + { + "file": "deno.ts", + "outcome": "partial", + "root": "deno", + "dropped": 22 + }, + { + "file": "deployctl.ts", + "outcome": "partial", + "root": "deployctl", + "dropped": 4 + }, + { + "file": "deta.ts", + "outcome": "partial", + "root": "deta", + "dropped": 2 + }, + { + "file": "df.ts", + "outcome": "full", + "root": "df" + }, + { + "file": "diff.ts", + "outcome": "partial", + "root": "diff", + "dropped": 2 + }, + { + "file": "dig.ts", + "outcome": "full", + "root": "dig" + }, + { + "file": "direnv.ts", + "outcome": "partial", + "root": "direnv", + "dropped": 11 + }, + { + "file": "dirname.ts", + "outcome": "full", + "root": "dirname" + }, + { + "file": "ditto.ts", + "outcome": "full", + "root": "ditto" + }, + { + "file": "django-admin.ts", + "outcome": "partial", + "root": "django-admin", + "dropped": 2 + }, + { + "file": "do-release-upgrade.ts", + "outcome": "full", + "root": "do-release-upgrade" + }, + { + "file": "do.ts", + "outcome": "full", + "root": "do" + }, + { + "file": "docker-compose.ts", + "outcome": "partial", + "root": "docker-compose", + "dropped": 46 + }, + { + "file": "docker.ts", + "outcome": "partial", + "root": "docker", + "dropped": 28534 + }, + { + "file": "doctl.ts", + "outcome": "full", + "root": "doctl" + }, + { + "file": "dog.ts", + "outcome": "full", + "root": "dog" + }, + { + "file": "doggo.ts", + "outcome": "full", + "root": "doggo" + }, + { + "file": "doppler.ts", + "outcome": "partial", + "root": "doppler", + "dropped": 191 + }, + { + "file": "dos2unix.ts", + "outcome": "full", + "root": "dos2unix" + }, + { + "file": "dotenv-vault.ts", + "outcome": "partial", + "root": "dotenv-vault", + "dropped": 3 + }, + { + "file": "dotenv.ts", + "outcome": "full", + "root": "dotenv" + }, + { + "file": "dotnet.ts", + "outcome": "partial", + "root": "dotnet", + "dropped": 73 + }, + { + "file": "dotslash.ts", + "outcome": "full", + "root": "dotslash" + }, + { + "file": "dpkg.ts", + "outcome": "full", + "root": "dpkg" + }, + { + "file": "dprint.ts", + "outcome": "partial", + "root": "dprint", + "dropped": 1 + }, + { + "file": "drush.ts", + "outcome": "partial", + "root": "drush", + "dropped": 1 + }, + { + "file": "dscacheutil.ts", + "outcome": "partial", + "root": "dscacheutil", + "dropped": 8 + }, + { + "file": "dscl.ts", + "outcome": "partial", + "root": "dscl", + "dropped": 120 + }, + { + "file": "dtm.ts", + "outcome": "partial", + "root": "dtm", + "dropped": 18 + }, + { + "file": "du.ts", + "outcome": "full", + "root": "du" + }, + { + "file": "dust.ts", + "outcome": "full", + "root": "dust" + }, + { + "file": "eas.ts", + "outcome": "partial", + "root": "eas", + "dropped": 7 + }, + { + "file": "eb.ts", + "outcome": "partial", + "root": "eb", + "dropped": 12 + }, + { + "file": "echo.ts", + "outcome": "partial", + "root": "echo", + "dropped": 2 + }, + { + "file": "electron.ts", + "outcome": "full", + "root": "electron" + }, + { + "file": "eleventy.ts", + "outcome": "full", + "root": "eleventy" + }, + { + "file": "elif.ts", + "outcome": "full", + "root": "elif" + }, + { + "file": "elixir.ts", + "outcome": "partial", + "root": "elixir", + "dropped": 1 + }, + { + "file": "elm-format.ts", + "outcome": "full", + "root": "elm-format" + }, + { + "file": "elm-json.ts", + "outcome": "partial", + "root": "elm-json", + "dropped": 13 + }, + { + "file": "elm-review.ts", + "outcome": "full", + "root": "elm-review" + }, + { + "file": "elm.ts", + "outcome": "partial", + "root": "elm", + "dropped": 3 + }, + { + "file": "else.ts", + "outcome": "full", + "root": "else" + }, + { + "file": "emacs.ts", + "outcome": "full", + "root": "emacs" + }, + { + "file": "enapter.ts", + "outcome": "full", + "root": "enapter" + }, + { + "file": "encore.ts", + "outcome": "full", + "root": "encore" + }, + { + "file": "env.ts", + "outcome": "partial", + "root": "env", + "dropped": 2 + }, + { + "file": "envchain.ts", + "outcome": "partial", + "root": "envchain", + "dropped": 4 + }, + { + "file": "esbuild.ts", + "outcome": "partial", + "root": "esbuild", + "dropped": 1 + }, + { + "file": "eslint.ts", + "outcome": "partial", + "root": "eslint", + "dropped": 4 + }, + { + "file": "exa.ts", + "outcome": "full", + "root": "exa" + }, + { + "file": "exec.ts", + "outcome": "full", + "root": "exec" + }, + { + "file": "exercism.ts", + "outcome": "full", + "root": "exercism" + }, + { + "file": "expo-cli.ts", + "outcome": "skipped", + "reason": "third-party copyright header without license grant" + }, + { + "file": "expo.ts", + "outcome": "skipped", + "reason": "third-party copyright header without license grant" + }, + { + "file": "export.ts", + "outcome": "skipped", + "reason": "no static spec (computed or generated)" + }, + { + "file": "expressots.ts", + "outcome": "partial", + "root": "expressots", + "dropped": 4 + }, + { + "file": "eza.ts", + "outcome": "full", + "root": "eza" + }, + { + "file": "fastlane.ts", + "outcome": "full", + "root": "fastlane" + }, + { + "file": "fastly.ts", + "outcome": "full", + "root": "fastly" + }, + { + "file": "fd.ts", + "outcome": "full", + "root": "fd" + }, + { + "file": "fdisk.ts", + "outcome": "full", + "root": "fdisk" + }, + { + "file": "ffmpeg.ts", + "outcome": "partial", + "root": "ffmpeg", + "dropped": 18 + }, + { + "file": "fig/index.ts", + "outcome": "partial", + "root": "fig", + "dropped": 28 + }, + { + "file": "figterm.ts", + "outcome": "full", + "root": "figterm" + }, + { + "file": "file.ts", + "outcome": "partial", + "root": "file", + "dropped": 1 + }, + { + "file": "fin.ts", + "outcome": "partial", + "root": "fin", + "dropped": 28593 + }, + { + "file": "find.ts", + "outcome": "full", + "root": "find" + }, + { + "file": "firebase.ts", + "outcome": "partial", + "root": "firebase", + "dropped": 2 + }, + { + "file": "firefox.ts", + "outcome": "partial", + "root": "firefox", + "dropped": 3 + }, + { + "file": "fisher.ts", + "outcome": "partial", + "root": "fisher", + "dropped": 4 + }, + { + "file": "flutter.ts", + "outcome": "partial", + "root": "flutter", + "dropped": 80 + }, + { + "file": "fly.ts", + "outcome": "partial", + "root": "fly", + "dropped": 184 + }, + { + "file": "flyctl.ts", + "outcome": "partial", + "root": "flyctl", + "dropped": 184 + }, + { + "file": "fmt.ts", + "outcome": "full", + "root": "fmt" + }, + { + "file": "fnm.ts", + "outcome": "partial", + "root": "fnm", + "dropped": 11 + }, + { + "file": "fold.ts", + "outcome": "full", + "root": "fold" + }, + { + "file": "for.ts", + "outcome": "full", + "root": "for" + }, + { + "file": "forc.ts", + "outcome": "full", + "root": "forc" + }, + { + "file": "forge.ts", + "outcome": "full", + "root": "@forge/cli" + }, + { + "file": "fvm.ts", + "outcome": "partial", + "root": "fvm", + "dropped": 82 + }, + { + "file": "fzf-tmux.ts", + "outcome": "full", + "root": "fzf-tmux" + }, + { + "file": "fzf.ts", + "outcome": "full", + "root": "fzf" + }, + { + "file": "g++.ts", + "outcome": "full", + "root": "g++" + }, + { + "file": "ganache-cli.ts", + "outcome": "full", + "root": "ganache-cli" + }, + { + "file": "gatsby.ts", + "outcome": "full", + "root": "gatsby" + }, + { + "file": "gcc.ts", + "outcome": "full", + "root": "gcc" + }, + { + "file": "gcloud.ts", + "outcome": "partial", + "root": "gcloud", + "dropped": 2 + }, + { + "file": "gem.ts", + "outcome": "partial", + "root": "gem", + "dropped": 45 + }, + { + "file": "gh.ts", + "outcome": "partial", + "root": "gh", + "dropped": 162 + }, + { + "file": "ghq.ts", + "outcome": "full", + "root": "ghq" + }, + { + "file": "gibo.ts", + "outcome": "partial", + "root": "gibo", + "dropped": 1 + }, + { + "file": "git-cliff.ts", + "outcome": "partial", + "root": "git-cliff", + "dropped": 2 + }, + { + "file": "git-flow.ts", + "outcome": "partial", + "root": "git-flow", + "dropped": 6 + }, + { + "file": "git-profile.ts", + "outcome": "partial", + "root": "git-profile", + "dropped": 2 + }, + { + "file": "git-quick-stats.ts", + "outcome": "full", + "root": "git-quick-stats" + }, + { + "file": "git.ts", + "outcome": "partial", + "root": "git", + "dropped": 1291 + }, + { + "file": "github.ts", + "outcome": "partial", + "root": "github", + "dropped": 2 + }, + { + "file": "glow.ts", + "outcome": "partial", + "root": "glow", + "dropped": 2 + }, + { + "file": "gltfjsx.ts", + "outcome": "full", + "root": "gltfjsx" + }, + { + "file": "go.ts", + "outcome": "partial", + "root": "go", + "dropped": 1 + }, + { + "file": "goctl.ts", + "outcome": "full", + "root": "goctl" + }, + { + "file": "goland.ts", + "outcome": "skipped", + "reason": "no static spec (computed or generated)" + }, + { + "file": "googler.ts", + "outcome": "full", + "root": "googler" + }, + { + "file": "goreleaser.ts", + "outcome": "full", + "root": "goreleaser" + }, + { + "file": "goto.ts", + "outcome": "partial", + "root": "goto", + "dropped": 6 + }, + { + "file": "gource.ts", + "outcome": "partial", + "root": "gource", + "dropped": 2 + }, + { + "file": "gpg.ts", + "outcome": "partial", + "root": "gpg", + "dropped": 12 + }, + { + "file": "gradle.ts", + "outcome": "full", + "root": "gradle" + }, + { + "file": "graphcdn.ts", + "outcome": "full", + "root": "graphcdn" + }, + { + "file": "grep.ts", + "outcome": "full", + "root": "grep" + }, + { + "file": "grex.ts", + "outcome": "full", + "root": "grex" + }, + { + "file": "gron.ts", + "outcome": "full", + "root": "gron" + }, + { + "file": "gt.ts", + "outcome": "full", + "root": "gt" + }, + { + "file": "gum.ts", + "outcome": "partial", + "root": "gum", + "dropped": 36 + }, + { + "file": "hardhat.ts", + "outcome": "full", + "root": "hardhat" + }, + { + "file": "hasura.ts", + "outcome": "partial", + "root": "hasura", + "dropped": 2 + }, + { + "file": "hb-service.ts", + "outcome": "full", + "root": "hb-service" + }, + { + "file": "head.ts", + "outcome": "full", + "root": "head" + }, + { + "file": "helm.ts", + "outcome": "full", + "root": "helm" + }, + { + "file": "helmfile.ts", + "outcome": "full", + "root": "helmfile" + }, + { + "file": "herd.ts", + "outcome": "partial", + "root": "herd", + "dropped": 48 + }, + { + "file": "heroku/index.ts", + "outcome": "partial", + "root": "heroku", + "dropped": 414 + }, + { + "file": "hexo.ts", + "outcome": "partial", + "root": "hexo", + "dropped": 2 + }, + { + "file": "homey.ts", + "outcome": "full", + "root": "homey" + }, + { + "file": "hop.ts", + "outcome": "full", + "root": "hop" + }, + { + "file": "hostname.ts", + "outcome": "full", + "root": "hostname" + }, + { + "file": "htop.ts", + "outcome": "full", + "root": "htop" + }, + { + "file": "http.ts", + "outcome": "full", + "root": "http" + }, + { + "file": "https.ts", + "outcome": "full", + "root": "https" + }, + { + "file": "httpy.ts", + "outcome": "full", + "root": "httpy" + }, + { + "file": "hub.ts", + "outcome": "skipped", + "reason": "no static spec (computed or generated)" + }, + { + "file": "hugo.ts", + "outcome": "partial", + "root": "hugo", + "dropped": 40 + }, + { + "file": "hx.ts", + "outcome": "full", + "root": "hx" + }, + { + "file": "hyper.ts", + "outcome": "partial", + "root": "hyper", + "dropped": 2 + }, + { + "file": "hyperfine.ts", + "outcome": "full", + "root": "hyperfine" + }, + { + "file": "ibus.ts", + "outcome": "partial", + "root": "ibus", + "dropped": 2 + }, + { + "file": "iconv.ts", + "outcome": "partial", + "root": "iconv", + "dropped": 4 + }, + { + "file": "id.ts", + "outcome": "partial", + "root": "id", + "dropped": 2 + }, + { + "file": "idea.ts", + "outcome": "skipped", + "reason": "no static spec (computed or generated)" + }, + { + "file": "iex.ts", + "outcome": "partial", + "root": "iex", + "dropped": 2 + }, + { + "file": "if.ts", + "outcome": "full", + "root": "if" + }, + { + "file": "ignite-cli.ts", + "outcome": "partial", + "root": "ignite-cli", + "dropped": 4 + }, + { + "file": "infracost/index.ts", + "outcome": "full", + "root": "infracost" + }, + { + "file": "install.ts", + "outcome": "full", + "root": "install" + }, + { + "file": "ionic.ts", + "outcome": "full", + "root": "ionic" + }, + { + "file": "ipatool.ts", + "outcome": "partial", + "root": "ipatool", + "dropped": 6 + }, + { + "file": "j.ts", + "outcome": "skipped", + "reason": "duplicate root name" + }, + { + "file": "java.ts", + "outcome": "partial", + "root": "java", + "dropped": 2 + }, + { + "file": "jenv.ts", + "outcome": "partial", + "root": "jenv", + "dropped": 26 + }, + { + "file": "jest.ts", + "outcome": "full", + "root": "jest" + }, + { + "file": "jmeter.ts", + "outcome": "full", + "root": "jmeter" + }, + { + "file": "join.ts", + "outcome": "full", + "root": "join" + }, + { + "file": "jq.ts", + "outcome": "full", + "root": "jq" + }, + { + "file": "julia.ts", + "outcome": "partial", + "root": "julia", + "dropped": 4 + }, + { + "file": "jupyter.ts", + "outcome": "full", + "root": "jupyter" + }, + { + "file": "just.ts", + "outcome": "partial", + "root": "just", + "dropped": 10 + }, + { + "file": "k3d.ts", + "outcome": "partial", + "root": "k3d", + "dropped": 32 + }, + { + "file": "k6.ts", + "outcome": "full", + "root": "k6" + }, + { + "file": "k9s.ts", + "outcome": "partial", + "root": "k9s", + "dropped": 2 + }, + { + "file": "kafkactl.ts", + "outcome": "full", + "root": "kafkactl" + }, + { + "file": "kamal.ts", + "outcome": "partial", + "root": "kamal", + "dropped": 1 + }, + { + "file": "kdoctor.ts", + "outcome": "full", + "root": "kdoctor" + }, + { + "file": "keytool.ts", + "outcome": "partial", + "root": "keytool", + "dropped": 2 + }, + { + "file": "kill.ts", + "outcome": "partial", + "root": "kill", + "dropped": 4 + }, + { + "file": "killall.ts", + "outcome": "partial", + "root": "killall", + "dropped": 5 + }, + { + "file": "kind.ts", + "outcome": "partial", + "root": "kind", + "dropped": 16 + }, + { + "file": "kitty.ts", + "outcome": "partial", + "root": "kitty", + "dropped": 2 + }, + { + "file": "klist.ts", + "outcome": "full", + "root": "klist" + }, + { + "file": "knex.ts", + "outcome": "full", + "root": "knex" + }, + { + "file": "kool.ts", + "outcome": "partial", + "root": "kool", + "dropped": 5 + }, + { + "file": "kotlinc.ts", + "outcome": "full", + "root": "kotlinc" + }, + { + "file": "kubecolor.ts", + "outcome": "skipped", + "reason": "no static spec (computed or generated)" + }, + { + "file": "kubectl.ts", + "outcome": "partial", + "root": "kubectl", + "dropped": 1776 + }, + { + "file": "kubectx.ts", + "outcome": "partial", + "root": "kubectx", + "dropped": 5 + }, + { + "file": "kubens.ts", + "outcome": "partial", + "root": "kubens", + "dropped": 3 + }, + { + "file": "laravel.ts", + "outcome": "full", + "root": "laravel" + }, + { + "file": "launchctl.ts", + "outcome": "partial", + "root": "launchctl", + "dropped": 10 + }, + { + "file": "ldd.ts", + "outcome": "full", + "root": "ldd" + }, + { + "file": "leaf.ts", + "outcome": "full", + "root": "leaf" + }, + { + "file": "lerna.ts", + "outcome": "partial", + "root": "lerna", + "dropped": 45 + }, + { + "file": "less.ts", + "outcome": "full", + "root": "less" + }, + { + "file": "lima.ts", + "outcome": "full", + "root": "lima" + }, + { + "file": "limactl.ts", + "outcome": "partial", + "root": "limactl", + "dropped": 26 + }, + { + "file": "ln.ts", + "outcome": "full", + "root": "ln" + }, + { + "file": "locust.ts", + "outcome": "full", + "root": "locust" + }, + { + "file": "login.ts", + "outcome": "partial", + "root": "login", + "dropped": 2 + }, + { + "file": "lp.ts", + "outcome": "partial", + "root": "lp", + "dropped": 1 + }, + { + "file": "lpass.ts", + "outcome": "full", + "root": "lpass" + }, + { + "file": "ls.ts", + "outcome": "full", + "root": "ls" + }, + { + "file": "lsblk.ts", + "outcome": "full", + "root": "lsblk" + }, + { + "file": "lsd.ts", + "outcome": "full", + "root": "lsd" + }, + { + "file": "lsof.ts", + "outcome": "partial", + "root": "lsof", + "dropped": 5 + }, + { + "file": "luz.ts", + "outcome": "full", + "root": "luz" + }, + { + "file": "lvim.ts", + "outcome": "full", + "root": "lvim" + }, + { + "file": "m.ts", + "outcome": "partial", + "root": "m", + "dropped": 48 + }, + { + "file": "mackup.ts", + "outcome": "partial", + "root": "mackup", + "dropped": 2 + }, + { + "file": "magento.ts", + "outcome": "partial", + "root": "magento", + "dropped": 1 + }, + { + "file": "maigret.ts", + "outcome": "full", + "root": "maigret" + }, + { + "file": "mailsy.ts", + "outcome": "full", + "root": "mailsy" + }, + { + "file": "make.ts", + "outcome": "partial", + "root": "make", + "dropped": 8 + }, + { + "file": "mamba.ts", + "outcome": "partial", + "root": "mamba", + "dropped": 21 + }, + { + "file": "man.ts", + "outcome": "partial", + "root": "man", + "dropped": 5 + }, + { + "file": "mas.ts", + "outcome": "full", + "root": "mas" + }, + { + "file": "mask.ts", + "outcome": "partial", + "root": "mask", + "dropped": 1 + }, + { + "file": "mdfind.ts", + "outcome": "partial", + "root": "mdfind", + "dropped": 2 + }, + { + "file": "mdls.ts", + "outcome": "full", + "root": "mdls" + }, + { + "file": "meroxa.ts", + "outcome": "full", + "root": "meroxa" + }, + { + "file": "meteor.ts", + "outcome": "partial", + "root": "meteor", + "dropped": 81 + }, + { + "file": "mgnl.ts", + "outcome": "partial", + "root": "mgnl", + "dropped": 29 + }, + { + "file": "micro.ts", + "outcome": "full", + "root": "micro" + }, + { + "file": "mikro-orm.ts", + "outcome": "full", + "root": "mikro-orm" + }, + { + "file": "minectl.ts", + "outcome": "full", + "root": "minectl" + }, + { + "file": "minikube.ts", + "outcome": "full", + "root": "minikube" + }, + { + "file": "mix.ts", + "outcome": "partial", + "root": "mix", + "dropped": 7 + }, + { + "file": "mkdir.ts", + "outcome": "full", + "root": "mkdir" + }, + { + "file": "mkdocs.ts", + "outcome": "partial", + "root": "mkdocs", + "dropped": 4 + }, + { + "file": "mkfifo.ts", + "outcome": "full", + "root": "mkfifo" + }, + { + "file": "mkinitcpio.ts", + "outcome": "partial", + "root": "mkinitcpio", + "dropped": 2 + }, + { + "file": "mknod.ts", + "outcome": "full", + "root": "mknod" + }, + { + "file": "mob.ts", + "outcome": "full", + "root": "mob" + }, + { + "file": "molecule.ts", + "outcome": "full", + "root": "molecule" + }, + { + "file": "mongocli.ts", + "outcome": "full", + "root": "mongocli" + }, + { + "file": "mongoimport.ts", + "outcome": "partial", + "root": "mongoimport", + "dropped": 6 + }, + { + "file": "mongosh.ts", + "outcome": "partial", + "root": "mongosh", + "dropped": 3 + }, + { + "file": "more.ts", + "outcome": "full", + "root": "more" + }, + { + "file": "mosh.ts", + "outcome": "partial", + "root": "mosh", + "dropped": 3 + }, + { + "file": "mount.ts", + "outcome": "partial", + "root": "mount", + "dropped": 3 + }, + { + "file": "multipass.ts", + "outcome": "partial", + "root": "multipass", + "dropped": 119 + }, + { + "file": "mv.ts", + "outcome": "full", + "root": "mv" + }, + { + "file": "mvn.ts", + "outcome": "full", + "root": "mvn" + }, + { + "file": "mypy.ts", + "outcome": "full", + "root": "mypy" + }, + { + "file": "mysql.ts", + "outcome": "full", + "root": "mysql" + }, + { + "file": "n.ts", + "outcome": "partial", + "root": "n", + "dropped": 20 + }, + { + "file": "nano.ts", + "outcome": "full", + "root": "nano" + }, + { + "file": "nativescript.ts", + "outcome": "skipped", + "reason": "no static spec (computed or generated)" + }, + { + "file": "nc.ts", + "outcome": "full", + "root": "nc" + }, + { + "file": "ncal.ts", + "outcome": "partial", + "root": "ncal", + "dropped": 2 + }, + { + "file": "ncu.ts", + "outcome": "full", + "root": "ncu" + }, + { + "file": "neofetch.ts", + "outcome": "partial", + "root": "neofetch", + "dropped": 2 + }, + { + "file": "nest.ts", + "outcome": "full", + "root": "nest" + }, + { + "file": "netlify.ts", + "outcome": "full", + "root": "netlify" + }, + { + "file": "networkQuality.ts", + "outcome": "partial", + "root": "networkQuality", + "dropped": 2 + }, + { + "file": "networksetup.ts", + "outcome": "partial", + "root": "networksetup", + "dropped": 148 + }, + { + "file": "newman.ts", + "outcome": "full", + "root": "newman" + }, + { + "file": "next.ts", + "outcome": "partial", + "root": "next", + "dropped": 2 + }, + { + "file": "nextflow.ts", + "outcome": "partial", + "root": "nextflow", + "dropped": 42 + }, + { + "file": "ng.ts", + "outcome": "partial", + "root": "ng", + "dropped": 4 + }, + { + "file": "nginx.ts", + "outcome": "full", + "root": "nginx" + }, + { + "file": "ngrok.ts", + "outcome": "full", + "root": "ngrok" + }, + { + "file": "nhost.ts", + "outcome": "full", + "root": "nhost" + }, + { + "file": "ni.ts", + "outcome": "partial", + "root": "ni", + "dropped": 4 + }, + { + "file": "nl.ts", + "outcome": "full", + "root": "nl" + }, + { + "file": "nmap.ts", + "outcome": "full", + "root": "nmap" + }, + { + "file": "nocorrect.ts", + "outcome": "skipped", + "reason": "no static spec (computed or generated)" + }, + { + "file": "node.ts", + "outcome": "partial", + "root": "node", + "dropped": 2 + }, + { + "file": "noglob.ts", + "outcome": "skipped", + "reason": "no static spec (computed or generated)" + }, + { + "file": "northflank.ts", + "outcome": "full", + "root": "northflank" + }, + { + "file": "np.ts", + "outcome": "full", + "root": "np" + }, + { + "file": "npm.ts", + "outcome": "partial", + "root": "npm", + "dropped": 66 + }, + { + "file": "npx.ts", + "outcome": "partial", + "root": "npx", + "dropped": 3 + }, + { + "file": "nr.ts", + "outcome": "partial", + "root": "nr", + "dropped": 2 + }, + { + "file": "nrm.ts", + "outcome": "partial", + "root": "nrm", + "dropped": 3 + }, + { + "file": "ns.ts", + "outcome": "partial", + "root": "ns", + "dropped": 27 + }, + { + "file": "nu.ts", + "outcome": "full", + "root": "nu" + }, + { + "file": "nuxi.ts", + "outcome": "partial", + "root": "nuxi", + "dropped": 1 + }, + { + "file": "nuxt.ts", + "outcome": "full", + "root": "nuxt" + }, + { + "file": "nvim.ts", + "outcome": "full", + "root": "nvim" + }, + { + "file": "nvm.ts", + "outcome": "full", + "root": "nvm" + }, + { + "file": "nx.ts", + "outcome": "partial", + "root": "nx", + "dropped": 18926 + }, + { + "file": "nylas.ts", + "outcome": "full", + "root": "nylas" + }, + { + "file": "oci.ts", + "outcome": "full", + "root": "oci" + }, + { + "file": "od.ts", + "outcome": "full", + "root": "od" + }, + { + "file": "oh-my-posh.ts", + "outcome": "full", + "root": "oh-my-posh" + }, + { + "file": "okta.ts", + "outcome": "full", + "root": "okta" + }, + { + "file": "okteto.ts", + "outcome": "partial", + "root": "okteto", + "dropped": 12 + }, + { + "file": "ollama.ts", + "outcome": "partial", + "root": "ollama", + "dropped": 10 + }, + { + "file": "omz.ts", + "outcome": "full", + "root": "omz" + }, + { + "file": "onboardbase.ts", + "outcome": "full", + "root": "onboardbase" + }, + { + "file": "op.ts", + "outcome": "partial", + "root": "op", + "dropped": 2 + }, + { + "file": "opa.ts", + "outcome": "full", + "root": "opa" + }, + { + "file": "open.ts", + "outcome": "partial", + "root": "open", + "dropped": 2 + }, + { + "file": "osascript.ts", + "outcome": "partial", + "root": "osascript", + "dropped": 1 + }, + { + "file": "osqueryi.ts", + "outcome": "partial", + "root": "osqueryi", + "dropped": 2 + }, + { + "file": "oxlint.ts", + "outcome": "partial", + "root": "oxlint", + "dropped": 27 + }, + { + "file": "pac.ts", + "outcome": "full", + "root": "pac" + }, + { + "file": "pageres.ts", + "outcome": "full", + "root": "pageres" + }, + { + "file": "palera1n.ts", + "outcome": "full", + "root": "palera1n" + }, + { + "file": "pandoc.ts", + "outcome": "partial", + "root": "pandoc", + "dropped": 42 + }, + { + "file": "paper.ts", + "outcome": "full", + "root": "paper" + }, + { + "file": "pass.ts", + "outcome": "partial", + "root": "pass", + "dropped": 1305 + }, + { + "file": "passwd.ts", + "outcome": "partial", + "root": "passwd", + "dropped": 2 + }, + { + "file": "paste.ts", + "outcome": "full", + "root": "paste" + }, + { + "file": "pathchk.ts", + "outcome": "full", + "root": "pathchk" + }, + { + "file": "pdfunite.ts", + "outcome": "partial", + "root": "pdfunite", + "dropped": 1 + }, + { + "file": "pg_dump.ts", + "outcome": "full", + "root": "pg_dump" + }, + { + "file": "pgcli.ts", + "outcome": "full", + "root": "pgcli" + }, + { + "file": "php.ts", + "outcome": "partial", + "root": "php", + "dropped": 1 + }, + { + "file": "phpstorm.ts", + "outcome": "skipped", + "reason": "no static spec (computed or generated)" + }, + { + "file": "phpunit-watcher.ts", + "outcome": "partial", + "root": "phpunit-watcher", + "dropped": 2 + }, + { + "file": "phpunit.ts", + "outcome": "full", + "root": "phpunit" + }, + { + "file": "pijul.ts", + "outcome": "full", + "root": "pijul" + }, + { + "file": "ping.ts", + "outcome": "full", + "root": "ping" + }, + { + "file": "pip.ts", + "outcome": "partial", + "root": "pip", + "dropped": 4 + }, + { + "file": "pip3.ts", + "outcome": "partial", + "root": "pip3", + "dropped": 4 + }, + { + "file": "pipenv.ts", + "outcome": "partial", + "root": "pipenv", + "dropped": 1 + }, + { + "file": "pipx.ts", + "outcome": "partial", + "root": "pipx", + "dropped": 8 + }, + { + "file": "pkg-config.ts", + "outcome": "full", + "root": "pkg-config" + }, + { + "file": "pkgutil.ts", + "outcome": "partial", + "root": "pkgutil", + "dropped": 56 + }, + { + "file": "pkill.ts", + "outcome": "full", + "root": "pkill" + }, + { + "file": "planter.ts", + "outcome": "full", + "root": "planter" + }, + { + "file": "playwright.ts", + "outcome": "full", + "root": "playwright" + }, + { + "file": "plutil.ts", + "outcome": "full", + "root": "plutil" + }, + { + "file": "pm2.ts", + "outcome": "partial", + "root": "pm2", + "dropped": 6 + }, + { + "file": "pmset.ts", + "outcome": "full", + "root": "pmset" + }, + { + "file": "pnpm.ts", + "outcome": "partial", + "root": "pnpm", + "dropped": 32 + }, + { + "file": "pnpx.ts", + "outcome": "partial", + "root": "pnpx", + "dropped": 25 + }, + { + "file": "pocketbase.ts", + "outcome": "full", + "root": "pocketbase" + }, + { + "file": "pod.ts", + "outcome": "partial", + "root": "pod", + "dropped": 5 + }, + { + "file": "podman.ts", + "outcome": "partial", + "root": "podman", + "dropped": 17017 + }, + { + "file": "poetry.ts", + "outcome": "full", + "root": "poetry" + }, + { + "file": "pre-commit.ts", + "outcome": "partial", + "root": "pre-commit", + "dropped": 154 + }, + { + "file": "premake.ts", + "outcome": "full", + "root": "premake" + }, + { + "file": "preset.ts", + "outcome": "skipped", + "reason": "no static spec (computed or generated)" + }, + { + "file": "prettier.ts", + "outcome": "full", + "root": "prettier" + }, + { + "file": "prisma.ts", + "outcome": "full", + "root": "prisma" + }, + { + "file": "pro.ts", + "outcome": "full", + "root": "pro" + }, + { + "file": "progressline.ts", + "outcome": "full", + "root": "progressline" + }, + { + "file": "projj.ts", + "outcome": "partial", + "root": "projj", + "dropped": 8 + }, + { + "file": "pry.ts", + "outcome": "full", + "root": "pry" + }, + { + "file": "ps.ts", + "outcome": "full", + "root": "ps" + }, + { + "file": "pscale.ts", + "outcome": "partial", + "root": "pscale", + "dropped": 3 + }, + { + "file": "psql.ts", + "outcome": "full", + "root": "psql" + }, + { + "file": "publish.ts", + "outcome": "full", + "root": "publish" + }, + { + "file": "pulumi.ts", + "outcome": "partial", + "root": "pulumi", + "dropped": 40 + }, + { + "file": "pushd.ts", + "outcome": "full", + "root": "pushd" + }, + { + "file": "pwd.ts", + "outcome": "full", + "root": "pwd" + }, + { + "file": "pycharm.ts", + "outcome": "skipped", + "reason": "no static spec (computed or generated)" + }, + { + "file": "pyenv.ts", + "outcome": "partial", + "root": "pyenv", + "dropped": 4 + }, + { + "file": "pytest.ts", + "outcome": "full", + "root": "pytest" + }, + { + "file": "python.ts", + "outcome": "partial", + "root": "python", + "dropped": 2 + }, + { + "file": "python3.ts", + "outcome": "partial", + "root": "python3", + "dropped": 2 + }, + { + "file": "q.ts", + "outcome": "partial", + "root": "q", + "dropped": 2 + }, + { + "file": "qodana.ts", + "outcome": "full", + "root": "qodana" + }, + { + "file": "quasar.ts", + "outcome": "full", + "root": "quasar" + }, + { + "file": "quickmail.ts", + "outcome": "partial", + "root": "quickmail", + "dropped": 4 + }, + { + "file": "r.ts", + "outcome": "partial", + "root": "R", + "dropped": 30 + }, + { + "file": "rails.ts", + "outcome": "partial", + "root": "rails", + "dropped": 1 + }, + { + "file": "railway.ts", + "outcome": "full", + "root": "railway" + }, + { + "file": "rake.ts", + "outcome": "partial", + "root": "rake", + "dropped": 2 + }, + { + "file": "rancher.ts", + "outcome": "partial", + "root": "rancher", + "dropped": 5 + }, + { + "file": "rbenv.ts", + "outcome": "partial", + "root": "rbenv", + "dropped": 5 + }, + { + "file": "rclone.ts", + "outcome": "partial", + "root": "rclone", + "dropped": 59 + }, + { + "file": "react-native.ts", + "outcome": "partial", + "root": "react-native", + "dropped": 23 + }, + { + "file": "readlink.ts", + "outcome": "full", + "root": "readlink" + }, + { + "file": "redwood.ts", + "outcome": "partial", + "root": "redwood", + "dropped": 2 + }, + { + "file": "remix.ts", + "outcome": "full", + "root": "remix" + }, + { + "file": "remotion.ts", + "outcome": "full", + "root": "remotion" + }, + { + "file": "repeat.ts", + "outcome": "full", + "root": "repeat" + }, + { + "file": "rg.ts", + "outcome": "full", + "root": "rg" + }, + { + "file": "rich.ts", + "outcome": "partial", + "root": "rich", + "dropped": 9 + }, + { + "file": "rm.ts", + "outcome": "full", + "root": "rm" + }, + { + "file": "rmdir.ts", + "outcome": "full", + "root": "rmdir" + }, + { + "file": "robot.ts", + "outcome": "partial", + "root": "robot", + "dropped": 29 + }, + { + "file": "rojo.ts", + "outcome": "partial", + "root": "rojo", + "dropped": 2 + }, + { + "file": "rollup.ts", + "outcome": "partial", + "root": "rollup", + "dropped": 1 + }, + { + "file": "rome.ts", + "outcome": "full", + "root": "rome" + }, + { + "file": "rscript.ts", + "outcome": "partial", + "root": "Rscript", + "dropped": 1 + }, + { + "file": "rsync.ts", + "outcome": "partial", + "root": "rsync", + "dropped": 7 + }, + { + "file": "rubocop.ts", + "outcome": "partial", + "root": "rubocop", + "dropped": 8 + }, + { + "file": "ruby.ts", + "outcome": "full", + "root": "ruby" + }, + { + "file": "rubymine.ts", + "outcome": "skipped", + "reason": "no static spec (computed or generated)" + }, + { + "file": "ruff.ts", + "outcome": "partial", + "root": "ruff", + "dropped": 1 + }, + { + "file": "rugby.ts", + "outcome": "partial", + "root": "rugby", + "dropped": 3 + }, + { + "file": "rush.ts", + "outcome": "partial", + "root": "rush", + "dropped": 40 + }, + { + "file": "rushx.ts", + "outcome": "partial", + "root": "rushx", + "dropped": 3 + }, + { + "file": "rustc.ts", + "outcome": "full", + "root": "rustc" + }, + { + "file": "rustrover.ts", + "outcome": "skipped", + "reason": "no static spec (computed or generated)" + }, + { + "file": "rustup.ts", + "outcome": "partial", + "root": "rustup", + "dropped": 19 + }, + { + "file": "rvm.ts", + "outcome": "full", + "root": "rvm" + }, + { + "file": "sake.ts", + "outcome": "partial", + "root": "sake", + "dropped": 3 + }, + { + "file": "sam.ts", + "outcome": "partial", + "root": "sam", + "dropped": 25 + }, + { + "file": "sanity.ts", + "outcome": "full", + "root": "sanity" + }, + { + "file": "sapphire.ts", + "outcome": "full", + "root": "sapphire" + }, + { + "file": "scarb.ts", + "outcome": "partial", + "root": "scarb", + "dropped": 12 + }, + { + "file": "scc.ts", + "outcome": "partial", + "root": "scc", + "dropped": 7 + }, + { + "file": "scp.ts", + "outcome": "partial", + "root": "scp", + "dropped": 6 + }, + { + "file": "screen.ts", + "outcome": "full", + "root": "screen" + }, + { + "file": "sed.ts", + "outcome": "full", + "root": "sed" + }, + { + "file": "seq.ts", + "outcome": "full", + "root": "seq" + }, + { + "file": "sequelize.ts", + "outcome": "partial", + "root": "sequelize", + "dropped": 1 + }, + { + "file": "serve.ts", + "outcome": "full", + "root": "serve" + }, + { + "file": "serverless.ts", + "outcome": "partial", + "root": "serverless", + "dropped": 1 + }, + { + "file": "sfdx.ts", + "outcome": "full", + "root": "sfdx" + }, + { + "file": "sftp.ts", + "outcome": "partial", + "root": "sftp", + "dropped": 6 + }, + { + "file": "sha1sum.ts", + "outcome": "full", + "root": "sha1sum" + }, + { + "file": "shadcn-ui.ts", + "outcome": "partial", + "root": "shadcn-ui", + "dropped": 4 + }, + { + "file": "shasum.ts", + "outcome": "full", + "root": "shasum" + }, + { + "file": "shell-config.ts", + "outcome": "full", + "root": "main" + }, + { + "file": "shelve.ts", + "outcome": "full", + "root": "shelve" + }, + { + "file": "shopify/index.ts", + "outcome": "full", + "root": "shopify" + }, + { + "file": "shortcuts.ts", + "outcome": "partial", + "root": "shortcuts", + "dropped": 8 + }, + { + "file": "shred.ts", + "outcome": "full", + "root": "shred" + }, + { + "file": "sidekiq.ts", + "outcome": "partial", + "root": "sidekiq", + "dropped": 1 + }, + { + "file": "simctl.ts", + "outcome": "full", + "root": "simctl" + }, + { + "file": "sips.ts", + "outcome": "full", + "root": "sips" + }, + { + "file": "sl.ts", + "outcome": "full", + "root": "sl" + }, + { + "file": "sls.ts", + "outcome": "partial", + "root": "sls", + "dropped": 1 + }, + { + "file": "snaplet.ts", + "outcome": "partial", + "root": "snaplet", + "dropped": 12 + }, + { + "file": "softwareupdate.ts", + "outcome": "partial", + "root": "softwareupdate", + "dropped": 3 + }, + { + "file": "sort.ts", + "outcome": "full", + "root": "sort" + }, + { + "file": "source.ts", + "outcome": "full", + "root": "source" + }, + { + "file": "space.ts", + "outcome": "full", + "root": "space" + }, + { + "file": "speedtest-cli.ts", + "outcome": "full", + "root": "speedtest-cli" + }, + { + "file": "speedtest.ts", + "outcome": "full", + "root": "speedtest" + }, + { + "file": "splash.ts", + "outcome": "full", + "root": "splash-cli" + }, + { + "file": "split.ts", + "outcome": "full", + "root": "split" + }, + { + "file": "spotify.ts", + "outcome": "full", + "root": "spotify" + }, + { + "file": "spring.ts", + "outcome": "partial", + "root": "spring", + "dropped": 11 + }, + { + "file": "sqlfluff.ts", + "outcome": "full", + "root": "sqlfluff" + }, + { + "file": "sqlite3.ts", + "outcome": "partial", + "root": "sqlite3", + "dropped": 2 + }, + { + "file": "sqlmesh.ts", + "outcome": "full", + "root": "sqlmesh" + }, + { + "file": "src.ts", + "outcome": "full", + "root": "src" + }, + { + "file": "ssh-keygen.ts", + "outcome": "partial", + "root": "ssh-keygen", + "dropped": 19 + }, + { + "file": "ssh.ts", + "outcome": "partial", + "root": "ssh", + "dropped": 3 + }, + { + "file": "st2.ts", + "outcome": "partial", + "root": "st2", + "dropped": 20 + }, + { + "file": "sta.ts", + "outcome": "skipped", + "reason": "no static spec (computed or generated)" + }, + { + "file": "stack.ts", + "outcome": "full", + "root": "stack" + }, + { + "file": "starkli.ts", + "outcome": "full", + "root": "starkli" + }, + { + "file": "start-storybook.ts", + "outcome": "full", + "root": "start-storybook" + }, + { + "file": "stat.ts", + "outcome": "full", + "root": "stat" + }, + { + "file": "steadybit.ts", + "outcome": "full", + "root": "steadybit" + }, + { + "file": "stencil.ts", + "outcome": "full", + "root": "stencil" + }, + { + "file": "stepzen.ts", + "outcome": "partial", + "root": "StepZen", + "dropped": 4 + }, + { + "file": "stow.ts", + "outcome": "full", + "root": "stow" + }, + { + "file": "streamlit.ts", + "outcome": "partial", + "root": "streamlit", + "dropped": 1 + }, + { + "file": "stripe.ts", + "outcome": "full", + "root": "stripe" + }, + { + "file": "su.ts", + "outcome": "full", + "root": "su" + }, + { + "file": "subl.ts", + "outcome": "partial", + "root": "subl", + "dropped": 1 + }, + { + "file": "sudo.ts", + "outcome": "full", + "root": "sudo" + }, + { + "file": "suitecloud.ts", + "outcome": "full", + "root": "suitecloud" + }, + { + "file": "supabase.ts", + "outcome": "full", + "root": "supabase" + }, + { + "file": "surreal.ts", + "outcome": "partial", + "root": "surreal", + "dropped": 4 + }, + { + "file": "svn.ts", + "outcome": "full", + "root": "svn" + }, + { + "file": "svokit.ts", + "outcome": "full", + "root": "svokit" + }, + { + "file": "svtplay-dl.ts", + "outcome": "partial", + "root": "svtplay-dl", + "dropped": 2 + }, + { + "file": "sw_vers.ts", + "outcome": "full", + "root": "sw_vers" + }, + { + "file": "swagger-typescript-api.ts", + "outcome": "full", + "root": "swagger-typescript-api" + }, + { + "file": "swc.ts", + "outcome": "full", + "root": "swc" + }, + { + "file": "swift.ts", + "outcome": "partial", + "root": "swift", + "dropped": 4 + }, + { + "file": "symfony.ts", + "outcome": "full", + "root": "symfony" + }, + { + "file": "sysctl.ts", + "outcome": "partial", + "root": "sysctl", + "dropped": 2 + }, + { + "file": "systemctl.ts", + "outcome": "partial", + "root": "systemctl", + "dropped": 62 + }, + { + "file": "tac.ts", + "outcome": "full", + "root": "tac" + }, + { + "file": "tail.ts", + "outcome": "full", + "root": "tail" + }, + { + "file": "tailcall.ts", + "outcome": "full", + "root": "tailcall" + }, + { + "file": "tailscale.ts", + "outcome": "partial", + "root": "tailscale", + "dropped": 3 + }, + { + "file": "tailwindcss.ts", + "outcome": "full", + "root": "tailwindcss" + }, + { + "file": "tangram.ts", + "outcome": "full", + "root": "tangram" + }, + { + "file": "taplo.ts", + "outcome": "full", + "root": "taplo" + }, + { + "file": "tar.ts", + "outcome": "full", + "root": "tar" + }, + { + "file": "task.ts", + "outcome": "partial", + "root": "task", + "dropped": 1 + }, + { + "file": "tb.ts", + "outcome": "full", + "root": "tb" + }, + { + "file": "tccutil.ts", + "outcome": "partial", + "root": "tccutil", + "dropped": 2 + }, + { + "file": "tee.ts", + "outcome": "full", + "root": "tee" + }, + { + "file": "terraform.ts", + "outcome": "partial", + "root": "terraform", + "dropped": 6 + }, + { + "file": "terragrunt.ts", + "outcome": "partial", + "root": "terragrunt", + "dropped": 6 + }, + { + "file": "tfenv.ts", + "outcome": "partial", + "root": "tfenv", + "dropped": 9 + }, + { + "file": "tfsec.ts", + "outcome": "partial", + "root": "tfsec", + "dropped": 2 + }, + { + "file": "then.ts", + "outcome": "full", + "root": "then" + }, + { + "file": "time.ts", + "outcome": "full", + "root": "time" + }, + { + "file": "tkn.ts", + "outcome": "full", + "root": "tkn" + }, + { + "file": "tldr.ts", + "outcome": "partial", + "root": "tldr", + "dropped": 11 + }, + { + "file": "tmutil.ts", + "outcome": "full", + "root": "tmutil" + }, + { + "file": "tmux.ts", + "outcome": "partial", + "root": "tmux", + "dropped": 73 + }, + { + "file": "tmuxinator.ts", + "outcome": "partial", + "root": "tmuxinator", + "dropped": 16 + }, + { + "file": "tns.ts", + "outcome": "skipped", + "reason": "no static spec (computed or generated)" + }, + { + "file": "tokei.ts", + "outcome": "partial", + "root": "tokei", + "dropped": 1 + }, + { + "file": "top.ts", + "outcome": "full", + "root": "top" + }, + { + "file": "touch.ts", + "outcome": "full", + "root": "touch" + }, + { + "file": "tr.ts", + "outcome": "full", + "root": "tr" + }, + { + "file": "traceroute.ts", + "outcome": "full", + "root": "traceroute" + }, + { + "file": "trap.ts", + "outcome": "partial", + "root": "trap", + "dropped": 1 + }, + { + "file": "trash.ts", + "outcome": "full", + "root": "trash" + }, + { + "file": "tree.ts", + "outcome": "full", + "root": "tree" + }, + { + "file": "trex.ts", + "outcome": "partial", + "root": "trex", + "dropped": 6 + }, + { + "file": "trivy.ts", + "outcome": "partial", + "root": "trivy", + "dropped": 36 + }, + { + "file": "truffle.ts", + "outcome": "partial", + "root": "truffle", + "dropped": 1 + }, + { + "file": "truncate.ts", + "outcome": "full", + "root": "truncate" + }, + { + "file": "trunk.ts", + "outcome": "full", + "root": "trunk" + }, + { + "file": "ts-node.ts", + "outcome": "partial", + "root": "ts-node", + "dropped": 8 + }, + { + "file": "tsc.ts", + "outcome": "partial", + "root": "tsc", + "dropped": 1 + }, + { + "file": "tsh.ts", + "outcome": "partial", + "root": "tsh", + "dropped": 172 + }, + { + "file": "tsuru.ts", + "outcome": "partial", + "root": "tsuru", + "dropped": 1148 + }, + { + "file": "tsx.ts", + "outcome": "partial", + "root": "tsx", + "dropped": 4 + }, + { + "file": "tuist.ts", + "outcome": "full", + "root": "tuist" + }, + { + "file": "turbo.ts", + "outcome": "partial", + "root": "turbo", + "dropped": 17 + }, + { + "file": "twiggy.ts", + "outcome": "full", + "root": "twiggy" + }, + { + "file": "twilio.ts", + "outcome": "partial", + "root": "twilio", + "dropped": 1 + }, + { + "file": "typeorm.ts", + "outcome": "full", + "root": "typeorm" + }, + { + "file": "typos.ts", + "outcome": "full", + "root": "typos" + }, + { + "file": "typst.ts", + "outcome": "partial", + "root": "typst", + "dropped": 3 + }, + { + "file": "ua.ts", + "outcome": "full", + "root": "ua" + }, + { + "file": "ubuntu-advantage.ts", + "outcome": "full", + "root": "ubuntu-advantage" + }, + { + "file": "uname.ts", + "outcome": "full", + "root": "uname" + }, + { + "file": "uniq.ts", + "outcome": "full", + "root": "uniq" + }, + { + "file": "unix2dos.ts", + "outcome": "full", + "root": "unix2dos" + }, + { + "file": "unset.ts", + "outcome": "partial", + "root": "unset", + "dropped": 2 + }, + { + "file": "until.ts", + "outcome": "full", + "root": "until" + }, + { + "file": "unzip.ts", + "outcome": "full", + "root": "unzip" + }, + { + "file": "uv.ts", + "outcome": "partial", + "root": "uv", + "dropped": 23 + }, + { + "file": "v.ts", + "outcome": "partial", + "root": "v", + "dropped": 2 + }, + { + "file": "vale.ts", + "outcome": "full", + "root": "vale" + }, + { + "file": "valet.ts", + "outcome": "partial", + "root": "valet", + "dropped": 2 + }, + { + "file": "vapor.ts", + "outcome": "full", + "root": "vapor" + }, + { + "file": "vault.ts", + "outcome": "full", + "root": "vault" + }, + { + "file": "vela.ts", + "outcome": "full", + "root": "vela" + }, + { + "file": "vercel.ts", + "outcome": "full", + "root": "vercel" + }, + { + "file": "vi.ts", + "outcome": "full", + "root": "vi" + }, + { + "file": "vim.ts", + "outcome": "full", + "root": "vim" + }, + { + "file": "vimr.ts", + "outcome": "full", + "root": "vimr" + }, + { + "file": "visudo.ts", + "outcome": "full", + "root": "visudo" + }, + { + "file": "vite.ts", + "outcome": "partial", + "root": "vite", + "dropped": 2 + }, + { + "file": "volta.ts", + "outcome": "full", + "root": "volta" + }, + { + "file": "vr.ts", + "outcome": "partial", + "root": "vr", + "dropped": 6 + }, + { + "file": "vsce.ts", + "outcome": "partial", + "root": "vsce", + "dropped": 62 + }, + { + "file": "vtex.ts", + "outcome": "full", + "root": "vtex" + }, + { + "file": "vue.ts", + "outcome": "full", + "root": "vue" + }, + { + "file": "vultr-cli.ts", + "outcome": "partial", + "root": "vultr-cli", + "dropped": 4 + }, + { + "file": "w.ts", + "outcome": "full", + "root": "w" + }, + { + "file": "wasm-bindgen.ts", + "outcome": "partial", + "root": "wasm-bindgen", + "dropped": 1 + }, + { + "file": "wasm-pack.ts", + "outcome": "full", + "root": "wasm-pack" + }, + { + "file": "watchman.ts", + "outcome": "full", + "root": "watchman" + }, + { + "file": "watson.ts", + "outcome": "partial", + "root": "watson", + "dropped": 35 + }, + { + "file": "wc.ts", + "outcome": "full", + "root": "wc" + }, + { + "file": "wd.ts", + "outcome": "partial", + "root": "wd", + "dropped": 10 + }, + { + "file": "webpack.ts", + "outcome": "full", + "root": "webpack" + }, + { + "file": "webstorm.ts", + "outcome": "skipped", + "reason": "no static spec (computed or generated)" + }, + { + "file": "wezterm.ts", + "outcome": "full", + "root": "wezterm" + }, + { + "file": "wget.ts", + "outcome": "full", + "root": "wget" + }, + { + "file": "whence.ts", + "outcome": "full", + "root": "whence" + }, + { + "file": "where.ts", + "outcome": "full", + "root": "where" + }, + { + "file": "whereis.ts", + "outcome": "full", + "root": "whereis" + }, + { + "file": "which.ts", + "outcome": "partial", + "root": "which", + "dropped": 2 + }, + { + "file": "while.ts", + "outcome": "full", + "root": "while" + }, + { + "file": "who.ts", + "outcome": "full", + "root": "who" + }, + { + "file": "whois.ts", + "outcome": "full", + "root": "whois" + }, + { + "file": "wifi-password.ts", + "outcome": "partial", + "root": "wifi-password", + "dropped": 2 + }, + { + "file": "wing.ts", + "outcome": "full", + "root": "wing" + }, + { + "file": "wp.ts", + "outcome": "full", + "root": "wp" + }, + { + "file": "wrangler.ts", + "outcome": "full", + "root": "wrangler" + }, + { + "file": "wrk.ts", + "outcome": "full", + "root": "wrk" + }, + { + "file": "wscat.ts", + "outcome": "full", + "root": "wscat" + }, + { + "file": "xargs.ts", + "outcome": "full", + "root": "xargs" + }, + { + "file": "xc.ts", + "outcome": "partial", + "root": "xc", + "dropped": 1 + }, + { + "file": "xcode-select.ts", + "outcome": "full", + "root": "xcode-select" + }, + { + "file": "xcodebuild.ts", + "outcome": "full", + "root": "xcodebuild" + }, + { + "file": "xcodeproj.ts", + "outcome": "partial", + "root": "xcodeproj", + "dropped": 12 + }, + { + "file": "xcodes.ts", + "outcome": "partial", + "root": "xcodes", + "dropped": 8 + }, + { + "file": "xcrun.ts", + "outcome": "full", + "root": "xcrun" + }, + { + "file": "xdg-mime.ts", + "outcome": "full", + "root": "xdg-mime" + }, + { + "file": "xdg-open.ts", + "outcome": "full", + "root": "xdg-open" + }, + { + "file": "xed.ts", + "outcome": "full", + "root": "xed" + }, + { + "file": "xxd.ts", + "outcome": "full", + "root": "xxd" + }, + { + "file": "yalc.ts", + "outcome": "partial", + "root": "yalc", + "dropped": 10 + }, + { + "file": "yank.ts", + "outcome": "full", + "root": "yank" + }, + { + "file": "yarn.ts", + "outcome": "partial", + "root": "yarn", + "dropped": 33 + }, + { + "file": "ykman.ts", + "outcome": "partial", + "root": "ykman", + "dropped": 6 + }, + { + "file": "yo.ts", + "outcome": "partial", + "root": "yo", + "dropped": 2 + }, + { + "file": "yomo.ts", + "outcome": "partial", + "root": "yomo", + "dropped": 3 + }, + { + "file": "youtube-dl.ts", + "outcome": "partial", + "root": "youtube-dl", + "dropped": 19 + }, + { + "file": "z.ts", + "outcome": "partial", + "root": "z", + "dropped": 1 + }, + { + "file": "zapier.ts", + "outcome": "full", + "root": "zapier-platform-cli" + }, + { + "file": "zed.ts", + "outcome": "full", + "root": "zed" + }, + { + "file": "zellij.ts", + "outcome": "partial", + "root": "zellij", + "dropped": 20 + }, + { + "file": "zig.ts", + "outcome": "partial", + "root": "zig", + "dropped": 2 + }, + { + "file": "zip.ts", + "outcome": "full", + "root": "zip" + }, + { + "file": "zipcloak.ts", + "outcome": "full", + "root": "zipcloak" + }, + { + "file": "zoxide.ts", + "outcome": "full", + "root": "zoxide" + } + ], + "carapace": [ + { + "file": "common/7z_completer", + "outcome": "unique", + "root": "7z" + }, + { + "file": "common/adb_completer", + "outcome": "enriched", + "root": "adb", + "subcommands": 0, + "options": 2, + "descriptions": 0 + }, + { + "file": "common/age_completer", + "outcome": "unique", + "root": "age" + }, + { + "file": "common/agg_completer", + "outcome": "unique", + "root": "agg" + }, + { + "file": "common/agy_completer", + "outcome": "unique", + "root": "agy" + }, + { + "file": "common/anchor_completer", + "outcome": "unique", + "root": "anchor" + }, + { + "file": "common/ant_completer", + "outcome": "unchanged (fig already covers it)", + "root": "ant" + }, + { + "file": "common/apropos_completer", + "outcome": "unique", + "root": "apropos" + }, + { + "file": "common/ar_completer", + "outcome": "unique", + "root": "ar" + }, + { + "file": "common/aria2c_completer", + "outcome": "unique", + "root": "aria2c" + }, + { + "file": "common/artisan_completer", + "outcome": "unique", + "root": "artisan" + }, + { + "file": "common/asciinema_completer", + "outcome": "enriched", + "root": "asciinema", + "subcommands": 3, + "options": 10, + "descriptions": 0 + }, + { + "file": "common/atuin_completer", + "outcome": "enriched", + "root": "atuin", + "subcommands": 39, + "options": 48, + "descriptions": 13 + }, + { + "file": "common/autoconf_completer", + "outcome": "unique", + "root": "autoconf" + }, + { + "file": "common/avdmanager_completer", + "outcome": "unique", + "root": "avdmanager" + }, + { + "file": "common/aws_completer", + "outcome": "enriched", + "root": "aws", + "subcommands": 0, + "options": 0, + "descriptions": 1 + }, + { + "file": "common/az_completer", + "outcome": "enriched", + "root": "az", + "subcommands": 0, + "options": 0, + "descriptions": 1 + }, + { + "file": "common/bat_completer", + "outcome": "enriched", + "root": "bat", + "subcommands": 0, + "options": 13, + "descriptions": 0 + }, + { + "file": "common/batdiff_completer", + "outcome": "unique", + "root": "batdiff" + }, + { + "file": "common/batgrep_completer", + "outcome": "unique", + "root": "batgrep" + }, + { + "file": "common/batman_completer", + "outcome": "unique", + "root": "batman" + }, + { + "file": "common/bazel_completer", + "outcome": "enriched", + "root": "bazel", + "subcommands": 18, + "options": 584, + "descriptions": 0 + }, + { + "file": "common/bc_completer", + "outcome": "unchanged (fig already covers it)", + "root": "bc" + }, + { + "file": "common/benthos_completer", + "outcome": "unique", + "root": "benthos" + }, + { + "file": "common/black_completer", + "outcome": "unchanged (fig already covers it)", + "root": "black" + }, + { + "file": "common/bloop_completer", + "outcome": "unique", + "root": "bloop" + }, + { + "file": "common/brew_completer", + "outcome": "enriched", + "root": "brew", + "subcommands": 86, + "options": 95, + "descriptions": 1 + }, + { + "file": "common/brotli_completer", + "outcome": "unique", + "root": "brotli" + }, + { + "file": "common/bru_completer", + "outcome": "unique", + "root": "bru" + }, + { + "file": "common/buildctl_completer", + "outcome": "unique", + "root": "buildctl" + }, + { + "file": "common/bun_completer", + "outcome": "enriched", + "root": "bun", + "subcommands": 1, + "options": 104, + "descriptions": 0 + }, + { + "file": "common/bunx_completer", + "outcome": "enriched", + "root": "bunx", + "subcommands": 0, + "options": 0, + "descriptions": 1 + }, + { + "file": "common/but_completer", + "outcome": "unique", + "root": "but" + }, + { + "file": "common/cal_completer", + "outcome": "enriched", + "root": "cal", + "subcommands": 0, + "options": 13, + "descriptions": 0 + }, + { + "file": "common/calibre_completer", + "outcome": "unique", + "root": "calibre" + }, + { + "file": "common/capslock_completer", + "outcome": "unique", + "root": "capslock" + }, + { + "file": "common/cargo-clippy_completer", + "outcome": "unique", + "root": "cargo-clippy" + }, + { + "file": "common/cargo-fmt_completer", + "outcome": "unique", + "root": "cargo-fmt" + }, + { + "file": "common/cargo-rm_completer", + "outcome": "unique", + "root": "cargo-rm" + }, + { + "file": "common/cargo-set-version_completer", + "outcome": "unique", + "root": "cargo-set-version" + }, + { + "file": "common/cargo-upgrade_completer", + "outcome": "unique", + "root": "cargo-upgrade" + }, + { + "file": "common/cargo-watch_completer", + "outcome": "unique", + "root": "cargo-watch" + }, + { + "file": "common/cargo_completer", + "outcome": "unique", + "root": "cargo" + }, + { + "file": "common/charm_completer", + "outcome": "enriched", + "root": "charm", + "subcommands": 1, + "options": 8, + "descriptions": 0 + }, + { + "file": "common/chdman_completer", + "outcome": "unique", + "root": "chdman" + }, + { + "file": "common/chroma_completer", + "outcome": "unique", + "root": "chroma" + }, + { + "file": "common/chromium_completer", + "outcome": "unique", + "root": "chromium" + }, + { + "file": "common/circleci_completer", + "outcome": "enriched", + "root": "circleci", + "subcommands": 9, + "options": 32, + "descriptions": 0 + }, + { + "file": "common/clamav-config_completer", + "outcome": "unique", + "root": "clamav-config" + }, + { + "file": "common/clamav-milter_completer", + "outcome": "unique", + "root": "clamav-milter" + }, + { + "file": "common/clambc_completer", + "outcome": "unique", + "root": "clambc" + }, + { + "file": "common/clamconf_completer", + "outcome": "unique", + "root": "clamconf" + }, + { + "file": "common/clamd_completer", + "outcome": "unique", + "root": "clamd" + }, + { + "file": "common/clamdscan_completer", + "outcome": "unique", + "root": "clamdscan" + }, + { + "file": "common/clamdtop_completer", + "outcome": "unique", + "root": "clamdtop" + }, + { + "file": "common/clamonacc_completer", + "outcome": "unique", + "root": "clamonacc" + }, + { + "file": "common/clamscan_completer", + "outcome": "unique", + "root": "clamscan" + }, + { + "file": "common/clamsubmit_completer", + "outcome": "unique", + "root": "clamsubmit" + }, + { + "file": "common/clion_completer", + "outcome": "unique", + "root": "clion" + }, + { + "file": "common/code-insiders_completer", + "outcome": "unchanged (fig already covers it)", + "root": "code-insiders" + }, + { + "file": "common/code_completer", + "outcome": "unchanged (fig already covers it)", + "root": "code" + }, + { + "file": "common/codecov_completer", + "outcome": "unique", + "root": "codecov" + }, + { + "file": "common/compare_completer", + "outcome": "unique", + "root": "compare" + }, + { + "file": "common/composite_completer", + "outcome": "unique", + "root": "composite" + }, + { + "file": "common/conda-content-trust_completer", + "outcome": "unique", + "root": "conda-content-trust" + }, + { + "file": "common/conda-env_completer", + "outcome": "unique", + "root": "conda-env" + }, + { + "file": "common/conda_completer", + "outcome": "enriched", + "root": "conda", + "subcommands": 0, + "options": 20, + "descriptions": 0 + }, + { + "file": "common/conky_completer", + "outcome": "unique", + "root": "conky" + }, + { + "file": "common/consul_completer", + "outcome": "unique", + "root": "consul" + }, + { + "file": "common/crush_completer", + "outcome": "unique", + "root": "crush" + }, + { + "file": "common/csview_completer", + "outcome": "unique", + "root": "csview" + }, + { + "file": "common/cura_completer", + "outcome": "unique", + "root": "cura" + }, + { + "file": "common/curl_completer", + "outcome": "enriched", + "root": "curl", + "subcommands": 0, + "options": 55, + "descriptions": 0 + }, + { + "file": "common/d2_completer", + "outcome": "unique", + "root": "d2" + }, + { + "file": "common/dagger_completer", + "outcome": "unique", + "root": "dagger" + }, + { + "file": "common/darktable-cli_completer", + "outcome": "unique", + "root": "darktable-cli" + }, + { + "file": "common/darktable_completer", + "outcome": "unique", + "root": "darktable" + }, + { + "file": "common/dart_completer", + "outcome": "enriched", + "root": "dart", + "subcommands": 1, + "options": 8, + "descriptions": 0 + }, + { + "file": "common/datagrip_completer", + "outcome": "unique", + "root": "datagrip" + }, + { + "file": "common/dataspell_completer", + "outcome": "unique", + "root": "dataspell" + }, + { + "file": "common/dbt_completer", + "outcome": "enriched", + "root": "dbt", + "subcommands": 1, + "options": 122, + "descriptions": 2 + }, + { + "file": "common/dc_completer", + "outcome": "unique", + "root": "dc" + }, + { + "file": "common/deadcode_completer", + "outcome": "unique", + "root": "deadcode" + }, + { + "file": "common/delta_completer", + "outcome": "unique", + "root": "delta" + }, + { + "file": "common/deno_completer", + "outcome": "enriched", + "root": "deno", + "subcommands": 0, + "options": 3, + "descriptions": 0 + }, + { + "file": "common/devbox_completer", + "outcome": "unique", + "root": "devbox" + }, + { + "file": "common/devenv_completer", + "outcome": "unique", + "root": "devenv" + }, + { + "file": "common/dfc_completer", + "outcome": "unique", + "root": "dfc" + }, + { + "file": "common/dict_completer", + "outcome": "unique", + "root": "dict" + }, + { + "file": "common/diff3_completer", + "outcome": "unique", + "root": "diff3" + }, + { + "file": "common/diff_completer", + "outcome": "enriched", + "root": "diff", + "subcommands": 0, + "options": 16, + "descriptions": 0 + }, + { + "file": "common/dig_completer", + "outcome": "enriched", + "root": "dig", + "subcommands": 0, + "options": 15, + "descriptions": 0 + }, + { + "file": "common/direnv_completer", + "outcome": "unchanged (fig already covers it)", + "root": "direnv" + }, + { + "file": "common/dive_completer", + "outcome": "unique", + "root": "dive" + }, + { + "file": "common/dlv_completer", + "outcome": "unique", + "root": "dlv" + }, + { + "file": "common/dms_completer", + "outcome": "unique", + "root": "dms" + }, + { + "file": "common/dngconverter_completer", + "outcome": "unique", + "root": "dngconverter" + }, + { + "file": "common/dnsmasq_completer", + "outcome": "unique", + "root": "dnsmasq" + }, + { + "file": "common/doas_completer", + "outcome": "unique", + "root": "doas" + }, + { + "file": "common/docker-buildx_completer", + "outcome": "unique", + "root": "docker-buildx" + }, + { + "file": "common/docker-compose_completer", + "outcome": "enriched", + "root": "docker-compose", + "subcommands": 10, + "options": 72, + "descriptions": 0 + }, + { + "file": "common/docker-scan_completer", + "outcome": "unique", + "root": "docker-scan" + }, + { + "file": "common/docker_completer", + "outcome": "enriched", + "root": "docker", + "subcommands": 6, + "options": 107, + "descriptions": 6 + }, + { + "file": "common/doing_completer", + "outcome": "unique", + "root": "doing" + }, + { + "file": "common/dos2unix_completer", + "outcome": "enriched", + "root": "dos2unix", + "subcommands": 0, + "options": 0, + "descriptions": 1 + }, + { + "file": "common/dotnet_completer", + "outcome": "enriched", + "root": "dotnet", + "subcommands": 3, + "options": 0, + "descriptions": 6 + }, + { + "file": "common/drift_completer", + "outcome": "unique", + "root": "drift" + }, + { + "file": "common/ebook-convert_completer", + "outcome": "unique", + "root": "ebook-convert" + }, + { + "file": "common/egrep_completer", + "outcome": "unique", + "root": "egrep" + }, + { + "file": "common/electron_completer", + "outcome": "unchanged (fig already covers it)", + "root": "electron" + }, + { + "file": "common/elvish_completer", + "outcome": "unique", + "root": "elvish" + }, + { + "file": "common/exa_completer", + "outcome": "unchanged (fig already covers it)", + "root": "exa" + }, + { + "file": "common/eza_completer", + "outcome": "unchanged (fig already covers it)", + "root": "eza" + }, + { + "file": "common/faas-cli_completer", + "outcome": "unique", + "root": "faas-cli" + }, + { + "file": "common/fastfetch_completer", + "outcome": "unique", + "root": "fastfetch" + }, + { + "file": "common/fd_completer", + "outcome": "enriched", + "root": "fd", + "subcommands": 0, + "options": 31, + "descriptions": 0 + }, + { + "file": "common/ffmpeg_completer", + "outcome": "unchanged (fig already covers it)", + "root": "ffmpeg" + }, + { + "file": "common/ffplay_completer", + "outcome": "unique", + "root": "ffplay" + }, + { + "file": "common/ffprobe_completer", + "outcome": "unique", + "root": "ffprobe" + }, + { + "file": "common/fgrep_completer", + "outcome": "unique", + "root": "fgrep" + }, + { + "file": "common/firefox_completer", + "outcome": "enriched", + "root": "firefox", + "subcommands": 0, + "options": 1, + "descriptions": 0 + }, + { + "file": "common/flutter_completer", + "outcome": "enriched", + "root": "flutter", + "subcommands": 31, + "options": 29, + "descriptions": 14 + }, + { + "file": "common/fnm_completer", + "outcome": "enriched", + "root": "fnm", + "subcommands": 0, + "options": 13, + "descriptions": 0 + }, + { + "file": "common/freeze_completer", + "outcome": "unique", + "root": "freeze" + }, + { + "file": "common/ftp_completer", + "outcome": "unique", + "root": "ftp" + }, + { + "file": "common/ftpd_completer", + "outcome": "unique", + "root": "ftpd" + }, + { + "file": "common/fury_completer", + "outcome": "unique", + "root": "fury" + }, + { + "file": "common/fzf_completer", + "outcome": "enriched", + "root": "fzf", + "subcommands": 0, + "options": 70, + "descriptions": 0 + }, + { + "file": "common/gatsby_completer", + "outcome": "enriched", + "root": "gatsby", + "subcommands": 3, + "options": 12, + "descriptions": 0 + }, + { + "file": "common/gcloud_completer", + "outcome": "unchanged (fig already covers it)", + "root": "gcloud" + }, + { + "file": "common/gdb_completer", + "outcome": "unique", + "root": "gdb" + }, + { + "file": "common/gdown_completer", + "outcome": "unique", + "root": "gdown" + }, + { + "file": "common/gdu_completer", + "outcome": "unique", + "root": "gdu" + }, + { + "file": "common/get-env_completer", + "outcome": "unique", + "root": "get-env" + }, + { + "file": "common/gh-copilot_completer", + "outcome": "unique", + "root": "gh-copilot" + }, + { + "file": "common/gh-dash_completer", + "outcome": "unique", + "root": "gh-dash" + }, + { + "file": "common/gh-stack_completer", + "outcome": "enriched", + "root": "stack", + "subcommands": 19, + "options": 2, + "descriptions": 0 + }, + { + "file": "common/gh_completer", + "outcome": "enriched", + "root": "gh", + "subcommands": 58, + "options": 268, + "descriptions": 1 + }, + { + "file": "common/ghostty_completer", + "outcome": "unique", + "root": "ghostty" + }, + { + "file": "common/gimp_completer", + "outcome": "unique", + "root": "gimp" + }, + { + "file": "common/git-abort_completer", + "outcome": "unique", + "root": "git-abort" + }, + { + "file": "common/git-alias_completer", + "outcome": "unique", + "root": "git-alias" + }, + { + "file": "common/git-archive-file_completer", + "outcome": "unique", + "root": "git-archive-file" + }, + { + "file": "common/git-authors_completer", + "outcome": "unique", + "root": "git-authors" + }, + { + "file": "common/git-browse-ci_completer", + "outcome": "unique", + "root": "git-browse-ci" + }, + { + "file": "common/git-browse_completer", + "outcome": "unique", + "root": "git-browse" + }, + { + "file": "common/git-brv_completer", + "outcome": "unique", + "root": "git-brv" + }, + { + "file": "common/git-bulk_completer", + "outcome": "unique", + "root": "git-bulk" + }, + { + "file": "common/git-changelog_completer", + "outcome": "unique", + "root": "git-changelog" + }, + { + "file": "common/git-clang-format_completer", + "outcome": "unique", + "root": "git-clang-format" + }, + { + "file": "common/git-clear-soft_completer", + "outcome": "unique", + "root": "git-clear-soft" + }, + { + "file": "common/git-clear_completer", + "outcome": "unique", + "root": "git-clear" + }, + { + "file": "common/git-coauthor_completer", + "outcome": "unique", + "root": "git-coauthor" + }, + { + "file": "common/git-commits-since_completer", + "outcome": "unique", + "root": "git-commits-since" + }, + { + "file": "common/git-continue_completer", + "outcome": "unique", + "root": "git-continue" + }, + { + "file": "common/git-contrib_completer", + "outcome": "unique", + "root": "git-contrib" + }, + { + "file": "common/git-count_completer", + "outcome": "unique", + "root": "git-count" + }, + { + "file": "common/git-cp_completer", + "outcome": "unique", + "root": "git-cp" + }, + { + "file": "common/git-create-branch_completer", + "outcome": "unique", + "root": "git-create-branch" + }, + { + "file": "common/git-delete-branch_completer", + "outcome": "unique", + "root": "git-delete-branch" + }, + { + "file": "common/git-delete-gone-branches_completer", + "outcome": "unique", + "root": "git-delete-gone-branches" + }, + { + "file": "common/git-delete-merged-branches_completer", + "outcome": "unique", + "root": "git-delete-merged-branches" + }, + { + "file": "common/git-delete-squashed-branches_completer", + "outcome": "unique", + "root": "git-delete-squashed-branches" + }, + { + "file": "common/git-delete-submodule_completer", + "outcome": "unique", + "root": "git-delete-submodule" + }, + { + "file": "common/git-delete-tag_completer", + "outcome": "unique", + "root": "git-delete-tag" + }, + { + "file": "common/git-delta_completer", + "outcome": "unique", + "root": "git-delta" + }, + { + "file": "common/git-effort_completer", + "outcome": "unique", + "root": "git-effort" + }, + { + "file": "common/git-extras_completer", + "outcome": "unique", + "root": "git-extras" + }, + { + "file": "common/git-feature_completer", + "outcome": "unique", + "root": "git-feature" + }, + { + "file": "common/git-force-clone_completer", + "outcome": "unique", + "root": "git-force-clone" + }, + { + "file": "common/git-fork_completer", + "outcome": "unique", + "root": "git-fork" + }, + { + "file": "common/git-fresh-branch_completer", + "outcome": "unique", + "root": "git-fresh-branch" + }, + { + "file": "common/git-get_completer", + "outcome": "unique", + "root": "git-get" + }, + { + "file": "common/git-gh-pages_completer", + "outcome": "unique", + "root": "git-gh-pages" + }, + { + "file": "common/git-graft_completer", + "outcome": "unique", + "root": "git-graft" + }, + { + "file": "common/git-guilt_completer", + "outcome": "unique", + "root": "git-guilt" + }, + { + "file": "common/git-ignore-io_completer", + "outcome": "unique", + "root": "git-ignore-io" + }, + { + "file": "common/git-ignore_completer", + "outcome": "unique", + "root": "git-ignore" + }, + { + "file": "common/git-info_completer", + "outcome": "unique", + "root": "git-info" + }, + { + "file": "common/git-local-commits_completer", + "outcome": "unique", + "root": "git-local-commits" + }, + { + "file": "common/git-lock_completer", + "outcome": "unique", + "root": "git-lock" + }, + { + "file": "common/git-locked_completer", + "outcome": "unique", + "root": "git-locked" + }, + { + "file": "common/git-magic_completer", + "outcome": "unique", + "root": "git-magic" + }, + { + "file": "common/git-merge-into_completer", + "outcome": "unique", + "root": "git-merge-into" + }, + { + "file": "common/git-merge-repo_completer", + "outcome": "unique", + "root": "git-merge-repo" + }, + { + "file": "common/git-meta_completer", + "outcome": "unique", + "root": "git-meta" + }, + { + "file": "common/git-missing_completer", + "outcome": "unique", + "root": "git-missing" + }, + { + "file": "common/git-mr_completer", + "outcome": "unique", + "root": "git-mr" + }, + { + "file": "common/git-obliterate_completer", + "outcome": "unique", + "root": "git-obliterate" + }, + { + "file": "common/git-p4_completer", + "outcome": "unique", + "root": "git-p4" + }, + { + "file": "common/git-paste_completer", + "outcome": "unique", + "root": "git-paste" + }, + { + "file": "common/git-pr_completer", + "outcome": "unique", + "root": "git-pr" + }, + { + "file": "common/git-psykorebase_completer", + "outcome": "unique", + "root": "git-psykorebase" + }, + { + "file": "common/git-pull-request_completer", + "outcome": "unique", + "root": "git-pull-request" + }, + { + "file": "common/git-reauthor_completer", + "outcome": "unique", + "root": "git-reauthor" + }, + { + "file": "common/git-rebase-patch_completer", + "outcome": "unique", + "root": "git-rebase-patch" + }, + { + "file": "common/git-release_completer", + "outcome": "unique", + "root": "git-release" + }, + { + "file": "common/git-rename-branch_completer", + "outcome": "unique", + "root": "git-rename-branch" + }, + { + "file": "common/git-rename-file_completer", + "outcome": "unique", + "root": "git-rename-file" + }, + { + "file": "common/git-rename-remote_completer", + "outcome": "unique", + "root": "git-rename-remote" + }, + { + "file": "common/git-rename-tag_completer", + "outcome": "unique", + "root": "git-rename-tag" + }, + { + "file": "common/git-repl_completer", + "outcome": "unique", + "root": "git-repl" + }, + { + "file": "common/git-reset-file_completer", + "outcome": "unique", + "root": "git-reset-file" + }, + { + "file": "common/git-root_completer", + "outcome": "unique", + "root": "git-root" + }, + { + "file": "common/git-rscp_completer", + "outcome": "unique", + "root": "git-rscp" + }, + { + "file": "common/git-scp_completer", + "outcome": "unique", + "root": "git-scp" + }, + { + "file": "common/git-sed_completer", + "outcome": "unique", + "root": "git-sed" + }, + { + "file": "common/git-setup_completer", + "outcome": "unique", + "root": "git-setup" + }, + { + "file": "common/git-show-merged-branches_completer", + "outcome": "unique", + "root": "git-show-merged-branches" + }, + { + "file": "common/git-show-tree_completer", + "outcome": "unique", + "root": "git-show-tree" + }, + { + "file": "common/git-show-unmerged-branches_completer", + "outcome": "unique", + "root": "git-show-unmerged-branches" + }, + { + "file": "common/git-squash_completer", + "outcome": "unique", + "root": "git-squash" + }, + { + "file": "common/git-stamp_completer", + "outcome": "unique", + "root": "git-stamp" + }, + { + "file": "common/git-standup_completer", + "outcome": "unique", + "root": "git-standup" + }, + { + "file": "common/git-summary_completer", + "outcome": "unique", + "root": "git-summary" + }, + { + "file": "common/git-svn_completer", + "outcome": "unique", + "root": "git-svn" + }, + { + "file": "common/git-sync_completer", + "outcome": "unique", + "root": "git-sync" + }, + { + "file": "common/git-touch_completer", + "outcome": "unique", + "root": "git-touch" + }, + { + "file": "common/git-undo_completer", + "outcome": "unique", + "root": "git-undo" + }, + { + "file": "common/git-unlock_completer", + "outcome": "unique", + "root": "git-unlock" + }, + { + "file": "common/git-unwip_completer", + "outcome": "unique", + "root": "git-unwip" + }, + { + "file": "common/git-utimes_completer", + "outcome": "unique", + "root": "git-utimes" + }, + { + "file": "common/git-wip_completer", + "outcome": "unique", + "root": "git-wip" + }, + { + "file": "common/git_completer", + "outcome": "enriched", + "root": "git", + "subcommands": 124, + "options": 279, + "descriptions": 5 + }, + { + "file": "common/gitk_completer", + "outcome": "unique", + "root": "gitk" + }, + { + "file": "common/gitui_completer", + "outcome": "unique", + "root": "gitui" + }, + { + "file": "common/glab_completer", + "outcome": "unique", + "root": "glab" + }, + { + "file": "common/glow_completer", + "outcome": "enriched", + "root": "glow", + "subcommands": 1, + "options": 3, + "descriptions": 0 + }, + { + "file": "common/gm_completer", + "outcome": "unique", + "root": "gm" + }, + { + "file": "common/go-carpet_completer", + "outcome": "unique", + "root": "go-carpet" + }, + { + "file": "common/go-tool-asm_completer", + "outcome": "unique", + "root": "go-tool-asm" + }, + { + "file": "common/go-tool-buildid_completer", + "outcome": "unique", + "root": "go-tool-buildid" + }, + { + "file": "common/go-tool-cgo_completer", + "outcome": "unique", + "root": "go-tool-cgo" + }, + { + "file": "common/go-tool-compile_completer", + "outcome": "unique", + "root": "go-tool-compile" + }, + { + "file": "common/go-tool-covdata_completer", + "outcome": "unique", + "root": "go-tool-covdata" + }, + { + "file": "common/go-tool-cover_completer", + "outcome": "unique", + "root": "cover" + }, + { + "file": "common/go-tool-dist_completer", + "outcome": "unique", + "root": "go-tool-dist" + }, + { + "file": "common/go-tool-doc_completer", + "outcome": "unique", + "root": "go-tool-doc" + }, + { + "file": "common/go-tool-fix_completer", + "outcome": "unique", + "root": "fix" + }, + { + "file": "common/go-tool-link_completer", + "outcome": "unique", + "root": "go-tool-link" + }, + { + "file": "common/go-tool-mockgen_completer", + "outcome": "unique", + "root": "go-tool-mockgen" + }, + { + "file": "common/go-tool-nm_completer", + "outcome": "unique", + "root": "nm" + }, + { + "file": "common/go-tool-objdump_completer", + "outcome": "unique", + "root": "go-tool-objdump" + }, + { + "file": "common/go-tool-pack_completer", + "outcome": "unique", + "root": "go-tool-pack" + }, + { + "file": "common/go_completer", + "outcome": "enriched", + "root": "go", + "subcommands": 3, + "options": 111, + "descriptions": 0 + }, + { + "file": "common/gocyclo_completer", + "outcome": "unique", + "root": "gocyclo" + }, + { + "file": "common/gofmt_completer", + "outcome": "unique", + "root": "gofmt" + }, + { + "file": "common/goimports_completer", + "outcome": "unique", + "root": "goimports" + }, + { + "file": "common/goland_completer", + "outcome": "unique", + "root": "goland" + }, + { + "file": "common/golangci-lint_completer", + "outcome": "unique", + "root": "golangci-lint" + }, + { + "file": "common/gonew_completer", + "outcome": "unique", + "root": "gonew" + }, + { + "file": "common/google-chrome_completer", + "outcome": "unique", + "root": "google-chrome" + }, + { + "file": "common/gopls_completer", + "outcome": "unique", + "root": "gopls" + }, + { + "file": "common/goreleaser_completer", + "outcome": "enriched", + "root": "goreleaser", + "subcommands": 1, + "options": 8, + "descriptions": 0 + }, + { + "file": "common/goweight_completer", + "outcome": "unique", + "root": "goweight" + }, + { + "file": "common/gpg-agent_completer", + "outcome": "unique", + "root": "gpg-agent" + }, + { + "file": "common/gpg_completer", + "outcome": "enriched", + "root": "gpg", + "subcommands": 0, + "options": 48, + "descriptions": 0 + }, + { + "file": "common/gradle_completer", + "outcome": "enriched", + "root": "gradle", + "subcommands": 0, + "options": 11, + "descriptions": 0 + }, + { + "file": "common/grep_completer", + "outcome": "enriched", + "root": "grep", + "subcommands": 0, + "options": 7, + "descriptions": 0 + }, + { + "file": "common/grit_completer", + "outcome": "unique", + "root": "grit" + }, + { + "file": "common/grype_completer", + "outcome": "unique", + "root": "grype" + }, + { + "file": "common/gsa_completer", + "outcome": "unique", + "root": "gsa" + }, + { + "file": "common/gulp_completer", + "outcome": "unique", + "root": "gulp" + }, + { + "file": "common/gum_completer", + "outcome": "enriched", + "root": "gum", + "subcommands": 2, + "options": 773, + "descriptions": 0 + }, + { + "file": "common/gunzip_completer", + "outcome": "unique", + "root": "gunzip" + }, + { + "file": "common/gzip_completer", + "outcome": "unique", + "root": "gzip" + }, + { + "file": "common/helix_completer", + "outcome": "unique", + "root": "helix" + }, + { + "file": "common/helm_completer", + "outcome": "enriched", + "root": "helm", + "subcommands": 1, + "options": 15, + "descriptions": 0 + }, + { + "file": "common/helmsman_completer", + "outcome": "unique", + "root": "helmsman" + }, + { + "file": "common/herdr_completer", + "outcome": "unique", + "root": "herdr" + }, + { + "file": "common/hexchat_completer", + "outcome": "unique", + "root": "hexchat" + }, + { + "file": "common/hexdump_completer", + "outcome": "unique", + "root": "hexdump" + }, + { + "file": "common/hl_completer", + "outcome": "unique", + "root": "hl" + }, + { + "file": "common/hostname_completer", + "outcome": "enriched", + "root": "hostname", + "subcommands": 0, + "options": 9, + "descriptions": 0 + }, + { + "file": "common/htop_completer", + "outcome": "enriched", + "root": "htop", + "subcommands": 0, + "options": 5, + "descriptions": 0 + }, + { + "file": "common/http_completer", + "outcome": "unchanged (fig already covers it)", + "root": "http" + }, + { + "file": "common/https_completer", + "outcome": "unchanged (fig already covers it)", + "root": "https" + }, + { + "file": "common/hugo_completer", + "outcome": "enriched", + "root": "hugo", + "subcommands": 0, + "options": 14, + "descriptions": 0 + }, + { + "file": "common/hunspell_completer", + "outcome": "unique", + "root": "hunspell" + }, + { + "file": "common/hurl_completer", + "outcome": "unique", + "root": "hurl" + }, + { + "file": "common/hx_completer", + "outcome": "unchanged (fig already covers it)", + "root": "hx" + }, + { + "file": "common/hyperfine_completer", + "outcome": "enriched", + "root": "hyperfine", + "subcommands": 0, + "options": 8, + "descriptions": 0 + }, + { + "file": "common/idea_completer", + "outcome": "unique", + "root": "idea" + }, + { + "file": "common/identify_completer", + "outcome": "unique", + "root": "identify" + }, + { + "file": "common/img2pdf_completer", + "outcome": "unique", + "root": "img2pdf" + }, + { + "file": "common/inkscape_completer", + "outcome": "unique", + "root": "inkscape" + }, + { + "file": "common/inshellisense_completer", + "outcome": "unique", + "root": "inshellisense" + }, + { + "file": "common/iqtree3_completer", + "outcome": "unique", + "root": "iqtree3" + }, + { + "file": "common/jar_completer", + "outcome": "unique", + "root": "jar" + }, + { + "file": "common/java_completer", + "outcome": "enriched", + "root": "java", + "subcommands": 0, + "options": 51, + "descriptions": 0 + }, + { + "file": "common/javac_completer", + "outcome": "unique", + "root": "javac" + }, + { + "file": "common/jj_completer", + "outcome": "unique", + "root": "jj" + }, + { + "file": "common/jq_completer", + "outcome": "enriched", + "root": "jq", + "subcommands": 0, + "options": 5, + "descriptions": 0 + }, + { + "file": "common/julia_completer", + "outcome": "unchanged (fig already covers it)", + "root": "julia" + }, + { + "file": "common/just_completer", + "outcome": "enriched", + "root": "just", + "subcommands": 0, + "options": 29, + "descriptions": 0 + }, + { + "file": "common/k3d_completer", + "outcome": "enriched", + "root": "k3d", + "subcommands": 3, + "options": 9, + "descriptions": 4 + }, + { + "file": "common/kak-lsp_completer", + "outcome": "unique", + "root": "kak-lsp" + }, + { + "file": "common/kak_completer", + "outcome": "unique", + "root": "kak" + }, + { + "file": "common/keytool_completer", + "outcome": "unchanged (fig already covers it)", + "root": "keytool" + }, + { + "file": "common/kitten_completer", + "outcome": "unique", + "root": "kitten" + }, + { + "file": "common/kmonad_completer", + "outcome": "unique", + "root": "kmonad" + }, + { + "file": "common/kompose_completer", + "outcome": "unique", + "root": "kompose" + }, + { + "file": "common/kotlin_completer", + "outcome": "unique", + "root": "kotlin" + }, + { + "file": "common/kotlinc_completer", + "outcome": "enriched", + "root": "kotlinc", + "subcommands": 0, + "options": 26, + "descriptions": 0 + }, + { + "file": "common/ktlint_completer", + "outcome": "unique", + "root": "ktlint" + }, + { + "file": "common/kubeadm_completer", + "outcome": "unique", + "root": "kubeadm" + }, + { + "file": "common/kubectl_completer", + "outcome": "enriched", + "root": "kubectl", + "subcommands": 11, + "options": 212, + "descriptions": 9 + }, + { + "file": "common/kubeseal_completer", + "outcome": "unique", + "root": "kubeseal" + }, + { + "file": "common/lazygit_completer", + "outcome": "unique", + "root": "lazygit" + }, + { + "file": "common/lf_completer", + "outcome": "unique", + "root": "lf" + }, + { + "file": "common/lnav_completer", + "outcome": "unique", + "root": "lnav" + }, + { + "file": "common/lncrawl_completer", + "outcome": "unique", + "root": "lncrawl" + }, + { + "file": "common/lore_completer", + "outcome": "unique", + "root": "lore" + }, + { + "file": "common/lua_completer", + "outcome": "unique", + "root": "lua" + }, + { + "file": "common/lzma_completer", + "outcome": "unique", + "root": "lzma" + }, + { + "file": "common/magick_completer", + "outcome": "unique", + "root": "magick" + }, + { + "file": "common/make_completer", + "outcome": "enriched", + "root": "make", + "subcommands": 0, + "options": 15, + "descriptions": 2 + }, + { + "file": "common/man_completer", + "outcome": "enriched", + "root": "man", + "subcommands": 0, + "options": 28, + "descriptions": 0 + }, + { + "file": "common/marp_completer", + "outcome": "unique", + "root": "marp" + }, + { + "file": "common/maturin_completer", + "outcome": "unique", + "root": "maturin" + }, + { + "file": "common/mcomix_completer", + "outcome": "unique", + "root": "mcomix" + }, + { + "file": "common/mdbook_completer", + "outcome": "unique", + "root": "mdbook" + }, + { + "file": "common/meld_completer", + "outcome": "unique", + "root": "meld" + }, + { + "file": "common/melt_completer", + "outcome": "unique", + "root": "melt" + }, + { + "file": "common/micro_completer", + "outcome": "enriched", + "root": "micro", + "subcommands": 0, + "options": 64, + "descriptions": 0 + }, + { + "file": "common/minikube_completer", + "outcome": "enriched", + "root": "minikube", + "subcommands": 5, + "options": 89, + "descriptions": 0 + }, + { + "file": "common/mitmproxy_completer", + "outcome": "unique", + "root": "mitmproxy" + }, + { + "file": "common/mix_completer", + "outcome": "unchanged (fig already covers it)", + "root": "mix" + }, + { + "file": "common/mkcert_completer", + "outcome": "unique", + "root": "mkcert" + }, + { + "file": "common/mogrify_completer", + "outcome": "unique", + "root": "mogrify" + }, + { + "file": "common/molecule_completer", + "outcome": "enriched", + "root": "molecule", + "subcommands": 1, + "options": 25, + "descriptions": 0 + }, + { + "file": "common/montage_completer", + "outcome": "unique", + "root": "montage" + }, + { + "file": "common/more_completer", + "outcome": "enriched", + "root": "more", + "subcommands": 0, + "options": 1, + "descriptions": 0 + }, + { + "file": "common/mosh_completer", + "outcome": "enriched", + "root": "mosh", + "subcommands": 0, + "options": 2, + "descriptions": 1 + }, + { + "file": "common/mousepad_completer", + "outcome": "unique", + "root": "mousepad" + }, + { + "file": "common/mpv_completer", + "outcome": "unique", + "root": "mpv" + }, + { + "file": "common/mvn_completer", + "outcome": "unchanged (fig already covers it)", + "root": "mvn" + }, + { + "file": "common/n-m3u8dl-re_completer", + "outcome": "unique", + "root": "n-m3u8dl-re" + }, + { + "file": "common/nano_completer", + "outcome": "enriched", + "root": "nano", + "subcommands": 0, + "options": 59, + "descriptions": 0 + }, + { + "file": "common/nc_completer", + "outcome": "unchanged (fig already covers it)", + "root": "nc" + }, + { + "file": "common/ncdu_completer", + "outcome": "unique", + "root": "ncdu" + }, + { + "file": "common/neomutt_completer", + "outcome": "unique", + "root": "neomutt" + }, + { + "file": "common/netcat_completer", + "outcome": "unique", + "root": "netcat" + }, + { + "file": "common/newman_completer", + "outcome": "unchanged (fig already covers it)", + "root": "newman" + }, + { + "file": "common/newrelic_completer", + "outcome": "unique", + "root": "newrelic" + }, + { + "file": "common/nfpm_completer", + "outcome": "unique", + "root": "nfpm" + }, + { + "file": "common/ng_completer", + "outcome": "enriched", + "root": "ng", + "subcommands": 26, + "options": 55, + "descriptions": 1 + }, + { + "file": "common/nilaway_completer", + "outcome": "unique", + "root": "nilaway" + }, + { + "file": "common/nix-build_completer", + "outcome": "unique", + "root": "nix-build" + }, + { + "file": "common/nix-channel_completer", + "outcome": "unique", + "root": "nix-channel" + }, + { + "file": "common/nix-collect-garbage_completer", + "outcome": "unique", + "root": "nix-collect-garbage" + }, + { + "file": "common/nix-copy-closure_completer", + "outcome": "unique", + "root": "nix-copy-closure" + }, + { + "file": "common/nix-env_completer", + "outcome": "unique", + "root": "nix-env" + }, + { + "file": "common/nix-hash_completer", + "outcome": "unique", + "root": "nix-hash" + }, + { + "file": "common/nix-install-package_completer", + "outcome": "unique", + "root": "nix-install-package" + }, + { + "file": "common/nix-instantiate_completer", + "outcome": "unique", + "root": "nix-instantiate" + }, + { + "file": "common/nix-prefetch-url_completer", + "outcome": "unique", + "root": "nix-prefetch-url" + }, + { + "file": "common/nix-push_completer", + "outcome": "unique", + "root": "nix-push" + }, + { + "file": "common/nix-shell_completer", + "outcome": "unique", + "root": "nix-shell" + }, + { + "file": "common/nix-store_completer", + "outcome": "unique", + "root": "nix-store" + }, + { + "file": "common/nix_completer", + "outcome": "unique", + "root": "nix" + }, + { + "file": "common/nixops_completer", + "outcome": "unique", + "root": "nixops" + }, + { + "file": "common/node_completer", + "outcome": "enriched", + "root": "node", + "subcommands": 0, + "options": 181, + "descriptions": 0 + }, + { + "file": "common/npm_completer", + "outcome": "enriched", + "root": "npm", + "subcommands": 27, + "options": 285, + "descriptions": 5 + }, + { + "file": "common/ntpd_completer", + "outcome": "unique", + "root": "ntpd" + }, + { + "file": "common/nu_completer", + "outcome": "enriched", + "root": "nu", + "subcommands": 0, + "options": 33, + "descriptions": 0 + }, + { + "file": "common/nvim_completer", + "outcome": "enriched", + "root": "nvim", + "subcommands": 0, + "options": 2, + "descriptions": 0 + }, + { + "file": "common/ollama_completer", + "outcome": "enriched", + "root": "ollama", + "subcommands": 0, + "options": 10, + "descriptions": 0 + }, + { + "file": "common/openscad_completer", + "outcome": "unique", + "root": "openscad" + }, + { + "file": "common/openssl_completer", + "outcome": "unique", + "root": "openssl" + }, + { + "file": "common/optipng_completer", + "outcome": "unique", + "root": "optipng" + }, + { + "file": "common/packer_completer", + "outcome": "unique", + "root": "packer" + }, + { + "file": "common/palemoon_completer", + "outcome": "unique", + "root": "palemoon" + }, + { + "file": "common/pandoc_completer", + "outcome": "enriched", + "root": "pandoc", + "subcommands": 0, + "options": 16, + "descriptions": 0 + }, + { + "file": "common/pass_completer", + "outcome": "enriched", + "root": "pass", + "subcommands": 1, + "options": 0, + "descriptions": 0 + }, + { + "file": "common/pathchk_completer", + "outcome": "enriched", + "root": "pathchk", + "subcommands": 0, + "options": 3, + "descriptions": 0 + }, + { + "file": "common/patool_completer", + "outcome": "unique", + "root": "patool" + }, + { + "file": "common/pdfattach_completer", + "outcome": "unique", + "root": "pdfattach" + }, + { + "file": "common/pdfdetach_completer", + "outcome": "unique", + "root": "pdfdetach" + }, + { + "file": "common/pdffonts_completer", + "outcome": "unique", + "root": "pdffonts" + }, + { + "file": "common/pdfimages_completer", + "outcome": "unique", + "root": "pdfimages" + }, + { + "file": "common/pdfinfo_completer", + "outcome": "unique", + "root": "pdfinfo" + }, + { + "file": "common/pdfseparate_completer", + "outcome": "unique", + "root": "pdfseparate" + }, + { + "file": "common/pdfsig_completer", + "outcome": "unique", + "root": "pdfsig" + }, + { + "file": "common/pdftocairo_completer", + "outcome": "unique", + "root": "pdftocairo" + }, + { + "file": "common/pdftohtml_completer", + "outcome": "unique", + "root": "pdftohtml" + }, + { + "file": "common/pdftoppm_completer", + "outcome": "unique", + "root": "pdftoppm" + }, + { + "file": "common/pdftops_completer", + "outcome": "unique", + "root": "pdftops" + }, + { + "file": "common/pdftotext_completer", + "outcome": "unique", + "root": "pdftotext" + }, + { + "file": "common/pdfunite_completer", + "outcome": "unchanged (fig already covers it)", + "root": "pdfunite" + }, + { + "file": "common/php_completer", + "outcome": "enriched", + "root": "php", + "subcommands": 0, + "options": 28, + "descriptions": 0 + }, + { + "file": "common/phpstorm_completer", + "outcome": "unique", + "root": "phpstorm" + }, + { + "file": "common/pi_completer", + "outcome": "unique", + "root": "pi" + }, + { + "file": "common/picard_completer", + "outcome": "unique", + "root": "picard" + }, + { + "file": "common/pigz_completer", + "outcome": "unique", + "root": "pigz" + }, + { + "file": "common/ping_completer", + "outcome": "enriched", + "root": "ping", + "subcommands": 0, + "options": 8, + "descriptions": 0 + }, + { + "file": "common/pip_completer", + "outcome": "enriched", + "root": "pip", + "subcommands": 10, + "options": 91, + "descriptions": 2 + }, + { + "file": "common/pixi_completer", + "outcome": "unique", + "root": "pixi" + }, + { + "file": "common/pkgsite_completer", + "outcome": "unique", + "root": "pkgsite" + }, + { + "file": "common/pngcheck_completer", + "outcome": "unique", + "root": "pngcheck" + }, + { + "file": "common/pnpm_completer", + "outcome": "enriched", + "root": "pnpm", + "subcommands": 67, + "options": 260, + "descriptions": 1 + }, + { + "file": "common/pprof_completer", + "outcome": "unique", + "root": "pprof" + }, + { + "file": "common/present_completer", + "outcome": "unique", + "root": "present" + }, + { + "file": "common/prettybat_completer", + "outcome": "unique", + "root": "prettybat" + }, + { + "file": "common/prettyping_completer", + "outcome": "unique", + "root": "prettyping" + }, + { + "file": "common/procs_completer", + "outcome": "unique", + "root": "procs" + }, + { + "file": "common/pulumi_completer", + "outcome": "enriched", + "root": "pulumi", + "subcommands": 4, + "options": 32, + "descriptions": 0 + }, + { + "file": "common/pwsh_completer", + "outcome": "unique", + "root": "pwsh" + }, + { + "file": "common/pycharm_completer", + "outcome": "unique", + "root": "pycharm" + }, + { + "file": "common/pytest_completer", + "outcome": "enriched", + "root": "pytest", + "subcommands": 0, + "options": 8, + "descriptions": 1 + }, + { + "file": "common/python_completer", + "outcome": "enriched", + "root": "python", + "subcommands": 0, + "options": 5, + "descriptions": 0 + }, + { + "file": "common/qmk_completer", + "outcome": "unique", + "root": "qmk" + }, + { + "file": "common/qpdf_completer", + "outcome": "unique", + "root": "qpdf" + }, + { + "file": "common/qrencode_completer", + "outcome": "unique", + "root": "qrencode" + }, + { + "file": "common/qutebrowser_completer", + "outcome": "unique", + "root": "qutebrowser" + }, + { + "file": "common/rails_completer", + "outcome": "enriched", + "root": "rails", + "subcommands": 0, + "options": 2, + "descriptions": 0 + }, + { + "file": "common/ranger_completer", + "outcome": "unique", + "root": "ranger" + }, + { + "file": "common/redis-cli_completer", + "outcome": "unique", + "root": "redis-cli" + }, + { + "file": "common/restic_completer", + "outcome": "unique", + "root": "restic" + }, + { + "file": "common/resume-cli_completer", + "outcome": "unique", + "root": "resume-cli" + }, + { + "file": "common/rg_completer", + "outcome": "enriched", + "root": "rg", + "subcommands": 0, + "options": 15, + "descriptions": 2 + }, + { + "file": "common/rider_completer", + "outcome": "unique", + "root": "rider" + }, + { + "file": "common/rifle_completer", + "outcome": "unique", + "root": "rifle" + }, + { + "file": "common/ripsecrets_completer", + "outcome": "unique", + "root": "ripsecrets" + }, + { + "file": "common/rsync_completer", + "outcome": "enriched", + "root": "rsync", + "subcommands": 0, + "options": 18, + "descriptions": 0 + }, + { + "file": "common/rubymine_completer", + "outcome": "unique", + "root": "rubymine" + }, + { + "file": "common/rust-analyzer_completer", + "outcome": "unique", + "root": "rust-analyzer" + }, + { + "file": "common/rustc_completer", + "outcome": "enriched", + "root": "rustc", + "subcommands": 0, + "options": 3, + "descriptions": 0 + }, + { + "file": "common/rustdoc_completer", + "outcome": "unique", + "root": "rustdoc" + }, + { + "file": "common/rustrover_completer", + "outcome": "unique", + "root": "rustrover" + }, + { + "file": "common/rustup_completer", + "outcome": "enriched", + "root": "rustup", + "subcommands": 48, + "options": 32, + "descriptions": 3 + }, + { + "file": "common/saw_completer", + "outcome": "unique", + "root": "saw" + }, + { + "file": "common/scc_completer", + "outcome": "enriched", + "root": "scc", + "subcommands": 0, + "options": 18, + "descriptions": 0 + }, + { + "file": "common/scp_completer", + "outcome": "enriched", + "root": "scp", + "subcommands": 0, + "options": 4, + "descriptions": 0 + }, + { + "file": "common/script_completer", + "outcome": "unique", + "root": "script" + }, + { + "file": "common/scriptlive_completer", + "outcome": "unique", + "root": "scriptlive" + }, + { + "file": "common/scriptreplay_completer", + "outcome": "unique", + "root": "scriptreplay" + }, + { + "file": "common/sd_completer", + "outcome": "unique", + "root": "sd" + }, + { + "file": "common/sdkmanager_completer", + "outcome": "unique", + "root": "sdkmanager" + }, + { + "file": "common/semver_completer", + "outcome": "unique", + "root": "semver" + }, + { + "file": "common/serie_completer", + "outcome": "unique", + "root": "serie" + }, + { + "file": "common/set-env_completer", + "outcome": "unique", + "root": "set-env" + }, + { + "file": "common/sftp_completer", + "outcome": "unchanged (fig already covers it)", + "root": "sftp" + }, + { + "file": "common/slides_completer", + "outcome": "unique", + "root": "slides" + }, + { + "file": "common/soft_completer", + "outcome": "unique", + "root": "soft" + }, + { + "file": "common/speedtest-cli_completer", + "outcome": "unchanged (fig already covers it)", + "root": "speedtest-cli" + }, + { + "file": "common/sqlite3_completer", + "outcome": "enriched", + "root": "sqlite3", + "subcommands": 0, + "options": 15, + "descriptions": 0 + }, + { + "file": "common/ssh-agent_completer", + "outcome": "unique", + "root": "ssh-agent" + }, + { + "file": "common/ssh-copy-id_completer", + "outcome": "unique", + "root": "ssh-copy-id" + }, + { + "file": "common/ssh-keygen_completer", + "outcome": "unchanged (fig already covers it)", + "root": "ssh-keygen" + }, + { + "file": "common/ssh_completer", + "outcome": "enriched", + "root": "ssh", + "subcommands": 0, + "options": 5, + "descriptions": 0 + }, + { + "file": "common/starship_completer", + "outcome": "unique", + "root": "starship" + }, + { + "file": "common/staticcheck_completer", + "outcome": "unique", + "root": "staticcheck" + }, + { + "file": "common/strings_completer", + "outcome": "unique", + "root": "strings" + }, + { + "file": "common/supervisorctl_completer", + "outcome": "unique", + "root": "supervisorctl" + }, + { + "file": "common/supervisord_completer", + "outcome": "unique", + "root": "supervisord" + }, + { + "file": "common/svg-term_completer", + "outcome": "unique", + "root": "svg-term" + }, + { + "file": "common/svgcleaner_completer", + "outcome": "unique", + "root": "svgcleaner" + }, + { + "file": "common/syft_completer", + "outcome": "unique", + "root": "syft" + }, + { + "file": "common/tailscale_completer", + "outcome": "enriched", + "root": "tailscale", + "subcommands": 26, + "options": 41, + "descriptions": 1 + }, + { + "file": "common/taplo_completer", + "outcome": "enriched", + "root": "taplo", + "subcommands": 2, + "options": 6, + "descriptions": 0 + }, + { + "file": "common/task_completer", + "outcome": "enriched", + "root": "task", + "subcommands": 2, + "options": 34, + "descriptions": 1 + }, + { + "file": "common/tea_completer", + "outcome": "unique", + "root": "tea" + }, + { + "file": "common/telnet_completer", + "outcome": "unique", + "root": "telnet" + }, + { + "file": "common/templ_completer", + "outcome": "unique", + "root": "templ" + }, + { + "file": "common/termux-apt-repo_completer", + "outcome": "unique", + "root": "termux-apt-repo" + }, + { + "file": "common/terraform-ls_completer", + "outcome": "unique", + "root": "terraform-ls" + }, + { + "file": "common/terraform_completer", + "outcome": "enriched", + "root": "terraform", + "subcommands": 13, + "options": 84, + "descriptions": 0 + }, + { + "file": "common/terragrunt_completer", + "outcome": "unchanged (fig already covers it)", + "root": "terragrunt" + }, + { + "file": "common/terramate_completer", + "outcome": "unique", + "root": "terramate" + }, + { + "file": "common/tesseract_completer", + "outcome": "unique", + "root": "tesseract" + }, + { + "file": "common/tig_completer", + "outcome": "unique", + "root": "tig" + }, + { + "file": "common/tinygo_completer", + "outcome": "unique", + "root": "tinygo" + }, + { + "file": "common/tldr_completer", + "outcome": "skipped", + "reason": "no static rootCmd" + }, + { + "file": "common/tmate_completer", + "outcome": "unique", + "root": "tmate" + }, + { + "file": "common/tofu_completer", + "outcome": "unique", + "root": "tofu" + }, + { + "file": "common/toit.lsp_completer", + "outcome": "unique", + "root": "toit.lsp" + }, + { + "file": "common/toit.pkg_completer", + "outcome": "unique", + "root": "toit.pkg" + }, + { + "file": "common/tor-browser_completer", + "outcome": "unique", + "root": "tor-browser" + }, + { + "file": "common/tor-gencert_completer", + "outcome": "unique", + "root": "tor-gencert" + }, + { + "file": "common/tor-print-ed-signing-cert_completer", + "outcome": "unique", + "root": "tor-print-ed-signing-cert" + }, + { + "file": "common/tor-resolve_completer", + "outcome": "unique", + "root": "tor-resolve" + }, + { + "file": "common/torsocks_completer", + "outcome": "unique", + "root": "torsocks" + }, + { + "file": "common/tox_completer", + "outcome": "unique", + "root": "tox" + }, + { + "file": "common/traefik_completer", + "outcome": "unique", + "root": "traefik" + }, + { + "file": "common/transmission-cli_completer", + "outcome": "unique", + "root": "transmission-cli" + }, + { + "file": "common/transmission-create_completer", + "outcome": "unique", + "root": "transmission-create" + }, + { + "file": "common/transmission-daemon_completer", + "outcome": "unique", + "root": "transmission-daemon" + }, + { + "file": "common/transmission-edit_completer", + "outcome": "unique", + "root": "transmission-edit" + }, + { + "file": "common/transmission-remote_completer", + "outcome": "unique", + "root": "transmission-remote" + }, + { + "file": "common/transmission-show_completer", + "outcome": "unique", + "root": "transmission-show" + }, + { + "file": "common/tree_completer", + "outcome": "enriched", + "root": "tree", + "subcommands": 0, + "options": 19, + "descriptions": 0 + }, + { + "file": "common/ts_completer", + "outcome": "unique", + "root": "ts" + }, + { + "file": "common/tsc_completer", + "outcome": "enriched", + "root": "tsc", + "subcommands": 0, + "options": 20, + "descriptions": 0 + }, + { + "file": "common/tsh_completer", + "outcome": "enriched", + "root": "tsh", + "subcommands": 15, + "options": 175, + "descriptions": 0 + }, + { + "file": "common/tshark_completer", + "outcome": "unique", + "root": "tshark" + }, + { + "file": "common/turbo_completer", + "outcome": "enriched", + "root": "turbo", + "subcommands": 2, + "options": 14, + "descriptions": 0 + }, + { + "file": "common/typst_completer", + "outcome": "enriched", + "root": "typst", + "subcommands": 3, + "options": 7, + "descriptions": 0 + }, + { + "file": "common/unbrotli_completer", + "outcome": "unique", + "root": "unbrotli" + }, + { + "file": "common/unlzma_completer", + "outcome": "unique", + "root": "unlzma" + }, + { + "file": "common/unpigz_completer", + "outcome": "unique", + "root": "unpigz" + }, + { + "file": "common/unset-env_completer", + "outcome": "unique", + "root": "unset-env" + }, + { + "file": "common/unxz_completer", + "outcome": "unique", + "root": "unxz" + }, + { + "file": "common/unzip_completer", + "outcome": "enriched", + "root": "unzip", + "subcommands": 0, + "options": 25, + "descriptions": 1 + }, + { + "file": "common/upower_completer", + "outcome": "unique", + "root": "upower" + }, + { + "file": "common/upx_completer", + "outcome": "unique", + "root": "upx" + }, + { + "file": "common/uv_completer", + "outcome": "enriched", + "root": "uv", + "subcommands": 10, + "options": 946, + "descriptions": 0 + }, + { + "file": "common/vagrant_completer", + "outcome": "unique", + "root": "vagrant" + }, + { + "file": "common/vercel_completer", + "outcome": "enriched", + "root": "vercel", + "subcommands": 44, + "options": 106, + "descriptions": 0 + }, + { + "file": "common/vhs_completer", + "outcome": "unique", + "root": "vhs" + }, + { + "file": "common/vi_completer", + "outcome": "enriched", + "root": "vi", + "subcommands": 0, + "options": 9, + "descriptions": 0 + }, + { + "file": "common/vim_completer", + "outcome": "enriched", + "root": "vim", + "subcommands": 0, + "options": 15, + "descriptions": 0 + }, + { + "file": "common/viu_completer", + "outcome": "unique", + "root": "viu" + }, + { + "file": "common/vivid_completer", + "outcome": "unique", + "root": "vivid" + }, + { + "file": "common/vlc_completer", + "outcome": "unique", + "root": "vlc" + }, + { + "file": "common/volta_completer", + "outcome": "enriched", + "root": "volta", + "subcommands": 0, + "options": 2, + "descriptions": 0 + }, + { + "file": "common/watch_completer", + "outcome": "unique", + "root": "watch" + }, + { + "file": "common/watchexec_completer", + "outcome": "unique", + "root": "watchexec" + }, + { + "file": "common/watchgnupg_completer", + "outcome": "unique", + "root": "watchgnupg" + }, + { + "file": "common/waypoint_completer", + "outcome": "unique", + "root": "waypoint" + }, + { + "file": "common/webstorm_completer", + "outcome": "unique", + "root": "webstorm" + }, + { + "file": "common/wezterm_completer", + "outcome": "enriched", + "root": "wezterm", + "subcommands": 10, + "options": 5, + "descriptions": 0 + }, + { + "file": "common/wget_completer", + "outcome": "enriched", + "root": "wget", + "subcommands": 0, + "options": 82, + "descriptions": 0 + }, + { + "file": "common/wire_completer", + "outcome": "unique", + "root": "wire" + }, + { + "file": "common/wireshark_completer", + "outcome": "unique", + "root": "wireshark" + }, + { + "file": "common/wishlist_completer", + "outcome": "unique", + "root": "wishlist" + }, + { + "file": "common/wt_completer", + "outcome": "unique", + "root": "wt" + }, + { + "file": "common/xh_completer", + "outcome": "unique", + "root": "xh" + }, + { + "file": "common/xonsh_completer", + "outcome": "unique", + "root": "xonsh" + }, + { + "file": "common/xxhsum_completer", + "outcome": "unique", + "root": "xxhsum" + }, + { + "file": "common/xz_completer", + "outcome": "unique", + "root": "xz" + }, + { + "file": "common/xzcat_completer", + "outcome": "unique", + "root": "xzcat" + }, + { + "file": "common/yarn_completer", + "outcome": "enriched", + "root": "yarn", + "subcommands": 19, + "options": 50, + "descriptions": 3 + }, + { + "file": "common/yj_completer", + "outcome": "unique", + "root": "yj" + }, + { + "file": "common/youtube-dl_completer", + "outcome": "enriched", + "root": "youtube-dl", + "subcommands": 0, + "options": 6, + "descriptions": 0 + }, + { + "file": "common/yt-dlp_completer", + "outcome": "unique", + "root": "yt-dlp" + }, + { + "file": "common/zellij_completer", + "outcome": "enriched", + "root": "zellij", + "subcommands": 131, + "options": 218, + "descriptions": 0 + }, + { + "file": "common/zig_completer", + "outcome": "enriched", + "root": "zig", + "subcommands": 9, + "options": 54, + "descriptions": 2 + }, + { + "file": "common/zip_completer", + "outcome": "enriched", + "root": "zip", + "subcommands": 0, + "options": 25, + "descriptions": 2 + }, + { + "file": "common/zoxide_completer", + "outcome": "enriched", + "root": "zoxide", + "subcommands": 1, + "options": 3, + "descriptions": 0 + }, + { + "file": "common/zpaq_completer", + "outcome": "unique", + "root": "zpaq" + }, + { + "file": "unix/awk_completer", + "outcome": "unique", + "root": "awk" + }, + { + "file": "unix/base32_completer", + "outcome": "unchanged (fig already covers it)", + "root": "base32" + }, + { + "file": "unix/base64_completer", + "outcome": "enriched", + "root": "base64", + "subcommands": 0, + "options": 2, + "descriptions": 0 + }, + { + "file": "unix/basename_completer", + "outcome": "enriched", + "root": "basename", + "subcommands": 0, + "options": 3, + "descriptions": 0 + }, + { + "file": "unix/bash-language-server_completer", + "outcome": "unique", + "root": "bash-language-server" + }, + { + "file": "unix/bash_completer", + "outcome": "unique", + "root": "bash" + }, + { + "file": "unix/bats_completer", + "outcome": "unique", + "root": "bats" + }, + { + "file": "unix/btop_completer", + "outcome": "enriched", + "root": "btop", + "subcommands": 0, + "options": 7, + "descriptions": 0 + }, + { + "file": "unix/cat_completer", + "outcome": "enriched", + "root": "cat", + "subcommands": 0, + "options": 5, + "descriptions": 0 + }, + { + "file": "unix/chgrp_completer", + "outcome": "unique", + "root": "chgrp" + }, + { + "file": "unix/chmod_completer", + "outcome": "enriched", + "root": "chmod", + "subcommands": 0, + "options": 8, + "descriptions": 0 + }, + { + "file": "unix/chown_completer", + "outcome": "enriched", + "root": "chown", + "subcommands": 0, + "options": 9, + "descriptions": 0 + }, + { + "file": "unix/chpasswd_completer", + "outcome": "unique", + "root": "chpasswd" + }, + { + "file": "unix/chroot_completer", + "outcome": "unique", + "root": "chroot" + }, + { + "file": "unix/chsh_completer", + "outcome": "unchanged (fig already covers it)", + "root": "chsh" + }, + { + "file": "unix/cksum_completer", + "outcome": "unique", + "root": "cksum" + }, + { + "file": "unix/cmus_completer", + "outcome": "unique", + "root": "cmus" + }, + { + "file": "unix/comm_completer", + "outcome": "unique", + "root": "comm" + }, + { + "file": "unix/cp_completer", + "outcome": "enriched", + "root": "cp", + "subcommands": 0, + "options": 28, + "descriptions": 0 + }, + { + "file": "unix/csplit_completer", + "outcome": "unique", + "root": "csplit" + }, + { + "file": "unix/cut_completer", + "outcome": "enriched", + "root": "cut", + "subcommands": 0, + "options": 5, + "descriptions": 0 + }, + { + "file": "unix/date_completer", + "outcome": "enriched", + "root": "date", + "subcommands": 0, + "options": 8, + "descriptions": 0 + }, + { + "file": "unix/dd_completer", + "outcome": "enriched", + "root": "dd", + "subcommands": 0, + "options": 2, + "descriptions": 0 + }, + { + "file": "unix/df_completer", + "outcome": "enriched", + "root": "df", + "subcommands": 0, + "options": 10, + "descriptions": 0 + }, + { + "file": "unix/dir_completer", + "outcome": "unique", + "root": "dir" + }, + { + "file": "unix/dircolors_completer", + "outcome": "unique", + "root": "dircolors" + }, + { + "file": "unix/dirname_completer", + "outcome": "enriched", + "root": "dirname", + "subcommands": 0, + "options": 3, + "descriptions": 0 + }, + { + "file": "unix/du_completer", + "outcome": "enriched", + "root": "du", + "subcommands": 0, + "options": 17, + "descriptions": 0 + }, + { + "file": "unix/env_completer", + "outcome": "enriched", + "root": "env", + "subcommands": 0, + "options": 8, + "descriptions": 0 + }, + { + "file": "unix/envsubst_completer", + "outcome": "unique", + "root": "envsubst" + }, + { + "file": "unix/expand_completer", + "outcome": "unique", + "root": "expand" + }, + { + "file": "unix/expr_completer", + "outcome": "unique", + "root": "expr" + }, + { + "file": "unix/factor_completer", + "outcome": "unique", + "root": "factor" + }, + { + "file": "unix/fakechroot_completer", + "outcome": "unique", + "root": "fakechroot" + }, + { + "file": "unix/fakeroot_completer", + "outcome": "unique", + "root": "fakeroot" + }, + { + "file": "unix/feh_completer", + "outcome": "unique", + "root": "feh" + }, + { + "file": "unix/file_completer", + "outcome": "enriched", + "root": "file", + "subcommands": 0, + "options": 2, + "descriptions": 0 + }, + { + "file": "unix/find_completer", + "outcome": "enriched", + "root": "find", + "subcommands": 0, + "options": 74, + "descriptions": 0 + }, + { + "file": "unix/fish_completer", + "outcome": "unique", + "root": "fish" + }, + { + "file": "unix/fmt_completer", + "outcome": "enriched", + "root": "fmt", + "subcommands": 0, + "options": 5, + "descriptions": 0 + }, + { + "file": "unix/fold_completer", + "outcome": "enriched", + "root": "fold", + "subcommands": 0, + "options": 3, + "descriptions": 0 + }, + { + "file": "unix/groups_completer", + "outcome": "unique", + "root": "groups" + }, + { + "file": "unix/head_completer", + "outcome": "enriched", + "root": "head", + "subcommands": 0, + "options": 1, + "descriptions": 0 + }, + { + "file": "unix/hostid_completer", + "outcome": "unique", + "root": "hostid" + }, + { + "file": "unix/id_completer", + "outcome": "enriched", + "root": "id", + "subcommands": 0, + "options": 6, + "descriptions": 0 + }, + { + "file": "unix/install_completer", + "outcome": "enriched", + "root": "install", + "subcommands": 0, + "options": 11, + "descriptions": 0 + }, + { + "file": "unix/join_completer", + "outcome": "enriched", + "root": "join", + "subcommands": 0, + "options": 7, + "descriptions": 0 + }, + { + "file": "unix/kill_completer", + "outcome": "enriched", + "root": "kill", + "subcommands": 0, + "options": 8, + "descriptions": 0 + }, + { + "file": "unix/killall_completer", + "outcome": "enriched", + "root": "killall", + "subcommands": 0, + "options": 9, + "descriptions": 0 + }, + { + "file": "unix/link_completer", + "outcome": "unique", + "root": "link" + }, + { + "file": "unix/ln_completer", + "outcome": "enriched", + "root": "ln", + "subcommands": 0, + "options": 11, + "descriptions": 0 + }, + { + "file": "unix/logname_completer", + "outcome": "unique", + "root": "logname" + }, + { + "file": "unix/ls_completer", + "outcome": "enriched", + "root": "ls", + "subcommands": 0, + "options": 24, + "descriptions": 0 + }, + { + "file": "unix/md5sum_completer", + "outcome": "unique", + "root": "md5sum" + }, + { + "file": "unix/mkdir_completer", + "outcome": "unchanged (fig already covers it)", + "root": "mkdir" + }, + { + "file": "unix/mkfifo_completer", + "outcome": "enriched", + "root": "mkfifo", + "subcommands": 0, + "options": 4, + "descriptions": 0 + }, + { + "file": "unix/mknod_completer", + "outcome": "enriched", + "root": "mknod", + "subcommands": 0, + "options": 5, + "descriptions": 0 + }, + { + "file": "unix/mktemp_completer", + "outcome": "unique", + "root": "mktemp" + }, + { + "file": "unix/mount_completer", + "outcome": "enriched", + "root": "mount", + "subcommands": 0, + "options": 8, + "descriptions": 0 + }, + { + "file": "unix/mv_completer", + "outcome": "enriched", + "root": "mv", + "subcommands": 0, + "options": 13, + "descriptions": 0 + }, + { + "file": "unix/nice_completer", + "outcome": "unique", + "root": "nice" + }, + { + "file": "unix/nl_completer", + "outcome": "enriched", + "root": "nl", + "subcommands": 0, + "options": 2, + "descriptions": 0 + }, + { + "file": "unix/nohup_completer", + "outcome": "unique", + "root": "nohup" + }, + { + "file": "unix/nproc_completer", + "outcome": "unique", + "root": "nproc" + }, + { + "file": "unix/numfmt_completer", + "outcome": "unique", + "root": "numfmt" + }, + { + "file": "unix/od_completer", + "outcome": "enriched", + "root": "od", + "subcommands": 0, + "options": 6, + "descriptions": 0 + }, + { + "file": "unix/passwd_completer", + "outcome": "enriched", + "root": "passwd", + "subcommands": 0, + "options": 14, + "descriptions": 0 + }, + { + "file": "unix/paste_completer", + "outcome": "enriched", + "root": "paste", + "subcommands": 0, + "options": 3, + "descriptions": 0 + }, + { + "file": "unix/patch_completer", + "outcome": "unique", + "root": "patch" + }, + { + "file": "unix/pgrep_completer", + "outcome": "unique", + "root": "pgrep" + }, + { + "file": "unix/pinky_completer", + "outcome": "unique", + "root": "pinky" + }, + { + "file": "unix/pkill_completer", + "outcome": "enriched", + "root": "pkill", + "subcommands": 0, + "options": 8, + "descriptions": 0 + }, + { + "file": "unix/pr_completer", + "outcome": "unique", + "root": "pr" + }, + { + "file": "unix/printenv_completer", + "outcome": "unique", + "root": "printenv" + }, + { + "file": "unix/ps_completer", + "outcome": "enriched", + "root": "ps", + "subcommands": 0, + "options": 9, + "descriptions": 2 + }, + { + "file": "unix/ptx_completer", + "outcome": "unique", + "root": "ptx" + }, + { + "file": "unix/pwd_completer", + "outcome": "enriched", + "root": "pwd", + "subcommands": 0, + "options": 2, + "descriptions": 0 + }, + { + "file": "unix/pwdx_completer", + "outcome": "unique", + "root": "pwdx" + }, + { + "file": "unix/readlink_completer", + "outcome": "enriched", + "root": "readlink", + "subcommands": 0, + "options": 8, + "descriptions": 0 + }, + { + "file": "unix/rename_completer", + "outcome": "unique", + "root": "rename" + }, + { + "file": "unix/rm_completer", + "outcome": "enriched", + "root": "rm", + "subcommands": 0, + "options": 7, + "descriptions": 0 + }, + { + "file": "unix/rmdir_completer", + "outcome": "enriched", + "root": "rmdir", + "subcommands": 0, + "options": 4, + "descriptions": 0 + }, + { + "file": "unix/sed_completer", + "outcome": "enriched", + "root": "sed", + "subcommands": 0, + "options": 9, + "descriptions": 0 + }, + { + "file": "unix/seq_completer", + "outcome": "enriched", + "root": "seq", + "subcommands": 0, + "options": 2, + "descriptions": 0 + }, + { + "file": "unix/sha1sum_completer", + "outcome": "unchanged (fig already covers it)", + "root": "sha1sum" + }, + { + "file": "unix/sha224sum_completer", + "outcome": "unique", + "root": "sha224sum" + }, + { + "file": "unix/sha256sum_completer", + "outcome": "unique", + "root": "sha256sum" + }, + { + "file": "unix/sha384sum_completer", + "outcome": "unique", + "root": "sha384sum" + }, + { + "file": "unix/sha512sum_completer", + "outcome": "unique", + "root": "sha512sum" + }, + { + "file": "unix/shred_completer", + "outcome": "enriched", + "root": "shred", + "subcommands": 0, + "options": 1, + "descriptions": 0 + }, + { + "file": "unix/sleep_completer", + "outcome": "unique", + "root": "sleep" + }, + { + "file": "unix/sort_completer", + "outcome": "unchanged (fig already covers it)", + "root": "sort" + }, + { + "file": "unix/split_completer", + "outcome": "enriched", + "root": "split", + "subcommands": 0, + "options": 13, + "descriptions": 0 + }, + { + "file": "unix/stat_completer", + "outcome": "enriched", + "root": "stat", + "subcommands": 0, + "options": 5, + "descriptions": 0 + }, + { + "file": "unix/stty_completer", + "outcome": "unique", + "root": "stty" + }, + { + "file": "unix/su_completer", + "outcome": "enriched", + "root": "su", + "subcommands": 0, + "options": 11, + "descriptions": 1 + }, + { + "file": "unix/sudo_completer", + "outcome": "enriched", + "root": "sudo", + "subcommands": 0, + "options": 23, + "descriptions": 0 + }, + { + "file": "unix/sudoedit_completer", + "outcome": "unique", + "root": "sudoedit" + }, + { + "file": "unix/sudoreplay_completer", + "outcome": "unique", + "root": "sudoreplay" + }, + { + "file": "unix/sulogin_completer", + "outcome": "unique", + "root": "sulogin" + }, + { + "file": "unix/sum_completer", + "outcome": "unique", + "root": "sum" + }, + { + "file": "unix/sync_completer", + "outcome": "unique", + "root": "sync" + }, + { + "file": "unix/tac_completer", + "outcome": "unchanged (fig already covers it)", + "root": "tac" + }, + { + "file": "unix/tail_completer", + "outcome": "enriched", + "root": "tail", + "subcommands": 0, + "options": 14, + "descriptions": 0 + }, + { + "file": "unix/tar_completer", + "outcome": "enriched", + "root": "tar", + "subcommands": 0, + "options": 131, + "descriptions": 0 + }, + { + "file": "unix/tee_completer", + "outcome": "enriched", + "root": "tee", + "subcommands": 0, + "options": 4, + "descriptions": 0 + }, + { + "file": "unix/timeout_completer", + "outcome": "unique", + "root": "timeout" + }, + { + "file": "unix/tmux_completer", + "outcome": "enriched", + "root": "tmux", + "subcommands": 4, + "options": 91, + "descriptions": 0 + }, + { + "file": "unix/touch_completer", + "outcome": "enriched", + "root": "touch", + "subcommands": 0, + "options": 4, + "descriptions": 0 + }, + { + "file": "unix/tr_completer", + "outcome": "enriched", + "root": "tr", + "subcommands": 0, + "options": 3, + "descriptions": 0 + }, + { + "file": "unix/truncate_completer", + "outcome": "unchanged (fig already covers it)", + "root": "truncate" + }, + { + "file": "unix/tsort_completer", + "outcome": "unique", + "root": "tsort" + }, + { + "file": "unix/umount_completer", + "outcome": "unique", + "root": "umount" + }, + { + "file": "unix/unexpand_completer", + "outcome": "unique", + "root": "unexpand" + }, + { + "file": "unix/uniq_completer", + "outcome": "enriched", + "root": "uniq", + "subcommands": 0, + "options": 1, + "descriptions": 0 + }, + { + "file": "unix/unlink_completer", + "outcome": "unique", + "root": "unlink" + }, + { + "file": "unix/uptime_completer", + "outcome": "unique", + "root": "uptime" + }, + { + "file": "unix/users_completer", + "outcome": "unique", + "root": "users" + }, + { + "file": "unix/vdir_completer", + "outcome": "unique", + "root": "vdir" + }, + { + "file": "unix/visudo_completer", + "outcome": "enriched", + "root": "visudo", + "subcommands": 0, + "options": 1, + "descriptions": 0 + }, + { + "file": "unix/wc_completer", + "outcome": "enriched", + "root": "wc", + "subcommands": 0, + "options": 6, + "descriptions": 0 + }, + { + "file": "unix/which_completer", + "outcome": "enriched", + "root": "which", + "subcommands": 0, + "options": 11, + "descriptions": 0 + }, + { + "file": "unix/who_completer", + "outcome": "enriched", + "root": "who", + "subcommands": 0, + "options": 5, + "descriptions": 0 + }, + { + "file": "unix/whoami_completer", + "outcome": "unique", + "root": "whoami" + }, + { + "file": "unix/wine_completer", + "outcome": "unique", + "root": "wine" + }, + { + "file": "unix/wineboot_completer", + "outcome": "unique", + "root": "wineboot" + }, + { + "file": "unix/winepath_completer", + "outcome": "unique", + "root": "winepath" + }, + { + "file": "unix/wineserver_completer", + "outcome": "unique", + "root": "wineserver" + }, + { + "file": "unix/winetricks_completer", + "outcome": "unique", + "root": "winetricks" + }, + { + "file": "unix/xargs_completer", + "outcome": "enriched", + "root": "xargs", + "subcommands": 0, + "options": 9, + "descriptions": 0 + }, + { + "file": "unix/yes_completer", + "outcome": "unique", + "root": "yes" + }, + { + "file": "unix/zathura_completer", + "outcome": "unique", + "root": "zathura" + }, + { + "file": "unix/zfs_completer", + "outcome": "unique", + "root": "zfs" + }, + { + "file": "unix/zpool_completer", + "outcome": "unique", + "root": "zpool" + }, + { + "file": "linux/acpi_completer", + "outcome": "unique", + "root": "acpi" + }, + { + "file": "linux/acpid_completer", + "outcome": "unique", + "root": "acpid" + }, + { + "file": "linux/alsamixer_completer", + "outcome": "unique", + "root": "alsamixer" + }, + { + "file": "linux/apk_completer", + "outcome": "unique", + "root": "apk" + }, + { + "file": "linux/aplay_completer", + "outcome": "unique", + "root": "aplay" + }, + { + "file": "linux/apt-cache_completer", + "outcome": "unique", + "root": "apt-cache" + }, + { + "file": "linux/apt-get_completer", + "outcome": "unique", + "root": "apt-get" + }, + { + "file": "linux/apt_completer", + "outcome": "enriched", + "root": "apt", + "subcommands": 8, + "options": 30, + "descriptions": 0 + }, + { + "file": "linux/archlinux-java_completer", + "outcome": "unique", + "root": "archlinux-java" + }, + { + "file": "linux/arecord_completer", + "outcome": "unique", + "root": "arecord" + }, + { + "file": "linux/baobab_completer", + "outcome": "unique", + "root": "baobab" + }, + { + "file": "linux/bluetoothctl_completer", + "outcome": "unique", + "root": "bluetoothctl" + }, + { + "file": "linux/cfdisk_completer", + "outcome": "unique", + "root": "cfdisk" + }, + { + "file": "linux/chcpu_completer", + "outcome": "unique", + "root": "chcpu" + }, + { + "file": "linux/cheese_completer", + "outcome": "unique", + "root": "cheese" + }, + { + "file": "linux/coredumpctl_completer", + "outcome": "unique", + "root": "coredumpctl" + }, + { + "file": "linux/dmenu_completer", + "outcome": "unique", + "root": "dmenu" + }, + { + "file": "linux/dmesg_completer", + "outcome": "unique", + "root": "dmesg" + }, + { + "file": "linux/dnf5_completer", + "outcome": "unique", + "root": "dnf5" + }, + { + "file": "linux/dnf_completer", + "outcome": "unique", + "root": "dnf" + }, + { + "file": "linux/dockerd_completer", + "outcome": "unique", + "root": "dockerd" + }, + { + "file": "linux/downgrade_completer", + "outcome": "unique", + "root": "downgrade" + }, + { + "file": "linux/dpkg_completer", + "outcome": "unchanged (fig already covers it)", + "root": "dpkg" + }, + { + "file": "linux/eopkg_completer", + "outcome": "unique", + "root": "eopkg" + }, + { + "file": "linux/fc-cache_completer", + "outcome": "unique", + "root": "fc-cache" + }, + { + "file": "linux/fc-cat_completer", + "outcome": "unique", + "root": "fc-cat" + }, + { + "file": "linux/fc-conflist_completer", + "outcome": "unique", + "root": "fc-conflist" + }, + { + "file": "linux/fc-list_completer", + "outcome": "unique", + "root": "fc-list" + }, + { + "file": "linux/fdisk_completer", + "outcome": "enriched", + "root": "fdisk", + "subcommands": 0, + "options": 1, + "descriptions": 0 + }, + { + "file": "linux/flatpak_completer", + "outcome": "unique", + "root": "flatpak" + }, + { + "file": "linux/foot_completer", + "outcome": "unique", + "root": "foot" + }, + { + "file": "linux/free_completer", + "outcome": "unique", + "root": "free" + }, + { + "file": "linux/gftp_completer", + "outcome": "unique", + "root": "gftp" + }, + { + "file": "linux/gnome-keyring-daemon_completer", + "outcome": "unique", + "root": "gnome-keyring-daemon" + }, + { + "file": "linux/gnome-keyring_completer", + "outcome": "unique", + "root": "gnome-keyring" + }, + { + "file": "linux/gnome-maps_completer", + "outcome": "unique", + "root": "gnome-maps" + }, + { + "file": "linux/gnome-terminal_completer", + "outcome": "unique", + "root": "gnome-terminal" + }, + { + "file": "linux/gocryptfs_completer", + "outcome": "unique", + "root": "gocryptfs" + }, + { + "file": "linux/gparted_completer", + "outcome": "unique", + "root": "gparted" + }, + { + "file": "linux/gpasswd_completer", + "outcome": "unique", + "root": "gpasswd" + }, + { + "file": "linux/groupadd_completer", + "outcome": "unique", + "root": "groupadd" + }, + { + "file": "linux/groupdel_completer", + "outcome": "unique", + "root": "groupdel" + }, + { + "file": "linux/groupmems_completer", + "outcome": "unique", + "root": "groupmems" + }, + { + "file": "linux/groupmod_completer", + "outcome": "unique", + "root": "groupmod" + }, + { + "file": "linux/halt_completer", + "outcome": "unique", + "root": "halt" + }, + { + "file": "linux/hugetop_completer", + "outcome": "unique", + "root": "hugetop" + }, + { + "file": "linux/hwinfo_completer", + "outcome": "unique", + "root": "hwinfo" + }, + { + "file": "linux/i3-scrot_completer", + "outcome": "unique", + "root": "i3-scrot" + }, + { + "file": "linux/i3_completer", + "outcome": "unique", + "root": "i3" + }, + { + "file": "linux/i3exit_completer", + "outcome": "unique", + "root": "i3exit" + }, + { + "file": "linux/i3lock_completer", + "outcome": "unique", + "root": "i3lock" + }, + { + "file": "linux/i3status-rs_completer", + "outcome": "unique", + "root": "i3status-rs" + }, + { + "file": "linux/i3status_completer", + "outcome": "unique", + "root": "i3status" + }, + { + "file": "linux/imv_completer", + "outcome": "unique", + "root": "imv" + }, + { + "file": "linux/ion_completer", + "outcome": "unique", + "root": "ion" + }, + { + "file": "linux/ip_completer", + "outcome": "unique", + "root": "ip" + }, + { + "file": "linux/journalctl_completer", + "outcome": "unique", + "root": "journalctl" + }, + { + "file": "linux/last_completer", + "outcome": "unique", + "root": "last" + }, + { + "file": "linux/lastb_completer", + "outcome": "unique", + "root": "lastb" + }, + { + "file": "linux/lastlog_completer", + "outcome": "unique", + "root": "lastlog" + }, + { + "file": "linux/light_completer", + "outcome": "unique", + "root": "light" + }, + { + "file": "linux/lightdm_completer", + "outcome": "unique", + "root": "lightdm" + }, + { + "file": "linux/locale_completer", + "outcome": "unique", + "root": "locale" + }, + { + "file": "linux/localectl_completer", + "outcome": "unique", + "root": "localectl" + }, + { + "file": "linux/lsb_release_completer", + "outcome": "unique", + "root": "lsb_release" + }, + { + "file": "linux/lsblk_completer", + "outcome": "enriched", + "root": "lsblk", + "subcommands": 0, + "options": 12, + "descriptions": 0 + }, + { + "file": "linux/lsclocks_completer", + "outcome": "unique", + "root": "lsclocks" + }, + { + "file": "linux/lscpu_completer", + "outcome": "unique", + "root": "lscpu" + }, + { + "file": "linux/lsfd_completer", + "outcome": "unique", + "root": "lsfd" + }, + { + "file": "linux/lsirq_completer", + "outcome": "unique", + "root": "lsirq" + }, + { + "file": "linux/lslocks_completer", + "outcome": "unique", + "root": "lslocks" + }, + { + "file": "linux/lslogins_completer", + "outcome": "unique", + "root": "lslogins" + }, + { + "file": "linux/lsmem_completer", + "outcome": "unique", + "root": "lsmem" + }, + { + "file": "linux/lsns_completer", + "outcome": "unique", + "root": "lsns" + }, + { + "file": "linux/lsusb_completer", + "outcome": "unique", + "root": "lsusb" + }, + { + "file": "linux/lzcat_completer", + "outcome": "unique", + "root": "lzcat" + }, + { + "file": "linux/makepkg_completer", + "outcome": "unique", + "root": "makepkg" + }, + { + "file": "linux/mkfs_completer", + "outcome": "unique", + "root": "mkfs" + }, + { + "file": "linux/mkswap_completer", + "outcome": "unique", + "root": "mkswap" + }, + { + "file": "linux/modinfo_completer", + "outcome": "unique", + "root": "modinfo" + }, + { + "file": "linux/modprobe_completer", + "outcome": "unique", + "root": "modprobe" + }, + { + "file": "linux/nh_completer", + "outcome": "unique", + "root": "nh" + }, + { + "file": "linux/nixos-build-vms_completer", + "outcome": "unique", + "root": "nixos-build-vms" + }, + { + "file": "linux/nixos-container_completer", + "outcome": "unique", + "root": "nixos-container" + }, + { + "file": "linux/nixos-generate-config_completer", + "outcome": "unique", + "root": "nixos-generate-config" + }, + { + "file": "linux/nixos-install_completer", + "outcome": "unique", + "root": "nixos-install" + }, + { + "file": "linux/nixos-option_completer", + "outcome": "unique", + "root": "nixos-option" + }, + { + "file": "linux/nixos-rebuild_completer", + "outcome": "unique", + "root": "nixos-rebuild" + }, + { + "file": "linux/nixos-version_completer", + "outcome": "unique", + "root": "nixos-version" + }, + { + "file": "linux/nmcli_completer", + "outcome": "unique", + "root": "nmcli" + }, + { + "file": "linux/openrc-run_completer", + "outcome": "unique", + "root": "openrc-run" + }, + { + "file": "linux/openrc-shutdown_completer", + "outcome": "unique", + "root": "openrc-shutdown" + }, + { + "file": "linux/openrc_completer", + "outcome": "unique", + "root": "openrc" + }, + { + "file": "linux/pacman-conf_completer", + "outcome": "unique", + "root": "pacman-conf" + }, + { + "file": "linux/pacman-db-upgrade_completer", + "outcome": "unique", + "root": "pacman-db-upgrade" + }, + { + "file": "linux/pacman-key_completer", + "outcome": "unique", + "root": "pacman-key" + }, + { + "file": "linux/pacman-mirrors_completer", + "outcome": "unique", + "root": "pacman-mirrors" + }, + { + "file": "linux/pacman_completer", + "outcome": "unique", + "root": "pacman" + }, + { + "file": "linux/pamac_completer", + "outcome": "unique", + "root": "pamac" + }, + { + "file": "linux/paru_completer", + "outcome": "unique", + "root": "paru" + }, + { + "file": "linux/pcmanfm_completer", + "outcome": "unique", + "root": "pcmanfm" + }, + { + "file": "linux/pidof_completer", + "outcome": "unique", + "root": "pidof" + }, + { + "file": "linux/pidwait_completer", + "outcome": "unique", + "root": "pidwait" + }, + { + "file": "linux/pmap_completer", + "outcome": "unique", + "root": "pmap" + }, + { + "file": "linux/poweroff_completer", + "outcome": "unique", + "root": "poweroff" + }, + { + "file": "linux/powertop_completer", + "outcome": "unique", + "root": "powertop" + }, + { + "file": "linux/qemu-aarch64_be_completer", + "outcome": "unique", + "root": "qemu-aarch64_be" + }, + { + "file": "linux/qemu-aarch64_completer", + "outcome": "unique", + "root": "qemu-aarch64" + }, + { + "file": "linux/qemu-alpha_completer", + "outcome": "unique", + "root": "qemu-alpha" + }, + { + "file": "linux/qemu-arm_completer", + "outcome": "unique", + "root": "qemu-arm" + }, + { + "file": "linux/qemu-armeb_completer", + "outcome": "unique", + "root": "qemu-armeb" + }, + { + "file": "linux/qemu-edid_completer", + "outcome": "unique", + "root": "qemu-edid" + }, + { + "file": "linux/qemu-hexagon_completer", + "outcome": "unique", + "root": "qemu-hexagon" + }, + { + "file": "linux/qemu-hppa_completer", + "outcome": "unique", + "root": "qemu-hppa" + }, + { + "file": "linux/qemu-i386_completer", + "outcome": "unique", + "root": "qemu-i386" + }, + { + "file": "linux/qemu-img_completer", + "outcome": "unique", + "root": "qemu-img" + }, + { + "file": "linux/qemu-io_completer", + "outcome": "unique", + "root": "qemu-io" + }, + { + "file": "linux/qemu-keymap_completer", + "outcome": "unique", + "root": "qemu-keymap" + }, + { + "file": "linux/qemu-loongarch64_completer", + "outcome": "unique", + "root": "qemu-loongarch64" + }, + { + "file": "linux/qemu-m68k_completer", + "outcome": "unique", + "root": "qemu-m68k" + }, + { + "file": "linux/qemu-microblaze_completer", + "outcome": "unique", + "root": "qemu-microblaze" + }, + { + "file": "linux/qemu-microblazeel_completer", + "outcome": "unique", + "root": "qemu-microblazeel" + }, + { + "file": "linux/qemu-mips64_completer", + "outcome": "unique", + "root": "qemu-mips64" + }, + { + "file": "linux/qemu-mips64el_completer", + "outcome": "unique", + "root": "qemu-mips64el" + }, + { + "file": "linux/qemu-mips_completer", + "outcome": "unique", + "root": "qemu-mips" + }, + { + "file": "linux/qemu-mipsel_completer", + "outcome": "unique", + "root": "qemu-mipsel" + }, + { + "file": "linux/qemu-mipsn32_completer", + "outcome": "unique", + "root": "qemu-mipsn32" + }, + { + "file": "linux/qemu-mipsn32el_completer", + "outcome": "unique", + "root": "qemu-mipsn32el" + }, + { + "file": "linux/qemu-nbd_completer", + "outcome": "unique", + "root": "qemu-nbd" + }, + { + "file": "linux/qemu-or1k_completer", + "outcome": "unique", + "root": "qemu-or1k" + }, + { + "file": "linux/qemu-ppc64_completer", + "outcome": "unique", + "root": "qemu-ppc64" + }, + { + "file": "linux/qemu-ppc64le_completer", + "outcome": "unique", + "root": "qemu-ppc64le" + }, + { + "file": "linux/qemu-ppc_completer", + "outcome": "unique", + "root": "qemu-ppc" + }, + { + "file": "linux/qemu-pr-helper_completer", + "outcome": "unique", + "root": "qemu-pr-helper" + }, + { + "file": "linux/qemu-riscv32_completer", + "outcome": "unique", + "root": "qemu-riscv32" + }, + { + "file": "linux/qemu-riscv64_completer", + "outcome": "unique", + "root": "qemu-riscv64" + }, + { + "file": "linux/qemu-s390x_completer", + "outcome": "unique", + "root": "qemu-s390x" + }, + { + "file": "linux/qemu-sh4_completer", + "outcome": "unique", + "root": "qemu-sh4" + }, + { + "file": "linux/qemu-sh4eb_completer", + "outcome": "unique", + "root": "qemu-sh4eb" + }, + { + "file": "linux/qemu-sparc32plus_completer", + "outcome": "unique", + "root": "qemu-sparc32plus" + }, + { + "file": "linux/qemu-sparc64_completer", + "outcome": "unique", + "root": "qemu-sparc64" + }, + { + "file": "linux/qemu-sparc_completer", + "outcome": "unique", + "root": "qemu-sparc" + }, + { + "file": "linux/qemu-storage-daemon_completer", + "outcome": "unique", + "root": "qemu-storage-daemon" + }, + { + "file": "linux/qemu-system-aarch64_completer", + "outcome": "unique", + "root": "qemu-system-aarch64" + }, + { + "file": "linux/qemu-system-alpha_completer", + "outcome": "unique", + "root": "qemu-system-alpha" + }, + { + "file": "linux/qemu-system-arm_completer", + "outcome": "unique", + "root": "qemu-system-arm" + }, + { + "file": "linux/qemu-system-avr_completer", + "outcome": "unique", + "root": "qemu-system-avr" + }, + { + "file": "linux/qemu-system-hppa_completer", + "outcome": "unique", + "root": "qemu-system-hppa" + }, + { + "file": "linux/qemu-system-i386_completer", + "outcome": "unique", + "root": "qemu-system-i386" + }, + { + "file": "linux/qemu-system-loongarch64_completer", + "outcome": "unique", + "root": "qemu-system-loongarch64" + }, + { + "file": "linux/qemu-system-m68k_completer", + "outcome": "unique", + "root": "qemu-system-m68k" + }, + { + "file": "linux/qemu-system-microblaze_completer", + "outcome": "unique", + "root": "qemu-system-microblaze" + }, + { + "file": "linux/qemu-system-mips64_completer", + "outcome": "unique", + "root": "qemu-system-mips64" + }, + { + "file": "linux/qemu-system-mips64el_completer", + "outcome": "unique", + "root": "qemu-system-mips64el" + }, + { + "file": "linux/qemu-system-mips_completer", + "outcome": "unique", + "root": "qemu-system-mips" + }, + { + "file": "linux/qemu-system-mipsel_completer", + "outcome": "unique", + "root": "qemu-system-mipsel" + }, + { + "file": "linux/qemu-system-or1k_completer", + "outcome": "unique", + "root": "qemu-system-or1k" + }, + { + "file": "linux/qemu-system-ppc64_completer", + "outcome": "unique", + "root": "qemu-system-ppc64" + }, + { + "file": "linux/qemu-system-ppc_completer", + "outcome": "unique", + "root": "qemu-system-ppc" + }, + { + "file": "linux/qemu-system-riscv32_completer", + "outcome": "unique", + "root": "qemu-system-riscv32" + }, + { + "file": "linux/qemu-system-riscv64_completer", + "outcome": "unique", + "root": "qemu-system-riscv64" + }, + { + "file": "linux/qemu-system-rx_completer", + "outcome": "unique", + "root": "qemu-system-rx" + }, + { + "file": "linux/qemu-system-s390x_completer", + "outcome": "unique", + "root": "qemu-system-s390x" + }, + { + "file": "linux/qemu-system-sh4_completer", + "outcome": "unique", + "root": "qemu-system-sh4" + }, + { + "file": "linux/qemu-system-sh4eb_completer", + "outcome": "unique", + "root": "qemu-system-sh4eb" + }, + { + "file": "linux/qemu-system-sparc64_completer", + "outcome": "unique", + "root": "qemu-system-sparc64" + }, + { + "file": "linux/qemu-system-sparc_completer", + "outcome": "unique", + "root": "qemu-system-sparc" + }, + { + "file": "linux/qemu-system-tricore_completer", + "outcome": "unique", + "root": "qemu-system-tricore" + }, + { + "file": "linux/qemu-system-x86_64_completer", + "outcome": "unique", + "root": "qemu-system-x86_64" + }, + { + "file": "linux/qemu-system-xtensa_completer", + "outcome": "unique", + "root": "qemu-system-xtensa" + }, + { + "file": "linux/qemu-system-xtensaeb_completer", + "outcome": "unique", + "root": "qemu-system-xtensaeb" + }, + { + "file": "linux/qemu-vmsr-helper_completer", + "outcome": "unique", + "root": "qemu-vmsr-helper" + }, + { + "file": "linux/qemu-x86_64_completer", + "outcome": "unique", + "root": "qemu-x86_64" + }, + { + "file": "linux/qemu-xtensa_completer", + "outcome": "unique", + "root": "qemu-xtensa" + }, + { + "file": "linux/qemu-xtensaeb_completer", + "outcome": "unique", + "root": "qemu-xtensaeb" + }, + { + "file": "linux/rc-depend_completer", + "outcome": "unique", + "root": "rc-depend" + }, + { + "file": "linux/rc-environ_completer", + "outcome": "unique", + "root": "rc-environ" + }, + { + "file": "linux/rc-service_completer", + "outcome": "unique", + "root": "rc-service" + }, + { + "file": "linux/rc-status_completer", + "outcome": "unique", + "root": "rc-status" + }, + { + "file": "linux/rc-update_completer", + "outcome": "unique", + "root": "rc-update" + }, + { + "file": "linux/reboot_completer", + "outcome": "unique", + "root": "reboot" + }, + { + "file": "linux/rmmod_completer", + "outcome": "unique", + "root": "rmmod" + }, + { + "file": "linux/run0_completer", + "outcome": "unique", + "root": "run0" + }, + { + "file": "linux/scrot_completer", + "outcome": "unique", + "root": "scrot" + }, + { + "file": "linux/showkey_completer", + "outcome": "unique", + "root": "showkey" + }, + { + "file": "linux/shutdown_completer", + "outcome": "unique", + "root": "shutdown" + }, + { + "file": "linux/singularity_completer", + "outcome": "unique", + "root": "singularity" + }, + { + "file": "linux/slabtop_completer", + "outcome": "unique", + "root": "slabtop" + }, + { + "file": "linux/st_completer", + "outcome": "unique", + "root": "st" + }, + { + "file": "linux/start-stop-daemon_completer", + "outcome": "unique", + "root": "start-stop-daemon" + }, + { + "file": "linux/supervise-daemon_completer", + "outcome": "unique", + "root": "supervise-daemon" + }, + { + "file": "linux/sway_completer", + "outcome": "unique", + "root": "sway" + }, + { + "file": "linux/swaybar_completer", + "outcome": "unique", + "root": "swaybar" + }, + { + "file": "linux/swaybg_completer", + "outcome": "unique", + "root": "swaybg" + }, + { + "file": "linux/swayidle_completer", + "outcome": "unique", + "root": "swayidle" + }, + { + "file": "linux/swaylock_completer", + "outcome": "unique", + "root": "swaylock" + }, + { + "file": "linux/swaymsg_completer", + "outcome": "unique", + "root": "swaymsg" + }, + { + "file": "linux/swaynag_completer", + "outcome": "unique", + "root": "swaynag" + }, + { + "file": "linux/sysctl_completer", + "outcome": "enriched", + "root": "sysctl", + "subcommands": 0, + "options": 8, + "descriptions": 0 + }, + { + "file": "linux/systemctl_completer", + "outcome": "enriched", + "root": "systemctl", + "subcommands": 8, + "options": 15, + "descriptions": 1 + }, + { + "file": "linux/systemd-analyze_completer", + "outcome": "unique", + "root": "systemd-analyze" + }, + { + "file": "linux/tload_completer", + "outcome": "unique", + "root": "tload" + }, + { + "file": "linux/top_completer", + "outcome": "enriched", + "root": "top", + "subcommands": 0, + "options": 15, + "descriptions": 0 + }, + { + "file": "linux/tty_completer", + "outcome": "unique", + "root": "tty" + }, + { + "file": "linux/ttyd_completer", + "outcome": "unique", + "root": "ttyd" + }, + { + "file": "linux/ufw_completer", + "outcome": "unique", + "root": "ufw" + }, + { + "file": "linux/uname_completer", + "outcome": "enriched", + "root": "uname", + "subcommands": 0, + "options": 4, + "descriptions": 0 + }, + { + "file": "linux/useradd_completer", + "outcome": "unique", + "root": "useradd" + }, + { + "file": "linux/userdel_completer", + "outcome": "unique", + "root": "userdel" + }, + { + "file": "linux/usermod_completer", + "outcome": "unique", + "root": "usermod" + }, + { + "file": "linux/viewnior_completer", + "outcome": "unique", + "root": "viewnior" + }, + { + "file": "linux/vmstat_completer", + "outcome": "unique", + "root": "vmstat" + }, + { + "file": "linux/w_completer", + "outcome": "enriched", + "root": "w", + "subcommands": 0, + "options": 9, + "descriptions": 0 + }, + { + "file": "linux/wg_completer", + "outcome": "unique", + "root": "wg" + }, + { + "file": "linux/whereis_completer", + "outcome": "enriched", + "root": "whereis", + "subcommands": 0, + "options": 4, + "descriptions": 0 + }, + { + "file": "linux/wl-mirror_completer", + "outcome": "unique", + "root": "wl-mirror" + }, + { + "file": "linux/woeusb_completer", + "outcome": "unique", + "root": "woeusb" + }, + { + "file": "linux/xbacklight_completer", + "outcome": "unique", + "root": "xbacklight" + }, + { + "file": "linux/xbps-alternatives_completer", + "outcome": "unique", + "root": "xbps-alternatives" + }, + { + "file": "linux/xbps-checkvers_completer", + "outcome": "unique", + "root": "xbps-checkvers" + }, + { + "file": "linux/xbps-create_completer", + "outcome": "unique", + "root": "xbps-create" + }, + { + "file": "linux/xbps-dgraph_completer", + "outcome": "unique", + "root": "xbps-dgraph" + }, + { + "file": "linux/xbps-digest_completer", + "outcome": "unique", + "root": "xbps-digest" + }, + { + "file": "linux/xbps-fbulk_completer", + "outcome": "unique", + "root": "xbps-fbulk" + }, + { + "file": "linux/xbps-fetch_completer", + "outcome": "unique", + "root": "xbps-fetch" + }, + { + "file": "linux/xbps-install_completer", + "outcome": "unique", + "root": "xbps-install" + }, + { + "file": "linux/xbps-pkgdb_completer", + "outcome": "unique", + "root": "xbps-pkgdb" + }, + { + "file": "linux/xbps-query_completer", + "outcome": "unique", + "root": "xbps-query" + }, + { + "file": "linux/xbps-reconfigure_completer", + "outcome": "unique", + "root": "xbps-reconfigure" + }, + { + "file": "linux/xbps-remove_completer", + "outcome": "unique", + "root": "xbps-remove" + }, + { + "file": "linux/xbps-rindex_completer", + "outcome": "unique", + "root": "xbps-rindex" + }, + { + "file": "linux/xbps-uchroot_completer", + "outcome": "unique", + "root": "xbps-uchroot" + }, + { + "file": "linux/xbps-uhelper_completer", + "outcome": "unique", + "root": "xbps-uhelper" + }, + { + "file": "linux/xbps-uunshare_completer", + "outcome": "unique", + "root": "xbps-uunshare" + }, + { + "file": "linux/xclip_completer", + "outcome": "unique", + "root": "xclip" + }, + { + "file": "linux/xdotool_completer", + "outcome": "unique", + "root": "xdotool" + }, + { + "file": "linux/yay_completer", + "outcome": "unique", + "root": "yay" + }, + { + "file": "linux/zcat_completer", + "outcome": "unique", + "root": "zcat" + }, + { + "file": "darwin/actool_completer", + "outcome": "unique", + "root": "actool" + }, + { + "file": "darwin/afconvert_completer", + "outcome": "unique", + "root": "afconvert" + }, + { + "file": "darwin/afinfo_completer", + "outcome": "unique", + "root": "afinfo" + }, + { + "file": "darwin/afplay_completer", + "outcome": "unchanged (fig already covers it)", + "root": "afplay" + }, + { + "file": "darwin/agvtool_completer", + "outcome": "unique", + "root": "agvtool" + }, + { + "file": "darwin/airport_completer", + "outcome": "unique", + "root": "airport" + }, + { + "file": "darwin/apfsctl_completer", + "outcome": "unique", + "root": "apfsctl" + }, + { + "file": "darwin/arch_completer", + "outcome": "unchanged (fig already covers it)", + "root": "arch" + }, + { + "file": "darwin/archiveutil_completer", + "outcome": "unique", + "root": "archiveutil" + }, + { + "file": "darwin/arp_completer", + "outcome": "unique", + "root": "arp" + }, + { + "file": "darwin/asr_completer", + "outcome": "enriched", + "root": "asr", + "subcommands": 0, + "options": 1, + "descriptions": 0 + }, + { + "file": "darwin/assetutil_completer", + "outcome": "unique", + "root": "assetutil" + }, + { + "file": "darwin/at_completer", + "outcome": "unique", + "root": "at" + }, + { + "file": "darwin/atos_completer", + "outcome": "unique", + "root": "atos" + }, + { + "file": "darwin/automator_completer", + "outcome": "unique", + "root": "automator" + }, + { + "file": "darwin/automount_completer", + "outcome": "unique", + "root": "automount" + }, + { + "file": "darwin/auval_completer", + "outcome": "unique", + "root": "auval" + }, + { + "file": "darwin/avconvert_completer", + "outcome": "unique", + "root": "avconvert" + }, + { + "file": "darwin/avmediainfo_completer", + "outcome": "unique", + "root": "avmediainfo" + }, + { + "file": "darwin/banner_completer", + "outcome": "unchanged (fig already covers it)", + "root": "banner" + }, + { + "file": "darwin/base64_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/basename_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/bless_completer", + "outcome": "unique", + "root": "bless" + }, + { + "file": "darwin/blueutil_completer", + "outcome": "unique", + "root": "blueutil" + }, + { + "file": "darwin/bputil_completer", + "outcome": "unique", + "root": "bputil" + }, + { + "file": "darwin/brctl_completer", + "outcome": "unique", + "root": "brctl" + }, + { + "file": "darwin/caffeinate_completer", + "outcome": "unchanged (fig already covers it)", + "root": "caffeinate" + }, + { + "file": "darwin/cat_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/chflags_completer", + "outcome": "unique", + "root": "chflags" + }, + { + "file": "darwin/chgrp_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/chmod_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/chown_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/chpass_completer", + "outcome": "unique", + "root": "chpass" + }, + { + "file": "darwin/cksum_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/codesign_completer", + "outcome": "enriched", + "root": "codesign", + "subcommands": 0, + "options": 4, + "descriptions": 0 + }, + { + "file": "darwin/compress_completer", + "outcome": "unique", + "root": "compress" + }, + { + "file": "darwin/compression_tool_completer", + "outcome": "unique", + "root": "compression_tool" + }, + { + "file": "darwin/cp_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/cpio_completer", + "outcome": "unique", + "root": "cpio" + }, + { + "file": "darwin/createinstallmedia_completer", + "outcome": "unique", + "root": "createinstallmedia" + }, + { + "file": "darwin/crontab_completer", + "outcome": "unchanged (fig already covers it)", + "root": "crontab" + }, + { + "file": "darwin/csreq_completer", + "outcome": "unique", + "root": "csreq" + }, + { + "file": "darwin/csrutil_completer", + "outcome": "unique", + "root": "csrutil" + }, + { + "file": "darwin/cupsaccept_completer", + "outcome": "unique", + "root": "cupsaccept" + }, + { + "file": "darwin/cupsctl_completer", + "outcome": "unique", + "root": "cupsctl" + }, + { + "file": "darwin/cupsdisable_completer", + "outcome": "unique", + "root": "cupsdisable" + }, + { + "file": "darwin/cupsenable_completer", + "outcome": "unique", + "root": "cupsenable" + }, + { + "file": "darwin/cupsfilter_completer", + "outcome": "unique", + "root": "cupsfilter" + }, + { + "file": "darwin/cupsreject_completer", + "outcome": "unique", + "root": "cupsreject" + }, + { + "file": "darwin/cut_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/date_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/dd_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/defaults_completer", + "outcome": "enriched", + "root": "defaults", + "subcommands": 2, + "options": 10, + "descriptions": 0 + }, + { + "file": "darwin/df_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/diff_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/diffstat_completer", + "outcome": "unique", + "root": "diffstat" + }, + { + "file": "darwin/dirname_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/diskutil_completer", + "outcome": "unique", + "root": "diskutil" + }, + { + "file": "darwin/ditto_completer", + "outcome": "enriched", + "root": "ditto", + "subcommands": 0, + "options": 14, + "descriptions": 0 + }, + { + "file": "darwin/dmesg_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/dnctl_completer", + "outcome": "unique", + "root": "dnctl" + }, + { + "file": "darwin/dot_clean_completer", + "outcome": "unique", + "root": "dot_clean" + }, + { + "file": "darwin/drutil_completer", + "outcome": "unique", + "root": "drutil" + }, + { + "file": "darwin/dscacheutil_completer", + "outcome": "enriched", + "root": "dscacheutil", + "subcommands": 0, + "options": 9, + "descriptions": 0 + }, + { + "file": "darwin/dscl_completer", + "outcome": "enriched", + "root": "dscl", + "subcommands": 0, + "options": 5, + "descriptions": 0 + }, + { + "file": "darwin/dseditgroup_completer", + "outcome": "unique", + "root": "dseditgroup" + }, + { + "file": "darwin/dsenableroot_completer", + "outcome": "unique", + "root": "dsenableroot" + }, + { + "file": "darwin/dsmemberutil_completer", + "outcome": "unique", + "root": "dsmemberutil" + }, + { + "file": "darwin/dtrace_completer", + "outcome": "unique", + "root": "dtrace" + }, + { + "file": "darwin/dtruss_completer", + "outcome": "unique", + "root": "dtruss" + }, + { + "file": "darwin/du_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/dyld_info_completer", + "outcome": "unique", + "root": "dyld_info" + }, + { + "file": "darwin/dyld_usage_completer", + "outcome": "unique", + "root": "dyld_usage" + }, + { + "file": "darwin/env_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/eslogger_completer", + "outcome": "unique", + "root": "eslogger" + }, + { + "file": "darwin/fddist_completer", + "outcome": "unique", + "root": "fddist" + }, + { + "file": "darwin/fdesetup_completer", + "outcome": "unique", + "root": "fdesetup" + }, + { + "file": "darwin/file_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/fileicon_completer", + "outcome": "unique", + "root": "fileicon" + }, + { + "file": "darwin/fileproviderctl_completer", + "outcome": "unique", + "root": "fileproviderctl" + }, + { + "file": "darwin/find_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/finger_completer", + "outcome": "unique", + "root": "finger" + }, + { + "file": "darwin/firmwarepasswd_completer", + "outcome": "unique", + "root": "firmwarepasswd" + }, + { + "file": "darwin/fontrestore_completer", + "outcome": "unique", + "root": "fontrestore" + }, + { + "file": "darwin/footprint_completer", + "outcome": "unique", + "root": "footprint" + }, + { + "file": "darwin/fping_completer", + "outcome": "unique", + "root": "fping" + }, + { + "file": "darwin/fs_usage_completer", + "outcome": "unique", + "root": "fs_usage" + }, + { + "file": "darwin/fsck_apfs_completer", + "outcome": "unique", + "root": "fsck_apfs" + }, + { + "file": "darwin/fsck_completer", + "outcome": "unique", + "root": "fsck" + }, + { + "file": "darwin/fsck_hfs_completer", + "outcome": "unique", + "root": "fsck_hfs" + }, + { + "file": "darwin/fstyp_completer", + "outcome": "unique", + "root": "fstyp" + }, + { + "file": "darwin/fuser_completer", + "outcome": "unique", + "root": "fuser" + }, + { + "file": "darwin/gpt_completer", + "outcome": "unique", + "root": "gpt" + }, + { + "file": "darwin/hdiutil_completer", + "outcome": "unique", + "root": "hdiutil" + }, + { + "file": "darwin/head_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/heap_completer", + "outcome": "unique", + "root": "heap" + }, + { + "file": "darwin/hidutil_completer", + "outcome": "unique", + "root": "hidutil" + }, + { + "file": "darwin/hiutil_completer", + "outcome": "unique", + "root": "hiutil" + }, + { + "file": "darwin/host_completer", + "outcome": "unique", + "root": "host" + }, + { + "file": "darwin/hostinfo_completer", + "outcome": "unique", + "root": "hostinfo" + }, + { + "file": "darwin/hostname_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/ibtool_completer", + "outcome": "unique", + "root": "ibtool" + }, + { + "file": "darwin/iconutil_completer", + "outcome": "unique", + "root": "iconutil" + }, + { + "file": "darwin/iconv_completer", + "outcome": "enriched", + "root": "iconv", + "subcommands": 0, + "options": 1, + "descriptions": 0 + }, + { + "file": "darwin/id_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/ifconfig_completer", + "outcome": "unique", + "root": "ifconfig" + }, + { + "file": "darwin/install_name_tool_completer", + "outcome": "unique", + "root": "install_name_tool" + }, + { + "file": "darwin/installer_completer", + "outcome": "unique", + "root": "installer" + }, + { + "file": "darwin/iopattern_completer", + "outcome": "unique", + "root": "iopattern" + }, + { + "file": "darwin/iopending_completer", + "outcome": "unique", + "root": "iopending" + }, + { + "file": "darwin/ioreg_completer", + "outcome": "unique", + "root": "ioreg" + }, + { + "file": "darwin/iosnoop_completer", + "outcome": "unique", + "root": "iosnoop" + }, + { + "file": "darwin/iostat_completer", + "outcome": "unique", + "root": "iostat" + }, + { + "file": "darwin/iotop_completer", + "outcome": "unique", + "root": "iotop" + }, + { + "file": "darwin/ipconfig_completer", + "outcome": "unique", + "root": "ipconfig" + }, + { + "file": "darwin/ipcrm_completer", + "outcome": "unique", + "root": "ipcrm" + }, + { + "file": "darwin/ipcs_completer", + "outcome": "unique", + "root": "ipcs" + }, + { + "file": "darwin/iperf3-darwin_completer", + "outcome": "unique", + "root": "iperf3-darwin" + }, + { + "file": "darwin/jot_completer", + "outcome": "unique", + "root": "jot" + }, + { + "file": "darwin/kdestroy_completer", + "outcome": "unique", + "root": "kdestroy" + }, + { + "file": "darwin/kextload_completer", + "outcome": "unique", + "root": "kextload" + }, + { + "file": "darwin/kextstat_completer", + "outcome": "unique", + "root": "kextstat" + }, + { + "file": "darwin/kextunload_completer", + "outcome": "unique", + "root": "kextunload" + }, + { + "file": "darwin/kill_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/kinit_completer", + "outcome": "unique", + "root": "kinit" + }, + { + "file": "darwin/klist_completer", + "outcome": "unchanged (fig already covers it)", + "root": "klist" + }, + { + "file": "darwin/kmutil_completer", + "outcome": "unique", + "root": "kmutil" + }, + { + "file": "darwin/kswitch_completer", + "outcome": "unique", + "root": "kswitch" + }, + { + "file": "darwin/ktutil_completer", + "outcome": "unique", + "root": "ktutil" + }, + { + "file": "darwin/lastwords_completer", + "outcome": "unique", + "root": "lastwords" + }, + { + "file": "darwin/launchctl_completer", + "outcome": "enriched", + "root": "launchctl", + "subcommands": 2, + "options": 8, + "descriptions": 0 + }, + { + "file": "darwin/leaks_completer", + "outcome": "unique", + "root": "leaks" + }, + { + "file": "darwin/less_completer", + "outcome": "enriched", + "root": "less", + "subcommands": 0, + "options": 1, + "descriptions": 0 + }, + { + "file": "darwin/lldb_completer", + "outcome": "unique", + "root": "lldb" + }, + { + "file": "darwin/ln_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/log_completer", + "outcome": "unique", + "root": "log" + }, + { + "file": "darwin/logger_completer", + "outcome": "unique", + "root": "logger" + }, + { + "file": "darwin/look_completer", + "outcome": "unique", + "root": "look" + }, + { + "file": "darwin/ls_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/lsappinfo_completer", + "outcome": "unique", + "root": "lsappinfo" + }, + { + "file": "darwin/lsof_completer", + "outcome": "enriched", + "root": "lsof", + "subcommands": 0, + "options": 1, + "descriptions": 0 + }, + { + "file": "darwin/mas_completer", + "outcome": "enriched", + "root": "mas", + "subcommands": 1, + "options": 34, + "descriptions": 0 + }, + { + "file": "darwin/mcxquery_completer", + "outcome": "unique", + "root": "mcxquery" + }, + { + "file": "darwin/md5_completer", + "outcome": "unique", + "root": "md5" + }, + { + "file": "darwin/mdfind_completer", + "outcome": "enriched", + "root": "mdfind", + "subcommands": 0, + "options": 7, + "descriptions": 0 + }, + { + "file": "darwin/mdimport_completer", + "outcome": "unique", + "root": "mdimport" + }, + { + "file": "darwin/mdls_completer", + "outcome": "enriched", + "root": "mdls", + "subcommands": 0, + "options": 3, + "descriptions": 0 + }, + { + "file": "darwin/mdutil_completer", + "outcome": "unique", + "root": "mdutil" + }, + { + "file": "darwin/memory_pressure_completer", + "outcome": "unique", + "root": "memory_pressure" + }, + { + "file": "darwin/mkbom_completer", + "outcome": "unique", + "root": "mkbom" + }, + { + "file": "darwin/mkdir_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/mkfile_completer", + "outcome": "unique", + "root": "mkfile" + }, + { + "file": "darwin/mount_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/mtree_completer", + "outcome": "unique", + "root": "mtree" + }, + { + "file": "darwin/mv_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/ncal_completer", + "outcome": "unchanged (fig already covers it)", + "root": "ncal" + }, + { + "file": "darwin/ndp_completer", + "outcome": "unique", + "root": "ndp" + }, + { + "file": "darwin/netstat_completer", + "outcome": "unique", + "root": "netstat" + }, + { + "file": "darwin/nettop_completer", + "outcome": "unique", + "root": "nettop" + }, + { + "file": "darwin/networkquality_completer", + "outcome": "unique", + "root": "networkquality" + }, + { + "file": "darwin/networksetup_completer", + "outcome": "enriched", + "root": "networksetup", + "subcommands": 0, + "options": 34, + "descriptions": 0 + }, + { + "file": "darwin/newfs_apfs_completer", + "outcome": "unique", + "root": "newfs_apfs" + }, + { + "file": "darwin/newfs_hfs_completer", + "outcome": "unique", + "root": "newfs_hfs" + }, + { + "file": "darwin/nfsstat_completer", + "outcome": "unique", + "root": "nfsstat" + }, + { + "file": "darwin/nslookup_completer", + "outcome": "unique", + "root": "nslookup" + }, + { + "file": "darwin/nvram_completer", + "outcome": "unique", + "root": "nvram" + }, + { + "file": "darwin/od_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/open_completer", + "outcome": "enriched", + "root": "open", + "subcommands": 0, + "options": 1, + "descriptions": 0 + }, + { + "file": "darwin/opensnoop_completer", + "outcome": "unique", + "root": "opensnoop" + }, + { + "file": "darwin/orb_completer", + "outcome": "unique", + "root": "orb" + }, + { + "file": "darwin/osascript_completer", + "outcome": "unchanged (fig already covers it)", + "root": "osascript" + }, + { + "file": "darwin/otool_completer", + "outcome": "unique", + "root": "otool" + }, + { + "file": "darwin/pagesize_completer", + "outcome": "unique", + "root": "pagesize" + }, + { + "file": "darwin/patch_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/pax_completer", + "outcome": "unique", + "root": "pax" + }, + { + "file": "darwin/pbcopy_completer", + "outcome": "unique", + "root": "pbcopy" + }, + { + "file": "darwin/pbpaste_completer", + "outcome": "unique", + "root": "pbpaste" + }, + { + "file": "darwin/pdisk_completer", + "outcome": "unique", + "root": "pdisk" + }, + { + "file": "darwin/pfctl_completer", + "outcome": "unique", + "root": "pfctl" + }, + { + "file": "darwin/ping6_completer", + "outcome": "unique", + "root": "ping6" + }, + { + "file": "darwin/ping_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/pkgbuild_completer", + "outcome": "unique", + "root": "pkgbuild" + }, + { + "file": "darwin/pkgutil_completer", + "outcome": "enriched", + "root": "pkgutil", + "subcommands": 0, + "options": 19, + "descriptions": 0 + }, + { + "file": "darwin/plockstat_completer", + "outcome": "unique", + "root": "plockstat" + }, + { + "file": "darwin/pluginkit_completer", + "outcome": "unique", + "root": "pluginkit" + }, + { + "file": "darwin/plutil_completer", + "outcome": "enriched", + "root": "plutil", + "subcommands": 0, + "options": 11, + "descriptions": 0 + }, + { + "file": "darwin/pmset_completer", + "outcome": "enriched", + "root": "pmset", + "subcommands": 0, + "options": 1, + "descriptions": 0 + }, + { + "file": "darwin/port_completer", + "outcome": "unique", + "root": "port" + }, + { + "file": "darwin/powermetrics_completer", + "outcome": "unique", + "root": "powermetrics" + }, + { + "file": "darwin/productbuild_completer", + "outcome": "unique", + "root": "productbuild" + }, + { + "file": "darwin/profiles_completer", + "outcome": "unique", + "root": "profiles" + }, + { + "file": "darwin/ps_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/purge_completer", + "outcome": "unique", + "root": "purge" + }, + { + "file": "darwin/pwpolicy_completer", + "outcome": "unique", + "root": "pwpolicy" + }, + { + "file": "darwin/qlmanage_completer", + "outcome": "unique", + "root": "qlmanage" + }, + { + "file": "darwin/realpath_completer", + "outcome": "unique", + "root": "realpath" + }, + { + "file": "darwin/renice_completer", + "outcome": "unique", + "root": "renice" + }, + { + "file": "darwin/repair_packages_completer", + "outcome": "unique", + "root": "repair_packages" + }, + { + "file": "darwin/rev_completer", + "outcome": "unique", + "root": "rev" + }, + { + "file": "darwin/rm_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/rmdir_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/route_completer", + "outcome": "unique", + "root": "route" + }, + { + "file": "darwin/rwsnoop_completer", + "outcome": "unique", + "root": "rwsnoop" + }, + { + "file": "darwin/sa_completer", + "outcome": "unique", + "root": "sa" + }, + { + "file": "darwin/sample_completer", + "outcome": "unique", + "root": "sample" + }, + { + "file": "darwin/say_completer", + "outcome": "unique", + "root": "say" + }, + { + "file": "darwin/sc_auth_completer", + "outcome": "unique", + "root": "sc_auth" + }, + { + "file": "darwin/sc_usage_completer", + "outcome": "unique", + "root": "sc_usage" + }, + { + "file": "darwin/screen_completer", + "outcome": "enriched", + "root": "screen", + "subcommands": 0, + "options": 4, + "descriptions": 0 + }, + { + "file": "darwin/screencapture_completer", + "outcome": "unique", + "root": "screencapture" + }, + { + "file": "darwin/scutil_completer", + "outcome": "unique", + "root": "scutil" + }, + { + "file": "darwin/security_completer", + "outcome": "unique", + "root": "security" + }, + { + "file": "darwin/sfltool_completer", + "outcome": "unique", + "root": "sfltool" + }, + { + "file": "darwin/sharing_completer", + "outcome": "unique", + "root": "sharing" + }, + { + "file": "darwin/shasum_completer", + "outcome": "unchanged (fig already covers it)", + "root": "shasum" + }, + { + "file": "darwin/shortcuts_completer", + "outcome": "enriched", + "root": "shortcuts", + "subcommands": 0, + "options": 1, + "descriptions": 0 + }, + { + "file": "darwin/simctl_completer", + "outcome": "enriched", + "root": "simctl", + "subcommands": 0, + "options": 5, + "descriptions": 1 + }, + { + "file": "darwin/sips_completer", + "outcome": "enriched", + "root": "sips", + "subcommands": 0, + "options": 1, + "descriptions": 1 + }, + { + "file": "darwin/skhd_completer", + "outcome": "unique", + "root": "skhd" + }, + { + "file": "darwin/smbutil_completer", + "outcome": "unique", + "root": "smbutil" + }, + { + "file": "darwin/sntp_completer", + "outcome": "unique", + "root": "sntp" + }, + { + "file": "darwin/softwareupdate_completer", + "outcome": "enriched", + "root": "softwareupdate", + "subcommands": 0, + "options": 19, + "descriptions": 0 + }, + { + "file": "darwin/sort_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/spctl_completer", + "outcome": "unique", + "root": "spctl" + }, + { + "file": "darwin/spindump_completer", + "outcome": "unique", + "root": "spindump" + }, + { + "file": "darwin/stapler_completer", + "outcome": "unique", + "root": "stapler" + }, + { + "file": "darwin/startosinstall_completer", + "outcome": "unique", + "root": "startosinstall" + }, + { + "file": "darwin/stat_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/stdbuf_completer", + "outcome": "unique", + "root": "stdbuf" + }, + { + "file": "darwin/sw_vers_completer", + "outcome": "enriched", + "root": "sw_vers", + "subcommands": 0, + "options": 8, + "descriptions": 0 + }, + { + "file": "darwin/swift_completer", + "outcome": "unchanged (fig already covers it)", + "root": "swift" + }, + { + "file": "darwin/swiftc_completer", + "outcome": "unique", + "root": "swiftc" + }, + { + "file": "darwin/sysadminctl_completer", + "outcome": "unique", + "root": "sysadminctl" + }, + { + "file": "darwin/sysctl_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/sysdiagnose_completer", + "outcome": "unique", + "root": "sysdiagnose" + }, + { + "file": "darwin/syslog_completer", + "outcome": "unique", + "root": "syslog" + }, + { + "file": "darwin/system_profiler_completer", + "outcome": "unique", + "root": "system_profiler" + }, + { + "file": "darwin/systemextensionsctl_completer", + "outcome": "unique", + "root": "systemextensionsctl" + }, + { + "file": "darwin/systemsetup_completer", + "outcome": "unique", + "root": "systemsetup" + }, + { + "file": "darwin/tail_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/tailspin_completer", + "outcome": "unique", + "root": "tailspin" + }, + { + "file": "darwin/talk_completer", + "outcome": "unique", + "root": "talk" + }, + { + "file": "darwin/tar_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/taskinfo_completer", + "outcome": "unique", + "root": "taskinfo" + }, + { + "file": "darwin/taskpolicy_completer", + "outcome": "unique", + "root": "taskpolicy" + }, + { + "file": "darwin/tccutil_completer", + "outcome": "unchanged (fig already covers it)", + "root": "tccutil" + }, + { + "file": "darwin/tcpdump_completer", + "outcome": "unique", + "root": "tcpdump" + }, + { + "file": "darwin/tee_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/textutil_completer", + "outcome": "unique", + "root": "textutil" + }, + { + "file": "darwin/tftp_completer", + "outcome": "unique", + "root": "tftp" + }, + { + "file": "darwin/tiff2icns_completer", + "outcome": "unique", + "root": "tiff2icns" + }, + { + "file": "darwin/tiffutil_completer", + "outcome": "unique", + "root": "tiffutil" + }, + { + "file": "darwin/tmutil_completer", + "outcome": "enriched", + "root": "tmutil", + "subcommands": 1, + "options": 18, + "descriptions": 0 + }, + { + "file": "darwin/top_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/touch_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/tr_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/traceroute6_completer", + "outcome": "unique", + "root": "traceroute6" + }, + { + "file": "darwin/traceroute_completer", + "outcome": "enriched", + "root": "traceroute", + "subcommands": 0, + "options": 1, + "descriptions": 0 + }, + { + "file": "darwin/trimforce_completer", + "outcome": "unique", + "root": "trimforce" + }, + { + "file": "darwin/umount_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/uname_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/uniq_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/uuidgen_completer", + "outcome": "unique", + "root": "uuidgen" + }, + { + "file": "darwin/vm_stat_completer", + "outcome": "unique", + "root": "vm_stat" + }, + { + "file": "darwin/vmmap_completer", + "outcome": "unique", + "root": "vmmap" + }, + { + "file": "darwin/vsdbutil_completer", + "outcome": "unique", + "root": "vsdbutil" + }, + { + "file": "darwin/wait4path_completer", + "outcome": "unique", + "root": "wait4path" + }, + { + "file": "darwin/wall_completer", + "outcome": "unique", + "root": "wall" + }, + { + "file": "darwin/wc_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/wdutil_completer", + "outcome": "unique", + "root": "wdutil" + }, + { + "file": "darwin/what_completer", + "outcome": "unique", + "root": "what" + }, + { + "file": "darwin/who_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/whois_completer", + "outcome": "unchanged (fig already covers it)", + "root": "whois" + }, + { + "file": "darwin/write_completer", + "outcome": "unique", + "root": "write" + }, + { + "file": "darwin/xar_completer", + "outcome": "unique", + "root": "xar" + }, + { + "file": "darwin/xargs_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + }, + { + "file": "darwin/xattr_completer", + "outcome": "unique", + "root": "xattr" + }, + { + "file": "darwin/xcode-select_completer", + "outcome": "unchanged (fig already covers it)", + "root": "xcode-select" + }, + { + "file": "darwin/xcodebuild_completer", + "outcome": "enriched", + "root": "xcodebuild", + "subcommands": 0, + "options": 19, + "descriptions": 0 + }, + { + "file": "darwin/xcrun_completer", + "outcome": "enriched", + "root": "xcrun", + "subcommands": 0, + "options": 1, + "descriptions": 0 + }, + { + "file": "darwin/xip_completer", + "outcome": "unique", + "root": "xip" + }, + { + "file": "darwin/xmllint_completer", + "outcome": "unique", + "root": "xmllint" + }, + { + "file": "darwin/xprotect_completer", + "outcome": "unique", + "root": "xprotect" + }, + { + "file": "darwin/xsltproc_completer", + "outcome": "unique", + "root": "xsltproc" + }, + { + "file": "darwin/xxd_completer", + "outcome": "enriched", + "root": "xxd", + "subcommands": 0, + "options": 9, + "descriptions": 0 + }, + { + "file": "darwin/zprint_completer", + "outcome": "unique", + "root": "zprint" + }, + { + "file": "bsd/sed_completer", + "outcome": "skipped", + "reason": "same command on an earlier platform" + } + ] + } +} diff --git a/assets/promo/nmsh-promo.mp4 b/assets/promo/nmsh-promo.mp4 new file mode 100644 index 00000000..87565e07 Binary files /dev/null and b/assets/promo/nmsh-promo.mp4 differ diff --git a/assets/promo/nmsh-promo.png b/assets/promo/nmsh-promo.png new file mode 100644 index 00000000..8e7e4d66 Binary files /dev/null and b/assets/promo/nmsh-promo.png differ diff --git a/assets/readme/architecture-detailed.svg b/assets/readme/architecture-detailed.svg new file mode 100644 index 00000000..dc9d3a2f --- /dev/null +++ b/assets/readme/architecture-detailed.svg @@ -0,0 +1,6 @@ + +NMSh detailed runtime flowA vertical host, frontend, session transport and shell stack. Isolated helpers support the composer. Output and lifecycle markers return to the frontend. Interactive programs bypass frontend presentation through raw passthrough. + + +NMSh / runtime flowTerminal hostKeys · mouse · resize · displaynotMyShellOwns editing and presentationComposer & command routingLexical Highlighter · slash actions · shell inputScreenPlan & presentationShared geometry · OutputBuffer · motion clockPrompt · Chroma · activity · FOLLOW / DETACHEDRendered frameIsolated helpersSemanticServiceCompletion / local AINever the host TTYSubmitSessionClientService or in-process transportOutput + markersPersistenceJournal / archivesHistory / config / themesInput / outputShellSession + ShellAdapterPersistent PTY · zsh / Bash / FishCLI commands / interactive programsRaw passthroughRaw output keeps its colors. Semantic colors belong to NMSh input and submitted commands. + diff --git a/assets/readme/architecture.svg b/assets/readme/architecture.svg index c03e5b99..15c97afc 100644 --- a/assets/readme/architecture.svg +++ b/assets/readme/architecture.svg @@ -1,33 +1,6 @@ - - - - - - - - Terminal Host - Ghostty / VS Code / Terminal - - - - - - - NMSh - Frontend · Editor · Highlighting - - - - - - - real zsh - Execution · Aliases · Parser + +NMSh: terminal host, frontend, real shellThe terminal host sends input to NMSh. NMSh owns editing and presentation over a persistent zsh, Bash or Fish PTY. Fullscreen applications pass through to the host. + + +Terminal hostGhostty / Zed / Terminal.appInput / displaynotMyShellComposer · prompt · highlightingTranscript · themes · live feedbackPersistent PTYReal shellzsh / Bash 4.4+ / FishExecution · aliases · stateFullscreen apps use passthrough to the host. diff --git a/assets/readme/ask.gif b/assets/readme/ask.gif new file mode 100644 index 00000000..8c887a3c Binary files /dev/null and b/assets/readme/ask.gif differ diff --git a/assets/readme/chroma.gif b/assets/readme/chroma.gif new file mode 100644 index 00000000..29e104b5 Binary files /dev/null and b/assets/readme/chroma.gif differ diff --git a/assets/readme/composer.gif b/assets/readme/composer.gif new file mode 100644 index 00000000..1b712b6f Binary files /dev/null and b/assets/readme/composer.gif differ diff --git a/assets/readme/keep-awake.gif b/assets/readme/keep-awake.gif new file mode 100644 index 00000000..77d01d9f Binary files /dev/null and b/assets/readme/keep-awake.gif differ diff --git a/assets/readme/keep-awake.png b/assets/readme/keep-awake.png new file mode 100644 index 00000000..a0d54188 Binary files /dev/null and b/assets/readme/keep-awake.png differ diff --git a/assets/readme/motion.gif b/assets/readme/motion.gif new file mode 100644 index 00000000..a6811e99 Binary files /dev/null and b/assets/readme/motion.gif differ diff --git a/assets/readme/nmsh-composer.png b/assets/readme/nmsh-composer.png new file mode 100644 index 00000000..370b0236 Binary files /dev/null and b/assets/readme/nmsh-composer.png differ diff --git a/assets/readme/nmsh-demo.gif b/assets/readme/nmsh-demo.gif index 1ba5349f..9876b40a 100644 Binary files a/assets/readme/nmsh-demo.gif and b/assets/readme/nmsh-demo.gif differ diff --git a/assets/readme/screensavers-cats.gif b/assets/readme/screensavers-cats.gif new file mode 100644 index 00000000..a019df3b Binary files /dev/null and b/assets/readme/screensavers-cats.gif differ diff --git a/assets/readme/screensavers-motion.gif b/assets/readme/screensavers-motion.gif new file mode 100644 index 00000000..755e6926 Binary files /dev/null and b/assets/readme/screensavers-motion.gif differ diff --git a/assets/readme/screensavers.gif b/assets/readme/screensavers.gif new file mode 100644 index 00000000..48bd5891 Binary files /dev/null and b/assets/readme/screensavers.gif differ diff --git a/assets/readme/sessions.gif b/assets/readme/sessions.gif new file mode 100644 index 00000000..57b6282b Binary files /dev/null and b/assets/readme/sessions.gif differ diff --git a/assets/readme/setup.gif b/assets/readme/setup.gif new file mode 100644 index 00000000..9987fb62 Binary files /dev/null and b/assets/readme/setup.gif differ diff --git a/assets/readme/shell-vim.gif b/assets/readme/shell-vim.gif new file mode 100644 index 00000000..8bc8cd83 Binary files /dev/null and b/assets/readme/shell-vim.gif differ diff --git a/assets/readme/syntax.gif b/assets/readme/syntax.gif new file mode 100644 index 00000000..ca17931b Binary files /dev/null and b/assets/readme/syntax.gif differ diff --git a/assets/readme/theme-bridge.gif b/assets/readme/theme-bridge.gif new file mode 100644 index 00000000..279199df Binary files /dev/null and b/assets/readme/theme-bridge.gif differ diff --git a/assets/readme/theme-studio.png b/assets/readme/theme-studio.png new file mode 100644 index 00000000..e6e7bb62 Binary files /dev/null and b/assets/readme/theme-studio.png differ diff --git a/assets/readme/themes.gif b/assets/readme/themes.gif new file mode 100644 index 00000000..d1ee88dc Binary files /dev/null and b/assets/readme/themes.gif differ diff --git a/assets/readme/tools.gif b/assets/readme/tools.gif new file mode 100644 index 00000000..81bfccc9 Binary files /dev/null and b/assets/readme/tools.gif differ diff --git a/assets/readme/vespyr-divider.svg b/assets/readme/vespyr-divider.svg new file mode 100644 index 00000000..109c141e --- /dev/null +++ b/assets/readme/vespyr-divider.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/understanding/recommended-model.json b/assets/understanding/recommended-model.json new file mode 100644 index 00000000..2cb55778 --- /dev/null +++ b/assets/understanding/recommended-model.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "model": "Qwen3 0.6B", + "upstream": { + "repository": "https://huggingface.co/Qwen/Qwen3-0.6B", + "license": "Apache-2.0" + }, + "quantization": "Q8_0", + "artifact": { + "publisher": "Qwen", + "repository": "https://huggingface.co/Qwen/Qwen3-0.6B-GGUF", + "revision": "1eaf4d9657fe65ad10a51eab76a8db5b363bddaa", + "file": "Qwen3-0.6B-Q8_0.gguf", + "url": "https://huggingface.co/Qwen/Qwen3-0.6B-GGUF/resolve/1eaf4d9657fe65ad10a51eab76a8db5b363bddaa/Qwen3-0.6B-Q8_0.gguf", + "source": "https://huggingface.co/Qwen/Qwen3-0.6B-GGUF/blob/1eaf4d9657fe65ad10a51eab76a8db5b363bddaa/Qwen3-0.6B-Q8_0.gguf", + "quantization": "Q8_0", + "bytes": 639446688, + "sha256": "9465e63a22add5354d9bb4b99e90117043c7124007664907259bd16d043bb031", + "license": "Apache-2.0" + } +} 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..fb6e5573 --- /dev/null +++ b/dev/tapes/README.md @@ -0,0 +1,50 @@ +# Development VHS tapes + +Two VHS sets with separate jobs: + +- **`scripts/demos/`** is the canonical source of README and `docs/demos.md` + media: `npm run demos` records committed tapes into `assets/readme/` from a + disposable demo home. Change those tapes when published media should change. +- **`dev/tapes/`** (this directory) holds development captures: feature and + regression looks (Settings, Chroma, idle visuals, shimmer) written to the + ignored `dev/tapes/output/`. They are never published as-is. + +The earlier asciinema/tmux recorder (`scripts/readme-demo/`) was retired: it +recorded against the maintainer's real home. Use plain `asciinema rec` by hand +when a real interactive session is needed as support evidence. + +Neither set is a runtime dependency, and neither is installed for NMSh users. + +## VHS tapes + +These optional VHS tapes capture welcome, Settings v2, an ordinary command, native history selection and idle visuals. 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 dev/tapes/idle-aurora.tape # Aurora Drift, Aurora Chroma +vhs dev/tapes/idle-warp.tape # Warp Starfield +vhs dev/tapes/idle-fireworks.tape # Fireworks, Rainbow Chroma +vhs dev/tapes/idle-catppuccin.tape # Aurora Drift following Catppuccin Mocha (blue accent) +vhs dev/tapes/shimmer.tape # color-aware light sweep, one pass per row +``` + +Idle-visual tapes write short WebM files (smooth gradients without large GIF +artifacts) plus a PNG screenshot. `NMSH_DEMO_CONFIG` passes a JSON overlay to +the isolated demo config (glyph style, Chroma palette, theme), and +`NMSH_IDLE_START_MS` picks the deterministic starting phase. + +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/idle-aurora.tape b/dev/tapes/idle-aurora.tape new file mode 100644 index 00000000..603ee105 --- /dev/null +++ b/dev/tapes/idle-aurora.tape @@ -0,0 +1,25 @@ +Require node +Require zsh + +# Idle visuals (aurora), deterministic: fixed seed and frame clock (NMSH_DETERMINISTIC=1). +Output dev/tapes/output/idle-aurora.webm +Set Shell "zsh" +Set Width 1200 +Set Height 650 +Set FontSize 18 +Set TypingSpeed 40ms +Set Framerate 24 + +Hide +Type "NMSH_IDLE_START_MS=20000 NMSH_DEMO_CONFIG='{"glyphStyle":"nerd","presentation":{"preset":"aurora"}}' node dev/tapes/launch.mjs" Enter +Wait+Screen /notMyShell/ +Show +Type "/screensaver start aurora" Sleep 300ms Enter +Sleep 6s +Screenshot dev/tapes/output/idle-aurora.png +Sleep 2s +Type " " +Sleep 1500ms +Hide +Ctrl+D +Sleep 1s diff --git a/dev/tapes/idle-catppuccin.tape b/dev/tapes/idle-catppuccin.tape new file mode 100644 index 00000000..0e3a8120 --- /dev/null +++ b/dev/tapes/idle-catppuccin.tape @@ -0,0 +1,25 @@ +Require node +Require zsh + +# Idle visuals (aurora), deterministic: fixed seed and frame clock (NMSH_DETERMINISTIC=1). +Output dev/tapes/output/idle-catppuccin.webm +Set Shell "zsh" +Set Width 1200 +Set Height 650 +Set FontSize 18 +Set TypingSpeed 40ms +Set Framerate 24 + +Hide +Type "NMSH_IDLE_START_MS=30000 NMSH_DEMO_CONFIG='{"glyphStyle":"nerd","nmsh":{"palette":"catppuccinMocha","accent":"blue"}}' node dev/tapes/launch.mjs" Enter +Wait+Screen /notMyShell/ +Show +Type "/screensaver start aurora" Sleep 300ms Enter +Sleep 6s +Screenshot dev/tapes/output/idle-catppuccin.png +Sleep 2s +Type " " +Sleep 1500ms +Hide +Ctrl+D +Sleep 1s diff --git a/dev/tapes/idle-fireworks.tape b/dev/tapes/idle-fireworks.tape new file mode 100644 index 00000000..2e21caad --- /dev/null +++ b/dev/tapes/idle-fireworks.tape @@ -0,0 +1,25 @@ +Require node +Require zsh + +# Idle visuals (fireworks), deterministic: fixed seed and frame clock (NMSH_DETERMINISTIC=1). +Output dev/tapes/output/idle-fireworks.webm +Set Shell "zsh" +Set Width 1200 +Set Height 650 +Set FontSize 18 +Set TypingSpeed 40ms +Set Framerate 24 + +Hide +Type "NMSH_IDLE_START_MS=2000 NMSH_DEMO_CONFIG='{"glyphStyle":"nerd","presentation":{"preset":"rainbow"}}' node dev/tapes/launch.mjs" Enter +Wait+Screen /notMyShell/ +Show +Type "/screensaver start fireworks" Sleep 300ms Enter +Sleep 6s +Screenshot dev/tapes/output/idle-fireworks.png +Sleep 2s +Type " " +Sleep 1500ms +Hide +Ctrl+D +Sleep 1s diff --git a/dev/tapes/idle-warp.tape b/dev/tapes/idle-warp.tape new file mode 100644 index 00000000..ba2849b4 --- /dev/null +++ b/dev/tapes/idle-warp.tape @@ -0,0 +1,25 @@ +Require node +Require zsh + +# Idle visuals (warp), deterministic: fixed seed and frame clock (NMSH_DETERMINISTIC=1). +Output dev/tapes/output/idle-warp.webm +Set Shell "zsh" +Set Width 1200 +Set Height 650 +Set FontSize 18 +Set TypingSpeed 40ms +Set Framerate 24 + +Hide +Type "NMSH_IDLE_START_MS=0 NMSH_DEMO_CONFIG='{"glyphStyle":"nerd","presentation":{"preset":"lavender"}}' node dev/tapes/launch.mjs" Enter +Wait+Screen /notMyShell/ +Show +Type "/screensaver start warp" Sleep 300ms Enter +Sleep 6s +Screenshot dev/tapes/output/idle-warp.png +Sleep 2s +Type " " +Sleep 1500ms +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..c7939d32 --- /dev/null +++ b/dev/tapes/launch.mjs @@ -0,0 +1,33 @@ +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}); +// NMSH_DEMO_CONFIG (JSON) overlays demo-only settings, e.g. a Chroma palette or glyph style for a tape. +const overlay = process.env.NMSH_DEMO_CONFIG ? JSON.parse(process.env.NMSH_DEMO_CONFIG) : {}; +writeFileSync(join(config, 'config.json'), JSON.stringify({onboardingComplete: true, + glyphChoiceComplete: true, glyphStyle: 'safe', welcome: 'vespyr', updateChecks: false, + liveSessionStartup: 'never', composerPosition: 'bottom', transcriptPresentation: 'normal', ...overlay})); +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/shimmer.tape b/dev/tapes/shimmer.tape new file mode 100644 index 00000000..3f40d62d --- /dev/null +++ b/dev/tapes/shimmer.tape @@ -0,0 +1,15 @@ +Require node + +# Color-aware light sweep, deterministic (stepped 100 ms clock): one pass left → right per row, then a pause. +Output dev/tapes/output/shimmer.webm +Set Shell "zsh" +Set Width 1000 +Set Height 420 +Set FontSize 20 +Set Framerate 30 + +Hide +Type "COLORTERM=truecolor node --import=tsx scripts/shimmer-demo.ts 10" Enter +Show +Sleep 10s +Screenshot dev/tapes/output/shimmer.png 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/context-modules.md b/docs/architecture/context-modules.md new file mode 100644 index 00000000..a0863b62 --- /dev/null +++ b/docs/architecture/context-modules.md @@ -0,0 +1,156 @@ +# Context Modules and the Context Engine + +NMSh runs around a real persistent zsh, Bash or Fish session. Its native modules +present useful context about that session and workspace without taking over shell +execution. The Context Engine is the current engineering direction for sharing +that context across NMSh's presentation surfaces. + +This guide describes the unreleased implementation on `feature/305-context-engine`. +The stable release remains v0.16.0. Installable Context Packs are a direction, +not an available public extension API. + +## From capability to presentation + +```text +shell lifecycle / editor demand / explicit refresh + → trusted core capability services + → immutable facts + → native modules + → Surface Router + → Main Prompt / Context Rail / Right Context +``` + +| Concept | Responsibility | +|---|---| +| Capability | A named operation implemented and governed by trusted NMSh core code, such as obtaining the session directory or Git branch | +| Fact | A resolved value with its source, collection time, freshness, trust, sensitivity, persistence policy and resolution cost | +| Module | A presentation definition using declared facts, visibility conditions, priority, icons and width policy | +| Surface Router | Selects a supported destination from module definitions and saved user choices, without collecting data | +| Context Pack | A planned installable collection of declarative module descriptions; it grants no arbitrary execution authority | + +Facts form a read-only model: consumers use resolved values rather than mutate them +or refresh them during painting. The current adapter converts the existing +`PromptContext` snapshot into typed facts, retaining compatibility with providers +and historical prompts. This is not yet a general-purpose capability scheduler or +a complete replacement of every legacy context field. + +Collection occurs outside rendering. Existing shell lifecycle and editor-demand +paths refresh context; rendering reads the available snapshot. A failure means +unavailable context, not a guessed value or permission to try another executable. +Modules receive a restricted projection of their fact inputs and visibility +inputs, rather than filesystem, process or network handles. + +## Presentation surfaces + +| Surface | Purpose | +|---|---| +| Main Prompt | Stable identity and navigation context, such as directory, project and Git | +| Right Context | Independently anchored context at the right edge of the prompt area | +| Context Rail | Live contextual modules attached to the composer, with their own geometry and priority fitting | + +`/prompt` exposes module routing: **Auto**, **Main Prompt**, **Right Context**, +**Context Rail** and **Hidden**. Auto uses the module's defined preference; it does +not dynamically move a module between surfaces when space runs out. Explicit +choices override that preference. Existing saved left/right placement remains in +effect unless a surface is explicitly chosen; upgrades do not automatically move +existing modules into the Rail. + +The Rail can be **Vertical** or **Right of Prompt**, with one or two content rows. +Relation, Direction, Integration, Spacing and Divider Anchor are independent +presentation choices. Theme and style can follow the Main Prompt or use supported +Rail choices. Inside integration uses horizontal divider geometry rather than +vertical box borders. Priority fitting compacts or drops whole module groups to +fit available space. + +A Rail placed Right of Prompt remains separate from Right Context. The shell +indicator, when placed on the right, keeps its independent prompt anchor; the Rail +must fit or drop before covering that anchor. Narrow layouts still prioritize the +composer and transcript. `screenPlan.ts` supplies the geometry used by drawing, +cursor placement, hit testing, viewport capacity and PTY sizing. + +Rail mode **Auto** reserves no rows without visible routed content. **Always** +reserves the configured rows, and **Off** reserves none. New Rail settings use +Auto, one row, Vertical, Follow Main direction, Outside integration, Gap spacing, +Prompt Level divider anchoring, Follow Main theme/style and Priority overflow. +Existing valid saved settings retain their presentation. + +The Rail is Native module presentation. External prompt providers and Prompt None +suppress it while retaining its settings. Fullscreen/raw passthrough hides it +along with NMSh's other frontend chrome. Rail content is not written to the +transcript or archived as a live aggregate. + +## Current first-party modules + +The existing registry is the starting point for the ecosystem, rather than a +second plugin framework. + +| Module group | Current modules | +|---|---| +| Identity and navigation | Project, working directory | +| Version control | Git branch, Rich Git status | +| Session | Exit status, current shell | +| Tooling | Marker-based toolchain context, opt-in discovered tools | +| Command context | Kubernetes context, Docker context | + +Visibility can depend on repository presence, a nonzero exit status or the command +being composed. Toolchains, Kubernetes and Docker support show-on-command +conditions. Auto preferences put identity/Git in Main Prompt, shell/inventory in +Right Context, and command contexts in Context Rail. Legacy saved placement still +wins when no new surface is selected. + +`/shell` exposes the current-shell module's **Hidden**, **When not default** and +**Always** policies, plus **Left** or **Right** placement. These edit the same module +state used by `/prompt`; they do not select a different shell. When not default +compares the active session backend with the configured default for new sessions. +Changing Side preserves visibility, including Hidden. + +## Discovery and security + +**Entering a repository must never itself execute arbitrary repository-controlled +code through NMSh context discovery.** Normal user-configured shell startup/hooks +and explicitly selected external prompt providers are separate trust boundaries; +context discovery does not inherit their execution authority. + +Metadata discovery uses parser-only reads with file-size and traversal bounds. +The contextual metadata reader refuses leaf symlinks and special files. Toolchain +markers and local inventory are evidence, not permission to execute a discovered +binary. Kubernetes authentication `exec` entries are never run to find a context. +Discovery does not source `.envrc`, run project scripts or installers, evaluate +configuration as code, or contact the network. + +When a probe is needed, trusted core code owns the executable identity, allowed +operations, environment, time and output limits. The contextual Git collector uses +trusted Git rather than repository/PATH candidates, disables hooks and fsmonitor, +and does not inspect submodules. Status fails closed for repository configuration +that could require filters, includes, partial-clone fetching or extra worktree +configuration; branch/root may remain available. Missing status is not presented +as proof of a clean repository. + +Fact-derived display text is bounded and neutralizes terminal controls and bidi +formatting controls. Display text is not reused as a filesystem path or shell +command. Secret facts and facts marked never-store are suppressed; display-only +facts can appear live but are excluded from prompt snapshots. Only snapshot-safe +facts may enter historical presentation. Privacy policy survives value refresh. + +## Declarative Context Packs: the direction + +A Context Pack will describe modules and request known capabilities. It will not +supply shell commands, JavaScript, executable paths, arbitrary arguments, hooks, +code-evaluating templates or includes. Core remains responsible for collection, +cache and execution policy. Merely installing a pack must not grant new execution +powers, and a repository must not activate an executable extension by being opened. + +There is no finalized pack format, installer or public module SDK in this +checkpoint. General capability scheduling, fuller migration of legacy context and +a bounded expansion of high-value first-party modules remain upcoming. Status +Strip module routing is deferred. Any executable extension tier would require a +separate sandbox and capability design; it is outside the data-only pack model. + +## Further reading + +- [Architecture overview](../../ARCHITECTURE.md): the frontend, shell transport, transcript and shared screen plan. +- [Native prompt customization](prompt-customization.md): style, theme, vibrance and Chroma. +- [Shell adapters](shell-adapter.md): the real zsh, Bash and Fish backends. +- [Roadmap](../../ROADMAP.md#current-engineering-focus--native-modules-and-context-engine): implemented and upcoming scope. +- [Security policy](../../SECURITY.md): NMSh's trust boundaries. +- [Demo gallery](../demos.md#see-notmyshell-in-motion): the reel and feature recordings. diff --git a/docs/architecture/host-actions.md b/docs/architecture/host-actions.md new file mode 100644 index 00000000..7c540014 --- /dev/null +++ b/docs/architecture/host-actions.md @@ -0,0 +1,116 @@ +# Product boundary, transcript find/filter and HostActions + +> Use the best native surface that already exists around NMSh instead of +> duplicating IDE functionality in the terminal. + +## Who owns what + +| NMSh owns | The host / editor owns | +|---|---| +| command composer and editor | source-code editing | +| shell completion presentation | repository-wide source search | +| shell and NMSh history | rich Git diff, staging, merge editor | +| command/output transcript | file explorer | +| transcript find and output filtering | diagnostics, symbols, outline | +| sessions, session search and management | code navigation UI | +| shell switching, prompt and status | | +| terminal-native presentation | | +| recognizing source references in output | | + +NMSh may **bridge** to the right column. It does not clone it: there is no +file explorer, code preview, project search, Git graph, staging UI or diff +viewer inside NMSh. Plain `git diff` keeps working in the transcript. + +## Transcript find (`/find`, Ctrl+F) + +- **Terms AND together.** Each `/find ` adds a clause; a logical line + matches only when every clause matches it. OR lives inside one regex clause + (`/find -r 'error|disk'`). There is no boolean expression language. +- Each clause keeps its own options: `-c` case-sensitive, `-r` regex. +- Matching is on **logical lines** (all wrapped rows of a line together), so + terminal width never changes whether a line matches. Navigation moves + between matching lines; every clause's spans are marked on them, the active + line most strongly. +- **Ctrl+F** opens a new clause input while NMSh owns the idle composer + (raw `^F` and Kitty CSI-u). A running command or passthrough program still + receives Ctrl+F unchanged. Cmd+F and the host's own find are not touched. +- In the clause input: typing edits only the new clause; Enter applies it (or, + when the input is empty, steps to the older match); Shift+Enter / Ctrl+J + steps newer; Up/Down step too; Tab cycles plain → case → regex → regex + case; + Esc discards only the uncommitted input — applied clauses stay. +- `/find` alone opens the input; `/find remove N` removes one clause; + `/find clear` removes all. `/find -b ` (first clause) scopes to the + current block. Archived sessions are a future scope behind the same model. +- Matches are recomputed only when the presented rows or clauses change. + +## Output filter (`/filter`) + +- Each `/filter ` adds a clause to the filter set of one block (the + focused block, else the newest completed one, chosen when the set is + created). Kept lines satisfy every clause; `-v` inverts a clause, `-C N` + keeps N lines of context (the set uses the largest), `-r`, `-c`. +- The set stays attached to its block: a newer command never takes it over. + `/filter remove N` removes one clause; `/filter clear` (or `/filter` alone) + removes the set and restores every line. +- The hint row inside the block remains. Presentation only: stored lines, + records, journals and `/copy` are unchanged. + +## Find / filter chrome + +Above the composer, at most **two rows**: Find on the left, Filter on the +right, at most two visible clauses per side, and `+N more` (the exact number of +hidden clauses) on the second row. Tags are concise (`[case]`, `[regex]`, +`[invert]`, `[±N]`); the clause being typed shows a caret and stays visible. +Narrow terminals shorten terms, then collapse to `⌕ N terms · 2/5` and +`⧩ N filters` on one row. + +## HostActions (`src/host/HostActions.ts`) + +Capabilities, not brand checks: `integratedEditor`, `nativeFileOpen`, +`nativeDirectoryOpen`, `nativeDiff`. Adapters implement them: + +| Adapter | Open file at location | Folder | Diff | +|---|---|---|---| +| Zed | `zed ::` | `zed ` | `zed --diff `, only when `zed --help` lists `--diff` | +| VS Code | `code --goto :[:]` | `code ` | `code --diff ` | +| VISUAL / EDITOR | a visible command in the composer (`nvim +12 'a b.ts'`, `hx a.ts:12:3`, `micro +12:3 a.ts`); unknown editors get no guessed flag | unsupported (explained) | unsupported (explained) | + +These forms come from the editors' own CLI sources (Zed `crates/cli`: +"Use `path:line:column` syntax", `--diff` pairs; VS Code `argv.ts`: `--goto +file:line[:character]`, `--diff file file`). + +Inside Zed or VS Code without its CLI on PATH, NMSh says the editor was +detected and how that editor installs its CLI (Zed: command palette → +`cli: install cli binary`, which symlinks into `/usr/local/bin` and may ask for +administrator rights; on Linux, official releases use `~/.local/bin` and some +packages name it `zeditor`, `zedit` or `zed-editor`. VS Code on macOS: command +palette → "Shell Command: Install 'code' command in PATH"). NMSh does not run +these installs itself; `/status` shows Integrated editor, Editor bridge +unavailable, and the missing CLI. + +Selection: an explicit **Open with** setting (`auto`, `zed`, `vscode`, +`editor`) wins; Auto uses the editor NMSh runs inside (from its own terminal +environment), then VISUAL/EDITOR. A terminal emulator is never treated as an +editor; inside Ghostty or Terminal.app with no editor configured, NMSh says so +and suggests nothing else. + +Safety: paths are resolved to absolute paths before launch (so they can never +look like options), every launch is argv-based with `shell: false`, and GUI +editors are started detached. Terminal editors are never run silently; the +exact command is placed in the composer, quoted for the current shell, for the +user to run. + +## Source references and `/open` + +- References recognized in output: `path:line` and `path:line:column` with + relative, absolute or `~` paths, and quoted forms containing spaces. + URLs, times and bare words are not references. +- `/open [:line[:column]]` resolves against the shell's cwd. +- `/open` alone lists references from the newest outputs, each resolved + against the cwd its command ran in (not NMSh's process cwd). +- Missing files, directories passed to `/open-diff`, control characters and an + unavailable editor all produce a factual message. +- Keyboard-first in this pass; clickable transcript links are not added. + +`/open-diff ` delegates to the editor's diff view where supported +and otherwise explains the fallback (`git diff` here). diff --git a/docs/architecture/image-surface.md b/docs/architecture/image-surface.md new file mode 100644 index 00000000..6cf2c111 --- /dev/null +++ b/docs/architecture/image-surface.md @@ -0,0 +1,36 @@ +# Image surface + +`src/presentation/ImageSurface.ts` is NMSh's one raster path. The terminal +emulator owns image support; NMSh can only use a protocol the host implements +and never prompts anyone to "install" image support or switch hosts. + +## Protocol selection + +1. **Kitty graphics protocol** when proven: the host answered the graphics + query (`a=q`) with `OK`, or the host is Kitty itself (long-standing record). + The query is sent only to hosts whose profile suggests support — Kitty, + Ghostty (its README lists the Kitty graphics protocol) and WezTerm — so + other hosts never receive an APC sequence. Replies are consumed by the + probe and never reach the composer. +2. **iTerm2 inline images** (OSC 1337 `File=`) from the known-host record: + iTerm2 and WezTerm (WezTerm documents iTerm2 protocol support). +3. **None**: the caller's terminal-native fallback. Multiplexers (tmux, + screen, Zellij), `TERM=dumb` and `NMSH_IMAGES=0` always get none. + +Zed and VS Code are not claimed to support inline graphics. + +## Lifecycle + +The renderer owns at most one overlay. Kitty data is uploaded once +(`a=t`, chunked at 4096 base64 bytes, `q=2` so no reply reaches stdin), placed +with `a=p` only when its rows repaint, and deleted with `a=d,d=I` when the +panel closes, when a foreground program takes the terminal (passthrough), and +on exit. iTerm2 images are cell content: repainting the rows removes them. + +## Uses + +Only `/about` (build identity and the project logo). Core UI never depends on +images; with no protocol `/about` shows a text logo and says inline images are +not available in this terminal. The logo is the repository's own +`assets/brand/nmsh-logo.png` (opaque background; a transparent raster is a +possible later refinement). 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/notmyui.md b/docs/architecture/notmyui.md index d7639b4f..f88cb4b0 100644 --- a/docs/architecture/notmyui.md +++ b/docs/architecture/notmyui.md @@ -1,17 +1,20 @@ # notMyUI: internal presentation and interaction toolkit -Internal to NMSh (not a published package). It exists only for NMSh-owned surfaces; raw PTY output, archived command output and `/copy` are never interpreted or recolored by it. zsh stays authoritative. +Internal to NMSh (not a published package). It exists only for NMSh-owned surfaces; raw PTY output, archived command output and `/copy` are never interpreted or recolored by it. The selected real shell stays authoritative. ## Primitives and their consumers | Primitive | Module | What it owns | Real consumers | | --- | --- | --- | --- | | Deterministic clock | `presentation/environment` | `NMSH_DETERMINISTIC`, `NMSH_REDUCED_MOTION` display seams | completion time, shimmer phase, welcome blink | -| Capability | `presentation/capabilities` | color level: none / 256 / truecolor | `chroma/escape`, `ui/palette` | +| Capability | `presentation/capabilities` | color level: none / 16 / 256 / truecolor | `chroma/escape`, `ui/palette` | | Chroma | `chroma/chroma`, `chroma/escape` | which color a cell gets: status / theme / identity roles, gradients, curves, fallback | palette helpers, shimmer, Settings status tones, surfaces | | Actions | `ui/actions` | identity, label, key, enabled state; derived footers | command palette, layout/syntax/transcript panels | | Form controls | `ui/formControls` | toggle, select, multi-select, text field, confirmation as proposals | Settings rows, Settings search | | Surfaces | `ui/surface` | frame, fill, padding, inset, width, alignment | `framePanel` (all panels) | +| Shared clock | `motion/PresentationClock` | visibility-aware animation subscriptions | Chroma, cursor, live activity, previews and screensavers | +| Language identity | `languages/linguistLanguageColors` | vendored Linguist language colors | toolchain language identity | +| Focus | `ui/palette`, `ui/PanelShell` | bold accent labels, tab/focus bands; checked markers are separate | settings, pickers, providers, tools, setup | | Motion | `motion/motion` | semantic motion profiles, sampled purely | shimmer | | Authored Markdown | `help/markdown` | branded, NMSh-owned content only | `/help` | | Settings v2 | `ui/SettingsPanel` | simple/advanced rows, changed marker, reset | `/settings` Config | @@ -20,13 +23,15 @@ Boundaries: Chroma decides color; surfaces and layout decide where cells are; co ## Not built (no consumer yet) -Shared animation scheduler, user-remappable bindings, surface variants beyond the top line in panels, Linguist language colors (#176), transient effects (#78), automatic 256-color detection, persisted reduced-motion setting. +User-remappable bindings remain future work. Shared animation scheduling, Linguist language colors, transient effects, terminal color capability detection and persisted Reduced Motion are implemented. See `motion/PresentationClock`, `languages/linguistLanguageColors`, `motion/` and `presentation/capabilities`. ## Acceptance See `docs/accessibility/baseline.md` for accessibility criteria and known gaps. -## v0.7 physical QA checklist (not yet performed) +## v0.7 physical QA record + +The v0.7 physical QA pass was completed before release. The items below record the tested areas and known limitations; screen-reader behavior remains unverified. - Ghostty and Terminal.app: `/settings` Config: `A` advanced, `/` search, `R` reset, changed `•` marker, remembered position after Esc and reopen. - Narrow widths (about 24 to 40 columns) for Settings, palette and `/help`. 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/platforms.md b/docs/architecture/platforms.md new file mode 100644 index 00000000..727bbaa7 --- /dev/null +++ b/docs/architecture/platforms.md @@ -0,0 +1,53 @@ +# Platforms: macOS, Linux, WSL 2 + +| Platform | Status | +|---|---| +| macOS | Supported; physically validated on the hosts listed in the QA records. | +| Linux | Supported by automated tests (Ubuntu CI on Node 22 and 26; this run also exercised zsh 5.9, Fish 3.7 and Bash 5.2 on Ubuntu). **Physical terminal validation pending.** | +| WSL 2 | The supported Windows path: Windows terminal host → WSL 2 → Linux Node.js → NMSh → zsh / Fish / Bash. Detection and logic are tested with simulated kernels and environments. **No real WSL physical QA yet.** | +| WSL 1 | Detected and reported as not supported (no Linux kernel; PTY, signal and socket behavior can differ). Nothing is disabled pre-emptively; `nmsh doctor` and `/status` say so. | +| Native Windows | Not supported. A future ConPTY + PowerShell/Nu design is out of scope; zsh is never forced onto Windows. | + +`src/host/platform.ts` separates the **guest** (where Node runs) from the +**terminal host** (which, under WSL, is a Windows application). + +## WSL detection + +Strongest evidence first: the kernel release (`…-microsoft-standard-WSL2` +means WSL 2; `4.4.0--Microsoft` means WSL 1), `/proc/version`, then +`WSL_DISTRO_NAME` / `WSL_INTEROP` (set by WSL 2). Unknown versions say +"version unknown; WSL 2 is the supported path". + +## Linux audit (v0.16) + +| Area | State | +|---|---| +| PTY lifecycle, process groups, signals | Same POSIX path as macOS (node-pty Unix backend). Interrupt, background jobs and shell switching tested live for zsh, Fish and Bash. | +| XDG paths | Config `$XDG_CONFIG_HOME/nmsh` (absolute only) else `~/.config/nmsh`; history import honors `XDG_DATA_HOME` for Fish. | +| Runtime/sockets | `$XDG_RUNTIME_DIR/nmsh` only when it is a private, owned, non-symlink directory with a short enough socket path; otherwise a per-uid temp directory. Windows mounts (`/mnt/c`, drvfs) never qualify because they fail the private-permission check. | +| Shell discovery | zsh: system paths then PATH. Fish: PATH only. Bash: PATH then system, version ≥ 4.4. | +| Clipboard | `wl-copy` (Wayland) or `xclip`/`xsel` (X11). WSLg provides Wayland; without it `/copy` explains what is missing. | +| Notifications | `notify-send` when installed and a desktop session (D-Bus, Wayland or X11) is reachable; otherwise unsupported, as before. | +| Package managers (v0.17) | `/tools` builds typed executable + argv plans for APT, DNF, pacman, zypper (chosen from `/etc/os-release` ID/ID_LIKE, WSL included) and Homebrew. Only curated, factual package names have a plan; everything else is shown as manual. Non-root plans are explicitly `sudo -n …` (never a password prompt) and name the command to run yourself if sudo needs one. | +| Open helper (v0.17) | `xdg-open`, then `wslview`; neither is required. | +| Install/uninstall | Same source-checkout + `npm link` model; `nmsh uninstall` works from XDG paths and refuses while live sessions run. | +| Config transfer | Exports contain no host paths, so they move between macOS and Linux. | +| Agent detection, history | Platform-independent. | +| Temp files | All bootstrap and helper directories are private `mkdtemp` directories, removed on exit; tests fail on leaks. | + +A container without a UTF-8 locale makes zsh treat non-ASCII names as +non-alphanumeric; one existing zsh test depends on a UTF-8 locale, as CI has. + +## Evidence levels (v0.17) + +These are different claims and are not interchangeable. + +| Platform | Supported | Tested in CI | Physically validated | Notes | +|---|---|---|---|---| +| macOS | Primary | Yes (macOS runner) | Yes (maintainer) | | +| Linux | Beta | Ubuntu 24.04 (full suite; zsh, Fish and Bash installed) and a Fedora subset | No | Package-manager plans, clipboard/notification/open fallbacks and XDG paths run from deterministic fixtures. | +| WSL 2 | Beta, documented limits | Fixtures only (detection, distro, Windows Terminal facts via `WT_SESSION`, mount handling, fallbacks); no WSL runner | No | NMSh inside WSL is a Linux environment. | +| WSL 1 | Detected and reported, not supported | Fixtures | No | | +| Native Windows | Unsupported | n/a | n/a | | + +Package-name tables for APT, DNF, pacman and zypper are curated from distribution naming and covered by unit tests; they were not installed on real machines in this release. diff --git a/docs/architecture/prompt-customization.md b/docs/architecture/prompt-customization.md new file mode 100644 index 00000000..fb7c33ff --- /dev/null +++ b/docs/architecture/prompt-customization.md @@ -0,0 +1,127 @@ +# Native prompt customization + +The NMSh Native prompt is customized along independent axes. None of them is a +preset of the others, and each persists separately in `config.json`. + +| Axis | Setting | What it changes | +| --- | --- | --- | +| Style | `nmsh.style` + `nmsh.styleProfiles` | Geometry and visual construction | +| Theme | `nmsh.palette` | Semantic module colors | +| Vibrance | `nmsh.vibrance` | Strength and separation of theme colors | +| Chroma | `presentation.*` | Optional color or gradient treatment | +| Motion | `presentation.motion/speed/curve/direction` | Optional animation of Chroma | + +## Modules and surfaces + +`/prompt` manages native modules and their routing to Main Prompt, Right Context, +Context Rail or Hidden; Auto uses each module's preferred surface. Existing saved +left/right placement remains valid. The Rail has its own presentation controls +and uses the shared native painter. See [Context Modules](context-modules.md) for +facts, discovery safety and the distinction between these surfaces. + +`/shell` edits the same shell module's visibility and Left/Right placement as +`/prompt`. Hidden, When not default and Always control display only; they do not +switch shells. Changing Side preserves Hidden. When not default compares the +current session backend with the configured default for new sessions. + +## Styles and their settings + +Every style renders the same semantic modules through one cell painter +(`src/prompt/powerline.ts`). That painter supplies Nerd and Safe glyphs, left +and right (mirrored) modules, one- and two-line layouts, narrow fitting and +historical replay for all styles. `/prompt` shows only the controls that change +the selected style. + +| Style | Look | Controls | +| --- | --- | --- | +| Powerline | Filled segments with shaped joins | Start, Connector, Connector fade (with a gap), Fade colors (with a fade), Gap, End, Padding | +| Soft | Filled pills, or one connected capsule | Caps, Layout, Gap (separated only), Padding, Fill (Filled / Subtle) | +| Minimal | Colored text, no fills | Separator, Spacing, Bold | +| Outline | Outlined segments, no fills | Outline (Rounded / Square / Angle), Layout, Gap (separated only), Padding | +| Breadcrumb | A text trail with one filled anchor | Separator, Anchor (first / last / none), Spacing | +| Compact | Dense filled cells, no gaps | Ends, Padding, Seams | +| Ribbon | One band; each module is colored text on it | Slant, Ends, Padding, Band | + +Powerline keeps its long-standing storage (the Main Prompt geometry fields plus +root `gap`/`spacing`), so existing Powerline prompts render exactly as before. +Every other style owns a profile under `nmsh.styleProfiles. + + + ${rects.join('\n ')} + +`; + +writeFileSync(new URL('../../assets/readme/vespyr-divider.svg', import.meta.url), svg); +console.log(`vespyr-divider.svg: ${svg.length} bytes`); diff --git a/scripts/idle-benchmarks.ts b/scripts/idle-benchmarks.ts new file mode 100644 index 00000000..06a86289 --- /dev/null +++ b/scripts/idle-benchmarks.ts @@ -0,0 +1,55 @@ +import {performance} from 'node:perf_hooks'; +import {arch, platform} from 'node:os'; +import {CellGrid} from '../src/idle/CellGrid.js'; +import {IDLE_FRAME_MS, IDLE_MODES, idlePalette} from '../src/idle/scenes.js'; +import {idleFrameRows} from '../src/idle/IdleVisuals.js'; +import {PresentationClock} from '../src/motion/PresentationClock.js'; +import {TerminalRenderer} from '../src/terminal/TerminalRenderer.js'; +import {PRESET_STOPS} from '../src/chroma/treatment.js'; +import {parseHexColor} from '../src/chroma/color.js'; + +/** + * Idle visual cost: frame computation plus serialization, terminal bytes + * actually written after row diffing, changed rows per frame, heap growth + * over many frames, and timer cleanup. Run: node --import=tsx scripts/idle-benchmarks.ts + */ +const palette = idlePalette(PRESET_STOPS.aurora.map(hex => parseHexColor(hex)!)); +const sizes: ReadonlyArray = [[80, 24], [120, 40], [180, 55]]; +const frames = Number(process.env.NMSH_BENCH_SAMPLES ?? 120); +console.log(JSON.stringify({node: process.version, platform: platform(), arch: arch(), framesPerCase: frames})); + +for (const mode of IDLE_MODES) { + for (const [width, height] of sizes) { + const grid = new CellGrid(); + let bytes = 0; + const renderer = new TerminalRenderer(data => { bytes += Buffer.byteLength(data); }); + renderer.enter(); + const times: number[] = []; + let changedRows = 0; + let previous: string[] = []; + global.gc?.(); + const heapBefore = process.memoryUsage().heapUsed; + for (let frame = 0; frame < frames; frame++) { + const started = performance.now(); + const rows = idleFrameRows(grid, {mode, width, height, time: frame * IDLE_FRAME_MS[mode], palette, level: 'truecolor', nerd: true}); + times.push(performance.now() - started); + changedRows += rows.filter((row, index) => row !== previous[index]).length; + previous = rows; + if (frame === 1) bytes = 0; + renderer.render({rows, columns: width, cursorRow: 1, cursorColumn: 1, cursorVisible: false}); + } + global.gc?.(); + const heapGrowthKb = Math.round((process.memoryUsage().heapUsed - heapBefore) / 1024); + renderer.leave(); + times.sort((a, b) => a - b); + console.log(JSON.stringify({mode, size: `${width}x${height}`, frameMs: IDLE_FRAME_MS[mode], + medianMs: +times[Math.floor(times.length / 2)]!.toFixed(3), p95Ms: +times[Math.floor(times.length * 0.95)]!.toFixed(3), + avgChangedRows: +(changedRows / frames).toFixed(1), avgBytesPerFrame: Math.round(bytes / Math.max(1, frames - 2)), heapGrowthKb})); + } +} + +// Timer cleanup: an unsubscribed scene leaves the clock with no subscribers and no pending timer. +const clock = new PresentationClock(); +const stop = clock.subscribe(() => {}, IDLE_FRAME_MS.aurora); +stop(); +console.log(JSON.stringify({timerCleanup: {subscribers: clock.subscriberCount, scheduled: clock.scheduled}})); 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/local-model/pin-model.mjs b/scripts/local-model/pin-model.mjs new file mode 100644 index 00000000..d3a6e289 --- /dev/null +++ b/scripts/local-model/pin-model.mjs @@ -0,0 +1,29 @@ +// Maintainer-only: pin the recommended local-understanding model artifact. +// Reads the publisher's metadata (revision, size, sha256 from the LFS pointer) +// and writes assets/understanding/recommended-model.json. Downloads nothing. +// +// node scripts/local-model/pin-model.mjs [revision] +// +// Example: node scripts/local-model/pin-model.mjs Qwen/Qwen3-0.6B-GGUF Qwen3-0.6B-Q8_0.gguf +// Review the license of the chosen repository before committing the result. +import {readFileSync, writeFileSync} from 'node:fs'; + +const [repo, file, revisionArg] = process.argv.slice(2); +if (!repo || !file) { console.error('usage: pin-model.mjs [revision]'); process.exit(2); } +const api = `https://huggingface.co/api/models/${repo}${revisionArg ? `/revision/${revisionArg}` : ''}`; +const info = await (await fetch(api)).json(); +const revision = info.sha; +const paths = await (await fetch(`https://huggingface.co/api/models/${repo}/paths-info/${revision}`, {method: 'POST', + headers: {'content-type': 'application/json'}, body: JSON.stringify({paths: [file]})})).json(); +const entry = paths.find(item => item.path === file); +if (!entry?.lfs?.oid || !entry.size) { console.error('file or LFS metadata not found'); process.exit(1); } +const license = info.cardData?.license ?? info.tags?.find(tag => tag.startsWith('license:'))?.slice(8); +const quantization = /(I?Q\d\w*|F16|BF16)/iu.exec(file)?.[1]?.toUpperCase() ?? 'unknown'; +const path = new URL('../../assets/understanding/recommended-model.json', import.meta.url); +const manifest = JSON.parse(readFileSync(path, 'utf8')); +manifest.quantization = quantization; +manifest.artifact = {repository: `https://huggingface.co/${repo}`, revision, file, url: `https://huggingface.co/${repo}/resolve/${revision}/${file}`, + quantization, bytes: entry.size, sha256: entry.lfs.oid, license: license ?? 'unknown'}; +delete manifest.note; +writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`); +console.log(`pinned ${repo}@${revision} ${file}: ${entry.size} bytes, sha256 ${entry.lfs.oid}, license ${license}`); 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/read-command-history.cjs b/scripts/read-command-history.cjs new file mode 100644 index 00000000..659459a7 --- /dev/null +++ b/scripts/read-command-history.cjs @@ -0,0 +1,30 @@ +// Reads authoritative journals off the editor thread; sends only eligible command metadata. +const {parentPort, workerData} = require('node:worker_threads'); +const {readdir, readFile} = require('node:fs/promises'); +const {join} = require('node:path'); + +(async () => { + let files; + try { files = await readdir(workerData.directory); } + catch (error) { if (error.code === 'ENOENT') return; throw error; } + for (const file of files) { + if (!file.endsWith('.json') || file.endsWith('.meta.json')) continue; + let session; + try { session = JSON.parse(await readFile(join(workerData.directory, file), 'utf8')); } + catch { continue; } + if (session.schemaVersion !== 1 || session.id !== file.slice(0, -5) || !Array.isArray(session.transcript?.records)) continue; + let records = []; + for (const record of session.transcript.records) { + if (record.historyEligible !== true || typeof record.command !== 'string' || /^\s/.test(record.command) + || !record.command.trim() || !Number.isSafeInteger(record.startId) || !Number.isInteger(record.exitCode)) continue; + const context = record.historicalContext; + records.push({command: record.command, historyEligible: true, startId: record.startId, exitCode: record.exitCode, + ...(Number.isFinite(record.startedAt) ? {startedAt: record.startedAt} : {}), + ...(Number.isFinite(record.durationMs) && record.durationMs >= 0 ? {durationMs: record.durationMs} : {}), + historicalContext: {cwd: typeof context?.cwd === 'string' ? context.cwd : undefined, + project: typeof context?.project === 'string' ? context.project : undefined}}); + if (records.length === 1024) { parentPort.postMessage({session: session.id, records}); records = []; } + } + if (records.length) parentPort.postMessage({session: session.id, records}); + } +})().catch(error => { throw error; }); diff --git a/scripts/readme-demo/demo.cast b/scripts/readme-demo/demo.cast deleted file mode 100644 index bd64c18d..00000000 --- a/scripts/readme-demo/demo.cast +++ /dev/null @@ -1,122 +0,0 @@ -{"version":3,"term":{"cols":90,"rows":18,"type":"tmux-256color","version":"tmux 3.7c"},"timestamp":1790110347,"command":"./bin/nmsh","env":{"SHELL":"/bin/zsh"}} -[0.661, "o", "\u001b[?1049h\u001b[>1u\u001b[?2004h\u001b[?1000h\u001b[?1006h\u001b[?25l\u001b[2J\u001b[H"] -[0.025, "o", "\u001b[?25l\u001b[1;1H\u001b[2K\u001b[0m\u001b[2;1H\u001b[2K\u001b[0m\u001b[3;1H\u001b[2K\u001b[0m\u001b[4;1H\u001b[2K\u001b[0m\u001b[5;1H\u001b[2K\u001b[0m\u001b[6;1H\u001b[2K\u001b[0m\u001b[7;1H\u001b[2K\u001b[0m\u001b[8;1H\u001b[2K\u001b[0m\u001b[9;1H\u001b[2K\u001b[0m\u001b[10;1H\u001b[2K\u001b[0m\u001b[11;1H\u001b[2K\u001b[0m\u001b[12;1H\u001b[2K\u001b[0m\u001b[13;1H\u001b[2K\u001b[0m\u001b[14;1H\u001b[2K\u001b[0m\u001b[15;1H\u001b[2K\u001b[0m\u001b[16;1H\u001b[2K\u001b[38;2;245;244;250m\u001b[48;2;84;82;132m … \u001b[0m\u001b[38;2;84;82;132m▓▒░ \u001b[38;2;139;132;178m───────────────────────────────────────────────────────────────────────────────────\u001b[0m\u001b[0m\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[0m\u001b[18;1H\u001b[2K\u001b[38;2;139;132;178m──────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.413, "o", "\u001b[?25l\u001b[16;1H\u001b[2K\u001b[38;2;245;244;250m\u001b[48;2;84;82;132m notMyShell \u001b[38;2;84;82;132m\u001b[48;2;52;105;98m\u001b[38;2;239;248;246m\u001b[48;2;52;105;98m  master \u001b[0m\u001b[38;2;52;105;98m▓▒░ \u001b[38;2;139;132;178m───────────────────────────────────────────────────────────────\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[2.433, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;242;240;236mg\u001b[0m\u001b[38;2;176;184;194mit init\u001b[0m\u001b[0m\u001b[17;4H\u001b[?25h"] -[0.002, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;205;115;123mg\u001b[0m\u001b[38;2;176;184;194mit init\u001b[0m\u001b[0m\u001b[17;4H\u001b[?25h"] -[0.106, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;242;240;236mg\u001b[0m\u001b[38;2;242;240;236mt\u001b[0m\u001b[0m\u001b[17;5H\u001b[?25h"] -[0.011, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;205;115;123mg\u001b[0m\u001b[38;2;205;115;123mt\u001b[0m\u001b[0m\u001b[17;5H\u001b[?25h"] -[0.071, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;242;240;236mg\u001b[0m\u001b[38;2;242;240;236mt\u001b[0m\u001b[38;2;242;240;236mi\u001b[0m\u001b[0m\u001b[17;6H\u001b[?25h"] -[0.008, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;205;115;123mg\u001b[0m\u001b[38;2;205;115;123mt\u001b[0m\u001b[38;2;205;115;123mi\u001b[0m\u001b[0m\u001b[17;6H\u001b[?25h"] -[0.077, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;205;115;123mg\u001b[0m\u001b[38;2;205;115;123mt\u001b[0m\u001b[38;2;205;115;123mi\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[0m\u001b[17;7H\u001b[?25h"] -[0.061, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;205;115;123mg\u001b[0m\u001b[38;2;205;115;123mt\u001b[0m\u001b[38;2;205;115;123mi\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;242;240;236ms\u001b[0m\u001b[0m\u001b[17;8H\u001b[?25h"] -[0.077, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;205;115;123mg\u001b[0m\u001b[38;2;205;115;123mt\u001b[0m\u001b[38;2;205;115;123mi\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;242;240;236ms\u001b[0m\u001b[38;2;242;240;236mt\u001b[0m\u001b[0m\u001b[17;9H\u001b[?25h"] -[0.104, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;205;115;123mg\u001b[0m\u001b[38;2;205;115;123mt\u001b[0m\u001b[38;2;205;115;123mi\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;242;240;236ms\u001b[0m\u001b[38;2;242;240;236mt\u001b[0m\u001b[38;2;242;240;236ma\u001b[0m\u001b[0m\u001b[17;10H\u001b[?25h"] -[0.114, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;205;115;123mg\u001b[0m\u001b[38;2;205;115;123mt\u001b[0m\u001b[38;2;205;115;123mi\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;242;240;236ms\u001b[0m\u001b[38;2;242;240;236mt\u001b[0m\u001b[38;2;242;240;236ma\u001b[0m\u001b[38;2;242;240;236mt\u001b[0m\u001b[0m\u001b[17;11H\u001b[?25h"] -[0.129, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;205;115;123mg\u001b[0m\u001b[38;2;205;115;123mt\u001b[0m\u001b[38;2;205;115;123mi\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;242;240;236ms\u001b[0m\u001b[38;2;242;240;236mt\u001b[0m\u001b[38;2;242;240;236ma\u001b[0m\u001b[38;2;242;240;236mt\u001b[0m\u001b[38;2;242;240;236mu\u001b[0m\u001b[0m\u001b[17;12H\u001b[?25h"] -[0.053, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;205;115;123mg\u001b[0m\u001b[38;2;205;115;123mt\u001b[0m\u001b[38;2;205;115;123mi\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;242;240;236ms\u001b[0m\u001b[38;2;242;240;236mt\u001b[0m\u001b[38;2;242;240;236ma\u001b[0m\u001b[38;2;242;240;236mt\u001b[0m\u001b[38;2;242;240;236mu\u001b[0m\u001b[38;2;242;240;236ms\u001b[0m\u001b[0m\u001b[17;13H\u001b[?25h"] -[1.288, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[0m\u001b[17;3H\u001b[?25h"] -[0.671, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;205;115;123mg\u001b[0m\u001b[38;2;176;184;194mit init\u001b[0m\u001b[0m\u001b[17;4H\u001b[?25h"] -[0.067, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;242;240;236mg\u001b[0m\u001b[38;2;242;240;236mi\u001b[0m\u001b[38;2;176;184;194mt init\u001b[0m\u001b[0m\u001b[17;5H\u001b[?25h"] -[0.014, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;205;115;123mg\u001b[0m\u001b[38;2;205;115;123mi\u001b[0m\u001b[38;2;176;184;194mt init\u001b[0m\u001b[0m\u001b[17;5H\u001b[?25h"] -[0.050, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;242;240;236mg\u001b[0m\u001b[38;2;242;240;236mi\u001b[0m\u001b[38;2;242;240;236mt\u001b[0m\u001b[38;2;176;184;194m init\u001b[0m\u001b[0m\u001b[17;6H\u001b[?25h"] -[0.002, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232mg\u001b[0m\u001b[38;2;197;185;232mi\u001b[0m\u001b[38;2;197;185;232mt\u001b[0m\u001b[38;2;176;184;194m init\u001b[0m\u001b[0m\u001b[17;6H\u001b[?25h"] -[0.088, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232mg\u001b[0m\u001b[38;2;197;185;232mi\u001b[0m\u001b[38;2;197;185;232mt\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;176;184;194minit\u001b[0m\u001b[0m\u001b[17;7H\u001b[?25h"] -[0.171, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232mg\u001b[0m\u001b[38;2;197;185;232mi\u001b[0m\u001b[38;2;197;185;232mt\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;176;184;194m-\u001b[0m\u001b[38;2;176;184;194m-no-pager diff -- Mac/OpenSidecarMacApp.swift\u001b[0m\u001b[0m\u001b[17;8H\u001b[?25h"] -[0.041, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232mg\u001b[0m\u001b[38;2;197;185;232mi\u001b[0m\u001b[38;2;197;185;232mt\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;176;184;194m-\u001b[0m\u001b[38;2;176;184;194m-\u001b[0m\u001b[38;2;176;184;194mno-pager diff -- Mac/OpenSidecarMacApp.swift\u001b[0m\u001b[0m\u001b[17;9H\u001b[?25h"] -[0.053, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232mg\u001b[0m\u001b[38;2;197;185;232mi\u001b[0m\u001b[38;2;197;185;232mt\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;176;184;194m-\u001b[0m\u001b[38;2;176;184;194m-\u001b[0m\u001b[38;2;176;184;194mv\u001b[0m\u001b[0m\u001b[17;10H\u001b[?25h"] -[0.117, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232mg\u001b[0m\u001b[38;2;197;185;232mi\u001b[0m\u001b[38;2;197;185;232mt\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;176;184;194m-\u001b[0m\u001b[38;2;176;184;194m-\u001b[0m\u001b[38;2;176;184;194mv\u001b[0m\u001b[38;2;176;184;194me\u001b[0m\u001b[0m\u001b[17;11H\u001b[?25h"] -[0.073, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232mg\u001b[0m\u001b[38;2;197;185;232mi\u001b[0m\u001b[38;2;197;185;232mt\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;176;184;194m-\u001b[0m\u001b[38;2;176;184;194m-\u001b[0m\u001b[38;2;176;184;194mv\u001b[0m\u001b[38;2;176;184;194me\u001b[0m\u001b[38;2;176;184;194mr\u001b[0m\u001b[0m\u001b[17;12H\u001b[?25h"] -[0.092, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232mg\u001b[0m\u001b[38;2;197;185;232mi\u001b[0m\u001b[38;2;197;185;232mt\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;176;184;194m-\u001b[0m\u001b[38;2;176;184;194m-\u001b[0m\u001b[38;2;176;184;194mv\u001b[0m\u001b[38;2;176;184;194me\u001b[0m\u001b[38;2;176;184;194mr\u001b[0m\u001b[38;2;176;184;194ms\u001b[0m\u001b[0m\u001b[17;13H\u001b[?25h"] -[0.079, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232mg\u001b[0m\u001b[38;2;197;185;232mi\u001b[0m\u001b[38;2;197;185;232mt\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;176;184;194m-\u001b[0m\u001b[38;2;176;184;194m-\u001b[0m\u001b[38;2;176;184;194mv\u001b[0m\u001b[38;2;176;184;194me\u001b[0m\u001b[38;2;176;184;194mr\u001b[0m\u001b[38;2;176;184;194ms\u001b[0m\u001b[38;2;176;184;194mi\u001b[0m\u001b[0m\u001b[17;14H\u001b[?25h"] -[0.075, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232mg\u001b[0m\u001b[38;2;197;185;232mi\u001b[0m\u001b[38;2;197;185;232mt\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;176;184;194m-\u001b[0m\u001b[38;2;176;184;194m-\u001b[0m\u001b[38;2;176;184;194mv\u001b[0m\u001b[38;2;176;184;194me\u001b[0m\u001b[38;2;176;184;194mr\u001b[0m\u001b[38;2;176;184;194ms\u001b[0m\u001b[38;2;176;184;194mi\u001b[0m\u001b[38;2;176;184;194mo\u001b[0m\u001b[0m\u001b[17;15H\u001b[?25h"] -[0.075, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232mg\u001b[0m\u001b[38;2;197;185;232mi\u001b[0m\u001b[38;2;197;185;232mt\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;176;184;194m-\u001b[0m\u001b[38;2;176;184;194m-\u001b[0m\u001b[38;2;176;184;194mv\u001b[0m\u001b[38;2;176;184;194me\u001b[0m\u001b[38;2;176;184;194mr\u001b[0m\u001b[38;2;176;184;194ms\u001b[0m\u001b[38;2;176;184;194mi\u001b[0m\u001b[38;2;176;184;194mo\u001b[0m\u001b[38;2;176;184;194mn\u001b[0m\u001b[0m\u001b[17;16H\u001b[?25h"] -[1.272, "o", "\u001b[?25l\u001b[13;1H\u001b[2K\u001b[0m\u001b[38;2;242;240;236m❯ \u001b[0m\u001b[38;2;197;185;232mgit\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;176;184;194m--version\u001b[0m\u001b[0m\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m·\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mR\u001b[38;2;139;132;178mo\u001b[38;2;139;132;178ma\u001b[38;2;139;132;178ms\u001b[38;2;139;132;178mt\u001b[38;2;139;132;178mi\u001b[38;2;139;132;178mn\u001b[38;2;139;132;178mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (0.0s)\u001b[0m\u001b[0m\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[0m\u001b[17;3H\u001b[?25h"] -[0.039, "o", "\u001b[?25l\u001b[12;1H\u001b[2K\u001b[0m\u001b[38;2;242;240;236m❯ \u001b[0m\u001b[38;2;197;185;232mgit\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;176;184;194m--version\u001b[0m\u001b[0m\u001b[13;1H\u001b[2Kgit version 2.55.0\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.013, "o", "\u001b[?25l\u001b[11;1H\u001b[2K\u001b[0m\u001b[38;2;242;240;236m❯ \u001b[0m\u001b[38;2;197;185;232mgit\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;176;184;194m--version\u001b[0m\u001b[0m\u001b[12;1H\u001b[2Kgit version 2.55.0\u001b[0m\u001b[0m\u001b[13;1H\u001b[2K\u001b[0m\u001b[0m\u001b[14;1H\u001b[2K\u001b[0m\u001b[38;2;116;181;154m✔ Roasted for 0.0s\u001b[0m\u001b[38;2;116;181;154m\u001b[38;2;176;184;194m · done 02:22\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[1.953, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;242;240;236me\u001b[0m\u001b[38;2;176;184;194mnd = s.index(\">>>>>>> fork/fix/mirror-capture-recovery-pause\", start)\u001b[0m\u001b[0m\u001b[17;4H\u001b[?25h"] -[0.003, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;205;115;123me\u001b[0m\u001b[38;2;176;184;194mnd = s.index(\">>>>>>> fork/fix/mirror-capture-recovery-pause\", start)\u001b[0m\u001b[0m\u001b[17;4H\u001b[?25h"] -[0.075, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;242;240;236me\u001b[0m\u001b[38;2;242;240;236mc\u001b[0m\u001b[38;2;176;184;194mho \"-----\"\u001b[0m\u001b[0m\u001b[17;5H\u001b[?25h"] -[0.001, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;205;115;123me\u001b[0m\u001b[38;2;205;115;123mc\u001b[0m\u001b[38;2;176;184;194mho \"-----\"\u001b[0m\u001b[0m\u001b[17;5H\u001b[?25h"] -[0.071, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;242;240;236me\u001b[0m\u001b[38;2;242;240;236mc\u001b[0m\u001b[38;2;242;240;236mh\u001b[0m\u001b[38;2;176;184;194mo \"-----\"\u001b[0m\u001b[0m\u001b[17;6H\u001b[?25h"] -[0.001, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;205;115;123me\u001b[0m\u001b[38;2;205;115;123mc\u001b[0m\u001b[38;2;205;115;123mh\u001b[0m\u001b[38;2;176;184;194mo \"-----\"\u001b[0m\u001b[0m\u001b[17;6H\u001b[?25h"] -[0.070, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;242;240;236me\u001b[0m\u001b[38;2;242;240;236mc\u001b[0m\u001b[38;2;242;240;236mh\u001b[0m\u001b[38;2;242;240;236mo\u001b[0m\u001b[38;2;176;184;194m \"-----\"\u001b[0m\u001b[0m\u001b[17;7H\u001b[?25h"] -[0.002, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;176;184;194m \"-----\"\u001b[0m\u001b[0m\u001b[17;7H\u001b[?25h"] -[0.071, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;176;184;194m\"-----\"\u001b[0m\u001b[0m\u001b[17;8H\u001b[?25h"] -[0.074, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;176;184;194m-----\"\u001b[0m\u001b[0m\u001b[17;9H\u001b[?25h"] -[0.073, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;198;156;109m$\u001b[0m\u001b[38;2;176;184;194mAPP\"\u001b[0m\u001b[0m\u001b[17;10H\u001b[?25h"] -[0.073, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;198;156;109m$\u001b[0m\u001b[38;2;198;156;109mH\u001b[0m\u001b[0m\u001b[17;11H\u001b[?25h"] -[0.073, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;198;156;109m$\u001b[0m\u001b[38;2;198;156;109mH\u001b[0m\u001b[38;2;198;156;109mO\u001b[0m\u001b[0m\u001b[17;12H\u001b[?25h"] -[0.083, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;198;156;109m$\u001b[0m\u001b[38;2;198;156;109mH\u001b[0m\u001b[38;2;198;156;109mO\u001b[0m\u001b[38;2;198;156;109mM\u001b[0m\u001b[0m\u001b[17;13H\u001b[?25h"] -[0.071, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;198;156;109m$\u001b[0m\u001b[38;2;198;156;109mH\u001b[0m\u001b[38;2;198;156;109mO\u001b[0m\u001b[38;2;198;156;109mM\u001b[0m\u001b[38;2;198;156;109mE\u001b[0m\u001b[0m\u001b[17;14H\u001b[?25h"] -[0.073, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;198;156;109m$\u001b[0m\u001b[38;2;198;156;109mH\u001b[0m\u001b[38;2;198;156;109mO\u001b[0m\u001b[38;2;198;156;109mM\u001b[0m\u001b[38;2;198;156;109mE\u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[0m\u001b[17;15H\u001b[?25h"] -[0.077, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;198;156;109m$\u001b[0m\u001b[38;2;198;156;109mH\u001b[0m\u001b[38;2;198;156;109mO\u001b[0m\u001b[38;2;198;156;109mM\u001b[0m\u001b[38;2;198;156;109mE\u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[0m\u001b[17;16H\u001b[?25h"] -[0.078, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;198;156;109m$\u001b[0m\u001b[38;2;198;156;109mH\u001b[0m\u001b[38;2;198;156;109mO\u001b[0m\u001b[38;2;198;156;109mM\u001b[0m\u001b[38;2;198;156;109mE\u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;125;133;144m|\u001b[0m\u001b[0m\u001b[17;17H\u001b[?25h"] -[0.081, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;198;156;109m$\u001b[0m\u001b[38;2;198;156;109mH\u001b[0m\u001b[38;2;198;156;109mO\u001b[0m\u001b[38;2;198;156;109mM\u001b[0m\u001b[38;2;198;156;109mE\u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;125;133;144m|\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[0m\u001b[17;18H\u001b[?25h"] -[0.076, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;198;156;109m$\u001b[0m\u001b[38;2;198;156;109mH\u001b[0m\u001b[38;2;198;156;109mO\u001b[0m\u001b[38;2;198;156;109mM\u001b[0m\u001b[38;2;198;156;109mE\u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;125;133;144m|\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;205;115;123mg\u001b[0m\u001b[0m\u001b[17;19H\u001b[?25h"] -[0.079, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;198;156;109m$\u001b[0m\u001b[38;2;198;156;109mH\u001b[0m\u001b[38;2;198;156;109mO\u001b[0m\u001b[38;2;198;156;109mM\u001b[0m\u001b[38;2;198;156;109mE\u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;125;133;144m|\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;242;240;236mg\u001b[0m\u001b[38;2;242;240;236mr\u001b[0m\u001b[0m\u001b[17;20H\u001b[?25h"] -[0.004, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;198;156;109m$\u001b[0m\u001b[38;2;198;156;109mH\u001b[0m\u001b[38;2;198;156;109mO\u001b[0m\u001b[38;2;198;156;109mM\u001b[0m\u001b[38;2;198;156;109mE\u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;125;133;144m|\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;205;115;123mg\u001b[0m\u001b[38;2;205;115;123mr\u001b[0m\u001b[0m\u001b[17;20H\u001b[?25h"] -[0.070, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;198;156;109m$\u001b[0m\u001b[38;2;198;156;109mH\u001b[0m\u001b[38;2;198;156;109mO\u001b[0m\u001b[38;2;198;156;109mM\u001b[0m\u001b[38;2;198;156;109mE\u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;125;133;144m|\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;242;240;236mg\u001b[0m\u001b[38;2;242;240;236mr\u001b[0m\u001b[38;2;242;240;236me\u001b[0m\u001b[0m\u001b[17;21H\u001b[?25h"] -[0.001, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;198;156;109m$\u001b[0m\u001b[38;2;198;156;109mH\u001b[0m\u001b[38;2;198;156;109mO\u001b[0m\u001b[38;2;198;156;109mM\u001b[0m\u001b[38;2;198;156;109mE\u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;125;133;144m|\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;205;115;123mg\u001b[0m\u001b[38;2;205;115;123mr\u001b[0m\u001b[38;2;205;115;123me\u001b[0m\u001b[0m\u001b[17;21H\u001b[?25h"] -[0.073, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;198;156;109m$\u001b[0m\u001b[38;2;198;156;109mH\u001b[0m\u001b[38;2;198;156;109mO\u001b[0m\u001b[38;2;198;156;109mM\u001b[0m\u001b[38;2;198;156;109mE\u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;125;133;144m|\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;242;240;236mg\u001b[0m\u001b[38;2;242;240;236mr\u001b[0m\u001b[38;2;242;240;236me\u001b[0m\u001b[38;2;242;240;236mp\u001b[0m\u001b[0m\u001b[17;22H\u001b[?25h"] -[0.001, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;198;156;109m$\u001b[0m\u001b[38;2;198;156;109mH\u001b[0m\u001b[38;2;198;156;109mO\u001b[0m\u001b[38;2;198;156;109mM\u001b[0m\u001b[38;2;198;156;109mE\u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;125;133;144m|\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;197;185;232mg\u001b[0m\u001b[38;2;197;185;232mr\u001b[0m\u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mp\u001b[0m\u001b[0m\u001b[17;22H\u001b[?25h"] -[0.078, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;198;156;109m$\u001b[0m\u001b[38;2;198;156;109mH\u001b[0m\u001b[38;2;198;156;109mO\u001b[0m\u001b[38;2;198;156;109mM\u001b[0m\u001b[38;2;198;156;109mE\u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;125;133;144m|\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;197;185;232mg\u001b[0m\u001b[38;2;197;185;232mr\u001b[0m\u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mp\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[0m\u001b[17;23H\u001b[?25h"] -[0.077, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;198;156;109m$\u001b[0m\u001b[38;2;198;156;109mH\u001b[0m\u001b[38;2;198;156;109mO\u001b[0m\u001b[38;2;198;156;109mM\u001b[0m\u001b[38;2;198;156;109mE\u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;125;133;144m|\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;197;185;232mg\u001b[0m\u001b[38;2;197;185;232mr\u001b[0m\u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mp\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;242;240;236mU\u001b[0m\u001b[0m\u001b[17;24H\u001b[?25h"] -[0.074, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;198;156;109m$\u001b[0m\u001b[38;2;198;156;109mH\u001b[0m\u001b[38;2;198;156;109mO\u001b[0m\u001b[38;2;198;156;109mM\u001b[0m\u001b[38;2;198;156;109mE\u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;125;133;144m|\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;197;185;232mg\u001b[0m\u001b[38;2;197;185;232mr\u001b[0m\u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mp\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;242;240;236mU\u001b[0m\u001b[38;2;242;240;236ms\u001b[0m\u001b[0m\u001b[17;25H\u001b[?25h"] -[0.077, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;198;156;109m$\u001b[0m\u001b[38;2;198;156;109mH\u001b[0m\u001b[38;2;198;156;109mO\u001b[0m\u001b[38;2;198;156;109mM\u001b[0m\u001b[38;2;198;156;109mE\u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;125;133;144m|\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;197;185;232mg\u001b[0m\u001b[38;2;197;185;232mr\u001b[0m\u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mp\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;242;240;236mU\u001b[0m\u001b[38;2;242;240;236ms\u001b[0m\u001b[38;2;242;240;236me\u001b[0m\u001b[0m\u001b[17;26H\u001b[?25h"] -[0.076, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;198;156;109m$\u001b[0m\u001b[38;2;198;156;109mH\u001b[0m\u001b[38;2;198;156;109mO\u001b[0m\u001b[38;2;198;156;109mM\u001b[0m\u001b[38;2;198;156;109mE\u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;125;133;144m|\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;197;185;232mg\u001b[0m\u001b[38;2;197;185;232mr\u001b[0m\u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mp\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;242;240;236mU\u001b[0m\u001b[38;2;242;240;236ms\u001b[0m\u001b[38;2;242;240;236me\u001b[0m\u001b[38;2;242;240;236mr\u001b[0m\u001b[0m\u001b[17;27H\u001b[?25h"] -[0.075, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mc\u001b[0m\u001b[38;2;197;185;232mh\u001b[0m\u001b[38;2;197;185;232mo\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;198;156;109m$\u001b[0m\u001b[38;2;198;156;109mH\u001b[0m\u001b[38;2;198;156;109mO\u001b[0m\u001b[38;2;198;156;109mM\u001b[0m\u001b[38;2;198;156;109mE\u001b[0m\u001b[38;2;198;156;109m\"\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;125;133;144m|\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;197;185;232mg\u001b[0m\u001b[38;2;197;185;232mr\u001b[0m\u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mp\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;242;240;236mU\u001b[0m\u001b[38;2;242;240;236ms\u001b[0m\u001b[38;2;242;240;236me\u001b[0m\u001b[38;2;242;240;236mr\u001b[0m\u001b[38;2;242;240;236ms\u001b[0m\u001b[0m\u001b[17;28H\u001b[?25h"] -[1.575, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[0m\u001b[17;3H\u001b[?25h"] -[0.513, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;242;240;236ms\u001b[0m\u001b[38;2;176;184;194mource .venv/bin/activate\u001b[0m\u001b[0m\u001b[17;4H\u001b[?25h"] -[0.002, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;205;115;123ms\u001b[0m\u001b[38;2;176;184;194mource .venv/bin/activate\u001b[0m\u001b[0m\u001b[17;4H\u001b[?25h"] -[0.079, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;242;240;236ms\u001b[0m\u001b[38;2;242;240;236ml\u001b[0m\u001b[38;2;176;184;194meep 2\u001b[0m\u001b[0m\u001b[17;5H\u001b[?25h"] -[0.001, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;205;115;123ms\u001b[0m\u001b[38;2;205;115;123ml\u001b[0m\u001b[38;2;176;184;194meep 2\u001b[0m\u001b[0m\u001b[17;5H\u001b[?25h"] -[0.072, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;242;240;236ms\u001b[0m\u001b[38;2;242;240;236ml\u001b[0m\u001b[38;2;242;240;236me\u001b[0m\u001b[38;2;176;184;194mep 2\u001b[0m\u001b[0m\u001b[17;6H\u001b[?25h"] -[0.002, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;205;115;123ms\u001b[0m\u001b[38;2;205;115;123ml\u001b[0m\u001b[38;2;205;115;123me\u001b[0m\u001b[38;2;176;184;194mep 2\u001b[0m\u001b[0m\u001b[17;6H\u001b[?25h"] -[0.071, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;242;240;236ms\u001b[0m\u001b[38;2;242;240;236ml\u001b[0m\u001b[38;2;242;240;236me\u001b[0m\u001b[38;2;242;240;236me\u001b[0m\u001b[38;2;176;184;194mp 2\u001b[0m\u001b[0m\u001b[17;7H\u001b[?25h"] -[0.001, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;205;115;123ms\u001b[0m\u001b[38;2;205;115;123ml\u001b[0m\u001b[38;2;205;115;123me\u001b[0m\u001b[38;2;205;115;123me\u001b[0m\u001b[38;2;176;184;194mp 2\u001b[0m\u001b[0m\u001b[17;7H\u001b[?25h"] -[0.070, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;242;240;236ms\u001b[0m\u001b[38;2;242;240;236ml\u001b[0m\u001b[38;2;242;240;236me\u001b[0m\u001b[38;2;242;240;236me\u001b[0m\u001b[38;2;242;240;236mp\u001b[0m\u001b[38;2;176;184;194m 2\u001b[0m\u001b[0m\u001b[17;8H\u001b[?25h"] -[0.002, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232ms\u001b[0m\u001b[38;2;197;185;232ml\u001b[0m\u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mp\u001b[0m\u001b[38;2;176;184;194m 2\u001b[0m\u001b[0m\u001b[17;8H\u001b[?25h"] -[0.071, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232ms\u001b[0m\u001b[38;2;197;185;232ml\u001b[0m\u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mp\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;176;184;194m2\u001b[0m\u001b[0m\u001b[17;9H\u001b[?25h"] -[0.072, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232ms\u001b[0m\u001b[38;2;197;185;232ml\u001b[0m\u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mp\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;242;240;236m3\u001b[0m\u001b[0m\u001b[17;10H\u001b[?25h"] -[1.095, "o", "\u001b[?25l\u001b[8;1H\u001b[2K\u001b[0m\u001b[38;2;242;240;236m❯ \u001b[0m\u001b[38;2;197;185;232mgit\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;176;184;194m--version\u001b[0m\u001b[0m\u001b[9;1H\u001b[2Kgit version 2.55.0\u001b[0m\u001b[0m\u001b[10;1H\u001b[2K\u001b[0m\u001b[0m\u001b[11;1H\u001b[2K\u001b[0m\u001b[38;2;116;181;154m✔ Roasted for 0.0s\u001b[0m\u001b[38;2;116;181;154m\u001b[38;2;176;184;194m · done 02:22\u001b[0m\u001b[0m\u001b[12;1H\u001b[2K\u001b[0m\u001b[0m\u001b[13;1H\u001b[2K\u001b[0m\u001b[38;2;242;240;236m❯ \u001b[0m\u001b[38;2;197;185;232msleep\u001b[0m\u001b[38;2;242;240;236m 3\u001b[0m\u001b[0m\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m·\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mT\u001b[38;2;139;132;178ma\u001b[38;2;139;132;178mp\u001b[38;2;139;132;178mi\u001b[38;2;139;132;178mn\u001b[38;2;139;132;178mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (0.0s)\u001b[0m\u001b[0m\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[0m\u001b[17;3H\u001b[?25h"] -[0.129, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m·\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mT\u001b[38;2;139;132;178ma\u001b[38;2;139;132;178mp\u001b[38;2;139;132;178mi\u001b[38;2;139;132;178mn\u001b[38;2;139;132;178mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (0.1s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.102, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m✢\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mT\u001b[38;2;139;132;178ma\u001b[38;2;139;132;178mp\u001b[38;2;139;132;178mi\u001b[38;2;139;132;178mn\u001b[38;2;139;132;178mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (0.2s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.104, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m✳\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mT\u001b[38;2;139;132;178ma\u001b[38;2;139;132;178mp\u001b[38;2;139;132;178mi\u001b[38;2;139;132;178mn\u001b[38;2;139;132;178mg\u001b[38;2;154;146;190m…\u001b[38;2;176;184;194m (0.3s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.099, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m✳\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mT\u001b[38;2;139;132;178ma\u001b[38;2;139;132;178mp\u001b[38;2;139;132;178mi\u001b[38;2;139;132;178mn\u001b[38;2;146;139;184mg\u001b[38;2;194;186;224m…\u001b[38;2;176;184;194m (0.4s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.101, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m✶\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mT\u001b[38;2;139;132;178ma\u001b[38;2;139;132;178mp\u001b[38;2;139;132;178mi\u001b[38;2;139;132;178mn\u001b[38;2;187;178;218mg\u001b[38;2;187;179;218m…\u001b[38;2;176;184;194m (0.5s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.106, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m✶\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mT\u001b[38;2;139;132;178ma\u001b[38;2;139;132;178mp\u001b[38;2;139;132;178mi\u001b[38;2;180;172;212mn\u001b[38;2;194;186;224mg\u001b[38;2;146;139;184m…\u001b[38;2;176;184;194m (0.6s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.098, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m✻\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mT\u001b[38;2;139;132;178ma\u001b[38;2;139;132;178mp\u001b[38;2;172;164;206mi\u001b[38;2;202;193;230mn\u001b[38;2;154;146;190mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (0.7s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.102, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m*\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mT\u001b[38;2;139;132;178ma\u001b[38;2;165;157;200mp\u001b[38;2;209;200;236mi\u001b[38;2;161;153;196mn\u001b[38;2;139;132;178mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (0.8s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.101, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m*\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mT\u001b[38;2;157;150;193ma\u001b[38;2;205;196;233mp\u001b[38;2;169;161;203mi\u001b[38;2;139;132;178mn\u001b[38;2;139;132;178mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (0.9s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.100, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m✻\u001b[38;2;139;132;178m \u001b[38;2;149;142;187mT\u001b[38;2;197;189;227ma\u001b[38;2;177;169;209mp\u001b[38;2;139;132;178mi\u001b[38;2;139;132;178mn\u001b[38;2;139;132;178mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (1.0s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.102, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m✻\u001b[38;2;142;135;181m \u001b[38;2;190;182;221mT\u001b[38;2;184;176;215ma\u001b[38;2;139;132;178mp\u001b[38;2;139;132;178mi\u001b[38;2;139;132;178mn\u001b[38;2;139;132;178mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (1.1s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.108, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m✶\u001b[38;2;182;174;214m \u001b[38;2;192;183;222mT\u001b[38;2;144;137;182ma\u001b[38;2;139;132;178mp\u001b[38;2;139;132;178mi\u001b[38;2;139;132;178mn\u001b[38;2;139;132;178mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (1.2s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.093, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;175;167;208m✳\u001b[38;2;199;191;228m \u001b[38;2;151;144;188mT\u001b[38;2;139;132;178ma\u001b[38;2;139;132;178mp\u001b[38;2;139;132;178mi\u001b[38;2;139;132;178mn\u001b[38;2;139;132;178mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (1.3s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.101, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;207;198;235m✳\u001b[38;2;159;151;195m \u001b[38;2;139;132;178mT\u001b[38;2;139;132;178ma\u001b[38;2;139;132;178mp\u001b[38;2;139;132;178mi\u001b[38;2;139;132;178mn\u001b[38;2;139;132;178mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (1.4s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.102, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;166;158;201m✢\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mT\u001b[38;2;139;132;178ma\u001b[38;2;139;132;178mp\u001b[38;2;139;132;178mi\u001b[38;2;139;132;178mn\u001b[38;2;139;132;178mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (1.6s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.102, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m·\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mT\u001b[38;2;139;132;178ma\u001b[38;2;139;132;178mp\u001b[38;2;139;132;178mi\u001b[38;2;139;132;178mn\u001b[38;2;139;132;178mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (1.7s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.101, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m·\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mT\u001b[38;2;139;132;178ma\u001b[38;2;139;132;178mp\u001b[38;2;139;132;178mi\u001b[38;2;139;132;178mn\u001b[38;2;139;132;178mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (1.8s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.102, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m✢\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mT\u001b[38;2;139;132;178ma\u001b[38;2;139;132;178mp\u001b[38;2;139;132;178mi\u001b[38;2;139;132;178mn\u001b[38;2;139;132;178mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (1.9s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.100, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m✢\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mT\u001b[38;2;139;132;178ma\u001b[38;2;139;132;178mp\u001b[38;2;139;132;178mi\u001b[38;2;139;132;178mn\u001b[38;2;139;132;178mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (2.0s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.102, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m✳\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mT\u001b[38;2;139;132;178ma\u001b[38;2;139;132;178mp\u001b[38;2;139;132;178mi\u001b[38;2;139;132;178mn\u001b[38;2;139;132;178mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (2.1s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.100, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m✶\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mT\u001b[38;2;139;132;178ma\u001b[38;2;139;132;178mp\u001b[38;2;139;132;178mi\u001b[38;2;139;132;178mn\u001b[38;2;139;132;178mg\u001b[38;2;163;155;198m…\u001b[38;2;176;184;194m (2.2s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.102, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m✶\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mT\u001b[38;2;139;132;178ma\u001b[38;2;139;132;178mp\u001b[38;2;139;132;178mi\u001b[38;2;139;132;178mn\u001b[38;2;155;148;191mg\u001b[38;2;203;194;231m…\u001b[38;2;176;184;194m (2.3s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.101, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m✻\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mT\u001b[38;2;139;132;178ma\u001b[38;2;139;132;178mp\u001b[38;2;139;132;178mi\u001b[38;2;147;140;185mn\u001b[38;2;195;187;225mg\u001b[38;2;179;171;211m…\u001b[38;2;176;184;194m (2.4s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.101, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m✻\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mT\u001b[38;2;139;132;178ma\u001b[38;2;139;132;178mp\u001b[38;2;140;133;179mi\u001b[38;2;188;179;219mn\u001b[38;2;186;178;217mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (2.5s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.102, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m*\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mT\u001b[38;2;139;132;178ma\u001b[38;2;139;132;178mp\u001b[38;2;180;172;212mi\u001b[38;2;194;185;224mn\u001b[38;2;146;139;184mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (2.6s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.128, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m✻\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mT\u001b[38;2;139;132;178ma\u001b[38;2;173;165;206mp\u001b[38;2;201;193;230mi\u001b[38;2;153;146;190mn\u001b[38;2;139;132;178mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (2.7s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.073, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m✻\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mT\u001b[38;2;165;157;200ma\u001b[38;2;209;200;236mp\u001b[38;2;161;153;196mi\u001b[38;2;139;132;178mn\u001b[38;2;139;132;178mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (2.8s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.100, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m✶\u001b[38;2;139;132;178m \u001b[38;2;157;150;193mT\u001b[38;2;205;197;233ma\u001b[38;2;169;161;203mp\u001b[38;2;139;132;178mi\u001b[38;2;139;132;178mn\u001b[38;2;139;132;178mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (2.9s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.102, "o", "\u001b[?25l\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m✶\u001b[38;2;150;143;187m \u001b[38;2;198;190;227mT\u001b[38;2;176;168;209ma\u001b[38;2;139;132;178mp\u001b[38;2;139;132;178mi\u001b[38;2;139;132;178mn\u001b[38;2;139;132;178mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (3.0s)\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[0.067, "o", "\u001b[?25l\u001b[7;1H\u001b[2K\u001b[0m\u001b[38;2;242;240;236m❯ \u001b[0m\u001b[38;2;197;185;232mgit\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;176;184;194m--version\u001b[0m\u001b[0m\u001b[8;1H\u001b[2Kgit version 2.55.0\u001b[0m\u001b[0m\u001b[9;1H\u001b[2K\u001b[0m\u001b[0m\u001b[10;1H\u001b[2K\u001b[0m\u001b[38;2;116;181;154m✔ Roasted for 0.0s\u001b[0m\u001b[38;2;116;181;154m\u001b[38;2;176;184;194m · done 02:22\u001b[0m\u001b[0m\u001b[11;1H\u001b[2K\u001b[0m\u001b[0m\u001b[12;1H\u001b[2K\u001b[0m\u001b[38;2;242;240;236m❯ \u001b[0m\u001b[38;2;197;185;232msleep\u001b[0m\u001b[38;2;242;240;236m 3\u001b[0m\u001b[0m\u001b[13;1H\u001b[2K\u001b[0m\u001b[0m\u001b[14;1H\u001b[2K\u001b[0m\u001b[38;2;116;181;154m✔ Taped for 3.0s\u001b[0m\u001b[38;2;116;181;154m\u001b[38;2;176;184;194m · done 02:22\u001b[0m\u001b[0m\u001b[17;3H\u001b[?25h"] -[2.484, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;205;115;123me\u001b[0m\u001b[38;2;176;184;194mnd = s.index(\">>>>>>> fork/fix/mirror-capture-recovery-pause\", start)\u001b[0m\u001b[0m\u001b[17;4H\u001b[?25h"] -[0.072, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;242;240;236me\u001b[0m\u001b[38;2;242;240;236mx\u001b[0m\u001b[38;2;176;184;194mec xcodegen generate --spec project.local.yml\u001b[0m\u001b[0m\u001b[17;5H\u001b[?25h"] -[0.002, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mx\u001b[0m\u001b[38;2;176;184;194mec xcodegen generate --spec project.local.yml\u001b[0m\u001b[0m\u001b[17;5H\u001b[?25h"] -[0.077, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;242;240;236me\u001b[0m\u001b[38;2;242;240;236mx\u001b[0m\u001b[38;2;242;240;236mi\u001b[0m\u001b[38;2;176;184;194mt 127\u001b[0m\u001b[0m\u001b[17;6H\u001b[?25h"] -[0.003, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;205;115;123me\u001b[0m\u001b[38;2;205;115;123mx\u001b[0m\u001b[38;2;205;115;123mi\u001b[0m\u001b[38;2;176;184;194mt 127\u001b[0m\u001b[0m\u001b[17;6H\u001b[?25h"] -[0.070, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;242;240;236me\u001b[0m\u001b[38;2;242;240;236mx\u001b[0m\u001b[38;2;242;240;236mi\u001b[0m\u001b[38;2;242;240;236mt\u001b[0m\u001b[38;2;176;184;194m 127\u001b[0m\u001b[0m\u001b[17;7H\u001b[?25h"] -[0.002, "o", "\u001b[?25l\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[38;2;197;185;232me\u001b[0m\u001b[38;2;197;185;232mx\u001b[0m\u001b[38;2;197;185;232mi\u001b[0m\u001b[38;2;197;185;232mt\u001b[0m\u001b[38;2;176;184;194m 127\u001b[0m\u001b[0m\u001b[17;7H\u001b[?25h"] -[0.071, "o", "\u001b[?25l\u001b[4;1H\u001b[2K\u001b[0m\u001b[38;2;242;240;236m❯ \u001b[0m\u001b[38;2;197;185;232mgit\u001b[0m\u001b[38;2;242;240;236m \u001b[0m\u001b[38;2;176;184;194m--version\u001b[0m\u001b[0m\u001b[5;1H\u001b[2Kgit version 2.55.0\u001b[0m\u001b[0m\u001b[6;1H\u001b[2K\u001b[0m\u001b[0m\u001b[7;1H\u001b[2K\u001b[0m\u001b[38;2;116;181;154m✔ Roasted for 0.0s\u001b[0m\u001b[38;2;116;181;154m\u001b[38;2;176;184;194m · done 02:22\u001b[0m\u001b[0m\u001b[8;1H\u001b[2K\u001b[0m\u001b[0m\u001b[9;1H\u001b[2K\u001b[0m\u001b[38;2;242;240;236m❯ \u001b[0m\u001b[38;2;197;185;232msleep\u001b[0m\u001b[38;2;242;240;236m 3\u001b[0m\u001b[0m\u001b[10;1H\u001b[2K\u001b[0m\u001b[0m\u001b[11;1H\u001b[2K\u001b[0m\u001b[38;2;116;181;154m✔ Taped for 3.0s\u001b[0m\u001b[38;2;116;181;154m\u001b[38;2;176;184;194m · done 02:22\u001b[0m\u001b[0m\u001b[12;1H\u001b[2K\u001b[0m\u001b[0m\u001b[13;1H\u001b[2K\u001b[0m\u001b[38;2;242;240;236m❯ \u001b[0m\u001b[38;2;197;185;232mexit\u001b[0m\u001b[0m\u001b[14;1H\u001b[2K\u001b[38;2;139;132;178m·\u001b[38;2;139;132;178m \u001b[38;2;139;132;178mW\u001b[38;2;139;132;178me\u001b[38;2;139;132;178ml\u001b[38;2;139;132;178md\u001b[38;2;139;132;178mi\u001b[38;2;139;132;178mn\u001b[38;2;139;132;178mg\u001b[38;2;139;132;178m…\u001b[38;2;176;184;194m (0.0s)\u001b[0m\u001b[0m\u001b[17;1H\u001b[2K\u001b[38;2;197;185;232m❯\u001b[0m \u001b[0m\u001b[17;3H\u001b[?25h"] -[0.012, "o", "\u001b[0m\u001b[?1006l\u001b[?1000l\u001b[?2004l\u001b[?25h\u001b[ new Promise(res => setTimeout(res, ms)); - -async function typeLiteral(session, text, delayMs = 65) { - for (const char of text) { - execFileSync('tmux', ['send-keys', '-t', session, '-l', char]); - await delay(delayMs); - } -} - -async function sendKey(session, key) { - execFileSync('tmux', ['send-keys', '-t', session, key]); -} - -async function run() { - const session = 'demorec'; - try { execSync('rm -f scripts/readme-demo/demo.cast'); } catch (e) {} - execSync(`tmux new-session -d -s ${session} -x 90 -y 18 "asciinema rec -c ./bin/nmsh scripts/readme-demo/demo.cast"`); - - await delay(3500); - - // 1. gti status (UNKNOWN) - await typeLiteral(session, 'gti status'); - await delay(1200); - await sendKey(session, 'C-u'); - await delay(500); - - // 2. git --version (KNOWN) - await typeLiteral(session, 'git --version'); - await delay(1200); - await sendKey(session, 'Enter'); - - await delay(2000); - - // 3. echo "$HOME" | grep Users (PIPELINE) - await typeLiteral(session, 'echo "$HOME" | grep Users'); - await delay(1500); - await sendKey(session, 'C-u'); - await delay(500); - - // 4. sleep 3 (LIVE ACTIVITY) - await typeLiteral(session, 'sleep 3'); - await delay(1000); // pause to show highlighting - await sendKey(session, 'Enter'); - - await delay(4500); - - // 5. clean idle - await delay(1000); - - await typeLiteral(session, 'exit'); - await sendKey(session, 'Enter'); - await delay(1000); - try { execSync(`tmux kill-session -t ${session}`); } catch (e) {} -} - -run().catch(console.error); diff --git a/scripts/shimmer-demo.ts b/scripts/shimmer-demo.ts new file mode 100644 index 00000000..3954e66d --- /dev/null +++ b/scripts/shimmer-demo.ts @@ -0,0 +1,56 @@ +import {normalizePromptConfiguration, type PromptConfiguration} from '../src/prompt/configuration.js'; +import {NATIVE_PROMPT_THEMES} from '../src/prompt/prompt.js'; +import {sweepDurationMs, sweepOnce, type SweepCell} from '../src/motion/lightSweep.js'; +import {sweepStyleFor} from '../src/motion/sweepStyle.js'; +import {colorLevel} from '../src/presentation/capabilities.js'; +import {graphemes} from '../src/input/inputLayout.js'; +import type {Rgb} from '../src/chroma/escape.js'; + +/** + * Deterministic light-sweep demo for physical review. Every frame is a pure + * function of a stepped clock (100 ms per frame), so recordings repeat + * exactly. Run: node --import=tsx scripts/shimmer-demo.ts [seconds] + * With --frame it prints one frame at that time and exits. + */ +const variants: Array<[string, object]> = [ + ['Full Chroma · Semantic Override', {presentation: {preset: 'aurora', semantic: 'override', shimmer: 'on'}}], + ['Full Chroma · Semantic Preserve', {presentation: {preset: 'aurora', semantic: 'preserve', shimmer: 'on'}}], + ['Theme accent (Chroma off)', {nmsh: {palette: 'ocean'}, presentation: {shimmer: 'on'}}], + ['Grayscale UI chrome (Grayscale theme)', {nmsh: {palette: 'grayscale'}, uiChrome: {source: 'custom', preset: 'grayscale'}, presentation: {shimmer: 'on'}}], +]; + +function sample(config: PromptConfiguration): SweepCell[] { + const theme = NATIVE_PROMPT_THEMES[config.nmsh.palette]; + const cells: SweepCell[] = []; + const add = (text: string, color: Rgb, semantic = false) => { for (const glyph of graphemes(text)) cells.push({glyph, color: glyph === ' ' ? undefined : color, semantic}); }; + add('✔ 0 ', theme.colors('success').background, true); + add('notMyShell ', theme.colors('project').background); + add('~/Projects ', theme.colors('cwd').background); + add('main ', theme.colors('gitBranch').background); + add('node ', theme.colors('node').background); + add('✘ 1', theme.colors('failure').background, true); + return cells; +} + +function frame(time: number): string[] { + return variants.flatMap(([label, patch]) => { + const config = normalizePromptConfiguration(patch); + // One pass at a time (as after a selection or value change), then a pause, then the next pass. + const cells = sample(config); + const pass = sweepDurationMs(cells.length) + 900; + return [`\u001B[2m${label}\u001B[0m`, ` ${sweepOnce(cells, time % pass, sweepStyleFor(config), colorLevel())}\u001B[0m`, '']; + }); +} + +const index = process.argv.indexOf('--frame'); +if (index !== -1) { + process.stdout.write(`${frame(Number(process.argv[index + 1] ?? 0)).join('\n')}\n`); +} else { + const seconds = Number(process.argv[2] ?? 12); + let step = 0; + process.stdout.write('\u001B[?25l\u001B[2J'); + const timer = setInterval(() => { + process.stdout.write(`\u001B[H${frame(step * 100).join('\n')}\n`); + if (++step > seconds * 10) { clearInterval(timer); process.stdout.write('\u001B[?25h'); } + }, 100); +} diff --git a/scripts/test-selection.mjs b/scripts/test-selection.mjs new file mode 100644 index 00000000..b04e8d28 --- /dev/null +++ b/scripts/test-selection.mjs @@ -0,0 +1,83 @@ +import {readdirSync} from 'node:fs'; +import {join} from 'node:path'; + +// Explicit reviewed subsets, never inferred from file names or execution time. +// Fast: pure editor/protocol/presentation/platform logic; no real PTY fixtures. +const suites = { + fast: ['testSharding', 'input', 'keys', 'highlighter', 'shellProtocol', 'sessionProtocol', 'completionModel', 'pathDisplay', 'linuxPlatform'], + // Minimum-runtime gate: actual launcher, persistent shell backends, protocol, + // platform discovery and build/version behavior without long detach scenarios. + node22: ['testSharding', 'buildInfo', 'startupLaunch', 'shellAdapters', 'sessionProtocol', 'shellProtocol', 'linuxPlatform', 'hostProfiles', 'serviceCompat'], + fedora: ['linuxPlatform', 'tools', 'update', 'hostProfiles', 'portabilityUninstall'], +}; + +// Rounded seconds from the macOS Node 26 canonical baseline (2026-10-04). +// Only slow files need weights; new/ordinary files default to one. Greedy +// whole-file scheduling avoids clustering the costly PTY files in one shard. +const runtimeWeights = { + "bundledCatalog.test.ts": 5, + "compatibilityHarness.test.ts": 28, + "configuredCompletion.test.ts": 6, + "ctrlZJobControl.test.ts": 12, + "detachedOutput.test.ts": 22, + "fullChromaQa.test.ts": 5, + "hostProfiles.test.ts": 10, + "interactiveCli.test.ts": 14, + "liveHardening.test.ts": 45, + "liveStatus.test.ts": 5, + "muxInterop.test.ts": 31, + "nativeCaptureLifecycle.test.ts": 6, + "sessionLifecycle.test.ts": 28, + "sessionPresets.test.ts": 20, + "shellAdapters.test.ts": 35, + "shellSwitchApp.test.ts": 23, + "startupBlocked.test.ts": 29, + "startupDiscovery.test.ts": 22, + "startupLaunch.test.ts": 8, + "themeBridgeLive.test.ts": 12 +}; + +export function discoverTestFiles() { + return readdirSync('tests', {recursive: true}).filter(name => name.endsWith('.test.ts')).map(name => join('tests', name)).sort(); +} + +export function parseTestOptions(argv) { + const options = {args: []}; + for (const arg of argv) { + if (arg.startsWith('--shard=')) { + if (options.shard) throw new Error('Specify --shard only once'); + const match = /^--shard=([1-9]\d*)\/([1-9]\d*)$/u.exec(arg); + if (!match) throw new Error('Expected --shard=index/count (1-based)'); + const index = Number(match[1]), count = Number(match[2]); + if (!Number.isSafeInteger(count) || !Number.isSafeInteger(index) || index > count) throw new Error('Invalid shard range'); + options.shard = {index, count}; + } else if (arg.startsWith('--suite=')) { + const suite = arg.slice('--suite='.length); + if (options.suite || !Object.hasOwn(suites, suite)) throw new Error('Unknown or repeated test suite'); + options.suite = suite; + } else options.args.push(arg); + } + if (options.shard && options.suite) throw new Error('Curated suites cannot be sharded'); + return options; +} + +export function selectTestGroups(files, {shard, suite} = {}) { + let selected = [...files].sort(); + if (suite) { + selected = suites[suite].map(name => `tests/${name}.test.ts`).sort(); + for (const file of selected) if (!files.includes(file)) throw new Error(`Missing curated test: ${file}`); + } + const ranking = selected.filter(file => file.endsWith('/suggestionRanking.test.ts')); + let runtime = selected.filter(file => !ranking.includes(file)); + if (shard) { + const weight = file => runtimeWeights[file.slice(file.lastIndexOf('/') + 1)] ?? 1; + const buckets = Array.from({length: shard.count}, () => ({files: [], weight: 0})); + for (const file of [...runtime].sort((a, b) => weight(b) - weight(a) || (a < b ? -1 : a > b ? 1 : 0))) { + const bucket = buckets.reduce((best, candidate) => candidate.weight < best.weight ? candidate : best); + bucket.files.push(file); + bucket.weight += weight(file); + } + runtime = buckets[shard.index - 1].files.sort(); + } + return [runtime, !shard || shard.index === 1 ? ranking : []]; +} diff --git a/scripts/test.mjs b/scripts/test.mjs new file mode 100644 index 00000000..2993a0ac --- /dev/null +++ b/scripts/test.mjs @@ -0,0 +1,59 @@ +import {spawn} from 'node:child_process'; +import {mkdtempSync, readdirSync, realpathSync, rmSync} from 'node:fs'; +import {availableParallelism, tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {pathToFileURL} from 'node:url'; +import {discoverTestFiles, parseTestOptions, selectTestGroups} from './test-selection.mjs'; + +/** Short private temp roots keep Unix sockets below their path length limit. */ +export async function runTestFiles(files, args = [], {cwd = process.cwd(), stdio = 'inherit', report = message => console.error(message)} = {}) { + const root = realpathSync(mkdtempSync(join(process.platform === 'win32' ? tmpdir() : '/tmp', 'nt-'))); + let leftovers = []; + try { + // Existing presentation snapshots pin truecolor; baseline fixtures override this explicitly. + // Tests never see the user's real NMSh settings: configuration lives under this run's private root. + const env = {...process.env, COLORTERM: process.env.COLORTERM ?? 'truecolor', TMPDIR: root, TMP: root, TEMP: root, XDG_CONFIG_HOME: join(root, 'config'), NMSH_DISABLE_UPDATES: '1'}; + // A nested runner must not impersonate its parent's test worker. + delete env.NODE_TEST_CONTEXT; + // PTY workers also own shell/helper processes. Bound fan-out on larger + // development hosts; explicit Node --test-concurrency options still win. + const concurrency = Math.max(1, Math.min(4, availableParallelism() - 1)); + const child = spawn(process.execPath, ['--import=tsx', '--test', `--test-concurrency=${concurrency}`, ...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|shell|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) { + try { + const {args, ...selection} = parseTestOptions(process.argv.slice(2)); + const groups = selectTestGroups(discoverTestFiles(), selection); + process.exitCode = 0; + for (const group of groups) { + if (group.length) process.exitCode = Math.max(process.exitCode, (await runTestFiles(group, args)).code); + } + } + catch (error) { console.error(error); process.exitCode = 1; } +} diff --git a/scripts/timing-smoke.mjs b/scripts/timing-smoke.mjs new file mode 100644 index 00000000..6668bcd2 --- /dev/null +++ b/scripts/timing-smoke.mjs @@ -0,0 +1,13 @@ +import {spawnSync} from 'node:child_process'; + +for (const args of [ + ['--import=tsx', 'scripts/platform-benchmarks.ts'], + ['--import=tsx', 'scripts/benchmarks.ts', 'completion/configured-cold', 'completion/configured-warm', 'composer/screen-plan', 'transcript/wrap-present-10000'], +]) { + const result = spawnSync(process.execPath, args, { + stdio: 'inherit', timeout: 120000, + env: {...process.env, NMSH_BENCH_SAMPLES: '5', NMSH_BENCH_WARMUP: '1'}, + }); + if (result.error) console.error(result.error); + if (result.status !== 0) { process.exitCode = result.status ?? 1; break; } +} 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/agents/AgentActivityStore.ts b/src/agents/AgentActivityStore.ts new file mode 100644 index 00000000..a6df2084 --- /dev/null +++ b/src/agents/AgentActivityStore.ts @@ -0,0 +1,165 @@ +import {mkdirSync, readFileSync, renameSync, rmSync, writeFileSync} from 'node:fs'; +import {dirname, join} from 'node:path'; +import {nmshConfigDirectory} from '../configuration/paths.js'; +import {KNOWN_AGENTS, type AgentId} from './agents.js'; + +/** + * Optional, local-only usage facts about known agent CLIs run inside NMSh. + * + * Stored per agent: total duration, run count, per-day duration and a short + * list of recent runs (start time, duration, exit code). Never stored: the + * command line, arguments, cwd, prompts, responses, output or environment. + * Nothing here is sent anywhere. + */ + +export const AGENT_ACTIVITY_SCHEMA = 1; +/** Days of per-day history kept per agent (a little over a year for the heatmap). */ +export const MAX_DAYS = 400; +export const MAX_RECENT = 50; + +export interface AgentRun { + agent: AgentId; + startedAt: number; + durationMs: number; + /** Absent when the run was interrupted or its status is unknown. */ + exitCode?: number; +} + +export interface AgentTotals { + durationMs: number; + runs: number; + /** Local calendar day (YYYY-MM-DD) → milliseconds worked that day. */ + days: Record; + lastUsedAt?: number; +} + +export interface AgentActivityData { + version: typeof AGENT_ACTIVITY_SCHEMA; + agents: Partial>; + recent: AgentRun[]; + /** Dedupe keys of recently recorded runs, so a replayed completion is never counted twice. */ + seen: string[]; +} + +export type LoadState = 'ok' | 'absent' | 'recovered-corrupt' | 'newer-version'; + +export function emptyActivity(): AgentActivityData { + return {version: AGENT_ACTIVITY_SCHEMA, agents: {}, recent: [], seen: []}; +} + +export function agentActivityPath(env: NodeJS.ProcessEnv = process.env): string { + return join(nmshConfigDirectory(env), 'agent-activity.json'); +} + +/** Local calendar day for a timestamp. */ +export function dayKey(at: number): string { + const date = new Date(at); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} + +const AGENT_IDS = new Set(KNOWN_AGENTS.map(agent => agent.id)); +const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0; + +function normalizeTotals(value: unknown): AgentTotals | undefined { + if (!value || typeof value !== 'object') return undefined; + const record = value as Record; + const days: Record = {}; + if (record.days && typeof record.days === 'object') { + for (const [day, ms] of Object.entries(record.days as Record)) if (/^\d{4}-\d{2}-\d{2}$/u.test(day) && finite(ms)) days[day] = ms; + } + return {durationMs: finite(record.durationMs) ? record.durationMs : 0, runs: finite(record.runs) ? Math.floor(record.runs) : 0, days: pruneDays(days), + ...(finite(record.lastUsedAt) ? {lastUsedAt: record.lastUsedAt} : {})}; +} + +function pruneDays(days: Record): Record { + const keys = Object.keys(days).sort(); + if (keys.length <= MAX_DAYS) return days; + return Object.fromEntries(keys.slice(-MAX_DAYS).map(key => [key, days[key]!])); +} + +/** + * Normalize a stored file into the current schema. Invalid fields are dropped + * individually; a file from a newer NMSh is reported, never rewritten. A later + * schema adds its upgrade step here, keyed on `version`. + */ +export function migrateActivity(value: unknown): AgentActivityData | 'newer' | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + if (typeof record.version !== 'number') return undefined; + if (record.version > AGENT_ACTIVITY_SCHEMA) return 'newer'; + const agents: AgentActivityData['agents'] = {}; + if (record.agents && typeof record.agents === 'object') { + for (const [id, raw] of Object.entries(record.agents as Record)) { + if (!AGENT_IDS.has(id)) continue; + const totals = normalizeTotals(raw); + if (totals) agents[id as AgentId] = totals; + } + } + const recent = Array.isArray(record.recent) ? record.recent.filter((run): run is AgentRun => !!run && typeof run === 'object' + && AGENT_IDS.has((run as AgentRun).agent) && finite((run as AgentRun).startedAt) && finite((run as AgentRun).durationMs)) + .map(run => ({agent: run.agent, startedAt: run.startedAt, durationMs: run.durationMs, + ...(Number.isSafeInteger(run.exitCode) ? {exitCode: run.exitCode} : {})})).slice(0, MAX_RECENT) : []; + const seen = Array.isArray(record.seen) ? record.seen.filter((key): key is string => typeof key === 'string' && key.length <= 200).slice(-MAX_RECENT * 2) : []; + return {version: AGENT_ACTIVITY_SCHEMA, agents, recent, seen}; +} + +/** Pure: add one finished run. Returns false when the dedupe key was already recorded. */ +export function addRun(data: AgentActivityData, run: AgentRun, key: string): boolean { + if (data.seen.includes(key)) return false; + const totals = data.agents[run.agent] ?? {durationMs: 0, runs: 0, days: {}}; + totals.durationMs += run.durationMs; + totals.runs += 1; + totals.lastUsedAt = Math.max(totals.lastUsedAt ?? 0, run.startedAt + run.durationMs); + // A run spanning midnight is credited to the day it started; good enough for a heatmap. + const day = dayKey(run.startedAt); + totals.days[day] = (totals.days[day] ?? 0) + run.durationMs; + totals.days = pruneDays(totals.days); + data.agents[run.agent] = totals; + data.recent = [run, ...data.recent].slice(0, MAX_RECENT); + data.seen = [...data.seen, key].slice(-MAX_RECENT * 2); + return true; +} + +export class AgentActivityStore { + state: LoadState = 'absent'; + + constructor(readonly path = agentActivityPath()) {} + + load(): AgentActivityData { + let text: string; + try { text = readFileSync(this.path, 'utf8'); } catch { this.state = 'absent'; return emptyActivity(); } + let migrated: ReturnType; + try { migrated = migrateActivity(JSON.parse(text)); } catch { migrated = undefined; } + if (migrated === 'newer') { this.state = 'newer-version'; return emptyActivity(); } + if (!migrated) { + // Keep the unreadable file for inspection instead of silently discarding it. + try { renameSync(this.path, `${this.path}.corrupt-${Date.now()}`); } catch { /* best effort */ } + this.state = 'recovered-corrupt'; + return emptyActivity(); + } + this.state = 'ok'; + return migrated; + } + + /** Record one run; a newer-schema file written by a later NMSh is never overwritten. */ + record(run: AgentRun, key: string): boolean { + const data = this.load(); + if (this.state === 'newer-version') return false; + if (!addRun(data, run, key)) return false; + this.save(data); + return true; + } + + save(data: AgentActivityData): void { + mkdirSync(dirname(this.path), {recursive: true, mode: 0o700}); + const temporary = `${this.path}.${process.pid}.tmp`; + writeFileSync(temporary, `${JSON.stringify(data)}\n`, {encoding: 'utf8', mode: 0o600}); + renameSync(temporary, this.path); + } + + /** Delete every stored agent-activity fact. */ + reset(): void { + rmSync(this.path, {force: true}); + this.state = 'absent'; + } +} diff --git a/src/agents/AgentStatsView.ts b/src/agents/AgentStatsView.ts new file mode 100644 index 00000000..60fa9f05 --- /dev/null +++ b/src/agents/AgentStatsView.ts @@ -0,0 +1,123 @@ +import {formatDuration} from '../status/commandTiming.js'; +import {foreground, UI_COLORS, type RgbColor} from '../ui/palette.js'; +import {getCurrentGlyphMode} from '../ui/glyphs.js'; +import {truncateAnsi} from '../util/text.js'; +import {agentDescriptor, KNOWN_AGENTS, type AgentId} from './agents.js'; +import {dayKey, type AgentActivityData, type LoadState} from './AgentActivityStore.js'; + +const RESET = '\u001b[0m'; +/** Density glyphs carry the level without color (NO_COLOR, monochrome hosts). */ +const LEVEL_GLYPHS = ['·', '░', '▒', '▓', '█'] as const; +const DAY_MS = 86_400_000; + +function hex(color: string): RgbColor { + return {red: Number.parseInt(color.slice(1, 3), 16), green: Number.parseInt(color.slice(3, 5), 16), blue: Number.parseInt(color.slice(5, 7), 16)}; +} + +function mix(a: RgbColor, b: RgbColor, t: number): RgbColor { + return {red: Math.round(a.red + (b.red - a.red) * t), green: Math.round(a.green + (b.green - a.green) * t), blue: Math.round(a.blue + (b.blue - a.blue) * t)}; +} + +/** Escape for an agent's accent; empty under NO_COLOR (foreground follows the color level). */ +export function agentColor(color: string): string { + return foreground(hex(color)); +} + +/** Combined per-day milliseconds across agents (or one agent). */ +export function dailyTotals(data: AgentActivityData, agent?: AgentId): Map { + const totals = new Map(); + for (const [id, entry] of Object.entries(data.agents)) { + if (agent && id !== agent) continue; + for (const [day, ms] of Object.entries(entry?.days ?? {})) totals.set(day, (totals.get(day) ?? 0) + ms); + } + return totals; +} + +/** Quantize a day's duration into 0..4 against the busiest day shown. Deterministic. */ +export function heatLevel(ms: number, max: number): number { + if (ms <= 0 || max <= 0) return 0; + return Math.max(1, Math.min(4, Math.ceil((ms / max) * 4))); +} + +/** + * A contribution-style grid: one column per week (oldest left), one row per + * weekday (Mon..Sun), ending at `now`'s week. Pure; no dependency. + */ +export function renderHeatmap(days: ReadonlyMap, now: number, weeks: number, accent: RgbColor): string[] { + const today = new Date(now); + const weekday = (today.getDay() + 6) % 7; // Monday = 0 + const lastMonday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - weekday).getTime(); + const firstMonday = lastMonday - (weeks - 1) * 7 * DAY_MS; + const cells: number[][] = Array.from({length: 7}, () => []); + let max = 0; + for (const ms of days.values()) max = Math.max(max, ms); + const base = UI_COLORS.subtle; + const labels = ['Mon', ' ', 'Wed', ' ', 'Fri', ' ', 'Sun']; + for (let week = 0; week < weeks; week += 1) { + for (let day = 0; day < 7; day += 1) { + // Calendar arithmetic through Date keeps DST transitions on the right day. + const start = new Date(firstMonday); + const at = new Date(start.getFullYear(), start.getMonth(), start.getDate() + week * 7 + day).getTime(); + cells[day]!.push(at > now ? -1 : heatLevel(days.get(dayKey(at)) ?? 0, max)); + } + } + return cells.map((row, day) => `${labels[day]} ${row.map(level => { + if (level < 0) return ' '; + const color = level === 0 ? base : mix(base, accent, 0.25 + level * 0.1875); + return `${foreground(color)}${LEVEL_GLYPHS[level]}${RESET}`; + }).join('')}`); +} + +export interface AgentStatsOptions { + now: number; + columns: number; + enabled: boolean; + loadState: LoadState; +} + +/** The /agents view: per-agent totals, recent runs and a heatmap. Facts only. */ +export function renderAgentStats(data: AgentActivityData, options: AgentStatsOptions): string[] { + const {now, columns} = options; + const accent = foreground(UI_COLORS.accent); + const dim = foreground(UI_COLORS.secondary); + const rows: string[] = [`${accent}Agent activity${RESET}${dim} · local only · nothing leaves this machine${RESET}`]; + if (!options.enabled) rows.push(`${dim}Recording is Off. Turn it on with /agents on; existing data is kept until /agents reset.${RESET}`); + if (options.loadState === 'newer-version') rows.push(`${dim}The activity file was written by a newer NMSh; it is shown empty and left unchanged.${RESET}`); + if (options.loadState === 'recovered-corrupt') rows.push(`${dim}The previous activity file was unreadable; it was kept beside the new one with a .corrupt suffix.${RESET}`); + rows.push(''); + const safe = getCurrentGlyphMode() === 'safe'; + const known = KNOWN_AGENTS.filter(agent => (data.agents[agent.id]?.runs ?? 0) > 0); + if (known.length === 0) rows.push(`${dim}No agent runs recorded yet. Claude Code and Codex CLI runs are counted when they finish.${RESET}`); + for (const agent of known) { + const totals = data.agents[agent.id]!; + const days = Object.keys(totals.days).length; + const color = foreground(hex(agent.color)); + rows.push(`${color}${safe ? agent.safeGlyph : agent.glyph}${RESET} ${agent.name.padEnd(12)} ${formatDuration(totals.durationMs).padStart(10)} ` + + `${dim}${totals.runs} run${totals.runs === 1 ? '' : 's'} · ${days} day${days === 1 ? '' : 's'} used${RESET}`); + } + if (known.length > 0) { + rows.push(''); + const weeks = Math.max(4, Math.min(52, columns - 6)); + rows.push(`${dim}Last ${weeks} weeks${RESET}`); + rows.push(...renderHeatmap(dailyTotals(data), now, weeks, UI_COLORS.accent)); + rows.push(`${dim}less ${LEVEL_GLYPHS.join('')} more${RESET}`); + rows.push(''); + rows.push(`${dim}Recent${RESET}`); + for (const run of data.recent.slice(0, 5)) { + const agent = agentDescriptor(run.agent); + const status = run.exitCode === undefined ? 'interrupted' : run.exitCode === 0 ? 'finished' : `exit ${run.exitCode}`; + rows.push(` ${new Date(run.startedAt).toLocaleString()} ${agent.short.padEnd(7)} ${formatDuration(run.durationMs).padStart(9)} ${dim}${status}${RESET}`); + } + } + rows.push(''); + rows.push(`${dim}/agents on · /agents off · /agents reset (deletes all agent activity data)${RESET}`); + return rows.map(row => truncateAnsi(row, columns)); +} + +/** Completion wording for a finished agent run, in the activity line's vocabulary. */ +export function agentCompletionText(agent: AgentId, elapsedMs: number, exitCode: number, interrupted: boolean): string { + const name = agentDescriptor(agent).short; + if (interrupted) return `Stopped ${name} after ${formatDuration(elapsedMs)}`; + if (exitCode !== 0) return `${name} exited ${exitCode} after ${formatDuration(elapsedMs)}`; + return `Worked with ${name} for ${formatDuration(elapsedMs)}`; +} diff --git a/src/agents/agents.ts b/src/agents/agents.ts new file mode 100644 index 00000000..62a48d3e --- /dev/null +++ b/src/agents/agents.ts @@ -0,0 +1,67 @@ +import {harness, HARNESSES, type HarnessDescriptor} from './harnesses.js'; + +/** + * Factual identification of known terminal agent CLIs. + * + * Identity comes only from the submitted command line's program word (after + * bounded, well-known wrappers) or the PTY's foreground process name. NMSh + * never reads an agent's prompt, conversation, response or output to decide + * anything here, and nothing in this module stores command arguments. + */ + +/** A harness id from the registry (src/agents/harnesses.ts); no fixed union, so new harnesses need no changes here. */ +export type AgentId = string; + +export type AgentDescriptor = HarnessDescriptor; + +export const KNOWN_AGENTS: readonly AgentDescriptor[] = HARNESSES; + +const BY_EXECUTABLE = new Map(KNOWN_AGENTS.flatMap(agent => agent.executables.map(name => [name, agent] as const))); +const BY_PACKAGE = new Map(KNOWN_AGENTS.flatMap(agent => agent.packages.map(name => [name, agent] as const))); + +export function agentDescriptor(id: AgentId): AgentDescriptor { + return harness(id) ?? {id, name: id, short: id, executables: [], packages: [], color: '#9aa4b2', glyph: '•', safeGlyph: '*'}; +} + +/** Prefix words that run the following command unchanged. Bounded; no flags are interpreted beyond these. */ +const TRANSPARENT_WRAPPERS = new Set(['command', 'exec', 'nohup', 'time', 'builtin', 'noglob', 'nice']); +const PACKAGE_RUNNERS = new Set(['npx', 'bunx', 'pnpx']); + +const baseName = (word: string) => word.slice(word.lastIndexOf('/') + 1); +/** Strip a version suffix: `@openai/codex@latest` → `@openai/codex`. */ +const packageName = (word: string) => word.replace(/^(@[^/@\s]+\/[^@\s]+|[^@\s]+)@.*$/u, '$1'); + +/** + * The known agent a command line starts, if any. Only the program position is + * considered: `git commit -m "claude"` and `echo codex` are not agents. + */ +export function detectAgentCommand(command: string): AgentDescriptor | undefined { + // Only the first simple command: a pipeline or list after it is not the program identity. + const head = command.trim().split(/\s*(?:\|\|?|&&|;|\n)\s*/u, 1)[0] ?? ''; + const words = head.split(/\s+/u).filter(Boolean).slice(0, 12); + let index = 0; + while (index < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[index]!)) index += 1; + while (index < words.length && TRANSPARENT_WRAPPERS.has(words[index]!)) { + index += 1; + while (index < words.length && words[index]!.startsWith('-')) index += 1; + } + const program = words[index]; + if (!program) return undefined; + const name = baseName(program); + const direct = BY_EXECUTABLE.get(name); + if (direct) return direct; + let next = index + 1; + if (name === 'pnpm' && words[next] === 'dlx') next += 1; + else if (name === 'npm' && (words[next] === 'exec' || words[next] === 'x')) next += 1; + else if (!PACKAGE_RUNNERS.has(name)) return undefined; + while (next < words.length && words[next] !== '--' && words[next]!.startsWith('-')) next += 1; + const target = words[next]; + if (!target) return undefined; + if (target === '--') return undefined; + return BY_PACKAGE.get(packageName(target)) ?? BY_EXECUTABLE.get(target); +} + +/** The known agent a foreground process name identifies (e.g. from the PTY), if any. */ +export function detectAgentProcess(process: string | undefined): AgentDescriptor | undefined { + return process ? BY_EXECUTABLE.get(baseName(process)) : undefined; +} diff --git a/src/agents/harnesses.ts b/src/agents/harnesses.ts new file mode 100644 index 00000000..c460ee6c --- /dev/null +++ b/src/agents/harnesses.ts @@ -0,0 +1,51 @@ +/** + * The terminal agent harnesses NMSh knows, as data. Identity (executables, + * package-runner names), presentation (glyph, safe glyph, accent) and which + * control adapter exists live here; activity statistics, process discovery, + * /ai, /sessions and the activity shelf all read this one registry, so a new + * harness is a descriptor (plus an adapter where a supported protocol exists) + * rather than changes across NMSh. NMSh never talks to model APIs itself. + */ +export interface HarnessDescriptor { + id: string; + /** Product name as its vendor writes it. */ + name: string; + /** Short form for compact status lines. */ + short: string; + /** Executable names that identify it (process name or program word). */ + executables: readonly string[]; + /** Package specifiers recognized after a package runner (npx, bunx, pnpm dlx). */ + packages: readonly string[]; + /** Truecolor accent used only when color is allowed. */ + color: string; + glyph: string; + safeGlyph: string; + /** The supported machine interface NMSh can drive, when one is implemented. */ + control?: 'claude-stream-json'; + /** Truthful note when there is no control adapter (shown instead of an input box). */ + controlNote?: string; +} + +export const HARNESSES: readonly HarnessDescriptor[] = [ + {id: 'claude', name: 'Claude Code', short: 'Claude', executables: ['claude'], packages: ['@anthropic-ai/claude-code'], color: '#d97757', glyph: '✻', safeGlyph: '*', + control: 'claude-stream-json'}, + {id: 'codex', name: 'Codex CLI', short: 'Codex', executables: ['codex'], packages: ['@openai/codex'], color: '#10a37f', glyph: '◇', safeGlyph: '<>', + controlNote: 'NMSh does not drive Codex\'s app-server protocol yet; running Codex sessions are shown, not controlled.'}, + {id: 'pi', name: 'Pi', short: 'Pi', executables: ['pi'], packages: ['@mariozechner/pi-coding-agent'], color: '#8fb3ff', glyph: 'π', safeGlyph: 'pi', + controlNote: 'NMSh does not drive Pi\'s RPC mode yet; running Pi sessions are shown, not controlled.'}, + {id: 'gemini', name: 'Gemini CLI', short: 'Gemini', executables: ['gemini'], packages: ['@google/gemini-cli'], color: '#7b9cff', glyph: '✧', safeGlyph: 'g', + controlNote: 'NMSh has no supported control channel for Gemini CLI; its sessions are shown, not controlled.'}, + {id: 'aider', name: 'Aider', short: 'Aider', executables: ['aider'], packages: [], color: '#58c48f', glyph: '⌁', safeGlyph: 'a', + controlNote: 'NMSh has no supported control channel for Aider; its sessions are shown, not controlled.'}, + {id: 'opencode', name: 'OpenCode', short: 'OpenCode', executables: ['opencode'], packages: ['opencode-ai'], color: '#c9a86a', glyph: '◎', safeGlyph: 'oc', + controlNote: 'NMSh does not drive OpenCode\'s server API yet; its sessions are shown, not controlled.'}, + {id: 'qwen', name: 'Qwen Code', short: 'Qwen', executables: ['qwen'], packages: ['@qwen-code/qwen-code'], color: '#9b8cf0', glyph: '◈', safeGlyph: 'q', + controlNote: 'NMSh has no supported control channel for Qwen Code; its sessions are shown, not controlled.'}, +]; + +const BY_ID = new Map(HARNESSES.map(harness => [harness.id, harness])); +export const harness = (id: string): HarnessDescriptor | undefined => BY_ID.get(id); +export const isHarnessId = (id: unknown): id is string => typeof id === 'string' && BY_ID.has(id); +const BY_EXECUTABLE = new Map(HARNESSES.flatMap(item => item.executables.map(name => [name, item] as const))); +/** The harness an exact process/program name identifies; never a fuzzy match. */ +export const harnessForExecutable = (name: string): HarnessDescriptor | undefined => BY_EXECUTABLE.get(name); diff --git a/src/agents/sessions/AgentViews.ts b/src/agents/sessions/AgentViews.ts new file mode 100644 index 00000000..296fbea4 --- /dev/null +++ b/src/agents/sessions/AgentViews.ts @@ -0,0 +1,221 @@ +import {signatureAccent} from '../../session/signatures.js'; +import {renderControls} from '../../ui/controls.js'; +import {getCurrentGlyphMode, GLYPHS} from '../../ui/glyphs.js'; +import {foreground, UI_COLORS} from '../../ui/palette.js'; +import {colorLevel} from '../../presentation/capabilities.js'; +import {displayWidth, padCells, truncateAnsi} from '../../util/text.js'; +import {harness, type HarnessDescriptor} from '../harnesses.js'; +import type {AgentEvent, AgentSession} from './model.js'; + +/** + * Agent session presentation: the transient shelf above the composer, the + * /ai panel, and the agent session view. Text never changes width while a + * state animates; provider identity reads without color (name + glyph). + */ + +const RESET = '\u001b[0m'; +const glyphOf = (descriptor: HarnessDescriptor | undefined) => (getCurrentGlyphMode() === 'safe' ? descriptor?.safeGlyph : descriptor?.glyph) ?? '*'; +const accent = (descriptor: HarnessDescriptor | undefined) => descriptor && colorLevel() !== 'none' ? foreground(hex(descriptor.color)) : ''; +function hex(value: string) { return {red: parseInt(value.slice(1, 3), 16), green: parseInt(value.slice(3, 5), 16), blue: parseInt(value.slice(5, 7), 16)}; } + +export function elapsedLabel(ms: number): string { + const seconds = Math.max(0, Math.floor(ms / 1000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`; +} + +/** Factual state words; activity only when a harness event supplied it. */ +export function stateLabel(session: AgentSession, now: number): string { + switch (session.state) { + case 'starting': return 'starting'; + case 'working': return session.activity ? `${session.activity.toLowerCase().startsWith('edit') ? 'editing' : session.activity.toLowerCase().startsWith('read') ? 'reading' : 'working'}${session.activity.includes(' ') ? ` ${session.activity.slice(session.activity.indexOf(' ') + 1)}` : ''}` : `working ${elapsedLabel(now - session.startedAt)}`; + case 'approval': return 'needs approval'; + case 'waiting': return 'waiting for you'; + case 'finished': return 'finished'; + case 'failed': return 'failed'; + case 'exited': return 'ended'; + case 'running': return `running ${elapsedLabel(now - session.startedAt)}`; + } +} + +/** Shelf order: what needs attention first, then newest. */ +export function shelfOrder(sessions: readonly AgentSession[]): AgentSession[] { + return [...sessions].sort((a, b) => Number(b.attention) - Number(a.attention) || b.updatedAt - a.updatedAt); +} + +/** One compact row: "✻ Claude · working 4m ◇ Codex · waiting", narrowing to "✻ Claude · working · +1", then "* Claude". */ +export function renderShelf(sessions: readonly AgentSession[], columns: number, now: number, selected?: number, focused = false): string { + const items = shelfOrder(sessions); + if (!items.length) return ''; + const subtle = foreground(UI_COLORS.subtle); + const primary = foreground(UI_COLORS.primary); + const marker = foreground(UI_COLORS.accent); + const cell = (session: AgentSession, index: number, withTitle: boolean) => { + const descriptor = harness(session.harness); + const chosen = focused && index === selected; + const title = withTitle && session.level === 'managed' && !session.title.includes(' · ') ? ` · ${session.title}` : ''; + const attention = session.attention ? (getCurrentGlyphMode() === 'safe' ? '! ' : '◆ ') : ''; + return `${chosen ? `${marker}${GLYPHS.selection} ${RESET}` : ''}${signatureChip(session.signature)}${accent(descriptor)}${glyphOf(descriptor)}${RESET} ${chosen ? primary : ''}${descriptor?.short ?? session.harness}${RESET}${subtle}${title} · ${attention}${stateLabel(session, now)}${RESET}`; + }; + for (const withTitle of [true, false]) { + const row = items.map((session, index) => cell(session, index, withTitle)).join(' '); + if (displayWidth(row) <= columns) return row; + } + const more = items.length > 1 ? `${subtle} · +${items.length - 1}${RESET}` : ''; + const first = cell(items[0]!, 0, false); + if (displayWidth(first + more) <= columns) return first + more; + // Narrow: the state word only ("✻ Claude · editing · +1"). + const head = items[0]!; + const descriptor = harness(head.harness); + const short = `${accent(descriptor)}${glyphOf(descriptor)}${RESET} ${descriptor?.short ?? head.harness}${subtle} · ${head.attention ? (getCurrentGlyphMode() === 'safe' ? '! ' : '◆ ') : ''}${stateLabel(head, now).split(' ')[0]}${RESET}`; + if (displayWidth(short + more) <= columns) return short + more; + return truncateAnsi(`${glyphOf(descriptor)} ${descriptor?.short ?? head.harness}`, columns); +} + +export interface AgentPanelState { + selected: number; + /** Renaming the selected session: the draft title. */ + rename?: string; + message?: string; +} + +export type AgentPanelRow = {kind: 'session'; session: AgentSession} | {kind: 'harness'; harness: HarnessDescriptor; executable?: string; controllable: boolean}; + +export function agentPanelRows(sessions: readonly AgentSession[], harnesses: ReadonlyArray<{harness: HarnessDescriptor; executable?: string; controllable: boolean}>): AgentPanelRow[] { + return [...shelfOrder(sessions).map(session => ({kind: 'session' as const, session})), ...harnesses.map(item => ({kind: 'harness' as const, ...item}))]; +} + +const levelLabel = (session: AgentSession) => session.level === 'managed' ? 'Managed' : session.level === 'attachable' ? 'Attachable' : 'Observed only'; + +export function renderAgentPanel(state: AgentPanelState, rows: readonly AgentPanelRow[], columns: number, now: number, height = Infinity): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const marker = foreground(UI_COLORS.accent); + const out = [`${primary} Agent sessions${RESET} ${subtle}external agent harnesses · NMSh launches and supervises; the harness owns models, auth and tools${RESET}`, '']; + const sessions = rows.filter(row => row.kind === 'session'); + if (!sessions.length) out.push(` ${subtle}No agent sessions yet. Choose a harness below to start one in the background.${RESET}`); + const nameWidth = 14; + rows.forEach((row, index) => { + if (row.kind === 'harness' && (index === 0 || rows[index - 1]!.kind === 'session')) out.push('', `${primary} Harnesses${RESET}`); + const pick = index === state.selected ? `${marker}${GLYPHS.selection}${RESET}` : ' '; + if (row.kind === 'session') { + const descriptor = harness(row.session.harness); + const where = [row.session.cwd ? row.session.cwd.replace(/^\/(?:Users|home)\/[^/]+/u, '~') : undefined, row.session.tty, row.session.pid ? `pid ${row.session.pid}` : undefined].filter(Boolean).join(' · '); + const title = state.rename !== undefined && index === state.selected ? `${state.rename}${marker}▏${RESET}` : row.session.title; + out.push(`${pick} ${signatureChip(row.session.signature, 8)}${accent(descriptor)}${glyphOf(descriptor)}${RESET} ${padCells(`${primary}${descriptor?.short ?? row.session.harness}${RESET}`, nameWidth)}${secondary}${title}${RESET} ${subtle}${row.session.attention ? '◆ ' : ''}${stateLabel(row.session, now)} · ${levelLabel(row.session)}${where ? ` · ${where}` : ''}${RESET}`); + } else { + const status = !row.executable ? 'not installed' : row.controllable ? 'installed · Enter starts a managed session' : 'installed · observed only (no supported control channel yet)'; + out.push(`${pick} ${accent(row.harness)}${glyphOf(row.harness)}${RESET} ${padCells(`${primary}${row.harness.name}${RESET}`, nameWidth)}${subtle}${status}${RESET}`); + } + }); + if (state.message) out.push('', ` ${secondary}${state.message}${RESET}`); + out.push('', renderControls(state.rename !== undefined ? [['Enter', 'rename'], ['Esc', 'cancel']] : [['↑↓', 'select'], ['Enter', 'open / start'], ['R', 'rename'], ['A', 'agent usage (/agents)'], ['Esc', 'back']])); + const bounded = Number.isFinite(height) && out.length > height ? [...out.slice(0, 2), ...out.slice(out.length - (height - 2))] : out; + return bounded.map(line => truncateAnsi(line, columns)); +} + +export interface AgentViewState { + sessionId: string; + input: string; + /** Tool blocks the person expanded (Ctrl+O); tool detail starts folded. */ + expanded: Set; + /** Rows scrolled up from the newest. */ + scroll: number; + message?: string; +} + +/** The visible transcript of a session as plain-text blocks: what /copy and the view share. */ +export function agentBlocks(session: AgentSession): Array<{kind: 'user' | 'assistant' | 'tool' | 'note'; text: string; id?: string; detail?: string}> { + const blocks: Array<{kind: 'user' | 'assistant' | 'tool' | 'note'; text: string; id?: string; detail?: string}> = []; + const tools = new Map(); + for (const event of session.events) { + if (event.kind === 'user') blocks.push({kind: 'user', text: event.text}); + else if (event.kind === 'assistant') { + const last = blocks.at(-1); + if (last?.kind === 'assistant') last.text += `\n${event.text}`; else blocks.push({kind: 'assistant', text: event.text}); + } else if (event.kind === 'tool') { + const existing = tools.get(event.id); + if (existing) { + if (event.detail) existing.detail = event.detail; + if (event.status === 'failed') existing.text += ' · failed'; + } else { + const block = {kind: 'tool' as const, text: `${event.name}${event.target ? ` ${event.target}` : ''}`, id: event.id, ...(event.detail ? {detail: event.detail} : {})}; + tools.set(event.id, block); + blocks.push(block); + } + } else if (event.kind === 'approval') blocks.push({kind: 'note', text: `Approval requested: ${event.tool}${event.target ? ` ${event.target}` : ''}`}); + else if (event.kind === 'approvalAnswered') blocks.push({kind: 'note', text: event.allowed ? 'You allowed it.' : 'You denied it.'}); + else if (event.kind === 'settled') blocks.push({kind: 'note', text: event.ok ? 'Finished; waiting for you.' : `Run failed${event.message ? ` (${event.message})` : ''}.`}); + else if (event.kind === 'exited') blocks.push({kind: 'note', text: `The harness process ended${event.code ? ` (exit ${event.code})` : ''}.`}); + } + return blocks; +} + +function wrap(text: string, width: number): string[] { + const out: string[] = []; + for (const paragraph of text.split('\n')) { + let line = ''; + for (const word of paragraph.split(/(\s+)/u)) { + if (line && displayWidth(line + word) > width && word.trim()) { out.push(line.trimEnd()); line = word.trimStart(); } else line += word; + } + out.push(line.trimEnd()); + } + return out; +} + +export function renderAgentView(session: AgentSession, state: AgentViewState, columns: number, height: number, now: number): string[] { + const descriptor = harness(session.harness); + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const marker = foreground(UI_COLORS.accent); + const width = Math.max(20, columns - 4); + const header = [`${signatureChip(session.signature)}${accent(descriptor)}${glyphOf(descriptor)}${RESET} ${primary}${descriptor?.short ?? session.harness} · ${session.title}${RESET}`, + ` ${subtle}${session.cwd ? `${session.cwd.replace(/^\/(?:Users|home)\/[^/]+/u, '~')} · ` : ''}${stateLabel(session, now)}${/\d+[smh]$/u.test(stateLabel(session, now)) ? '' : ` · ${elapsedLabel(now - session.startedAt)}`} · ${session.level === 'managed' ? 'Managed' : 'Observed only'}${RESET}`, '']; + const body: string[] = []; + if (session.level === 'observed') { + body.push(...wrap(`NMSh can see this ${descriptor?.name ?? session.harness} process but has no supported way to read or control its conversation.`, width - 2).map(line => ` ${secondary}${line}${RESET}`), + ` ${subtle}${[session.tty ? `terminal ${session.tty}` : undefined, session.pid ? `pid ${session.pid}` : undefined, `running ${elapsedLabel(now - session.startedAt)}`].filter(Boolean).join(' · ')}${RESET}`); + if (descriptor?.controlNote) body.push(...wrap(descriptor.controlNote, width - 2).map(line => ` ${subtle}${line}${RESET}`)); + } else { + for (const block of agentBlocks(session)) { + if (block.kind === 'user') body.push(` ${subtle}You${RESET}`, ...wrap(block.text, width - 2).map(line => ` ${primary}${line}${RESET}`), ''); + else if (block.kind === 'assistant') body.push(` ${accent(descriptor)}${descriptor?.short ?? 'Agent'}${RESET}`, ...wrap(block.text, width - 2).map(line => ` ${secondary}${line}${RESET}`), ''); + else if (block.kind === 'note') body.push(` ${subtle}${block.text}${RESET}`, ''); + else { + body.push(` ${secondary}${block.text}${RESET}`); + const lines = block.detail ? block.detail.split('\n') : []; + if (lines.length) { + if (state.expanded.has(block.id!)) body.push(...lines.slice(0, 400).map(line => ` ${subtle}${line}${RESET}`)); + else body.push(` ${subtle}› ${lines.length} detail line${lines.length === 1 ? '' : 's'} hidden · Ctrl+O${RESET}`); + } + } + } + } + const footer: string[] = ['']; + if (session.pendingApproval) { + footer.push(` ${marker}${descriptor?.short ?? 'The agent'} asks to use ${session.pendingApproval.tool}${session.pendingApproval.target ? ` on ${session.pendingApproval.target}` : ''}.${RESET}`, + ` ${primary}[A] Allow once${RESET} ${primary}[D] Deny${RESET} ${subtle}NMSh never answers for you.${RESET}`); + } + if (session.level === 'managed' && session.state !== 'exited' && session.state !== 'failed') { + footer.push(` ${marker}›${RESET} ${state.input ? `${primary}${state.input.split('\n')[0]}${RESET}` : `${subtle}type to ${descriptor?.short ?? 'the agent'}… (input goes to the agent, not the shell)${RESET}`}${marker}▏${RESET}`); + } + if (state.message) footer.push(` ${secondary}${state.message}${RESET}`); + footer.push('', renderControls([...(session.level === 'managed' ? [['Enter', 'send'] as [string, string], ['Ctrl+O', 'details'] as [string, string]] : []), + ...(session.state === 'working' ? [['Ctrl+C', 'interrupt'] as [string, string]] : []), ['PgUp/PgDn', 'scroll'], ['Esc', 'back to shell']])); + const room = Math.max(3, height - header.length - footer.length); + state.scroll = Math.max(0, Math.min(state.scroll, Math.max(0, body.length - room))); + const end = body.length - state.scroll; + const visible = body.slice(Math.max(0, end - room), end); + return [...header, ...visible, ...footer].map(line => truncateAnsi(line, columns)); +} + +/** A session's familiar name with its subtle accent (plain text without color); the name itself is the cue. */ +export function signatureChip(signature: string | undefined, width = 0): string { + if (!signature) return ''; + const accent = signatureAccent(signature); + const text = width ? signature.padEnd(width) : signature; + return `${accent ? foreground(accent) : ''}${text}\u001b[0m `; +} diff --git a/src/agents/sessions/claudeAdapter.ts b/src/agents/sessions/claudeAdapter.ts new file mode 100644 index 00000000..13cb7dd0 --- /dev/null +++ b/src/agents/sessions/claudeAdapter.ts @@ -0,0 +1,206 @@ +import {spawn, spawnSync, type ChildProcess} from 'node:child_process'; +import {randomUUID} from 'node:crypto'; +import type {AgentEvent} from './model.js'; + +/** + * Claude Code through its own machine interface: `--print` with stream-json + * input and output (the interface the installed CLI documents in --help). + * NMSh answers permission prompts as the host only when the person chooses; + * it never approves on its own, and anything it does not understand is + * denied. No TUI is parsed and no model API is called by NMSh. + */ + +export interface ClaudeCapabilities { + streamJson: boolean; + /** `--permission-prompts host`: NMSh answers each prompt; otherwise prompts are denied by the harness. */ + hostPermissions: boolean; + resume: boolean; + sessionId: boolean; +} + +const capabilityCache = new Map(); + +/** Read the installed CLI's own --help (local, bounded) and keep only what it documents. */ +export function claudeCapabilities(executable: string, help?: string): ClaudeCapabilities { + const cached = help === undefined ? capabilityCache.get(executable) : undefined; + if (cached) return cached; + const text = help ?? (spawnSync(executable, ['--help'], {encoding: 'utf8', timeout: 5000, maxBuffer: 512 * 1024}).stdout ?? ''); + const capabilities = { + streamJson: /--input-format[\s\S]*stream-json/u.test(text) && /--output-format[\s\S]*stream-json/u.test(text) && /--print\b/u.test(text), + hostPermissions: /--permission-prompts[\s\S]{0,200}"host"/u.test(text), + resume: /--resume\b/u.test(text), + sessionId: /--session-id\b/u.test(text), + }; + if (help === undefined) capabilityCache.set(executable, capabilities); + return capabilities; +} + +/** The factual target of a tool call from its input: a path, a command or a pattern, never invented. */ +function toolTarget(input: unknown): string | undefined { + if (!input || typeof input !== 'object') return undefined; + const record = input as Record; + for (const key of ['file_path', 'path', 'notebook_path', 'command', 'pattern', 'url', 'description']) { + const value = record[key]; + if (typeof value === 'string' && value.trim()) return value.split('\n')[0]!.slice(0, 160); + } + return undefined; +} + +const textOf = (content: unknown): string => typeof content === 'string' ? content + : Array.isArray(content) ? content.map(part => (part && typeof part === 'object' && (part as {type?: unknown}).type === 'text' ? String((part as {text?: unknown}).text ?? '') : '')).join('') : ''; + +/** One stdout line → normalized events (unknown shapes produce nothing). */ +export function claudeEvents(line: string): {events: AgentEvent[]; control?: {requestId: string; subtype: string; tool?: string; input?: unknown}} { + let message: Record; + try { message = JSON.parse(line) as Record; } catch { return {events: []}; } + const type = message.type; + if (type === 'system' && message.subtype === 'init') return {events: [{kind: 'started', ...(typeof message.session_id === 'string' ? {harnessSessionId: message.session_id} : {})}]}; + if (type === 'assistant') { + const content = (message.message as {content?: unknown} | undefined)?.content; + const events: AgentEvent[] = []; + for (const part of Array.isArray(content) ? content : []) { + const block = part as {type?: unknown; text?: unknown; id?: unknown; name?: unknown; input?: unknown}; + if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) events.push({kind: 'assistant', text: block.text}); + else if (block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') { + const target = toolTarget(block.input); + events.push({kind: 'tool', id: block.id, name: block.name, ...(target ? {target} : {}), status: 'started'}); + } + } + return {events}; + } + if (type === 'user') { + const content = (message.message as {content?: unknown} | undefined)?.content; + const events: AgentEvent[] = []; + for (const part of Array.isArray(content) ? content : []) { + const block = part as {type?: unknown; tool_use_id?: unknown; is_error?: unknown; content?: unknown}; + if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') { + const detail = textOf(block.content).slice(0, 8000); + events.push({kind: 'tool', id: block.tool_use_id, name: '', status: block.is_error === true ? 'failed' : 'finished', ...(detail ? {detail} : {})}); + } + } + return {events}; + } + if (type === 'result') { + const ok = message.is_error !== true && (message.subtype === undefined || message.subtype === 'success'); + return {events: [{kind: 'settled', ok, ...(!ok && typeof message.subtype === 'string' ? {message: message.subtype} : {})}]}; + } + if (type === 'control_request' && typeof message.request_id === 'string') { + const request = message.request as {subtype?: unknown; tool_name?: unknown; input?: unknown} | undefined; + const subtype = typeof request?.subtype === 'string' ? request.subtype : 'unknown'; + const tool = typeof request?.tool_name === 'string' ? request.tool_name : undefined; + const target = toolTarget(request?.input); + return {events: subtype === 'can_use_tool' && tool ? [{kind: 'approval', requestId: message.request_id, tool, ...(target ? {target} : {})}] : [], + control: {requestId: message.request_id, subtype, ...(tool ? {tool} : {}), input: request?.input}}; + } + return {events: []}; +} + +export interface ClaudeSessionOptions { + executable: string; + cwd: string; + /** Resume this harness session id (only when the CLI documents --resume). */ + resume?: string; + /** Extra provider-approved args from a launch profile (validated by the caller). */ + args?: readonly string[]; + env?: NodeJS.ProcessEnv; + onEvent(event: AgentEvent): void; +} + +/** One managed Claude Code process for one conversation. */ +export class ClaudeSession { + private child?: ChildProcess; + private buffer = ''; + private queue: string[] = []; + private writable = true; + private readonly pending = new Map(); + readonly capabilities: ClaudeCapabilities; + + constructor(private readonly options: ClaudeSessionOptions, help?: string) { + this.capabilities = claudeCapabilities(options.executable, help); + } + + get pid(): number | undefined { return this.child?.pid; } + + start(): {ok: true} | {ok: false; reason: string} { + if (!this.capabilities.streamJson) return {ok: false, reason: 'This Claude Code version does not document stream-json input and output, so NMSh cannot drive it.'}; + const args = ['--print', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', + // Host prompts let the person answer in NMSh; without them, anything that would prompt is denied by Claude itself. + ...(this.capabilities.hostPermissions ? ['--permission-prompts', 'host'] : []), + ...(this.options.resume && this.capabilities.resume ? ['--resume', this.options.resume] : this.capabilities.sessionId ? ['--session-id', randomUUID()] : []), + ...(this.options.args ?? [])]; + try { + this.child = spawn(this.options.executable, args, {cwd: this.options.cwd, env: this.options.env ?? process.env, stdio: ['pipe', 'pipe', 'ignore']}); + } catch (error) { return {ok: false, reason: error instanceof Error ? error.message : String(error)}; } + const child = this.child; + child.stdout!.setEncoding('utf8'); + child.stdout!.on('data', (chunk: string) => this.read(chunk)); + child.stdin!.on('drain', () => { this.writable = true; this.flush(); }); + child.stdin!.on('error', () => { /* the exit event reports it */ }); + child.on('error', () => this.options.onEvent({kind: 'exited', code: -1})); + child.on('exit', code => { this.child = undefined; this.options.onEvent({kind: 'exited', code}); }); + return {ok: true}; + } + + private read(chunk: string): void { + this.buffer += chunk; + if (this.buffer.length > 8 * 1024 * 1024) { this.buffer = ''; return; } + let newline: number; + while ((newline = this.buffer.indexOf('\n')) >= 0) { + const line = this.buffer.slice(0, newline).trim(); + this.buffer = this.buffer.slice(newline + 1); + if (!line) continue; + const {events, control} = claudeEvents(line); + for (const event of events) this.options.onEvent(event); + if (control) { + if (control.subtype === 'can_use_tool' && control.tool) this.pending.set(control.requestId, {input: control.input}); + // Requests NMSh does not implement are refused rather than ignored, so the harness never waits on us. + else this.write({type: 'control_response', response: {subtype: 'error', request_id: control.requestId, error: 'Not supported by NMSh'}}); + } + } + } + + private write(message: unknown): void { + this.queue.push(`${JSON.stringify(message)}\n`); + this.flush(); + } + + /** Honor stdin backpressure: write until the pipe asks to wait. */ + private flush(): void { + const stdin = this.child?.stdin; + while (stdin && this.writable && this.queue.length) this.writable = stdin.write(this.queue.shift()!); + } + + send(text: string): boolean { + if (!this.child) return false; + this.options.onEvent({kind: 'user', text}); + this.write({type: 'user', message: {role: 'user', content: [{type: 'text', text}]}}); + return true; + } + + /** Answer one pending permission prompt with the person's explicit choice; never automatic. */ + answer(requestId: string, allow: boolean): boolean { + const pending = this.pending.get(requestId); + if (!pending || !this.child) return false; + this.pending.delete(requestId); + this.write({type: 'control_response', response: {subtype: 'success', request_id: requestId, + response: allow ? {behavior: 'allow', updatedInput: pending.input ?? {}} : {behavior: 'deny', message: 'Denied in NMSh.'}}}); + this.options.onEvent({kind: 'approvalAnswered', requestId, allowed: allow}); + return true; + } + + /** Interrupt the current run (the harness's own interrupt request; SIGINT if it does not settle). */ + cancel(): void { + if (!this.child) return; + this.write({type: 'control_request', request_id: randomUUID(), request: {subtype: 'interrupt'}}); + const child = this.child; + setTimeout(() => { if (this.child === child) child.kill('SIGINT'); }, 3000).unref(); + } + + close(): void { + const child = this.child; + if (!child) return; + child.stdin?.end(); + setTimeout(() => { if (this.child === child) child.kill('SIGTERM'); }, 2000).unref(); + setTimeout(() => { if (this.child === child) child.kill('SIGKILL'); }, 5000).unref(); + } +} diff --git a/src/agents/sessions/discovery.ts b/src/agents/sessions/discovery.ts new file mode 100644 index 00000000..b73234ee --- /dev/null +++ b/src/agents/sessions/discovery.ts @@ -0,0 +1,76 @@ +import {execFile} from 'node:child_process'; +import {readlinkSync} from 'node:fs'; +import {harnessForExecutable} from '../harnesses.js'; + +/** + * System-wide discovery of known agent processes from process metadata only: + * pid, parent, terminal, elapsed time and executable name. Command-line + * arguments are never read (they can hold prompts or secrets). A process is an + * agent only when its executable name is in the harness registry. + */ + +export interface ProcessRow {pid: number; ppid: number; tty?: string; elapsedMs: number; name: string} +export interface DiscoveredAgent {pid: number; harness: string; tty?: string; startedAt: number; cwd?: string} + +/** `[[dd-]hh:]mm:ss` → milliseconds. */ +export function parseElapsed(value: string): number | undefined { + const match = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/u.exec(value.trim()); + if (!match) return undefined; + const [, days, hours, minutes, seconds] = match; + return (((Number(days ?? 0) * 24 + Number(hours ?? 0)) * 60 + Number(minutes)) * 60 + Number(seconds)) * 1000; +} + +/** Rows of `ps -A -o pid=,ppid=,tty=,etime=,comm=` (comm is the executable, never its arguments). */ +export function parsePs(output: string, limit = 20_000): ProcessRow[] { + const rows: ProcessRow[] = []; + for (const line of output.split('\n').slice(0, limit)) { + const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.+?)\s*$/u.exec(line); + if (!match) continue; + const elapsedMs = parseElapsed(match[4]!); + if (elapsedMs === undefined) continue; + const command = match[5]!; + const name = command.slice(command.lastIndexOf('/') + 1).replace(/^-/u, ''); + rows.push({pid: Number(match[1]), ppid: Number(match[2]), ...(match[3] !== '?' && match[3] !== '??' ? {tty: match[3]} : {}), elapsedMs, name}); + } + return rows; +} + +/** + * Known agents among process rows. A harness that spawns helpers with the + * same name counts once (the top-most matching ancestor); `exclude` drops + * processes NMSh already manages so one agent never appears twice. + */ +export function findAgents(rows: readonly ProcessRow[], now: number, exclude: ReadonlySet = new Set(), cwdOf?: (pid: number) => string | undefined): DiscoveredAgent[] { + const byPid = new Map(rows.map(row => [row.pid, row])); + const agents: DiscoveredAgent[] = []; + for (const row of rows) { + const harness = harnessForExecutable(row.name); + if (!harness || exclude.has(row.pid)) continue; + // Skip when an ancestor is already an agent (same harness helper) or is managed by NMSh. + let parent = byPid.get(row.ppid); + let nested = false; + for (let depth = 0; parent && depth < 32; depth += 1) { + if (exclude.has(parent.pid) || harnessForExecutable(parent.name)) { nested = true; break; } + parent = byPid.get(parent.ppid); + } + if (nested) continue; + const cwd = cwdOf?.(row.pid); + agents.push({pid: row.pid, harness: harness.id, ...(row.tty ? {tty: row.tty} : {}), startedAt: now - row.elapsedMs, ...(cwd ? {cwd} : {})}); + } + return agents; +} + +/** Linux exposes a process's cwd as a symlink we may read; elsewhere it stays unknown rather than being guessed. */ +export function procCwd(pid: number): string | undefined { + if (process.platform !== 'linux') return undefined; + try { return readlinkSync(`/proc/${pid}/cwd`); } catch { return undefined; } +} + +/** One bounded, asynchronous scan (never on the input path). */ +export function scanAgents(exclude: ReadonlySet = new Set(), now = Date.now()): Promise { + return new Promise(resolve => { + execFile('ps', ['-A', '-o', 'pid=,ppid=,tty=,etime=,comm='], {timeout: 3000, maxBuffer: 4 * 1024 * 1024}, (error, stdout) => { + resolve(error ? [] : findAgents(parsePs(stdout), now, exclude, procCwd)); + }); + }); +} diff --git a/src/agents/sessions/manager.ts b/src/agents/sessions/manager.ts new file mode 100644 index 00000000..d10a6a16 --- /dev/null +++ b/src/agents/sessions/manager.ts @@ -0,0 +1,181 @@ +import {assignSignature} from '../../session/signatures.js'; +import {basename, isAbsolute} from 'node:path'; +import {resolveCommand} from '../../providers/providers.js'; +import {harness, HARNESSES, type HarnessDescriptor} from '../harnesses.js'; +import {ClaudeSession} from './claudeAdapter.js'; +import {scanAgents, type DiscoveredAgent} from './discovery.js'; +import {pushEvent, titleFromPrompt, type AgentEvent, type AgentSession} from './model.js'; + +/** + * Launch profiles: provider-specific, non-secret launch settings. Only fields + * an adapter supports are kept; credentials, tokens and arbitrary shell text + * are never stored (authentication stays with the harness). + */ +export interface AgentProfile { + name: string; + harness: string; + label?: string; + /** Claude: --model value. */ + model?: string; + /** Claude: --permission-mode, one of the modes the CLI documents (never bypassPermissions). */ + permissionMode?: 'acceptEdits' | 'auto' | 'manual' | 'dontAsk' | 'plan'; + /** Claude: CLAUDE_CONFIG_DIR, the harness's own config/account directory selector. */ + configDir?: string; +} + +const PERMISSION_MODES = new Set(['acceptEdits', 'auto', 'manual', 'dontAsk', 'plan']); + +export function normalizeProfiles(value: unknown): AgentProfile[] { + if (!Array.isArray(value)) return []; + const profiles: AgentProfile[] = []; + for (const raw of value.slice(0, 32)) { + const item = raw as Record; + if (typeof item?.name !== 'string' || !/^[\w.-]{1,40}$/u.test(item.name) || typeof item.harness !== 'string' || !harness(item.harness)) continue; + if (profiles.some(profile => profile.name === item.name)) continue; + const profile: AgentProfile = {name: item.name, harness: item.harness}; + if (typeof item.label === 'string') profile.label = item.label.slice(0, 40); + if (item.harness === 'claude') { + if (typeof item.model === 'string' && /^[\w.:@-]{1,64}$/u.test(item.model)) profile.model = item.model; + if (typeof item.permissionMode === 'string' && PERMISSION_MODES.has(item.permissionMode)) profile.permissionMode = item.permissionMode as AgentProfile['permissionMode']; + if (typeof item.configDir === 'string' && isAbsolute(item.configDir) && !/[\n\u0000]/u.test(item.configDir)) profile.configDir = item.configDir; + } + profiles.push(profile); + } + return profiles; +} + +export type LaunchResult = {ok: true; session: AgentSession} | {ok: false; reason: string}; + +export interface AgentEnvironment { + resolve(executable: string): string | undefined; + scan(exclude: ReadonlySet): Promise; + now(): number; + /** Claude's --help text (tests), else the installed CLI is asked. */ + claudeHelp?: string; +} + +export const systemAgentEnvironment: AgentEnvironment = {resolve: name => resolveCommand(name), scan: exclude => scanAgents(exclude), now: () => Date.now()}; + +let ordinal = 0; + +export class AgentSessions { + readonly sessions: AgentSession[] = []; + private readonly controls = new Map(); + private listeners = new Set<(session?: AgentSession, event?: AgentEvent) => void>(); + scanning = false; + + constructor(private readonly env: AgentEnvironment = systemAgentEnvironment) {} + + onChange(listener: (session?: AgentSession, event?: AgentEvent) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + private emit(session?: AgentSession, event?: AgentEvent): void { for (const listener of this.listeners) listener(session, event); } + + get(id: string): AgentSession | undefined { return this.sessions.find(session => session.id === id); } + + /** Installed and controllable state of every registered harness, for /ai. */ + harnesses(): Array<{harness: HarnessDescriptor; executable?: string; controllable: boolean}> { + return HARNESSES.map(item => { + const executable = item.executables.map(name => this.env.resolve(name)).find(Boolean); + return {harness: item, ...(executable ? {executable} : {}), controllable: Boolean(executable && item.control)}; + }); + } + + /** Start a managed session in the background; the shell stays where it is. */ + launch(harnessId: string, cwd: string, options: {prompt?: string; profile?: AgentProfile} = {}): LaunchResult { + const descriptor = harness(harnessId); + if (!descriptor) return {ok: false, reason: `NMSh doesn't know a harness called ${harnessId}.`}; + const executable = descriptor.executables.map(name => this.env.resolve(name)).find(Boolean); + if (!executable) return {ok: false, reason: `${descriptor.name} is not installed (${descriptor.executables.join(', ')} not found). NMSh has no verified install recipe for it; install it from its official instructions.`}; + if (descriptor.control !== 'claude-stream-json') return {ok: false, reason: descriptor.controlNote ?? `NMSh has no supported control channel for ${descriptor.name}.`}; + ordinal += 1; + const now = this.env.now(); + const id = `agent-${now.toString(36)}-${ordinal}`; + const session: AgentSession = {id, harness: descriptor.id, level: 'managed', cwd, signature: assignSignature(id, this.sessions.flatMap(item => item.signature ? [item.signature] : [])), + title: options.profile?.label ?? `${basename(cwd) || descriptor.short} · ${descriptor.short} ${ordinal}`, startedAt: now, state: 'starting', events: [], attention: false, updatedAt: now}; + const profile = options.profile?.harness === descriptor.id ? options.profile : undefined; + const args = [...(profile?.model ? ['--model', profile.model] : []), ...(profile?.permissionMode ? ['--permission-mode', profile.permissionMode] : [])]; + const control = new ClaudeSession({executable, cwd, args, ...(profile?.configDir ? {env: {...process.env, CLAUDE_CONFIG_DIR: profile.configDir}} : {}), + onEvent: event => { pushEvent(session, event, this.env.now()); if (event.kind === 'exited') this.controls.delete(session.id); this.emit(session, event); }}, this.env.claudeHelp); + const started = control.start(); + if (!started.ok) return started; + session.pid = control.pid; + this.controls.set(session.id, control); + this.sessions.push(session); + if (options.prompt) this.send(session.id, options.prompt); + this.emit(session); + return {ok: true, session}; + } + + /** Text for a managed session; observed sessions never accept input. */ + send(id: string, text: string): boolean { + const session = this.get(id); + const control = this.controls.get(id); + if (!session || session.level === 'observed' || !control || !text.trim()) return false; + if (!session.events.some(event => event.kind === 'user') && session.title.includes(' · ')) session.title = titleFromPrompt(text, session.title); + return control.send(text); + } + + /** The person's explicit answer to a pending approval; NMSh never answers on its own. */ + answer(id: string, allow: boolean): boolean { + const session = this.get(id); + const pending = session?.pendingApproval; + const control = this.controls.get(id); + return Boolean(session && pending && control && control.answer(pending.requestId, allow)); + } + + cancel(id: string): boolean { const control = this.controls.get(id); control?.cancel(); return Boolean(control); } + + rename(id: string, title: string): boolean { + const session = this.get(id); + const clean = title.replace(/[\u0000-\u001f\u007f]/gu, '').trim().slice(0, 60); + if (!session || !clean) return false; + pushEvent(session, {kind: 'renamed', title: clean}, this.env.now()); + this.emit(session); + return true; + } + + /** The user saw it: normal auto-hide resumes. */ + acknowledge(id: string): void { + const session = this.get(id); + if (session?.attention) { session.attention = false; this.emit(session); } + } + + close(id: string): void { + this.controls.get(id)?.close(); + const index = this.sessions.findIndex(session => session.id === id); + if (index >= 0 && this.sessions[index]!.level !== 'managed') this.sessions.splice(index, 1); + } + + /** Merge a discovery scan: new observed agents appear, vanished ones go; managed processes are never duplicated. */ + async discover(): Promise { + if (this.scanning) return; + this.scanning = true; + try { + const managed = new Set(this.sessions.flatMap(session => session.level === 'managed' && session.pid ? [session.pid] : [])); + const found = await this.env.scan(managed); + const live = new Set(found.map(agent => agent.pid)); + let changed = false; + for (let index = this.sessions.length - 1; index >= 0; index -= 1) { + const session = this.sessions[index]!; + if (session.level === 'observed' && !live.has(session.pid!)) { this.sessions.splice(index, 1); changed = true; } + } + for (const agent of found) { + if (this.sessions.some(session => session.pid === agent.pid)) continue; + const descriptor = harness(agent.harness)!; + this.sessions.push({id: `observed-${agent.pid}`, harness: agent.harness, level: 'observed', title: descriptor.short, + signature: assignSignature(`observed-${agent.pid}`, this.sessions.flatMap(item => item.signature ? [item.signature] : [])), ...(agent.cwd ? {cwd: agent.cwd} : {}), + startedAt: agent.startedAt, state: 'running', pid: agent.pid, ...(agent.tty ? {tty: agent.tty} : {}), events: [], attention: false, updatedAt: this.env.now()}); + changed = true; + } + if (changed) this.emit(); + } finally { this.scanning = false; } + } + + dispose(): void { + for (const control of this.controls.values()) control.close(); + this.controls.clear(); + this.listeners.clear(); + } +} diff --git a/src/agents/sessions/model.ts b/src/agents/sessions/model.ts new file mode 100644 index 00000000..e560c4e6 --- /dev/null +++ b/src/agents/sessions/model.ts @@ -0,0 +1,86 @@ +/** + * Agent sessions: NMSh's view of sessions owned by external agent harnesses. + * An agent session is not a shell session and not a model connection: the + * harness owns models, auth, conversation, tools and permissions. Adapters + * turn each harness's own protocol into these normalized events. + */ + +/** managed: launched by NMSh with a supported control channel. attachable: NMSh can prove and rejoin it. observed: a known process, metadata only. */ +export type AgentLevel = 'managed' | 'attachable' | 'observed'; + +export type AgentState = 'starting' | 'working' | 'waiting' | 'approval' | 'finished' | 'failed' | 'running' | 'exited'; + +export type AgentEvent = + | {kind: 'started'; harnessSessionId?: string} + | {kind: 'user'; text: string} + | {kind: 'assistant'; text: string} + /** A tool call; `target` is a factual file/command from the harness event, never inferred. */ + | {kind: 'tool'; id: string; name: string; target?: string; status: 'started' | 'finished' | 'failed'; detail?: string} + | {kind: 'approval'; requestId: string; tool: string; target?: string} + | {kind: 'approvalAnswered'; requestId: string; allowed: boolean} + | {kind: 'settled'; ok: boolean; message?: string} + | {kind: 'exited'; code: number | null} + | {kind: 'renamed'; title: string}; + +export interface AgentSession { + /** NMSh's id for this session. */ + id: string; + harness: string; + level: AgentLevel; + title: string; + /** Familiar signature ("Autumn"), unique among this window's agent sessions; the title stays separately renamable. */ + signature?: string; + cwd?: string; + startedAt: number; + state: AgentState; + /** Factual activity ("Edit TerminalApp.ts") only from a harness tool event; otherwise undefined. */ + activity?: string; + pid?: number; + tty?: string; + /** The harness's own session id, from its protocol. */ + harnessSessionId?: string; + /** Bounded, in-memory visible transcript (managed sessions only). */ + events: AgentEvent[]; + pendingApproval?: {requestId: string; tool: string; target?: string}; + /** Meaningful news the user has not acknowledged (approval, waiting, finished, failed, exited). */ + attention: boolean; + updatedAt: number; +} + +export const MAX_AGENT_EVENTS = 2000; + +const FILLER = /^(?:(?:please|pls|can you|could you|would you|help me|i want to|i need to|let's|lets|go ahead and|now)\s+)+/iu; +const VERBS = /^(?:implement|add|fix|make|create|update|write|refactor|build|change|improve|investigate|look into|review|debug|set up|setup)\s+(?:the\s+|a\s+|an\s+|my\s+|our\s+)?/iu; + +/** A compact deterministic title from the first prompt: "implement the Ask chat viewport and fix folding" → "Ask chat viewport". */ +export function titleFromPrompt(prompt: string, fallback: string): string { + let text = prompt.split('\n')[0]!.trim().replace(FILLER, '').replace(VERBS, ''); + text = text.split(/\s+(?:and|then|so|because|but)\s+|[,.;:!?(]/u)[0]!.trim(); + const words = text.split(/\s+/u).filter(Boolean).slice(0, 4); + if (!words.length || words.join(' ').length < 3) return fallback; + const title = words.join(' ').slice(0, 40); + return title[0]!.toUpperCase() + title.slice(1); +} + +/** Factual one-line activity from a tool event: "Edit TerminalApp.ts", "Bash npm test". */ +export function toolActivity(name: string, target?: string): string { + const base = target ? (target.includes('/') && !/\s/u.test(target) ? target.slice(target.lastIndexOf('/') + 1) : target) : ''; + return `${name}${base ? ` ${base.length > 40 ? `${base.slice(0, 39)}…` : base}` : ''}`; +} + +export function pushEvent(session: AgentSession, event: AgentEvent, now = Date.now()): void { + session.events.push(event); + if (session.events.length > MAX_AGENT_EVENTS) session.events.splice(0, session.events.length - MAX_AGENT_EVENTS); + session.updatedAt = now; + switch (event.kind) { + case 'started': if (event.harnessSessionId) session.harnessSessionId = event.harnessSessionId; break; + case 'user': session.state = 'working'; session.activity = undefined; session.attention = false; break; + case 'assistant': if (session.state !== 'approval') session.state = 'working'; break; + case 'tool': session.activity = event.status === 'started' ? toolActivity(event.name, event.target) : session.activity; if (session.state !== 'approval') session.state = 'working'; break; + case 'approval': session.state = 'approval'; session.pendingApproval = {requestId: event.requestId, tool: event.tool, ...(event.target ? {target: event.target} : {})}; session.attention = true; break; + case 'approvalAnswered': session.pendingApproval = undefined; session.state = 'working'; break; + case 'settled': session.state = event.ok ? 'waiting' : 'failed'; session.activity = undefined; session.attention = true; break; + case 'exited': session.state = event.code === 0 ? 'exited' : 'failed'; session.activity = undefined; session.attention = true; session.pendingApproval = undefined; break; + case 'renamed': session.title = event.title; break; + } +} diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index f082a700..621bb49d 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -1,74 +1,256 @@ +import {railNeedsPromptConversion, railPreviewConfiguration} from '../prompt/railLayout.js'; +import {prepareRail, paintRailComposition, railCompositionPreview, type PreparedRail} from '../prompt/railComposition.js'; +import {routeModule} from '../context/surfaceRouter.js'; +import {promptFacts, moduleFactContext, updateFact, factAllowed} from '../context/facts.js'; +import {chromaPreviewNote} from '../appearance/chromaNotes.js'; +import {presentationClock} from '../motion/PresentationClock.js'; +import {EffectState, applyEffect, effectRegion} from '../motion/effects.js'; +import {dividerAnimated, MIN_CUSTOM_STOPS, TREATMENT_MOTION_LABELS, treatmentFor, treatmentText, paintDivider, PRESET_STOPS, setActiveThemeStops, TREATMENT_PRESETS, TREATMENT_PRESET_LABELS, treatmentAnimated, treatmentSwatch} from '../chroma/treatment.js'; +import {colorLevel} from '../presentation/capabilities.js'; +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 {detectBackend, keepAwakePath, KeepAwakeController, type KeepAwakeRecord} from '../keepAwake/keepAwake.js'; +import {awakeDuration, awakeLabel, awakeStyle, awakeView, edgeAccessoryColumns, edgeFits, placeOnSaver, renderComposerEdge, resolveAccessorySlot, sliceAnsiCells} from '../keepAwake/presentation.js'; +import {createKeepAwakePanel, describeStart, keepAwakeKey, renderKeepAwakePanel, requestStart, statusLines, type KeepAwakePanel} from '../keepAwake/KeepAwakePanel.js'; import {homedir} from 'node:os'; -import {GLYPHS, setIconStyle} from '../ui/glyphs.js'; -import {framePanel} from '../ui/PanelShell.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 {selectOpener} from '../host/desktop.js'; +import {integrationActivation} from '../tools/Activation.js'; +import {confirmToolInstall, createToolsPanel, refreshTools, renderTools, toolsKey, type ToolsPanel} from '../tools/ToolsPanel.js'; +import {describeCommandSource, describeSlashCommand, inspectCommand, renderInspector} from '../shell/CommandInspector.js'; +import {CHROMA_PREVIEW_NOTE, createSetup, NATIVE_ONLY_NOTE, renderSetup, SETUP_MIN_SIZE, SETUP_SECTIONS, setupIsIdempotent, setupKey, setupSelectedRow, type SetupState} from '../setup/SetupCat.js'; +import {glyphDiagnosticRows} from '../setup/glyphDiagnostic.js'; +import {fits, renderTooSmall, type MinimumSize} from '../ui/Modal.js'; +import {CellGrid} from '../idle/CellGrid.js'; +import {IDLE_FRAME_MS, type IdleMode} from '../idle/scenes.js'; +import {createScreensaverPanel, effectiveMode, idleFrameRows, idleMotion, idlePaletteFor, previewSize, renderScreensaverPanel, sceneTime, + SCREENSAVER_MIN_SIZE, screensaverKey, IDLE_SEED, type ScreensaverPanelState} from '../idle/IdleVisuals.js'; +import {captureFromRows, cropCapture, type ScreenCapture} from '../idle/screenCapture.js'; +import {makeRng} from '../idle/screenEffects.js'; +import {SCREEN_MODE_EFFECT, pickRandomSaver, saverLoopComplete, IDLE_FRAME_MS as SAVER_FRAME_MS} from '../idle/scenes.js'; +import {createThemeStudio, readThemeImport, previewTheme, renderThemeStudio, STUDIO_MIN_SIZE, studioKey, writeThemeExport, type StudioContext, type StudioTab, type ThemeStudioState} from '../appearance/ThemeStudio.js'; +import {addTheme, deleteTheme, duplicateBuiltin, duplicateCurrentToCustom, duplicateRefToCustom, duplicateTheme, renameTheme, saveTheme, setActiveTheme, type ActionResult} from '../appearance/themeLibraryActions.js'; +import {findTheme} from '../appearance/themeLibrary.js'; +import {activeThemeRef, assetRef, selectableThemes, themeRefLabel} from '../appearance/themeRefs.js'; +import {resolveSemanticPalette} from '../appearance/semanticPalette.js'; +import {anyBridgeTargetActive, targetsPinnedTo, type BridgeTargetId} from '../themeBridge/model.js'; +import {applyThemeBridge, bridgeStateExists, detectTargets, fzfBridgeArgs, integrationHealth, reloadTmux, reportTargets, setupBat, targetPalette, themeBridgeKey, type ApplyOutcome, type BridgeContext, type TargetFacts, type TargetReport} from '../themeBridge/runtime.js'; +import {createThemeBridgePanel, renderThemeBridgePanel, themeBridgeKey as themeBridgePanelKey, type BridgePanelAction, type BridgePanelContext, type ThemeBridgePanelState} from '../themeBridge/ThemeBridgePanel.js'; +import {createRowPanel, renderRowPanel, rowPanelKey, type RowPanelState} from '../ui/RowPanel.js'; +import {configureListKey, renderConfigureList, type ConfigureListState} from '../tools/config/ConfigureList.js'; +import {registryFacts, toolConfigEntry} from '../tools/config/registry.js'; +import {createTmuxPanel, describeTmuxChange, pendingChanges, renderTmuxPanel, tmuxPanelKey, type TmuxPanelState} from '../tools/config/TmuxPanel.js'; +import {applyTmuxChange, loadTmuxModel, parseTmuxConfig, readUserTmuxConfig, saveTmuxModel} from '../tools/config/tmux.js'; +import {writeTmuxManaged} from '../tools/config/tmuxManaged.js'; +import {createDotfilesPanel, dotfilesKey, renderDotfilesPanel, type DotfilesAction, type DotfilesState} from '../dotfiles/DotfilesPanel.js'; +import {expandSource, isRemoteSource, scanDotfiles} from '../dotfiles/scan.js'; +import {applyPlan as applyDotfilesPlan, buildPlan, reviewLines} from '../dotfiles/plan.js'; +import {applyHook, applyHookRemoval, artifactPath, hookSpec, loadLedger, ownership, planHook, planHookRemoval, recordedHook, removeArtifact, type HookSpec, type HookTarget, type ManagedTarget} from '../themeBridge/artifacts.js'; +import {BRIDGE_MODE_LABELS, BRIDGE_POLICY_LABELS, BRIDGE_TARGET_LABELS, effectiveMode as bridgeMode} from '../themeBridge/model.js'; +import type {FileEditPlan} from '../ask/fileEdit.js'; +import {colorEscape} from '../chroma/escape.js'; +import {parseHexColor} from '../chroma/color.js'; +import type {CustomTheme} from '../appearance/customTheme.js'; +import {commandWord, createInstallPrompt, ignoreInstallSuggestion, installCandidate, installPromptKey, renderInstallPrompt, shouldOfferInstall, + type InstallPromptState} from '../tools/InstallSuggestion.js'; +import {detectTool, knownToolForExecutable, suggestibleToolFor, toolInstall, TOOLS, type Tool} from '../tools/catalog.js'; +import {openGuidedInstall, openPrevious} from '../tools/OhMyZshView.js'; +import {planPackageInstall} from '../packages/managers.js'; +import {loadToolUpdateState, runToolUpdateCheck, toolUpdateCheckDue, type ToolUpdateState} from '../tools/ToolUpdates.js'; +import type {CommandSource} from '../shell/SemanticService.js'; +import {GLYPHS, setIconStyle, getCurrentGlyphMode, setPromptSymbol} from '../ui/glyphs.js'; +import {applyUiTheme, uiColorsFor} from '../appearance/uiTheme.js'; +import {chromeColorsFrom, resolveChrome} from '../appearance/uiChrome.js'; +import {CHROME_EDITOR_MIN_SIZE, chromeEditorKey, createChromeEditor, renderChromeEditor, type ChromeEditorState} from '../appearance/ChromeEditor.js'; +import {promptSymbolGlyph} from '../prompt/glyphChoices.js'; +import {framePanel, renderTabStrip} from '../ui/PanelShell.js'; +import {providerExplanation} from '../setup/providerExplanations.js'; +import {liveActivityPaint} from '../status/liveActivityColors.js'; +import {renderControls} from '../ui/controls.js'; +import {gradientEditorControls, gradientEditorKey, renderGradientEditorRows, type GradientEditorState} from '../ui/GradientEditor.js'; import { adjustSettingsRow, isInlineEditable, resetSettingsRow, settingsRowChanged, renderSettingsPanel, selectedSettingsRow, settingsItemCount, settingsRowDestination, - settingsView, statusLineCount, visibleSettingsRows, switchSettingsView, toggleSettingsRow, type SettingsDestination, type SettingsPanelState, + settingsRowValue, settingsView, statusLineCount, statusSection, visibleSettingsRows, switchSettingsView, toggleSettingsRow, type SettingsDestination, type SettingsPanelState, type SettingsView, type StatusSections, SETTINGS_ENTRIES, SETTINGS_ROWS, } from '../ui/SettingsPanel.js'; import {OUTPUT_FOLDING_MODES} from '../output/FoldPolicy.js'; -import {appendFileSync, existsSync} from 'node:fs'; -import {delimiter, join} from 'node:path'; -import {CompletionService, type CompletionCandidate} from '../shell/CompletionService.js'; +import {accessSync, appendFileSync, constants, existsSync, readFileSync, realpathSync, rmSync, statSync} from 'node:fs'; +import {shouldProbeGraphics} from '../host/capabilities.js'; +import {basename, delimiter, join, resolve as resolvePath} from 'node:path'; +import {spawn} from 'node:child_process'; +import {completionMenuRows, renderCompletion, renderCompletionMore, COMPLETION_ACTIONS} from '../shell/CompletionMenu.js'; +import {CommandDescriptions, identityDescription} from '../shell/CommandDescriptions.js'; +import {localKnowledge} from '../shell/CommandKnowledge.js'; +import {ComposerHistory, recallSource, SESSION_SUBMISSION_LIMIT, type SessionSubmission} from '../input/ComposerHistory.js'; +import {resolveAction} from '../ui/actions.js'; +import {CompletionService, defaultCompletionSources, 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 {ignorePatternFromEnv, SUGGESTION_PROVIDERS} from '../suggestions/types.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'; import {CommandEditor} from '../input/CommandEditor.js'; -import {OutputBuffer, serializeCopyPayload, type HistoricalContextSnapshot} from '../output/OutputBuffer.js'; -import {createWelcomeSnapshot, renderWelcome, WELCOME_BLINK_CLOSED_MS, welcomeBlinkDelay} from '../output/Welcome.js'; +import {authoredLink, closeAuthoredLinks} from '../output/Hyperlinks.js'; +import {HostSemantics, semanticSupport} from '../host/semanticMarks.js'; +import {OutputBuffer, renderHistoricalContext, serializeCopyPayload, type CompletedCommand, type HistoricalContextSnapshot} from '../output/OutputBuffer.js'; +import {createWelcomeSnapshot, renderWelcome, vespyrSprite, WELCOME_BLINK_CLOSED_MS, welcomeBlinkDelay} from '../output/Welcome.js'; import {captureWelcome, WELCOME_PROVIDERS, welcomeProvider} from '../output/WelcomeProviders.js'; -import {clearProviderDetection, detectProvider, resolveCommand, resolveProvider} from '../providers/providers.js'; +import {clearProviderDetection, detectProvider, installUnavailableReason, providerInstall, resolveCommand, resolveProvider, runExternal, type ProviderStatus} from '../providers/providers.js'; import {createProviderPanel, handleProviderPanelKey, providerPanelEnterAction, providerPanelSelection, renderProviderPanel, type ProviderPanelState} from '../providers/ProviderPanel.js'; import {TapActivityObserver} from '../output/TapActivityObserver.js'; import {HistoryViewport, stickyHeaderFor, type StickyHeader, type WrappedRow} from '../output/viewport.js'; -import {buildContextLine, buildInlineContextPrefix, buildRightContext, isOnCommandRelevant, buildRichGitShowcaseLine, buildThemePreviewLine, RICH_GIT_SHOWCASE, moduleShowcaseContext, nativePromptSnapshot, themePreviewContext} from '../prompt/prompt.js'; -import {handleTranscriptPanelKey, renderTranscriptPanel, type TranscriptPanelState} from '../output/TranscriptPanel.js'; +import {NATIVE_PROMPT_THEMES, setThemeContext, themeContext, themeChromaStops, themeLabel, buildContextLine, buildInlineContextPrefix, buildRightContext, isOnCommandRelevant, buildRichGitShowcaseLine, buildThemePreviewLine, RICH_GIT_SHOWCASE, moduleShowcaseContext, nativePromptSnapshot, renderedModules, themePreviewContext} from '../prompt/prompt.js'; +import {foldingPreview, handleTranscriptPanelKey, renderTranscriptPanel, type TranscriptPanelState} from '../output/TranscriptPanel.js'; import {tabCompletionAction} from '../input/tabBehavior.js'; import {formatBuildIdentity, readBuildIdentity} from '../buildInfo.js'; -import {hasVisibleContextModule, loadPromptConfiguration, NATIVE_PALETTE_IDS, savePromptConfiguration, type PromptConfiguration, type PromptProviderId} from '../prompt/configuration.js'; +import {hasVisibleContextModule, loadPromptConfiguration, NATIVE_PALETTE_IDS, savePromptConfiguration, type NativePaletteId, type PromptConfiguration, type PromptProviderId} from '../prompt/configuration.js'; import {detectStarship, renderStarshipPrompt, type StarshipPromptResult, type StarshipStatus} from '../prompt/starship.js'; import {STARSHIP_MODULES, StarshipConfigAdapter} from '../prompt/StarshipConfigAdapter.js'; import {detectPowerlevel10k, renderPowerlevel10kPrompt, type Powerlevel10kStatus} from '../prompt/powerlevel10k.js'; +import {detectOhMyPosh, renderOhMyPoshPrompt} from '../prompt/ohMyPosh.js'; import {configuratorFileChanged, launchPowerlevel10kConfigurator, preparePowerlevel10kConfigurator} from '../prompt/Powerlevel10kConfigurator.js'; -import {APPEARANCE_MODULES_ROW, applyLayoutChoice, onModulesRow, layoutLabel, describePromptConfiguration, PROVIDER_ORDER, providerLabel, handlePromptPanelKey, layoutChoiceIndex, renderPromptPanel, type PromptPanelState} from '../prompt/PromptPanel.js'; +import {galleryPalettes, promptPanelOwnsKey, appearanceModulesRow, closeGradientEditor, onGradientRow, openGradientEditor, applyLayoutChoice, onModulesRow, layoutLabel, describePromptConfiguration, PROVIDER_ORDER, providerLabel, handlePromptPanelKey, layoutChoiceIndex, renderPromptPanel, type PromptPanelState} from '../prompt/PromptPanel.js'; import type {PromptSnapshot} from '../prompt/snapshot.js'; import {CommandContextCache, commandWords, type CommandContextId} from '../prompt/commandContext.js'; -import {applyUpdate, backgroundUpdateCheck, compareVersions, detectInstall, fetchLatestRelease, installRoot, planUpdate, systemRunner, type ReleaseInfo} from '../update/update.js'; +import {discoverLocalExecutables} from '../tools/localDiscovery.js'; +import {applyUpdate, checkForUpdate, compareVersions, detectInstall, installProvenanceLabel, fetchLatestRelease, installRoot, loadUpdateState, planUpdate, prepareAutomaticUpdate, readyVersion, recordInstalled, systemRunner, updatesDisabledByEnvironment, type ReleaseInfo, type UpdateCheckFrequency} from '../update/update.js'; import {resolvePathAbbreviations} from '../prompt/pathDisplay.js'; import {resolvePromptContext, type PromptContext} from '../shell/ShellContext.js'; import type {AttachedSession, SessionClient, SessionConnection, StreamStamp} from '../session/SessionClient.js'; import {InProcessSessionClient} from '../session/InProcessSessionClient.js'; -import {TerminalRenderer} from '../terminal/TerminalRenderer.js'; +import {cursorStyleSequence, TerminalRenderer} from '../terminal/TerminalRenderer.js'; 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 {parseSlashCommand, slashCommands, slashSuggestions, suggestionWindow, type ParsedSlashCommand} from '../commands/slashCommands.js'; +import {ClipboardUnavailableError, copyFeedback, copyStats, writeClipboard} from '../clipboard/clipboard.js'; +import {beginSelection, extendSelection, isRowSelected, selectedText, type TranscriptSelection} from '../output/TranscriptSelection.js'; import {shouldPassthrough} from '../passthrough/PassthroughPolicy.js'; import {layoutInput, graphemes} from '../input/inputLayout.js'; import {editText} from '../ui/formControls.js'; import {helpMarkdown} from '../help/helpContent.js'; import {renderMarkdownText} from '../help/markdown.js'; -import {shimmerText} from '../status/shimmer.js'; -import {isReducedMotion, presentationAnimationElapsed, presentationCompletionTime, presentationNow} from '../presentation/environment.js'; +import {sweepAnimates, sweepAnsiRow, sweepCells, sweepOnce} from '../motion/lightSweep.js'; +import {sweepStill, sweepStyleFor} from '../motion/sweepStyle.js'; +import {mixRgb} from '../chroma/chroma.js'; +import type {Rgb} from '../chroma/escape.js'; +import {isDeterministicPresentation, isReducedMotion, presentationAnimationElapsed, presentationCompletionTime, presentationNow} from '../presentation/environment.js'; import {TaskProgress} from '../status/TaskProgress.js'; +import {LocalStats, renderStatusStrip, STRIP_REFRESH_MS, stripVisible, type StatsSource, type SystemStats} from '../status/StatusStrip.js'; import {completedActivity, liveActivityParts} from '../status/activity.js'; import {extractFacts} from '../status/adapters.js'; -import {foreground, background, UI_COLORS} from '../ui/palette.js'; -import {cursorScreenRow, planScreen, regionAt, screenRowFromTerminal, terminalRowFromScreen, type Region, type ScreenPlan} from './screenPlan.js'; +import {focusForeground, foreground, background, UI_COLORS, lazyForeground} from '../ui/palette.js'; +import {AgentActivityStore} from '../agents/AgentActivityStore.js'; +import {isShellId, knowledgeJobCount, type ShellId} from '../shell/adapters/ShellAdapter.js'; +import {findSourceReferences, parseOpenArgument, resolveHostActions, resolveLocation, runHostAction, type HostAction, type HostActionAdapter} from '../host/HostActions.js'; +import {openPanelKey, renderOpenPanel, type OpenPanelState} from '../host/OpenPanel.js'; +import {fishQuote, posixQuote} from '../shell/adapters/ShellAdapter.js'; +import {compileQuery, createFind, findCount, parseSearchCommand, refreshFind, revealStart, stepFind, type FindState} from '../output/TranscriptSearch.js'; +import {searchChromeRows} from '../output/SearchChrome.js'; + +/** A row's plain text with spans marked: the active result strongly, others underlined. */ +function markSpans(plain: string, spans: ReadonlyArray<{start: number; end: number}>, strong: string, base: string, weak: string): string { + const ordered = [...spans].sort((a, b) => a.start - b.start); + let output = base; + let index = 0; + for (const span of ordered) { + if (span.start < index) continue; + output += `${plain.slice(index, span.start)}\u001b[0m${weak || strong}${plain.slice(span.start, span.end)}\u001b[0m${base}`; + index = span.end; + } + return `${output}${plain.slice(index)}\u001b[0m`; +} +import {shellAdapter, shellAvailability, shellInstall} from '../shell/adapters/registry.js'; +import {commandReference} from '../shell/CommandReference.js'; +import {gitNextSteps, gitRunAllowed, gitSummary, renderCommand} from '../ask/gitAssist.js'; +import {readGitFacts} from '../ask/git.js'; +import {configTargets, systemConfigEnvironment} from '../ask/configTargets.js'; +import {systemFileAssistEnvironment, validateAfterWrite} from '../ask/configAssist.js'; +import {formatterAllowed} from '../ask/repair.js'; +import {packageIntent, packageQueries, type BrewFacts, type PackageIntent} from '../ask/packages.js'; +import {brewMutationAllowed, homebrewAdapter} from '../packages/homebrew.js'; +import {toolOwner} from '../tools/ToolUpdates.js'; +import {tldrExamples} from '../shell/tldr.js'; +import {normalizeRequest} from '../ask/resolver.js'; +import {applyPlan, sha256} from '../ask/fileEdit.js'; +import type {CommandEnvironment} from '../ask/commands.js'; +import type {AskOption} from '../ask/types.js'; +import {askStarters} from '../ask/guide.js'; +import {applyAskCompletion, pushTurn, ASK_GREETING, askKey, askTranscriptText, createAskState, receiveOutcome, renderAsk, type AskEvent, type AskState} from '../ask/AskPanel.js'; +import {readArgv, resolveRequest} from '../ask/resolver.js'; +import {completePath, listProjectFiles} from '../ask/files.js'; +import {recipeRunAllowed} from '../ask/recipes.js'; +import {openableUrl, projectRunAllowed, readProjectFacts} from '../ask/project.js'; +import {ManagedTasks} from '../tasks/ManagedTasks.js'; +import {CursorPresenter} from '../cursor/CursorPresenter.js'; +import {runDoctor} from '../doctor/doctor.js'; +import {parseWatch, watchSafety, WatchTasks} from '../tasks/WatchTasks.js'; +import {renderWatchPanel, watchPanelKey, watchRow, type WatchPanelState} from '../tasks/WatchPanel.js'; +import {analyzePaste, KIND_LABELS, needsPreview, PASTE_EXACT_NOTE, pasteHeader, primaryKind, type PasteAnalysis} from '../input/pasteGuard.js'; +import {createPasteReview, displaySafe, pasteReviewKey, renderPasteReview, type PasteReviewState} from '../input/PasteReview.js'; +import {WHY_FAILED} from '../ask/failure.js'; +import {nmshConfigDirectory} from '../configuration/paths.js'; +import {createDoctorPanel, doctorKey, renderDoctorPanel, type DoctorPanelState} from '../doctor/DoctorPanel.js'; +import {signatureAccent} from '../session/signatures.js'; +import {renameSession} from '../session/SocketSessionClient.js'; +import {appearanceHubKey, createAppearanceHub, hubMotionPreview, renderAppearanceHub, type AppearanceHubState} from '../appearance/AppearanceHub.js'; +import {diffModules, progress, transitionPaint, Transitions, type MotionGate} from '../motion/transitions.js'; +import {overlayRow, type CellPaint} from '../presentation/cellOverlay.js'; +import {chooseBackend, hostCursorFacts, nativeBackendFor, setCursorHostProvider, type BackendChoice, type HostCursorFacts} from '../cursor/backends.js'; +import {includeLine, nativeCursorIntegrated, nativeHostLabel, reloadInstruction, setupPlan, writeManagedFiles, type ManagedWrite} from '../cursor/native.js'; +import {createCursorPanel, cursorPanelKey, renderCursorPanel, type CursorPanelEnv, type CursorPanelOptions, type CursorPanelState} from '../cursor/CursorPanel.js'; +import {contextFor, resolveCursorSettings} from '../cursor/colors.js'; +import {renderCursorPreview} from '../cursor/CursorPreview.js'; +import {MOTION_ROWS} from '../motion/motionRows.js'; +import {renderMotionPreview} from '../motion/MotionPreview.js'; +import {liveLine} from '../status/liveLine.js'; +import {browseOutcome} from '../ask/fileAssist.js'; +import {gitWorktrees} from '../ask/git.js'; +import type {AskAction, AskContext, AskOutcome} from '../ask/types.js'; +import {askProviderFacts, PROVIDER_FAMILIES, providerFamily, selectProvider, type SwitchableFamily} from '../providers/families.js'; +import {LocalUnderstanding, understandingStatusRows, understandingWelcomeText} from '../understanding/LocalUnderstanding.js'; +import {stateLabel, createUnderstandingPanel, renderUnderstandingPanel, understandingKey, type UnderstandingFacts, type UnderstandingPanelState} from '../understanding/UnderstandingPanel.js'; +import {downloadPinned, loadRecommendedModel} from '../understanding/recommended.js'; +import {modelChoice, nmshModelDirectory} from '../understanding/discovery.js'; +import {foldExcerpt} from '../understanding/tasks.js'; +import {applyFoldHint, hintEligible} from '../output/FoldPolicy.js'; +import {CAPABILITIES, modelInventory, resolveModelIntent, resolveWithInterpretation} from '../ask/resolver.js'; +import {createProvidersOverview, providersOverviewKey, renderProvidersOverview, type ProvidersOverviewState} from '../providers/ProvidersOverview.js'; +import {InstallProvenance} from '../tools/InstallProvenance.js'; +import {PathClassifier, type CommandClassifier} from '../shell/PathClassifier.js'; +import {applyShellIndicatorAction, createShellPanel, renderShellPanel, shellPanelKey, type ShellPanelState} from '../shell/ShellPanel.js'; +import {detectPlatform, type PlatformInfo} from '../host/platform.js'; +import {createImageOverlay, fitCells, pngSize, selectImageProtocol, type ImageOverlay, type ImageProtocol, type ImageSize} from '../presentation/ImageSurface.js'; +import {detectShellEnvironment, shellEnvironmentRows, type ShellEnvironmentReport} from '../shell/ShellEnvironment.js'; +import {agentColor, agentCompletionText, renderAgentStats} from '../agents/AgentStatsView.js'; +import {detectAgentCommand} from '../agents/agents.js'; +import {describeNotice, noticeExpiresAt, noticeKey, noticeVisible, selectNotices, sessionLabel, type NoticeView, type SessionNotice} from '../session/SessionNotices.js'; +import {composerEdgeStates, cursorScreenRow, planScreen, regionAt, withNoticeRows, withStatusRow, 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 {handleSyntaxPanelKey, renderSyntaxPanel, renderSyntaxPreviewLine, 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'; @@ -78,22 +260,45 @@ import {formatBytes} from '../session/sessionList.js'; import type {PresentationMode} from '../output/PresentationMode.js'; import type {SessionInfo} from '../session/SessionProtocol.js'; import {SessionJournal} from '../sessions/SessionJournal.js'; -import {createResumeBrowser, describeLiveSession, navigateResume, resumeDayLabel, resumeRowCount, resumeSelection, +import {createSessionsView} from '../sessions/ResumeBrowser.js'; +import {liveSessionRows} from '../sessions/LiveSessionView.js'; +import {createResumeBrowser, describeArchivedRow, describeLiveRow, LIVE_ROW_LABELS, liveRowAgent, liveRowState, navigateResume, resumeDayLabel, resumeRowCount, resumeSelection, visibleLiveSessions, visibleResumeSessions, type ResumeBrowserState} from '../sessions/ResumeBrowser.js'; -import {listLiveSessions} from '../session/connectSession.js'; +import {dismissSessionNotice, listLiveSessions, listSessionNotices} from '../session/connectSession.js'; +import {OLDER_SERVICE_SWITCH} from '../session/SocketSessionClient.js'; import {killAndArchive} from '../session/liveSessions.js'; import {recoverEndedSessions} from '../session/recovery.js'; -import {defaultRuntimeDir} from '../session/runtimeDir.js'; +import {AgentSessions} from '../agents/sessions/manager.js'; +import type {AgentSession} from '../agents/sessions/model.js'; +import {agentBlocks, agentPanelRows, renderAgentPanel, renderAgentView, renderShelf, shelfOrder, type AgentPanelState, type AgentViewState} from '../agents/sessions/AgentViews.js'; +import {harness} from '../agents/harnesses.js'; +import {defaultRuntimeDir, socketPathFor} from '../session/runtimeDir.js'; /** Editor text that marks interactive history search. */ const HISTORY_SEARCH = '/history '; -const PRIMARY = foreground(UI_COLORS.primary); -const SECONDARY = foreground(UI_COLORS.secondary); -const SUBTLE = foreground(UI_COLORS.subtle); -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 DIRECTORY_SEARCH = '/dirs '; +const PRIMARY = lazyForeground(UI_COLORS.primary); +const SECONDARY = lazyForeground(UI_COLORS.secondary); +const SUBTLE = lazyForeground(UI_COLORS.subtle); +const SEPARATOR = lazyForeground(UI_COLORS.separator); +/** Keep Awake presentation poll: record changes, timeout, idle reminder and its minute counter. */ +const AWAKE_POLL_MS = 5000; +/** Free cells the input row keeps after typed text before Keep Awake yields it (Input row placement). */ +const AWAKE_INPUT_SLACK = 8; +const ACCENT = lazyForeground(UI_COLORS.accent); +/** NMSh ran this install with the user's confirmation; record it so an uninstall can be offered honestly. */ +function recordInstall(toolId: string, install: {label: string; command: string; args: readonly string[]}): void { + try { new InstallProvenance().record({id: toolId, package: install.args.at(-1) ?? toolId}, {...install, args: [...install.args]}); } catch { /* best effort */ } +} +/** Session notices change on human timescales; a slow poll keeps the service quiet. */ +const NOTICE_REFRESH_MS = 4000; +/** Agent process discovery cadence while NMSh owns the screen. */ +const AGENT_DISCOVERY_MS = 15_000; +/** The shelf hides after this long when nothing needs attention. */ +const SHELF_IDLE_MS = 6000; +const SUCCESS = lazyForeground(UI_COLORS.success); +const ERROR = lazyForeground(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']); @@ -101,8 +306,9 @@ 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; - +const INVERSE = '\u001B[7m'; +/** The Settings row the glyph preview returns to. */ +const GLYPH_ENTRY_INDEX = (): number => Math.max(0, SETTINGS_ENTRIES.findIndex(entry => entry.id === 'glyphPreview')); export class TerminalApp { private readonly buildIdentity = readBuildIdentity(); private updateInProgress = false; @@ -110,10 +316,13 @@ 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; + private semanticService: CommandClassifier; private readonly keyDecoder = new KeyDecoder(); private readonly output = new OutputBuffer(() => { this.historyViewport.latest(); @@ -123,18 +332,80 @@ export class TerminalApp { private readonly historyViewport = new HistoryViewport(); private readonly session: SessionClient; private readonly historyService = new HistoryService(); + /** Shell-style Up/Down recall in the ordinary composer. Frontend-local; never persisted. */ + private readonly composerHistory = new ComposerHistory(); + /** + * What was submitted in this NMSh session, for recall only. NMSh slash + * commands live here and nowhere else: never zsh, Atuin or other history. + */ + private readonly sessionSubmissions: SessionSubmission[] = []; + /** Unsubscribes the Chroma panel preview from the presentation clock. */ + private panelAnimation?: () => void; + /** A milestone effect waiting for the owning panel to close. */ + private pendingMilestone = false; + private readonly commandDescriptions = new CommandDescriptions(); + private commandUsageVersion = -1; private readonly nativeSuggestions = new NativeSuggestions(ignorePatternFromEnv()); private readonly suggestions = new SuggestionController(() => this.render(), reason => this.output.addHistoryLine(`${SUBTLE}Suggestion provider unavailable (${reason}); using NMSh Native.${RESET}`)); /** Commands submitted this session, most recent first: the sequence context for suggestions. */ private readonly submittedCommands: string[] = []; private readonly transcriptStore = new TranscriptStore(); - private readonly completionService = new CompletionService(); + private 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[] = []; private context: PromptContext = {cwd: process.cwd(), project: '…', exitStatus: 0}; private readonly commandContexts = new CommandContextCache(() => this.render()); - private promptConfiguration: PromptConfiguration = loadPromptConfiguration(); + private configuration: PromptConfiguration = loadPromptConfiguration(); + /** Decorative surfaces without prompt context follow the active Native theme for Current Theme Chroma. */ + private themeStopsKey = ''; + private get promptConfiguration(): PromptConfiguration { + const config = this.configuration; + const key = `${config.nmsh.palette}:${config.nmsh.vibrance}:${config.nmsh.accent}:${config.promptSymbol}:${config.promptSymbolCustom ?? ''}:${ + config.nmsh.palette === 'custom' ? JSON.stringify(config.customTheme ?? null) : ''}:${JSON.stringify(config.uiChrome)}`; + if (key !== this.themeStopsKey) { + this.themeStopsKey = key; + // Theme context first: Current Theme stops and the chrome both read it. + setThemeContext(config.nmsh.accent, config.customTheme); + applyUiTheme(uiColorsFor(resolveChrome(config.uiChrome, config.nmsh.palette, config.nmsh.accent, config.customTheme))); + setActiveThemeStops(themeChromaStops(config.nmsh.palette, config.nmsh.vibrance)); + setPromptSymbol(promptSymbolGlyph(config.promptSymbol, config.promptSymbolCustom, true), + promptSymbolGlyph(config.promptSymbol, config.promptSymbolCustom, false)); + } + return config; + } + private set promptConfiguration(next: PromptConfiguration) { + const turnedOff = next.localUnderstanding.mode === 'off' && this.configuration.localUnderstanding.mode !== 'off'; + const previous = this.configuration; + this.configuration = next; + // One place for every way configuration changes (Settings, Setup, /cursor, import): the caret style applies to this + // session at once, and NMSh's managed native cursor files follow the cursor and theme settings. + if (this.renderer) { + this.renderer.setCursorStyle(cursorStyleSequence(next.cursor.shape, next.cursor.blink)); + if (JSON.stringify(previous.cursor) !== JSON.stringify(next.cursor) || previous.nmsh.palette !== next.nmsh.palette || previous.nmsh.accent !== next.nmsh.accent + || JSON.stringify(previous.customTheme) !== JSON.stringify(next.customTheme)) this.syncCursorNative(next); + } + // Off: no model use from this window, and the shared service is told to unload. + if (turnedOff) this.understanding?.modeChanged(); + // Theme Bridge follows the same funnel: a theme, library or bridge change regenerates what Follow/Choose targets use. + if (themeBridgeKey(previous) !== themeBridgeKey(next)) this.scheduleThemeBridge(); + } + /** Optional local understanding; creates nothing until a feature is eligible to use it. */ + private readonly understanding = new LocalUnderstanding(() => this.configuration.localUnderstanding); private effectivePromptProvider: PromptProviderId = this.promptConfiguration.provider; private starshipStatus?: StarshipStatus; /** Live Starship/Powerlevel10k rendering for the effective provider. */ @@ -143,10 +414,231 @@ export class TerminalApp { /** /prompt preview rendering; never shown as the live prompt. */ private panelExternalPrompt?: {provider: PromptProviderId; result: StarshipPromptResult}; private p10kStatus?: Powerlevel10kStatus; + /** Cancels a superseded Oh My Posh render, so at most one child runs. */ + private ohMyPoshRender?: AbortController; private promptPanelState?: PromptPanelState; 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; + /** Idle visuals: one inactivity timer while armed, one frame subscription while showing; neither exists otherwise. */ + private idleTimer?: NodeJS.Timeout; + private idle?: {mode: IdleMode; startedAt: number; frame: number; interval: number; preview: boolean; paused: boolean; still: boolean; capture?: ScreenCapture; random?: boolean; offset: number}; + /** Snapshot of the screen taken when the screensaver gallery opened: the gallery preview's material. */ + private saverCapture?: ScreenCapture; + private saverGalleryCapture?: ScreenCapture; + private saverGalleryMode?: IdleMode; + private randomSaver = makeRng(0x5eed); + private idleSubscription?: () => void; + private readonly idleGrid = new CellGrid(); + private lastActivity = Date.now(); + private idleArmedFor = -1; + private screensaverPanel?: ScreensaverPanelState; + private screensaverAnimation?: () => void; + private readonly screensaverGrid = new CellGrid(); + /** Custom UI chrome colors draft. */ + private chromeEditor?: ChromeEditorState; + /** Custom colors for idle visuals or Live activity, in the shared gradient stop editor. */ + private stopsEditor?: {target: 'idle' | 'activity'; gradient: GradientEditorState}; + /** Theme Studio: a custom theme draft; nothing persists until Save. */ + private themeStudio?: ThemeStudioState; + /** Setup Cat: one draft over the saved configuration; nothing persists until Apply. */ + private setupState?: SetupState; + /** A missing curated command's install offer; the submitted text is kept until the user decides. */ + private installPrompt?: InstallPromptState; + /** The last optional tool update check (bookkeeping file, read once). */ + private toolUpdates: ToolUpdateState = loadToolUpdateState(); + private toolUpdateCheckRunning = false; + /** What command words resolve to in the configured zsh, filled off the keypress path for the inspector. */ + private readonly commandSources = new Map(); + /** Status strip data: sampled from local OS counters on its own modest timer, only while enabled. */ + private statsSource: StatsSource = new LocalStats(); + private stripStats: SystemStats = {}; + private stripTimer?: () => void; + /** Cross-session notices from the session service; frontend chrome only. */ + private noticeView: NoticeView = {notices: [], hidden: 0}; + /** Every notice the service reported at the last poll; visibility (TTL) is applied on top, never stored. */ + private noticeSource: SessionNotice[] = []; + private noticeExpiry?: ReturnType; + private noticeLabels = new Map(); + private noticeTimer?: () => void; + private noticePolling = false; + /** Notices this window cleared while an older service could not clear them for everyone. */ + private readonly dismissedNotices = new Set(); + private readonly agentActivity = new AgentActivityStore(); + /** The shell backend under this session (zsh, Fish or Bash). */ + private shellId: ShellId = 'zsh'; + private shellPanel?: ShellPanelState; + private shellSwitching = false; + /** The slash text whose suggestion menu Down entered; Up from its first row leaves it. */ + private slashMenuFor?: string; + + private leaveSlashMenu(): true { + this.slashMenuFor = undefined; + this.selectedSuggestion = 0; + return true; + } + /** The transcript find bar, while open. */ + private findState?: FindState; + /** Facts about the machine and shell setup; read once, never per frame. */ + private get platformInfo(): PlatformInfo { return this.cachedPlatform ??= detectPlatform(); } + private cachedPlatform?: PlatformInfo; + private get shellEnvironment(): ShellEnvironmentReport { return this.cachedEnvironment ??= detectShellEnvironment(); } + private cachedEnvironment?: ShellEnvironmentReport; + private stripSampling = false; + /** Frontend PATH and recipe lookups for install offers; replaceable in tests. */ + private installProbe = {onPath: (name: string) => resolveCommand(name) !== undefined, recipe: (tool: Tool) => planPackageInstall(tool) ?? toolInstall(tool)}; + private misePanel?: MisePanel; + private keepAwakePanel?: KeepAwakePanel; + private keepAwakeController?: KeepAwakeController; + /** One controller for /caffeinate, /awake and /zoomies; the backend is detected once. */ + private keepAwake(): KeepAwakeController { return this.keepAwakeController ??= new KeepAwakeController(detectBackend()); } + + /** The verified Keep Awake record presentation shows; undefined (Off) renders nothing anywhere. */ + private awakeRecord?: KeepAwakeRecord; + private awakeTimer?: () => void; + /** Last local NMSh input (keys, mouse, paste): the idle reminder's clock. Output and OS idle state never count. */ + private lastUserInput = Date.now(); + /** A cheap key for what Keep Awake shows, so the poll repaints only when it changes. */ + private awakeShown = ''; + + /** Full verified refresh (ownership checked): on launch, after every Keep Awake action, and when the stored record changes. */ + private refreshAwake(): void { + if (!this.keepAwakeController && !existsSync(keepAwakePath())) { this.awakeRecord = undefined; return; } + const status = this.keepAwake().status(); + this.awakeRecord = status.state === 'running' ? status.record : undefined; + } + + /** Polled while NMSh presents: a file read, plus an ownership check only when the record changed or its timeout passed. */ + private pollAwake(): void { + const stored = this.keepAwakeController || existsSync(keepAwakePath()) ? this.keepAwake().peek() : undefined; + const current = this.awakeRecord; + const expired = current?.timeoutSeconds !== undefined && Date.now() >= current.startedAt + current.timeoutSeconds * 1000; + if (stored?.token !== current?.token || expired) this.refreshAwake(); + const shown = this.awakeShownKey(); + if (shown !== this.awakeShown) { this.awakeShown = shown; this.render(); } + } + + private awakeShownKey(): string { + const record = this.awakeRecord; + if (!record) return ''; + return `${record.token}|${this.awakeIdle()}|${awakeDuration(record, Date.now())}`; + } + + /** One timer while Keep Awake is active (or could become active from another window); none once NMSh stops presenting. */ + private syncAwake(): void { + const wanted = this.presentationStarted && !this.stopped && !this.passthrough && !this.externalPassthrough && !this.frontendSuspended; + if (wanted && !this.awakeTimer) this.awakeTimer = presentationClock.subscribe(() => this.pollAwake(), AWAKE_POLL_MS); + else if (!wanted && this.awakeTimer) { this.awakeTimer(); this.awakeTimer = undefined; } + } + + /** Expanded reminder after no local NMSh input for the configured delay (a deterministic demo may shorten it). */ + private awakeIdle(now = Date.now()): boolean { + const settings = this.promptConfiguration.keepAwake; + if (!this.awakeRecord || !settings.idleReminder) return false; + const override = isDeterministicPresentation() ? Number(process.env.NMSH_DEMO_AWAKE_IDLE_MS) : Number.NaN; + const delay = Number.isFinite(override) && override >= 0 ? override : settings.idleAfterSeconds * 1000; + return now - this.lastUserInput >= delay; + } + + private awakeViewNow(now = Date.now()) { + const record = this.awakeRecord; + return record ? awakeView(record, this.promptConfiguration.keepAwake, this.awakeIdle(now), now) : undefined; + } + + /** + * Input row placement: the width reserved at the end of the first input row, + * or 0. Only when completely safe: a single-line edit with room to spare and + * no right prompt on that row. Wrapping, the caret, selection and mouse hit + * testing all use the narrowed width (inputColumns), so typed text can never + * run under it. + */ + private awakeInputReserve(columns: number): number { + const view = this.awakeViewNow(); + if (!view || this.promptConfiguration.keepAwake.placement !== 'input') return 0; + if (this.promptConfiguration.composerLayout === 'oneLine' && this.effectivePromptProvider === 'nmsh') return 0; + if (this.editor.displayText.includes('\n')) return 0; + const reserve = view.compact.width + 2; + const prefix = this.inputFirstLinePrefix(columns); + const used = displayWidth(prefix ?? `${GLYPHS.prompt} `) + displayWidth(this.editor.displayText) + 1; + return used + AWAKE_INPUT_SLACK <= columns - reserve ? reserve : 0; + } + + /** The editor's width: the terminal width less any Keep Awake input reservation. */ + private inputColumns(columns: number): number { + const rail = this.preparedRail(columns).presentation; + const available = rail.rows ? rail.editorColumns : columns; + return Math.max(1, available - this.awakeInputReserve(columns)); + } + + /** Resolves this frame's slot against real composer geometry; the saved preference never changes. */ + private awakeDecision(plan: ScreenPlan, columns: number): ScreenPlan['awake'] { + const view = this.awakeViewNow(); + if (!view || plan.panelActive) return undefined; + const edges = composerEdgeStates(plan, this.promptConfiguration.placement); + const fits = edgeFits(columns, view.compact.width); + const slot = resolveAccessorySlot(this.promptConfiguration.keepAwake.placement, { + topEdge: edges.topEdge === 'available' && fits ? 'available' : edges.topEdge === 'occupied' ? 'occupied' : 'unavailable', + bottomEdge: edges.bottomEdge === 'available' && fits ? 'available' : 'unavailable', + inputTrailing: this.awakeInputReserve(columns) > 0 ? 'available' : 'unavailable', + }); + return {slot, expandedOnEdge: Boolean(view.expanded && (slot === 'topEdge' || slot === 'bottomEdge') && edgeFits(columns, view.expanded.width))}; + } + + /** Rows the adjacent slot needs: its own row, or the muted reminder when the idle form does not fit where the label is. */ + private awakeAdjacentRows(decision: ScreenPlan['awake']): number { + if (!decision) return 0; + if (decision.slot === 'adjacentRow') return 1; + return this.awakeIdle() && !decision.expandedOnEdge ? 1 : 0; + } + + private awakeRows(plan: ScreenPlan, columns: number): string[] { + const view = this.awakeViewNow(); + if (!view || !plan.awake) return []; + return [plan.awake.slot === 'adjacentRow' ? view.row(columns) : view.reminder.ansi]; + } + + /** The composer edge (border or separator) through the one shared renderer: the rule, plus the accessory when this edge holds it. */ + private composerEdgeRow(kind: 'composerBorder' | 'separator', plan: ScreenPlan, columns: number, now: number): string { + const rule = `${paintDivider(repeatToWidth(GLYPHS.separator, columns), this.promptConfiguration.presentation, now)}${RESET}`; + const accessory = this.edgeAccessory(kind, plan, now); + return accessory ? renderComposerEdge({rule, width: columns, accessory}) : rule; + } + + private edgeAccessory(kind: 'composerBorder' | 'separator', plan: ScreenPlan, now = Date.now()): {ansi: string; width: number} | undefined { + const view = this.awakeViewNow(now); + if (!view || plan.awake?.slot !== (kind === 'composerBorder' ? 'topEdge' : 'bottomEdge')) return undefined; + return plan.awake.expandedOnEdge && view.expanded ? view.expanded : view.compact; + } + + private handleKeepAwakeSlash(command: string, slash: Extract): void { + const controller = this.keepAwake(); + if (slash.op === 'panel') { + this.panelOrigin = undefined; + this.keepAwakePanel = {...createKeepAwakePanel(controller, slash.invalid ? `"${slash.invalid}" is not a Keep Awake mode or duration. Modes: idle, display, system, all; durations like 45s, 30m, 2h.` : undefined), command: command.split(/\s+/u)[0]!}; + } else if (slash.op === 'status') this.output.addFrontendInteraction(command, statusLines(controller).join('\n'), INFO); + else if (slash.op === 'stop') this.output.addFrontendInteraction(command, controller.stop(), INFO); + else { + const result = controller.start(slash.mode!, slash.timeoutSeconds); + if (result.kind === 'needsConfirm') { + // A different mode is running: the panel asks first (default No). + this.keepAwakePanel = {...createKeepAwakePanel(controller), command: command.split(/\s+/u)[0]!}; + requestStart(this.keepAwakePanel, controller, slash.mode!, slash.timeoutSeconds); + } else this.output.addFrontendInteraction(command, describeStart(result), result.kind === 'failed' || result.kind === 'unsupported' ? ERROR : INFO); + } + this.refreshAwake(); + this.render(); + } + 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[] = []; @@ -159,8 +651,10 @@ 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}; + private running?: {command: string; startedAt: number; interrupted: boolean; cleared: boolean; startId: number; cwd: string; historyAllowed?: number; awaitingExec?: boolean}; private hoveredLineIndex?: number; + /** NMSh-owned transcript selection (plain drag); presentation only. */ + private selection?: TranscriptSelection; private focusedLineIndex?: number; private focusedActivityId?: string; private focusedCommandIndex?: number; @@ -168,13 +662,16 @@ 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; + private appearanceHub?: AppearanceHubState; private keyboardState?: KeyboardState; /** * Where the currently-open top-level panel (prompt/transcript/appearance/ @@ -200,26 +697,57 @@ 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); + // The backend this frontend manages, known before the first welcome is drawn. + this.shellId = isShellId(connection?.shell) ? connection.shell : isShellId(connection?.attached?.shell) ? connection.attached.shell as ShellId : 'zsh'; 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); + this.renderer.setCursorStyle(cursorStyleSequence(this.promptConfiguration.cursor.shape, this.promptConfiguration.cursor.blink)); + // Settings and Setup rows read the host's cursor capabilities through the one provider this window owns. + setCursorHostProvider(() => this.cursorFacts()); const dimensions = this.dimensions(); this.session = connection?.client ?? new InProcessSessionClient({cwd: this.initialCwd, columns: dimensions.columns, rows: Math.max(2, dimensions.rows - 4)}); - this.semanticService = new SemanticService(this.initialCwd); + this.semanticService = this.shellId === 'zsh' ? new SemanticService(this.initialCwd) : new PathClassifier(shellAdapter(this.shellId)); + if (this.shellId !== 'zsh') this.bindShellServices(this.shellId, false); this.done = new Promise(resolve => { 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('exec', (command, stamp) => { if (this.inStream(stamp)) this.onShellExec(command, stamp.at); }); + this.session.on('prompt', (marker, stamp) => { + if (this.inStream(stamp)) { + if (marker.knowledge !== undefined) { + this.shellJobs = knowledgeJobCount(marker.knowledge) ?? 0; + this.semanticService.applyShellKnowledge(marker.knowledge); + this.commandSources.clear(); + this.completionService.setShellKnowledge(parseShellKnowledge(marker.knowledge)); + this.rememberShellNames(marker.knowledge); + } + // Host OSC 7 / OSC 133: a projection of this authoritative marker, never the other way round. + this.hostSemantics.prompt(marker.cwd, marker.exitCode); + this.onShellPrompt(marker.exitCode, marker.cwd, stamp.at); + } + }); + this.session.on('exec', (command, stamp) => { if (this.inStream(stamp)) { this.hostSemantics.exec(); 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; + this.hostSemantics.end(); if (event.lost) { // Recorded in the journal before it closes; nothing claims the shell survived. this.lostServiceConnection = true; @@ -232,9 +760,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. */ @@ -267,10 +838,17 @@ export class TerminalApp { * handlers live output uses, and finishReplay() settles the final state. */ private beginReattach(attached: AttachedSession, journal: TranscriptSession | undefined): void { + this.noteActivity(); this.attachedSession = attached; this.replaying = true; this.shellCwd = attached.cwd; this.streamSeq = attached.ackedSeq; + if (attached.knowledge !== undefined) { + this.shellJobs = knowledgeJobCount(attached.knowledge) ?? 0; + this.semanticService.applyShellKnowledge(attached.knowledge); + this.completionService.setShellKnowledge(parseShellKnowledge(attached.knowledge)); + this.rememberShellNames(attached.knowledge); + } if (!journal) return; this.continuedJournal = journal; this.welcomeGeneration += 1; @@ -282,7 +860,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}; + 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}; } } } @@ -301,7 +882,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(); @@ -316,6 +898,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(); @@ -323,10 +906,13 @@ export class TerminalApp { } private onActiveModeChange(mode: PresentationMode): void { + if (this.idle) this.dismissIdle(false); 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); @@ -338,13 +924,26 @@ export class TerminalApp { * zsh started a command line this frontend did not submit (it began while * detached, or came from type-ahead): give it its own transcript block. */ - private onShellExec(command: string, at = Date.now()): void { - if (this.running) return; + private onShellExec(command: string, at = Date.now(), historyAllowed?: number): void { + 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)}); + this.historicalContext(this.shellCwd, this.context, command)); this.tapActivityObserver.reset(this.output.activeOutputStartId ?? startId); - this.running = {command, startedAt: at, interrupted: false, cleared: false, startId, cwd: this.shellCwd}; + this.running = {command, startedAt: at, interrupted: false, cleared: false, startId, cwd: this.shellCwd, historyAllowed}; this.scheduleJournal(); if (!this.replaying) this.render(); } @@ -361,10 +960,29 @@ export class TerminalApp { const outputStartId = this.output.activeOutputStartId; return {sessionId: this.sessionId, seq: this.streamSeq, ...(running && outputStartId !== undefined ? {running: {command: running.command, startedAt: running.startedAt, - cwd: running.cwd, startId: running.startId, outputStartId}} : {})}; + cwd: running.cwd, startId: running.startId, outputStartId, historyAllowed: running.historyAllowed}} : {})}; } 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(); }; + }, + }, undefined, {graphics: shouldProbeGraphics(process.env)}); + 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), @@ -377,16 +995,9 @@ export class TerminalApp { } catch { this.output.addFrontendInteraction('/resume', 'Continuous session journaling could not start; check local storage.', ERROR); } - if (!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) { - this.promptPanelState = {onboarding: true, step: 'provider', selectedIndex: PROVIDER_ORDER.indexOf(this.promptConfiguration.provider), - draft: structuredClone(this.promptConfiguration), saved: structuredClone(this.promptConfiguration)}; - } - this.renderer.enter(); - this.rendererEntered = true; - if (this.passthrough) this.enterAttachedPassthrough(); + this.startOnboarding(); + // 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); @@ -394,25 +1005,34 @@ 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.refreshAwake(); + // Re-apply (or clean up) Theme Bridge state once per launch; nothing happens when it was never used. + this.scheduleThemeBridge(500); this.scheduleWelcomeBlink(); void this.loadHistory(); this.render(); void this.quietUpdateCheck(); + void this.quietToolUpdateCheck(); + this.armIdle(); + 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'); @@ -426,17 +1046,73 @@ 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; } const keys = this.keyDecoder.push(data); - for (const key of keys) this.handleKey(key); + this.scheduleEscapeFlush(); + this.handleDecodedKeys(keys); + }; + + /** The Escape key flush timer (see KeyDecoder.pendingEscape); at most one, cleared by the next input. */ + private escapeFlushTimer?: NodeJS.Timeout; + private static readonly ESCAPE_FLUSH_MS = 35; + + private scheduleEscapeFlush(): void { + if (this.escapeFlushTimer) { clearTimeout(this.escapeFlushTimer); this.escapeFlushTimer = undefined; } + if (!this.keyDecoder.pendingEscape) return; + this.escapeFlushTimer = setTimeout(() => { + this.escapeFlushTimer = undefined; + if (this.stopped) return; + const keys = this.keyDecoder.flush(); + if (keys.length) this.handleDecodedKeys(keys); + }, TerminalApp.ESCAPE_FLUSH_MS); + this.escapeFlushTimer.unref?.(); + } + + private handleDecodedKeys(keys: Key[]): void { + if (this.idle) { + // The idle overlay owns input and passes nothing on. Losing focus keeps it running + // (the terminal may still be visible); focus returning or any real input dismisses it. + if (keys.some(key => key.kind === 'focusIn' || key.kind === 'focusOut')) this.terminalFocus = [...keys].reverse().find(key => key.kind === 'focusIn' || key.kind === 'focusOut')!.kind === 'focusOut' ? 'blurred' : 'focused'; + if (keys.some(key => key.kind !== 'focusOut')) this.dismissIdle(); + return; + } + // Focus reports alone are not user activity (a terminal can report them on its own). + if (keys.some(key => key.kind !== 'focusIn' && key.kind !== 'focusOut')) { + // Pointer motion alone is not interaction for the Keep Awake reminder; keys, clicks, scroll and paste are. + if (keys.some(key => key.kind !== 'focusIn' && key.kind !== 'focusOut' && key.kind !== 'mouseMove')) this.lastUserInput = Date.now(); + this.noteActivity(); + } + for (const key of keys) { + const before = {text: this.editor.text, index: this.editor.displayCursorIndex}; + this.handleKey(key); + this.noteCaretTravel(before); + this.requestCommandContexts(); + } // Passive motion renders only when hover changes; skip the generic frame. if (keys.length === 0 || keys.some(key => key.kind !== 'mouseMove')) this.render(); - }; + } + + /** + * A multi-cell caret jump on the same editor line starts the soft travel trail. Purely visual: the logical caret + * already moved, nothing waits for the animation, and a newer jump retargets the old trail. + */ + private noteCaretTravel(before: {text: string; index: number}): void { + if (this.editor.text !== before.text || this.editor.displayCursorIndex === before.index || this.settingsPanelActive || this.passthrough) return; + const columns = this.dimensions().columns; + const prefix = this.railInputPrefix(columns); + const at = (index: number) => layoutInput(this.editor.displayText, index, this.inputColumns(columns), Number.POSITIVE_INFINITY, prefix); + const from = at(before.index), to = at(this.editor.displayCursorIndex); + if (from.caretRow !== to.caretRow) return; + this.transitions.travel(from.caretColumn, to.caretColumn, to.caretRow, Date.now()); + } private readonly onResize = (): void => { + this.effects.cancel(); + if (this.idle) this.dismissIdle(false); + this.noteActivity(); if (this.externalPassthrough) return; this.renderer.invalidate(); this.lastPtyRows = 0; @@ -457,7 +1133,158 @@ 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 { + // Adjacent typing barely animates the caret; every other movement travels (cursor effects only). + this.caretCause = key.kind === 'text' || key.kind === 'backspace' || key.kind === 'delete' || key.kind === 'paste' ? 'typing' : 'jump'; + 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.keepAwakePanel) { + const result = keepAwakeKey(this.keepAwakePanel, this.keepAwake(), key, this.promptConfiguration.keepAwake); + if (result && typeof result === 'object' && 'settings' in result) { + const next = result.settings; + this.updateConfiguration(configuration => { configuration.keepAwake = next; }); + } else if (result) { + const command = this.keepAwakePanel.command; + this.keepAwakePanel = undefined; + if (typeof result === 'object' && 'done' in result) this.output.addFrontendInteraction(command ?? '/caffeinate', result.done, INFO); + this.refreshAwake(); + this.returnFromPanel(); + } + this.render(); + return; + } + if (this.installPrompt) { + void this.handleInstallPromptKey(key, this.installPrompt); + return; + } + if (this.setupState) { + this.handleSetupKey(key, this.setupState); + return; + } + if (this.themeStudio) { + this.handleThemeStudioKey(key, this.themeStudio); + return; + } + if (this.themeBridgePanel) { + void this.handleThemeBridgeKey(key, this.themeBridgePanel); + return; + } + if (this.rowPanel) { + const action = rowPanelKey(this.rowPanel, key, this.promptConfiguration); + if (action?.kind === 'close') { this.rowPanel = undefined; this.returnFromPanel(); } + else if (action?.kind === 'change') { this.applySettingsConfiguration(action.configuration); this.syncStatusStrip(); } + this.render(); + return; + } + if (this.configureList) { + const tools = registryFacts(); + const action = configureListKey(this.configureList, key, tools); + if (action?.kind === 'close') { this.configureList = undefined; this.returnFromPanel(); } + else if (action?.kind === 'open') { this.configureList = undefined; void this.openConfigure(action.id, '/configure'); } + else if (action?.kind === 'bridge') { this.configureList = undefined; void this.openThemeBridge(); } + this.render(); + return; + } + if (this.tmuxPanel) { + void this.handleTmuxKey(key, this.tmuxPanel); + return; + } + if (this.dotfiles) { + const action = dotfilesKey(this.dotfiles, key); + if (action) void this.handleDotfilesAction(action, this.dotfiles).then(() => this.render()); + this.render(); + return; + } + if (this.screensaverPanel) { + this.handleScreensaverKey(key, this.screensaverPanel); + return; + } + if (this.stopsEditor) { + this.handleStopsEditorKey(key, this.stopsEditor); + return; + } + if (this.chromeEditor) { + const result = chromeEditorKey(this.chromeEditor, key, colorLevel()); + if (result?.kind === 'cancel') { this.chromeEditor = undefined; this.returnFromPanel(); } + else if (result?.kind === 'save' && this.applySettingsConfiguration({...this.promptConfiguration, + uiChrome: {source: 'custom', preset: 'custom', colors: result.colors}})) { this.chromeEditor = undefined; this.returnFromPanel(); } + 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; @@ -542,6 +1369,14 @@ export class TerminalApp { this.render(); return; } + if (this.promptPanelState.step === 'railInsideConfirm' && (key.kind === 'escape' || key.kind === 'interrupt')) { + const state = this.promptPanelState; + state.step = state.railConfirmReturn?.step ?? 'appearance'; + state.selectedIndex = state.railConfirmReturn?.selectedIndex ?? 0; + state.railConfirmReturn = undefined; + this.render(); + return; + } if (this.promptPanelState.step === 'starshipConfirm' && (key.kind === 'escape' || key.kind === 'interrupt')) { this.promptPanelState.step = 'starshipModules'; this.promptPanelState.starshipProposal = undefined; @@ -564,7 +1399,17 @@ export class TerminalApp { if (key.kind === 'escape' && this.promptPanelState.step === 'modules') { // Esc leaves the module manager, keeping its draft edits for the final save. this.promptPanelState.step = 'appearance'; - this.promptPanelState.selectedIndex = APPEARANCE_MODULES_ROW; + this.promptPanelState.selectedIndex = appearanceModulesRow(this.promptPanelState.draft); + this.render(); + } else if (this.promptPanelState.step === 'gradient' && (key.kind === 'escape' || key.kind === 'enter' || key.kind === 'interrupt')) { + // The stop editor owns Enter (edit/apply) and Esc (cancel an edit, else return with its stops). + const editing = this.promptPanelState.gradient?.editing !== undefined; + if (key.kind === 'enter' || editing) handlePromptPanelKey(key.kind === 'interrupt' ? {kind: 'escape'} : key, this.promptPanelState); + else closeGradientEditor(this.promptPanelState); + this.render(); + } else if (promptPanelOwnsKey(this.promptPanelState, key)) { + // Typing a custom glyph owns Enter and Esc until it is set or cancelled. + handlePromptPanelKey(key, this.promptPanelState); this.render(); } else if (key.kind === 'escape' || key.kind === 'interrupt') { if (this.promptPanelState.onboarding) void this.savePromptSettings(); @@ -574,6 +1419,117 @@ export class TerminalApp { } else if (handlePromptPanelKey(key, this.promptPanelState)) this.render(); return; } + if (this.findState?.editing && !this.settingsPanelActive && this.handleFindKey(key)) return; + if (this.openPanel) { + const panel = this.openPanel; + const action = openPanelKey(panel, key); + if (action === 'close') { this.openPanel = undefined; this.returnFromPanel(); } + else if (action === 'open') { + const reference = panel.references[panel.selected]!; + this.openPanel = undefined; + void this.openLocation(`/open ${reference.text}`, `${reference.path}:${reference.line ?? ''}${reference.column ? `:${reference.column}` : ''}`.replace(/:$/u, ''), reference.cwd); + } + this.render(); + return; + } + if (this.cursorPanel) { this.handleCursorPanelKey(key, this.cursorPanel); this.render(); return; } + if (this.watchPanel) { + const panel = this.watchPanel; + const action = watchPanelKey(panel, key, this.watches.active()); + if (action?.kind === 'close') { this.watchPanel = undefined; this.returnFromPanel(); } + else if (action?.kind === 'confirm' && panel.confirm) { + const pending = panel.confirm; + panel.confirm = undefined; + const safety = watchSafety(pending.command); + if (safety.kind === 'refused') panel.message = safety.reason; + else { this.watches.start(pending.command, pending.cwd, pending.intervalMs, safety); panel.selected = this.watches.active().length - 1; } + } else if (action?.kind === 'pause') this.watches.pause(action.id); + else if (action?.kind === 'resume') this.watches.resume(action.id); + else if (action?.kind === 'now') this.watches.runNow(action.id); + else if (action?.kind === 'stop') this.watches.stop(action.id); + this.render(); + return; + } + if (this.doctorPanel) { + const action = doctorKey(this.doctorPanel, key); + if (action?.kind === 'close') { this.doctorPanel = undefined; this.returnFromPanel(); } + else if (action?.kind === 'rerun') void this.openDoctor(); + else if (action?.kind === 'action') { + // Doctor only opens things; any change still goes through that surface's own confirmation. + this.doctorPanel = undefined; + if (action.action.kind === 'slash') { const parsed = parseSlashCommand(action.action.command); if (parsed) void this.runSlash(action.action.command, parsed); } + else this.openAsk(action.action.request); + } + this.render(); + return; + } + if (this.understandingPanel) { + const action = understandingKey(this.understandingPanel, key, this.understandingFacts()); + if (action) void this.handleUnderstandingAction(action); + this.render(); + return; + } + if (this.providersOverview) { + const action = providersOverviewKey(this.providersOverview, key, {configuration: this.promptConfiguration, statuses: this.providerStatuses}); + if (action?.kind === 'close') { this.providersOverview = undefined; this.returnFromPanel(); } + else if (action?.kind === 'detect') void this.refreshProvidersOverview(true); + else if (action?.kind === 'open') this.openProviderFamily(action.row); + else if (action?.kind === 'select') this.selectProviderInline(action.family, action.id); + else if (action?.kind === 'install') void this.installProviderInline(action.family, action.id); + this.render(); + return; + } + if (this.agentView) { this.handleAgentViewKey(key); return; } + if (this.pasteReview && this.pastePreview) { + // The bounded review surface: Enter inserts the original text (nothing runs), Esc returns to the compact preview. + const outcome = pasteReviewKey(this.pasteReview, key, this.dimensions().rows - 4); + if (outcome === 'insert') { const text = this.pastePreview.text; this.pasteReview = undefined; this.pastePreview = undefined; this.editor.insertPaste(text); this.selectedSuggestion = 0; } + else if (outcome === 'back') { this.pasteReview = undefined; if (!this.pasteCompactFits()) this.pastePreview = undefined; } + else if (outcome === 'cancel') { this.pasteReview = undefined; this.pastePreview = undefined; } + this.render(); + return; + } + if (this.pastePreview) { + const preview = this.pastePreview; + if (key.kind === 'enter') { this.pastePreview = undefined; this.editor.insertPaste(preview.text); this.selectedSuggestion = 0; } + else if (key.kind === 'text' && key.value.toLowerCase() === 'r') this.pasteReview = createPasteReview(preview.text, preview.analysis); + else if (key.kind === 'escape' || key.kind === 'interrupt') this.pastePreview = undefined; + this.render(); + return; + } + if (this.shelf.focused && this.handleShelfKey(key)) return; + if (this.agentPanel) { this.handleAgentPanelKey(key); return; } + if (this.askState) { + const event = askKey(this.askState, key, this.dimensions().columns); + if (event) void this.handleAskEvent(event); + this.render(); + return; + } + if (this.shellPanel) { + const action = shellPanelKey(this.shellPanel, key); + if (action?.kind === 'close') { this.shellPanel = undefined; this.returnFromPanel(); } + else if (action?.kind === 'switch') { this.shellPanel = undefined; void this.switchShell(action.shell, '/shell'); } + else if (action?.kind === 'install') void this.installShell(action.shell, action.install); + else if (action?.kind === 'indicatorVisibility' || action?.kind === 'indicatorSide') { + this.updateConfiguration(configuration => applyShellIndicatorAction(configuration, action)); + this.shellPanel.promptConfiguration = this.promptConfiguration; + } + else if (action?.kind === 'default') { + this.updateConfiguration(configuration => { configuration.shellBackend = action.shell; }); + this.shellPanel.defaultShell = action.shell; + this.shellPanel.message = `${shellAdapter(action.shell).label} is now the default for new sessions. This session is unchanged.`; + } + this.render(); + return; + } + if (this.aboutPanel) { + // Read-only panel: any key closes it, and its image goes with it. + this.aboutPanel = undefined; + this.renderer.setImageOverlay(undefined); + this.returnFromPanel(); + this.render(); + return; + } if (this.resumeBrowser) { const browser = this.resumeBrowser; if (browser.confirmKill) { @@ -584,9 +1540,12 @@ export class TerminalApp { } if (key.kind === 'escape' || key.kind === 'interrupt') { this.resumeBrowser = undefined; + } else if (key.kind === 'complete' && browser.liveOnly) { + this.resumeBrowser = undefined; + this.agentPanel = {selected: 0}; } else if (key.kind === 'deleteLineAfter') { const selection = resumeSelection(browser); - if (selection?.kind === 'live' && selection.session.state === 'detached') browser.confirmKill = selection.session.id; + if (selection?.kind === 'live' && selection.session.state === 'detached' && selection.session.id !== browser.currentId) browser.confirmKill = selection.session.id; } else if (key.kind === 'up') { browser.selectedIndex = Math.max(0, browser.selectedIndex - 1); } else if (key.kind === 'down') { @@ -603,12 +1562,21 @@ export class TerminalApp { browser.selectedIndex = 0; } else if (key.kind === 'enter') { const selection = resumeSelection(browser); - if (selection?.kind === 'live') this.switchToLiveSession(selection.session.id, selection.session.state); + if (selection?.kind === 'live' && selection.session.id === browser.currentId) { + this.resumeBrowser = undefined; + this.output.addFrontendInteraction('/sessions', 'That is this window\'s session; nothing to switch.', INFO); + } else if (selection?.kind === 'live') this.switchToLiveSession(selection.session.id, selection.session.state); else void this.resumeSelectedSession(); } this.render(); return; } + if (key.kind === 'mouseDrag' || key.kind === 'mouseRelease') { this.handleSelectionPointer(key.kind, key.y); return; } + // Any other key or click ends a finished selection (its text is already on the clipboard). + if (this.selection && !this.selection.dragging && key.kind !== 'wheelUp' && key.kind !== 'wheelDown' && key.kind !== 'mouseMove') { + this.selection = undefined; + this.render(); + } if (key.kind === 'mouseMove' || key.kind === 'mouseClick') { const {columns, rows} = this.dimensions(); // Hit-test against the same plan render paints; Shift+mouse never reaches here (native selection). @@ -634,7 +1602,16 @@ export class TerminalApp { } } else if (localVisibleIndex >= 0) { const row = wrapped[viewStart + localVisibleIndex]; + // A press may start a drag selection; a plain click still acts exactly as before. + if (key.kind === 'mouseClick' && row && key.y) this.selection = beginSelection(viewStart + localVisibleIndex, key.y); 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(); @@ -662,21 +1639,22 @@ export class TerminalApp { } return; } - if (this.appearanceState) { - if (key.kind === 'escape' || key.kind === 'interrupt') { - this.appearanceState = undefined; - this.output.addHistoryLine(`${STOPPED}✻ Appearance configuration cancelled${RESET}`); - this.returnFromPanel(); - this.render(); - return; - } - if (key.kind === 'enter') { - void this.saveAppearance(); - return; - } - if (handleAppearanceKey(key, this.appearanceState)) { - this.render(); + if (this.appearanceHub) { + const action = appearanceHubKey(this.appearanceHub, key, this.promptConfiguration); + if (action?.kind === 'close') { this.appearanceHub = undefined; this.returnFromPanel(); } + else if (action?.kind === 'motion') this.updateConfiguration(configuration => { configuration.motion = action.motion; }); + else if (action?.kind === 'saveHost') void this.saveAppearance(); + else if (action?.kind === 'open') { + // The canonical editors; /appearance never duplicates them. + this.appearanceHub = undefined; + if (action.destination === 'prompt') void this.startPromptSettings(false); + else if (action.destination === 'theme') this.openThemeStudio(); + else if (action.destination === 'themeBridge') void this.openThemeBridge(); + else if (action.destination === 'cursor') this.openCursorPanel(); + else if (action.destination === 'chroma') this.startChromaSettings(); + else this.focusConfigRow('uiChrome'); } + this.render(); return; } if (this.keyboardState) { @@ -703,6 +1681,7 @@ export class TerminalApp { } if (key.kind === 'wheelUp') { this.scrollLines(-3); + this.followSelectionPointer(); return; } if (key.kind === 'pageDown') { @@ -711,6 +1690,7 @@ export class TerminalApp { } if (key.kind === 'wheelDown') { this.scrollLines(3); + this.followSelectionPointer(); return; } if (key.kind === 'latest') { @@ -723,10 +1703,26 @@ 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(() => { + this.historyQuery = undefined; + this.historyResults = []; + this.render(); + }).catch(() => { this.output.addFrontendInteraction('/history', 'Could not persist history deletion.', ERROR); this.render(); }); + return; + } if (key.kind === 'historySearch') { if (!this.running) { - this.editor.clear(); - this.editor.insert(HISTORY_SEARCH); + void this.openHistoryPicker(''); } return; } @@ -742,6 +1738,7 @@ export class TerminalApp { return; } if (key.kind === 'interrupt') { + this.clearCorrection(); if (this.running) { this.running.interrupted = true; this.editor.clear(); @@ -752,6 +1749,13 @@ export class TerminalApp { } return; } + if (key.kind === 'find') { + // Transcript find while NMSh owns the idle composer; a running command + // still receives the byte, exactly as it would without NMSh. + if (this.running) this.session.write('\u0006'); + else { this.openFindEditor(); this.render(); } + return; + } if (key.kind === 'suspend') { // Job control belongs to zsh: forward ^Z so it stops the foreground job. // With no foreground command there is nothing to suspend, so the idle @@ -769,20 +1773,75 @@ export class TerminalApp { return; } + 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); + // Up from the first candidate leaves the menu for shell history, as Up + // from an editor's first line does; Down still enters the menu. + if (action?.id === 'move' && !(key.kind === 'up' && this.selectedSuggestion === 0)) { + this.selectedSuggestion = (this.selectedSuggestion + (key.kind === 'down' ? 1 : -1) + this.shellSuggestions.length) % this.shellSuggestions.length; + return; + } + if (action?.id === 'insert') { + const candidate = this.shellSuggestions[this.selectedSuggestion] ?? this.shellSuggestions[0]; + if (candidate) { + if (this.promptConfiguration.picker !== 'native' && this.shellSuggestions.length > 1) void this.openCompletionPicker(); + else this.applySuggestion(candidate); + } + return; + } + if (action?.id === 'cancel') { + this.completionGeneration += 1; + this.completionService.cancel(); + this.shellSuggestions = []; + return; + } + } + if (!this.running && this.handleSuggestionKey(key)) { this.selectedSuggestion = 0; return; } // 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('/'); - if (key.kind === 'up' && suggestions.length > 0) { + // /history and /dirs own Up/Down. Slash suggestions behave like the shell + // completion menu: Down enters it, Up from its first row (or before + // entering it) leaves it for command history. + const searchSurface = this.historySearchActive || this.directorySearchActive; + // A bottom composer lists picker results above the query, best match nearest it: Up moves away from the input. + if (searchSurface && this.pickerFromBottom() && (key.kind === 'up' || key.kind === 'down')) key = {...key, kind: key.kind === 'up' ? 'down' : 'up'} as Key; + const inSlashMenu = this.slashMenuFor === this.editor.text; + if (key.kind === 'up' && suggestions.length > 0 && (searchSurface || (inSlashMenu && this.selectedSuggestion > 0))) { this.selectedSuggestion = (this.selectedSuggestion - 1 + suggestions.length) % suggestions.length; - } else if (key.kind === 'down' && suggestions.length > 0) { - this.selectedSuggestion = (this.selectedSuggestion + 1) % suggestions.length; + } else if (key.kind === 'down' && suggestions.length > 0 && (searchSurface || isSlash)) { + if (!searchSurface && !inSlashMenu) { this.slashMenuFor = this.editor.text; this.selectedSuggestion = 0; } + else this.selectedSuggestion = (this.selectedSuggestion + 1) % suggestions.length; + } else if (key.kind === 'up' && isSlash && !searchSurface && suggestions.length > 0 && this.leaveSlashMenu()) { + // Left the slash menu; fall through to history recall below. + const {columns} = this.dimensions(); + if (!this.editor.moveUp(this.inputColumns(columns), this.railInputPrefix(columns)) && !this.running) this.recallHistory('previous'); } else if (key.kind === 'complete') { const action = tabCompletionAction(this.shellSuggestions.length, isSlash ? suggestions.length : 0); if (action === 'shell-suggestion') { @@ -790,13 +1849,22 @@ 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') { this.editor.insert(key.value); this.selectedSuggestion = 0; } else if (key.kind === 'paste') { + // Paste Guard: worth-a-look pastes are previewed first (never changed); ordinary ones insert at once. + const analysis = analyzePaste(key.value); + if (needsPreview(analysis, this.promptConfiguration.pastePreview)) { + this.pastePreview = {text: key.value, analysis}; + // The compact strip never squeezes the composer: when this screen has no room for it, Review is the surface. + if (!this.pasteCompactFits()) this.pasteReview = createPasteReview(key.value, analysis); + this.render(); + return; + } this.editor.insertPaste(key.value); this.selectedSuggestion = 0; } else if (key.kind === 'focusNext' || key.kind === 'focusPrevious') { @@ -810,8 +1878,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}`; @@ -848,7 +1917,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); @@ -861,8 +1930,9 @@ export class TerminalApp { } return; } + else if (key.kind === 'left' && this.composerIdle()) { void this.openSessionsView(); return; } else if (key.kind === 'left') this.editor.moveLeft(); - else if (key.kind === 'right') this.editor.moveRight(); + else if (key.kind === 'right') this.acceptingGhost(() => this.editor.moveRight()); else if (key.kind === 'selectLeft') this.editor.selectLeft(); else if (key.kind === 'selectRight') this.editor.selectRight(); else if (key.kind === 'wordLeft') this.editor.wordLeft(); @@ -871,23 +1941,25 @@ export class TerminalApp { else if (key.kind === 'selectWordRight') this.editor.selectWordRight(); else if (key.kind === 'up') { const {columns} = this.dimensions(); - this.editor.moveUp(columns, this.inputFirstLinePrefix(columns)); + if (!this.editor.moveUp(this.inputColumns(columns), this.railInputPrefix(columns)) && !this.running) this.recallHistory('previous'); } else if (key.kind === 'selectUp') { const {columns} = this.dimensions(); - this.editor.selectUp(columns, this.inputFirstLinePrefix(columns)); + this.editor.selectUp(this.inputColumns(columns), this.railInputPrefix(columns)); } else if (key.kind === 'down') { const {columns} = this.dimensions(); - this.editor.moveDown(columns, this.inputFirstLinePrefix(columns)); + // At the newest, empty composer ↓ has nothing to do: it reveals the agent shelf (and a second ↓ focuses it). + if (!this.editor.text && !this.composerHistory.active && this.agents.sessions.length && !this.running) { this.revealShelf(); this.render(); return; } + if (!this.editor.moveDown(this.inputColumns(columns), this.railInputPrefix(columns)) && !this.running) this.recallHistory('next'); } else if (key.kind === 'selectDown') { const {columns} = this.dimensions(); - this.editor.selectDown(columns, this.inputFirstLinePrefix(columns)); + this.editor.selectDown(this.inputColumns(columns), this.railInputPrefix(columns)); } else if (key.kind === 'lineHome') this.editor.lineHome(); else if (key.kind === 'selectLineHome') this.editor.selectLineHome(); - else if (key.kind === 'lineEnd') this.editor.lineEnd(); + else if (key.kind === 'lineEnd') this.acceptingGhost(() => this.editor.lineEnd()); else if (key.kind === 'selectLineEnd') this.editor.selectLineEnd(); else if (key.kind === 'bufferHome') this.editor.moveBufferHome(); - else if (key.kind === 'bufferEnd') this.editor.moveBufferEnd(); + else if (key.kind === 'bufferEnd') this.acceptingGhost(() => this.editor.moveBufferEnd()); else if (key.kind === 'selectBufferHome') this.editor.selectBufferHome(); else if (key.kind === 'selectBufferEnd') this.editor.selectBufferEnd(); else if (key.kind === 'backspace') this.editor.backspace(); @@ -908,7 +1980,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 { @@ -919,29 +1994,117 @@ export class TerminalApp { } - private async fetchSuggestions(): Promise { - if (this.running || this.editor.hasPasteAtoms || this.editor.text.startsWith('/')) { - this.shellSuggestions = []; - return; - } - const input = this.editor.text; - if (input === this.lastSuggestionInput) return; - this.lastSuggestionInput = input; + /** + * The highlighted candidate's description: structured completion text, + * local knowledge, the session-identity fallback (alias/function bodies are + * never shown), or a local man-page summary looked up off the keypress path. + */ + private completionDescription(candidate: CompletionCandidate): string { + if (candidate.description) return candidate.description; + if (candidate.kind !== 'command') return ''; + const known = localKnowledge(candidate.value, candidate.value, true)?.description ?? identityDescription(candidate); + if (known) return known; + const cached = this.commandDescriptions.cached(candidate.value); + if (cached !== undefined) return cached; + void this.commandDescriptions.request(candidate.value).then(description => { + // Every visible row shows its description, so any candidate still listed repaints. + if (description && !this.stopped && this.shellSuggestions.some(item => item.value === candidate.value)) this.render(); + }); + return ''; + } - if (input.trim().length === 0) { - this.shellSuggestions = []; - this.render(); - return; + /** Command-name use from the eligible history index, recomputed only when it changed. */ + private refreshCommandUsage(): void { + const index = this.historyService.index; + if (index.version === this.commandUsageVersion) return; + this.commandUsageVersion = index.version; + const usage = new Map(); + const entries = index.all(); + for (let position = 0; position < Math.min(entries.length, 5000); position += 1) { + const entry = entries[position]!; + const name = /^\s*([^\s=;|&()<>]+)(?=\s|$)/u.exec(entry.command)?.[1]; + if (!name) continue; + const known = usage.get(name); + if (known) known.count += 1; + else usage.set(name, {count: 1, last: entry.at ?? 0}); } + this.completionService.setCommandUsage(usage); + } + + /** + * Up past the editor's first row recalls older commands; Down past its last + * row walks newer ones and finally restores the unsent draft. Only text is + * placed in the composer: nothing runs until Enter. + */ + private recallHistory(direction: 'previous' | 'next'): void { + if (this.editor.hasPasteAtoms) return; + const current = this.editor.text; + const text = direction === 'previous' + ? this.composerHistory.previous(current, () => recallSource(this.sessionSubmissions, [...historyCommands(this.historyService.index.all())])) + : this.composerHistory.next(current); + if (text === undefined) return; + this.editor.replaceText(text); + this.selectedSuggestion = 0; + this.shellSuggestions = []; + } - const comps = await this.completionService.suggest(input, this.context.cwd); - if (this.editor.text === input) { + 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; + // A recalled command is not being typed: no completion menu claims Up/Down until it is edited. + const eligible = !this.running && !this.settingsPanelActive && !this.editor.hasPasteAtoms && !input.startsWith('/') && Boolean(input.trim()) + && !this.composerHistory.showing(input); + const key = eligible ? JSON.stringify([input, cursor, cwd]) : ''; + if (key === this.lastSuggestionInput) return; + this.lastSuggestionInput = key; + const generation = ++this.completionGeneration; + this.completionService.cancel(); + // Clear before the next frame: results for another buffer must never flash. + this.shellSuggestions = []; + this.selectedSuggestion = 0; + if (!eligible) return; + this.refreshCommandUsage(); + const comps = await this.completionService.suggest(input, cwd, cursor); + if (!this.stopped && generation === this.completionGeneration && this.editor.text === input && this.context.cwd === cwd + && this.completionCursor === cursor && !this.running && !this.settingsPanelActive && !this.editor.hasPasteAtoms) { this.shellSuggestions = comps; - this.selectedSuggestion = 0; 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, process.env, await this.fzfThemeArgs(), this.fzfLayout()); + 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 @@ -956,36 +2119,247 @@ 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, process.env, await this.fzfThemeArgs(), this.fzfLayout()); + 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, process.env, await this.fzfThemeArgs(), this.fzfLayout()); + 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(); + } + }; + + /** An accepted ghost suggestion became real text: it materializes like a completion. */ + private acceptingGhost(move: () => void): void { + const before = graphemes(this.editor.text).length; + const text = this.editor.text; + move(); + const after = graphemes(this.editor.text).length; + if (after > before && this.editor.text.startsWith(text)) this.transitions.materialize(before, after, this.editor.text, Date.now()); + } + + private applySuggestion(suggestion: {insertion: string; insertionCursor?: number}): void { + const before = graphemes(this.editor.text); this.editor.clear(); this.editor.insert(suggestion.insertion); + // Completion Materialization: only the newly inserted graphemes (common prefix and suffix kept). + const after = graphemes(this.editor.text); + let start = 0; + while (start < before.length && start < after.length && before[start] === after[start]) start += 1; + let tail = 0; + while (tail < before.length - start && tail < after.length - start && before[before.length - 1 - tail] === after[after.length - 1 - tail]) tail += 1; + this.transitions.materialize(start, after.length - tail, this.editor.text, Date.now()); + 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|confetti [top|bottom] · /effects stop · Escape cancels. Owned gaps/rules only; Reduced Motion and Decorative 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 === 'motion') { + // The same Motion screen and state as /appearance → Motion. + this.panelOrigin = undefined; + await this.startAppearance(); + if (this.appearanceHub) { this.appearanceHub.view = 'motion'; this.appearanceHub.selected = 0; this.appearanceHub.previewStart = Date.now(); } + } else if (slash.kind === 'prompt') { this.panelOrigin = undefined; await this.startPromptSettings(false); } + else if (slash.kind === 'chroma') { this.panelOrigin = undefined; this.startChromaSettings(); } + else if (slash.kind === 'screensaver') { + this.panelOrigin = undefined; + if (slash.start) { + if (slash.mode) this.applySettingsConfiguration({...this.promptConfiguration, idleVisuals: {...this.promptConfiguration.idleVisuals, mode: slash.mode}}); + this.startIdle(true); + } else this.openScreensaverGallery(); + } + // The cursor has one configuration: /cursor opens its existing Settings rows. + else if (slash.kind === 'cursor') this.openCursorPanel(); + else if (slash.kind === 'activity') { this.panelOrigin = undefined; this.focusConfigRow('activityColors'); } + else if (slash.kind === 'theme') { this.panelOrigin = undefined; this.openThemeStudio(); } + else if (slash.kind === 'themeBridge') { this.panelOrigin = undefined; await this.openThemeBridge(); } + // Short routes into the canonical surfaces: no second editor and no second state anywhere. + else if (slash.kind === 'chrome') { this.panelOrigin = undefined; this.focusConfigRow('uiChrome'); } + else if (slash.kind === 'glyphs') { + this.panelOrigin = undefined; + this.settingsPanelState = {section: 'appearance', selectedIndex: this.promptConfiguration.glyphStyle === 'nerd' ? 0 : 1, glyphStyle: this.promptConfiguration.glyphStyle, onboarding: false}; + } + else if (slash.kind === 'statusStrip') { this.panelOrigin = undefined; this.openStatusStrip(); } + else if (slash.kind === 'configure') { this.panelOrigin = undefined; await this.openConfigure(slash.tool, command); } + else if (slash.kind === 'integrations') { this.panelOrigin = undefined; await this.openIntegrations(); } + else if (slash.kind === 'dotfiles') { this.panelOrigin = undefined; this.dotfiles = createDotfilesPanel(slash.source ?? '~/dotfiles'); if (slash.source) await this.handleDotfilesAction({kind: 'scan', source: slash.source}, this.dotfiles); this.render(); } else if (slash.kind === 'settings') this.openSettingsPanel(slash.view); + else if (slash.kind === 'tools') { this.panelOrigin = undefined; this.startTools(); } + else if (slash.kind === 'keepAwake') this.handleKeepAwakeSlash(command, slash); + else if (slash.kind === 'setup') { this.panelOrigin = undefined; this.startSetup(slash.entry); } 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(); } else if (slash.kind === 'keyboard') { this.panelOrigin = undefined; await this.startKeyboard(); } - else if (slash.kind === 'zsh') this.leaveForOrdinaryZsh(); - else if (slash.kind === 'version') this.output.addFrontendInteraction(command, formatBuildIdentity(this.buildIdentity), INFO); + else if (slash.kind === 'handoff') this.leaveForOrdinaryShell(slash.shell ?? this.promptConfiguration.shellBackend, command); + else if (slash.kind === 'version') this.output.addFrontendInteraction(command, `${formatBuildIdentity(this.buildIdentity)}\nInstalled ${installProvenanceLabel()}`, 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 === 'sessions') await this.openSessionsView(); + else if (slash.kind === 'ai') this.openAi(command, slash.target); 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 === 'agents') this.runAgentsCommand(command, slash.action); + else if (slash.kind === 'about') { this.panelOrigin = undefined; this.openAbout(); } + else if (slash.kind === 'find') this.findCommand(command, slash.arguments); + else if (slash.kind === 'open') { + if (slash.target) await this.openLocation(command, slash.target, this.shellCwd); + else { this.panelOrigin = undefined; this.openPanel = {references: this.recentReferences(), selected: 0, editor: this.hostActions().label}; } + } + else if (slash.kind === 'openDiff') await this.openDiff(command, slash.left, slash.right); + else if (slash.kind === 'filter') this.applyFilterCommand(command, slash.arguments); + else if (slash.kind === 'shell') { + if (slash.shell) await this.switchShell(slash.shell, command); + else this.openShellPanel(); + } + else if (slash.kind === 'notices') await this.runNoticesCommand(command, slash.action); + 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 if (slash.kind === 'ask') this.openAsk(slash.request, /^\/ask(?:\s|$)/u.test(command) ? '/ask' : '/btw'); + else if (slash.kind === 'providers') this.openProvidersOverview(slash.family); + else if (slash.kind === 'llm') this.openUnderstandingPanel(); + else if (slash.kind === 'doctor') void this.openDoctor(); + else if (slash.kind === 'watch') this.handleWatch(command, slash.op, slash.arguments); + else if (slash.kind === 'rename') { + if (this.sessionMode !== 'service' || !this.sessionId) this.output.addFrontendInteraction(command, 'Renaming needs a live session (this one runs in-process).', INFO); + else { + try { + const info = await renameSession(socketPathFor(defaultRuntimeDir(process.env)), this.sessionId, slash.name); + this.output.addFrontendInteraction(command, info ? `This session is ${info.name || info.signature || 'unnamed'}${info.name && info.signature ? ` (signature ${info.signature})` : ''}.` : 'The session service did not answer.', INFO); + } catch { this.output.addFrontendInteraction(command, 'The session service did not answer; nothing changed.', ERROR); } + } + } 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 === 'explain') { this.explainBlock = record.startId; this.openAsk('why did this fail'); } + 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. */ @@ -994,6 +2368,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); @@ -1027,18 +2404,36 @@ 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, skipInstallCheck = false): Promise { + this.clearCorrection(); const command = this.editor.text; + const slash = realShell ? undefined : parseSlashCommand(command); + if (!slash && !skipInstallCheck && !this.startupPending && installCandidate(command, this.promptConfiguration)) { + // Real zsh resolution decides first; the composer keeps the text while it is asked. + this.preparingCommand = true; + let offered = false; + try { offered = await this.offerInstallFor(command); } finally { this.preparingCommand = false; } + if (this.stopped) return; + if (offered) { this.render(); return; } + if (this.editor.text !== command) return; + } this.editor.clear(); + this.composerHistory.reset(); if (!command.trim()) return; + // Valid NMSh commands are recalled with the session; unknown slash input runs as typed and follows shell history. + // With Ask recording off, the request text is not kept for recall either. + if (!(slash?.kind === 'ask' && !this.promptConfiguration.askRecord)) this.sessionSubmissions.push({text: command, slash: Boolean(slash && slash.kind !== 'unknown')}); + if (this.sessionSubmissions.length > SESSION_SUBMISSION_LIMIT) this.sessionSubmissions.shift(); - const slash = parseSlashCommand(command); if (slash) { await this.runSlash(command, slash); this.render(); @@ -1046,30 +2441,35 @@ 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); } this.render(); - }, {cwd: this.shellCwd, project: contextAtSubmission.project, branch: contextAtSubmission.branch, - prompt: this.currentPromptSnapshot(command)}); + }, this.historicalContext(this.shellCwd, contextAtSubmission, command)); this.tapActivityObserver.reset(this.output.activeOutputStartId ?? startId); 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}; + // Command Transfer: the command already went to the shell; the sweep only presents the handoff. + if (!this.replaying && !this.askState) this.transitions.launch(startedAt); 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); @@ -1079,18 +2479,20 @@ export class TerminalApp { } else { this.session.submit(command); } + // After the command is on its way: one sweep acknowledging the submission (never delays it). + if (!this.passthrough) this.startSweep('prompt', 'vivid'); this.render(); } private async saveAppearance(): Promise { - if (!this.appearanceState) return; - const state = this.appearanceState; - this.appearanceState = undefined; - + const hub = this.appearanceHub; + const state = hub?.host; + if (!hub || !state || !this.host.integration) return; + hub.hostDirty = false; 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 @@ -1100,7 +2502,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}`); @@ -1110,32 +2512,10 @@ 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); - 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; + 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; } this.keyboardState = { selectedIndex: 0 }; this.render(); @@ -1146,14 +2526,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}`); @@ -1161,30 +2541,20 @@ export class TerminalApp { this.render(); } + /** /appearance: the visual hub. NMSh rows always work; host window rows edit opacity/blur only where the host supports it. */ 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; - } - - const settings = await readGhosttySettings(); - this.appearanceState = { - opacity: settings.opacity, - blurModeIndex: Math.max(0, BLUR_MODES.indexOf(settings.blurMode)), - blurStrength: settings.blurStrength, - selectedIndex: 0 - }; + let host: AppearanceState | undefined; + if (this.host.capabilities.appearanceIntegration && this.host.integration) { + const settings = await this.host.integration.readAppearance(); + host = {opacity: settings.opacity, blurModeIndex: Math.max(0, BLUR_MODES.indexOf(settings.blurMode)), blurStrength: settings.blurStrength, selectedIndex: 0}; + } + this.appearanceHub = createAppearanceHub(this.host.name, host, this.host.appearanceGuidance); this.render(); } private async copyRecent(index: number): Promise { const command = index === 1 ? '/copy' : `/copy ${index}`; - const record = this.output.recent(index); + const record = this.output.recentShell(index); if (!record) { this.output.addFrontendInteraction(command, `No completed command output at /copy ${index}`, ERROR); return; @@ -1193,8 +2563,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); } } @@ -1211,14 +2581,18 @@ export class TerminalApp { }); } - private async startFreshPresentation(): Promise { + /** + * Archive the current presentation (it stays available in /resume) and + * start a fresh one with a new welcome. Used by /clear and /shell. + */ + private async startFreshPresentation(command = '/clear'): Promise { if (this.running) { - this.output.addFrontendInteraction('/clear', 'Wait for the foreground command to finish before clearing the transcript.', INFO); - return; + this.output.addFrontendInteraction(command, 'Wait for the foreground command to finish before clearing the transcript.', INFO); + return false; } try { await this.archiveCurrentPresentation(); } catch { - this.output.addFrontendInteraction('/clear', 'Could not archive this transcript; the current view was kept.', ERROR); - return; + this.output.addFrontendInteraction(command, 'Could not archive this transcript; the current view was kept.', ERROR); + return false; } this.output.clearPresentation(); this.presentationStartCwd = this.shellCwd; @@ -1226,8 +2600,9 @@ export class TerminalApp { this.historyViewport.latest(); try { await this.journal?.start(); this.journalActive = Boolean(this.journal); } catch { this.journalActive = false; - this.output.addFrontendInteraction('/clear', 'A fresh view started, but its journal could not be persisted yet.', ERROR); + this.output.addFrontendInteraction(command, 'A fresh view started, but its journal could not be persisted yet.', ERROR); } + return true; } /** Session id the launcher should attach after this frontend detaches. */ @@ -1266,6 +2641,48 @@ export class TerminalApp { this.render(); } + /** /sessions: live sessions right now, through the same browser /resume uses (live-only mode). */ + private async openSessionsView(): Promise { + if (this.sessionMode !== 'service') { + this.output.addFrontendInteraction('/sessions', 'This window runs its shell in-process (no session service), so it is the only live session it can see. /resume lists archived transcripts.', INFO); + return; + } + let live: SessionInfo[] = []; + try { live = await listLiveSessions(); } catch { + this.output.addFrontendInteraction('/sessions', 'The session service did not answer; try again.', ERROR); + return; + } + this.panelOrigin = undefined; + this.resumeBrowser = createSessionsView(live, this.sessionId); + } + + private sessionsViewRows(browser: ResumeBrowserState, columns: number): string[] { + const now = Date.now(); + const safe = getCurrentGlyphMode() === 'safe'; + const visible = visibleLiveSessions(browser); + const rows = liveSessionRows(visible, browser.currentId, now); + const out = [`${PRIMARY} Sessions${RESET} ${SUBTLE}live now · archived transcripts are in /resume${RESET}`, '']; + if (browser.query) out.push(`${SECONDARY} Search: ${browser.query}${RESET}`, ''); + if (!rows.length) out.push(` ${SUBTLE}No live NMSh sessions${browser.query ? ' match' : ''}.${RESET}`); + rows.forEach((row, index) => { + const selected = index === browser.selectedIndex; + const marker = row.current ? (safe ? '*' : '●') : row.session.state === 'detached' ? (safe ? '-' : '◌') : (safe ? 'o' : '○'); + const color = row.state === 'failed' ? ERROR : row.state === 'attention' ? ACCENT : row.state === 'completed' ? SUCCESS : SECONDARY; + const who = row.agent ? `${agentColor(row.agent.color)}${safe ? row.agent.safeGlyph : row.agent.glyph} ${row.agent.short}${RESET} ` : ''; + out.push(truncateAnsi(`${selected ? `${ACCENT}›` : ' '} ${marker} ${PRIMARY}${(row.current ? 'this' : `#${row.ordinal}`).padEnd(5)}${RESET}${SECONDARY}${row.shell.padEnd(5)}${RESET} ` + + `${color}${row.stateLabel.padEnd(16)}${RESET}${who}${focusForeground(selected)}${row.summary}${RESET}`, columns)); + }); + const confirming = browser.live.find(session => session.id === browser.confirmKill); + if (confirming) out.push('', truncateAnsi(`${ERROR} Kill the live session in ${confirming.cwd}? Its shell and anything running in it will end. Enter kill · Esc cancel${RESET}`, columns)); + else out.push('', `${SUBTLE} ↑↓ move · type to search · Enter switch to a detached session · Ctrl+K kill a detached session · Tab agent sessions · Esc close${RESET}`); + // Agent sessions share this switcher: a compact section here, the full list (and their views) one Tab away. + if (this.agents.sessions.length) { + out.push('', `${PRIMARY} Agent sessions${RESET} ${SUBTLE}Tab opens them${RESET}`); + for (const session of shelfOrder(this.agents.sessions).slice(0, 6)) out.push(truncateAnsi(` ${renderShelf([session], columns - 4, now)}${SUBTLE} · ${session.level === 'observed' ? 'observed only' : session.level}${RESET}`, columns)); + } + return out; + } + private async openResumePicker(): Promise { try { // Anything that ended while no window watched is archived before listing. @@ -1296,10 +2713,14 @@ export class TerminalApp { if (!browser) return; const selection = resumeSelection(browser); if (selection?.kind !== 'archived') return; - const selected = selection.session; + await this.restoreTranscriptById(selection.session.id); + } + + /** Restore one archived transcript into this window (the current view is archived first). Shared by /resume and Ask. */ + private async restoreTranscriptById(id: string): Promise { let restored: TranscriptSession; try { - restored = await this.transcriptStore.load(selected.id); + restored = await this.transcriptStore.load(id); const current = this.output.transcript(); if (current.welcome || current.records.length > 0 || current.lines.length > 0) await this.archiveCurrentPresentation(); } catch { @@ -1309,7 +2730,7 @@ export class TerminalApp { } this.output.restoreTranscript(restored.transcript); this.welcomeGeneration += 1; - this.presentationStartCwd = selected.startCwd; + this.presentationStartCwd = restored.startCwd; this.resumeBrowser = undefined; this.historyViewport.latest(); try { await this.journal?.start(); this.journalActive = Boolean(this.journal); } catch { @@ -1337,12 +2758,65 @@ export class TerminalApp { } - /** Opt-in background discovery: one quiet line per newly seen release, never an interruption. */ + private pendingAutoUpdate?: ReleaseInfo; + private autoUpdateTimer?: NodeJS.Timeout; + private preparingUpdate?: string; + + private updateFrequencyInEffect(): UpdateCheckFrequency { + const {updateMode, updateFrequency} = this.promptConfiguration; + return updateMode === 'off' || updatesDisabledByEnvironment() ? 'off' : updateFrequency; + } + + /** + * Quiet, due-gated discovery. Notify only: one line per newly seen release. + * Automatic: remember the release and prepare it at the next idle point. + */ private async quietUpdateCheck(): Promise { - const release = await backgroundUpdateCheck(this.buildIdentity.version, this.promptConfiguration.updateChecks).catch(() => undefined); - if (!release || this.stopped) return; - this.output.addHistoryLine(`${INFO}NMSh ${release.version} is available (you have ${this.buildIdentity.version}) · /update${RESET}`); + const result = await checkForUpdate(this.buildIdentity.version, this.updateFrequencyInEffect()).catch(() => ({announce: false} as const)); + if (this.stopped) return; + if (this.promptConfiguration.updateMode === 'automatic' && this.updateFrequencyInEffect() !== 'off') { + const state = loadUpdateState(); + const version = 'release' in result && result.release ? result.release.version : state.latestVersion; + const settled = version !== undefined && (state.installedVersion === version || state.failed?.version === version || state.skipped?.version === version); + if (version && compareVersions(version, this.buildIdentity.version) > 0 && !settled) { + this.pendingAutoUpdate = ('release' in result && result.release) || {version, tag: `v${version}`, summary: [], + url: `https://github.com/raiseCatError/notMyShell/releases/tag/v${version}`}; + this.scheduleAutoUpdate(5_000); + } + return; + } + if (!('release' in result) || !result.release || !result.announce) return; + this.output.addHistoryLine(`${INFO}NMSh ${result.release.version} is available (you have ${this.buildIdentity.version}) · /update${RESET}`); + this.render(); + } + + private scheduleAutoUpdate(delay: number): void { + if (this.autoUpdateTimer || this.stopped || !this.pendingAutoUpdate) return; + this.autoUpdateTimer = setTimeout(() => { this.autoUpdateTimer = undefined; void this.autoUpdateTick(); }, delay); + this.autoUpdateTimer.unref?.(); + } + + /** Starts the build only at a calm prompt NMSh owns; otherwise waits for the next tick. */ + private async autoUpdateTick(): Promise { + const release = this.pendingAutoUpdate; + if (!release || this.stopped || this.promptConfiguration.updateMode !== 'automatic') { this.pendingAutoUpdate = undefined; return; } + if (this.updateInProgress || !this.idleEligible()) { this.scheduleAutoUpdate(30_000); return; } + this.pendingAutoUpdate = undefined; + this.updateInProgress = true; + this.preparingUpdate = release.version; this.render(); + try { + const outcome = await prepareAutomaticUpdate(release); + if (this.stopped || !outcome.announce) return; + const line = outcome.kind === 'ready' ? `NMSh ${outcome.version} ready · Restart NMSh to use it` + : outcome.kind === 'skipped' ? `NMSh ${outcome.version} is available · Automatic update skipped · Run /update for details` + : `Automatic update to ${outcome.version} did not complete and was rolled back · Run /update for details`; + this.output.addHistoryLine(`${outcome.kind === 'ready' ? SUCCESS : INFO}${line}${RESET}`); + } catch { /* an automatic update never crashes the session */ } finally { + this.updateInProgress = false; + this.preparingUpdate = undefined; + if (!this.stopped) this.render(); + } } /** @@ -1362,6 +2836,9 @@ export class TerminalApp { return reply(`Could not check for updates: ${error instanceof Error ? error.message : String(error)}. Nothing was changed.`, ERROR); } if (compareVersions(release.version, current) <= 0) return reply(`NMSh ${current} is up to date (latest release ${release.tag}).`, SUCCESS); + if (readyVersion(loadUpdateState(), current) === release.version) { + return reply(`Running ${current}\nInstalled ${release.version}\nStatus Restart NMSh to use it`, SUCCESS); + } const header = [`NMSh ${current} → ${release.version} is available.`, ...release.summary.map(line => ` ${line}`), release.url]; const planned = await planUpdate(await detectInstall(installRoot()), release); if (!planned.ok) { @@ -1379,8 +2856,9 @@ export class TerminalApp { this.render(); }); this.offeredUpdate = undefined; + if (result.ok) { recordInstalled(release.version); this.milestoneEffect(); } this.output.addHistoryLine(result.ok - ? `${SUCCESS}NMSh ${release.version} is installed. Restart NMSh to use it; this session keeps running ${current}.${RESET}` + ? `${SUCCESS}NMSh ${release.version} is ready ✓ This session is still running ${current}. Restart NMSh when convenient to use the new version.${RESET}` : `${ERROR}The update did not complete; the lines above say what happened.${RESET}`); } finally { this.updateInProgress = false; @@ -1388,14 +2866,62 @@ export class TerminalApp { } } + private updateStatusRows(): Array<{label: string; value: string; tone?: 'success' | 'warning' | 'muted'}> { + const running = this.buildIdentity.version; + const {updateMode, updateFrequency} = this.promptConfiguration; + const state = loadUpdateState(); + const latest = state.latestVersion; + const newer = latest !== undefined && compareVersions(latest, running) > 0; + const ready = readyVersion(state, running); + let status: {value: string; tone?: 'success' | 'warning' | 'muted'}; + if (this.preparingUpdate) status = {value: `Preparing ${running} → ${this.preparingUpdate}`}; + else if (ready) status = {value: `Ready · restart NMSh to use ${ready}`, tone: 'success'}; + else if (state.failed && latest === state.failed.version && newer) status = {value: 'Last automatic update failed · run /update', tone: 'warning'}; + else if (state.skipped && latest === state.skipped.version && newer) status = {value: 'Automatic update skipped · run /update for details', tone: 'warning'}; + else if (newer) status = {value: 'Update available', tone: 'warning'}; + else if (latest) status = {value: 'Up to date', tone: 'success'}; + else status = {value: updateMode === 'off' ? 'Not checking' : 'Check pending', tone: 'muted'}; + const ago = state.lastCheck === undefined ? undefined : (() => { + const minutes = Math.max(0, Math.round((Date.now() - state.lastCheck) / 60_000)); + return minutes < 1 ? 'just now' : minutes < 90 ? `${minutes}m ago` : minutes < 2880 ? `${Math.round(minutes / 60)}h ago` : `${Math.round(minutes / 1440)}d ago`; + })(); + return [ + {label: 'Running version', value: running}, + ...(latest ? [{label: 'Latest release', value: latest}] : []), + {label: 'Mode', value: updateMode === 'automatic' ? 'Automatic' : updateMode === 'notify' ? 'Notify only' : 'Off'}, + ...(updateMode === 'off' ? [] : [{label: 'Check frequency', value: updateFrequency === 'daily' ? 'Daily' : 'Weekly'}]), + {label: 'State', ...status}, + ...(ago ? [{label: 'Last checked', value: ago}] : []), + ]; + } + private showHelp(command: string): void { - const helpText = renderMarkdownText(helpMarkdown(), {columns: Math.max(20, this.dimensions().columns - 6), - hyperlinks: false}); // the transcript cell model has no OSC 8 support - this.output.addFrontendInteraction(command, helpText, INFO); + // Authored links are stored as authored cells and painted only where the host supports OSC 8; elsewhere the URL is shown inline. + const linked = this.host.capabilities.hyperlinks; + const helpText = renderMarkdownText(helpMarkdown(), {columns: Math.max(20, this.dimensions().columns - 6), hyperlinks: linked}); + this.output.addFrontendInteraction(command, helpText, INFO, linked); + } + + 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 { + // New output is activity: it ends idle visuals and restarts the inactivity timer. + this.noteActivity(); if (this.passthrough) { + this.renderer.observePassthrough(data); process.stdout.write(data); } else { this.commandModes.observeModes(data); @@ -1405,6 +2931,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(); @@ -1413,20 +2940,60 @@ 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(); + this.switchedShellStarting = false; + 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; + // Program identity only (the command's program word); never the agent's prompt or output. + const agent = detectAgentCommand(command.command); + if (agent && this.promptConfiguration.agentActivity && !command.cleared) { + const interrupted = command.interrupted || exitCode === 130; + try { + this.agentActivity.record({agent: agent.id, startedAt: command.startedAt, durationMs: Math.max(0, at - command.startedAt), + ...(interrupted ? {} : {exitCode})}, `${this.sessionId ?? this.journal?.id ?? 'local'}:${command.startedAt}`); + } catch { /* local stats are best effort and never affect the command */ } + } + 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; this.output.setActiveActivities(this.tapActivityObserver.finish(completedAt.getTime())); const completedRecord = this.output.complete(exitCode); + if (completedRecord) { + 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; + if (!this.replaying) void this.adviseFolding(completedRecord); + } this.suggestions.record({command: command.command, cwd: command.cwd, exitCode, at: command.startedAt, previous: this.submittedCommands[0]}); this.submittedCommands.unshift(command.command); if (this.submittedCommands.length > 50) this.submittedCommands.length = 50; @@ -1436,11 +3003,26 @@ 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'); + else if (agent) parts.main = agentCompletionText(agent.id, elapsed, exitCode, isInterrupted); 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}`); + // The raw shell error stays; a curated command NMSh knows gets a factual follow-up (identity only, no lookup or install). + const known = failure === 'command-not-found' ? this.knownMissingNote(command.command) : undefined; + if (known) this.output.addHistoryLine(`${INFO}${known}${RESET}`); } this.running = undefined; + if (!this.replaying && !command.cleared && completedRecord) { + const interrupted = command.interrupted || exitCode === 130; + const now = Date.now(); + this.transitions.seal(completedRecord.startId, interrupted ? 'interrupted' : exitCode === 0 ? 'success' : 'failure', now); + // Semantic Echo only for meaningful outcomes: failures, and successes after a long wait. + if (!interrupted && exitCode !== 0) this.transitions.echo('failure', now); + else if (!interrupted && elapsed >= 10_000) this.transitions.echo('longSuccess', now); + } + 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); }); @@ -1453,17 +3035,24 @@ export class TerminalApp { } void this.refreshContext(cwd); this.render(); + this.advancePresetStartup(exitCode, cwd); } private async refreshContext(cwd: string): Promise { const generation = ++this.contextGeneration; + const wantsDiscovery = this.promptConfiguration.modules.some(module => module.id === 'discoveredTools' && routeModule(module) !== 'hidden'); const [context, pathAbbreviations] = await Promise.all([ - resolvePromptContext(cwd, undefined, undefined, {status: this.promptConfiguration.nmsh.gitEnabled}), + resolvePromptContext(cwd, undefined, undefined, {status: this.promptConfiguration.nmsh.gitEnabled, + ...(wantsDiscovery ? {discovery: await discoverLocalExecutables()} : {})}), resolvePathAbbreviations(cwd, homedir()), ]); if (generation !== this.contextGeneration || this.stopped) return; - this.context = {...context, pathAbbreviations, exitStatus: this.context.exitStatus ?? 0}; + // Semantic Echo when Git conflicts become visible (not on every redraw while they remain). + if ((context.git?.conflicts ?? 0) > 0 && !(this.context.git?.conflicts ?? 0)) this.transitions.echo('conflict', Date.now()); + this.context = {...context, home: homedir(), pathAbbreviations, exitStatus: this.context.exitStatus ?? 0}; + this.context.facts = promptFacts(this.context, Date.now()); + this.requestCommandContexts(); await this.refreshProviderPrompt(); this.render(); } @@ -1474,18 +3063,65 @@ export class TerminalApp { */ private promptContext(command = this.editor.text): PromptContext { const words = commandWords(command); - const wanted = (id: CommandContextId) => this.promptConfiguration.modules.some(module => module.id === id && module.visible + const wanted = (id: CommandContextId) => this.promptConfiguration.modules.some(module => module.id === id && routeModule(module) !== 'hidden' && (module.condition !== 'onCommand' || isOnCommandRelevant(id, words))); - const kubeContext = wanted('kubeContext') ? this.commandContexts.get('kubeContext') : undefined; - const dockerContext = wanted('dockerContext') ? this.commandContexts.get('dockerContext') : undefined; - return {...this.context, commandWords: words, ...(kubeContext ? {kubeContext} : {}), ...(dockerContext ? {dockerContext} : {})}; + const kubeContext = wanted('kubeContext') ? this.commandContexts.peek('kubeContext') : undefined; + const dockerContext = wanted('dockerContext') ? this.commandContexts.peek('dockerContext') : undefined; + // Read live, so the current-shell module follows /shell and the default-shell setting immediately. + const shell = {current: this.shellId, differs: this.shellId !== this.promptConfiguration.shellBackend}; + const live = {...this.context, commandWords: words, shell, ...(kubeContext ? {kubeContext} : {}), ...(dockerContext ? {dockerContext} : {})}; + // Workspace facts retain collection time; cheap live shell/exit and cache values + // are adapted in memory, without asking any reader to run. + const facts = promptFacts({...live, facts: undefined}); + return {...live, facts: {...this.context.facts, exitStatus: updateFact(facts.exitStatus, this.context.facts?.exitStatus), shell: updateFact(facts.shell, this.context.facts?.shell), + kubeContext: updateFact(facts.kubeContext, this.context.facts?.kubeContext), dockerContext: updateFact(facts.dockerContext, this.context.facts?.dockerContext)}}; + } + + private requestCommandContexts(): void { + if (this.stopped || this.passthrough || this.externalPassthrough || this.effectivePromptProvider !== 'nmsh') return; + const words = commandWords(this.editor.text); + for (const id of ['kubeContext', 'dockerContext'] as const) { + const wanted = this.promptConfiguration.modules.some(module => module.id === id && routeModule(module) !== 'hidden' + && (routeModule(module) !== 'contextRail' || this.promptConfiguration.contextRail.mode !== 'off') + && (module.condition !== 'onCommand' || isOnCommandRelevant(id, words))); + if (wanted) this.commandContexts.request(id); + } + } + + private renderRail?: {columns: number; prepared: PreparedRail}; + + private preparedRail(columns: number): PreparedRail { + if (this.renderRail?.columns === columns) return this.renderRail.prepared; + const configuration = this.passthrough || this.externalPassthrough || this.effectivePromptProvider !== 'nmsh' + ? {...this.promptConfiguration, contextRail: {...this.promptConfiguration.contextRail, mode: 'off' as const}} : this.promptConfiguration; + return prepareRail(this.promptContext(), columns, configuration); + } + + private contextRailRows(columns: number): string[] { + if (this.passthrough || this.externalPassthrough || this.effectivePromptProvider !== 'nmsh') return []; + return this.preparedRail(columns).content; + } + + /** Prompt None submissions genuinely have no prompt snapshot; history never substitutes Native for them. */ + private historicalContext(cwd: string, context: Pick, command: string): HistoricalContextSnapshot { + const prompt = this.currentPromptSnapshot(command); + const legacy = {cwd, project: context.project, branch: context.branch, facts: context.facts}; + const metadata = moduleFactContext(legacy, promptFacts(legacy), ['cwd', 'project', 'branch'], 'snapshot'); + return {cwd: metadata.cwd, project: metadata.project, branch: metadata.branch, ...(prompt ? {prompt} : this.effectivePromptProvider === 'none' ? {promptless: true as const} : {})}; } - private currentPromptSnapshot(command?: string): PromptSnapshot { + private currentPromptSnapshot(command?: string): PromptSnapshot | undefined { + if (this.effectivePromptProvider === 'none') return undefined; if (this.effectivePromptProvider !== 'nmsh' && this.externalPrompt) { + const context = this.promptContext(command); + const facts = promptFacts(context); + const metadata = moduleFactContext(context, facts, ['cwd', 'branch'], 'snapshot'); + // External output has no fact-to-segment provenance. Retain it only when + // all input facts may be persisted; otherwise fail closed on presentation. + const persist = Object.values(facts).every(fact => !fact || factAllowed(fact, 'snapshot')); return {provider: this.effectivePromptProvider, layout: this.promptConfiguration.composerLayout, - segments: structuredClone(this.externalPrompt.segments), cwd: this.context.cwd, - ...(this.context.branch ? {branch: this.context.branch} : {})}; + segments: persist ? structuredClone(this.externalPrompt.segments) : [], cwd: metadata.cwd, + ...(metadata.branch ? {branch: metadata.branch} : {})}; } return nativePromptSnapshot(this.promptContext(command), this.promptConfiguration); } @@ -1508,6 +3144,14 @@ export class TerminalApp { this.p10kStatus = this.detectPowerlevel10k(configuration); return renderPowerlevel10kPrompt(this.context, this.p10kStatus); } + if (configuration.provider === 'ohMyPosh') { + const status = await detectOhMyPosh(configuration.ohMyPosh.configPath ?? undefined); + this.ohMyPoshRender?.abort(); + const controller = new AbortController(); + this.ohMyPoshRender = controller; + try { return await renderOhMyPoshPrompt(this.context, status, process.env, {signal: controller.signal}); } + finally { if (this.ohMyPoshRender === controller) this.ohMyPoshRender = undefined; } + } const env = this.starshipEnvironment(configuration); this.starshipStatus ??= await detectStarship(env); if (!this.starshipStatus.installed) throw new Error('Starship is not installed or not available on PATH.'); @@ -1515,8 +3159,8 @@ export class TerminalApp { } private async refreshProviderPrompt(): Promise { - if (this.promptConfiguration.provider === 'nmsh') { - this.effectivePromptProvider = 'nmsh'; + if (this.promptConfiguration.provider === 'nmsh' || this.promptConfiguration.provider === 'none') { + this.effectivePromptProvider = this.promptConfiguration.provider; this.externalPrompt = undefined; this.externalPromptError = undefined; return; @@ -1530,8 +3174,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. */ } } } @@ -1559,10 +3204,36 @@ export class TerminalApp { this.render(); } + /** /chroma: the /prompt Chroma view directly, editing the same presentation settings. */ + private startChromaSettings(): void { + this.promptPanelState = {onboarding: false, step: 'appearance', view: 'chroma', selectedIndex: 0, + draft: structuredClone(this.promptConfiguration), saved: structuredClone(this.promptConfiguration)}; + this.panelExternalPrompt = undefined; + this.render(); + } + + /** + * A brief, restrained effect for a real milestone (a confirmed install, + * an applied update, finished setup). Respects Milestone effects, Reduced + * Motion and Effects Off; never during a command, passthrough or panel. + */ + private milestoneEffect(): boolean { + const presentation = this.promptConfiguration.presentation; + if (presentation.autoEffects === false || presentation.effectsOff || presentation.reducedMotion || isReducedMotion()) return false; + // Panels own the screen and leave no decorative gap: celebrate once the panel closes. + if (this.settingsPanelActive || this.running || this.passthrough || this.externalPassthrough || this.frontendSuspended) { + this.pendingMilestone = true; + return false; + } + this.pendingMilestone = false; + return this.effects.trigger('confetti', 'bottom', Date.now(), Date.now() >>> 0, presentation); + } + private async runPowerlevel10kWizard(status: Powerlevel10kStatus): Promise { 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; @@ -1574,6 +3245,7 @@ export class TerminalApp { inputDetached = true; process.stdin.setRawMode(false); rawModeReleased = true; + this.terminalFocus = 'unknown'; this.renderer.leave(); rendererLeft = true; process.on('SIGINT', ignoreInterrupt); @@ -1613,9 +3285,18 @@ export class TerminalApp { state.step = 'powerlevel10k'; state.selectedIndex = 0; if (state.p10kStatus.installed) await this.refreshPanelPreview(state); + } else if (state.draft.provider === 'ohMyPosh' && !(await detectOhMyPosh(state.draft.ohMyPosh.configPath ?? undefined)).installed) { + // Nothing to render with: stay on the provider list and say why. + state.draft.provider = state.saved?.provider ?? this.promptConfiguration.provider; + state.message = 'Oh My Posh is not installed. Install it from /tools (Shell / Workflow) and choose it again.'; + } else if (state.draft.provider === 'none') { + // Composer only: no layout applies; the appearance step keeps the theme and the input marker. + state.step = 'appearance'; + state.selectedIndex = 0; } else { state.step = 'layout'; state.selectedIndex = layoutChoiceIndex(state.draft); + if (state.draft.provider === 'ohMyPosh') await this.refreshPanelPreview(state); } } else if (state.step === 'powerlevel10k') { const installed = Boolean(state.p10kStatus?.installed); @@ -1749,7 +3430,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; @@ -1757,20 +3438,32 @@ export class TerminalApp { this.starshipStatus = await detectStarship(process.env); state.starshipStatus = this.starshipStatus; if (!this.starshipStatus.installed) state.task.markFailure('Homebrew completed, but starship was not found on PATH.'); + else { recordInstall('starship', {label: 'brew install starship', command: 'brew', args: ['install', 'starship']}); this.milestoneEffect(); } } state.step = 'installResult'; state.selectedIndex = 0; } + } else if (state.step === 'railInsideConfirm') { + const confirmed = state.selectedIndex === 0; + state.step = state.railConfirmReturn?.step ?? 'appearance'; + state.selectedIndex = state.railConfirmReturn?.selectedIndex ?? 0; + state.railConfirmReturn = undefined; + if (confirmed) { + state.draft = railPreviewConfiguration(state.draft); + await this.savePromptSettings(); + } } else if (state.step === 'layout') { applyLayoutChoice(state.draft, state.selectedIndex); - if (state.draft.provider === 'nmsh') { state.step = 'appearance'; state.selectedIndex = 0; } + if (state.draft.provider === 'nmsh' || state.draft.provider === 'none') { state.step = 'appearance'; state.selectedIndex = 0; } else await this.savePromptSettings(); } else if (onModulesRow(state)) { state.step = 'modules'; state.selectedIndex = 0; + } else if (onGradientRow(state)) { + openGradientEditor(state); } else if (state.step === 'modules') { state.step = 'appearance'; - state.selectedIndex = APPEARANCE_MODULES_ROW; + state.selectedIndex = appearanceModulesRow(state.draft); } else { await this.savePromptSettings(); } @@ -1780,15 +3473,24 @@ export class TerminalApp { private async savePromptSettings(): Promise { const state = this.promptPanelState; if (!state) return; + if (railNeedsPromptConversion(state.draft)) { + state.railConfirmReturn = {step: state.step, selectedIndex: state.selectedIndex}; + state.step = 'railInsideConfirm'; + state.selectedIndex = 1; + this.render(); + 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); await this.refreshProviderPrompt(); + this.requestCommandContexts(); // refreshProviderPrompt already fell back to NMSh and saved that truthfully. if (this.externalPromptError && state.draft.provider !== 'nmsh') { this.output.addHistoryLine(`${ERROR}${providerLabel(state.draft.provider)} prompt failed; NMSh is active. ${this.externalPromptError}${RESET}`); @@ -1806,7 +3508,22 @@ export class TerminalApp { const state = this.promptPanelState; if (!state) return []; const width = Math.max(1, columns - 4); - const previewConfig = structuredClone(state.draft); + const previewConfig = railPreviewConfiguration(state.draft); + if (state.step === 'railInsideConfirm' || state.step === 'appearance' && state.view === 'rail') { + const label = (text: string) => ` ${SECONDARY}${truncateAnsi(text, Math.max(1, width - 2))}${RESET}`; + const currentContent = prepareRail(this.promptContext(), width, previewConfig).content; + const current = railCompositionPreview(this.promptContext(), width, previewConfig); + const showcase = structuredClone(previewConfig); + // Synthetic facts exercise configured Rail modules without stealing Main or Right Context. + showcase.modules = showcase.modules.map(module => ({...module, visible: true})); + const sample = railCompositionPreview(moduleShowcaseContext(), width, showcase); + const empty = previewConfig.contextRail.mode === 'off' ? [label('Rail is Off')] + : [label('No visible Rail context'), label(previewConfig.contextRail.mode === 'always' + ? `Always reserves ${previewConfig.contextRail.rows} row${previewConfig.contextRail.rows === 1 ? '' : 's'}; module visibility still applies` + : 'Route modules to Rail in Modules; visibility conditions still apply')]; + return [label('Current'), ...(currentContent.some(row => stripAnsi(row).trim()) ? current : [...empty, ...current]), label('Showcase'), + ...(sample.length ? sample : [label('Rail is Off')])]; + } if (state.step === 'provider') previewConfig.provider = PROVIDER_ORDER[state.selectedIndex] ?? 'nmsh'; if (state.step === 'starship') previewConfig.provider = 'starship'; if (state.step === 'powerlevel10k') previewConfig.provider = 'powerlevel10k'; @@ -1817,6 +3534,7 @@ export class TerminalApp { const context = state.step === 'modules' || state.step === 'appearance' || state.step === 'layout' ? moduleShowcaseContext() : this.promptContext(); let providerRow: string; + if (previewConfig.provider === 'none') return [`${SUBTLE}None · composer only${RESET}`, boundary, `${ACCENT}${GLYPHS.prompt}${RESET} echo hello`, boundary]; if (previewConfig.provider !== 'nmsh') { const preview = this.panelExternalPrompt?.provider === previewConfig.provider ? this.panelExternalPrompt.result : undefined; if (!preview) return [this.externalPanelStatusText(state, previewConfig.provider, width)]; @@ -1855,11 +3573,53 @@ 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.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.themeBridgePanel || this.rowPanel || this.configureList || this.tmuxPanel || this.dotfiles || this.setupState || this.installPrompt || this.presetPanel || this.toolsPanel || this.toolConfigurationLoading || this.toolConfiguration || this.promptPanelState || this.transcriptPanelState || this.providerPanelState || this.paletteState || this.syntaxPanelState || this.layoutPanelState || this.settingsPanelState + || this.resumeBrowser || this.appearanceHub || this.keyboardState || this.startupPanel || this.aboutPanel || this.shellPanel || this.openPanel || this.askState || this.agentView || this.agentPanel || this.providersOverview || this.understandingPanel || this.cursorPanel || this.doctorPanel || this.watchPanel || this.pasteReview || this.misePanel || this.keepAwakePanel); + } + + /** Complex panels declare the smallest size that shows their essential controls. */ + private panelMinimum(): MinimumSize | undefined { + if (this.setupState) return SETUP_MIN_SIZE; + if (this.themeStudio) return STUDIO_MIN_SIZE; + if (this.themeBridgePanel) return {columns: 56, rows: 14}; + if (this.tmuxPanel || this.configureList || this.rowPanel || this.dotfiles) return {columns: 50, rows: 12}; + if (this.screensaverPanel) return SCREENSAVER_MIN_SIZE; + if (this.chromeEditor || this.stopsEditor) return CHROME_EDITOR_MIN_SIZE; + return undefined; } private settingsPanelRows(columns: number): string[] { + const minimum = this.panelMinimum(); + const {rows} = this.dimensions(); + if (minimum && !fits(minimum, columns, rows)) return renderTooSmall(minimum, columns, rows); + return this.panelContentRows(columns); + } + + private panelContentRows(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.keepAwakePanel) return renderKeepAwakePanel(this.keepAwakePanel, this.keepAwake(), columns, this.dimensions().rows, this.promptConfiguration.keepAwake); + if (this.installPrompt) return renderInstallPrompt(this.installPrompt, columns); + if (this.themeStudio) return this.renderThemeStudioRows(this.themeStudio, columns); + if (this.themeBridgePanel) return renderThemeBridgePanel(this.themeBridgePanel, this.themeBridgePanelContext(), columns, this.dimensions().rows); + if (this.rowPanel) return renderRowPanel(this.rowPanel, this.promptConfiguration, columns, this.dimensions().rows, this.promptConfiguration.statusStrip.enabled ? [this.statusStripRow(columns - 2)] : [' (Status strip Off)']); + if (this.configureList) return renderConfigureList(this.configureList, registryFacts(), columns, this.dimensions().rows); + if (this.tmuxPanel) return renderTmuxPanel(this.tmuxPanel, columns, this.dimensions().rows); + if (this.dotfiles) return renderDotfilesPanel(this.dotfiles, columns, this.dimensions().rows); + if (this.screensaverPanel) return this.renderScreensaverRows(this.screensaverPanel, columns); + if (this.stopsEditor) return this.renderStopsEditor(this.stopsEditor, columns); + if (this.chromeEditor) return renderChromeEditor(this.chromeEditor, columns, this.dimensions().rows, colorLevel()); + if (this.setupState) { + const state = this.setupState; + if (state.cursorPanel) return this.withDraftTheme(state.draft, () => framePanel(this.cursorPanelRows(state.cursorPanel!, columns, this.cursorEnv(state.cursorPanel!.draft, state.draft)), columns)); + if (!state.toolBrowser) state.context.preview = this.withDraftTheme(state.draft, () => this.setupPreview(state, columns)); + state.context.title = this.setupTitle(state); + return this.withDraftTheme(state.draft, () => renderSetup(state, 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}); @@ -1878,8 +3638,27 @@ export class TerminalApp { this.dimensions().rows - 4), columns); } if (this.transcriptPanelState) { - return framePanel(renderTranscriptPanel(this.transcriptPanelState, columns, this.transcriptPreviewSample(), this.dimensions().rows - 4), columns); + return framePanel(renderTranscriptPanel(this.transcriptPanelState, columns, this.transcriptPreviewSample(), this.dimensions().rows - 4, this.promptConfiguration.presentation), columns); + } + if (this.aboutPanel) return framePanel(this.aboutRows(columns), columns); + if (this.openPanel) return framePanel(renderOpenPanel(this.openPanel, columns, this.dimensions().rows - 4), columns); + if (this.agentView) { + const session = this.agents.get(this.agentView.sessionId); + if (session) return framePanel(renderAgentView(session, this.agentView, columns, this.dimensions().rows - 4, Date.now()), columns); } + if (this.agentPanel) return framePanel(renderAgentPanel(this.agentPanel, this.agentPanelRows(), columns, Date.now(), this.dimensions().rows - 4), columns); + if (this.askState) { + const activity = this.askActivityLine(); + return framePanel(renderAsk(this.askState, columns, {presentation: this.promptConfiguration.askPresentation, height: this.dimensions().rows - 4, shell: this.shellId, ...(activity ? {activity} : {})}), columns); + } + if (this.pasteReview) return framePanel(renderPasteReview(this.pasteReview, columns, this.dimensions().rows - 4), columns); + if (this.watchPanel) return framePanel(renderWatchPanel(this.watchPanel, this.watches.active(), columns, Date.now(), this.dimensions().rows - 4), columns); + if (this.doctorPanel) return framePanel(renderDoctorPanel(this.doctorPanel, columns, Date.now(), !this.decorativeMotionAllowed()), columns); + if (this.cursorPanel) return framePanel(this.cursorPanelRows(this.cursorPanel, columns, this.cursorEnv(this.cursorPanel.draft, this.promptConfiguration)), columns); + if (this.understandingPanel) return framePanel(renderUnderstandingPanel(this.understandingPanel, this.understandingFacts(), columns), columns); + if (this.providersOverview) return framePanel(renderProvidersOverview(this.providersOverview, this.providersOverviewFacts(), columns, this.dimensions().rows - 3), columns); + if (this.shellPanel) return framePanel(renderShellPanel(this.shellPanel, columns), columns); + if (this.resumeBrowser?.liveOnly) return framePanel(this.sessionsViewRows(this.resumeBrowser, columns), columns); if (this.resumeBrowser) { const browser = this.resumeBrowser; const sessions = visibleResumeSessions(browser); @@ -1892,9 +3671,17 @@ export class TerminalApp { if (live.length > 0) { const now = Date.now(); rows.push(`${SUBTLE} LIVE${RESET}`); + const safe = getCurrentGlyphMode() === 'safe'; live.forEach((session, index) => { const selected = index === browser.selectedIndex; - rows.push(truncateAnsi(`${selected ? ACCENT : SECONDARY}${selected ? '›' : ' '} ● ${describeLiveSession(session, now)}${RESET}`, columns)); + const state = liveRowState(session, now); + const stateColor = state === 'failed' ? ERROR : state === 'attention' ? ACCENT : state === 'completed' ? SUCCESS : state === 'active' ? PRIMARY : SECONDARY; + const agent = liveRowAgent(session); + // Agent color only when identity is proven and color is allowed; generic otherwise. + const who = agent ? `${agentColor(agent.color)}${safe ? agent.safeGlyph : agent.glyph} ${agent.short}${RESET} ` : ''; + const signature = signatureAccent(session.signature); + rows.push(truncateAnsi(`${selected ? ACCENT : SECONDARY}${selected ? '›' : ' '} ${stateColor}${(safe ? '*' : '●')} ${LIVE_ROW_LABELS[state].padEnd(16)}${RESET}${signature ? `${foreground(signature)}${safe ? '+' : '◆'}${RESET} ` : ''}${who}` + + `${selected ? ACCENT : SECONDARY}${describeLiveRow(session, now)}${RESET}`, columns)); }); rows.push('', `${SUBTLE} ARCHIVED${RESET}`); } @@ -1910,8 +3697,7 @@ export class TerminalApp { lastDay = day; const selected = index + live.length === browser.selectedIndex; const time = new Date(session.createdAt).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}); - const interrupted = session.journaled && !session.endedAt ? ' · interrupted' : ''; - rows.push(truncateAnsi(`${selected ? ACCENT : SECONDARY}${selected ? '›' : ' '} ${time} ${session.project || 'notMyShell'} · ${session.finalCwd} · ${session.commandCount} commands${interrupted}${RESET}`, columns)); + rows.push(truncateAnsi(`${selected ? ACCENT : SECONDARY}${selected ? '›' : ' '} ${time} ${SUBTLE}Archived${RESET}${selected ? ACCENT : SECONDARY} ${describeArchivedRow(session, Date.now())}${RESET}`, columns)); } if (sessions.length === 0) rows.push(`${SUBTLE} No matching archived sessions${RESET}`); const confirming = browser.live.find(session => session.id === browser.confirmKill); @@ -1923,8 +3709,20 @@ 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.appearanceHub) { + const theme = themeLabel(this.promptConfiguration.nmsh.palette); + const now = Date.now(); + const gate = this.motionPreviewGate(); + // A frame clock only while the one-shot preview is animating; none once it settles. + const busy = hubMotionPreview(this.appearanceHub, this.promptConfiguration, columns, gate, now)?.busy ?? false; + if (busy && !this.motionPreviewClock) this.motionPreviewClock = presentationClock.subscribe(() => { + if (!this.stopped && this.appearanceHub) { this.render(); return; } + this.motionPreviewClock?.(); this.motionPreviewClock = undefined; + }, 33, 16); + else if (!busy && this.motionPreviewClock) { this.motionPreviewClock(); this.motionPreviewClock = undefined; } + return framePanel(renderAppearanceHub(this.appearanceHub, this.promptConfiguration, columns, theme, this.cursorBackend().backend.label, {gate, now}, this.dimensions().rows - 4), columns); + } + if (this.keyboardState) return framePanel(renderKeyboardPanel(this.keyboardState, columns, this.host.name), columns); return framePanel(this.renderedPromptPanel(columns), columns); } @@ -1945,19 +3743,20 @@ 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; this.settingsPanelState = undefined; - if (!onboarding) this.settingsPanelState = {section: 'root', view: 'settings', selectedIndex: 0, contentIndex: 1, + if (!onboarding) this.settingsPanelState = {section: 'root', view: 'settings', selectedIndex: 0, contentIndex: GLYPH_ENTRY_INDEX(), glyphStyle: style, onboarding: false}; if (onboarding && !next.onboardingComplete) { 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; } } @@ -1966,6 +3765,8 @@ export class TerminalApp { private settingsMemory?: {contentIndex: number; searchQuery: string; showAdvanced: boolean}; private openSettingsPanel(view: SettingsView): void { + // Status reports the shared model service as it is now (never starts it). + if (view === 'status') void this.understanding.refreshStatus().then(() => this.render(), () => undefined); const memory = view === 'config' ? this.settingsMemory : undefined; this.settingsPanelState = {section: 'root', view, selectedIndex: 0, contentIndex: memory?.contentIndex ?? 0, searchQuery: memory?.searchQuery, showAdvanced: memory?.showAdvanced, @@ -1978,7 +3779,7 @@ export class TerminalApp { const state = this.settingsPanelState!; state.searchQuery = ''; state.showAdvanced = state.showAdvanced || SETTINGS_ROWS.find(row => row.id === rowId)?.level === 'advanced'; - state.contentIndex = Math.max(0, visibleSettingsRows(state).findIndex(row => row.id === rowId)); + state.contentIndex = Math.max(0, visibleSettingsRows(state, this.promptConfiguration).findIndex(row => row.id === rowId)); } /** @@ -1992,7 +3793,7 @@ export class TerminalApp { private handleSettingsKey(key: Key, state: SettingsPanelState): void { if (key.kind === 'escape' || key.kind === 'interrupt') { if (state.onboarding) this.saveGlyphChoice(state.glyphStyle); - else if (state.section === 'appearance') { state.section = 'root'; state.view = 'settings'; state.contentIndex = 1; } + else if (state.section === 'appearance') { state.section = 'root'; state.view = 'settings'; state.contentIndex = GLYPH_ENTRY_INDEX(); } else if (state.searchQuery || state.searchFocused) { state.searchQuery = ''; state.searchFocused = false; state.contentIndex = 0; } else this.settingsPanelState = undefined; return; @@ -2003,7 +3804,7 @@ export class TerminalApp { return; } const view = settingsView(state); - const row = selectedSettingsRow(state); + const row = selectedSettingsRow(state, this.promptConfiguration); const editedSearch = state.searchFocused ? editText(state.searchQuery ?? '', key) : undefined; if (editedSearch !== undefined) { state.searchQuery = editedSearch; @@ -2030,7 +3831,7 @@ export class TerminalApp { } else if (state.focus === 'tabs') { if (key.kind === 'down' || key.kind === 'enter') { state.focus = 'rows'; state.contentIndex = 0; } } else if (key.kind === 'up' || key.kind === 'down') { - const count = settingsItemCount(state); + const count = settingsItemCount(state, this.promptConfiguration); const index = state.contentIndex ?? 0; if (key.kind === 'up' && index === 0 && !state.searchFocused) state.focus = 'tabs'; else if (count > 0) state.contentIndex = Math.max(0, Math.min(count - 1, index + (key.kind === 'up' ? -1 : 1))); @@ -2044,6 +3845,12 @@ export class TerminalApp { } private openSettingsDestination(destination: SettingsDestination, view: SettingsView, rowIndex: number, state: SettingsPanelState): void { + if (destination === 'resetInstallSuggestions') { + const count = this.promptConfiguration.ignoredInstallSuggestions.length; + if (count) this.applySettingsConfiguration({...this.promptConfiguration, ignoredInstallSuggestions: []}); + this.output.addFrontendInteraction('/settings', count ? `Install suggestions reset for ${count} tool${count === 1 ? '' : 's'}.` : 'No ignored install suggestions to reset.', INFO); + return; + } if (destination === 'glyph') { state.section = 'appearance'; state.selectedIndex = this.promptConfiguration.glyphStyle === 'nerd' ? 0 : 1; @@ -2053,524 +3860,3905 @@ export class TerminalApp { this.panelOriginView = view; this.panelOriginRow = rowIndex; this.settingsPanelState = undefined; - if (destination === 'appearance') void this.startAppearance(); + this.openDestinationPanel(destination); + } + + /** Open the panel behind a Settings destination (also where Setup routes after Apply). */ + private openDestinationPanel(destination: SettingsDestination): void { + if (destination === 'tools') this.startTools(); + else if (destination === 'setup') this.startSetup(); + else if (destination === 'screensaver') this.openScreensaverGallery(); + else if (destination === 'chromeColors') { + const config = this.promptConfiguration; + this.chromeEditor = createChromeEditor(config.uiChrome.colors ?? chromeColorsFrom(resolveChrome({...config.uiChrome, source: 'theme'}, config.nmsh.palette, config.nmsh.accent, config.customTheme))); + } + else if (destination === 'idleColors' || destination === 'activityColors') this.openStopsEditor(destination === 'idleColors' ? 'idle' : 'activity'); + 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') this.startProviderPanel(destination); + else if (destination === 'cursor') this.openCursorPanel(); + else if (destination === 'themeStudio') this.openThemeStudio(); + else if (destination === 'themeBridge') void this.openThemeBridge(); + else if (destination === 'welcome' || destination === 'suggestions' || destination === 'history' || destination === 'picker' || destination === 'navigation') this.startProviderPanel(destination); else void this.startKeyboard(); } - /** - * Read-only facts for the Status view, from in-memory state only: no - * subprocesses, no environment values beyond the terminal's self-reported - * TERM_PROGRAM, nothing that could carry credentials. - */ - private statusSections(): StatusSections { - const config = this.promptConfiguration; - const build = this.buildIdentity; - 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 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: '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: 'Terminal size', value: `${columns}×${rows}`}, - ], - [ - {label: 'Prompt provider', value: providerLabel(config.provider)}, - ...(active !== config.provider ? [{label: 'Active prompt', value: `${providerLabel(active)} (fallback)`, tone: 'warning' as const}] : []), - {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: 'History colors', value: config.transcript.historyColors === 'followPrompt' ? 'Follow prompt' : config.transcript.historyColors === 'theme' ? 'Theme' : 'Grayscale'}, - ], - [ - {label: 'Session journal', value: this.journalActive ? 'active' : 'inactive', tone: this.journalActive ? 'success' : 'warning'}, - {label: 'Session retention', value: config.sessionRetention === null ? 'unlimited' : `${config.sessionRetention} sessions`}, - {label: 'Config file', value: tilde(promptConfigurationPath()), tone: 'muted'}, - ], - ]; - } - - /** Persists an inline Settings edit and applies it live; on failure the old value stays. */ - private applySettingsConfiguration(next: PromptConfiguration | undefined): void { - if (!next) return; + private async startToolConfiguration(id: string): Promise { + const generation = ++this.toolConfigurationGeneration; + this.toolConfigurationLoading = true; + this.render(); try { - savePromptConfiguration(next); + const adapter = await openSupportedConfiguration(id, this.starshipEnvironment(this.promptConfiguration)); + const state = await createConfigurationPanel(adapter); + if (!this.stopped && generation === this.toolConfigurationGeneration) this.toolConfiguration = state; } catch { - return; + 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.promptConfiguration = next; - setIconStyle(next.glyphStyle); - this.output.setTranscriptAppearance(next.transcript); - this.output.setOutputFolding(next.outputFolding); - this.output.presenter.setLayout(next.transcriptPresentation); - if (this.settingsPanelState) this.settingsPanelState.glyphStyle = next.glyphStyle; + this.render(); } - /** - * Starts the welcome for a new presentation. External providers are - * captured once in the background; failure falls back to Vespyr quietly. - */ - private startWelcome(cwd: string): void { - const generation = ++this.welcomeGeneration; - const provider = this.promptConfiguration.welcome; - const vespyr = () => this.output.setWelcome(createWelcomeSnapshot(this.buildIdentity, cwd)); - if (provider === 'none') return; - if (provider === 'vespyr') { vespyr(); return; } - void captureWelcome(provider, cwd).then(result => { - if (generation !== this.welcomeGeneration || this.stopped) return; - if (result.ok) this.output.setWelcome({...createWelcomeSnapshot(this.buildIdentity, cwd), provider, captured: result.lines}); - else { - vespyr(); - this.output.addHistoryLine(`${SUBTLE}${welcomeProvider(provider).label} welcome ${result.reason}; showing Vespyr.${RESET}`); - } - 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); } } - /** History loads in the background after startup; suggestions refine once it is indexed. */ - private async loadHistory(): Promise { - await this.historyService.reload(); - if (this.stopped) return; - await this.nativeSuggestions.loadInChunks(this.historyService.getEntries()); - if (this.stopped) return; - this.suggestions.refresh(); - this.render(); + 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(); } - /** Suggestions apply to plain shell input at the end of the buffer only. */ - private suggestionGhost(): string | undefined { - const text = this.editor.text; - if (this.editor.hasPasteAtoms || this.historySearchActive || text.startsWith('/')) { - this.suggestions.reset(); - return undefined; + 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); } - this.suggestions.update({buffer: text, cwd: this.shellCwd, previous: this.submittedCommands, now: Date.now()}, - this.promptConfiguration.suggestionsOnEmpty && !this.running); - return this.suggestions.ghost(text); } - /** - * Suggestion keys never steal an existing binding: word-right only accepts - * at the end of the buffer, Ctrl+N/Ctrl+P were unbound, and Up/Down/Enter - * act on alternatives only while that list is open. - */ - private handleSuggestionKey(key: Key): boolean { - const buffer = this.editor.text; - const atEnd = this.editor.cursorIndex === graphemes(buffer).length && !this.editor.hasPasteAtoms; - if (key.kind === 'suggestNext' || key.kind === 'suggestPrevious') { - this.suggestions.cycle(key.kind === 'suggestNext' ? 1 : -1); - return true; + private shellNames?: ReadonlySet; + private shellNamesComplete = false; + private rememberShellNames(knowledge: string): void { + this.shellNames = new Set(parseShellKnowledge(knowledge).keys()); + this.shellNamesComplete = /^complete$/mu.test(knowledge); + } + + 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); + state.updates = this.toolUpdates; + state.activation = toolId => integrationActivation(toolId, this.shellId, this.shellNames, this.shellNamesComplete); + state.prompt = {selected: config.provider, effective: this.effectivePromptProvider}; + state.shellBackend = this.shellId; + void refreshTools(state, () => { if (!this.stopped && this.toolsPanel === state) this.render(); }); + } + + /** /tools → Use as prompt: the one canonical provider setting, then a truthful render (fallback to Native if it fails). */ + private async useToolAsPrompt(state: ToolsPanel, provider: NonNullable): Promise { + const saved = structuredClone(this.promptConfiguration); + this.promptConfiguration.provider = provider; + try { savePromptConfiguration(this.promptConfiguration, undefined, saved); } catch { /* applies to this window */ } + this.starshipStatus = undefined; + await this.refreshProviderPrompt(); + state.prompt = {selected: this.promptConfiguration.provider, effective: this.effectivePromptProvider}; + state.message = this.effectivePromptProvider === provider + ? `${providerLabel(provider)} is now the prompt provider. No shell rc file was changed.` + : `${providerLabel(provider)} could not render (${this.externalPromptError ?? 'unknown error'}); NMSh Native stays active.`; + } + + private async handleToolsKey(key: Key, state: ToolsPanel): Promise { + if (state.confirm) { + await confirmToolInstall(state, key, () => this.renderTaskPresentation()); + if (state.task?.state.status === 'succeeded' && state.detail && state.statuses[state.detail.id]?.state === 'installed') this.milestoneEffect(); + this.render(); + return; } - if (this.suggestions.alternativesOpen) { - if (key.kind === 'up' || key.kind === 'down') { - this.suggestions.cycle(key.kind === 'down' ? 1 : -1); - return true; + const wasOnboarding = state.onboarding !== undefined; + const action = toolsKey(state, key); + if (wasOnboarding && (action === 'close' || action === 'finishOnboarding')) { + this.applySettingsConfiguration({...this.promptConfiguration, toolsSetupComplete: true}); + // First-run setup is complete; a skip is not a milestone. + if (action === 'finishOnboarding') this.milestoneEffect(); + } + 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) { this.toolsPanel = undefined; await this.openConfigure(state.detail.configuration, '/tools'); } + else if (action === 'usePrompt' && state.detail?.promptProvider) await this.useToolAsPrompt(state, state.detail.promptProvider); + else if (action === 'promptSettings') { this.toolsPanel = undefined; await this.startPromptSettings(false); } + else if (action === 'p10kConfigure') { + // The existing configurator flow: backups of ~/.p10k.zsh and .zshrc, then the official wizard. + this.toolsPanel = undefined; + await this.startPromptSettings(false); + if (this.promptPanelState) { + this.promptPanelState.draft.provider = 'powerlevel10k'; + this.promptPanelState.p10kStatus = this.detectPowerlevel10k(this.promptPanelState.draft); + this.promptPanelState.step = 'p10kConfirm'; + this.promptPanelState.selectedIndex = 0; } - if (key.kind === 'right' || key.kind === 'lineEnd' || key.kind === 'bufferEnd' || key.kind === 'complete' || key.kind === 'enter') { - const text = this.suggestions.acceptance(buffer); - this.suggestions.alternativesOpen = false; - if (text) { - this.editor.clear(); - this.editor.insert(text); + } else if (action === 'importAppearance') { + const status = await detectOhMyPosh(this.promptConfiguration.ohMyPosh.configPath ?? undefined); + if (!status.configPath) state.message = 'Oh My Posh is using its built-in default config, so there is no local file to import. Choose a config in /prompt, or import a file in /theme → Import.'; + else if (!status.configExists) state.message = `Oh My Posh config not found: ${status.configPath}`; + else { + // The existing Theme Studio import (static colors only); the provider is not switched. + this.toolsPanel = undefined; + this.openThemeStudio('import'); + if (this.themeStudio) { + this.themeStudio.importPath = status.configPath; + const result = readThemeImport(status.configPath, this.shellCwd, 'auto'); + if ('errors' in result) this.themeStudio.message = result.errors.join(' '); + else this.themeStudio.importPreview = result; } - return true; } - if (key.kind === 'escape') return this.suggestions.dismiss(buffer); - return false; + } else if (action === 'openFiles' && state.openPaths?.length === 2) { + await this.performHostAction(this.hostActions().openDiff(state.openPaths[0]!, state.openPaths[1]!), message => { state.message = message; this.render(); }); } - if (key.kind === 'wordRight' && atEnd) { - const word = this.suggestions.nextWord(buffer); - if (!word) return false; - this.editor.insert(word); - return true; + else if (action === 'provider') { + const family = state.detail?.providerFamily; + if (family === 'welcome' || family === 'history' || family === 'picker' || family === 'navigation') { + // The one provider surface, focused on this family. + this.toolsPanel = undefined; + this.openProvidersOverview(family); + } + } else if (action === 'refresh') await refreshTools(state, () => this.render()); + else if (action === 'checkUpdates') await this.checkToolUpdates(state); + this.render(); + } + + /** An explicit or due background check: one batched package-manager call, never on render or keystrokes. */ + private async checkToolUpdates(panel?: ToolsPanel): Promise { + if (this.toolUpdateCheckRunning) return; + this.toolUpdateCheckRunning = true; + if (panel) { panel.checking = true; this.render(); } + try { + this.toolUpdates = await runToolUpdateCheck(); + } finally { + this.toolUpdateCheckRunning = false; + if (panel) { panel.checking = false; panel.updates = this.toolUpdates; } } - if (key.kind === 'escape') return this.suggestions.dismiss(buffer); - return false; + if (this.toolsPanel) this.toolsPanel.updates = this.toolUpdates; + if (!this.stopped) this.render(); } - /** External providers are detected in the background; Native answers until then and whenever they are unusable. */ - private applySuggestionProvider(): void { - const id = this.promptConfiguration.suggestions; - this.suggestions.setProvider(id === 'none' ? undefined : this.nativeSuggestions, this.nativeSuggestions); - if (id !== 'deja') return; - const descriptor = SUGGESTION_PROVIDERS.find(provider => provider.id === 'deja')!; - void detectProvider(descriptor).then(status => { - if (this.stopped || this.promptConfiguration.suggestions !== 'deja') return; - const resolved = resolveProvider(SUGGESTION_PROVIDERS, 'deja', status, 'nmsh'); - if (resolved.id === 'deja' && status.binary) this.suggestions.setProvider(new DejaSuggestions(status.binary), this.nativeSuggestions); - else if (resolved.notice) this.output.addHistoryLine(`${SUBTLE}${resolved.notice}${RESET}`); + private async quietToolUpdateCheck(): Promise { + if (!toolUpdateCheckDue(this.promptConfiguration.toolUpdateChecks, this.toolUpdates)) return; + await this.checkToolUpdates(); + const count = Object.keys(this.toolUpdates.outdated).length; + if (count && !this.stopped) { + this.output.addHistoryLine(`${SUBTLE}Optional tool updates available · /tools${RESET}`); this.render(); + } + } + + private studioContext(): StudioContext { + const config = this.promptConfiguration; + const ref = activeThemeRef(config); + const chroma = config.presentation.preset === 'off' ? 'Off' : `${TREATMENT_PRESET_LABELS[config.presentation.preset]} · ${config.presentation.motion}`; + return {themes: config.themes, accent: config.nmsh.accent, ...(ref ? {activeRef: ref} : {}), pinnedTo: target => targetsPinnedTo(config.themeBridge, target), + chroma, activeName: themeLabel(config.nmsh.palette)}; + } + + private openThemeStudio(tab?: StudioTab): void { + this.themeStudio = createThemeStudio(this.studioContext(), tab); + } + + /** + * The shared theme preview: the real Native renderer over synthetic context + * (project, path, Git, Node/Go/Python/Docker, Kubernetes, success and + * failure), UI text tiers and roles, and a syntax sample. Built-in, + * Imported and Custom themes all preview through this one path. + */ + private themePreviewRows(theme: CustomTheme, palette: NativePaletteId | undefined, columns: number, previewChroma = false): string[] { + const base = this.promptConfiguration; + // Preview Chroma Off (the default) shows the exact theme colors; On uses the saved Chroma. Nothing is persisted either way. + const draft: PromptConfiguration = {...base, provider: 'nmsh', customTheme: theme, nmsh: {...base.nmsh, palette: palette ?? 'custom'}, + presentation: previewChroma ? base.presentation : {...base.presentation, preset: 'off'}}; + const width = Math.max(1, columns - 16); + const level = colorLevel(); + const label = (text: string) => ` ${SECONDARY}${text.padEnd(12)}${RESET}`; + const fg = (hex: string) => level === 'none' ? '' : colorEscape(38, parseHexColor(hex)!, level); + const bg = (hex: string) => level === 'none' ? '' : colorEscape(48, parseHexColor(hex)!, level); + const ui = theme.ui; + return this.withDraftTheme(draft, () => { + const showcase = {...draft, modules: draft.modules.map(module => ({...module, visible: true}))}; + return [ + `${label('Prompt')}${buildThemePreviewLine(draft, draft.nmsh.palette, width)}${RESET}`, + `${label('Context')}${buildContextLine(moduleShowcaseContext(), width, showcase, 'composer', 0)}${RESET}`, + truncateAnsi(`${label('Interface')}${fg(ui.primary)}Primary ${fg(ui.secondary)}Secondary ${fg(ui.subtle)}Muted ${fg(ui.accent)}● Accent ${fg(ui.separator)}│ ${RESET}${bg(ui.selection)}${fg(ui.primary)} Selected ${RESET}`, columns), + truncateAnsi(`${label('Status')}${fg(ui.success)}${GLYPHS.success} done ${fg(ui.warning)}! warning ${fg(ui.failure)}${GLYPHS.failure} failed ${fg(ui.info)}i info${RESET}`, columns), + `${label('Syntax')}${renderSyntaxPreviewLine('git commit -m "fix" && npm test', draft.syntax, draft.nmsh.palette)}${RESET}`, + ]; }); } - private startProviderPanel(family: 'welcome' | 'suggestions'): void { - const state: ProviderPanelState = family === 'welcome' - ? createProviderPanel(family, 'Welcome', WELCOME_PROVIDERS, this.promptConfiguration.welcome) - : createProviderPanel(family, 'Suggestions', SUGGESTION_PROVIDERS, this.promptConfiguration.suggestions); - this.providerPanelState = state; - this.welcomePreviews.clear(); - for (const provider of state.providers) { - void detectProvider(provider).then(status => { - state.statuses[provider.id] = status; - if (this.providerPanelState === state) this.render(); - }); - } + private renderThemeStudioRows(state: ThemeStudioState, columns: number): string[] { + const context = this.studioContext(); + const shown = previewTheme(state, context); + const rows = this.dimensions().rows; + const preview = !shown || state.editor?.picker || rows < 24 ? [] : this.themePreviewRows(shown.theme, shown.palette, columns, state.previewChroma); + return renderThemeStudio(state, context, columns, rows, colorLevel(), preview); } - /** 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 === 'suggestions') { - if (selected.id === 'none') return [`${SUBTLE}No ghost text while typing.${RESET}`]; - return [`${ACCENT}${GLYPHS.prompt}${RESET} git st${SECONDARY}atus${RESET} ${SUBTLE}→ / End accept · Alt+→ next word · Ctrl+N/P alternatives · Esc dismiss${RESET}`]; + private handleThemeStudioKey(key: Key, state: ThemeStudioState): void { + const action = studioKey(state, key, colorLevel(), this.shellCwd, this.studioContext()); + if (!action) return; + if (action.kind === 'close') { this.themeStudio = undefined; this.returnFromPanel(); return; } + const config = this.promptConfiguration; + if (action.kind === 'export') { + const asset = findTheme(config.themes, action.id); + if (!asset) { state.message = 'That theme no longer exists.'; return; } + try { state.message = `Exported to ${writeThemeExport(asset.theme)}`; } + catch (error) { state.message = `Export failed: ${error instanceof Error ? error.message : String(error)}`; } + return; } - if (selected.id === 'none') return [`${SUBTLE}No welcome; new sessions start at the first command.${RESET}`]; - if (selected.id === 'vespyr') return renderWelcome(createWelcomeSnapshot(this.buildIdentity, this.shellCwd), width).map(row => row.ansi); - if (state.statuses[selected.id]?.state !== 'installed') return []; - 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 => { - 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}`]); - if (this.providerPanelState === state) this.render(); - }); - return this.welcomePreviews.get(selected.id)!; + const result: ActionResult = action.kind === 'activate' ? setActiveTheme(config, action.ref) + : action.kind === 'saveTheme' ? (action.id ? saveTheme(config, action.id, action.theme) : addTheme(config, action.theme)) + : action.kind === 'importTheme' ? addTheme(config, action.theme, action.origin) + : action.kind === 'rename' ? renameTheme(config, action.id, action.name) + : action.kind === 'duplicate' ? duplicateTheme(config, action.id) + : action.kind === 'duplicateBuiltin' ? duplicateBuiltin(config, action.ref) + : action.kind === 'duplicateCurrent' ? duplicateCurrentToCustom(config) + : deleteTheme(config, action.id, action.confirmIndependent); + if (!result.ok) { state.message = result.error; return; } + if (!this.applySettingsConfiguration(result.config)) return; + state.message = result.message; + // New and imported themes land on their tab, selected, ready to use or edit. + if (result.id && (action.kind === 'importTheme' || action.kind === 'saveTheme' || action.kind.startsWith('duplicate'))) { + const asset = findTheme(result.config.themes, result.id); + if (asset) { + const tab = asset.origin ? 'imported' : 'custom'; + state.tab = tab; + state.focus = 'list'; + state.selected[tab] = result.config.themes.filter(item => Boolean(item.origin) === Boolean(asset.origin)).findIndex(item => item.id === asset.id) + (tab === 'custom' ? 2 : 0); + } + } + if (action.kind === 'activate') this.startSweep('prompt', 'vivid'); } - private async handleProviderPanelKey(key: Key, state: ProviderPanelState): Promise { - if (state.step === 'installProgress') return; - if (key.kind === 'escape' || key.kind === 'interrupt') { - if (state.step === 'installConfirm') state.step = 'list'; - else { this.providerPanelState = undefined; this.returnFromPanel(); } - } else if (key.kind === 'enter') { - 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); - this.render(); - const outcome = await state.task.run(selected.install.command, [...selected.install.args]); - if (this.stopped) return; - clearProviderDetection(); - state.statuses[selected.id] = await detectProvider(selected); - state.step = 'list'; - state.message = outcome.status === 'succeeded' && state.statuses[selected.id]?.state === 'installed' - ? `${selected.label} installed.` : `${selected.label} was not installed. ${state.task.state.error ?? ''}`.trim(); + // ---- Theme Bridge ----------------------------------------------------------- + + private bridgeFacts?: Record; + private bridgeTimer?: NodeJS.Timeout; + private bridgeRun?: Promise; + /** The last Theme Bridge application problems, shown by /theme-bridge. */ + private bridgeProblems: ApplyOutcome[] = []; + + private async themeBridgeContext(): Promise { + this.bridgeFacts ??= await detectTargets(); + const config = this.promptConfiguration; + return {source: config, facts: this.bridgeFacts, level: colorLevel()}; + } + + /** + * Debounced application of the current Theme Bridge state. With every + * target Independent and nothing generated before, nothing is written at all. + */ + private scheduleThemeBridge(delay = 150): void { + if (this.bridgeTimer) clearTimeout(this.bridgeTimer); + this.bridgeTimer = setTimeout(() => { + this.bridgeTimer = undefined; + if (this.stopped) return; + const config = this.promptConfiguration; + if (!anyBridgeTargetActive(config.themeBridge) && !bridgeStateExists()) return; + this.bridgeRun = this.themeBridgeContext().then(applyThemeBridge).then(outcomes => { + this.bridgeProblems = outcomes.filter(outcome => !outcome.ok); + if (this.themeBridgePanel) this.render(); + return outcomes; + }).catch(error => { + this.bridgeProblems = [{target: 'pager', ok: false, message: error instanceof Error ? error.message : String(error)}]; + return this.bridgeProblems; + }); + }, delay); + this.bridgeTimer.unref?.(); + } + + /** OSC 7 / OSC 133 for capable hosts, held while a fullscreen program owns the terminal. */ + private readonly hostSemantics = new HostSemantics(semanticSupport(), data => { process.stdout.write(data); }, + () => this.presentationStarted && !this.stopped && !this.passthrough && !this.externalPassthrough && !this.frontendSuspended); + private themeBridgePanel?: ThemeBridgePanelState; + private bridgeReports: TargetReport[] = []; + /** A plan shown for confirmation, kept with what it came from; confirming applies exactly this plan. */ + private bridgePlan?: {target: BridgeTargetId; plan?: FileEditPlan; spec?: HookSpec; removal: boolean}; + /** bat setup awaiting confirmation: the source theme reference (possibly a fresh Custom duplicate). */ + private bridgeBatPlan?: {ref: string}; + /** Apply all: the reviewed include plans by target. */ + private bridgeReviewPlans = new Map(); + + private async openThemeBridge(): Promise { + this.themeBridgePanel = createThemeBridgePanel(); + await this.refreshBridgeReports(); + this.render(); + } + + private async refreshBridgeReports(): Promise { + this.bridgeFacts ??= await detectTargets(); + this.bridgeReports = reportTargets(await this.themeBridgeContext()); + } + + private themeBridgePanelContext(): BridgePanelContext { + const config = this.promptConfiguration; + const ledger = loadLedger(); + const active = activeThemeRef(config); + const bridge = config.themeBridge; + return {enabled: bridge.enabled, policy: bridge.policy, ...(bridge.theme ? {globalTheme: bridge.theme, globalThemeLabel: themeRefLabel(bridge.theme, config)} : {}), + reports: this.bridgeReports, themes: selectableThemes(config), pinned: target => bridge.targets[target].theme, + ...(active ? {activeRef: active, activeLabel: themeRefLabel(active, config)} : {}), + managed: target => target === 'tmux' || target === 'neovim' || target === 'vim' || target === 'helix' || target === 'bat' + ? {...(ledger.entries[target] && ownership(target, ledger) === 'owned' ? {artifact: artifactPath(target)} : {}), ...(target !== 'bat' && recordedHook(target as HookTarget) ? {include: recordedHook(target as HookTarget)!.configPath} : {})} + : undefined}; + } + + /** Save a Theme Bridge settings change and apply it at once (not debounced) so the panel status is factual. */ + private async saveBridge(change: (bridge: PromptConfiguration['themeBridge']) => void): Promise { + const config = structuredClone(this.promptConfiguration); + change(config.themeBridge); + if (!this.applySettingsConfiguration(config)) return false; + if (this.bridgeTimer) { clearTimeout(this.bridgeTimer); this.bridgeTimer = undefined; } + this.bridgeProblems = (await applyThemeBridge(await this.themeBridgeContext())).filter(outcome => !outcome.ok); + return true; + } + + private async handleThemeBridgeKey(key: Key, state: ThemeBridgePanelState): Promise { + const action = themeBridgePanelKey(state, key, this.themeBridgePanelContext()); + if (!action) { this.render(); return; } + await this.handleThemeBridgeAction(action, state); + } + + private async handleThemeBridgeAction(action: BridgePanelAction, state: ThemeBridgePanelState): Promise { + if (action.kind === 'close') { this.themeBridgePanel = undefined; this.bridgePlan = undefined; this.bridgeBatPlan = undefined; this.returnFromPanel(); this.render(); return; } + const home = process.env.HOME || homedir(); + const problem = (target: BridgeTargetId) => this.bridgeProblems.find(outcome => outcome.target === target)?.message; + if (action.kind === 'setEnabled') { + if (await this.saveBridge(bridge => { bridge.enabled = action.enabled; })) { + state.message = action.enabled ? 'Theme Bridge is On.' : 'Theme Bridge is Off; every tool is Independent and NMSh-set values are restored at the next prompt.'; + } + } else if (action.kind === 'setPolicy') { + // The per-target Manual settings are never rewritten by a global policy. + if (await this.saveBridge(bridge => { bridge.policy = action.policy; if (action.theme) bridge.theme = action.theme; })) { + state.message = `Apply themes · ${BRIDGE_POLICY_LABELS[action.policy]}${action.policy === 'manual' ? ' · each tool\'s own setting is back' : ''}`; + } + } else if (action.kind === 'setGlobalTheme') { + if (await this.saveBridge(bridge => { bridge.theme = action.theme; })) state.message = `Every supported tool uses ${themeRefLabel(action.theme, this.promptConfiguration)}.`; + } else if (action.kind === 'setMode') { + if (await this.saveBridge(bridge => { + const theme = action.theme ?? bridge.targets[action.target].theme; + bridge.targets[action.target] = {mode: action.mode, ...(theme ? {theme} : {})}; + // Choosing a mode for a tool is the opt-in; Independent leaves the switch as it is. + if (action.mode !== 'independent') bridge.enabled = true; + })) { + state.message = problem(action.target) ?? `${BRIDGE_TARGET_LABELS[action.target]} · ${BRIDGE_MODE_LABELS[action.mode]}${action.target === 'pager' || action.target === 'lsColors' ? ' · NMSh shells apply it at their next prompt' : ''}`; + } + } else if (action.kind === 'reloadTmux') { + state.message = (await reloadTmux()).message; + } else if (action.kind === 'planHook') { + const target = action.target as HookTarget; + const spec = hookSpec(target); + if ('error' in spec) state.message = spec.error; + else { + const planned = planHook(spec, home); + if ('error' in planned) state.message = planned.error; + else if ('noop' in planned) state.message = 'The include is already in place.'; + else { + this.bridgePlan = {target, plan: planned.plan, spec, removal: false}; + state.confirm = {kind: 'hook', target, path: spec.configPath, preview: planned.plan.preview}; + } + } + } else if (action.kind === 'planRemoval') { + const target = action.target as HookTarget | 'bat'; + const hook = target === 'bat' ? undefined : recordedHook(target as HookTarget); + if (!hook) { + this.bridgePlan = {target, removal: true}; + state.confirm = {kind: 'removeSetup', target, path: artifactPath(target), preview: [`- ${artifactPath(target)} (NMSh-managed file; no config include is recorded)`]}; } else { - const action = providerPanelEnterAction(state); - if (action === 'installConfirm') state.step = 'installConfirm'; - else if (action === 'unavailable') state.message = `${selected.label} is not available on this system.`; - else this.saveProviderChoice(state); + const planned = planHookRemoval(target as HookTarget, home); + if ('error' in planned) state.message = planned.error; + else { + this.bridgePlan = {target, ...('plan' in planned ? {plan: planned.plan} : {}), removal: true}; + state.confirm = {kind: 'removeSetup', target, path: hook.configPath, preview: ['plan' in planned ? planned.plan.preview : [' (the include is already gone from the file)'], [`- ${artifactPath(target)}`]].flat()}; + } } - } else if (!handleProviderPanelKey(key, state)) return; + } else if (action.kind === 'confirmHook' || action.kind === 'confirmRemoval') { + const pending = this.bridgePlan; + this.bridgePlan = undefined; + if (!pending || pending.target !== action.target) state.message = 'Nothing was changed.'; + else if (!pending.removal && pending.plan && pending.spec) { + const result = applyHook(pending.target as HookTarget, pending.plan, pending.spec); + state.message = result.ok ? `Added the include to ${pending.spec.configPath}. New ${BRIDGE_TARGET_LABELS[pending.target]} instances load NMSh colors; later theme changes update automatically${pending.target === 'tmux' ? ' (Reload applies them to the running server)' : ''}.` : result.error; + } else if (pending.removal) { + // Remove managed setup: NMSh's file and include only. The tool's mode is not changed behind the user's back. + const removed = pending.target === 'bat' ? {ok: true as const} : applyHookRemoval(pending.target as HookTarget, pending.plan); + if (!removed.ok) state.message = removed.error; + else { + const artifact = removeArtifact(pending.target as ManagedTarget); + state.message = artifact.ok ? `${BRIDGE_TARGET_LABELS[pending.target]}: NMSh's managed setup is removed${bridgeMode(this.promptConfiguration.themeBridge, pending.target) !== 'independent' ? '; it reports Needs setup until set up again' : ''}.` : artifact.error; + if (artifact.ok && pending.target !== 'bat') { + // A target that is still active would regenerate its file at once; only Independent targets stay clean. + if (bridgeMode(this.promptConfiguration.themeBridge, pending.target) !== 'independent') this.bridgeProblems = (await applyThemeBridge(await this.themeBridgeContext())).filter(outcome => !outcome.ok); + } + } + } + } else if (action.kind === 'planBat') { + const reports = this.bridgeReports.find(report => report.target === 'bat'); + let ref = targetPalette(this.promptConfiguration.themeBridge, 'bat', this.promptConfiguration).ref ?? activeThemeRef(this.promptConfiguration); + if (action.source === 'duplicate' && ref) { + // An ordinary Custom copy of the source theme, editable in Theme Studio; bat then pins it (Manual). + const copy = duplicateRefToCustom(this.promptConfiguration, ref); + if (!copy.ok) { state.message = copy.error; this.render(); return; } + const newRef = assetRef(copy.id!); + const config = copy.config; + config.themeBridge = {...config.themeBridge, policy: config.themeBridge.policy, targets: {...config.themeBridge.targets, bat: {mode: 'choose', theme: newRef}}}; + if (!this.applySettingsConfiguration(config)) { this.render(); return; } + ref = newRef; + state.message = `Created ${themeRefLabel(newRef, this.promptConfiguration)} (Custom). Edit it in /theme; bat ${this.promptConfiguration.themeBridge.policy === 'manual' ? 'is pinned to it' : 'uses it when Apply themes is Manual'}.`; + } + if (!ref) { state.message = 'No theme to create the bat theme from.'; this.render(); return; } + this.bridgeBatPlan = {ref}; + void reports; + state.confirm = {kind: 'batCache', target: 'bat', path: artifactPath('bat'), preview: [`+ ${artifactPath('bat')} (from ${themeRefLabel(ref, this.promptConfiguration)})`, '+ bat cache --build', ' bat --list-themes must then include nmsh-bridge; only then is BAT_THEME set in NMSh shells']}; + } else if (action.kind === 'confirmBat') { + const pending = this.bridgeBatPlan; + this.bridgeBatPlan = undefined; + const resolved = pending ? resolveSemanticPalette(pending.ref, this.promptConfiguration) : undefined; + if (!resolved?.ok) state.message = 'Nothing was changed.'; + else { + const result = await setupBat(resolved.palette, bridgeMode(this.promptConfiguration.themeBridge, 'bat'), pending!.ref); + state.message = result.message; + this.bridgeProblems = (await applyThemeBridge(await this.themeBridgeContext())).filter(outcome => !outcome.ok); + } + } else if (action.kind === 'reviewAll') { + const items = integrationHealth(await this.themeBridgeContext()); + const previews: Record = {}; + this.bridgeReviewPlans.clear(); + for (const item of items) { + if (item.action !== 'include') continue; + const spec = hookSpec(item.target as HookTarget); + if ('error' in spec) { item.action = undefined; item.state = 'conflict'; item.detail = spec.error; continue; } + const planned = planHook(spec, home); + if ('plan' in planned) { this.bridgeReviewPlans.set(item.target, {plan: planned.plan, spec}); previews[item.target] = [`${spec.configPath}`, ...planned.plan.preview.filter(line => line.startsWith('+'))]; } + else { item.action = undefined; item.detail = 'error' in planned ? planned.error : 'Already in place'; item.state = 'error' in planned ? 'conflict' : 'ready'; } + } + for (const item of items) if (item.action === 'cache') previews[item.target] = [`+ ${artifactPath('bat')}`, '+ bat cache --build (then verified with bat --list-themes)']; + state.review = {items, previews, yes: false}; + } else if (action.kind === 'applyAll') { + const results: string[] = []; + const context = await this.themeBridgeContext(); + for (const item of integrationHealth(context)) { + if (item.action === 'generate') { const outcomes = await applyThemeBridge(context); results.push(`${item.label}: ${outcomes.find(outcome => outcome.target === item.target)?.message ?? 'generated'}`); } + else if (item.action === 'include') { + const reviewed = this.bridgeReviewPlans.get(item.target); + if (!reviewed) { results.push(`${item.label}: skipped (not in the reviewed plan)`); continue; } + const result = applyHook(item.target as HookTarget, reviewed.plan, reviewed.spec); + results.push(`${item.label}: ${result.ok ? 'include added' : result.error}`); + } else if (item.action === 'cache') { + const palette = targetPalette(this.promptConfiguration.themeBridge, 'bat', this.promptConfiguration); + const result = palette.palette ? await setupBat(palette.palette, bridgeMode(this.promptConfiguration.themeBridge, 'bat'), palette.ref ?? '') : {ok: false, message: 'no theme'}; + results.push(`${item.label}: ${result.ok ? 'ready' : result.message}`); + } + } + this.bridgeReviewPlans.clear(); + this.bridgeProblems = (await applyThemeBridge(await this.themeBridgeContext())).filter(outcome => !outcome.ok); + state.message = results.length ? results.join(' · ') : 'Nothing needed changing.'; + } + await this.refreshBridgeReports(); this.render(); } - private saveProviderChoice(state: ProviderPanelState): void { - const selected = providerPanelSelection(state); - const next = state.family === 'welcome' - ? {...structuredClone(this.promptConfiguration), welcome: selected.id as PromptConfiguration['welcome']} - : {...structuredClone(this.promptConfiguration), suggestions: selected.id as PromptConfiguration['suggestions']}; - try { - savePromptConfiguration(next); - this.promptConfiguration = next; - this.providerPanelState = undefined; - if (state.family === 'suggestions') this.applySuggestionProvider(); - this.output.addHistoryLine(state.family === 'welcome' - ? `${SUCCESS}Welcome · ${selected.label} · shown on launch and /clear.${RESET}` - : `${SUCCESS}Suggestions · ${selected.label}.${RESET}`); - } catch (error) { - state.message = `Could not save: ${error instanceof Error ? error.message : String(error)}`; - } + /** + * Picker orientation follows the composer: Bottom puts the query at the + * bottom with results above (best match nearest the input), Top puts the + * query at the top with results below. Flow keeps results below its input, + * so it reads like Top; fullscreen external pickers use the bottom layout for Flow. + */ + private pickerFromBottom(): boolean { + return this.promptConfiguration.composerPosition === 'bottom'; } - private startTranscriptSettings(): void { - const saved = structuredClone(this.promptConfiguration.transcript); - this.transcriptPanelState = {selectedIndex: 0, draft: structuredClone(saved), saved}; + private orientPicker(rows: string[]): string[] { + return (this.historySearchActive || this.directorySearchActive) && this.pickerFromBottom() ? [...rows].reverse() : rows; } - /** A representative history header: the live provider's identity over preview-only modules. */ - private transcriptPreviewSample(): HistoricalContextSnapshot { - const context = themePreviewContext(); - const prompt = this.effectivePromptProvider !== 'nmsh' && this.externalPrompt - ? this.currentPromptSnapshot() - : nativePromptSnapshot(context, this.promptConfiguration); - return {cwd: context.cwd, project: context.project, branch: context.branch, prompt}; + /** fzf layout for NMSh-owned launches: query at the bottom (fzf's default) unless the composer docks at the top. */ + private fzfLayout(): 'default' | 'reverse' { + return this.promptConfiguration.composerPosition === 'top' ? 'reverse' : 'default'; } - private saveTranscriptSettings(): void { - const state = this.transcriptPanelState; - if (!state) return; - const next = {...structuredClone(this.promptConfiguration), transcript: structuredClone(state.draft)}; - try { - savePromptConfiguration(next); - this.promptConfiguration = next; - this.output.setTranscriptAppearance(next.transcript); - this.transcriptPanelState = undefined; - this.output.addHistoryLine(`${SUCCESS}Transcript settings saved.${RESET}`); - } catch (error) { - state.message = `Could not save transcript settings: ${error instanceof Error ? error.message : String(error)}`; + // ---- Status strip, /configure, /tmux, /integrations ------------------------------- + + private rowPanel?: RowPanelState; + private configureList?: ConfigureListState; + private tmuxPanel?: TmuxPanelState; + /** The include plan shown in the tmux review, applied exactly if confirmed. */ + private tmuxIncludePlan?: {plan: FileEditPlan; spec: HookSpec}; + + /** /strip and /status-strip: the canonical Status strip rows, with a live strip preview. */ + private openStatusStrip(): void { + this.rowPanel = createRowPanel('Status strip', 'compact NMSh status row, top right · same settings as Config', ['statusStrip', 'stripClock', 'stripBattery', 'stripCpu', 'stripRam', 'stripRamDisplay', 'stripUptime']); + } + + /** /configure [tool] and /tmux: the registered adapter's editor, or a factual answer. */ + private async openConfigure(tool: string | undefined, command: string): Promise { + if (!tool) { this.configureList = {selected: 0}; this.render(); return; } + const entry = toolConfigEntry(tool); + if (!entry) { this.output.addFrontendInteraction(command, `NMSh has no managed configuration adapter for ${tool}. /configure lists the tools it can configure.`, INFO); this.render(); return; } + if (entry.id === 'starship') { void this.startToolConfiguration('starship'); return; } + if (entry.id === 'tmux') { + const user = readUserTmuxConfig(); + this.bridgeFacts ??= await detectTargets(); + const report = reportTargets(await this.themeBridgeContext()).find(item => item.target === 'tmux'); + this.tmuxPanel = createTmuxPanel(loadTmuxModel(), user ? {path: user.path, parsed: parseTmuxConfig(user.text)} : undefined, + report ? `${BRIDGE_MODE_LABELS[report.mode]}${report.themeLabel && report.mode !== 'independent' ? ` · ${report.themeLabel}` : ''}` : 'Independent', Boolean(this.bridgeFacts.tmux?.installed)); + this.render(); + return; } + if (entry.ownership === 'theme-bridge') { await this.openThemeBridge(); return; } + this.output.addFrontendInteraction(command, `${entry.label}: ${entry.summary}.`, INFO); this.render(); } - private startLayoutSettings(): void { - const {composerPosition, transcriptPresentation} = this.promptConfiguration; - this.layoutPanelState = createLayoutPanel({composerPosition, transcriptPresentation}); + /** /integrations: the shared Theme Bridge planner, opened straight on Review all. */ + private async openIntegrations(): Promise { + await this.openThemeBridge(); + if (this.themeBridgePanel) await this.handleThemeBridgeAction({kind: 'reviewAll'}, this.themeBridgePanel); } - /** Persist the chosen layout and apply it live; the preview itself never touched the real transcript. */ - private saveLayoutSettings(): void { - const state = this.layoutPanelState; - if (!state) return; - const next = {...structuredClone(this.promptConfiguration), ...state.draft}; - this.applySettingsConfiguration(next); - if (this.promptConfiguration !== next) { - state.message = 'Could not save the layout; check that the NMSh configuration directory is writable.'; + private dotfiles?: DotfilesState; + private dotfilesInclude?: {plan: FileEditPlan; spec: HookSpec}; + + /** /dotfiles actions: scanning reads data only; cloning needs the confirmed step; applying goes through the adapters. */ + private async handleDotfilesAction(action: DotfilesAction, state: DotfilesState): Promise { + if (action.kind === 'close') { this.dotfiles = undefined; this.dotfilesInclude = undefined; this.returnFromPanel(); return; } + if (action.kind === 'scan') { + if (isRemoteSource(action.source)) { + const slug = action.source.replace(/^.*[/:]/u, '').replace(/\.git$/u, '').replace(/[^A-Za-z0-9._-]/gu, '-').slice(0, 60) || 'repository'; + state.clone = {url: action.source, target: join(nmshConfigDirectory(), 'dotfiles-inspect', `${slug}-${Date.now()}`), yes: false}; + state.step = 'clone'; + return; + } + const root = expandSource(action.source, this.shellCwd); + const scan = scanDotfiles(root); + if ('error' in scan) { state.message = scan.error; state.step = 'source'; return; } + state.scan = scan; + state.items = buildPlan(scan); + state.selected = 0; + state.step = 'items'; + return; + } + if (action.kind === 'clone' && state.clone) { + const git = resolveCommand('git', process.env.PATH ?? ''); + if (!git) { state.message = 'git is not installed.'; state.step = 'source'; return; } + state.message = 'Cloning…'; this.render(); + const env = {...process.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1'}; + const result = await runExternal(git, ['clone', '--depth', '1', '--no-recurse-submodules', '-c', 'core.hooksPath=/dev/null', '-c', 'protocol.file.allow=never', '--', state.clone.url, state.clone.target], + {timeoutMs: 120_000, maxBytes: 64 * 1024, env}); + if (!result.ok) { state.message = 'The clone failed; nothing else was done.'; state.step = 'source'; state.clone = undefined; return; } + const target = state.clone.target; + state.clone = undefined; + await this.handleDotfilesAction({kind: 'scan', source: target}, state); return; } - this.layoutPanelState = undefined; - this.historyViewport.latest(); - this.returnFromPanel(); - this.output.addHistoryLine(`${SUCCESS}Layout saved.${RESET}`); + if (action.kind === 'review') { + this.dotfilesInclude = undefined; + const include: string[] = []; + if (state.items.some(item => item.mode === 'import' && item.fields?.some(field => field.use)) && !recordedHook('tmux')) { + const spec = hookSpec('tmux'); + if (!('error' in spec)) { + const planned = planHook(spec, process.env.HOME || homedir()); + if ('plan' in planned) { this.dotfilesInclude = {plan: planned.plan, spec}; include.push(spec.configPath, ...planned.plan.preview.filter(line => line.startsWith('+'))); } + } + } + state.review = {lines: reviewLines(state.items, include), yes: false}; + state.step = 'review'; + return; + } + if (action.kind === 'apply') { + const results = applyDotfilesPlan(state.items); + if (this.dotfilesInclude) { + const included = applyHook('tmux', this.dotfilesInclude.plan, this.dotfilesInclude.spec); + results.push(`tmux.conf: ${included.ok ? 'one include added' : included.error}`); + this.dotfilesInclude = undefined; + } + state.results = results.length ? results : ['Nothing was selected, so nothing changed.']; + state.step = 'result'; + } + } + + private async handleTmuxKey(key: Key, state: TmuxPanelState): Promise { + const action = tmuxPanelKey(state, key); + if (action?.kind === 'close') { this.tmuxPanel = undefined; this.tmuxIncludePlan = undefined; this.returnFromPanel(); } + else if (action?.kind === 'openPrompt') { this.tmuxPanel = undefined; void this.startPromptSettings(false); } + else if (action?.kind === 'openBridge') { this.tmuxPanel = undefined; await this.openThemeBridge(); } + else if (action?.kind === 'reload') state.message = (await reloadTmux()).message; + else if (action?.kind === 'review') { + const include: string[] = []; + this.tmuxIncludePlan = undefined; + if (!recordedHook('tmux')) { + const spec = hookSpec('tmux'); + if ('error' in spec) include.push(` ${spec.error}`); + else { + const planned = planHook(spec, process.env.HOME || homedir()); + if ('plan' in planned) { this.tmuxIncludePlan = {plan: planned.plan, spec}; include.push(spec.configPath, ...planned.plan.preview); } + } + } + state.review = {lines: pendingChanges(state).map(describeTmuxChange), include, yes: false}; + } else if (action?.kind === 'apply') { + try { + saveTmuxModel(state.draft); + const written = writeTmuxManaged(state.draft); + if (!written.ok) state.message = written.error; + else { + const included = this.tmuxIncludePlan ? applyHook('tmux', this.tmuxIncludePlan.plan, this.tmuxIncludePlan.spec) : {ok: true as const}; + this.tmuxIncludePlan = undefined; + state.saved = structuredClone(state.draft); + state.message = included.ok ? 'Saved to NMSh\'s managed tmux file. New tmux servers load it; R reloads a running server now.' : included.error; + } + } catch (error) { state.message = `Nothing was applied: ${error instanceof Error ? error.message : String(error)}`; } + } this.render(); } - private startSyntaxSettings(): void { - const saved = structuredClone(this.promptConfiguration.syntax); - this.syntaxPanelState = {selectedIndex: 0, draft: structuredClone(saved), saved}; + /** fzf `--color` for an NMSh-owned launch; empty unless fzf is the picker and its bridge mode is active. */ + private async fzfThemeArgs(): Promise { + const config = this.promptConfiguration; + if (config.picker !== 'fzf' || config.themeBridge.targets.fzf.mode === 'independent') return []; + return fzfBridgeArgs(await this.themeBridgeContext()); } - /** New input and new commands use the saved style; submitted history keeps its captured ANSI. */ - private saveSyntaxSettings(): void { - const state = this.syntaxPanelState; - if (!state) return; - const next = {...structuredClone(this.promptConfiguration), syntax: structuredClone(state.draft)}; - try { - savePromptConfiguration(next); - this.promptConfiguration = next; - this.syntaxPanelState = undefined; - this.returnFromPanel(); - this.output.addHistoryLine(`${SUCCESS}Syntax settings saved.${RESET}`); - } catch (error) { - state.message = `Could not save syntax settings: ${error instanceof Error ? error.message : String(error)}`; + // ---- Idle visuals --------------------------------------------------------------- + + /** Any input, output, resize or lifecycle change: restart the inactivity countdown (and end idle visuals). */ + private noteActivity(): void { + this.lastActivity = Date.now(); + if (this.idle) this.dismissIdle(); + else this.armIdle(); + } + + /** One timer for the configured timeout; none when the timeout is Never or NMSh is not running. */ + private armIdle(): void { + if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = undefined; } + const minutes = this.promptConfiguration.idleVisuals.timeout; + // While idle visuals own the screen there is nothing to count down to. + if (!minutes || this.stopped || !this.presentationStarted || this.idle) return; + const delay = Math.max(1000, this.lastActivity + minutes * 60_000 - Date.now()); + this.idleTimer = setTimeout(() => { this.idleTimer = undefined; this.onIdleTimeout(); }, delay); + this.idleTimer.unref?.(); + } + + /** + * Starts only at a safe, quiet prompt that NMSh owns: no running or waiting command, no panel, + * picker or palette, no passthrough, not suspended. Focus does not matter: an unfocused + * terminal is often still visible (another monitor, beside a browser or editor). + */ + private idleEligible(): boolean { + return !this.stopped && this.presentationStarted && !this.passthrough && !this.externalPassthrough && !this.frontendSuspended + && (!this.running || this.promptConfiguration.idleVisuals.runWhileBusy) && !this.startupPending && !this.settingsPanelActive && !this.paletteState && !this.presetStartup?.active + && !this.pickerOpening && !this.updateInProgress && !this.agents.sessions.some(session => session.attention); + } + + private onIdleTimeout(): void { + const minutes = this.promptConfiguration.idleVisuals.timeout; + if (!minutes) return; + if (Date.now() - this.lastActivity < minutes * 60_000 - 50) { this.armIdle(); return; } + // Reduced Motion: screen savers never start by themselves (a manual preview still works). + const screenMode = SCREEN_MODE_EFFECT[this.promptConfiguration.idleVisuals.mode] !== undefined || this.promptConfiguration.idleVisuals.mode === 'random'; + if (screenMode && idleMotion(this.promptConfiguration).still) { this.lastActivity = Date.now(); this.armIdle(); return; } + if (this.idleEligible()) this.startIdle(false); + else { this.lastActivity = Date.now(); this.armIdle(); } + } + + /** Begins the overlay; `preview` is an explicit start from /screensaver. Effects Off keeps idle visuals off. */ + private startIdle(preview: boolean): void { + const motion = idleMotion(this.promptConfiguration); + if (motion.disabled) { + if (preview) this.output.addFrontendInteraction('/screensaver', 'Idle visuals stay off while Decorative effects are Off.', INFO); + return; + } + if (this.stopped || this.passthrough || this.externalPassthrough || this.frontendSuspended || this.idle) return; + if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = undefined; } + const configured = effectiveMode(this.promptConfiguration.idleVisuals.mode, motion); + // The screen as the user sees it right now (or, from the gallery, as it was before the gallery opened). + const wantsCapture = configured === 'random' || SCREEN_MODE_EFFECT[configured] !== undefined; + const captureDimensions = this.dimensions(); + const captured = wantsCapture + ? (this.screensaverPanel && this.saverCapture ? this.saverCapture : this.captureScreen(captureDimensions.columns, captureDimensions.rows)) : undefined; + // Exclusive ownership: every other presentation owner stops before the first idle frame. + // The gallery (if any) stays open underneath and resumes its own preview on dismissal. + this.suspendPresentationOwners(); + const random = configured === 'random'; + const mode: IdleMode = random ? this.pickRandomSaver() : configured; + this.idle = {mode, startedAt: Date.now(), frame: 0, interval: IDLE_FRAME_MS[mode], preview, paused: false, still: motion.still, + ...(captured ? {capture: {...captured, instances: {}}} : {}), random, offset: 0}; + this.renderer.invalidate(); + this.paintIdle(); + if (!motion.still) this.idleSubscription = presentationClock.subscribe(now => this.tickIdle(now), this.idle.interval); + } + + private captureScreen(columns: number, rows: number): ScreenCapture { + return captureFromRows(this.renderer.snapshot(), columns, rows, isDeterministicPresentation() ? IDLE_SEED : (Date.now() ^ (process.pid << 8)) >>> 0); + } + + private pickRandomSaver(previous?: IdleMode): IdleMode { + return (pickRandomSaver(this.randomSaver, previous) ?? 'blackHole') as IdleMode; + } + + private tickIdle(now: number): void { + const idle = this.idle; + if (!idle || idle.paused) return; + idle.frame += 1; + // Random switches only when the current effect finished a full loop, never mid-effect. + if (idle.random) { + const {columns, rows} = this.dimensions(); + const sceneNow = isDeterministicPresentation() ? idle.frame * (SAVER_FRAME_MS[idle.mode] ?? 66) : now - idle.startedAt; + if (saverLoopComplete(idle.mode, {elapsed: sceneNow - idle.offset, width: columns, height: rows, ...(idle.capture ? {capture: idle.capture} : {})})) { + idle.mode = this.pickRandomSaver(idle.mode); + if (idle.capture) idle.capture = {...idle.capture, instances: {}}; + idle.offset = sceneNow; + idle.interval = SAVER_FRAME_MS[idle.mode] ?? 66; + this.stopIdleFrames(); + this.idleSubscription = presentationClock.subscribe(next => this.tickIdle(next), idle.interval); + } + } + const started = performance.now(); + this.paintIdle(now); + // Adaptive cadence: a frame that costs too much slows the scene instead of the shell. + const cost = performance.now() - started; + if (cost > 35 && idle.interval < 500) { + idle.interval = Math.min(500, Math.round(idle.interval * 1.5)); + this.stopIdleFrames(); + this.idleSubscription = presentationClock.subscribe(next => this.tickIdle(next), idle.interval); + } + } + + private paintIdle(now = Date.now()): void { + const idle = this.idle; + if (!idle) return; + const {columns, rows} = this.dimensions(); + const time = idle.still ? 20_000 : sceneTime(now - idle.startedAt, idle.frame, idle.mode); + const frame = idleFrameRows(this.idleGrid, {mode: idle.mode, width: columns, height: rows, time: Math.max(0, time - idle.offset), + palette: idlePaletteFor(this.promptConfiguration), level: colorLevel(), nerd: getCurrentGlyphMode() === 'nerd', ...(idle.capture ? {capture: idle.capture} : {})}); + const shown = this.saverAwake(frame, columns, now); + try { this.renderer.render({rows: shown, columns, cursorRow: 1, cursorColumn: 1, cursorVisible: false}); } + catch (error) { this.onTerminate(); throw error; } + } + + /** The screensaver's positioned Keep Awake status: a small separate element; the saver and mascot are untouched. */ + private saverAwake(frame: string[], columns: number, now: number): string[] { + const record = this.awakeRecord; + const settings = this.promptConfiguration.keepAwake; + if (!record || !settings.screensaver) return frame; + const text = `${awakeLabel(record, settings.display, 'full')} · ${awakeDuration(record, now)}`; + return placeOnSaver(frame, columns, {ansi: `${awakeStyle.active()}${text}${awakeStyle.reset}`, width: displayWidth(text)}, settings.screensaverPosition, + (row, column, ansi, width) => `${sliceAnsiCells(row, 0, column)}${RESET}${' '.repeat(Math.max(0, column - Math.min(column, displayWidth(stripAnsi(sliceAnsiCells(row, 0, column))))))}${ansi}${sliceAnsiCells(row, column + width, columns)}${RESET}`); + } + + private stopIdleFrames(): void { + this.idleSubscription?.(); this.idleSubscription = undefined; + } + + /** + * Stop every animation subscription that paints the normal UI, so the idle + * scene is the only thing drawn while it is active. Each one is recreated by + * the normal render path after dismissal (render → syncPresentationClock, + * syncPanelAnimation, the gallery's own preview subscription). + */ + private suspendPresentationOwners(): void { + this.effects.cancel(); + this.endSweep(); + this.presentationSubscription?.(); this.presentationSubscription = undefined; + this.panelAnimation?.(); this.panelAnimation = undefined; + this.screensaverAnimation?.(); this.screensaverAnimation = undefined; + this.stripTimer?.(); this.stripTimer = undefined; + this.noticeTimer?.(); this.noticeTimer = undefined; + } + + /** Restores the exact presentation underneath: nothing it covered was changed. */ + private dismissIdle(render = true): void { + if (!this.idle) return; + this.stopIdleFrames(); + this.idle = undefined; + this.lastActivity = Date.now(); + this.renderer.invalidate(); + if (render) this.render(); + this.armIdle(); + } + + /** + * Setup Cat's preview for the current step, from the real renderers and the + * draft (never the saved config). Animated steps get one repaint timer while + * shown; nothing ever changes the real terminal cursor. + */ + private setupPreview(state: SetupState, columns: number): string[] { + const section0 = SETUP_SECTIONS[state.section]?.id; + // Prompt/Appearance previews show the base theme unless the local preview switch asks for the draft's Chroma. + const rawPreview = (section0 === 'appearance' || section0 === 'prompt') && !state.previewChroma; + const draft = rawPreview ? {...state.draft, presentation: {...state.draft.presentation, preset: 'off' as const}} : state.draft; + const chromaNote = section0 === 'appearance' || section0 === 'prompt' + ? [` ${PRIMARY}\u001b[1mPreview Chroma${RESET} ${SUBTLE}${state.previewChroma ? 'On' : 'Off'} · Chroma setting ${state.draft.presentation.preset === 'off' ? 'Off' : TREATMENT_PRESET_LABELS[state.draft.presentation.preset]} · P toggles the preview only${RESET}`, + ` ${SUBTLE}${chromaPreviewNote(Boolean(state.previewChroma) && state.draft.presentation.preset !== 'off')}${RESET}`] : []; + const section = SETUP_SECTIONS[state.section]?.id; + const width = Math.max(10, columns - 4); + const label = (text: string) => ` ${SUBTLE}${text.padEnd(12)}${RESET}`; + let animate = false; + const rows: string[] = []; + const motion = idleMotion(draft); + switch (section) { + case 'welcome': { + const cat = vespyrSprite(); + const caption = [`${PRIMARY}Vespyr${RESET} ${SUBTLE}the NMSh cat${RESET}`, `${SUBTLE}also the Native Welcome; Setup Cat is this wizard${RESET}`]; + cat.forEach((line, index) => rows.push(` ${line}${RESET} ${caption[index - 1] ?? ''}`)); + break; + } + case 'terminal': { + // The real production glyphs in both modes, column by column, so a font problem is visible. + rows.push(...glyphDiagnosticRows(draft.glyphStyle).map(row => ` ${row}`), ''); + break; + } + case 'cursor': { + // The selected row, demonstrated by the same preview /cursor shows; it restarts when the row or a value changes. + const selected = setupSelectedRow(state); + const id = selected?.row.id ?? 'cursorShape'; + const scene = id === 'cursorShape' ? 'shape' : id === 'cursorBlink' ? 'blink' : id === 'cursorIdle' ? 'idle' : 'jump'; + const signature = `${id}|${JSON.stringify(draft.cursor)}`; + const now = Date.now(); + if (state.previewKey !== signature) { state.previewKey = signature; state.previewStart = now; } + const env = this.cursorEnv(draft.cursor, draft); + const reason = selected && id !== 'cursorBlink' ? selected.row.unavailable?.(draft) : undefined; + const shown = renderCursorPreview({scene, title: selected ? `${selected.row.label}: ${settingsRowValue(selected.row, draft) ?? ''}` : 'Cursor', settings: resolveCursorSettings(draft.cursor, env.context), + choice: env.choice, columns, elapsed: now - (state.previewStart ?? now), still: env.still, ...(reason ? {unavailable: reason} : {})}); + rows.push(...shown.rows); + if (shown.busy && !this.setupCursorClock) this.setupCursorClock = presentationClock.subscribe(() => { if (this.setupState) this.render(); }, 40, 16); + else if (!shown.busy && this.setupCursorClock) { this.setupCursorClock(); this.setupCursorClock = undefined; } + break; + } + case 'motion': { + // The selected motion, run once on sample content by the same renderer /appearance → Motion uses; it restarts on a new row or value. + const selected = setupSelectedRow(state); + const item = MOTION_ROWS.find(entry => `motion_${entry.key}` === selected?.row.id) ?? MOTION_ROWS.find(entry => entry.key === 'commandLaunch')!; + const gate: MotionGate = {reducedMotion: draft.presentation.reducedMotion || isReducedMotion(), effectsOff: draft.presentation.effectsOff, color: colorLevel() !== 'none'}; + const signature = `${item.key}|${JSON.stringify(draft.motion)}|${gate.reducedMotion}|${gate.effectsOff}`; + const now = Date.now(); + if (state.previewKey !== signature) { state.previewKey = signature; state.previewStart = now; } + const shown = renderMotionPreview(item.key, draft.motion, gate, columns, state.previewStart ?? now, now); + rows.push(` ${SUBTLE}Preview · ${item.label} · ${draft.motion.rendering === 'rich' ? 'Rich' : 'Clean'}${RESET}`, ...shown.rows); + if (shown.busy && !this.setupCursorClock) this.setupCursorClock = presentationClock.subscribe(() => { if (this.setupState) this.render(); }, 40, 16); + else if (!shown.busy && this.setupCursorClock) { this.setupCursorClock(); this.setupCursorClock = undefined; } + break; + } + case 'prompt': { + if (draft.provider === 'nmsh') { + rows.push(`${label('Prompt')}${buildContextLine(themePreviewContext(), width - 12, draft, 'composer', Date.now())}${RESET}`); + rows.push(`${label('Composer')}${ACCENT}${GLYPHS.prompt}${RESET} ${PRIMARY}git status${RESET}`); + animate ||= treatmentAnimated(draft.presentation); + break; + } + if (draft.provider === 'none') { + rows.push(`${label('Prompt')}${SUBTLE}None · composer only${RESET}`, `${label('Composer')}${ACCENT}${GLYPHS.prompt}${RESET} ${PRIMARY}git status${RESET}`); + rows.push(` ${SUBTLE}${providerExplanation('prompt', draft.provider)}${RESET}`); + break; + } + // An external provider's own prompt, never the Native one standing in for it. + rows.push(this.setupExternalPromptRow(draft, width - 12, label(providerLabel(draft.provider)))); + rows.push(` ${SUBTLE}${providerExplanation('prompt', draft.provider)}${RESET}`, ` ${SUBTLE}${NATIVE_ONLY_NOTE}${RESET}`); + break; + } + case 'appearance': { + rows.push(`${label('Prompt')}${buildThemePreviewLine(draft, draft.nmsh.palette, width - 12, Date.now())}${RESET}`); + rows.push(`${label('Chrome')}${renderTabStrip(['Settings', 'Status', 'Config'], 2, Math.min(36, width - 12), true)}`); + rows.push(`${label('')}${SEPARATOR}${repeatToWidth(GLYPHS.separator, Math.min(36, width - 12))}${RESET}`); + rows.push(`${label('')}${background(UI_COLORS.selection)}${ACCENT}${GLYPHS.selection} ${PRIMARY}Selected row${' '.repeat(Math.max(0, Math.min(36, width - 12) - 14))}${RESET}`); + if (draft.presentation.preset !== 'off') { + // The selected treatment, live: Breathe breathes, Comet and Travel move, Static stays still. + const treatment = treatmentFor({...draft.presentation, intensity: 1}, themeChromaStops(draft.nmsh.palette, draft.nmsh.vibrance)); + const swatch = treatment ? treatmentText('█'.repeat(Math.max(4, Math.min(36, width - 12))), treatment, + {role: 'effect', base: {red: 40, green: 40, blue: 46}, reducedMotion: draft.presentation.reducedMotion || isReducedMotion(), effectsOff: draft.presentation.effectsOff}, Date.now()) : ''; + rows.push(`${label('Chroma')}${swatch}${RESET} ${SUBTLE}${TREATMENT_MOTION_LABELS[draft.presentation.motion]}${RESET}`); + if (draft.provider === 'nmsh') rows.push(` ${SUBTLE}${CHROMA_PREVIEW_NOTE}${RESET}`); + } + rows.push(` ${SUBTLE}${draft.presentation.effectsOff ? 'Decorative effects Off: Chroma motion, sparkles and idle visuals stay still.' + : draft.presentation.reducedMotion ? 'Reduced Motion: colors stay, movement stops.' : 'Decorative effects On: Chroma motion and effects may move.'}${RESET}`); + // The light sweep over a semantic sample: with Semantic Preserve, ✔ and ✘ keep their hues. + const style = sweepStyleFor(draft); + if (style.level !== 'off') { + const sample: Array<{glyph: string; color?: Rgb; semantic?: boolean}> = []; + const add = (text: string, color: Rgb | undefined, semantic = false) => { for (const glyph of graphemes(text)) sample.push({glyph, color: glyph === ' ' ? undefined : color, semantic}); }; + add('✔ 0 ', NATIVE_PROMPT_THEMES[draft.nmsh.palette].colors('success').background, true); + add('notMyShell ', NATIVE_PROMPT_THEMES[draft.nmsh.palette].colors('project').background); + add('~/src ', NATIVE_PROMPT_THEMES[draft.nmsh.palette].colors('cwd').background); + add('✘ 1', NATIVE_PROMPT_THEMES[draft.nmsh.palette].colors('failure').background, true); + // One pass on each change in this step (the same event as the selected row), not a loop. + rows.push(`${label('Shimmer')}${sweepOnce(sample, this.sweep?.target === 'selection' ? this.sweepElapsed() : Number.POSITIVE_INFINITY, style, colorLevel(), sweepStill(draft))}${RESET}`); + } + animate ||= treatmentAnimated(draft.presentation); + break; + } + case 'editor': { + rows.push(...this.setupEditorPreview(draft, width)); + break; + } + case 'transcript': { + rows.push(...this.setupTranscriptPreview(draft, width)); + break; + } + case 'history': { + for (const [family, title, id] of [['history', 'History', draft.history], ['navigation', 'Navigation', draft.navigation], ['picker', 'Picker', draft.picker]] as const) { + rows.push(`${label(title)}${PRIMARY}${family === 'history' ? HISTORY_PROVIDERS.find(item => item.id === id)?.label : family === 'navigation' + ? NAVIGATION_PROVIDERS.find(item => item.id === id)?.label : PICKER_PROVIDERS.find(item => item.id === id)?.label}${RESET}`); + rows.push(`${label('')}${SUBTLE}${providerExplanation(family, id)}${RESET}`); + } + break; + } + case 'welcomeScreen': { + rows.push(` ${SUBTLE}${providerExplanation('welcome', draft.welcome)}${RESET}`); + if (draft.welcome === 'vespyr') rows.push(...vespyrSprite().map(line => ` ${line}${RESET}`)); + if (draft.statusStrip.enabled) rows.push(`${label('Strip')}${stripAnsi(renderStatusStrip(draft.statusStrip, this.stripStats, Math.min(60, width - 12))).trim()}`); + break; + } + case 'idle': { + if (motion.disabled) { rows.push(` ${SUBTLE}Decorative effects Off: idle visuals stay off.${RESET}`); break; } + const mode = effectiveMode(draft.idleVisuals.mode, motion); + const elapsed = Date.now() - this.lastActivity; + rows.push(...idleFrameRows(this.screensaverGrid, {mode, width: Math.max(10, Math.min(56, columns - 6)), height: 6, palette: idlePaletteFor(draft), + level: colorLevel(), nerd: getCurrentGlyphMode() === 'nerd', time: motion.still ? 20_000 : sceneTime(elapsed, Math.floor(elapsed / IDLE_FRAME_MS[mode]), mode)}) + .map(line => ` ${line}`)); + animate = !motion.still; + break; + } + case 'tools': { + for (const tool of TOOLS.filter(item => item.tier === 'recommended')) { + const status = state.context.statuses[tool.executable ?? ''] ?? this.toolStatuses.get(tool.id); + const installed = status?.state === 'installed' ? `${SUCCESS}installed${RESET}` : status?.state === 'missing' ? `${SUBTLE}not installed${RESET}` : `${SUBTLE}…${RESET}`; + rows.push(`${label(tool.label)}${installed} ${SUBTLE}${tool.description}${RESET}`); + } + break; + } + } + if (animate && !this.screensaverAnimation) { + this.screensaverAnimation = presentationClock.subscribe(() => { if (this.setupState) this.render(); }, 150); + } else if (!animate && this.screensaverAnimation) { this.screensaverAnimation(); this.screensaverAnimation = undefined; } + return [...chromaNote, ...rows].map(row => truncateAnsi(row, columns - 2)); + } + + /** + * A compact, representative picture of the Editor step: where the composer + * sits, Normal or Chat transcript rows, a folded output block, syntax + * colors, ghost text and the empty-prompt prediction, each following the draft. + */ + private setupEditorPreview(draft: PromptConfiguration, width: number): string[] { + const inner = Math.max(20, Math.min(60, width)); + const chat = draft.transcriptPresentation === 'chat'; + const command = (text: string) => { + const painted = `${renderSyntaxPreviewLine(text, draft.syntax, draft.nmsh.palette)}${RESET}`; + return chat ? `${' '.repeat(Math.max(1, inner - displayWidth(text) - 2))}${painted} ${SUBTLE}${GLYPHS.prompt}${RESET}` : `${SUBTLE}${GLYPHS.prompt}${RESET} ${painted}`; + }; + const transcript = [command('npm test'), `${SECONDARY} ${GLYPHS.success} 42 passed${RESET}`]; + if (draft.outputFolding !== 'never') transcript.push(`${SUBTLE} ${getCurrentGlyphMode() === 'nerd' ? '▸' : '>'} 318 more lines folded${RESET}`); + const ghost = draft.suggestions === 'none' ? '' : `${SECONDARY} --amend${RESET}`; + const composer = [`${ACCENT}${GLYPHS.prompt}${RESET} ${renderSyntaxPreviewLine('git commit -m "fix"', draft.syntax, draft.nmsh.palette)}${ghost}`]; + const rule = `${SUBTLE}${repeatToWidth(GLYPHS.separator, inner)}${RESET}`; + const body = draft.composerPosition === 'top' ? [...composer, rule, ...transcript] : [...transcript, rule, ...composer]; + const rows = body.map(line => ` ${SUBTLE}│${RESET} ${line}`); + const where = draft.composerPosition === 'flow' ? 'Flow: the composer follows the newest output' : `Composer docked at the ${draft.composerPosition}`; + rows.push(` ${SUBTLE}${where} · ${chat ? 'Chat' : 'Normal'} transcript · syntax ${draft.syntax.highlighting ? 'on' : 'off'} · folding ${draft.outputFolding}${RESET}`); + rows.push(draft.suggestionsOnEmpty && draft.suggestions !== 'none' + ? ` ${SUBTLE}Empty prompt:${RESET} ${ACCENT}${GLYPHS.prompt}${RESET} ${SECONDARY}git push${RESET} ${SUBTLE}predicted before typing${RESET}` + : ` ${SUBTLE}Empty prompt: no prediction until you type${RESET}`); + rows.push(` ${SUBTLE}${providerExplanation('suggestions', draft.suggestions)}${RESET}`); + return rows; + } + + /** + * The Transcript step's preview through the real history-header renderer: + * a historical prompt with its divider, the command (Normal or Chat), short + * output, and a long block as the Output folding draft would present it. + */ + private setupTranscriptPreview(draft: PromptConfiguration, width: number): string[] { + const inner = Math.max(20, Math.min(72, width)); + const chat = draft.transcriptPresentation === 'chat'; + const sample = this.transcriptPreviewSample(); + const header = renderHistoricalContext(sample, chat ? Math.max(10, Math.floor(inner * 0.6)) : inner, draft.transcript, draft.presentation); + const rows: string[] = []; + const place = (line: string) => chat ? `${' '.repeat(Math.max(0, inner - displayWidth(line)))}${line}` : line; + if (header) rows.push(place(header.ansi)); + rows.push(place(`${SECONDARY}${GLYPHS.prompt} ${RESET}${PRIMARY}npm test${RESET}`), `${SUBTLE} ${GLYPHS.success} 42 passing${RESET}`); + if (header) rows.push(place(header.ansi)); + // The same long-block preview /transcript shows for this Output folding choice. + return [...rows.map(line => ` ${line}`), ...foldingPreview(draft.outputFolding)]; + } + + /** Setup Cat's external prompt preview, rendered off the render path once per provider. */ + private setupExternalPrompt?: {provider: PromptProviderId; result?: StarshipPromptResult; error?: string}; + + private setupExternalPromptRow(draft: PromptConfiguration, width: number, label: string): string { + const provider = draft.provider; + const cached = this.setupExternalPrompt?.provider === provider ? this.setupExternalPrompt : undefined; + if (!cached) { + const entry: NonNullable = {provider}; + this.setupExternalPrompt = entry; + void this.renderExternalPrompt(draft).then(result => { entry.result = result; }, (error: unknown) => { + entry.error = error instanceof Error ? error.message : String(error); + }).then(() => { if (!this.stopped && this.setupState && this.setupExternalPrompt === entry) this.render(); }); + return `${label}${SUBTLE}Checking ${providerLabel(provider)}…${RESET}`; + } + if (cached.result) return `${label}${this.externalPromptRow(cached.result, width, 'composer')}${RESET}`; + if (cached.error) return `${label}${SUBTLE}${truncateText(`No preview: ${cached.error}`, width)}${RESET}`; + return `${label}${SUBTLE}Checking ${providerLabel(provider)}…${RESET}`; + } + + /** The Setup Cat title takes the current one-shot sweep when the step changes; otherwise it rests. */ + private setupTitle(state: SetupState): string { + const text = 'Setup Cat'; + const cells = [...text].map(glyph => ({glyph, color: glyph === ' ' ? undefined : {...UI_COLORS.primary}})); + const elapsed = this.sweep && this.sweep.section === state.section && this.sweep.titled ? this.sweepElapsed() : Number.POSITIVE_INFINITY; + return sweepOnce(cells, elapsed, sweepStyleFor(this.promptConfiguration), colorLevel()); + } + + // ---- Light sweep controller ----------------------------------------------------- + + /** + * At most one sweep at a time, started by an event (a new selection or a changed value in the + * active panel, a submitted command, an Apply or Save). A new event replaces the running sweep, so + * fast navigation never builds a backlog. One clock subscription exists only while a sweep runs. + */ + private sweep?: {target: 'selection' | 'prompt'; startedAt: number; frame: number; strength: 'subtle' | 'vivid'; section?: number; titled?: boolean}; + private sweepTimer?: () => void; + private lastSelection?: string; + private static readonly SWEEP_FRAME_MS = 40; + private static readonly SWEEP_MAX_MS = 1300; + + private sweepAllowed(): boolean { + const style = sweepStyleFor(this.promptConfiguration); + return sweepAnimates(style.level, sweepStill(this.promptConfiguration), colorLevel()) && !this.stopped && !this.idle; + } + + /** Elapsed time of the running sweep; deterministic runs use the frame count. */ + private sweepElapsed(now = Date.now()): number { + if (!this.sweep) return Number.POSITIVE_INFINITY; + return isDeterministicPresentation() ? this.sweep.frame * TerminalApp.SWEEP_FRAME_MS : now - this.sweep.startedAt; + } + + private startSweep(target: 'selection' | 'prompt', strength: 'subtle' | 'vivid', extra: {section?: number; titled?: boolean} = {}): void { + if (!this.sweepAllowed()) return; + this.sweep = {target, startedAt: Date.now(), frame: 0, strength, ...extra}; + if (!this.sweepTimer) { + this.sweepTimer = presentationClock.subscribe(now => { + if (!this.sweep) return; + this.sweep.frame += 1; + if (this.sweepElapsed(now) > TerminalApp.SWEEP_MAX_MS) this.endSweep(); + this.render(); + }, TerminalApp.SWEEP_FRAME_MS); + } + } + + private endSweep(): void { + this.sweep = undefined; + this.sweepTimer?.(); this.sweepTimer = undefined; + } + + /** The selected panel row: the line marked with the selection pointer. */ + private static selectedRowIndex(rows: readonly string[]): number { + return rows.findIndex(row => /^\s*[›>] +\S/u.test(stripAnsi(row))); + } + + /** Called with the panel rows of each render: a new selection or a changed value starts one sweep. */ + private noteSelection(rows: readonly string[] | undefined): void { + const index = rows ? TerminalApp.selectedRowIndex(rows) : -1; + const signature = index === -1 ? undefined : `${this.setupState ? `setup:${this.setupState.section}` : 'panel'}|${stripAnsi(rows![index]!).trim()}`; + if (signature === this.lastSelection) return; + const sectionChanged = Boolean(this.setupState && this.lastSelection && !this.lastSelection.startsWith(`setup:${this.setupState.section}|`)); + const opening = this.lastSelection === undefined; + this.lastSelection = signature; + if (!signature) { if (this.sweep?.target === 'selection') this.endSweep(); return; } + this.startSweep('selection', 'subtle', this.setupState ? {section: this.setupState.section, titled: sectionChanged || opening} : {}); + } + + /** Applies the running sweep to its target row in a painted frame (presentation only). */ + private applySweep(rows: string[], plan: ScreenPlan, columns: number): void { + const sweep = this.sweep; + if (!sweep) return; + const elapsed = this.sweepElapsed(); + const style = sweepStyleFor(this.promptConfiguration, sweep.strength); + const level = colorLevel(); + const contentRange = (row: string, skipPointer: boolean) => { + const plain = stripAnsi(row); + const start = skipPointer ? plain.search(/[›>] \S/u) + 2 : plain.search(/\S/u); + return {from: Math.max(0, displayWidth(plain.slice(0, Math.max(0, start)))), to: Math.min(columns, displayWidth(plain.trimEnd()))}; + }; + if (sweep.target === 'prompt') { + const region = plan.regions.find(item => item.kind === 'prompt' && item.height > 0) ?? plan.regions.find(item => item.kind === 'input'); + if (region) rows[region.top] = sweepAnsiRow(rows[region.top]!, elapsed, style, level, contentRange(rows[region.top]!, false)); + return; + } + const panel = plan.regions.find(item => item.kind === 'panel'); + if (!panel) return; + const local = TerminalApp.selectedRowIndex(rows.slice(panel.top, panel.top + panel.height)); + if (local === -1) return; + const row = rows[panel.top + local]!; + rows[panel.top + local] = sweepAnsiRow(row, elapsed, style, level, contentRange(row, true)); + } + + /** Recommended-tool install state for Setup Cat's tools preview, detected once off the render path. */ + private readonly toolStatuses = new Map(); + + private openScreensaverGallery(): void { + const {columns, rows} = this.dimensions(); + this.saverCapture = this.captureScreen(columns, rows); + this.saverGalleryCapture = undefined; + this.screensaverPanel = createScreensaverPanel(Date.now()); + } + + private renderScreensaverRows(state: ScreensaverPanelState, columns: number): string[] { + const {rows} = this.dimensions(); + const settings = this.promptConfiguration.idleVisuals; + const motion = idleMotion(this.promptConfiguration); + const size = previewSize(columns, rows); + const mode = effectiveMode(settings.mode, motion); + const elapsed = Date.now() - state.startedAt; + // Screen savers preview over the real screen snapshot taken when the gallery opened (cropped to the preview box). + const previewMode: IdleMode = mode === 'random' ? 'blackHole' : mode; + let capture: ScreenCapture | undefined; + if (SCREEN_MODE_EFFECT[previewMode] && this.saverCapture) { + if (!this.saverGalleryCapture || this.saverGalleryCapture.width !== Math.min(size.width, this.saverCapture.width) || this.saverGalleryCapture.height !== Math.min(size.height, this.saverCapture.height) || this.saverGalleryMode !== previewMode) { + this.saverGalleryCapture = cropCapture(this.saverCapture, size.width, size.height); this.saverGalleryMode = previewMode; + } + capture = this.saverGalleryCapture; + } + const preview = motion.disabled ? [] : idleFrameRows(this.screensaverGrid, {mode: previewMode, width: capture?.width ?? size.width, height: capture?.height ?? size.height, + time: motion.still ? 20_000 : sceneTime(elapsed, Math.floor(elapsed / IDLE_FRAME_MS[mode]), mode), + palette: idlePaletteFor(this.promptConfiguration), level: colorLevel(), nerd: getCurrentGlyphMode() === 'nerd', ...(capture ? {capture} : {})}); + // A real animated preview while the gallery is open; one timer, removed with the panel. + if (!motion.still && !motion.disabled && !this.screensaverAnimation) { + this.screensaverAnimation = presentationClock.subscribe(() => { if (this.screensaverPanel) this.render(); }, Math.max(120, IDLE_FRAME_MS[mode])); + } + return renderScreensaverPanel(state, columns, rows, {settings, motion, preview}); + } + + private handleScreensaverKey(key: Key, state: ScreensaverPanelState): void { + const action = screensaverKey(state, key, this.promptConfiguration.idleVisuals, this.promptConfiguration); + if (!action) return; + if (action.kind === 'replay') { + // Restart from the pristine snapshot: a fresh clock and a fresh effect instance, no stacked timers. + state.startedAt = Date.now(); + this.saverGalleryCapture = undefined; + if (!idleMotion(this.promptConfiguration).still) this.render(); + return; + } + if (action.kind === 'editColors') { + this.screensaverPanel = undefined; + this.screensaverAnimation?.(); this.screensaverAnimation = undefined; + this.openStopsEditor('idle'); + return; + } + if (action.kind === 'close') { + this.screensaverPanel = undefined; + this.screensaverAnimation?.(); this.screensaverAnimation = undefined; + this.returnFromPanel(); + } else if (action.kind === 'change') { + this.applySettingsConfiguration({...this.promptConfiguration, idleVisuals: action.settings}); + this.screensaverAnimation?.(); this.screensaverAnimation = undefined; + this.armIdle(); + } else this.startIdle(true); + } + + private openStopsEditor(target: 'idle' | 'activity'): void { + const config = this.promptConfiguration; + const stops = target === 'idle' ? config.idleVisuals.customStops : config.liveActivity.customStops; + this.stopsEditor = {target, gradient: {stops: [...(stops.length >= MIN_CUSTOM_STOPS ? stops : PRESET_STOPS.lavender)], index: 0}}; + } + + /** The draft stops applied to a copy of the configuration, for live previews. */ + private stopsDraft(editor: NonNullable): PromptConfiguration { + const config = this.promptConfiguration; + const customStops = editor.gradient.stops.length >= MIN_CUSTOM_STOPS ? [...editor.gradient.stops] : [...PRESET_STOPS.lavender]; + return editor.target === 'idle' + ? {...config, idleVisuals: {...config.idleVisuals, colorSource: 'custom', customStops}} + : {...config, liveActivity: {colors: 'custom', customStops}}; + } + + /** Shared stop editor keys; Esc (when not typing a hex) saves the stops and returns. */ + private handleStopsEditorKey(key: Key, editor: NonNullable): void { + const saved = editor.target === 'idle' ? this.promptConfiguration.idleVisuals.customStops : this.promptConfiguration.liveActivity.customStops; + if (gradientEditorKey(editor.gradient, key, () => saved.length >= MIN_CUSTOM_STOPS ? saved : PRESET_STOPS.lavender)) return; + if (key.kind !== 'escape' && key.kind !== 'interrupt') return; + if (key.kind === 'escape' && this.applySettingsConfiguration(this.stopsDraft(editor))) this.armIdle(); + this.stopsEditor = undefined; + this.screensaverAnimation?.(); this.screensaverAnimation = undefined; + this.returnFromPanel(); + } + + private renderStopsEditor(editor: NonNullable, columns: number): string[] { + const draft = this.stopsDraft(editor); + const title = editor.target === 'idle' ? 'Idle visuals · Custom colors' : 'Live activity · Custom colors'; + const rows = ['', ...renderGradientEditorRows(editor.gradient, title).map(row => ` ${row}`), '', ` ${SUBTLE}${GLYPHS.separator.repeat(2)} Preview${RESET}`]; + if (editor.target === 'idle') { + rows.push(...idleFrameRows(this.screensaverGrid, {mode: draft.idleVisuals.mode, width: Math.max(10, Math.min(56, columns - 6)), height: 5, + palette: idlePaletteFor(draft), level: colorLevel(), nerd: getCurrentGlyphMode() === 'nerd', time: 20_000}).map(line => ` ${line}`)); + } else { + const {cells, style} = liveActivityPaint('• Running sleep 5 · ', draft); + rows.push(` ${sweepCells(cells, Number.POSITIVE_INFINITY, style, colorLevel(), true)}${SECONDARY}3.4s${RESET}`, + ` ${SUBTLE}Only live work uses these colors; finished commands show their plain success or failure.${RESET}`); + } + rows.push('', renderControls(gradientEditorControls(editor.gradient))); + return framePanel(rows.map(row => truncateAnsi(row, columns)), columns).slice(0, Math.max(1, this.dimensions().rows)); + } + + private setupCursorClock?: () => void; + + private handleSetupCursorKey(key: Key, state: SetupState, panel: CursorPanelState): void { + const action = cursorPanelKey(panel, key, this.cursorEnv(panel.draft, state.draft)); + if (!action) return; + if (action.kind === 'close') { state.cursorPanel = undefined; this.cursorPanelClock?.(); this.cursorPanelClock = undefined; return; } + if (action.kind === 'apply') state.draft = {...state.draft, cursor: action.settings}; + } + + /** Presets deliberately bypass discovery; completed and legacy onboarding stay completed. */ + private startOnboarding(): void { + if (this.presetStartup) return; + if (!this.promptConfiguration.onboardingComplete) { + this.startSetup(); + this.setupState!.onboarding = true; + } else if (!this.promptConfiguration.toolsSetupComplete) this.startTools(true); + } + + private startSetup(entry?: string): void { + this.setupExternalPrompt = undefined; + const state = this.setupState = createSetup(this.promptConfiguration, entry); + void this.loadSetupContext(state); + } + + /** Provider install state and completion facts, detected once per Setup Cat open; never on render. */ + private async loadSetupContext(state: SetupState): Promise { + const descriptors = [...PICKER_PROVIDERS, ...NAVIGATION_PROVIDERS, ...HISTORY_PROVIDERS, ...SUGGESTION_PROVIDERS, ...WELCOME_PROVIDERS] + .filter(descriptor => descriptor.kind === 'external' && descriptor.executable); + const statuses: Record = {}; + const facts = this.semanticService.completionFacts().then(result => { + state.context = {...state.context, completion: result ?? {zshCompletions: false, fzfTab: false, completionSystem: false}}; + }); + await Promise.all(descriptors.map(async descriptor => { statuses[descriptor.executable!] = await detectProvider(descriptor); })); + const starship = await detectProvider({id: 'starship', family: 'prompt', label: 'Starship', kind: 'external', description: '', executable: 'starship'}); + statuses.starship = starship; + for (const tool of TOOLS.filter(item => item.tier === 'recommended')) { + const status = await detectProvider(tool); + this.toolStatuses.set(tool.id, status); + if (tool.executable) statuses[tool.executable] ??= status; + } + state.context = {...state.context, statuses}; + // Theme Bridge targets present on this system (PATH facts; cached for the session). + this.bridgeFacts ??= await detectTargets(); + state.context = {...state.context, bridgeTargets: (Object.entries(this.bridgeFacts) as Array<[BridgeTargetId, TargetFacts]>).filter(([, facts]) => facts.installed).map(([target]) => target)}; + await facts; + if (!this.stopped && this.setupState === state) this.render(); + } + + private handleSetupKey(key: Key, state: SetupState): void { + if (state.toolBrowser) { void this.handleSetupToolsKey(key, state, state.toolBrowser); return; } + if (state.cursorPanel) { this.handleSetupCursorKey(key, state, state.cursorPanel); return; } + const result = setupKey(state, key); + if (!result) return; + if (result.kind === 'browseTools') { this.openSetupToolBrowser(state, result.toolId); return; } + if (result.kind === 'cursorEditor') { + // The shared /cursor panel over the draft's cursor settings: changes land in the draft, nothing is saved until Apply. + state.cursorPanel = createCursorPanel(state.draft.cursor, Date.now(), {embedded: true, advanced: result.advanced, ...(result.row ? {row: result.row} : {})}); + return; + } + this.setupState = undefined; + this.screensaverAnimation?.(); this.screensaverAnimation = undefined; + + if (result.kind === 'cancel') { this.returnFromPanel(); return; } + const previous = this.promptConfiguration; + const configured = result.tools !== 'keep' ? {...result.configuration, toolsSetupComplete: true} : result.configuration; + const next = state.onboarding ? {...configured, onboardingComplete: true, glyphChoiceComplete: true, toolsSetupComplete: true} : configured; + if ((result.changed || state.onboarding) && !setupIsIdempotent({...state, draft: next})) { + if (!this.applySettingsConfiguration(next)) return; + this.armIdle(); + if (previous.suggestions !== next.suggestions) this.applySuggestionProvider(); + if (previous.history !== next.history) void this.loadHistory(); + if (previous.navigation !== next.navigation) { this.directoryQueryAbort?.abort(); this.directoryQuery = undefined; this.directoryResults = []; } + if (previous.provider !== next.provider) void this.refreshProviderPrompt().then(() => this.render()); + this.output.addFrontendInteraction('/setup', 'Setup Cat applied your changes. Rerun /setup anytime; it starts from your current settings.', SUCCESS); + if (this.cursorReload && JSON.stringify(previous.cursor) !== JSON.stringify(next.cursor)) this.output.addFrontendInteraction('/setup', this.cursorReload, INFO); + this.startSweep('prompt', 'vivid'); + } else this.output.addFrontendInteraction('/setup', 'Setup Cat: no changes; your settings are unchanged.', INFO); + this.panelOrigin = undefined; + // A route row: the Setup draft is applied, then the real editor opens. + if (result.then) { this.openDestinationPanel(result.then); return; } + // Auto or Always with a use enabled: continue straight into model setup (the same /llm controller), detecting first. + const understanding = next.localUnderstanding; + if (understanding.mode !== 'off' && (understanding.ask || understanding.folding) + && JSON.stringify(previous.localUnderstanding) !== JSON.stringify(understanding)) { this.openUnderstandingPanel(true); return; } + if (result.tools === 'recommended' || result.tools === 'enhanced' || result.tools === 'individual') { + this.startTools(); + if (this.toolsPanel) this.toolsPanel.tier = result.tools === 'individual' ? undefined : result.tools; + } + } + + /** + * The shared tool browser inside Setup Cat: the same panel, recipes, previews, confirmation and + * installer as /tools. The Setup Cat draft and step are untouched; Esc returns to them. + */ + private openSetupToolBrowser(state: SetupState, toolId?: string): void { + const config = state.draft; + const browser = createToolsPanel(new Set([config.history, config.picker, config.navigation, config.welcome, config.provider])); + browser.updates = this.toolUpdates; + const tool = toolId ? TOOLS.find(item => item.id === toolId) : undefined; + if (tool) browser.detail = tool; + state.toolBrowser = browser; + void refreshTools(browser, () => { if (!this.stopped && state.toolBrowser === browser) this.render(); }); + } + + private async handleSetupToolsKey(key: Key, state: SetupState, browser: ToolsPanel): Promise { + if (browser.confirm) { + // Same confirmation and installer as /tools; a failure stays visible and the draft is kept. + await confirmToolInstall(browser, key, () => this.renderTaskPresentation()); + if (browser.task?.state.status === 'succeeded' && browser.detail && browser.statuses[browser.detail.id]?.state === 'installed') this.pendingMilestone = true; + this.render(); + return; + } + const action = toolsKey(browser, key); + if (action === 'close') { + state.toolBrowser = undefined; + // Fresh install state for the provider rows, so a just-installed tool can be selected at once. + clearProviderDetection(); + void this.loadSetupContext(state); + } else if (action === 'refresh') await refreshTools(browser, () => this.render()); + else if (action === 'checkUpdates') await this.checkToolUpdates(browser); + this.render(); + } + + /** + * Before a submitted command runs: when its first word exactly names a curated tool that the real + * zsh cannot resolve (no alias, function, builtin or executable), offer an install instead. Returns + * true when the offer is shown; the command text stays in the composer meanwhile. + */ + /** One line for an exact curated command that is not installed; undefined for unknown commands (nothing is invented). */ + private knownMissingNote(command: string): string | undefined { + const word = commandWord(command); + const tool = word ? knownToolForExecutable(word) : undefined; + if (!word || !tool || resolveCommand(word) !== undefined) return undefined; + const provided = tool.package && tool.package !== word ? ` · provided by ${tool.package}` : ''; + return `${tool.label} is not installed${provided} · install it from /tools`; + } + + private async offerInstallFor(command: string): Promise { + const tool = installCandidate(command, this.promptConfiguration); + if (!tool || !tool.executable) return false; + const source = await this.semanticService.resolveSource(tool.executable); + const resolution = source === undefined ? 'unavailable' : source.kind; + const onPath = this.installProbe.onPath(tool.executable); + const recipe = this.installProbe.recipe(tool); + if (!shouldOfferInstall(tool, resolution, onPath, recipe)) return false; + this.installPrompt = createInstallPrompt(tool, recipe, command); + return true; + } + + private async handleInstallPromptKey(key: Key, state: InstallPromptState): Promise { + const action = installPromptKey(state, key); + if (!action) { this.render(); return; } + const restore = () => { this.editor.clear(); this.editor.insert(state.command); }; + if (action === 'close' || action === 'later') { this.installPrompt = undefined; restore(); } + else if (action === 'run') { this.installPrompt = undefined; restore(); await this.submit(false, true); } + else if (action === 'ignoreTool' || action === 'never') { + this.installPrompt = undefined; restore(); + this.applySettingsConfiguration(ignoreInstallSuggestion(this.promptConfiguration, action, state.tool)); + this.output.addFrontendInteraction('/tools', action === 'never' + ? 'Install suggestions are off. Turn them back on in /settings (Tools).' + : `NMSh will not offer to install ${state.tool.label} again. Reset in /settings (Tools).`, INFO); + } else if (action === 'install') { + state.task = new TaskProgress(`Installing ${state.tool.label}`, () => this.renderTaskPresentation(), Date.now(), state.tool.label); + this.render(); + const outcome = await state.task.run(state.recipe.command, [...state.recipe.args]); + if (this.stopped) return; + clearProviderDetection(); + this.semanticService.cache.delete(state.tool.executable ?? ''); + this.commandSources.clear(); + const installed = resolveCommand(state.tool.executable ?? state.tool.id) !== undefined; + if (outcome.status === 'succeeded' && installed) recordInstall(state.tool.id, state.recipe); + state.result = outcome.status === 'succeeded' && installed + ? {ok: true, message: `${state.tool.label} installed. Nothing was run.`} + : {ok: false, message: outcome.status === 'succeeded' ? `${state.recipe.label} finished, but ${state.tool.executable} was not found.` + : `${state.tool.label} was not installed. ${state.task.state.error ?? ''}`.trim()}; + restore(); + if (state.result.ok) this.pendingMilestone = true; + } + 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 + * TERM_PROGRAM, nothing that could carry credentials. + */ + private statusSections(): StatusSections { + const config = this.promptConfiguration; + const build = this.buildIdentity; + const {columns, rows} = this.dimensions(); + const home = homedir(); + const tilde = (path: string) => path === home ? '~' : path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path; + const terminal = this.host.name; + const active = this.effectivePromptProvider; + const editorRows = (() => { + const host = this.hostActions(); + const caps = host.capabilities; + const editor = caps.integratedEditor === 'zed' ? 'Zed' : caps.integratedEditor === 'vscode' ? 'VS Code' : undefined; + return [ + ...(editor ? [{label: 'Integrated editor', value: editor}] : []), + {label: 'Editor bridge', value: host.id === 'none' ? (editor ? 'unavailable' : 'no editor known (set VISUAL/EDITOR or Open with)') + : `${host.label} · file ${caps.nativeFileOpen ? 'yes' : 'no'} · folder ${caps.nativeDirectoryOpen ? 'yes' : 'no'} · diff ${caps.nativeDiff ? 'yes' : 'no'}`, + ...(host.id === 'none' ? {tone: 'warning' as const} : {})}, + ...(caps.cliMissing ? [{label: caps.cliMissing === 'zed' ? 'Zed CLI' : 'code CLI', value: 'not found on PATH', tone: 'warning' as const}] : []), + ]; + })(); + // Named sections: the same facts as before, grouped so Status reads as a short report instead of one list. + return [ + statusSection('Build & Platform', [ + {label: 'Version', value: build.version}, + {label: 'Installed', value: installProvenanceLabel()}, + {label: 'Build', value: `${build.commit}${build.branch ? ` (${build.branch}${build.dirty ? ', dirty' : ''})` : ''}`, tone: build.commit === 'unknown' ? 'muted' : undefined}, + {label: 'Platform', value: `${process.platform} ${process.arch}`}, + {label: 'Platform support', value: this.platformInfo.support, tone: this.platformInfo.wsl?.version === 1 ? 'warning' as const : undefined}, + {label: 'Node', value: process.version}, + ]), + statusSection('Updates', this.updateStatusRows()), + statusSection('Shell & Session', [ + {label: 'Shell', value: `${shellAdapter(this.shellId).label}${this.shellId === this.promptConfiguration.shellBackend ? '' : ` (default for new sessions: ${shellAdapter(this.promptConfiguration.shellBackend).label})`}`}, + {label: 'Shell capabilities', value: (() => { const caps = shellAdapter(this.shellId).capabilities; return `completion ${caps.completion}${caps.completionDescriptions ? ' + descriptions' : ''} · live names ${caps.liveNames ? 'yes' : 'no'} · history import ${caps.historyImport ? 'yes' : 'no'}`; })()}, + {label: 'Session', value: this.sessionId ? `live · ${this.sessionId}` : 'in-process', tone: this.sessionMode === 'service' ? undefined : 'muted'}, + ...(this.sessionMode === 'service' ? [ + {label: 'Session service', value: this.session.serviceBuild ? `connected · ${this.session.serviceBuild}` : 'connected · older build (no build reported)'}, + {label: 'Shell switching', value: this.session.features.has('shell-switch') ? 'available' : 'unavailable (older service; ends with its sessions)', + ...(this.session.features.has('shell-switch') ? {} : {tone: 'warning' as const})}, + ...(this.session.features.has('notices') ? [] : [{label: 'Session notices', value: 'basic (older service reports no notices)', tone: 'muted' as const}]), + ] : []), + {label: 'Working directory', value: tilde(this.shellCwd)}, + ]), + statusSection('Terminal', [ + ...(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}`}, + ]), + statusSection('Local Understanding', understandingStatusRows(this.promptConfiguration.localUnderstanding, this.understanding.status) + .map(row => ({label: row.label, value: row.value}))), + statusSection('NMSh & Providers', [ + {label: 'Prompt provider', value: providerLabel(config.provider)}, + ...(active !== config.provider ? [{label: 'Active prompt', value: `${providerLabel(active)} (fallback)`, tone: 'warning' as const}] : []), + {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')}, + ...editorRows, + {label: 'Completion sources', value: this.completionService.sourceIds.join(' + ')}, + {label: 'History colors', value: config.transcript.historyColors === 'followPrompt' ? 'Follow prompt' : config.transcript.historyColors === 'theme' ? 'Theme' : 'Grayscale'}, + ]), + statusSection('Shell Environment', shellEnvironmentRows(this.shellEnvironment, {id: this.shellId, ...(shellAdapter(this.shellId).resolveExecutable(process.env) ? {path: shellAdapter(this.shellId).resolveExecutable(process.env)!} : {})}).map(([label, value]) => ({label, value}))), + statusSection('Services & Activity', [ + {label: 'Session notices', value: config.sessionNotices ? (this.sessionMode === 'service' ? 'On' : 'On (needs the live-session service)') : 'Off'}, + {label: 'Agent activity', value: config.agentActivity ? 'On · local only' : 'Off'}, + ]), + statusSection('Storage', [ + {label: 'Session journal', value: this.journalActive ? 'active' : 'inactive', tone: this.journalActive ? 'success' : 'warning'}, + {label: 'Session retention', value: config.sessionRetention === null ? 'unlimited' : `${config.sessionRetention} sessions`}, + {label: 'Config file', value: tilde(promptConfigurationPath()), tone: 'muted'}, + {label: 'Runtime directory', value: tilde(defaultRuntimeDir()), tone: 'muted'}, + ]), + ].filter(section => section.length > 0); + } + + /** Persists an inline Settings edit and applies it live; on failure the old value stays. */ + private applySettingsConfiguration(next: PromptConfiguration | undefined): boolean { + if (!next) return false; + try { + savePromptConfiguration(next, undefined, this.promptConfiguration); + } catch (error) { + this.output.addHistoryLine(`${ERROR}${error instanceof Error ? error.message : String(error)}${RESET}`); + this.render(); + return false; + } + this.promptConfiguration = next; + setIconStyle(next.glyphStyle); + if (next.idleVisuals.timeout !== this.idleArmedFor) { this.idleArmedFor = next.idleVisuals.timeout; this.armIdle(); } + 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; + return true; + } + + /** + * Starts the welcome for a new presentation. External providers are + * captured once in the background; failure falls back to Vespyr quietly. + */ + private startWelcome(cwd: string): void { + const generation = ++this.welcomeGeneration; + const provider = this.promptConfiguration.welcome; + const vespyr = () => this.output.setWelcome(createWelcomeSnapshot(this.buildIdentity, cwd, this.shellId, this.welcomeUnderstanding())); + if (provider === 'none') return; + if (provider === 'vespyr') { vespyr(); return; } + void captureWelcome(provider, cwd).then(result => { + if (generation !== this.welcomeGeneration || this.stopped) return; + if (result.ok) this.output.setWelcome({...createWelcomeSnapshot(this.buildIdentity, cwd, this.shellId, this.welcomeUnderstanding()), provider, captured: result.lines}); + else { + vespyr(); + this.output.addHistoryLine(`${SUBTLE}${welcomeProvider(provider).label} welcome ${result.reason}; showing Vespyr.${RESET}`); + } + this.render(); + }); + } + + /** History loads in the background after startup; suggestions refine once it is indexed. */ + private async loadHistory(): Promise { + this.historyQuery = undefined; + this.historyQueryAbort?.abort(); + this.historyResults = []; + try { if (!await this.historyService.reload(this.promptConfiguration.history)) return; } catch { + if (this.stopped) return; + this.output.addFrontendInteraction('/history', 'Command history is unavailable; check local storage.', ERROR); + return; + } + this.historyQuery = undefined; + if (this.stopped) return; + await this.nativeSuggestions.loadInChunks(this.historyService.getEntries()); + if (this.stopped) return; + this.suggestions.refresh(); + this.render(); + } + + /** Suggestions apply to plain shell input at the end of the buffer only. */ + private suggestionGhost(): string | undefined { + const text = this.editor.text; + if (this.editor.hasPasteAtoms || this.historySearchActive || text.startsWith('/')) { + this.suggestions.reset(); + return undefined; + } + this.suggestions.update({buffer: text, cwd: this.shellCwd, previous: this.submittedCommands, now: Date.now()}, + this.promptConfiguration.suggestionsOnEmpty && !this.running); + return this.suggestions.ghost(text); + } + + /** + * Suggestion keys never steal an existing binding: word-right only accepts + * at the end of the buffer, Ctrl+N/Ctrl+P were unbound, and Up/Down/Enter + * act on alternatives only while that list is open. + */ + private handleSuggestionKey(key: Key): boolean { + const buffer = this.editor.text; + const atEnd = this.editor.cursorIndex === graphemes(buffer).length && !this.editor.hasPasteAtoms; + if (key.kind === 'suggestNext' || key.kind === 'suggestPrevious') { + this.suggestions.cycle(key.kind === 'suggestNext' ? 1 : -1); + return true; + } + if (this.suggestions.alternativesOpen) { + if (key.kind === 'up' || key.kind === 'down') { + this.suggestions.cycle(key.kind === 'down' ? 1 : -1); + return true; + } + if (key.kind === 'right' || key.kind === 'lineEnd' || key.kind === 'bufferEnd' || key.kind === 'complete' || key.kind === 'enter') { + const text = this.suggestions.acceptance(buffer); + this.suggestions.alternativesOpen = false; + if (text) { + this.editor.clear(); + this.editor.insert(text); + } + return true; + } + if (key.kind === 'escape') return this.suggestions.dismiss(buffer); + return false; + } + if (key.kind === 'wordRight' && atEnd) { + const word = this.suggestions.nextWord(buffer); + if (!word) return false; + this.editor.insert(word); + return true; + } + if (key.kind === 'escape') return this.suggestions.dismiss(buffer); + return false; + } + + /** External providers are detected in the background; Native answers until then and whenever they are unusable. */ + private applySuggestionProvider(): void { + const id = this.promptConfiguration.suggestions; + this.suggestions.setProvider(id === 'none' ? undefined : this.nativeSuggestions, this.nativeSuggestions); + if (id !== 'deja') return; + const descriptor = SUGGESTION_PROVIDERS.find(provider => provider.id === 'deja')!; + void detectProvider(descriptor).then(status => { + if (this.stopped || this.promptConfiguration.suggestions !== 'deja') return; + const resolved = resolveProvider(SUGGESTION_PROVIDERS, 'deja', status, 'nmsh'); + if (resolved.id === 'deja' && status.binary) this.suggestions.setProvider(new DejaSuggestions(status.binary), this.nativeSuggestions); + else if (resolved.notice) this.output.addHistoryLine(`${SUBTLE}${resolved.notice}${RESET}`); + this.render(); + }); + } + + 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; + this.welcomePreviews.clear(); + for (const provider of state.providers) { + void detectProvider(provider).then(status => { + state.statuses[provider.id] = status; + if (this.providerPanelState === state) this.render(); + }); + } + } + + /** 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}`]; + return [`${ACCENT}${GLYPHS.prompt}${RESET} git st${SECONDARY}atus${RESET} ${SUBTLE}→ / End accept · Alt+→ next word · Ctrl+N/P alternatives · Esc dismiss${RESET}`]; + } + if (selected.id === 'none') return [`${SUBTLE}No welcome; new sessions start at the first command.${RESET}`]; + if (selected.id === 'vespyr') return renderWelcome(createWelcomeSnapshot(this.buildIdentity, this.shellCwd, this.shellId), width).map(row => row.ansi); + if (state.statuses[selected.id]?.state !== 'installed') return []; + 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 Exclude, this.shellCwd).then(result => { + this.welcomePreviews.set(selected.id, result.ok + ? renderWelcome({...createWelcomeSnapshot(this.buildIdentity, this.shellCwd, this.shellId), captured: result.lines}, width).map(row => row.ansi) + : [`${SUBTLE}${selected.label} failed: ${result.reason}${RESET}`]); + if (this.providerPanelState === state) this.render(); + }); + return this.welcomePreviews.get(selected.id)!; + } + + private async handleProviderPanelKey(key: Key, state: ProviderPanelState): Promise { + if (state.step === 'installProgress') return; + if (key.kind === 'escape' || key.kind === 'interrupt') { + if (state.step === 'installConfirm') state.step = 'list'; + else { this.providerPanelState = undefined; this.returnFromPanel(); } + } else if (key.kind === 'enter') { + const selected = providerPanelSelection(state); + const install = providerInstall(selected); + if (state.step === 'installConfirm' && install) { + state.step = 'installProgress'; + state.task = new TaskProgress(`Installing ${selected.label}`, () => this.renderTaskPresentation(), Date.now(), selected.label); + this.render(); + const outcome = await state.task.run(install.command, [...install.args]); + if (this.stopped) return; + clearProviderDetection(); + state.statuses[selected.id] = await detectProvider(selected); + state.step = 'list'; + if (outcome.status === 'succeeded' && state.statuses[selected.id]?.state === 'installed') { + recordInstall(selected.executable ?? selected.id, install); + // Installed and re-detected: use it right away, as the user asked. + this.saveProviderChoice(state); + this.milestoneEffect(); + } else { + state.message = outcome.status === 'succeeded' + ? `${install.label} finished, but ${selected.label} was not found on PATH; nothing was selected.` + : `${selected.label} was not installed. ${state.task.state.error ?? ''}`.trim(); + } + } else { + const action = providerPanelEnterAction(state); + if (action === 'installConfirm') state.step = 'installConfirm'; + else if (action === 'unavailable') state.message = installUnavailableReason(selected); + else this.saveProviderChoice(state); + } + } else if (!handleProviderPanelKey(key, state)) return; + this.render(); + } + + private saveProviderChoice(state: ProviderPanelState): void { + 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, 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}`); + } catch (error) { + state.message = `Could not save: ${error instanceof Error ? error.message : String(error)}`; + } + } + + private startTranscriptSettings(): void { + const saved = structuredClone(this.promptConfiguration.transcript); + const folding = this.promptConfiguration.outputFolding; + this.transcriptPanelState = {selectedIndex: 0, draft: structuredClone(saved), saved, folding: {draft: folding, saved: folding}, + presentation: {draft: this.promptConfiguration.transcriptPresentation, saved: this.promptConfiguration.transcriptPresentation}}; + } + + /** A representative history header: the live provider's identity over preview-only modules. */ + private transcriptPreviewSample(): HistoricalContextSnapshot { + const context = themePreviewContext(); + const prompt = this.effectivePromptProvider === 'none' ? undefined : this.effectivePromptProvider !== 'nmsh' && this.externalPrompt + ? this.currentPromptSnapshot() + : nativePromptSnapshot(context, this.promptConfiguration); + return {cwd: context.cwd, project: context.project, branch: context.branch, ...(prompt ? {prompt} : {promptless: true as const})}; + } + + private saveTranscriptSettings(): void { + const state = this.transcriptPanelState; + if (!state) return; + const next = {...structuredClone(this.promptConfiguration), transcript: structuredClone(state.draft), + outputFolding: state.folding?.draft ?? this.promptConfiguration.outputFolding, + transcriptPresentation: state.presentation?.draft ?? this.promptConfiguration.transcriptPresentation}; + try { + savePromptConfiguration(next, undefined, this.promptConfiguration); + this.promptConfiguration = next; + this.output.setTranscriptAppearance(next.transcript); + this.output.setOutputFolding(next.outputFolding); + this.output.presenter.setTreatment(next.presentation); + this.output.presenter.setLayout(next.transcriptPresentation); + this.transcriptPanelState = undefined; + this.output.addHistoryLine(`${SUCCESS}Transcript settings saved.${RESET}`); + } catch (error) { + state.message = `Could not save transcript settings: ${error instanceof Error ? error.message : String(error)}`; + } + this.render(); + } + + private startLayoutSettings(): void { + const {composerPosition, transcriptPresentation} = this.promptConfiguration; + this.layoutPanelState = createLayoutPanel({composerPosition, transcriptPresentation}); + } + + /** Persist the chosen layout and apply it live; the preview itself never touched the real transcript. */ + private saveLayoutSettings(): void { + const state = this.layoutPanelState; + if (!state) return; + const next = {...structuredClone(this.promptConfiguration), ...state.draft}; + this.applySettingsConfiguration(next); + if (this.promptConfiguration !== next) { + state.message = 'Could not save the layout; check that the NMSh configuration directory is writable.'; + this.render(); + return; + } + this.layoutPanelState = undefined; + this.historyViewport.latest(); + this.returnFromPanel(); + this.output.addHistoryLine(`${SUCCESS}Layout saved.${RESET}`); + this.render(); + } + + private startSyntaxSettings(): void { + const saved = structuredClone(this.promptConfiguration.syntax); + this.syntaxPanelState = {selectedIndex: 0, draft: structuredClone(saved), saved}; + } + + /** New input and new commands use the saved style; submitted history keeps its captured ANSI. */ + private saveSyntaxSettings(): void { + const state = this.syntaxPanelState; + if (!state) return; + const next = {...structuredClone(this.promptConfiguration), syntax: structuredClone(state.draft)}; + try { + savePromptConfiguration(next, undefined, this.promptConfiguration); + this.promptConfiguration = next; + this.syntaxPanelState = undefined; + this.returnFromPanel(); + this.output.addHistoryLine(`${SUCCESS}Syntax settings saved.${RESET}`); + } catch (error) { + state.message = `Could not save syntax settings: ${error instanceof Error ? error.message : String(error)}`; + } + this.render(); + } + + private renderedPromptPanel(columns: number): string[] { + const state = this.promptPanelState; + if (!state) return []; + return this.withDraftTheme(state.draft, () => this.renderPromptPanelRows(state, columns)); + } + + private renderPromptPanelRows(state: PromptPanelState, columns: number): string[] { + const now = Date.now(); + const preview = state.step.startsWith('install') ? [] : this.chromaPanelActive(state) ? this.chromaPanelPreview(columns, now) : this.promptPanelPreview(columns); + const stops = themeChromaStops(state.draft.nmsh.palette, state.draft.nmsh.vibrance); + const full = renderPromptPanel(state, columns, preview, this.promptThemePreviews(columns, now), this.dimensions().rows - 1, + this.promptGitShowcase(columns), stops); + // Short terminals keep the editable rows and live preview; the theme gallery goes first, then preview lines. + const budget = this.dimensions().rows - 3; + if (full.length <= budget) return full; + const compact = renderPromptPanel(state, columns, preview, [], this.dimensions().rows - 1, [], stops); + const overflow = compact.length - budget; + if (overflow <= 0 || !preview.length) return compact; + return renderPromptPanel(state, columns, preview.slice(0, Math.max(1, preview.length - overflow)), [], this.dimensions().rows - 1, [], stops); + } + + private chromaPanelActive(state: PromptPanelState): boolean { + return state.step === 'gradient' || (state.step === 'appearance' && state.view === 'chroma'); + } + + /** The Chroma draft (including stops being edited) the previews render. */ + private chromaPreviewDraft(state: PromptPanelState): PromptConfiguration { + const draft = structuredClone(state.draft); + // Previews show the Native treatment even while an external provider is selected. + draft.provider = 'nmsh'; + if (state.step === 'gradient' && state.gradient && state.gradient.stops.length >= 2) { + draft.presentation = {...draft.presentation, preset: 'custom', customStops: [...state.gradient.stops]}; + } + return draft; + } + + /** + * Chroma preview: the user's current prompt (live context, saved style, + * theme, vibrance, modules, icons) and the synthetic full-module showcase, + * both through the real renderer with the draft treatment at `now`. + */ + private chromaPanelPreview(columns: number, now: number): string[] { + const state = this.promptPanelState!; + const width = Math.max(1, columns - 16); + const draft = this.chromaPreviewDraft(state); + const label = (text: string) => ` ${SECONDARY}${text.padEnd(12)}${RESET}`; + const current = buildContextLine(this.promptContext(), width, draft, 'composer', now); + const showcase = structuredClone(draft); + showcase.modules = showcase.modules.map(module => ({...module, visible: true})); + return [`${label('Current')}${current}${RESET}`, `${label('Showcase')}${buildContextLine(moduleShowcaseContext(), width, showcase, 'composer', now)}${RESET}`]; + } + + /** Panels with an animated Chroma preview repaint on the shared clock; nothing ticks otherwise. */ + private syncPanelAnimation(): void { + const state = this.promptPanelState; + const animated = Boolean(state && this.chromaPanelActive(state) && treatmentAnimated(this.chromaPreviewDraft(state).presentation) + && colorLevel() !== 'none' && !this.stopped); + if (animated && !this.panelAnimation) this.panelAnimation = presentationClock.subscribe(() => this.render(), 120); + if (!animated && this.panelAnimation) { this.panelAnimation(); this.panelAnimation = undefined; } + } + + /** Previews of a draft see its accent and custom theme; the live context is restored afterwards. */ + private withDraftTheme(draft: PromptConfiguration, render: () => T): T { + const live = themeContext(); + const symbol = {nerd: promptSymbolGlyph(this.configuration.promptSymbol, this.configuration.promptSymbolCustom, true), + safe: promptSymbolGlyph(this.configuration.promptSymbol, this.configuration.promptSymbolCustom, false)}; + const config = this.configuration; + setThemeContext(draft.nmsh.accent, draft.customTheme); + setPromptSymbol(promptSymbolGlyph(draft.promptSymbol, draft.promptSymbolCustom, true), promptSymbolGlyph(draft.promptSymbol, draft.promptSymbolCustom, false)); + // The draft's chrome and Current Theme stops too, so previews show what Apply would show. + applyUiTheme(uiColorsFor(resolveChrome(draft.uiChrome, draft.nmsh.palette, draft.nmsh.accent, draft.customTheme))); + setActiveThemeStops(themeChromaStops(draft.nmsh.palette, draft.nmsh.vibrance)); + try { return render(); } finally { + setThemeContext(live.accent, live.custom); + setPromptSymbol(symbol.nerd, symbol.safe); + applyUiTheme(uiColorsFor(resolveChrome(config.uiChrome, config.nmsh.palette, config.nmsh.accent, config.customTheme))); + setActiveThemeStops(themeChromaStops(config.nmsh.palette, config.nmsh.vibrance)); + } + } + + /** Rich Git view rows: the draft's colors and geometry over synthetic states; never runs Git. */ + private promptGitShowcase(columns: number): string[] { + const state = this.promptPanelState; + if (!state || state.step !== 'appearance' || state.view !== 'git') return []; + const width = Math.max(1, columns - 15); + // Disabled Rich Git still shows what it would add, dimmed by the panel. + const draft = {...state.draft, nmsh: {...state.draft.nmsh, gitEnabled: true}}; + return RICH_GIT_SHOWCASE.map(entry => buildRichGitShowcaseLine(draft, entry.git, width)); + } + + /** One preview row per theme: the draft's geometry over synthetic preview-only modules. */ + private promptThemePreviews(columns: number, now = Date.now()): string[] { + const state = this.promptPanelState; + if (!state || state.step !== 'appearance' || state.view === 'git' || state.view === 'rail') return []; + if (state.view === 'chroma') { + // One row per palette: the showcase prompt in the draft style, theme and vibrance. + const width = Math.max(1, columns - 20); + const draft = this.chromaPreviewDraft(state); + return TREATMENT_PRESETS.map(preset => buildThemePreviewLine({...draft, presentation: {...draft.presentation, preset, + customStops: draft.presentation.customStops.length ? draft.presentation.customStops : [...PRESET_STOPS.lavender]}}, + draft.nmsh.palette, width, now)); + } + const width = Math.max(1, columns - 22); + return galleryPalettes(state.draft).map(palette => buildThemePreviewLine(state.draft, palette, width)); + } + + private externalPanelStatusText(state: PromptPanelState, provider: PromptProviderId, width: number): string { + const installed = provider === 'starship' ? state.starshipStatus?.installed : state.p10kStatus?.installed; + if (installed) return truncateText(state.message ?? `${providerLabel(provider)} preview is unavailable.`, width); + return truncateText(`${providerLabel(provider)} is not installed; see the options above.`, width); + } + + + private hasVisibleProviderPrompt(): boolean { + if (this.effectivePromptProvider === 'none') return false; + if (this.effectivePromptProvider !== 'nmsh') return Boolean(this.externalPrompt?.text.trim()); + return hasVisibleContextModule(this.promptConfiguration, this.promptContext(), isOnCommandRelevant); + } + + private currentPromptLine(width: number, time = Date.now()): string { + if (this.effectivePromptProvider === 'none') return ''; + if (this.effectivePromptProvider !== 'nmsh' && this.externalPrompt) { + return this.externalPromptRow(this.externalPrompt, width, this.promptConfiguration.placement, time); + } + return buildContextLine(this.promptContext(), width, this.promptConfiguration, this.promptConfiguration.placement, time); + } + + /** Animated Chroma on the Native prompt row needs presentation frames; external prompts never do. */ + private promptChromaAnimated(): boolean { + return this.effectivePromptProvider === 'nmsh' && treatmentAnimated(this.promptConfiguration.presentation) && colorLevel() !== 'none'; + } + + /** + * Blink the welcome cat occasionally. The frame lives on OutputBuffer as + * presentation state, so transcript contents, row count, and width never + * change. Blinks are skipped (not queued) while no welcome is present. + */ + private scheduleWelcomeBlink(): void { + 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.welcomeBlinkTimer = presentationClock.after(() => { + this.welcomeBlinkTimer = undefined; + this.output.setWelcomeFrame('open'); + if (this.stopped) return; + this.welcomeBlinkCount += 1; + this.scheduleWelcomeBlink(); + this.render(); + }, WELCOME_BLINK_CLOSED_MS); + this.render(); + }, welcomeBlinkDelay(this.welcomeBlinkCount)); + } + + /** External provider content follows the native placement rule: the divider fill only in header placement. */ + private externalPromptRow(prompt: StarshipPromptResult, width: number, placement: PromptConfiguration['placement'], time = Date.now()): string { + const content = truncateAnsi(prompt.ansi, Math.max(0, width - 1)); + if (placement === 'composer') return `${content}${RESET}`; + return `${content}${RESET}${paintDivider(repeatToWidth(GLYPHS.separator, Math.max(0, width - displayWidth(content))), this.promptConfiguration.presentation, time)}${RESET}`; + } + + private scroll(direction: -1 | 1): void { + const {columns} = this.dimensions(); + const outputHeight = this.transcriptViewportHeight(); + const total = this.output.wrapped(columns).length; + this.historyViewport.resolve(total, outputHeight); + this.historyViewport.page(total, outputHeight, direction); + } + + private scrollLines(amount: number): void { + const {columns} = this.dimensions(); + const outputHeight = this.transcriptViewportHeight(); + const total = this.output.wrapped(columns).length; + this.historyViewport.resolve(total, outputHeight); + this.historyViewport.scrollLines(total, outputHeight, amount); + } + + /** Scroll paging needs at least one row even when the plan leaves the transcript empty. */ + private transcriptViewportHeight(): number { + const {columns, rows} = this.dimensions(); + return this.planFrame(columns, rows).viewportRows; + } + + private formatCommandAnsi(command: string, startId: number | null, sgr = this.syntaxSgr): string[] { + const inputChars = graphemes(command); + const tokens = this.highlighter.tokenize(inputChars, this.semanticService.cache); + const charColors = syntaxCharStyles(tokens, inputChars.length, sgr); + + for (const token of tokens) { + if (token.type === 'Command' && startId !== null) { + // Resolution re-renders with the style captured at submission, so + // later syntax setting changes never recolor this history entry. + void this.semanticService.classifyCommand(token.text).then(() => { + const newFormatted = this.formatCommandAnsi(command, null, sgr); + this.output.updateCommandHighlight(startId, newFormatted); + this.render(); + }); + } + } + + const lines: string[] = []; + let currentLine = ''; + let isFirstLine = true; + let currentColor = ''; + + const pushLine = () => { + lines.push(`${currentLine}${RESET}`); + }; + + for (let i = 0; i < inputChars.length; i++) { + const char = inputChars[i]; + if (char === '\n') { + pushLine(); + currentLine = ''; + isFirstLine = false; + currentColor = ''; + continue; + } + if (currentLine === '') { + const prefix = isFirstLine ? `${GLYPHS.prompt} ` : ' '; + currentLine += `${foreground(UI_COLORS.command)}${prefix}`; + currentColor = foreground(UI_COLORS.command); + } + const color = charColors[i] ?? PRIMARY; + if (color !== currentColor) { + currentLine += `${RESET}${color}`; + currentColor = color; + } + currentLine += char; + } + pushLine(); + return lines; + } + + /** Interactive history search is the editor state `/history `; it never reaches zsh or the transcript. */ + private get historySearchActive(): boolean { + 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; + this.historyQueryAbort?.abort(); + const active = new AbortController(); + this.historyQueryAbort = active; + this.historyResults = []; + const ranked = this.historyService.status.active === 'native' + ? this.historyService.searchRanked(query, {cwd: this.shellCwd, project: this.context.project, session: this.journal?.id ?? this.sessionId, now: Date.now()}, active.signal) + : this.historyService.search(query, active.signal); + void ranked.then(entries => { + if (this.stopped || active.signal.aborted || this.historyQuery !== query) return; + this.historyResults = entries; + this.selectedSuggestion = 0; + this.render(); + }).catch(() => {}); + } + return this.historyResults.map(entry => ({id: entry.id, name: entry.command.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' '), insertion: entry.command, + description: ['count' in entry && typeof entry.count === 'number' && entry.count > 1 ? `×${entry.count}` : undefined, + entry.agent ? `agent ${entry.agent}` : undefined, entry.exitCode === undefined ? undefined : `exit ${entry.exitCode}`, + entry.durationMs === undefined ? undefined : `${entry.durationMs}ms`, entry.cwd, entry.project].filter(Boolean).join(' · ') || 'History'})); + } + + /** 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(); + if (alternatives.items.length > 0) return alternatives.items.map(item => ({name: item.text, description: ''})); + return this.shellSuggestions; + } + + /** + * 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) return []; + if (this.editor.text.startsWith('/')) { + const slash = describeSlashCommand(this.editor.text); + return slash ? [truncateText(`Inspect ${this.editor.text.trim().split(/\s+/u)[0]}`, columns), truncateText(slash, columns)] : []; + } + const context = inspectCommand(this.editor.text, this.editor.cursorIndex, this.shellCwd, this.shellSuggestions, this.semanticService.cache); + if (!context || context.kind !== 'command' || context.value !== context.command) return renderInspector(context, columns); + return renderInspector(context, columns, describeCommandSource(context.value, this.commandSource(context.value))); + } + + /** Cached source lookup; a miss is resolved off the keypress path and re-renders when known. */ + private commandSource(word: string): CommandSource | undefined { + if (this.commandSources.has(word)) return this.commandSources.get(word) ?? undefined; + if (this.commandSources.size >= 512) this.commandSources.delete(this.commandSources.keys().next().value!); + this.commandSources.set(word, null); + void this.semanticService.resolveSource(word).then(source => { + if (this.stopped) return; + if (source) this.commandSources.set(word, source); else this.commandSources.delete(word); + if (source && this.inspectorVisible) this.render(); + }); + return undefined; + } + + /** The panel position being shown: Setup previews its unsaved draft so the choice is visible immediately. */ + private panelPositionInEffect(): 'bottom' | 'top' { + return (this.setupState?.draft ?? this.promptConfiguration).panelPosition === 'top' ? 'top' : 'bottom'; + } + + private planFrame( + columns: number, + rows: number, + fullInput = this.layoutEditorInput(columns), + suggestions = this.composerSuggestions().length, + panelRows = this.settingsPanelActive ? this.settingsPanelRows(columns).length : undefined, + ): ScreenPlan { + // The status strip owns one top row only when it is on, fits, and no panel owns the screen. + if (panelRows === undefined && this.stripActive(columns, rows)) return withStatusRow(this.planWithNotices(columns, rows - 1, fullInput, suggestions, panelRows)); + return this.planWithNotices(columns, rows, fullInput, suggestions, panelRows); + } + + private planWithNotices(columns: number, rows: number, fullInput: ReturnType, suggestions: number, + panelRows: number | undefined): ScreenPlan { + const find = panelRows === undefined ? this.searchChrome(columns).length : 0; + const count = panelRows === undefined ? this.noticeRows(columns).length : 0; + // Notices never squeeze the composer or transcript out: small screens simply do not show them. + const notices = count > 0 && rows - find >= 12 + count ? count : 0; + const plan = this.planComposer(columns, rows - notices - find, fullInput, suggestions, panelRows); + // Keep Awake: decided against this geometry; an adjacent row (when needed and the screen has room) sits closest to the composer. + const decision = this.awakeDecision(plan, columns); + const wanted = this.awakeAdjacentRows(decision); + const awakeRows = wanted && rows - notices - find >= 10 + wanted ? wanted : 0; + const base = awakeRows ? this.planComposer(columns, rows - notices - find - awakeRows, fullInput, suggestions, panelRows) : plan; + const awake = decision && (decision.slot !== 'adjacentRow' || awakeRows) ? decision : undefined; + // The find bar sits right above the composer, notices above it. + return withNoticeRows(withNoticeRows(withNoticeRows({...base, ...(awake ? {awake} : {})}, find, 'find'), notices), awakeRows, 'awake'); + } + + /** One compact line per notice (max three, the last may summarize overflow). */ + private noticeRows(columns: number): string[] { + // The agent shelf shares this chrome slot (and the screen plan's rows): hidden at rest, never a permanent row. + const shelf = this.shelfRow(columns); + const now = Date.now(); + const rows = [...this.sessionNoticeRows(columns), ...this.taskRows(columns), ...this.watches.active().map(watch => truncateAnsi(watchRow(watch, now), columns)), ...this.pastePreviewRows(columns)]; + return shelf ? [...rows, shelf] : rows; + } + + /** Whether the compact preview fits above the composer on this screen (the same rule notices follow). */ + private pasteCompactFits(): boolean { + const {columns, rows} = this.dimensions(); + return rows - this.searchChrome(columns).length >= 12 + this.noticeRows(columns).length; + } + + /** Paste Preview rows above the composer: compact and bounded (R opens the full Review); only risky lines get a semantic color. */ + private pastePreviewRows(columns: number): string[] { + const preview = this.pastePreview; + if (!preview) return []; + const {analysis} = preview; + const lines = preview.text.replace(/\r\n?/gu, '\n').split('\n'); + const rows = [`${SUBTLE}${pasteHeader(analysis)}${RESET}`, '']; + for (const line of lines.slice(0, 4)) rows.push(` ${SECONDARY}${displaySafe(line)}${RESET}`); + if (lines.length > 4) rows.push(` ${SUBTLE}… ${lines.length - 4} more line${lines.length - 4 === 1 ? '' : 's'}${RESET}`); + const risky = new Set(['destructive', 'privilege', 'pipeline']); + const items = analysis.commands.slice(0, 12).map((command, index) => { + const kind = primaryKind(command.kinds); + const color = risky.has(kind) ? ERROR : kind === 'install' || kind === 'modifies' ? ACCENT : SUBTLE; + return {plain: `${index + 1} ${KIND_LABELS[kind]}`, styled: `${color}${index + 1} ${KIND_LABELS[kind]}${RESET}`}; + }); + // Summary items wrap onto at most two rows; the rest is counted, never dumped into this strip. + const summary: string[] = []; + let line = ''; + let width = 0; + let used = 0; + for (const item of items) { + const cost = displayWidth(item.plain) + 3; + if (width && width + cost > columns - 2) { summary.push(line); line = ''; width = 0; } + if (summary.length >= 2) break; + line += `${line ? `${SUBTLE} · ${RESET}` : ''}${item.styled}`; + width += cost; + used += 1; + } + if (line && summary.length < 2) summary.push(line); + const hidden = analysis.commands.length - used; + if (hidden > 0 && summary.length) summary[summary.length - 1] += `${SUBTLE} · +${hidden} more${RESET}`; + for (const item of summary) rows.push(` ${item}`); + if (analysis.commands.some(command => ['text', 'unknown'].includes(primaryKind(command.kinds)))) rows.push(`${SUBTLE}${PASTE_EXACT_NOTE}${RESET}`); + rows.push(`${SUBTLE}Enter insert · R review · Esc cancel${RESET}`); + return rows.map(row => truncateAnsi(row, columns)); + } + + /** Live rows for NMSh-managed tasks (shared live-activity look); finished ones linger briefly, then go. */ + private taskRows(columns: number): string[] { + if (this.passthrough || this.externalPassthrough) return []; + const now = Date.now(); + const still = this.promptConfiguration.presentation.reducedMotion || this.promptConfiguration.presentation.effectsOff || isReducedMotion(); + return this.managedTasks.tasks.filter(task => !task.endedAt || now - task.endedAt < 8000).map(task => { + const live = task.status === 'starting' || task.status === 'running' || task.status === 'waiting' || task.status === 'stopping'; + const url = task.urls[0]; + if (!live) return truncateAnsi(`${task.status === 'failed' ? ERROR : SUCCESS}${task.status === 'failed' ? GLYPHS.failure : GLYPHS.success}${RESET} ${SECONDARY}${task.label} ${task.status === 'failed' ? `failed · exit ${task.exitCode ?? '?'}` : 'stopped'}${RESET}`, columns); + // The dev-server URL is an NMSh-authored link (safe http(s) target only) where the host supports OSC 8. + const shownUrl = url ? authoredLink(url, url, this.host.capabilities.hyperlinks && !this.passthrough) : undefined; + return closeAuthoredLinks(truncateAnsi(liveLine(task.label, [task.status === 'running' ? undefined : task.status, shownUrl].filter(Boolean).join(' · ') || undefined, task.startedAt, now, {still}), columns)); + }); + } + + /** A ticking clock only while a managed task is live (its elapsed time and shimmer); none otherwise. */ + private syncTaskClock(): void { + const live = this.managedTasks.live().length > 0; + if (live && !this.taskClock) this.taskClock = presentationClock.subscribe(() => { if (!this.stopped) this.render(); }, 250); + else if (!live && this.taskClock) { this.taskClock(); this.taskClock = undefined; setTimeout(() => { if (!this.stopped) this.render(); }, 8100).unref(); } + } + + private shelfRow(columns: number): string | undefined { + if (this.passthrough || this.externalPassthrough || !this.agents.sessions.length) return undefined; + const attention = this.agents.sessions.some(session => session.attention); + if (!this.shelf.visible && !this.shelf.focused && !attention) return undefined; + return renderShelf(this.agents.sessions, columns, Date.now(), this.shelf.selected, this.shelf.focused) || undefined; + } + + private sessionNoticeRows(columns: number): string[] { + if (!this.promptConfiguration.sessionNotices || this.passthrough) return []; + const {hidden} = this.noticeView; + const now = Date.now(); + // Expired notices never paint, even between polls. + const notices = this.noticeView.notices.filter(notice => noticeVisible(notice, now)); + const safe = getCurrentGlyphMode() === 'safe'; + const symbols = {done: safe ? '+' : '✦', attention: safe ? '!' : '◆', failed: safe ? 'x' : '×', ended: safe ? '-' : '○', long: safe ? '~' : '◷'}; + const rows = notices.map(notice => { + const parts = describeNotice(notice, this.noticeLabels.get(notice.sessionId) ?? sessionLabel(notice.sessionId), now); + const color = parts.symbol === 'failed' ? ERROR : parts.symbol === 'attention' ? ACCENT : parts.symbol === 'done' ? SUCCESS : SECONDARY; + return truncateAnsi(`${color}${symbols[parts.symbol]}${RESET} ${SECONDARY}${parts.text}${RESET}`, columns); + }); + if (hidden > 0) rows.push(truncateAnsi(`${SECONDARY}… ${hidden} more session update${hidden === 1 ? '' : 's'} · /resume · /notices clear${RESET}`, columns)); + return rows; + } + + /** Poll the service's session list on a slow cadence while notices are on and NMSh owns the screen. */ + private syncNotices(): void { + const wanted = this.presentationStarted && !this.stopped && !this.passthrough && !this.externalPassthrough && !this.frontendSuspended + && this.sessionMode === 'service' && this.promptConfiguration.sessionNotices; + if (wanted && !this.noticeTimer) { + this.noticeTimer = presentationClock.subscribe(() => void this.refreshNotices(), NOTICE_REFRESH_MS); + void this.refreshNotices(); + } else if (!wanted && this.noticeTimer) { + this.noticeTimer(); this.noticeTimer = undefined; + } + if (!wanted && !this.promptConfiguration.sessionNotices && this.noticeView.notices.length) this.noticeView = {notices: [], hidden: 0}; + } + + private async refreshNotices(): Promise { + if (this.noticePolling) return; + this.noticePolling = true; + try { + const {sessions, ended} = await listSessionNotices(); + if (this.stopped) return; + const ordered = [...sessions].sort((a, b) => a.createdAt - b.createdAt); + // The person's own name, else the familiar signature, else the old ordinal label. + this.noticeLabels = new Map(ordered.map((session, index) => [session.id, session.name || session.signature || sessionLabel(session.id, index + 1)])); + this.noticeSource = [...sessions.flatMap(session => (session.notice ? [session.notice] : [])), ...ended]; + this.applyNotices(); + } catch { /* notices are best effort */ } finally { this.noticePolling = false; } + } + + /** Select what is visible now (expired notices drop out) and wake exactly when the next one expires. */ + private applyNotices(now = Date.now()): void { + const next = selectNotices(this.noticeSource, this.sessionId, this.dismissedNotices, now); + const changed = JSON.stringify(next) !== JSON.stringify(this.noticeView); + this.noticeView = next; + if (this.noticeExpiry) { clearTimeout(this.noticeExpiry); this.noticeExpiry = undefined; } + const soonest = this.noticeSource.filter(notice => selectNotices([notice], this.sessionId, this.dismissedNotices, now).notices.length) + .map(notice => noticeExpiresAt(notice)).filter((at): at is number => at !== undefined).sort((a, b) => a - b)[0]; + if (soonest !== undefined && !this.stopped) { + this.noticeExpiry = setTimeout(() => { this.noticeExpiry = undefined; this.applyNotices(); if (!this.stopped) this.render(); }, Math.max(50, soonest - now + 50)); + this.noticeExpiry.unref(); + } + if (changed) this.render(); + } + + /** Clear every visible notice, for every attached frontend where the service supports it. */ + private async clearNotices(): Promise { + const shown = [...this.noticeView.notices]; + for (const notice of shown) this.dismissedNotices.add(noticeKey(notice)); + this.noticeView = {notices: [], hidden: 0}; + await Promise.all(shown.map(notice => dismissSessionNotice(notice.sessionId))); + await this.refreshNotices(); + } + + private stripActive(columns: number, rows: number): boolean { + return stripVisible(this.promptConfiguration.statusStrip, columns, rows); + } + + private statusStripRow(columns: number): string { + // Active Keep Awake is always part of an enabled strip (no per-item switch); the strip itself is never forced on. + const record = this.awakeRecord; + const display = this.promptConfiguration.keepAwake.display; + return renderStatusStrip(this.promptConfiguration.statusStrip, this.stripStats, columns, undefined, + record ? {full: awakeLabel(record, display, 'full'), short: awakeLabel(record, display, 'short'), glyph: awakeLabel(record, display, 'glyph')} : undefined); + } + + /** One timer while the strip is on and NMSh owns the screen; none otherwise. */ + private syncStatusStrip(): void { + const wanted = this.presentationStarted && !this.stopped && !this.passthrough && !this.externalPassthrough && !this.frontendSuspended + && this.promptConfiguration.statusStrip.enabled; + if (wanted && !this.stripTimer) { + this.stripTimer = presentationClock.subscribe(() => void this.sampleStrip(), STRIP_REFRESH_MS); + void this.sampleStrip(); + } else if (!wanted && this.stripTimer) { + this.stripTimer(); this.stripTimer = undefined; + } + } + + private async sampleStrip(): Promise { + if (this.stripSampling) return; + this.stripSampling = true; + try { + const {columns} = this.dimensions(); + const before = this.statusStripRow(columns); + this.stripStats = await this.statsSource.sample(); + // The clock also moves without new stats; repaint only when the row text changes. + if (this.stripTimer && !this.stopped && this.statusStripRow(columns) !== before) this.render(); + } catch { /* A failed sample keeps the previous values. */ } + finally { this.stripSampling = false; } + } + + private openPanel?: OpenPanelState; + + private hostActions(): HostActionAdapter { + return resolveHostActions(this.promptConfiguration.openWith); + } + + /** path:line references in the newest outputs, each with the cwd its command ran in. */ + private recentReferences(): OpenPanelState['references'] { + const references: OpenPanelState['references'] = []; + for (let index = 1; index <= 5 && references.length < 200; index += 1) { + const record = this.output.recentShell(index); + if (!record) break; + const cwd = record.historicalContext?.cwd ?? this.shellCwd; + const lines = stripAnsi(record.output).split('\n').slice(-2000).reverse(); + for (const line of lines) for (const reference of findSourceReferences(line)) { + if (!references.some(item => item.text === reference.text && item.cwd === cwd)) references.push({...reference, cwd, command: record.command}); + } + } + return references; + } + + /** Delegate a location to the editor; relative paths resolve against the command's own cwd. */ + private async openLocation(command: string, target: string, cwd: string): Promise { + const report = (message: string, style: string) => { this.output.addFrontendInteraction(command, message, style); this.render(); }; + const parsed = parseOpenArgument(target); + if (!parsed) return report('Usage: /open [:line[:column]]', INFO); + const resolved = resolveLocation(parsed, cwd); + if (!resolved.ok) return report(resolved.reason, ERROR); + const adapter = this.hostActions(); + const action = resolved.kind === 'directory' ? adapter.openDirectory(resolved.location.path) : adapter.openFile(resolved.location); + await this.performHostAction(action, report); + } + + private async openDiff(command: string, left: string, right: string): Promise { + const report = (message: string, style: string) => { this.output.addFrontendInteraction(command, message, style); this.render(); }; + if (!left || !right) return report('Usage: /open-diff (opens your editor\'s diff view)', INFO); + const paths: string[] = []; + for (const side of [left, right]) { + const resolved = resolveLocation({path: side}, this.shellCwd); + if (!resolved.ok) return report(resolved.reason, ERROR); + if (resolved.kind !== 'file') return report(`${resolved.location.path} is a directory; /open-diff compares two files.`, ERROR); + paths.push(resolved.location.path); + } + await this.performHostAction(this.hostActions().openDiff(paths[0]!, paths[1]!), report); + } + + private async performHostAction(action: HostAction, report: (message: string, style: string) => void): Promise { + if (action.kind === 'unsupported') return report(action.reason, INFO); + if (action.kind === 'compose') { + // Terminal editors take over the terminal: the exact command goes in the composer for you to run. + const quote = this.shellId === 'fish' ? fishQuote : posixQuote; + const line = action.argv.map(arg => (/^[\w@%+=:,./-]+$/u.test(arg) ? arg : quote(arg))).join(' '); + this.editor.clear(); + this.editor.insert(line); + return report(`Ready to open in ${action.label}: press Enter to run it.`, INFO); + } + const failure = await runHostAction(action); + report(failure ? `Could not start ${action.label}: ${failure}` : `Opened in ${action.label}.`, failure ? ERROR : INFO); + } + + /** Changes whenever presented rows may have changed; matches are recomputed only then. */ + private findGeneration(wrapped: readonly WrappedRow[], columns: number): string { + const filter = this.output.activeFilter; + return `${wrapped.length}|${columns}|${this.lastOutputTime}|${filter ? JSON.stringify(filter) : ''}`; + } + + private revealFindMatch(totalRows: number, height: number): void { + const result = this.findState?.results[this.findState.active]; + if (!result) return; + this.historyViewport.scrollLines(totalRows, height, revealStart(result.row, totalRows, height) - this.historyViewport.resolve(totalRows, height)); + } + + /** The block a block-scoped action targets: the focused block, else the newest completed one. */ + private targetBlockStartId(): number | undefined { + const index = this.focusedCommandIndex ?? 0; + return this.output.recent(index + 1)?.startId; + } + + /** Ctrl+F or /find alone: a fresh clause input; applied clauses stay. */ + private openFindEditor(): void { + this.findState ??= createFind(); + this.findState.editing = {query: '', options: {regex: false, caseSensitive: false}}; + } + + private findCommand(command: string, argumentsText: string): void { + const parsed = parseSearchCommand(argumentsText); + const say = (message: string, style = INFO) => this.output.addFrontendInteraction(command, message, style); + if (parsed.kind === 'open') { this.openFindEditor(); return; } + if (parsed.kind === 'clear') { this.findState = undefined; say('Find cleared.'); return; } + if (parsed.kind === 'remove') { + const clauses = this.findState?.clauses ?? []; + if (parsed.index < 1 || parsed.index > clauses.length) { say(`No find term ${parsed.index}; ${clauses.length} active.`, ERROR); return; } + clauses.splice(parsed.index - 1, 1); + if (!clauses.length && !this.findState?.editing) this.findState = undefined; + return; + } + if (!parsed.parsed.query) { this.openFindEditor(); return; } + const check = compileQuery(parsed.parsed.query, parsed.parsed.options); + if (!check.ok) { say(`Invalid regular expression: ${check.error}`, ERROR); return; } + if (!this.findState) { + const block = parsed.parsed.block ? this.targetBlockStartId() : undefined; + this.findState = createFind(block === undefined ? 'transcript' : 'block', block); + } + // Repeated /find adds a clause: every clause must match the same logical line (AND). + this.findState.clauses.push({query: parsed.parsed.query, options: parsed.parsed.options}); + } + + /** Find and filter status above the composer: at most two rows. */ + private searchChrome(columns: number): string[] { + const find = this.findState; + const filter = this.output.activeFilter; + const safe = getCurrentGlyphMode() === 'safe'; + return searchChromeRows(find ? {clauses: find.clauses, ...(find.editing ? {editing: find.editing} : {}), count: findCount(find), error: Boolean(find.error)} : undefined, + filter ? {clauses: filter.clauses} : undefined, columns, + {accent: ACCENT, primary: PRIMARY, secondary: SECONDARY, subtle: SUBTLE, error: ERROR, reset: RESET}, + safe ? {find: '/', filter: '|'} : {find: '⌕', filter: '⧩'}).map(row => truncateAnsi(row, columns)); + } + + /** + * Find editor keys (while a clause is being typed). Enter applies a typed + * clause, or with an empty input steps to the older match; Shift+Enter steps + * newer; Tab cycles the clause's options; Esc discards only the input. + * Returns false for keys the editor does not own. + */ + private handleFindKey(key: Key): boolean { + const state = this.findState!; + const editing = state.editing; + if (!editing) return false; + const {columns, rows} = this.dimensions(); + const step = (direction: 'next' | 'previous') => { + stepFind(state, direction); + const total = this.output.wrapped(columns).length; + this.revealFindMatch(total, this.planFrame(columns, rows).viewportRows); + }; + if (key.kind === 'escape' || key.kind === 'interrupt') { + state.editing = undefined; + if (!state.clauses.length) this.findState = undefined; + } else if (key.kind === 'enter') { + if (editing.query) { + if (!compileQuery(editing.query, editing.options).ok) return true; + state.clauses.push(editing); + state.editing = {query: '', options: {regex: false, caseSensitive: false}}; + } else step('next'); + } else if (key.kind === 'up') step('next'); + else if (key.kind === 'newline' || key.kind === 'down') step('previous'); + else if (key.kind === 'complete') { + // Tab cycles: plain → case-sensitive → regex → regex + case. + const order = [[false, false], [false, true], [true, false], [true, true]] as const; + const current = order.findIndex(([regex, caseSensitive]) => regex === editing.options.regex && caseSensitive === editing.options.caseSensitive); + const [regex, caseSensitive] = order[(current + 1) % order.length]!; + editing.options = {regex, caseSensitive}; + } else if (key.kind === 'text') editing.query += key.value; + else if (key.kind === 'paste') editing.query += key.value.replace(/[\r\n]+/gu, ' '); + else if (key.kind === 'backspace') editing.query = [...editing.query].slice(0, -1).join(''); + else if (key.kind === 'deleteWord' || key.kind === 'deleteLineBefore') editing.query = ''; + else if (key.kind === 'find') { /* already open */ } + else return false; + this.render(); + return true; + } + + private applyFilterCommand(command: string, argumentsText: string): void { + const parsed = parseSearchCommand(argumentsText); + const say = (message: string, style = INFO) => { this.output.addFrontendInteraction(command, message, style); this.render(); }; + const current = this.output.activeFilter; + if (parsed.kind === 'open' || parsed.kind === 'clear') { + this.output.setOutputFilter(undefined); + say(current ? 'Filter cleared; the complete output is shown again.' : 'No filter is active. /filter shows only matching lines of the newest output.'); + return; + } + if (parsed.kind === 'remove') { + if (!current || parsed.index < 1 || parsed.index > current.clauses.length) { say(`No filter term ${parsed.index}; ${current?.clauses.length ?? 0} active.`, ERROR); return; } + const clauses = current.clauses.filter((_clause, index) => index !== parsed.index - 1); + this.output.setOutputFilter(clauses.length ? {startId: current.startId, clauses} : undefined); + this.render(); + return; + } + const {query, options, invert, context} = parsed.parsed; + const check = compileQuery(query, options); + if (!check.ok) { say(`Invalid regular expression: ${check.error}`, ERROR); return; } + // Repeated /filter adds a clause to the same block's set (AND); it never moves to a newer block. + const startId = current?.startId ?? this.targetBlockStartId(); + if (startId === undefined) { say('There is no command output to filter yet.', ERROR); return; } + this.output.setOutputFilter({startId, clauses: [...(current?.clauses ?? []), {query, options, invert, context}]}); + this.historyViewport.latest(); + this.render(); + } + + private providersOverview?: ProvidersOverviewState; + private doctorPanel?: DoctorPanelState; + private watchPanel?: WatchPanelState; + /** Watches NMSh schedules itself; they live (and stop) with this window. */ + private readonly watches = new WatchTasks(resolveCommand('sh') ?? '/bin/sh'); + private readonly watchSubscription = this.watches.onChange((_watch, transition) => { + if (this.stopped) return; + // A watch moving between failing and passing is one meaningful event. + if (transition) this.transitions.echo(transition === 'pass' ? 'taskDone' : 'taskFailed', Date.now()); + this.render(); + }); + + /** /watch …: start (classified first), list, pause, resume, run now, stop. */ + private handleWatch(command: string, op: 'list' | 'stop' | 'pause' | 'resume' | 'now' | 'start', argument: string): void { + const active = this.watches.active(); + const pick = () => { + const index = /^\d+$/u.test(argument) ? Number(argument) - 1 : active.length - 1; + return active[index]; + }; + if (op === 'list') { this.panelOrigin = undefined; this.watchPanel = {selected: 0, output: false}; return; } + if (op === 'start') { + const {command: watched, intervalMs} = parseWatch(argument); + const safety = watchSafety(watched); + if (safety.kind === 'refused') { this.output.addFrontendInteraction(command, safety.reason, ERROR); return; } + this.panelOrigin = undefined; + if (safety.kind === 'confirm' || safety.remote) { + this.watchPanel = {selected: 0, output: false, confirm: {command: watched, cwd: this.shellCwd, ...(intervalMs ? {intervalMs} : {}), + reason: safety.kind === 'confirm' ? safety.reason : 'It contacts a remote host every interval (at least every 15s).', choice: 'no'}}; + return; + } + this.watches.start(watched, this.shellCwd, intervalMs, safety); + this.watchPanel = {selected: this.watches.active().length - 1, output: false}; + return; + } + if (op === 'stop' && argument === 'all') { for (const watch of active) this.watches.stop(watch.id); this.output.addFrontendInteraction(command, 'Stopped every watch.', INFO); return; } + const watch = pick(); + if (!watch) { this.output.addFrontendInteraction(command, 'Nothing is being watched.', INFO); return; } + const done = op === 'stop' ? this.watches.stop(watch.id) : op === 'pause' ? this.watches.pause(watch.id) : op === 'resume' ? this.watches.resume(watch.id) : (this.watches.runNow(watch.id), true); + this.output.addFrontendInteraction(command, done ? `${op === 'now' ? 'Running' : op === 'stop' ? 'Stopped' : op === 'pause' ? 'Paused' : 'Resumed'} the watch on ${watch.command}.` : 'Nothing changed.', INFO); + } + + /** /doctor: local, bounded, read-only checks gathered in parallel; a live line while they run. */ + private async openDoctor(): Promise { + this.panelOrigin = undefined; + const root = this.context.root; + const panel = createDoctorPanel(basename(root ?? this.shellCwd) || this.shellCwd); + this.doctorPanel = panel; + const clock = this.decorativeMotionAllowed() ? presentationClock.subscribe(() => { if (this.doctorPanel === panel && !this.stopped) this.render(); }, 100) : undefined; + this.render(); + try { + const [serviceReachable, git] = await Promise.all([ + this.sessionMode === 'service' ? listLiveSessions().then(() => true, () => false) : Promise.resolve(undefined), + root ? readGitFacts(root) : Promise.resolve(undefined), + this.refreshProviderStatuses().catch(() => {}), + ]); + const configuration = this.promptConfiguration; + const providerRows = ([['picker', configuration.picker], ['history', configuration.history], ['navigation', configuration.navigation], ['suggestions', configuration.suggestions], ['prompt', configuration.provider]] as const) + .flatMap(([family, id]) => { + const descriptor = PROVIDER_FAMILIES.flatMap(item => item.providers).find(item => item.id === id && item.kind === 'external'); + if (!descriptor) return []; + const status = this.providerStatuses.get(descriptor.id); + return [{family, label: descriptor.label, available: status?.state === 'installed'}]; + }); + const model = configuration.localUnderstanding.model; + const runtimeAvailable = model ? (model.runtime === 'llama.cpp' ? Boolean(resolveCommand('llama-server')) + : Boolean(this.understanding.discovery?.runtimes.some(runtime => runtime.kind === model.runtime && runtime.running))) : false; + const checks = runDoctor({cwd: this.shellCwd, platform: process.platform, which: name => resolveCommand(name), exists: path => existsSync(path), + writable: path => { try { accessSync(path, constants.W_OK); return true; } catch { try { accessSync(join(path, '..'), constants.W_OK); return !existsSync(path); } catch { return false; } } }, + nmsh: {configurationLoaded: true, sessionMode: this.sessionMode, ...(serviceReachable !== undefined ? {serviceReachable} : {}), transcriptDirectory: join(nmshConfigDirectory(), 'sessions'), + shell: {id: this.shellId, label: shellAdapter(this.shellId).label, ...(resolveCommand(this.shellId) ? {executable: resolveCommand(this.shellId)!} : {}), promptSeen: !this.startupPending}, + host: {name: this.host.name, truecolor: colorLevel() === 'truecolor', keyboard: this.host.capabilities.enhancedKeyboard || this.host.capabilities.kittyKeyboard}}, + ...(git ? {git} : {}), ...(root ? {repoRoot: root} : {}), providers: providerRows, + understanding: {mode: configuration.localUnderstanding.mode, ...(model ? {model: {label: model.label, runtime: model.runtime, ...(model.path ? {path: model.path} : {}), owned: Boolean(model.owned)}} : {}), + runtimeAvailable, ...(model ? {state: stateLabel(this.understanding.status, configuration.localUnderstanding)} : {})}, + agents: this.agents.harnesses().map(item => ({label: item.harness.name, installed: Boolean(item.executable)})), + ...(process.env.VIRTUAL_ENV ? {virtualEnv: process.env.VIRTUAL_ENV} : {})}); + if (this.doctorPanel === panel) { panel.checks = checks; panel.selected = 0; } + } finally { clock?.(); } + this.render(); + } + private cursorPanel?: CursorPanelState; + private cursorPanelClock?: () => void; + + /** What the cursor surfaces need to know: backend choice, host facts, theme colors, color level. `config` is the saved or (in Setup) the draft configuration. */ + private cursorEnv(draft: PromptConfiguration['cursor'], config: PromptConfiguration): CursorPanelEnv { + const facts = this.cursorFacts(); + return {choice: chooseBackend(draft, facts), facts, context: {...contextFor(config), chrome: UI_COLORS.accent}, + still: !this.decorativeMotionAllowed() || colorLevel() === 'none', level: colorLevel()}; + } + + private cursorFacts(): HostCursorFacts { + this.cursorHost ??= hostCursorFacts(process.env, host => nativeCursorIntegrated(host)); + return this.cursorHost; + } + + /** The panel's rows; one frame clock exists only while its preview is animating and stops when it settles. */ + private cursorPanelRows(state: CursorPanelState, columns: number, env: CursorPanelEnv): string[] { + const now = Date.now(); + const rows = renderCursorPanel(state, columns, now, env, this.dimensions().rows - 4); + const busy = Boolean(state.previewBusy) && !state.picker && !state.gradient && !state.native; + if (busy && !this.cursorPanelClock) this.cursorPanelClock = presentationClock.subscribe(() => { if ((this.cursorPanel || this.setupState?.cursorPanel) && !this.stopped) this.render(); }, 33, 16); + else if (!busy && this.cursorPanelClock) { this.cursorPanelClock(); this.cursorPanelClock = undefined; } + return rows; + } + + /** /cursor: the canonical cursor & effects surface; its preview animates only while it is open (and motion is allowed). */ + private openCursorPanel(options: CursorPanelOptions = {}): void { + this.panelOrigin = undefined; + this.cursorPanel = createCursorPanel(this.promptConfiguration.cursor, Date.now(), options); + } + + private closeCursorPanel(): void { + this.cursorPanel = undefined; + this.cursorPanelClock?.(); this.cursorPanelClock = undefined; + this.returnFromPanel(); + } + + private handleCursorPanelKey(key: Key, state: CursorPanelState, config: PromptConfiguration = this.promptConfiguration): void { + const action = cursorPanelKey(state, key, this.cursorEnv(state.draft, config)); + if (!action) return; + if (action.kind === 'close') { this.closeCursorPanel(); return; } + if (action.kind === 'apply') { + // The configuration setter applies shape/blink to the live renderer and refreshes NMSh's managed native files. + this.updateConfiguration(configuration => { configuration.cursor = action.settings; }); + if (this.cursorReload) state.message = this.cursorReload; + return; + } + const facts = this.cursorFacts(); + const host = facts.host === 'other' ? undefined : facts.host; + if (action.kind === 'native') { + if (!host || !nativeBackendFor(facts)) { state.message = 'This terminal has no native cursor effects NMSh can use; Portable works here.'; return; } + if (facts.integrated) { state.message = `${nativeHostLabel(host)} native is already set up; changes here update NMSh's managed files.`; return; } + const setup = setupPlan(host); + const blocked = setup.plan.kind === 'refuse' ? setup.plan.reason : undefined; + state.native = {host, configPath: setup.configPath, ...(setup.plan.kind === 'plan' || setup.plan.kind === 'noop' ? {line: includeLine(host)} : {}), related: setup.related, + ...(blocked ? {blocked} : {}), choice: 'no'}; + return; + } + if (action.kind === 'nativeConfirm' && state.native) { + const native = state.native; + state.native = undefined; + const setup = setupPlan(native.host); + let written: ManagedWrite; + try { written = writeManagedFiles(native.host, resolveCursorSettings(state.draft, contextFor(this.promptConfiguration))); } catch { state.message = 'Could not write NMSh\'s managed cursor files; nothing else was changed.'; return; } + if (setup.plan.kind === 'plan') { + const applied = applyPlan(setup.plan.plan); + if (!applied.ok) { state.message = applied.reason; return; } + } + this.cursorHost = hostCursorFacts(process.env, value => nativeCursorIntegrated(value)); + void written; + state.message = this.cursorHost.integrated + ? (reloadInstruction(native.host, {changed: ['fragment']}) ?? `Set up. Renderer Auto now uses ${nativeHostLabel(native.host)} native.`) + : 'Setup did not verify; Portable stays in use.'; + } + } + + /** The reload line for the latest managed-file refresh, until the next one (shown in /cursor and after Setup applies). */ + private cursorReload?: string; + + /** + * Keep NMSh's own native files in step with the cursor settings and the theme + * colors they resolve to: only when the host integration is already set up, only + * NMSh's own fragment and shader, and only files whose content changed. + */ + private syncCursorNative(next: PromptConfiguration): void { + const facts = this.cursorFacts(); + if (facts.host === 'other' || !facts.integrated) { this.cursorReload = undefined; return; } + try { + const written = writeManagedFiles(facts.host, resolveCursorSettings(next.cursor, contextFor(next))); + this.cursorReload = reloadInstruction(facts.host, written); + } catch { this.cursorReload = 'Could not update the managed cursor files.'; } + } + private understandingPanel?: UnderstandingPanelState; + + /** The welcome's factual local-understanding text at presentation start; it never claims a model it has not seen loaded. */ + private welcomeUnderstanding(): string { + return understandingWelcomeText(this.configuration.localUnderstanding, this.understanding?.status); + } + + private async refreshUnderstandingDiscovery(again: boolean): Promise { + try { await this.understanding.discover(again); } catch { /* discovery is best effort */ } + try { await this.understanding.refreshStatus(); } catch { /* no service: idle */ } + } + + /** /providers' Local understanding row: what is in use, and the facts behind it. */ + private understandingSummary(): {active: string; detail: string[]} { + const settings = this.promptConfiguration.localUnderstanding; + const loaded = this.understanding.status && (this.understanding.status.state === 'ready' || this.understanding.status.state === 'busy'); + const active = settings.mode === 'off' || !settings.model ? 'Built-in' : loaded ? `${settings.model.label}` : `Built-in (${settings.model.label} idle)`; + const found = this.understanding.discovery; + const detail = understandingStatusRows(settings, this.understanding.status).map(row => `${row.label}: ${row.value}`); + if (found) detail.push(`Found locally: ${found.models.filter(model => model.suitability !== 'unsuitable').length} usable model(s); runtimes: ${found.runtimes.map(runtime => runtime.label).join(', ') || 'none'}`); + return {active, detail}; + } + + private openUnderstandingPanel(onboarding = false): void { + this.panelOrigin = undefined; + this.understandingPanel = createUnderstandingPanel(onboarding); + void this.refreshUnderstandingDiscovery(false).then(() => this.render()); + } + + private understandingFacts(): UnderstandingFacts { + const brew = resolveCommand('brew'); + const recommended = loadRecommendedModel(); + return {settings: this.promptConfiguration.localUnderstanding, ...(this.understanding.discovery ? {discovery: this.understanding.discovery} : {}), + ...(this.understanding.status ? {status: this.understanding.status} : {}), ...(recommended ? {recommended} : {}), + ...(this.understanding.downloadFailure ? {downloadFailure: this.understanding.downloadFailure} : {}), + ...(brew && (process.platform === 'darwin' || process.platform === 'linux') ? {runtimeRecipe: 'brew install llama.cpp'} : {}), + activity: {requests: this.understanding.requests, ...(this.understanding.lastRoute ? {lastRoute: this.understanding.lastRoute} : {}), + ...(this.understanding.lastInference ? {lastInference: this.understanding.lastInference} : {})}, + ...(this.ownedModelFacts() ? {ownedModel: this.ownedModelFacts()!} : {}), + ...(brew && (() => { try { return new InstallProvenance().find('llama-server'); } catch { return undefined; } })() ? {runtimeOwned: {label: 'brew install llama.cpp'}} : {}), + now: Date.now()}; + } + + /** + * The model file NMSh itself downloaded: inside NMSh's own model folder (real + * path checked, no symlink escape) and a regular .gguf file. Nothing else is + * ever offered for removal. + */ + private ownedModelFacts(): {path: string; bytes?: number; inUse: boolean} | undefined { + const directory = nmshModelDirectory(); + const configured = this.promptConfiguration.localUnderstanding.model; + const candidates = [configured?.owned ? configured.path : undefined, + ...(this.understanding.discovery?.models.filter(model => model.owned).map(model => model.path) ?? [])].filter((path): path is string => Boolean(path)); + for (const path of candidates) { + try { + const real = realpathSync(path); + const root = realpathSync(directory); + if (!real.startsWith(`${root}/`) || !/\.gguf$/iu.test(real) || !statSync(real).isFile()) continue; + return {path: real, bytes: statSync(real).size, inUse: Boolean(configured?.path && (configured.path === path || configured.path === real))}; + } catch { /* gone */ } + } + return undefined; + } + + /** Approved steps run here, then are verified and activated; nothing runs without the panel's Yes. */ + private async handleUnderstandingAction(action: import('../understanding/UnderstandingPanel.js').UnderstandingAction): Promise { + const panel = this.understandingPanel; + if (!panel) return; + const update = (change: (settings: PromptConfiguration['localUnderstanding']) => PromptConfiguration['localUnderstanding']) => + this.updateConfiguration(configuration => { configuration.localUnderstanding = change({...configuration.localUnderstanding}); }); + if (action.kind === 'close') { this.understandingPanel = undefined; this.returnFromPanel(); return; } + if (action.kind === 'detect') { await this.refreshUnderstandingDiscovery(true); panel.message = 'Detected again.'; this.render(); return; } + if (action.kind === 'mode') { + const modes = ['off', 'auto', 'always'] as const; + update(settings => ({...settings, mode: modes[(modes.indexOf(settings.mode) + action.delta + modes.length) % modes.length]!})); + return; + } + if (action.kind === 'scope') { update(settings => ({...settings, [action.scope]: !settings[action.scope]})); return; } + if (action.kind === 'stop') { + panel.message = await this.understanding.stopModel() ? 'Unloading the model; it loads again on next use.' : 'No model service is running.'; + await this.refreshUnderstandingDiscovery(false); + this.render(); + return; + } + if (action.kind === 'remove') { + // Re-checked right before deleting: only the exact NMSh-owned file. + const owned = this.ownedModelFacts(); + if (!owned) { panel.message = 'There is no NMSh-downloaded model to remove.'; return; } + if (owned.inUse) { + await this.understanding.stopModel(); + update(settings => { const {model: _model, ...rest} = settings; return rest; }); + } + try { rmSync(owned.path); panel.message = `Removed ${owned.path}.`; } catch (error) { panel.message = `Couldn't remove it: ${error instanceof Error ? error.message : String(error)}`; } + await this.refreshUnderstandingDiscovery(true); + this.render(); + return; + } + if (action.kind === 'uninstallRuntime') { + const record = (() => { try { return new InstallProvenance().find('llama-server'); } catch { return undefined; } })(); + const brew = resolveCommand('brew'); + if (!record || !brew) { panel.message = 'NMSh has no record of installing llama.cpp, so it won\'t uninstall it.'; return; } + panel.working = 'Running brew uninstall llama.cpp…'; + this.render(); + await this.understanding.stopModel(); + const task = new TaskProgress('Uninstalling llama.cpp', () => this.render(), Date.now(), 'llama.cpp'); + const outcome = await task.run(brew, ['uninstall', 'llama.cpp']); + panel.working = undefined; + if (outcome.status === 'succeeded') { try { new InstallProvenance().forget('llama-server'); } catch { /* record stays */ } } + clearProviderDetection(); + await this.refreshUnderstandingDiscovery(true); + panel.message = outcome.status === 'succeeded' ? 'llama.cpp was uninstalled.' : `llama.cpp was not uninstalled. ${task.state.error ?? ''}`.trim(); + this.render(); + return; + } + if (action.kind === 'use') { + update(settings => ({...settings, model: modelChoice(action.model), mode: settings.mode === 'off' ? 'auto' : settings.mode})); + panel.message = `Using ${action.model.label}${action.model.owned ? '' : ' (found on this machine; NMSh will not delete it)'}.` + + (this.promptConfiguration.localUnderstanding.ask || this.promptConfiguration.localUnderstanding.folding ? '' : ' Enable Ask or Smart Folding above to use it.'); + return; + } + if (action.kind === 'runtime') { + panel.working = 'Running brew install llama.cpp…'; + this.render(); + const task = new TaskProgress('Installing llama.cpp', () => this.render(), Date.now(), 'llama.cpp'); + const outcome = await task.run(resolveCommand('brew') ?? 'brew', ['install', 'llama.cpp']); + panel.working = undefined; + clearProviderDetection(); + await this.refreshUnderstandingDiscovery(true); + const found = this.understanding.discovery?.runtimes.some(runtime => runtime.kind === 'llama.cpp'); + if (outcome.status === 'succeeded' && found) recordInstall('llama-server', {label: 'brew install llama.cpp', command: 'brew', args: ['install', 'llama.cpp']}); + panel.message = outcome.status === 'succeeded' && found ? 'llama.cpp is installed and detected.' + : outcome.status === 'succeeded' ? 'brew finished, but llama-server was not found on PATH; nothing was changed.' : `llama.cpp was not installed. ${task.state.error ?? ''}`.trim(); + this.render(); + return; + } + if (action.kind === 'download') { + const artifact = loadRecommendedModel()?.artifact; + if (!artifact) { panel.message = 'No verified download is pinned in this build.'; return; } + const label = `${loadRecommendedModel()?.model ?? 'Qwen3 0.6B'}`; + panel.progress = {label, stage: 'Downloading', received: 0, total: artifact.bytes, since: Date.now()}; + const clock = presentationClock.subscribe(() => { if (!this.stopped) this.render(); }, 100); + this.render(); + try { + const path = await downloadPinned(artifact, nmshModelDirectory(), received => { if (panel.progress) panel.progress.received = received; }, + fetch, undefined, stage => { if (panel.progress) panel.progress.stage = stage === 'verify' ? 'Verifying SHA-256' : 'Installing'; this.render(); }); + clock(); + panel.progress = undefined; + update(settings => ({...settings, mode: settings.mode === 'off' ? 'auto' : settings.mode, + model: {label: `Qwen3 0.6B ${artifact.quantization}`, runtime: 'llama.cpp', path, owned: true}})); + this.understanding.downloadFailure = undefined; + await this.refreshUnderstandingDiscovery(true); + panel.message = 'Downloaded and verified (sha256). It loads on first use and unloads when idle.'; + this.transitions.echo('setupDone', Date.now()); + } catch (error) { + clock(); + panel.progress = undefined; + // Verification failure is remembered: the model is not used and nothing retries until you choose to. + this.understanding.downloadFailure = error instanceof Error ? error.message : String(error); + this.transitions.echo('setupFailed', Date.now()); + panel.message = `The recommended model was not installed: ${this.understanding.downloadFailure}. The incomplete file was removed; nothing was changed. ` + + 'Ask and Smart Folding keep working without it, and you can still choose a compatible model already on this machine.'; + } + this.render(); + } + } + + /** Optional, advisory, bounded: a late hint may tip only a borderline block the user has not touched. */ + private async adviseFolding(record: CompletedCommand): Promise { + if (!this.understanding.eligible('folding')) return; + const input = {command: record.command, output: record.output, exitCode: record.exitCode ?? 0, lineCount: (record.endId ?? record.outputStartId) - record.outputStartId}; + if (!hintEligible(this.promptConfiguration.outputFolding, input)) return; + const hint = await this.understanding.foldHint(foldExcerpt(record.command, record.output, record.exitCode ?? 0)); + if (!hint || this.stopped) return; + if (this.output.applyAdvisoryFold(record.startId, applyFoldHint(input, hint))) this.render(); + } + + private openProvidersOverview(focus?: SwitchableFamily): void { + this.panelOrigin = undefined; + this.providersOverview = createProvidersOverview(focus); + void this.refreshProvidersOverview(false); + } + + /** Local detection only (PATH and known locations); R forgets cached results first. */ + private async refreshProvidersOverview(again: boolean): Promise { + const state = this.providersOverview; + if (!state) return; + state.detecting = true; + if (again) { clearProviderDetection(); this.providerStatuses.clear(); } + await this.refreshProviderStatuses(); + await this.refreshUnderstandingDiscovery(again); + state.detecting = false; + if (again) state.message = 'Detected again.'; + if (this.providersOverview === state) this.render(); + } + + private providersOverviewFacts() { + let installedByNmsh = new Set(); + try { installedByNmsh = new Set(new InstallProvenance().list().map(record => record.toolId)); } catch { /* no provenance yet */ } + return {configuration: this.promptConfiguration, statuses: this.providerStatuses, installedByNmsh, + understanding: this.understandingSummary(), shell: {current: shellAdapter(this.shellId).label, defaultShell: shellAdapter(this.promptConfiguration.shellBackend).label}}; + } + + /** + * Inline selection from /providers: the same configuration and the same + * side effects as the family panels, applied at once so the overview is + * immediately factual (the family row shows the new provider as Active). + */ + private selectProviderInline(family: SwitchableFamily, id: string): void { + const next = selectProvider(this.promptConfiguration, family, id); + const state = this.providersOverview; + if (!next || !state) return; + if (!this.applySettingsConfiguration(next)) return; + if (family === 'suggestions') this.applySuggestionProvider(); + if (family === 'history') void this.loadHistory(); + if (family === 'navigation') { this.directoryQueryAbort?.abort(); this.directoryQuery = undefined; this.directoryResults = []; } + if (family === 'prompt') void this.refreshProviderPrompt().then(() => this.render()); + const label = providerFamily(family)!.providers.find(provider => provider.id === id)!.label; + state.message = `${providerFamily(family)!.title} · ${label}`; + } + + private async installProviderInline(family: SwitchableFamily, id: string): Promise { + const state = this.providersOverview; + const descriptor = providerFamily(family)?.providers.find(provider => provider.id === id); + const install = descriptor ? providerInstall(descriptor) : undefined; + if (!state || !descriptor || !install) return; + state.installing = {family, id, line: `Installing ${descriptor.label} · ${install.label}…`}; + this.render(); + const task = new TaskProgress(`Installing ${descriptor.label}`, () => { if (this.providersOverview === state) this.render(); }, Date.now(), descriptor.label); + const outcome = await task.run(install.command, [...install.args]); + if (this.stopped) return; + clearProviderDetection(); + const status = await detectProvider(descriptor); + this.providerStatuses.set(descriptor.id, status); + state.installing = undefined; + if (outcome.status === 'succeeded' && status.state === 'installed') { + recordInstall(descriptor.executable ?? descriptor.id, install); + this.selectProviderInline(family, id); + this.milestoneEffect(); + } else state.message = outcome.status === 'succeeded' ? `${install.label} finished, but ${descriptor.label} was not found on PATH; nothing was selected.` + : `${descriptor.label} was not installed. ${task.state.error ?? ''}`.trim(); + this.render(); + } + + /** Each family opens its existing panel: switching, previewed installs and configuration live there. */ + private openProviderFamily(row: string): void { + this.providersOverview = undefined; + if (row === 'prompt') void this.startPromptSettings(false); + else if (row === 'shell') this.openShellPanel(); + else if (row === 'understanding') this.openUnderstandingPanel(); + else this.startProviderPanel(row as 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation'); + } + + /** Ask's in-memory interaction; discarded on close (only visible turns may be recorded). */ + private askState?: AskState; + /** External agent harness sessions (managed via supported protocols, or observed processes). */ + private readonly agents = new AgentSessions(); + private readonly agentsSubscription = this.agents.onChange((_session, event) => { + if (this.stopped) return; + // An agent asking for approval is an attention event (once per request). + if (event?.kind === 'approval') this.transitions.echo('attention', Date.now()); + this.render(); + }); + /** Long-lived tasks Ask started (dev servers): owned by NMSh, stopped when it exits. */ + private readonly managedTasks = new ManagedTasks(); + /** Portable cursor effects over NMSh's own input (presentation only; Off schedules nothing). */ + private readonly cursorPresenter = new CursorPresenter(() => resolveCursorSettings(this.promptConfiguration.cursor, {...contextFor(this.promptConfiguration), chrome: UI_COLORS.accent}), () => { if (!this.stopped) this.paintPresentation(Date.now()); }); + private caretCause: 'typing' | 'jump' = 'jump'; + /** A paste waiting for Insert / Review / Cancel (presentation and classification only; the text is never changed). */ + private pastePreview?: {text: string; analysis: PasteAnalysis}; + private pasteReview?: PasteReviewState; + /** Short presentation transitions (launch, completion materialization, Block Seal, Semantic Echo, prompt morph). */ + private readonly transitions = new Transitions(() => this.promptConfiguration.motion, + () => ({reducedMotion: !this.decorativeMotionAllowed(), effectsOff: false, color: colorLevel() !== 'none'})); + private transitionClock?: () => void; + private motionPreviewClock?: () => void; + /** The /appearance → Motion preview gate: Effects Off and Reduced Motion stay distinct so the preview can say which applies. */ + private motionPreviewGate(): MotionGate { + const presentation = this.promptConfiguration.presentation; + return {reducedMotion: presentation.reducedMotion || isReducedMotion(), effectsOff: presentation.effectsOff, color: colorLevel() !== 'none'}; + } + /** The semantic prompt modules last shown, for context morph (ids, roles and text only). */ + private lastModules?: Array<{id: string; text: string; role?: string}>; + /** Each visible transcript row's owning block, for Block Seal placement. */ + private visibleBlocks: Array = []; + private cursorHost?: HostCursorFacts; + private cursorBackend(): BackendChoice { + return chooseBackend(this.promptConfiguration.cursor, this.cursorFacts()); + } + private readonly taskStates = new Map(); + private readonly tasksSubscription = this.managedTasks.onChange(() => { + if (this.stopped) return; + // Semantic Echo once per task ending: done, or failed (a task the person stopped ends quietly). + for (const task of this.managedTasks.tasks) { + const previous = this.taskStates.get(task.id); + if (previous !== task.status && previous && previous !== 'stopping' && (task.status === 'completed' || task.status === 'failed')) this.transitions.echo(task.status === 'failed' ? 'taskFailed' : 'taskDone', Date.now()); + this.taskStates.set(task.id, task.status); + } + this.syncTaskClock(); this.render(); + }); + private taskClock?: () => void; + private agentPanel?: AgentPanelState; + private agentView?: AgentViewState; + /** The transient activity shelf above the composer: hidden at rest, revealed by ↓, pinned while something needs attention. */ + private shelf = {visible: false, focused: false, selected: 0, shownAt: 0}; + private agentDiscoveryTimer?: () => void; + private askGeneration = 0; + /** The block Explain failure was chosen on, so Ask explains that one (not just the newest failure). */ + private explainBlock?: number; + /** What Ask is doing right now (factual stage), for its transient live line; never recorded. */ + private askStage?: {label: string; since: number; started: number}; + private askClock?: () => void; + + private setAskStage(label: string | undefined): void { + if (!label) { this.askStage = undefined; this.askClock?.(); this.askClock = undefined; return; } + const started = this.askStage?.started ?? Date.now(); + this.askStage = {label, since: Date.now(), started}; + // A clock only while Ask works; it stops with the stage. + this.askClock ??= presentationClock.subscribe(() => { if (!this.stopped && this.askState?.busy) this.render(); }, 100); + } + + /** The live line under Ask's input: shown only after ~300 ms, so instant answers never flash. */ + private askActivityLine(): string | undefined { + const stage = this.askStage; + if (!stage || !this.askState?.busy) return undefined; + const now = Date.now(); + if (now - stage.started < 300) return undefined; + const still = this.promptConfiguration.presentation.reducedMotion || this.promptConfiguration.presentation.effectsOff || isReducedMotion(); + const model = this.understanding.activeSince ? this.promptConfiguration.localUnderstanding.model?.label : undefined; + return liveLine(model ? `Local understanding · ${model}` : stage.label, undefined, model ? this.understanding.activeSince! : stage.started, now, {still}); + } + + /** `/ask` and `/ask ` open the same Ask; with a request it is submitted at once. */ + private openAsk(request: string, command: '/btw' | '/ask' = '/btw'): void { + this.panelOrigin = undefined; + const parked = this.parkedAsk; + this.parkedAsk = undefined; + this.askGeneration += 1; + // /ask alone returns to a conversation parked by Insert; a new request starts fresh (the parked one is recorded as closed). + if (parked && !request) { parked.pending = undefined; this.askState = parked; return; } + if (parked) this.recordAsk(parked); + this.askState = createAskState(); + this.askState.command = command; + if (!request) { + // Starters from strong facts only (a dirty repository, a recent command); nothing is guessed. + const git = this.context.git; + const recent = this.output.recentShell(1); + this.askState.pending = {kind: 'choose', reason: 'missing', question: ASK_GREETING, options: askStarters({...(this.context.root ? {repoRoot: this.context.root} : {}), + ...(this.context.branch ? {branch: this.context.branch} : {}), dirty: Boolean(git && (git.staged || git.modified || git.untracked)), + recent: recent ? [{command: recent.command, exitCode: recent.exitCode, lines: 0}] : []})}; + } + if (request) { + this.askState.turns.push({role: 'you', text: request}); + this.askState.submitted = true; + this.askState.original = request; + this.askState.busy = true; + void this.handleAskEvent({kind: 'resolve', text: request}); + } + } + + private async handleAskEvent(event: AskEvent): Promise { + const state = this.askState; + if (!state) return; + if (event.kind === 'close') { this.closeAsk(); this.render(); return; } + if (event.kind === 'complete') { + // Paths only after the request's first word ("open pa", "find src/"): the same directory facts the composer completes from. + const before = [...event.text].slice(0, event.caret).join(''); + if (!/\s/u.test(before.trimStart()) && !before.includes('/')) return; + const completion = completePath(event.text, event.caret, this.shellCwd, homedir()); + if (completion) applyAskCompletion(state, completion); + this.render(); + return; + } + if (event.kind === 'resolve') { + const generation = this.askGeneration; + let outcome: AskOutcome; + this.setAskStage('Resolving locally'); + try { outcome = await this.resolveAsk(event.text, state); } catch { + outcome = {kind: 'unclear', text: 'Something went wrong while looking that up.', categories: []}; + } finally { this.setAskStage(undefined); } + if (this.askState !== state || generation !== this.askGeneration || this.stopped) return; + const next = receiveOutcome(state, outcome); + if (next) await this.handleAskEvent(next); + this.render(); + return; + } + if (event.kind === 'copy' || event.kind === 'insert') { + const text = event.block.literal ?? event.block.script ?? renderCommand(event.block, this.shellId); + if (event.kind === 'copy') { + try { await writeClipboard(text); pushTurn(state, 'ask', 'Copied the command. Nothing was run.'); } catch { pushTurn(state, 'ask', 'The clipboard isn\'t available here; Insert puts the command in the composer instead.'); } + this.render(); + return; + } + // Insert: the command waits, unsent, in the shell composer; the conversation is parked and /ask reopens it. + this.parkedAsk = state; + this.askState = undefined; + this.askGeneration += 1; + this.returnFromPanel(); + this.editor.clear(); + this.editor.insert(text); + this.render(); + return; + } + // Actions with a factual result stay inside the conversation; navigation to another surface leaves Ask. + if (event.action.kind === 'git' || event.action.kind === 'recipe' || event.action.kind === 'project' || event.action.kind === 'startTask' || event.action.kind === 'stopTask' + || event.action.kind === 'taskOutput' || event.action.kind === 'openUrl' || event.action.kind === 'read' || event.action.kind === 'setting' || event.action.kind === 'installTool' || event.action.kind === 'applyEdit' || event.action.kind === 'openFile' || event.action.kind === 'format' || event.action.kind === 'brew') { + await this.runInAsk(state, event.action); + this.render(); + return; + } + if (event.action.kind === 'pickFile') { await this.pickFileInAsk(state, event.action.root); this.render(); return; } + if (event.action.kind === 'watch' || event.action.kind === 'watchControl') { + const action = event.action; + this.closeAsk(); + if (action.kind === 'watch') this.handleWatch('/watch', 'start', `${action.intervalMs ? `--every ${action.intervalMs / 1000}s ` : ''}${action.command}`); + else if (action.op === 'show') this.handleWatch('/watch', 'list', ''); + else this.handleWatch('/watch', action.op === 'stopAll' ? 'stop' : action.op, action.op === 'stopAll' ? 'all' : ''); + this.render(); + return; + } + this.closeAsk(); + await this.executeAskAction(event.action); + this.render(); + } + + /** + * "open" with an external picker configured (fzf, Television): the project's + * files in that picker; the chosen file opens in the editor. Native, a + * missing tool or a busy terminal fall back to Ask's own file list. + */ + private async pickFileInAsk(state: AskState, root: string): Promise { + const fallback = () => { receiveOutcome(state, browseOutcome(this.shellCwd, {cwd: this.shellCwd, home: homedir(), ...(this.context.root ? {repoRoot: this.context.root} : {}), + editor: {label: this.hostActions().label, available: true}} as AskContext, {note: 'type to filter'})); }; + if (this.promptConfiguration.picker === 'native' || this.pickerOpening || this.running) { fallback(); return; } + this.pickerOpening = true; + try { + const files = listProjectFiles(root).slice(0, 20_000); + const result = await openPicker(this.promptConfiguration.picker, files.map(path => ({id: path, label: path, value: path})), fallback, this.pickerHandoff, process.env, await this.fzfThemeArgs(), this.fzfLayout()); + if (this.askState !== state || this.stopped) return; + if (result?.kind === 'selected') await this.runInAsk(state, {kind: 'openFile', path: resolvePath(root, result.candidate.value)}); + else if (result?.kind === 'fallback') fallback(); + else pushTurn(state, 'ask', 'Nothing was opened.'); + } finally { this.pickerOpening = false; } + } + + /** The Homebrew facts one package request needs; nothing else is queried. */ + private async gatherBrew(intent: PackageIntent): Promise { + const adapter = homebrewAdapter(); + if (!adapter.executable()) return {available: false}; + const wanted = packageQueries(intent); + const names = [...new Set([...wanted.info, ...wanted.uses, ...wanted.prefix])]; + const [installed, outdated, info, search, uses, prefix] = await Promise.all([ + wanted.installed ? adapter.installed() : undefined, + wanted.outdated ? adapter.outdated() : undefined, + Promise.all(wanted.info.map(async name => [name, await adapter.info(name)] as const)), + Promise.all(wanted.search.map(async term => [term, await adapter.search(term)] as const)), + Promise.all(wanted.uses.map(async name => [name, await adapter.uses(name)] as const)), + Promise.all(wanted.prefix.map(async name => [name, await adapter.prefix(name)] as const)), + ]); + const identity = Object.fromEntries(names.map(name => { const path = resolveCommand(name); return [name, {...(path ? {path} : {}), owner: toolOwner(path)}]; })); + return {available: true, ...(installed ? {installed} : {}), ...(outdated ? {outdated} : {}), info: Object.fromEntries(info), search: Object.fromEntries(search), + uses: Object.fromEntries(uses), prefix: Object.fromEntries(prefix), identity}; + } + + /** Current facts the guide shows next to features ("now: …"): only settings NMSh already holds. */ + private askNmshFacts(): Record { + const config = this.promptConfiguration; + const label = (id: string) => shellAdapter(id as ShellId).label; + return {shell: this.shellId === config.shellBackend ? label(this.shellId) : `${label(this.shellId)} (default ${label(config.shellBackend)})`, + chroma: config.presentation.preset === 'off' ? 'Off' : TREATMENT_PRESET_LABELS[config.presentation.preset], folding: config.outputFolding === 'never' ? 'Off' : config.outputFolding === 'smart' ? 'Smart' : 'Always', + understanding: config.localUnderstanding.mode === 'off' ? 'Off' : config.localUnderstanding.mode === 'auto' ? 'Auto' : 'Always', layout: `${config.composerPosition} · ${config.transcriptPresentation}`, + suggestions: askProviderFacts(config, this.providerStatuses).find(item => item.family === 'suggestions' && item.active)?.label ?? config.suggestions}; + } + + /** A conversation parked by Insert; /ask with no request reopens it. */ + private parkedAsk?: AskState; + + /** + * Run an Ask action without leaving Ask: show what is running, wait for the + * structured result (exit status of the visible command, install outcome, + * applied setting), then add a factual result turn and next steps from + * refreshed facts. Esc remains the only way out. + */ + private async runInAsk(state: AskState, action: AskAction): Promise { + const finish = (text: string, next: AskOption[] = []) => { + state.working = undefined; + state.pending = next.length ? {kind: 'answer', capability: 'help.command', text, next} : undefined; + pushTurn(state, 'ask', text); + state.scroll = 0; + }; + if (action.kind === 'setting') { + await this.executeAskAction(action); + finish(`Done: ${action.label}.`); + return; + } + if (action.kind === 'startTask') { + const project = readProjectFacts(action.cwd); + if (!projectRunAllowed(action.argv, project)) { finish('That script is no longer defined by this project, so nothing was started.'); return; } + const started = this.managedTasks.start(action.label, action.argv, action.cwd); + if ('error' in started) { finish(`${action.label} didn't start: ${started.error}`); return; } + finish(`Started ${action.label} in the background (${action.argv.join(' ')}). The shell stays free; its URL shows above the composer once it prints one.`, + [{key: `task:url:${started.id}`, label: 'What URL is it on?', refine: 'what url is the dev server on'}, {key: `task:out:${started.id}`, label: 'Show its output', outcome: {kind: 'proposal', capability: 'project.task', safety: 'navigate', confidence: 1, direct: true, text: 'Output', action: {kind: 'taskOutput', id: started.id}}}, + {key: `task:stop:${started.id}`, label: 'Stop it', refine: 'stop the dev server'}]); + return; + } + if (action.kind === 'stopTask') { + const task = this.managedTasks.get(action.id); + finish(task && this.managedTasks.stop(action.id) ? `Stopping ${task.label}…` : 'That task isn\'t running.'); + return; + } + if (action.kind === 'taskOutput') { + const task = this.managedTasks.get(action.id); + if (!task) { finish('That task is gone.'); return; } + const tail = task.output.slice(-30); + finish(`${task.label} · ${task.status} · ${task.output.length} line${task.output.length === 1 ? '' : 's'}${tail.length < task.output.length ? ' (last 30)' : ''}\n${tail.map(line => ` ${line}`).join('\n') || ' (no output yet)'}`); + return; + } + if (action.kind === 'openUrl') { + if (!openableUrl(action.url)) { finish('That isn\'t a URL Ask opens.'); return; } + const opener = selectOpener(); + if (!opener) { finish(`No system URL opener is available here. The URL is ${action.url}`); return; } + try { spawn(opener, [action.url], {detached: true, stdio: 'ignore'}).unref(); finish(`Opened ${action.url}.`); } catch { finish(`Couldn't open ${action.url}.`); } + return; + } + if (action.kind === 'openFile') { + await this.openLocation('/btw', action.path, this.shellCwd); + finish(`Opened ${action.path.startsWith(`${homedir()}/`) ? `~${action.path.slice(homedir().length)}` : action.path} in ${this.hostActions().label}.`); + state.referents = {...state.referents, file: action.path}; + return; + } + if (action.kind === 'applyEdit') { + const applied = applyPlan(action.plan); + if (!applied.ok) { finish(applied.reason); return; } + // Verify by reading back: the file must now be exactly what the preview showed. + let verified = false; + try { verified = sha256(readFileSync(action.plan.resolvedPath, 'utf8')) === action.plan.resultSha256; } catch { /* unreadable */ } + state.referents = {...state.referents, file: action.plan.path, block: undefined}; + const open: AskOption = {key: 'edit:open', label: 'Open the file', outcome: {kind: 'proposal', capability: 'file.open', safety: 'navigate', confidence: 0.95, + text: `Opening ${action.plan.path}.`, action: {kind: 'openFile', path: action.plan.path}}}; + let check: string | undefined; + try { check = validateAfterWrite(action.plan, readFileSync(action.plan.resolvedPath, 'utf8'), resolveCommand('python3')); } catch { /* unreadable */ } + finish(verified ? `Updated ${action.plan.path}: ${action.plan.reason}.${check ? ` ${check}` : ''}` : `Wrote ${action.plan.path}, but reading it back did not match the preview; check the file.`, [open]); + return; + } + if (action.kind === 'brew') { + const adapter = homebrewAdapter(); + const brewPath = adapter.executable(); + if (!brewPath || !brewMutationAllowed(action.argv)) { finish('Ask can\'t run that Homebrew command, so nothing was run.'); return; } + state.working = `Running ${action.argv.join(' ')}…`; + this.render(); + const task = new TaskProgress(action.argv.join(' '), () => this.render(), Date.now(), action.name); + const outcome = await task.run(brewPath, action.argv.slice(1)); + if (this.askState !== state) return; + // Verify with Homebrew itself rather than trusting the exit status. + const after = (await adapter.info(action.name)).find(item => item.name === action.name); + const ok = action.expect === 'absent' ? !after?.installed.length : action.expect === 'installed' ? Boolean(after?.installed.length) : Boolean(after?.installed.length && !after.outdated); + if (ok && action.expect === 'installed') { const tool = TOOLS.find(item => item.package === action.name || item.id === action.name); if (tool) recordInstall(tool.id, {label: action.argv.join(' '), command: 'brew', args: action.argv.slice(1)}); } + clearProviderDetection(); + this.commandSources.delete(action.name); + const verb = action.expect === 'installed' ? 'Installed' : action.expect === 'upgraded' ? 'Upgraded' : 'Uninstalled'; + this.transitions.echo(ok ? 'installDone' : 'installFailed', Date.now()); + const next: AskOption[] = ok && action.expect !== 'absent' && after?.kind === 'formula' ? [{key: `cmd:${action.name}`, label: 'Basic command overview', refine: `what is ${action.name}`}, + {key: `syntax:${action.name}`, label: 'Show syntax', refine: `how do i use ${action.name}`}] : []; + finish(ok ? `${verb} ${action.name}${after?.installed.length ? ` ${after.installed.at(-1)}` : ''}.` : `${action.argv.join(' ')} ${outcome.status === 'succeeded' ? 'finished, but Homebrew does not report the expected result' : 'did not succeed'}. Nothing else was changed.`, next); + return; + } + if (action.kind === 'installTool') { + const tool = TOOLS.find(item => item.id === action.tool); + const recipe = tool ? toolInstall(tool) : undefined; + // Only the exact recipe that was shown and confirmed runs. + if (!tool || !recipe || recipe.label !== action.label) { finish('That install is no longer available here, so nothing was run.'); return; } + state.working = `Installing ${tool.label} · ${recipe.label}…`; + this.render(); + const task = new TaskProgress(`Installing ${tool.label}`, () => this.render(), Date.now(), tool.label); + const outcome = await task.run(recipe.command, [...recipe.args]); + if (this.askState !== state) return; + if (outcome.status === 'succeeded') recordInstall(tool.id, recipe); + clearProviderDetection(); + this.commandSources.delete(tool.executable ?? tool.id); + const executable = tool.executable ?? tool.id; + const found = resolveCommand(executable); + if (outcome.status !== 'succeeded' || !found) { finish(`Installing ${tool.label} did not succeed${outcome.status === 'succeeded' ? ' (it is still not found)' : ''}. Nothing else was changed.`); return; } + const next: AskOption[] = [{key: `syntax:${executable}`, label: 'Show syntax and useful options', refine: `how do i use ${executable}`}]; + if (tool.family && selectProvider(this.promptConfiguration, tool.family, tool.id)) next.push({key: `use:${tool.id}`, label: `Use ${tool.label} as the ${tool.family} provider`, refine: `switch ${tool.family} to ${tool.id}`}); + finish(`Installed ${tool.label} at ${found}.`, next); + return; + } + if (action.kind !== 'git' && action.kind !== 'read' && action.kind !== 'format' && action.kind !== 'recipe' && action.kind !== 'project') return; + if (action.kind === 'project' && !projectRunAllowed(action.argv, readProjectFacts(state.repoRoot ?? this.shellCwd))) { finish('That script is no longer defined by this project, so nothing was run.'); return; } + if (action.kind === 'recipe' && recipeRunAllowed(action.argv) !== action.risk) { finish('Ask can\'t run that command, so nothing was run.'); return; } + const argv = action.kind === 'read' ? readArgv(action.command) : action.argv; + if (action.kind === 'git' && gitRunAllowed(action.argv) !== action.risk) { finish('Ask can\'t run that command, so nothing was run.'); return; } + if (action.kind === 'format' && !formatterAllowed(action.argv)) { finish('Ask can\'t run that formatter command, so nothing was run.'); return; } + const command = renderCommand({argv}, this.shellId); + const previous = this.output.recentShell(1)?.startId; + const draft = this.editor.text; + state.working = `Running ${command}… (its output goes to the transcript)`; + this.render(); + // A normal, visible submission: the command and its output follow ordinary transcript and history rules. + this.editor.clear(); + this.editor.insert(command); + await this.submit(false, true); + if (draft) this.editor.insert(draft); + for (let waited = 0; this.askState === state && !this.stopped && waited < 30 * 60_000; waited += 50) { + const latest = this.output.recentShell(1); + if (!this.running && latest && latest.startId !== previous) break; + await new Promise(resolve => setTimeout(resolve, 50)); + } + if (this.askState !== state) return; + const record = this.output.recentShell(1); + const ok = record?.exitCode === 0; + const result = `${ok ? '✓' : '✗'} ${command} ${ok ? 'finished' : `exited with status ${record?.exitCode ?? '?'}`}. Its output is in the transcript.`; + if (argv[0] === 'git') { + // Refresh the repository this conversation is about. + const git = await readGitFacts(state.repoRoot ?? this.shellCwd); + if (this.askState !== state) return; + if (git) { state.referents = {...state.referents, files: undefined}; finish(`${result}\n\n${gitSummary(git)}`, gitNextSteps(git)); return; } + } + finish(result); + } + + /** Close Ask; its visible turns join the transcript only when "Record Ask in transcript" is on. */ + private closeAsk(): void { + const state = this.askState; + this.askState = undefined; + this.askGeneration += 1; + if (!state) return; + this.recordAsk(state); + this.returnFromPanel(); + } + + /** Visible turns join the transcript only when "Record Ask in transcript" is on; referents and outcomes never do. */ + private recordAsk(state: AskState): void { + const recorded = this.promptConfiguration.askRecord ? askTranscriptText(state, this.shellId) : undefined; + if (recorded) this.output.addAskInteraction(recorded.request, recorded.turns, state.command); + } + + /** Command knowledge and identity for Ask: the completion catalog's facts and this shell's names; nothing is run. */ + private askCommands(): CommandEnvironment { + return {reference: commandReference(), identity: name => { + const type = this.semanticService.cache.get(name); + if (type === 'alias' || type === 'function' || type === 'builtin') return {kind: type}; + const path = /^[\w.+-]+$/u.test(name) ? resolveCommand(name) : undefined; + return path ? {kind: 'executable', path} : undefined; + }, ...(resolveCommand('tldr') ? {examples: (path: readonly string[]) => tldrExamples(resolveCommand('tldr'), path)} : {}), install: name => { + // Only a curated /tools entry for this exact executable name; never a guessed package. + const tool = suggestibleToolFor(name); + const recipe = tool ? (planPackageInstall(tool) ?? toolInstall(tool)) : undefined; + return tool && recipe ? {tool: tool.id, label: recipe.label} : undefined; + }}; + } + + /** Deterministic resolution first; an optional local interpretation may refine it (see LocalUnderstanding). */ + private async resolveAsk(text: string, state: AskState): Promise { + const context = await this.askContext(text); + state.repoRoot = context.repoRoot; + const commands = this.askCommands(); + const files = systemFileAssistEnvironment(homedir(), context.repoRoot, resolveCommand('python3'), process.execPath, name => resolveCommand(name), commands.install); + const deterministic = resolveRequest(text, context, {rejected: state.rejected}, commands, files); + const deterministicRoute = () => { this.understanding.lastRoute = {route: 'deterministic', at: Date.now()}; return deterministic; }; + if (!this.understanding.eligible('ask')) return deterministicRoute(); + // Auto: deterministic first; the model only when it is unsure. Always: the model first, still feeding deterministic builders. + const unsure = deterministic.kind === 'unclear' || (deterministic.kind === 'choose' && deterministic.reason === 'ambiguous'); + if (!this.understanding.prefersModel && !unsure) return deterministicRoute(); + const inventory = modelInventory(); + const ids = new Set(inventory.map(item => item.id)); + const facts: Record = {shell: context.shell, defaultShell: context.defaultShell, + ...(context.repoRoot ? {repository: basename(context.repoRoot)} : {}), ...(context.branch ? {branch: context.branch} : {}), + ...(context.project?.kind === 'node' ? {scripts: Object.keys(context.project.scripts).slice(0, 12)} : {}), + ...(context.tasks?.length ? {backgroundTasks: context.tasks.map(task => `${task.label} ${task.status}`)} : {}), + ...(context.worktrees.length > 1 ? {worktrees: context.worktrees.map(item => basename(item.path))} : {}), + ...(context.recentFiles.length ? {recentFiles: context.recentFiles.slice(0, 5).map(path => basename(path))} : {}), + ...(context.transcripts.length ? {transcripts: context.transcripts.slice(0, 5).map(item => `${item.createdAt.slice(0, 16)} ${basename(item.finalCwd)}`)} : {})}; + const request = {text, capabilities: inventory, facts}; + const interpret = (interpretation: Awaited>) => interpretation + ? resolveModelIntent(interpretation, context, {rejected: state.rejected}, commands, files) ?? resolveWithInterpretation(text, interpretation as never, context, {rejected: state.rejected}) + : undefined; + // Non-thinking first: fast and enough for ordinary wording. + let modelled = interpret(await this.understanding.interpretAsk(request, ids, 'fast')); + // A bounded thinking retry only when the request is still genuinely ambiguous to both. + if (!modelled && unsure && this.askState === state) modelled = interpret(await this.understanding.interpretAsk(request, ids, 'thinking')); + // A missing, failed or unsure model keeps the deterministic outcome: model failure is not the user's ambiguity. + if (!modelled) return deterministicRoute(); + this.understanding.lastRoute = {route: 'model', at: Date.now()}; + return modelled; + } + + /** Bounded facts from existing services: no environment, file contents or output beyond these. */ + /** Runtime-only detection of every external provider (cached, bounded); never persisted. */ + private readonly providerStatuses = new Map(); + private async refreshProviderStatuses(): Promise { + const descriptors = PROVIDER_FAMILIES.flatMap(family => family.providers).filter(descriptor => descriptor.kind === 'external'); + const results = await Promise.all(descriptors.map(async descriptor => [descriptor.id, await detectProvider(descriptor)] as const)); + for (const [id, status] of results) this.providerStatuses.set(id, status); + } + + private async askContext(text: string): Promise { + const root = this.context.root; + const worktrees = root ? await gitWorktrees(this.shellCwd, root) : []; + await this.refreshProviderStatuses(); + let sessions: AskContext['sessions'] = []; + if (this.sessionMode === 'service') { + try { + sessions = (await listLiveSessions()).map(session => ({id: session.id, state: session.state, current: session.id === this.sessionId, + cwd: session.cwd, createdAt: session.createdAt, ...(session.shell ? {shell: session.shell} : {}), ...(session.running ? {running: session.running} : {})})); + } catch { /* service unreachable: no live facts */ } + } + let transcripts: AskContext['transcripts'] = []; + try { + transcripts = (await this.transcriptStore.listSummaries()).filter(item => item.id !== this.journal?.id).slice(0, 40) + .map(item => ({id: item.id, createdAt: item.createdAt, startCwd: item.startCwd, finalCwd: item.finalCwd, project: item.project, commandCount: item.commandCount})); + } catch { /* no archives readable */ } + const host = this.hostActions(); + const probe = host.openFile({path: this.shellCwd}); + const brew = resolveCommand('brew'); + const shells = shellAvailability(process.env).map(item => ({id: item.adapter.id, label: item.adapter.label, installed: Boolean(item.executable), + installable: shellInstall(item.adapter.id, brew).kind === 'recipe'})); + const statuses = this.providerStatuses; + const recentFiles = this.recentReferences().map(reference => resolvePath(reference.cwd, reference.path)).filter((path, index, all) => all.indexOf(path) === index).slice(0, 10); + const recentCommands: string[] = []; + const recent: NonNullable = []; + for (let index = 1; index <= 8; index += 1) { + const record = this.output.recentShell(index); + if (!record) break; + recentCommands.push(record.command.slice(0, 80)); + // Facts only: never the output itself. + recent.push({command: record.command.slice(0, 200), ...(record.historicalContext?.cwd ? {cwd: record.historicalContext.cwd} : {}), + ...(record.historicalContext?.branch ? {branch: record.historicalContext.branch} : {}), exitCode: record.exitCode, + ...(record.durationMs !== undefined ? {durationMs: record.durationMs} : {}), lines: Math.max(0, (record.endId ?? record.outputStartId) - record.outputStartId)}); + } + // The project file list is read (names only, bounded) only for requests about opening things. + if (this.askStage) this.setAskStage('Checking repository files'); + const files = /\b(?:open|edit|view|show|list|ls|find|where|locate|file|files|folder|repo|config|json|this|that|it|one|typescript|python|tests?)\b/iu.test(text) ? listProjectFiles(root ?? this.shellCwd) : undefined; + const conversation = this.askState?.referents; + // Homebrew facts only for package requests (bounded, local, auto-update off). + const packageRequest = packageIntent(normalizeRequest(text)); + if (packageRequest && this.askStage) this.setAskStage('Checking Homebrew'); + const brewFacts = packageRequest ? await this.gatherBrew(packageRequest) : undefined; + // Config targets (existence checks only) for requests about config files or edits. + const configs = /\b(?:config(?:uration)?|settings|rc|dotfile|zshrc|bashrc|add|put|insert|append|set|replace|paste|it|that|this)\b/iu.test(text) || conversation?.config || conversation?.file + ? configTargets(systemConfigEnvironment(this.shellId, root ?? this.shellCwd)).map(target => ({...target, exists: Boolean(target.path && existsSync(target.path))})) : undefined; + // Git facts (local status and remote names; no network) only when the request or the conversation is about Git or its files. + const referents = this.askState?.referents; + if (this.askStage && root) this.setAskStage('Checking Git state'); + const git = root && (/\b(?:git|branch|upstream|remotes?|untracked|staged?|unstaged|commit|push|pull|fetch|conflicts?|conflicted|clean|working tree|changes|changed)\b/iu.test(text) || referents?.files) + ? await readGitFacts(root) : undefined; + if (this.askStage) this.setAskStage('Reading project files'); + const projectFacts = readProjectFacts(root ?? this.shellCwd); + const tasks = this.managedTasks.tasks.map(task => ({id: task.id, label: task.label, status: task.status, urls: [...task.urls], startedAt: task.startedAt, lines: task.output.length, command: task.argv.join(' ')})); + // A failure question gets the failed block's bounded, redacted excerpt (the chosen block, else the newest failure). + let failure: AskContext['failure']; + if (WHY_FAILED.test(normalizeRequest(text))) { + const records = this.output.view().completed.filter(record => !record.frontend); + const record = (this.explainBlock !== undefined ? records.find(item => item.startId === this.explainBlock) : undefined) ?? records.find(item => (item.exitCode ?? 0) !== 0); + this.explainBlock = undefined; + if (record) failure = {command: record.command, exitCode: record.exitCode ?? 1, output: record.output.split('\n').slice(-400).join('\n'), startId: record.startId, + cwd: record.historicalContext?.cwd ?? this.shellCwd}; + } + const understanding = this.promptConfiguration.localUnderstanding; + const llm = {mode: understanding.mode, requests: this.understanding.requests, + ...(understanding.model ? {model: {label: understanding.model.label, runtime: understanding.model.runtime, owned: Boolean(understanding.model.owned)}} : {}), + ...(understanding.model ? {state: stateLabel(this.understanding.status, understanding)} : {}), + ...(this.understanding.lastRoute ? {lastRoute: this.understanding.lastRoute.route} : {}), + ...(this.understanding.lastInference ? {lastInference: this.understanding.lastInference.label} : {})}; + return {cwd: this.shellCwd, home: homedir(), platform: process.platform, picker: this.promptConfiguration.picker, llm, ...(failure ? {failure} : {}), ...(projectFacts ? {project: projectFacts} : {}), tasks, ...(root ? {repoRoot: root} : {}), ...(this.context.branch ? {branch: this.context.branch} : {}), + ...(this.context.git ? {dirty: Boolean(this.context.git.staged || this.context.git.modified || this.context.git.untracked)} : {}), + worktrees, shell: this.shellId, defaultShell: this.promptConfiguration.shellBackend, shells, sessions, transcripts, recentFiles, recentCommands, + editor: {label: host.label, available: probe.kind !== 'unsupported', ...(probe.kind === 'unsupported' ? {reason: probe.reason} : {})}, + providers: askProviderFacts(this.promptConfiguration, statuses), sessionMode: this.sessionMode, now: Date.now(), ...(files ? {files} : {}), + ...(git ? {git} : {}), ...(referents ? {referents} : {}), recent, nmsh: this.askNmshFacts(), ...(configs ? {configs} : {}), ...(brewFacts ? {brew: brewFacts} : {})}; + } + + /** Every Ask action is an existing NMSh handler; read-only commands are NMSh-built argv submitted visibly. */ + private async executeAskAction(action: AskAction): Promise { + switch (action.kind) { + case 'slash': await this.runSlash(action.label, action.slash); return; + case 'tmux': { + // Ask's own Yes (which starts on No) confirmed exactly these typed changes. + let model = loadTmuxModel(); + for (const change of action.changes) { const next = applyTmuxChange(model, change); if (!('error' in next)) model = next; } + saveTmuxModel(model); + const written = writeTmuxManaged(model); + this.output.addFrontendInteraction('/btw', written.ok ? `tmux: ${action.label}. Saved in NMSh's managed tmux file${recordedHook('tmux') ? '; /tmux → R reloads a running server' : '; your tmux.conf does not load it yet: /tmux → Review & apply adds the one include after you review it'}.` : written.error, written.ok ? SUCCESS : ERROR); + return; + } + case 'themeBridge': { + await this.saveBridge(bridge => { + if (action.enabled !== undefined) bridge.enabled = action.enabled; + if (action.policy) bridge.policy = action.policy; + for (const [target, setting] of Object.entries(action.targets ?? {})) bridge.targets[target as BridgeTargetId] = {mode: setting.mode, ...(setting.theme ? {theme: setting.theme} : {})}; + }); + this.output.addFrontendInteraction('/btw', `Theme Bridge · ${action.label}. Tools that need a one-time include or cache build show it in /integrations.`, SUCCESS); + return; + } + case 'switchShell': await this.switchShell(action.shell, `/shell ${action.shell}`); return; + case 'installShell': { + this.openShellPanel(action.shell); + const recipe = shellInstall(action.shell, resolveCommand('brew')); + // Ask's own Yes (which starts on No) was the confirmation of this exact recipe. + if (recipe.kind === 'recipe') await this.installShell(action.shell, recipe); + return; + } + case 'openFile': await this.openLocation('/btw', action.path, this.shellCwd); return; + case 'read': { + const argv = readArgv(action.command); + const quote = this.shellId === 'fish' ? fishQuote : posixQuote; + // A normal, visible submission: the command and its output follow ordinary transcript and history rules. + this.editor.clear(); + this.editor.insert(argv.map(part => /^[\w./=-]+$/u.test(part) ? part : quote(part)).join(' ')); + await this.submit(false, true); + return; + } + case 'git': { + // Re-checked here: only allowlisted, non-destructive Git argv that NMSh built from facts ever runs. + if (gitRunAllowed(action.argv) !== action.risk) return; + const quote = this.shellId === 'fish' ? fishQuote : posixQuote; + this.editor.clear(); + this.editor.insert(action.argv.map(part => /^[\w@%+=:,./-]+$/u.test(part) ? part : quote(part)).join(' ')); + await this.submit(false, true); + return; + } + case 'resumeTranscript': await this.restoreTranscriptById(action.id); return; + case 'attachSession': this.switchToLiveSession(action.id, 'detached'); return; + case 'toolView': { + const tool = TOOLS.find(item => item.id === action.tool); + if (!tool) return; + this.startTools(); + const panel = this.toolsPanel!; + panel.detail = tool; + panel.statuses[tool.id] = await detectTool(tool); + if (action.view === 'guided') panel.framework = openGuidedInstall(); + else if (action.view === 'previous') panel.framework = openPrevious(); + else if (action.view === 'p10kConfigure' || action.view === 'importAppearance') await this.handleToolsKey({kind: 'text', value: action.view === 'p10kConfigure' ? 'c' : 't'} as Key, panel); + return; + } + case 'setting': { + if (action.setting === 'shellBackend') { + if (isShellId(action.value)) this.updateConfiguration(configuration => { configuration.shellBackend = action.value as ShellId; }); + } else if (action.setting === 'composerDividers') { + this.updateConfiguration(configuration => { configuration.composerDividers = action.value === 'on'; }); + } else if (action.setting === 'localUnderstanding') { + const mode = action.value as PromptConfiguration['localUnderstanding']['mode']; + if (['off', 'auto', 'always'].includes(mode)) this.updateConfiguration(configuration => { configuration.localUnderstanding = {...configuration.localUnderstanding, mode}; }); + } else { + const next = selectProvider(this.promptConfiguration, action.setting, action.value); + if (next) this.applySettingsConfiguration(next); + } + this.output.addFrontendInteraction('/settings', `${action.label}.`, INFO); + return; + } } - this.render(); } - private renderedPromptPanel(columns: number): string[] { - if (!this.promptPanelState) return []; - const preview = this.promptPanelState.step.startsWith('install') ? [] : this.promptPanelPreview(columns); - const full = renderPromptPanel(this.promptPanelState, columns, preview, this.promptThemePreviews(columns), this.dimensions().rows - 1, - this.promptGitShowcase(columns)); - // Short terminals keep the editable rows and live preview; the theme gallery goes first. - return full.length <= this.dimensions().rows - 3 ? full : renderPromptPanel(this.promptPanelState, columns, preview, [], this.dimensions().rows - 1); + /** The composer is completely idle: ← and ↓ shortcuts apply only here, never over editing, menus, panels or a running command. */ + private composerIdle(): boolean { + return !this.editor.text && !this.running && !this.historySearchActive && !this.directorySearchActive && this.composerSuggestions().length === 0 + && !this.passthrough && !this.externalPassthrough && !this.settingsPanelActive && !this.editor.hasPasteAtoms; } - /** Rich Git view rows: the draft's colors and geometry over synthetic states; never runs Git. */ - private promptGitShowcase(columns: number): string[] { - const state = this.promptPanelState; - if (!state || state.step !== 'appearance' || state.view !== 'git') return []; - const width = Math.max(1, columns - 15); - // Disabled Rich Git still shows what it would add, dimmed by the panel. - const draft = {...state.draft, nmsh: {...state.draft.nmsh, gitEnabled: true}}; - return RICH_GIT_SHOWCASE.map(entry => buildRichGitShowcaseLine(draft, entry.git, width)); + private revealShelf(): void { + if (this.shelf.visible) { this.shelf.focused = true; this.shelf.selected = 0; return; } + this.shelf.visible = true; + this.shelf.shownAt = Date.now(); + // Auto-hide after a short idle period unless something needs attention (checked on the presentation clock). + setTimeout(() => { if (!this.stopped) this.render(); }, SHELF_IDLE_MS + 50).unref?.(); } - /** One preview row per theme: the draft's geometry over synthetic preview-only modules. */ - private promptThemePreviews(columns: number): string[] { - const state = this.promptPanelState; - if (!state || state.step !== 'appearance' || state.view === 'git') return []; - const width = Math.max(1, columns - 22); - return NATIVE_PALETTE_IDS.map(palette => buildThemePreviewLine(state.draft, palette, width)); + private handleShelfKey(key: Key): boolean { + const items = shelfOrder(this.agents.sessions); + if (!items.length) { this.shelf.focused = false; return false; } + if (key.kind === 'left' || key.kind === 'right') this.shelf.selected = (this.shelf.selected + (key.kind === 'left' ? -1 : 1) + items.length) % items.length; + else if (key.kind === 'enter') { const session = items[this.shelf.selected]; this.shelf.focused = false; if (session) this.openAgentView(session.id); } + else if (key.kind === 'up' || key.kind === 'escape') { this.shelf.focused = false; this.shelf.shownAt = Date.now(); } + else return false; + this.render(); + return true; } - private externalPanelStatusText(state: PromptPanelState, provider: PromptProviderId, width: number): string { - const installed = provider === 'starship' ? state.starshipStatus?.installed : state.p10kStatus?.installed; - if (installed) return truncateText(state.message ?? `${providerLabel(provider)} preview is unavailable.`, width); - return truncateText(`${providerLabel(provider)} is not installed; see the options above.`, width); + private syncAgents(): void { + const wanted = this.presentationStarted && !this.stopped && !this.passthrough && !this.externalPassthrough && !this.frontendSuspended; + if (wanted && !this.agentDiscoveryTimer) { + // Bounded, asynchronous discovery on a modest cadence, only while NMSh owns the screen; never at startup. + this.agentDiscoveryTimer = presentationClock.subscribe(() => void this.agents.discover(), AGENT_DISCOVERY_MS); + setTimeout(() => { if (!this.stopped) void this.agents.discover(); }, 3000).unref?.(); + } else if (!wanted && this.agentDiscoveryTimer) { this.agentDiscoveryTimer(); this.agentDiscoveryTimer = undefined; } + const attention = this.agents.sessions.some(session => session.attention); + if (this.shelf.visible && !this.shelf.focused && !attention && Date.now() - this.shelf.shownAt > SHELF_IDLE_MS) this.shelf.visible = false; } - - private hasVisibleProviderPrompt(): boolean { - if (this.effectivePromptProvider !== 'nmsh') return Boolean(this.externalPrompt?.text.trim()); - return hasVisibleContextModule(this.promptConfiguration, this.promptContext(), isOnCommandRelevant); + private agentPanelRows() { + return agentPanelRows(this.agents.sessions, this.agents.harnesses()); } - private currentPromptLine(width: number): string { - if (this.effectivePromptProvider !== 'nmsh' && this.externalPrompt) { - return this.externalPromptRow(this.externalPrompt, width, this.promptConfiguration.placement); + /** /ai: the agent session list; /ai : start a managed session in the background. */ + private openAi(command: string, target?: string): void { + this.panelOrigin = undefined; + if (!target) { this.agentPanel = {selected: 0}; void this.agents.discover(); return; } + const profile = this.promptConfiguration.agentProfiles.find(item => item.name === target); + const harnessId = profile?.harness ?? target; + if (!harness(harnessId)) { + this.output.addFrontendInteraction(command, `No harness or launch profile is called "${target}". /ai lists the harnesses; profiles live in NMSh's config as agentProfiles (name, harness, and for Claude: model, permissionMode, configDir).`, INFO); + return; } - return buildContextLine(this.promptContext(), width, this.promptConfiguration); + const result = this.agents.launch(harnessId, this.shellCwd, profile ? {profile} : {}); + if (!result.ok) { this.output.addFrontendInteraction(command, result.reason, ERROR); return; } + // The agent runs in the background; its view opens so the first message can be typed. Esc returns to the shell. + this.openAgentView(result.session.id); } - /** - * Blink the welcome cat occasionally. The frame lives on OutputBuffer as - * presentation state, so transcript contents, row count, and width never - * 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.output.hasWelcome || this.passthrough) { - this.welcomeBlinkCount += 1; - this.scheduleWelcomeBlink(); - return; - } - this.output.setWelcomeFrame('blink'); - this.render(); - this.welcomeBlinkTimer = setTimeout(() => { - this.output.setWelcomeFrame('open'); - if (this.stopped) return; - this.render(); - this.welcomeBlinkCount += 1; - this.scheduleWelcomeBlink(); - }, WELCOME_BLINK_CLOSED_MS); - }, welcomeBlinkDelay(this.welcomeBlinkCount)); + private openAgentView(id: string): void { + this.agentPanel = undefined; + this.resumeBrowser = undefined; + this.agentView = {sessionId: id, input: '', expanded: new Set(), scroll: 0}; + this.agents.acknowledge(id); + this.shelf.visible = false; } - /** External provider content follows the native placement rule: the divider fill only in header placement. */ - private externalPromptRow(prompt: StarshipPromptResult, width: number, placement: PromptConfiguration['placement']): string { - const content = truncateAnsi(prompt.ansi, Math.max(0, width - 1)); - if (placement === 'composer') return `${content}${RESET}`; - return `${content}${RESET}${SEPARATOR}${repeatToWidth(GLYPHS.separator, Math.max(0, width - displayWidth(content)))}${RESET}`; + private handleAgentPanelKey(key: Key): void { + const panel = this.agentPanel!; + const rows = this.agentPanelRows(); + if (panel.rename !== undefined) { + const row = rows[panel.selected]; + if (key.kind === 'escape' || key.kind === 'interrupt') panel.rename = undefined; + else if (key.kind === 'enter') { if (row?.kind === 'session') this.agents.rename(row.session.id, panel.rename); panel.rename = undefined; } + else if (key.kind === 'backspace') panel.rename = [...panel.rename].slice(0, -1).join(''); + else if (key.kind === 'text') panel.rename += key.value; + this.render(); + return; + } + if (key.kind === 'escape' || key.kind === 'interrupt') { this.agentPanel = undefined; this.returnFromPanel(); } + else if (key.kind === 'up' || key.kind === 'down') panel.selected = (panel.selected + (key.kind === 'up' ? -1 : 1) + rows.length) % Math.max(1, rows.length); + else if (key.kind === 'text' && /^[rR]$/u.test(key.value) && rows[panel.selected]?.kind === 'session') panel.rename = (rows[panel.selected] as {session: AgentSession}).session.title; + else if (key.kind === 'text' && /^[aA]$/u.test(key.value)) { this.agentPanel = undefined; void this.runSlash('/agents', {kind: 'agents', action: 'show'}); } + else if (key.kind === 'enter') { + const row = rows[panel.selected]; + if (row?.kind === 'session') this.openAgentView(row.session.id); + else if (row?.kind === 'harness') { + const result = this.agents.launch(row.harness.id, this.shellCwd); + if (result.ok) this.openAgentView(result.session.id); else panel.message = result.reason; + } + } + this.render(); } - private scroll(direction: -1 | 1): void { - const {columns} = this.dimensions(); - const outputHeight = this.transcriptViewportHeight(); - const total = this.output.wrapped(columns).length; - this.historyViewport.resolve(total, outputHeight); - this.historyViewport.page(total, outputHeight, direction); + private handleAgentViewKey(key: Key): void { + const view = this.agentView!; + const session = this.agents.get(view.sessionId); + if (!session || key.kind === 'escape') { this.agentView = undefined; this.returnFromPanel(); this.render(); return; } + view.message = undefined; + // Approvals are explicit: A allows once, D denies; nothing else answers them. + if (session.pendingApproval && !view.input && key.kind === 'text' && /^[aAdD]$/u.test(key.value)) { + this.agents.answer(session.id, /^[aA]$/u.test(key.value)); + } else if (key.kind === 'interrupt') { + if (session.state === 'working' || session.state === 'approval') this.agents.cancel(session.id); else { this.agentView = undefined; this.returnFromPanel(); } + } else if (key.kind === 'toggleDetails') { + const tools = agentBlocks(session).filter(block => block.kind === 'tool' && block.detail); + const last = tools.at(-1); + if (last?.id) { if (view.expanded.has(last.id)) view.expanded.delete(last.id); else view.expanded.add(last.id); } + } else if (key.kind === 'pageUp' || key.kind === 'wheelUp') view.scroll += key.kind === 'pageUp' ? 10 : 3; + else if (key.kind === 'pageDown' || key.kind === 'wheelDown') view.scroll = Math.max(0, view.scroll - (key.kind === 'pageDown' ? 10 : 3)); + else if (session.level === 'observed') { /* metadata only: no input */ } + else if (key.kind === 'text' || key.kind === 'paste') view.input += key.value.replace(/[\u0000-\u0009\u000b-\u001f\u007f]/gu, ''); + else if (key.kind === 'newline') view.input += '\n'; + else if (key.kind === 'backspace') view.input = [...view.input].slice(0, -1).join(''); + else if (key.kind === 'enter' && view.input.trim()) { + const text = view.input; + view.input = ''; + const copy = /^\/copy(?:\s+(\d+))?\s*$/u.exec(text.trim()); + if (copy) void this.copyAgentBlock(session, Number(copy[1] ?? 1)); + else if (!this.agents.send(session.id, text)) view.message = 'This session is not accepting input.'; + view.scroll = 0; + } + this.render(); } - private scrollLines(amount: number): void { - const {columns} = this.dimensions(); - const outputHeight = this.transcriptViewportHeight(); - const total = this.output.wrapped(columns).length; - this.historyViewport.resolve(total, outputHeight); - this.historyViewport.scrollLines(total, outputHeight, amount); + /** /copy inside an agent view: the Nth newest reply's visible text, never protocol data. */ + private async copyAgentBlock(session: AgentSession, index: number): Promise { + const replies = agentBlocks(session).filter(block => block.kind === 'assistant'); + const block = replies[replies.length - index]; + if (!block) { if (this.agentView) this.agentView.message = 'Nothing to copy yet.'; this.render(); return; } + try { await writeClipboard(block.text); if (this.agentView) this.agentView.message = 'Copied the reply.'; } catch { if (this.agentView) this.agentView.message = 'The clipboard is not available here.'; } + this.render(); } - /** Scroll paging needs at least one row even when the plan leaves the transcript empty. */ - private transcriptViewportHeight(): number { - const {columns, rows} = this.dimensions(); - return this.planFrame(columns, rows).viewportRows; + private openShellPanel(select?: ShellId): void { + this.panelOrigin = undefined; + this.shellPanel = createShellPanel(shellAvailability(process.env, true), this.shellId, this.promptConfiguration.shellBackend, this.switchBlocker(), this.promptConfiguration); + this.shellPanel.installFor = shell => shellInstall(shell, resolveCommand('brew')); + if (select) this.shellPanel.selected = Math.max(0, this.shellPanel.shells.findIndex(item => item.adapter.id === select)); } - private formatCommandAnsi(command: string, startId: number | null, sgr = this.syntaxSgr): string[] { - const inputChars = graphemes(command); - const tokens = this.highlighter.tokenize(inputChars, this.semanticService.cache); - const charColors = syntaxCharStyles(tokens, inputChars.length, sgr); - - for (const token of tokens) { - if (token.type === 'Command' && startId !== null) { - // Resolution re-renders with the style captured at submission, so - // later syntax setting changes never recolor this history entry. - void this.semanticService.classifyCommand(token.text).then(() => { - const newFormatted = this.formatCommandAnsi(command, null, sgr); - this.output.updateCommandHighlight(startId, newFormatted); - this.render(); - }); - } - } + /** Runs an explicitly confirmed `brew install ` (argv, no sudo), then refreshes what is available. */ + private async installShell(shell: ShellId, install: {command: string; args: string[]; label: string}): Promise { + const panel = this.shellPanel; + if (!panel) return; + panel.installing = `Running ${install.label}…`; + this.render(); + const task = new TaskProgress(`Installing ${shellAdapter(shell).label}`, () => this.render(), Date.now(), shellAdapter(shell).label); + const outcome = await task.run(install.command, [...install.args]); + if (this.stopped || this.shellPanel !== panel) return; + panel.installing = undefined; + clearProviderDetection(); + panel.shells = shellAvailability(process.env, true); + const found = panel.shells.find(item => item.adapter.id === shell); + panel.message = outcome.status === 'succeeded' && found?.executable + ? `${shellAdapter(shell).label} is installed: ${found.version ?? found.executable}. Enter switches this session to it.` + : `${shellAdapter(shell).label} was not installed. ${outcome.status === 'succeeded' ? `${install.label} finished, but no usable ${shell} is on PATH.` : task.state.error ?? ''}`.trim(); + this.render(); + } - const lines: string[] = []; - let currentLine = ''; - let isFirstLine = true; - let currentColor = ''; + /** A factual reason the session cannot switch shells right now, checked before asking the session. */ + private switchBlocker(): string | undefined { + if (!this.session.features.has('shell-switch')) return OLDER_SERVICE_SWITCH; + if (this.running) return `"${this.running.command.slice(0, 60)}" is still running; switching would end it. Finish or interrupt it first.`; + if (this.passthrough) return 'A full-screen program owns the terminal; switching would end it.'; + if (this.startupPending || this.switchedShellStarting) return 'The shell is still starting; switch once it is ready.'; + return undefined; + } - const pushLine = () => { - lines.push(`${currentLine}${RESET}`); - }; + /** + * Replace this session's shell backend in place. The frontend, transcript, + * draft, session identity, settings and NMSh history stay; shell-specific + * services (classification, completion, shell history import) are rebound. + */ + private async switchShell(target: ShellId, command: string): Promise { + const adapter = shellAdapter(target); + const blocker = this.switchBlocker(); + const refuse = (message: string) => { this.output.addFrontendInteraction(command, message, ERROR); this.render(); }; + if (target === this.shellId) { this.output.addFrontendInteraction(command, `This session already runs ${adapter.label}.`, INFO); this.render(); return; } + // The session's own limits (an older service, a running command) come first: they hold whatever shell is chosen. + if (blocker) return refuse(blocker); + const unavailable = adapter.unavailableReason(process.env); + if (unavailable) return refuse(unavailable); + if (this.shellSwitching) return refuse('A shell switch is already in progress.'); + this.shellSwitching = true; + const from = shellAdapter(this.shellId).label; + try { + await this.session.switchShell(target, this.shellCwd); + } catch (error) { + refuse(`Did not switch to ${adapter.label}: ${error instanceof Error ? error.message : String(error)}`); + return; + } finally { this.shellSwitching = false; } + this.shellId = target; + this.shellJobs = 0; + // The old shell's zone ends here; the new shell's first prompt opens the next one. + this.hostSemantics.end(); + this.switchedShellStarting = true; + // The new shell's first prompt is readiness, not a command completion. + this.presetShellReady = true; + this.bindShellServices(target, true); + // The old presentation ends with the transition and is archived (see /resume); the new backend gets a fresh welcome. + this.output.addFrontendInteraction(command, `Switched this session from ${from} to ${adapter.label}.`, INFO); + const fresh = await this.startFreshPresentation(command); + this.output.addFrontendInteraction(command, `${fresh ? `Same session, now ${adapter.label}, in ${this.shellCwd}; the ${from} view is in /resume. ` : ''}` + + `${from} aliases, functions, variables and jobs stayed with ${from}; NMSh history and settings carry over.` + + (target === this.promptConfiguration.shellBackend ? '' : ` New sessions still start ${shellAdapter(this.promptConfiguration.shellBackend).label} (/shell, D to change).`), INFO); + this.render(); + } - for (let i = 0; i < inputChars.length; i++) { - const char = inputChars[i]; - if (char === '\n') { - pushLine(); - currentLine = ''; - isFirstLine = false; - currentColor = ''; - continue; - } - if (currentLine === '') { - const prefix = isFirstLine ? `${GLYPHS.prompt} ` : ' '; - currentLine += `${foreground(UI_COLORS.command)}${prefix}`; - currentColor = foreground(UI_COLORS.command); - } - const color = charColors[i] ?? PRIMARY; - if (color !== currentColor) { - currentLine += `${RESET}${color}`; - currentColor = color; - } - currentLine += char; + /** Rebind everything that depends on the shell backend; NMSh-owned state is untouched. */ + private bindShellServices(target: ShellId, reload: boolean): void { + const adapter = shellAdapter(target); + if (reload) { + this.semanticService.kill(); + this.semanticService = target === 'zsh' ? new SemanticService(this.shellCwd) : new PathClassifier(adapter); } - pushLine(); - return lines; + this.completionService.dispose(); + this.completionService = target === 'zsh' ? new CompletionService() : new CompletionService(defaultCompletionSources(adapter.completionSource())); + this.commandUsageVersion = -1; + this.commandSources.clear(); + this.shellSuggestions = []; + this.historyService.shellHistory = target === 'zsh' ? undefined + : {id: target, file: adapter.historyFile(process.env, homedir()), parse: content => adapter.parseHistory(content)}; + if (reload) void this.loadHistory(); } - /** Interactive history search is the editor state `/history `; it never reaches zsh or the transcript. */ - private get historySearchActive(): boolean { - return !this.editor.hasPasteAtoms && this.editor.text.startsWith(HISTORY_SEARCH); + /** Apply one small settings change and persist it over a fresh read (other windows' edits survive). */ + private updateConfiguration(change: (configuration: PromptConfiguration) => void): void { + const next = structuredClone(this.promptConfiguration); + change(next); + try { savePromptConfiguration(next, undefined, this.promptConfiguration); } catch { /* applies to this window */ } + this.promptConfiguration = next; } - private historyMatches(query: string): Array<{name: string; insertion: string; description: string}> { - const q = query.toLowerCase(); - return this.historyService.getAll().filter(h => h.toLowerCase().includes(q)) - .slice(0, 100).map(m => ({name: m, insertion: m, description: 'History'})); + private runAgentsCommand(command: string, action: 'show' | 'on' | 'off' | 'reset'): void { + if (action === 'on' || action === 'off') { + this.updateConfiguration(configuration => { configuration.agentActivity = action === 'on'; }); + this.output.addFrontendInteraction(command, action === 'on' ? 'Agent activity recording is On (local only).' : 'Agent activity recording is Off; existing data is kept until /agents reset.', INFO); + return; + } + if (action === 'reset') { + try { this.agentActivity.reset(); this.output.addFrontendInteraction(command, 'Deleted all local agent activity data.', INFO); } + catch (error) { this.output.addFrontendInteraction(command, `Could not delete agent activity data: ${error instanceof Error ? error.message : String(error)}`, ERROR); } + return; + } + const data = this.agentActivity.load(); + this.output.addFrontendBlock(command, renderAgentStats(data, {now: Date.now(), columns: Math.max(20, this.dimensions().columns - 2), + enabled: this.promptConfiguration.agentActivity, loadState: this.agentActivity.state})); } - /** 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.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(); - if (alternatives.items.length > 0) return alternatives.items.map(item => ({name: item.text, description: ''})); - return this.shellSuggestions; + private async runNoticesCommand(command: string, action: 'show' | 'on' | 'off' | 'clear'): Promise { + if (action === 'on' || action === 'off') { + this.updateConfiguration(configuration => { configuration.sessionNotices = action === 'on'; }); + this.output.addFrontendInteraction(command, `Session notices are ${action === 'on' ? 'On' : 'Off'}.`, INFO); + } else if (action === 'clear') { + await this.clearNotices(); + this.output.addFrontendInteraction(command, 'Cleared session notices.', INFO); + } else { + const state = this.promptConfiguration.sessionNotices ? 'On' : 'Off'; + const mode = this.sessionMode === 'service' ? '' : ' They need the live-session service; this window runs its shell in-process.'; + this.output.addFrontendInteraction(command, `Session notices are ${state}: other sessions' finished, failed, attention and ended states show above the composer.${mode}`, INFO); + } + this.syncNotices(); } - /** - * 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 planFrame( - columns: number, - rows: number, - fullInput = this.layoutEditorInput(columns), - suggestions = this.composerSuggestions().length, - panelRows = this.settingsPanelActive ? this.settingsPanelRows(columns).length : undefined, - ): ScreenPlan { + private planComposer(columns: number, rows: number, fullInput: ReturnType, suggestions: number, + panelRows: number | undefined): ScreenPlan { const transcriptRows = this.output.wrapped(columns).length; const input = { rows, inputRows: fullInput.allRows.length, suggestions, + inspectorRows: this.inspectorRows(columns).length, running: Boolean(this.running), detached: this.historyViewport.detached, hasOutput: transcriptRows > 0, composerPosition: this.promptConfiguration.composerPosition, + panelPosition: this.panelPositionInEffect(), transcriptRows, contextPlacement: this.promptConfiguration.placement, hasVisibleContext: this.hasVisibleProviderPrompt(), - composerLayout: this.promptConfiguration.composerLayout, + // Prompt None is only the input: framed like the one-line composer (divider, input, divider), never a two-line gap. + composerLayout: this.effectivePromptProvider === 'none' ? 'oneLine' : this.promptConfiguration.composerLayout, + composerDividers: this.promptConfiguration.composerDividers, panelRows, + railPresentation: this.preparedRail(columns).presentation, }; if (input.composerPosition !== 'flow' || !input.detached || panelRows !== undefined) return planScreen(input); // Flow scrolled back: where the view starts decides how much of the composer @@ -2581,20 +7769,33 @@ 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; } + this.hostSemantics.flush(); + if (this.idle) { this.paintIdle(); return; } + for (const task of [this.promptPanelState?.task, this.toolsPanel?.task, this.providerPanelState?.task]) task?.setReducedMotion(!this.decorativeMotionAllowed()); + if (!this.decorativeMotionAllowed()) this.effects.cancel(); + this.syncPanelAnimation(); + if (this.pendingMilestone && !this.settingsPanelActive && !this.running) this.milestoneEffect(); void this.fetchSuggestions(); const {columns, rows} = this.dimensions(); const availableSuggestions = this.composerSuggestions(); const panelRows = this.settingsPanelActive ? this.settingsPanelRows(columns) : undefined; + this.noteSelection(panelRows); + this.renderRail = undefined; + this.renderRail = {columns, prepared: this.preparedRail(columns)}; const promptLine = this.currentPromptLine(columns); this.editor.ghost = this.suggestionGhost(); const fullInput = this.layoutEditorInput(columns); - const plan = this.planFrame(columns, rows, fullInput, availableSuggestions.length, panelRows?.length); + const completionMenu = availableSuggestions === this.shellSuggestions && availableSuggestions.length > 0; + const plan = this.planFrame(columns, rows, fullInput, completionMenu ? completionMenuRows(availableSuggestions.length) : availableSuggestions.length, panelRows?.length); const input = plan.panelActive ? {...fullInput, rows: [], caretRow: 0, caretColumn: 0} : this.layoutEditorInput(columns, plan.inputHeight); const effectiveSelection = Math.max(0, Math.min(availableSuggestions.length - 1, this.selectedSuggestion)); - const suggestionView = suggestionWindow(availableSuggestions, effectiveSelection, plan.suggestionCount); + // A bounded completion viewport: when candidates overflow it, the last row says how many follow. + const menuOverflow = completionMenu && plan.suggestionCount > 1 && availableSuggestions.length > plan.suggestionCount; + const suggestionView = suggestionWindow(availableSuggestions, effectiveSelection, menuOverflow ? plan.suggestionCount - 1 : plan.suggestionCount); + const hiddenBelow = menuOverflow ? availableSuggestions.length - suggestionView.start - suggestionView.items.length : 0; if (this.lastPtyRows !== plan.ptyRows || this.lastPtyColumns !== columns) { this.lastPtyRows = plan.ptyRows; this.lastPtyColumns = columns; @@ -2602,6 +7803,11 @@ export class TerminalApp { } const wrapped = this.output.wrapped(columns); + if (this.findState) { + const before = this.findState.active; + refreshFind(this.findState, wrapped, this.findGeneration(wrapped, columns)); + if (this.findState.active !== before) this.revealFindMatch(wrapped.length, plan.viewportRows); + } const viewStart = this.historyViewport.resolve(wrapped.length, plan.viewportRows); // Flow's viewport scrolls by its capacity; the region shows only what is on screen. const outputHeight = plan.transcript.height; @@ -2609,8 +7815,22 @@ 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)); + // Matching lines show every clause's spans; the active result is marked more strongly. Presentation only. + const findSpans = new Map; active: boolean}>(); + if (this.findState) this.findState.results.forEach((result, index) => { + for (const [row, spans] of result.spans) findSpans.set(row, {spans, active: index === this.findState!.active}); + }); + this.visibleBlocks = wrapped.slice(viewStart, viewStart + outputHeight).map(row => row.blockStartId); + const visible = wrapped.slice(viewStart, viewStart + outputHeight).map((row, offset) => { + if (isRowSelected(this.selection, viewStart + offset)) return `${background(UI_COLORS.selection)}${PRIMARY}${row.plain}${RESET}`; + const marked = findSpans.get(viewStart + offset); + if (marked) return markSpans(row.plain, marked.spans, `${background(UI_COLORS.selection)}${PRIMARY}`, marked.active ? `${PRIMARY}` : SECONDARY, + marked.active ? '' : '\u001b[4m'); + 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; @@ -2624,7 +7844,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(); + }); } } @@ -2649,37 +7874,54 @@ export class TerminalApp { if (this.editor.ghost && !this.editor.hasPasteAtoms && this.editor.cursorIndex === graphemes(this.editor.text).length && row === input.rows[input.rows.length - 1]) { suffix = `${SECONDARY}${this.editor.ghost.substring(this.editor.text.length)}${RESET}`; } - const line = truncateAnsi(`${prefix}${textStyled}${suffix}`, columns); - return row.charStart === 0 && row === input.allRows[0] ? `${line}${this.oneLineRightContext(line, columns)}` : line; + const editorColumns = this.inputColumns(columns); + const line = truncateAnsi(`${prefix}${textStyled}${suffix}`, editorColumns); + if (row.charStart !== 0 || row !== input.allRows[0]) return line; + // Input row placement: the reserved trailing cells, outside the editor's own width. + const accessory = plan.awake?.slot === 'inputTrailing' && editorColumns < columns ? this.awakeViewNow()?.compact : undefined; + if (accessory) return `${line}${RESET}${' '.repeat(Math.max(0, columns - displayWidth(line) - accessory.width))}${accessory.ansi}`; + return `${line}${this.oneLineRightContext(line, columns)}`; }); // The plan decides where each region lives; this only decides what paints into it. - const separator = `${SEPARATOR}${repeatToWidth(GLYPHS.separator, columns)}${RESET}`; + // Composer top and bottom edges share one renderer (composerEdgeRow); the prompt row's divider fill uses the same rule source. + const now = Date.now(); const regionRows = (region: Region): string[] => { switch (region.kind) { - case 'transcript': return visible; + // Setup Cat owns a clean screen: the ordinary transcript/welcome is not drawn behind it (presentation only; nothing is cleared). + case 'transcript': return this.setupState ? [] : visible; case 'gap': return []; case 'jump': return [this.jumpAffordance(columns)]; - // 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] ?? '')) + // The frame line sits on the panel edge that faces the transcript: the bottom edge for a Top panel. + case 'panel': return plan.panelPosition === 'top' && panelRows && /^[─-]+$/u.test(stripAnsi(panelRows[0] ?? '')) ? [...panelRows.slice(1), panelRows[0]!] : panelRows ?? []; - case 'suggestions': return suggestionView.items.map((suggestion, visibleIndex) => { + case 'inspector': return this.inspectorRows(columns); + case 'suggestions': return this.orientPicker([...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, this.completionDescription(suggestion)); + } return truncateAnsi( `${selected ? ACCENT : SECONDARY}${selected ? '›' : ' '} ${suggestion.name.padEnd(10)}${RESET}${SECONDARY} ${suggestion.description}${RESET}`, columns, ); - }); + }), ...(menuOverflow ? [renderCompletionMore(hiddenBelow, suggestionView.start, columns)] : [])]); // The spacer sits between the newest output and the activity line in both positions. case 'activity': { if (!this.running) return []; const activity = truncateAnsi(this.currentActivity(), columns); return plan.composerPosition === 'top' ? ['', activity] : [activity, '']; } - case 'composerBorder': return [separator]; + case 'composerBorder': return [this.composerEdgeRow('composerBorder', plan, columns, now)]; case 'prompt': return [promptLine]; case 'input': return inputRows; - case 'separator': return [separator]; + case 'separator': return [this.composerEdgeRow('separator', plan, columns, now)]; + case 'contextRail': case 'railGap': case 'railEdge': return []; + case 'awake': return this.awakeRows(plan, columns); + case 'status': return [this.statusStripRow(columns)]; + case 'notices': return this.noticeRows(columns); + case 'find': return this.searchChrome(columns); } }; const frameRows = new Array(plan.rows).fill(''); @@ -2688,23 +7930,268 @@ 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.renderer.setImageOverlay(this.aboutOverlay(plan, columns)); + this.presentationFrame = {frame, plan}; + this.paintPresentation(Date.now()); + this.renderRail = undefined; + this.syncPresentationClock(); + } + + /** /about: build identity and the logo, as an image where the host supports one, text otherwise. */ + private aboutPanel?: {protocol: ImageProtocol; png?: Buffer}; + + private openAbout(): void { + const protocol = selectImageProtocol({capabilities: this.host.capabilities, env: process.env}); + let png: Buffer | undefined; + if (protocol !== 'none') try { png = readFileSync(join(installRoot(), 'assets', 'brand', 'nmsh-logo.png')); } catch { /* text logo */ } + this.aboutPanel = {protocol: png ? protocol : 'none', ...(png ? {png} : {})}; + } + + private aboutLogoSize(columns: number): ImageSize { + const size = this.aboutPanel?.png && pngSize(this.aboutPanel.png); + return size ? fitCells(size.width, size.height, Math.max(8, columns - 6), 6) : {columns: 0, rows: 0}; + } + + private aboutRows(columns: number): string[] { + const panel = this.aboutPanel!; + const rows = [`${PRIMARY} About NMSh${RESET}`, '']; + if (panel.protocol !== 'none') rows.push(...Array(this.aboutLogoSize(columns).rows).fill('')); + else rows.push(` ${ACCENT}\u001b[1mN${SECONDARY}❯${ACCENT}MSh${RESET}`, ` ${PRIMARY}not${ACCENT}My${PRIMARY}Shell${RESET}`); + const images = panel.protocol === 'kitty' ? 'Kitty graphics protocol' : panel.protocol === 'iterm2' ? 'iTerm2 inline images' + : 'not available in this terminal; NMSh works fully without them'; + rows.push('', ` ${SUBTLE}${formatBuildIdentity(this.buildIdentity)}${RESET}`, + ` ${SUBTLE}Keep your terminal. Keep your shell. Upgrade the interaction layer.${RESET}`, + ` ${SUBTLE}Inline images: ${images}${RESET}`, '', ` ${SUBTLE}Any key closes${RESET}`); + return rows; + } + + /** The logo overlay sits in the blank rows reserved by aboutRows inside the panel region. */ + private aboutOverlay(plan: ScreenPlan, columns: number): ImageOverlay | undefined { + const panel = this.aboutPanel; + const region = plan.regions.find(item => item.kind === 'panel'); + if (!panel?.png || panel.protocol === 'none' || !region) return undefined; + const size = this.aboutLogoSize(columns); + // framePanel's frame line leads the panel (it moves below the panel under Dock Top), then title and spacer. + const row = region.top + (plan.panelPosition === 'top' ? 0 : 1) + 2; + if (row + size.rows > region.top + region.height) return undefined; + return createImageOverlay(panel.protocol, panel.png, `about:${panel.protocol}:${row}:${size.columns}x${size.rows}`, row, 2, size); + } + + /** Existing #91 tasks repaint their panel only while its geometry is unchanged. */ + private renderTaskPresentation(): void { + if (this.idle) return; + 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.panelPosition === '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()); + } + + /** + * The short transitions, in one composition order after Chroma and before the cursor: + * prompt morph, launch, materialization, Block Seal, Semantic Echo. Drawn by the selected rendering (Clean or Rich). + */ + private paintTransitions(rows: string[], plan: ScreenPlan, columns: number, now: number): void { + // Context morph: a semantic diff of the prompt's modules (never raw ANSI); one epoch per change. + if (this.promptConfiguration.motion.contextTransitions !== 'off' && this.effectivePromptProvider === 'nmsh') { + const modules = renderedModules(this.promptContext(), this.promptConfiguration).map(module => ({id: module.id, text: module.text, role: module.role})); + if (this.lastModules) { const changes = diffModules(this.lastModules, modules); if (changes.length) this.transitions.morph(changes, now); } + this.lastModules = modules; + } + this.transitions.editorChanged(this.editor.text); + const live = plan.panelActive || this.passthrough || this.externalPassthrough ? [] : this.transitions.live(now); + const paints = new Map>(); + const add = (row: number, cells: Map) => { + if (row < 0 || row >= rows.length || !cells.size) return; + const line = paints.get(row) ?? new Map(); + for (const [column, paint] of cells) line.set(column, {...line.get(column), ...paint}); + paints.set(row, line); + }; + const regions = (...kinds: string[]) => plan.regions.filter(region => kinds.includes(region.kind) && region.height > 0); + for (const transition of live) { + const t = progress(transition, now); + if (transition.kind === 'launch') { + for (const region of regions('input', 'separator', 'composerBorder')) for (let index = 0; index < region.height; index += 1) { + add(region.top + index, transitionPaint.launch(transition.style, columns, t, region.kind !== 'input', transition.look)); + } + } else if (transition.kind === 'materialize') { + const input = regions('input')[0]; + if (!input) continue; + const prefix = this.railInputPrefix(columns); + const at = (index: number) => layoutInput(this.editor.displayText, index, this.inputColumns(columns), Number.POSITIVE_INFINITY, prefix); + const from = at(transition.from); + const to = at(transition.to); + const caret = this.layoutEditorInput(columns, Math.max(1, input.height)); + const offset = caret.caretRow - at(this.editor.displayCursorIndex).caretRow; + if (from.caretRow === to.caretRow) add(input.top + from.caretRow + offset, transitionPaint.materialize(from.caretColumn, to.caretColumn, t, transition.vivid, transition.look)); + } else if (transition.kind === 'travel') { + const input = regions('input')[0]; + if (!input) continue; + const caret = this.layoutEditorInput(columns, Math.max(1, input.height)); + add(input.top + transition.row + (caret.caretRow - layoutInput(this.editor.displayText, this.editor.displayCursorIndex, this.inputColumns(columns), Number.POSITIVE_INFINITY, this.railInputPrefix(columns)).caretRow), + transitionPaint.travel(transition.from, transition.to, t, transition.look)); + } else if (transition.kind === 'seal') { + const transcript = plan.regions.find(region => region.kind === 'transcript'); + const index = this.visibleBlocks.lastIndexOf(transition.blockStartId); + if (transcript && index >= 0) add(transcript.top + index, transitionPaint.seal(transition.tone, columns, t, transition.look)); + } else if (transition.kind === 'echo') { + for (const region of regions('separator', 'composerBorder')) add(region.top, transitionPaint.echoRule(transition.event, columns, t, transition.expressive, transition.look)); + if (transition.expressive) for (const region of regions('input')) add(region.top, transitionPaint.echoInput(transition.event, columns, t, transition.look)); + } else if (transition.kind === 'morph') { + const prompt = regions('prompt')[0] ?? regions('input')[0]; + if (!prompt) continue; + const plain = stripAnsi(rows[prompt.top] ?? ''); + for (const change of transition.changes) { + if (change.change === 'disappeared' || !change.text) continue; + // The final geometry, located by the module's own text; a module the width rules removed is simply skipped. + const start = plain.indexOf(change.text); + if (start < 0) continue; + const column = displayWidth(plain.slice(0, start)); + add(prompt.top, transitionPaint.morph(column, column + displayWidth(change.text), t, transition.expressive, change.change, transition.look)); + } + } + } + // Transition light passes over the rule but not over a Keep Awake accessory: its text stays semantically stable. + for (const region of regions('separator', 'composerBorder')) { + const accessory = this.edgeAccessory(region.kind as 'separator' | 'composerBorder', plan, now); + const span = accessory && edgeAccessoryColumns(columns, accessory.width); + const cells = span && paints.get(region.top); + if (cells) for (let column = span.start - 1; column <= span.end; column += 1) cells.delete(column); + } + if (paints.size) for (const [row, cells] of paints) rows[row] = overlayRow(rows[row] ?? '', cells, columns); + // A clock only while a transition is live (~30 fps for their short lifetime); none otherwise. + const busy = this.transitions.busy; + if (busy && !this.transitionClock) this.transitionClock = presentationClock.subscribe(() => { if (!this.stopped) this.paintPresentation(Date.now()); }, 33, 16); + else if (!busy && this.transitionClock) { this.transitionClock(); this.transitionClock = undefined; } + } + + private decorativeMotionAllowed(): boolean { + return !isReducedMotion() && !this.promptConfiguration.presentation.reducedMotion && !this.promptConfiguration.presentation.effectsOff; + } + + private cancelPresentation(): void { + this.effects.cancel(); + this.endSweep(); + if (this.escapeFlushTimer) { clearTimeout(this.escapeFlushTimer); this.escapeFlushTimer = undefined; } + this.stopIdleFrames(); + this.idle = undefined; + this.screensaverAnimation?.(); this.screensaverAnimation = undefined; + this.stripTimer?.(); this.stripTimer = undefined; + this.awakeTimer?.(); this.awakeTimer = undefined; + this.noticeTimer?.(); this.noticeTimer = undefined; + this.panelAnimation?.(); this.panelAnimation = undefined; + 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 { + // Idle visuals are the sole owner of the screen while active. + if (this.idle) return; + 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] ?? ''; + } + // Live composer divider lines move with Chroma only when Divider lines follow Chroma. + // The composed edge (rule + any Keep Awake accessory) is repainted, never a bare rule over it. + if ((region.kind === 'separator' || region.kind === 'composerBorder') && dividerAnimated(settings)) { + rows[region.top] = this.composerEdgeRow(region.kind, plan, frame.columns ?? 80, now); + } + // The prompt row is re-rendered from the same semantic modules; only Chroma colors move (its divider fill too). + if (region.kind === 'prompt' && region.height > 0 && (this.promptChromaAnimated() || dividerAnimated(settings)) && this.decorativeMotionAllowed()) { + rows[region.top] = this.currentPromptLine(frame.columns ?? 80, now); + } + } + paintRailComposition(rows, plan, this.preparedRail(frame.columns ?? 80), this.promptContext(), frame.columns ?? 80, this.promptConfiguration, now); + this.applySweep(rows, plan, frame.columns ?? 80); + const active = this.effects.active; + const region = active && effectRegion(plan, active.placement); + if (active && !region) this.effects.cancel(); + const columns = frame.columns ?? 80; + this.paintTransitions(rows, plan, columns, now); + // Cursor effects: an overlay on the input rows only, never over panels, passthrough or idle visuals. + const input = plan.regions.find(item => item.kind === 'input'); + const caretShown = frame.cursorVisible !== false && Boolean(input) && !plan.panelActive; + const cursor = this.cursorPresenter.apply(rows, caretShown ? {row: frame.cursorRow - 1, column: frame.cursorColumn - 1} : undefined, + {top: Math.max(0, (input?.top ?? 0) - 1), bottom: (input?.top ?? 0) + (input?.height ?? 1) - 1, columns}, this.caretCause, + !this.passthrough && !this.externalPassthrough && this.decorativeMotionAllowed() && colorLevel() !== 'none', now, this.cursorBackend()); + const painted = cursor.rows; + try { + this.renderer.render({...frame, ...(cursor.hideCaret ? {cursorVisible: false} : {}), rows: active && region + ? applyEffect(painted, active, region, columns, now, getCurrentGlyphMode() === 'safe', colorLevel()) : painted}); + } catch (error) { this.onTerminate(); throw error; } + } + + private renderPresentation(now: number): void { + if (this.idle) return; + 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 || this.idle) return; + this.syncStatusStrip(); + this.syncAwake(); + this.syncNotices(); + this.syncAgents(); + const settings = this.promptConfiguration.presentation; + const animatedRule = this.presentationFrame?.plan.regions.some(region => region.kind === 'separator' || region.kind === 'composerBorder' + || (region.kind === 'prompt' && region.height > 0)) && dividerAnimated(settings) && colorLevel() !== 'none'; + const animatedPrompt = this.presentationFrame?.plan.regions.some(region => region.kind === 'prompt' && region.height > 0) && this.promptChromaAnimated(); + const needsFrames = Boolean(this.running || this.effects.active || ((animatedRule || animatedPrompt) && 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}`; + // A soft light sweep over the working phrase: its own colors lifted in place, never moved; faster while output arrives. + // Colors come from Live activity colors; the sweep is the same light sweep everywhere. + const still = !this.decorativeMotionAllowed() || sweepStill(this.promptConfiguration); + const {cells, style} = liveActivityPaint(parts.phrase, this.promptConfiguration, this.activityAnimationNow, still); + const phrase = sweepCells(cells, animationElapsed * (isActive ? 1.4 : 1), style, colorLevel(), !this.decorativeMotionAllowed()); + return `${phrase}${SECONDARY}${parts.duration}${RESET}`; } private jumpAffordance(columns: number): string { @@ -2719,6 +8206,8 @@ export class TerminalApp { } private inputFirstLinePrefix(columns: number): string | undefined { + // Prompt None: no context at all; the input marker (the configured prompt symbol) stays. + if (this.effectivePromptProvider === 'none') return undefined; if (this.promptConfiguration.composerLayout !== 'oneLine') return undefined; if (this.effectivePromptProvider !== 'nmsh' && this.externalPrompt) { const maxWidth = Math.max(0, columns - 1); @@ -2734,6 +8223,8 @@ export class TerminalApp { */ private oneLineRightContext(line: string, columns: number): string { if (this.promptConfiguration.composerLayout !== 'oneLine' || this.effectivePromptProvider !== 'nmsh') return ''; + const rail = this.preparedRail(columns).presentation; + if (rail.rows && rail.relation === 'right') return ''; const used = displayWidth(line); // Two cells of breathing room after the text, plus the caret cell. const right = buildRightContext(this.promptContext(), columns - used - 2, this.promptConfiguration); @@ -2745,12 +8236,16 @@ export class TerminalApp { return layoutInput( this.editor.displayText, this.editor.displayCursorIndex, - columns, + this.inputColumns(columns), maxVisibleRows, - this.inputFirstLinePrefix(columns), + this.railInputPrefix(columns), ); } + private railInputPrefix(columns: number): string | undefined { + return this.inputFirstLinePrefix(this.inputColumns(columns)); + } + private dimensions(): {columns: number; rows: number} { return { columns: Math.max(1, process.stdout.columns || 80), @@ -2758,44 +8253,157 @@ export class TerminalApp { }; } - get ordinaryZshHandoffCwd(): string | undefined { - return this.shellHandoffCwd; + /** Transcript row under a terminal row, clamped to the transcript region so a drag past its edge keeps selecting. */ + private transcriptRowAt(y: number): number | undefined { + const {columns, rows} = this.dimensions(); + const plan = this.planFrame(columns, rows); + const wrapped = this.output.wrapped(columns); + if (!wrapped.length) return undefined; + const viewStart = this.historyViewport.resolve(wrapped.length, plan.viewportRows); + const top = plan.transcript.top; + const local = Math.max(0, Math.min(plan.transcript.height - 1, screenRowFromTerminal(y) - top)); + return Math.min(wrapped.length - 1, viewStart + local); + } + + private handleSelectionPointer(kind: 'mouseDrag' | 'mouseRelease', y: number | undefined): void { + const selection = this.selection; + if (!selection?.dragging || !y) return; + const row = this.transcriptRowAt(y); + if (row !== undefined) extendSelection(selection, row, y); + if (kind === 'mouseRelease') { + selection.dragging = false; + if (!selection.moved) { this.selection = undefined; return; } + const text = selectedText(this.output.wrapped(this.dimensions().columns), selection); + // Like a terminal selection: copied on release, silently; only a failure is worth a line. + if (text) void writeClipboard(text).catch(() => { + this.output.addFrontendInteraction('selection', 'Could not copy the selection: no clipboard is available here.', ERROR); + this.render(); + }); + } + this.render(); + } + + /** While dragging, a wheel step scrolls the transcript and the selection follows the pointer's row. */ + private followSelectionPointer(): void { + const selection = this.selection; + if (!selection?.dragging || selection.pointerY === undefined) return; + const row = this.transcriptRowAt(selection.pointerY); + if (row !== undefined) extendSelection(selection, row); + this.render(); } - get isOrdinaryZshHandoffRequested(): boolean { - return this.shellHandoffRequested; + /** The ordinary shell to hand the terminal to after NMSh exits, when one was requested. */ + get shellHandoff(): {shell: ShellId; executable: string; label: string; cwd?: string; returnSession?: string} | undefined { + return this.requestedHandoff; } - private leaveForOrdinaryZsh(): void { - const decision: ShellHandoffDecision = chooseShellHandoff(Boolean(this.running), this.shellCwd, this.initialCwd); + /** Kept for existing callers. */ + get ordinaryZshHandoffCwd(): string | undefined { return this.requestedHandoff?.cwd; } + get isOrdinaryZshHandoffRequested(): boolean { return Boolean(this.requestedHandoff); } + + /** + * Leave NMSh for an ordinary interactive shell: /zsh, /fish, /bash, or /exit + * (the configured default backend, never $SHELL). One decision path; a busy + * session or a missing shell keeps NMSh running and untouched. + */ + private leaveForOrdinaryShell(target: ShellId, command: string): void { + const adapter = shellAdapter(target); + const executable = adapter.resolveExecutable(process.env); + if (!executable) { + const isDefault = command === '/exit'; + this.output.addFrontendInteraction(command, `${isDefault ? `Your default shell (${adapter.label}) is not available. ` : ''}${adapter.unavailableReason(process.env) ?? `${adapter.label} is not available.`} ` + + `NMSh stays open. ${isDefault ? 'Install it from /shell, or choose another default there (D). ' : 'Install it from /shell. '}No other shell was started.`, ERROR); + this.render(); + return; + } + const busy = this.running ? `"${this.running.command.slice(0, 60)}" is still running` + : this.passthrough || this.externalPassthrough ? 'a full-screen program owns the terminal' + : this.startupPending || this.switchedShellStarting ? 'the shell is still starting' + : this.shellJobs ? `${this.shellJobs} background or stopped job${this.shellJobs === 1 ? '' : 's'} would end with the session (jobs, fg, kill %N)` : undefined; + const decision: ShellHandoffDecision = chooseShellHandoff(busy ? `${busy}; finish or interrupt it, then run ${command} again.` : false, this.shellCwd, this.initialCwd); if (decision.kind === 'busy') { - this.output.addFrontendInteraction('/zsh', 'Wait for the foreground command to finish or interrupt it, then run /zsh.', INFO); + this.output.addFrontendInteraction(command, `Not leaving NMSh: ${decision.reason ?? 'the session is busy.'}`, INFO); this.render(); return; } - - this.shellHandoffCwd = decision.cwd; - this.shellHandoffRequested = true; - this.shellEnded = true; - this.session.kill(); + // A service session is detached, not ended: `nmsh` in the ordinary shell returns to exactly this session. + // In-process there is no service to keep it, so the session ends as before. + const keep = this.sessionMode === 'service' && Boolean(this.sessionId); + this.requestedHandoff = {shell: target, executable, label: adapter.label, ...(decision.cwd ? {cwd: decision.cwd} : {}), + ...(keep ? {returnSession: this.sessionId!} : {})}; + if (keep) { + this.detaching = true; + this.session.detach(); + } else { + this.shellEnded = true; + this.session.kill(); + } this.stop(0); } + /** A /shell switch started a new backend that has not reached its first prompt. */ + private switchedShellStarting = false; + /** Background/stopped jobs in the managed shell, from its latest name snapshot. */ + private shellJobs = 0; + private requestedHandoff?: {shell: ShellId; executable: string; label: string; cwd?: string; returnSession?: string}; + private stop(exitCode: number): void { if (this.stopped) return; this.stopped = true; - if (this.activityTimer) clearInterval(this.activityTimer); + if (this.noticeExpiry) { clearTimeout(this.noticeExpiry); this.noticeExpiry = undefined; } + setCursorHostProvider(undefined); + this.setupCursorClock?.(); this.setupCursorClock = undefined; + this.understanding.dispose(); + this.agentDiscoveryTimer?.(); + this.agentsSubscription(); + this.agents.dispose(); + this.cursorPresenter.dispose(); + this.transitions.cancel(); this.transitionClock?.(); this.transitionClock = undefined; this.motionPreviewClock?.(); this.motionPreviewClock = undefined; + this.cursorPanelClock?.(); this.cursorPanelClock = undefined; + this.tasksSubscription(); + this.watchSubscription(); + this.watches.dispose(); + this.taskClock?.(); this.taskClock = undefined; + this.askClock?.(); this.askClock = undefined; + this.managedTasks.dispose(); + 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.installPrompt?.task?.dispose(); + this.providerPanelState?.task?.dispose(); + this.welcomeBlinkTimer?.(); this.welcomeBlinkTimer = undefined; + if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = undefined; } + this.stopIdleFrames(); + this.idle = undefined; + this.screensaverAnimation?.(); this.screensaverAnimation = undefined; + this.endSweep(); 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.terminalFocus = 'unknown'; + try { this.renderer.leave(); } catch { /* A closed terminal must not prevent resource cleanup. */ } + this.completionService.dispose(); + this.commandDescriptions.dispose(); + this.historyQueryAbort?.abort(); + this.clearCorrection(); + this.directoryQueryAbort?.abort(); + this.pickerAbort?.abort(); + this.historyService.dispose(); this.semanticService.kill(); this.finish(exitCode); } } + +/** Commands newest first, read lazily so navigation stops at its bound. */ +function* historyCommands(entries: readonly {command: string}[]): Iterable { + for (const entry of entries) yield entry.command; +} diff --git a/src/app/layout.ts b/src/app/layout.ts index b231a049..8e4285ae 100644 --- a/src/app/layout.ts +++ b/src/app/layout.ts @@ -24,12 +24,14 @@ export function calculateScreenLayout( contextPlacement: ContextPlacement = 'header', hasVisibleContext = true, composerLayout: ComposerLayout = 'twoLine', + /** Off removes the decorative rule rows around the composer (the prompt row itself stays). */ + composerDividers = true, ): ScreenLayout { const safeRows = Math.max(1, rows); const oneLine = composerLayout === 'oneLine'; const showPrompt = safeRows >= 2 && hasVisibleContext && !oneLine; - const showSeparator = safeRows >= 3; - const showComposerTopBorder = safeRows >= (oneLine ? 3 : 4) + const showSeparator = composerDividers && safeRows >= 3; + const showComposerTopBorder = composerDividers && safeRows >= (oneLine ? 3 : 4) && (oneLine || (showPrompt && contextPlacement === 'composer')); const fixedRows = Number(showPrompt) + Number(showSeparator) + Number(showComposerTopBorder); const minimumOutput = safeRows >= 7 ? 2 : 0; diff --git a/src/app/screenPlan.ts b/src/app/screenPlan.ts index 1230a180..1ede8d90 100644 --- a/src/app/screenPlan.ts +++ b/src/app/screenPlan.ts @@ -1,3 +1,4 @@ +import {verticalRailRows, type RailPresentation, type RailRow} from '../prompt/railLayout.js'; import type {ComposerLayout, ComposerPosition, ContextPlacement} from '../prompt/configuration.js'; import {calculateScreenLayout} from './layout.js'; @@ -20,12 +21,23 @@ export type RegionKind = | 'gap' | 'jump' | 'panel' + | 'inspector' | 'suggestions' | 'activity' | 'composerBorder' | 'prompt' | 'input' - | 'separator'; + | 'separator' + /** The optional NMSh status strip: one owned row at the top. */ + | 'status' + /** Cross-session notices: frontend chrome immediately above the composer, never transcript. */ + | 'notices' + /** The transcript find bar (query, match count, options); frontend chrome above the composer. */ + | 'find' + /** Keep Awake's adjacent row (its own row, or the muted idle reminder): frontend chrome right next to the composer, never transcript. */ + | 'awake' + /** Live contextual modules attached to the composer, never transcript. */ + | 'contextRail' | 'railGap' | 'railEdge'; export interface Region { kind: RegionKind; @@ -40,6 +52,7 @@ export interface ScreenPlanInput { inputRows: number; /** Suggestion rows the composer would like to show. */ suggestions: number; + inspectorRows?: number; running: boolean; detached: boolean; hasOutput: boolean; @@ -50,10 +63,17 @@ export interface ScreenPlanInput { panelRows?: number; /** Dock Bottom (default), Dock Top, or Flow. */ composerPosition?: ComposerPosition; + /** Where full-width NMSh panels sit: Bottom (default) or Top. Independent of the composer position. */ + panelPosition?: 'bottom' | 'top'; /** Presented transcript rows; Dock Top and Flow use it to keep what follows next to the newest output. */ transcriptRows?: number; /** Flow while scrolled back: the first transcript row in view (the composer follows the transcript's end). */ viewStart?: number; + /** Decorative rule rows around the composer; Off removes them from the geometry (default On). */ + composerDividers?: boolean; + /** Already resolved visible Rail demand; capped to two rows and available view space. */ + contextRailRows?: number; + railPresentation?: RailPresentation; } export interface ScreenPlan { @@ -75,6 +95,25 @@ export interface ScreenPlan { viewportRows: number; composerPosition: ComposerPosition; panelActive: boolean; + /** Where the active panel is anchored (meaningful while panelActive). */ + panelPosition: 'bottom' | 'top'; + /** Where this frame's Keep Awake accessory lives, when one is active (decided with the plan so every consumer agrees). */ + rail?: {presentation: RailPresentation; slots: {top: number; index: number}[]; edgeRow: number; start: number; end: number}; + awake?: {slot: 'topEdge' | 'bottomEdge' | 'adjacentRow' | 'inputTrailing'; expandedOnEdge: boolean}; +} + +/** + * Whether each composer edge can host auxiliary text. A plain composer border + * or separator is available; a header prompt row (the prompt drawn into the + * top divider) occupies the top edge; no rule at all (dividers Off, a tiny + * screen) is unavailable. Width is checked by the caller. + */ +export function composerEdgeStates(plan: ScreenPlan, contextPlacement: ContextPlacement): {topEdge: 'available' | 'occupied' | 'unavailable'; bottomEdge: 'available' | 'unavailable'} { + const has = (kind: RegionKind) => plan.regions.some(region => region.kind === kind && region.height > 0); + return { + topEdge: has('composerBorder') ? 'available' : has('prompt') && contextPlacement === 'header' ? 'occupied' : 'unavailable', + bottomEdge: has('separator') ? 'available' : 'unavailable', + }; } export interface RegionHit { @@ -84,19 +123,37 @@ export interface RegionHit { } export function planScreen(input: ScreenPlanInput): ScreenPlan { + if (input.railPresentation?.rows && input.panelRows === undefined) { + const base = planScreen({...input, railPresentation: undefined, contextRailRows: 0}); + const composed = withRailPresentation(base, input.railPresentation); + if (input.composerPosition === 'flow' && input.detached) { + // Measure the complete group independently of viewport clipping. Scrolling + // the input offscreen must not resize the hidden shell's terminal. + const reference = planScreen({...input, detached: false, transcriptRows: 0, railPresentation: undefined, contextRailRows: 0}); + const measured = withRailPresentation(reference, input.railPresentation); + const reserved = reference.ptyRows - measured.ptyRows; + return {...composed, ptyRows: measured.ptyRows, viewportRows: Math.max(1, base.viewportRows - reserved)}; + } + return composed; + } + if (input.contextRailRows && input.panelRows === undefined) { + return withContextRail(planScreen({...input, contextRailRows: 0}), input.contextRailRows); + } const rows = Math.max(1, input.rows); const top = input.composerPosition === 'top'; if (input.panelRows !== undefined) { - // Panel takeover: the panel pins to the composer's edge and the transcript keeps the rest. + // Panel takeover follows the explicit NMSh panel position, never the composer position: + // Bottom keeps the bottom edge fixed (the top edge moves with height), Top keeps the top edge fixed. const panelHeight = Math.min(rows, Math.max(0, input.panelRows)); const panel: Array<[RegionKind, number]> = [['panel', panelHeight]]; const transcript: Array<[RegionKind, number]> = [['transcript', rows - panelHeight]]; - // Flow pins panels to the bottom edge, like Bottom. - return build(rows, top ? [...panel, ...transcript] : [...transcript, ...panel], - {inputHeight: 0, suggestionCount: 0, panelActive: true, composerPosition: input.composerPosition ?? 'bottom'}); + const atTop = input.panelPosition === 'top'; + return build(rows, atTop ? [...panel, ...transcript] : [...transcript, ...panel], + {inputHeight: 0, suggestionCount: 0, panelActive: true, composerPosition: input.composerPosition ?? 'bottom', panelPosition: atTop ? 'top' : '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, @@ -105,6 +162,7 @@ export function planScreen(input: ScreenPlanInput): ScreenPlan { input.contextPlacement, input.hasVisibleContext, input.composerLayout, + input.composerDividers !== false, ); if (top) { // Dock Top: composer, its menus, then a chronological transcript. The transcript @@ -117,6 +175,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,8 +189,8 @@ 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, - input.contextPlacement, input.hasVisibleContext, input.composerLayout) + ? calculateScreenLayout(rows - inspectorHeight, input.inputRows, input.suggestions, input.running, false, input.hasOutput, + input.contextPlacement, input.hasVisibleContext, input.composerLayout, input.composerDividers !== false) : layout; const capacity = followLayout.outputHeight; const total = Math.max(0, input.transcriptRows ?? capacity); @@ -144,6 +203,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 +222,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], @@ -169,10 +230,102 @@ export function planScreen(input: ScreenPlanInput): ScreenPlan { ], {inputHeight: layout.inputHeight, suggestionCount: layout.suggestionCount, panelActive: false, composerPosition: 'bottom'}); } +/** Compile a declarative group stack while preserving the input's existing anchor. */ +function withRailPresentation(plan: ScreenPlan, presentation: RailPresentation): ScreenPlan { + const input = regionOf(plan, 'input'); + if (!input) return plan.composerPosition === 'flow' + ? {...plan, ptyRows: Math.max(1, plan.ptyRows - presentation.rows), viewportRows: Math.max(1, plan.viewportRows - presentation.rows)} : plan; + const top = plan.composerPosition === 'top'; + const members = ['composerBorder', 'prompt', 'input', 'separator', ...(top ? ['inspector', 'suggestions'] : [])]; + const start = Math.min(...plan.regions.filter(r => members.includes(r.kind)).map(r => r.top)); + const end = Math.max(...plan.regions.filter(r => members.includes(r.kind) && r.top >= start).map(r => r.top + r.height)); + type Token = {kind: RegionKind; height: number; row?: RailRow}; + let tokens: Token[] = plan.regions.filter(r => r.top >= start && r.top < end).map(r => ({kind: r.kind, height: r.height})); + const p = {...presentation}; + // Keep one usable transcript row; optional decoration yields before content. + const maximum = Math.max(0, plan.ptyRows - 1); + let railRows = verticalRailRows(p, top); + while (railRows.length > maximum && railRows.some(r => r.kind === 'gap')) railRows.splice(railRows.findIndex(r => r.kind === 'gap'), 1); + if (p.relation === 'vertical' && railRows.length > maximum && railRows.some(r => r.kind === 'edge')) { + railRows = railRows.filter(r => r.kind !== 'edge'); + p.anchor = 'rail'; + } + if (p.relation === 'vertical' && railRows.length > maximum) { + railRows = railRows.slice(0, maximum); + if (!railRows.length) return plan; + } + const token = (row: RailRow): Token => ({kind: row.kind === 'content' ? 'contextRail' : row.kind === 'gap' ? 'railGap' : 'railEdge', height: 1, row}); + if (p.relation === 'vertical') { + if (p.inside) tokens = tokens.filter(t => t.kind !== (top ? 'separator' : 'composerBorder')); + tokens = top ? [...tokens, ...railRows.map(token)] : [...railRows.map(token), ...tokens]; + } else { + const main = tokens.find(t => t.kind === 'prompt') ?? tokens.find(t => t.kind === 'input')!; + const edge = tokens.find(t => t.kind === 'composerBorder'); + const primary = p.inside && p.anchor === 'rail' && edge ? edge : main; + const hasSecondary = primary !== main || main.kind === 'prompt' || input.height > 1; + if (p.rows === 2 && !hasSecondary && maximum) { + const secondary = token({kind: 'content', index: 1}); + tokens = top ? [...tokens, secondary] : [secondary, ...tokens]; + } + if (p.inside && p.anchor === 'above') { + tokens = tokens.filter(t => t !== edge); + tokens.unshift(edge ?? token({kind: 'edge'})); + } + } + const before = tokens.slice(0, tokens.findIndex(t => t.kind === 'input')).reduce((n, t) => n + t.height, 0); + const deltaBefore = before - (input.top - start); + const total = tokens.reduce((n, t) => n + t.height, 0); + const delta = total - (end - start); + const removed = top ? 0 : Math.min(deltaBefore, plan.transcript.height); + const extra = top ? 0 : deltaBefore - removed; + const groupStart = top ? start : start - removed; + let row = groupStart; + const group: Region[] = tokens.map(t => { const r = {kind: t.kind, top: row, height: t.height}; row += t.height; return r; }); + const slots = tokens.flatMap((t, i) => t.row?.kind === 'content' ? [{top: group[i]!.top, index: t.row.index}] : []); + const main = group.find(r => r.kind === 'prompt') ?? group.find(r => r.kind === 'input')!; + const edge = group.find(r => r.kind === 'composerBorder'); + if (p.relation === 'right') { + const primary = p.inside && p.anchor === 'rail' && edge ? edge : main; + slots.push({top: primary.top, index: 0}); + if (p.rows === 2 && !slots.some(slot => slot.index === 1)) slots.push({top: primary !== main ? main.top : main.kind === 'prompt' ? group.find(r => r.kind === 'input')!.top : main.top + 1, index: 1}); + } + const boundary = group.find(r => r.kind === 'railEdge'); + const edgeRow = boundary ? boundary.top : p.anchor === 'above' ? (edge ?? main).top + : p.anchor === 'rail' ? (top && p.relation === 'vertical' ? slots.at(-1)!.top : slots[0]!.top) : (edge ?? main).top; + const transcript = {...plan.transcript, top: top ? plan.transcript.top + delta : plan.transcript.top, + height: Math.max(0, plan.transcript.height - (top ? Math.max(0, delta - (plan.ptyRows - plan.transcript.height)) : removed))}; + const outside = plan.regions.filter(r => r.top < start || r.top >= end).map(r => r.kind === 'transcript' ? transcript + : top ? (r.top >= end ? {...r, top: r.top + delta} : r) + : r.top >= end ? {...r, top: r.top + extra + delta - deltaBefore} : {...r, top: r.top - removed}); + const regions = [...outside, ...group].filter(r => r.height > 0 && r.top >= 0 && r.top < plan.rows) + .map(r => ({...r, height: Math.min(r.height, plan.rows - r.top)})).sort((a,b) => a.top-b.top); + return {...plan, regions, transcript, ptyRows: Math.max(1, plan.ptyRows - delta), + viewportRows: Math.max(1, plan.composerPosition === 'flow' ? transcript.height : plan.viewportRows - delta), + rail: {presentation: p, slots: slots.filter(slot => slot.top >= 0 && slot.top < plan.rows), edgeRow, + start: groupStart, end: Math.min(plan.rows, row)}}; +} + +/** Compatibility input for older callers; all Rail geometry uses one compiler. */ +function withContextRail(plan: ScreenPlan, requested: number): ScreenPlan { + const count = Math.min(2, Math.max(0, Math.floor(requested))); + const result = withRailPresentation(plan, {relation: 'vertical', inside: false, + anchor: 'prompt', mirrored: false, rows: count, gap: 0, between: 0, + width: 0, column: 0, editorColumns: 0}); + // Historical callers address a contiguous Rail region by local row. + const regions: Region[] = []; + for (const region of result.regions) { + const previous = regions.at(-1); + if (region.kind === 'contextRail' && previous?.kind === 'contextRail' && previous.top + previous.height === region.top) + previous.height += region.height; + else regions.push({...region}); + } + return {...result, regions}; +} + function build( rows: number, stack: Array<[RegionKind, number]>, - extra: Pick, + extra: Pick & {panelPosition?: 'bottom' | 'top'}, ): ScreenPlan { const regions: Region[] = []; let transcript: Region = {kind: 'transcript', top: 0, height: 0}; @@ -184,7 +337,34 @@ function build( if (height > 0) regions.push(region); top += height; } - return {rows, regions, transcript, ptyRows: transcript.height, viewportRows: Math.max(1, transcript.height), ...extra}; + return {rows, regions, transcript, ptyRows: transcript.height, viewportRows: Math.max(1, transcript.height), panelPosition: 'bottom', ...extra}; +} + +/** + * A plan with one owned status row above everything else. Every region moves + * down by one, so hit-testing, cursor placement and viewport math stay in + * agreement; the transcript keeps its own height. + */ +export function withStatusRow(plan: ScreenPlan): ScreenPlan { + const shift = (region: Region): Region => ({...region, top: region.top + 1}); + return {...plan, rows: plan.rows + 1, regions: [{kind: 'status', top: 0, height: 1}, ...plan.regions.map(shift)], transcript: shift(plan.transcript)}; +} + +const COMPOSER_KINDS: ReadonlySet = new Set(['composerBorder', 'prompt', 'input', 'contextRail', 'railGap', 'railEdge']); + +/** + * A plan (built for `plan.rows`) with `count` notice rows inserted immediately + * above the composer. Regions from the composer on move down; nothing above it + * moves, so the transcript keeps its geometry. Without a composer (a panel owns + * the screen) the plan is returned unchanged. + */ +export function withNoticeRows(plan: ScreenPlan, count: number, kind: 'notices' | 'find' | 'awake' = 'notices'): ScreenPlan { + const index = plan.regions.findIndex(region => COMPOSER_KINDS.has(region.kind)); + if (count <= 0 || index === -1 || plan.panelActive) return plan; + const at = plan.regions[index]!.top; + const shift = (region: Region): Region => (region.top >= at ? {...region, top: region.top + count} : region); + const regions = [...plan.regions.slice(0, index), {kind, top: at, height: count}, ...plan.regions.slice(index).map(shift)]; + return {...plan, rows: plan.rows + count, regions, transcript: shift(plan.transcript)}; } export function regionOf(plan: ScreenPlan, kind: RegionKind): Region | undefined { diff --git a/src/appearance/AppearanceHub.ts b/src/appearance/AppearanceHub.ts new file mode 100644 index 00000000..2c5a2158 --- /dev/null +++ b/src/appearance/AppearanceHub.ts @@ -0,0 +1,174 @@ +import {chromaPreviewNote} from './chromaNotes.js'; +import type {Key} from '../terminal/keys.js'; +import type {MotionSettings, PromptConfiguration} from '../prompt/configuration.js'; +import {MOTION_ITEMS, MOTION_LABELS, MOTION_ROWS, MOTION_TUNING_ITEMS, type MotionItem} from '../motion/motionRows.js'; +import {renderControls} from '../ui/controls.js'; +import {GLYPHS} from '../ui/glyphs.js'; +import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; +import {padCells, truncateAnsi} from '../util/text.js'; +import {BLUR_MODES, handleAppearanceKey, type AppearanceState} from './AppearancePanel.js'; +import {cursorLabel} from '../cursor/CursorPanel.js'; +import {renderMotionPreview, type MotionPreview} from '../motion/MotionPreview.js'; +import type {MotionGate} from '../motion/transitions.js'; +import {librarySummary} from './themeLibrary.js'; +import {providerLabel} from '../prompt/PromptPanel.js'; +import {BRIDGE_TARGETS, BRIDGE_TARGET_LABELS, effectiveMode} from '../themeBridge/model.js'; + +/** + * /appearance: the visual hub. NMSh rows summarize and open the canonical + * surfaces (/prompt, /cursor, UI chrome, /chroma) or the general Motion + * screen; nothing is edited twice. Host window rows keep the existing + * opacity/blur editing where the host supports it, and say who controls them + * where it does not, so the hub is useful in every terminal. + */ +export type HubDestination = 'theme' | 'prompt' | 'cursor' | 'chrome' | 'chroma' | 'themeBridge'; + +export interface AppearanceHubState { + view: 'hub' | 'motion' | 'motionAdvanced'; + selected: number; + /** Host window editing (the existing AppearanceState), when the host has appearance integration. */ + host?: AppearanceState; + hostName: string; + hostGuidance?: string; + /** Host values were changed and not saved yet. */ + hostDirty: boolean; + /** When the Motion preview last started (row selected, value changed, R); one run, never a loop. */ + previewStart?: number; +} + +export type HubAction = {kind: 'close'} | {kind: 'open'; destination: HubDestination} | {kind: 'motion'; motion: MotionSettings} | {kind: 'saveHost'}; + +/** Compact launcher: each row opens its canonical editor (/theme, /prompt, /cursor, UI chrome, /chroma, Motion, /theme-bridge). */ +const NMSH_ROWS: Array<{id: HubDestination | 'motion'; label: string}> = [ + {id: 'theme', label: 'Theme Studio'}, {id: 'prompt', label: 'Prompt'}, {id: 'cursor', label: 'Cursor & effects'}, {id: 'chrome', label: 'UI chrome'}, + {id: 'chroma', label: 'Chroma'}, {id: 'motion', label: 'Motion'}, {id: 'themeBridge', label: 'Theme Bridge'}, +]; + +export function createAppearanceHub(hostName: string, host?: AppearanceState, hostGuidance?: string): AppearanceHubState { + return {view: 'hub', selected: 0, ...(host ? {host} : {}), hostName, ...(hostGuidance ? {hostGuidance} : {}), hostDirty: false}; +} + +/** Host rows that exist for this host (opacity, blur mode, and blur strength for Numeric). */ +function hostRowCount(state: AppearanceHubState): number { + if (!state.host) return 0; + return BLUR_MODES[state.host.blurModeIndex] === 'Numeric' ? 3 : 2; +} + +export function appearanceHubKey(state: AppearanceHubState, key: Key, configuration: PromptConfiguration, now = Date.now()): HubAction | undefined { + if (state.view === 'motion' || state.view === 'motionAdvanced') { + const advanced = state.view === 'motionAdvanced'; + // The Motion list: Rendering, the five effects, then Advanced (a child screen with the selected rendering's own tuning). + const items: readonly MotionItem[] = advanced ? MOTION_TUNING_ITEMS : MOTION_ITEMS; + const count = items.length + (advanced ? 0 : 1); + if (key.kind === 'escape' || key.kind === 'interrupt') { + if (advanced) { state.view = 'motion'; state.selected = MOTION_ITEMS.length; state.previewStart = now; return undefined; } + state.view = 'hub'; state.selected = NMSH_ROWS.findIndex(row => row.id === 'motion'); return undefined; + } + if (key.kind === 'up' || key.kind === 'down') { state.selected = (state.selected + (key.kind === 'up' ? -1 : 1) + count) % count; state.previewStart = now; return undefined; } + if (key.kind === 'text' && key.value.toLowerCase() === 'r') { state.previewStart = now; return undefined; } + if (key.kind === 'left' || key.kind === 'right' || key.kind === 'enter') { + const item = items[state.selected]; + if (!item) { if (key.kind !== 'left') { state.view = 'motionAdvanced'; state.selected = 0; state.previewStart = now; } return undefined; } + const current = item.get(configuration.motion); + const index = item.values.indexOf(current); + const next = item.values[(index + (key.kind === 'left' ? -1 : 1) + item.values.length) % item.values.length]!; + state.previewStart = now; + return {kind: 'motion', motion: item.set(configuration.motion, next)}; + } + return undefined; + } + const total = NMSH_ROWS.length + hostRowCount(state); + if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'close'}; + if (key.kind === 'up' || key.kind === 'down') { state.selected = (state.selected + (key.kind === 'up' ? -1 : 1) + total) % total; return undefined; } + if (state.selected < NMSH_ROWS.length) { + if (key.kind !== 'enter' && key.kind !== 'right') return undefined; + const row = NMSH_ROWS[state.selected]!; + if (row.id === 'motion') { state.view = 'motion'; state.selected = 0; state.previewStart = now; return undefined; } + return {kind: 'open', destination: row.id}; + } + // Host rows: the existing opacity/blur editor, with Enter saving through the host integration. + if (!state.host) return undefined; + if (key.kind === 'enter') return state.hostDirty ? {kind: 'saveHost'} : undefined; + state.host.selectedIndex = state.selected - NMSH_ROWS.length; + if ((key.kind === 'left' || key.kind === 'right') && handleAppearanceKey(key, state.host)) { + state.hostDirty = true; + state.selected = NMSH_ROWS.length + Math.min(state.host.selectedIndex, hostRowCount(state) - 1); + } + return undefined; +} + +/** The Motion screen's preview for the selected row, or undefined outside it. */ +export function hubMotionPreview(state: AppearanceHubState, configuration: PromptConfiguration, columns: number, gate: MotionGate, now: number): MotionPreview | undefined { + if (state.view !== 'motion' && state.view !== 'motionAdvanced') return undefined; + const item = (state.view === 'motion' ? MOTION_ITEMS : MOTION_TUNING_ITEMS)[state.selected]; + return renderMotionPreview(item?.preview ?? 'commandLaunch', configuration.motion, gate, columns, state.previewStart ?? now - 10_000, now); +} + +export function renderAppearanceHub(state: AppearanceHubState, configuration: PromptConfiguration, columns: number, themeLabel: string, cursorBackend: string, + preview?: {gate: MotionGate; now: number}, height?: number): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const reset = '\u001b[0m'; + const mark = (selected: boolean) => selected ? `${accent}${GLYPHS.selection}${reset}` : ' '; + if (state.view === 'motion' || state.view === 'motionAdvanced') { + const advanced = state.view === 'motionAdvanced'; + const rendering = configuration.motion.rendering === 'rich' ? 'Rich' : 'Clean'; + const items: readonly MotionItem[] = advanced ? MOTION_TUNING_ITEMS : MOTION_ITEMS; + const head = [`${primary} Appearance › Motion${advanced ? ` › Advanced (${rendering})` : ''}${reset}`, ` ${subtle}${advanced + ? `Tuning for ${rendering} rendering only; ${rendering === 'Rich' ? 'Clean' : 'Rich'} keeps its own values.` + : 'General NMSh motion. Cursor motion lives in /cursor; Chroma color motion in /chroma. Reduced Motion and Decorative Effects Off stop all of it.'}${reset}`, '']; + const list: string[] = []; + items.forEach((item, index) => { + const selected = index === state.selected; + const value = item.labelOf(item.get(configuration.motion)); + list.push(`${mark(selected)} ${focusForeground(selected)}${padCells(item.label, 24)}${reset}${selected ? `${accent}‹ ${value} ›${reset}` : `${secondary}${value}${reset}`}`); + }); + if (!advanced) { + const selected = state.selected === items.length; + list.push(`${mark(selected)} ${focusForeground(selected)}${padCells('Advanced', 24)}${reset}${selected ? accent : secondary}${rendering} tuning ›${reset}`); + } + const noteText = items[state.selected]?.note ?? `Intensity and speed for ${rendering} rendering; Enter opens them`; + const note = ['', ` ${subtle}${noteText}${reset}`]; + const shown = preview ? hubMotionPreview(state, configuration, columns, preview.gate, preview.now) : undefined; + const controls = ['', renderControls([['↑↓', 'select'], ['←→', 'change'], ...(!advanced && state.selected === items.length ? [['Enter', 'open'] as [string, string]] : []), ...(shown ? [['R', 'replay'] as [string, string]] : []), ['Esc', 'back']])]; + // The preview is always the same size; a short terminal gives up the intro, then the preview, never the controls or the list. + const block = shown ? ['', ` ${subtle}Preview · ${rendering}${reset}`, ...shown.rows] : []; + const layouts = [[...head, ...list, ...note, ...block, ...controls], [head[0]!, ...list, ...note, ...block, ...controls], [head[0]!, ...list, ...note, ...controls], [head[0]!, ...list, ...controls], [...list, ...controls]]; + const rows = height === undefined ? layouts[0]! : layouts.find(layout => layout.length <= height) ?? layouts[layouts.length - 1]!; + return rows.map(row => truncateAnsi(row, columns)); + } + const cursor = configuration.cursor; + const motion = configuration.motion; + const anyMotion = MOTION_ROWS.some(row => motion[row.key] !== 'off'); + const summaries: Record = { + theme: [themeLabel, librarySummary(configuration.themes)].filter(Boolean).join(' · '), + prompt: configuration.provider === 'none' ? 'None · composer only' : `${providerLabel(configuration.provider)} · ${configuration.nmsh.textColors === 'neutral' ? 'Neutral text' : 'Theme text'}`, + themeBridge: (() => { const active = BRIDGE_TARGETS.filter(target => effectiveMode(configuration.themeBridge, target) !== 'independent'); + return active.length ? active.map(target => BRIDGE_TARGET_LABELS[target]).join(', ') : 'Off · every tool Independent'; })(), + cursor: `${cursorLabel(cursor.shape)} · ${cursor.motion === 'off' ? 'no motion' : cursorLabel(cursor.motion)}${cursor.effect !== 'none' ? ` · ${cursorLabel(cursor.effect)}` : ''} · ${cursorBackend}`, + chrome: configuration.uiChrome.source === 'theme' ? 'Follow theme' : 'Custom', + chroma: configuration.presentation.preset === 'off' ? 'Off' : `${configuration.presentation.preset} · ${configuration.presentation.motion}`, + motion: anyMotion ? `${motion.rendering === 'rich' ? 'Rich' : 'Clean'} · Launch ${MOTION_LABELS[motion.commandLaunch]} · Context ${MOTION_LABELS[motion.contextTransitions]} · Events ${MOTION_LABELS[motion.eventFeedback]}` : 'Off', + }; + const rows = [`${primary} Appearance${reset}`, ` ${subtle}Everything visual in one place; each row opens its own editor.${reset}`, '', ` ${subtle}NMSh${reset}`]; + NMSH_ROWS.forEach((row, index) => { + const selected = index === state.selected; + rows.push(`${mark(selected)} ${focusForeground(selected)}${padCells(row.label, 20)}${reset}${subtle}${summaries[row.id]}${reset} ${selected ? `${accent}›${reset}` : ''}`); + }); + rows.push(` ${subtle}Chroma ${configuration.presentation.preset === 'off' ? 'OFF' : 'ON'} · ${chromaPreviewNote(configuration.presentation.preset !== 'off')}${reset}`); + rows.push('', ` ${subtle}Host window${reset}`, ` ${secondary}${padCells('Host', 20)}${reset}${primary}${state.hostName}${reset}`); + if (state.host) { + const bar = (fraction: number) => `${'█'.repeat(Math.round(fraction * 10))}${'░'.repeat(10 - Math.round(fraction * 10))}`; + const hostRows = [['Opacity', `${bar(state.host.opacity)} ${Math.round(state.host.opacity * 100)}%`], ['Blur mode', BLUR_MODES[state.host.blurModeIndex]!]]; + if (BLUR_MODES[state.host.blurModeIndex] === 'Numeric') hostRows.push(['Blur', `${bar(state.host.blurStrength / 50)} ${state.host.blurStrength}`]); + hostRows.forEach(([label, value], index) => { + const selected = NMSH_ROWS.length + index === state.selected; + rows.push(`${mark(selected)} ${focusForeground(selected)}${padCells(label!, 20)}${reset}${selected ? `${accent}‹ ${value} ›${reset}` : `${secondary}${value}${reset}`}`); + }); + if (state.hostDirty) rows.push(` ${subtle}Enter saves the host window settings${reset}`); + } else rows.push(` ${subtle}${state.hostGuidance ?? `Opacity and blur are controlled by ${state.hostName}.`}${reset}`); + rows.push('', renderControls([['↑↓', 'select'], ['Enter', 'open'], ...(state.host ? [['←→', 'adjust host'] as [string, string]] : []), ['Esc', 'close']])); + return rows.map(row => truncateAnsi(row, columns)); +} diff --git a/src/appearance/AppearancePanel.ts b/src/appearance/AppearancePanel.ts index d749ab4d..9db5d948 100644 --- a/src/appearance/AppearancePanel.ts +++ b/src/appearance/AppearancePanel.ts @@ -1,11 +1,11 @@ import {Key} from '../terminal/keys.js'; -import {foreground, UI_COLORS} from '../ui/palette.js'; +import {focusForeground, foreground, UI_COLORS, lazyForeground} from '../ui/palette.js'; import {truncateAnsi} from '../util/text.js'; import {BlurMode} from './ghostty.js'; -const PRIMARY = foreground(UI_COLORS.primary); -const SECONDARY = foreground(UI_COLORS.secondary); -const INTERACTIVE = foreground(UI_COLORS.accent); +const PRIMARY = lazyForeground(UI_COLORS.primary); +const SECONDARY = lazyForeground(UI_COLORS.secondary); +const INTERACTIVE = lazyForeground(UI_COLORS.accent); const RESET = '\u001B[0m'; export const BLUR_MODES: BlurMode[] = ['Off', 'Numeric', 'Glass Regular', 'Glass Clear']; @@ -63,7 +63,7 @@ export function renderAppearancePanel(state: AppearanceState, columns: number): }; const sel = (index: number) => index === state.selectedIndex ? `${INTERACTIVE}>${RESET}` : ' '; - const labelColor = (index: number) => index === state.selectedIndex ? PRIMARY : SECONDARY; + const labelColor = (index: number) => focusForeground(index === state.selectedIndex); // Opacity rows.push(` ${sel(0)} ${labelColor(0)}Opacity ${INTERACTIVE}${drawBar(state.opacity)} ${Math.round(state.opacity * 100)}%${RESET}`); diff --git a/src/appearance/ChromeEditor.ts b/src/appearance/ChromeEditor.ts new file mode 100644 index 00000000..85de074c --- /dev/null +++ b/src/appearance/ChromeEditor.ts @@ -0,0 +1,72 @@ +import type {Key} from '../terminal/keys.js'; +import type {ColorLevel} from '../presentation/capabilities.js'; +import {colorEscape} from '../chroma/escape.js'; +import {hexColor, parseHexColor} from '../chroma/color.js'; +import {colorPickerKey, createColorPicker, renderColorPicker, type ColorPickerState} from '../ui/ColorPicker.js'; +import {framePanel} from '../ui/PanelShell.js'; +import {renderControls} from '../ui/controls.js'; +import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; +import {GLYPHS} from '../ui/glyphs.js'; +import {padCells, truncateAnsi} from '../util/text.js'; +import {ROLE_LABELS, UI_THEME_ROLES, type UiThemeRole} from './customTheme.js'; + +/** + * Custom UI chrome colors: the semantic interface roles NMSh derives its + * frames, rules, tabs, selection and accents from. Edits stay a draft until + * Save; Esc leaves the saved chrome untouched. + */ +export interface ChromeEditorState { + colors: Record; + selected: number; + picker?: {role: UiThemeRole; state: ColorPickerState}; +} + +export const CHROME_EDITOR_MIN_SIZE = {columns: 48, rows: 16} as const; + +export function createChromeEditor(colors: Record): ChromeEditorState { + return {colors: {...colors}, selected: 0}; +} + +/** Rows: one per role, then Save. */ +const ROW_COUNT = UI_THEME_ROLES.length + 1; + +export function chromeEditorKey(state: ChromeEditorState, key: Key, level: ColorLevel): {kind: 'save'; colors: Record} | {kind: 'cancel'} | undefined { + if (state.picker) { + const outcome = colorPickerKey(state.picker.state, key, level); + if (outcome === 'confirm') { state.colors[state.picker.role] = hexColor(state.picker.state.color); state.picker = undefined; } + else if (outcome === 'cancel') state.picker = undefined; + return undefined; + } + if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'cancel'}; + if (key.kind === 'up') state.selected = (state.selected + ROW_COUNT - 1) % ROW_COUNT; + else if (key.kind === 'down') state.selected = (state.selected + 1) % ROW_COUNT; + else if (key.kind === 'enter') { + const role = UI_THEME_ROLES[state.selected]; + if (!role) return {kind: 'save', colors: {...state.colors}}; + state.picker = {role, state: createColorPicker(state.colors[role], level)}; + } + return undefined; +} + +export function renderChromeEditor(state: ChromeEditorState, columns: number, height: number, level: ColorLevel): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const reset = '\u001B[0m'; + const out = [` ${primary}UI chrome colors${reset} ${subtle}frames, rules, tabs, selection and accents · NMSh only${reset}`, '']; + if (state.picker) { + out.push(...renderColorPicker(state.picker.state, ROLE_LABELS[state.picker.role], columns, level, + {columns: Math.min(40, columns - 8), rows: Math.max(3, Math.min(8, height - 16))})); + } else { + UI_THEME_ROLES.forEach((role, index) => { + const selected = index === state.selected; + const swatch = level === 'none' ? '' : `${colorEscape(48, parseHexColor(state.colors[role])!, level)} ${reset} `; + out.push(`${selected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${focusForeground(selected)}${padCells(ROLE_LABELS[role], 14)}${reset}${swatch}${state.colors[role]}`); + }); + const onSave = state.selected === UI_THEME_ROLES.length; + out.push(`${onSave ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${onSave ? primary : secondary}Save & use${reset} ${subtle}applies to NMSh chrome now${reset}`); + out.push('', renderControls([['↑↓', 'select'], ['Enter', 'edit / save'], ['Esc', 'cancel']])); + } + return framePanel(out.map(row => truncateAnsi(row, columns)), columns).slice(0, Math.max(1, height)); +} diff --git a/src/appearance/ThemeStudio.ts b/src/appearance/ThemeStudio.ts new file mode 100644 index 00000000..f237dc99 --- /dev/null +++ b/src/appearance/ThemeStudio.ts @@ -0,0 +1,624 @@ +import {chromaPreviewNote} from './chromaNotes.js'; +import {mkdirSync, readFileSync, renameSync, statSync, writeFileSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {basename, isAbsolute, join, resolve} from 'node:path'; +import type {Key} from '../terminal/keys.js'; +import type {ColorLevel} from '../presentation/capabilities.js'; +import {colorEscape} from '../chroma/escape.js'; +import {parseHexColor} from '../chroma/color.js'; +import {THEME_PALETTE_IDS, type NativePaletteId} from '../prompt/configuration.js'; +import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js'; +import {nmshConfigDirectory} from '../configuration/paths.js'; +import {editText} from '../ui/formControls.js'; +import {framePanel, renderTabStrip} from '../ui/PanelShell.js'; +import {renderControls} from '../ui/controls.js'; +import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; +import {GLYPHS} from '../ui/glyphs.js'; +import {colorPickerKey, createColorPicker, renderColorPicker, type ColorPickerState} from '../ui/ColorPicker.js'; +import {padCells, truncateAnsi, truncateText} from '../util/text.js'; +import { + exportTheme, PROMPT_THEME_ROLES, ROLE_LABELS, themeSlug, UI_THEME_ROLES, type CustomTheme, type PromptThemeRole, type UiThemeRole, +} from './customTheme.js'; +import {cloneFromPalette} from './themeSelection.js'; +import {defaultUiColors} from './uiTheme.js'; +import {hexColor} from '../chroma/color.js'; +import {categoryOf, findTheme, IMPORTER_VERSION, provenanceLabel, type ThemeAsset, type ThemeOrigin} from './themeLibrary.js'; +import {assetRef, builtinRef, builtinTheme, type ThemeRef} from './themeRefs.js'; +import {IMPORT_FORMAT_CHOICES, IMPORT_SIZE_LIMIT, importFormatLabel, importThemeSource, type ImportOutcome, type ImportPreview} from './themeImporters.js'; +import {BRIDGE_TARGET_LABELS, type BridgeTargetId} from '../themeBridge/model.js'; +import type {CatppuccinAccent} from './themeFamilies.js'; + +/** + * Theme Studio (/theme): create, edit, import, manage and select NMSh Native + * theme assets. Built-in themes are immutable; Imported and Custom themes + * are the same Native assets edited by the same editor and rendered by the + * same renderer (imported is provenance only). The studio never writes the + * configuration itself: it returns actions that TerminalApp applies through + * the library actions and the normal configuration path. Import parses and + * previews first; cancel stores nothing. + */ + +// ---- Color editor ----------------------------------------------------------- + +type EditorRow = + | {kind: 'name'} + | {kind: 'basedOn'} + | {kind: 'dark'} + | {kind: 'role'; group: 'prompt' | 'ui'; role: PromptThemeRole | UiThemeRole} + | {kind: 'reset'} + | {kind: 'save'}; + +export const STUDIO_ROWS: readonly EditorRow[] = [ + {kind: 'name'}, {kind: 'basedOn'}, {kind: 'dark'}, + ...PROMPT_THEME_ROLES.map(role => ({kind: 'role' as const, group: 'prompt' as const, role})), + ...UI_THEME_ROLES.map(role => ({kind: 'role' as const, group: 'ui' as const, role})), + {kind: 'reset'}, {kind: 'save'}, +]; + +export interface ThemeEditorState { + draft: CustomTheme; + /** The asset being edited; undefined creates a new Custom theme on save. */ + assetId?: string; + /** The asset's saved theme, if any. */ + saved?: CustomTheme; + /** Provenance line for imported assets (display only). */ + provenance?: string; + selected: number; + /** The clone source shown on the Based on row; Enter clones it. */ + base: NativePaletteId; + picker?: {row: EditorRow & {kind: 'role'}; state: ColorPickerState}; + editingName?: string; + message?: string; + /** A pending Reset to base that would discard unsaved draft edits. */ + confirmReset?: boolean; +} + +export const STUDIO_MIN_SIZE = {columns: 56, rows: 18} as const; + +/** An editor over a theme; `current` undefined starts a new Custom theme cloned from `palette`. */ +export function createThemeEditor(current: CustomTheme | undefined, palette: NativePaletteId, asset?: ThemeAsset): ThemeEditorState { + const draft = current ? structuredClone(current) : cloneFromPalette(palette); + // An existing theme resets to the theme it was based on, found by its recorded name. + const recorded = current?.basedOn ? THEME_PALETTE_IDS.find(id => id !== 'custom' && NATIVE_PROMPT_THEMES[id].label === current.basedOn) : undefined; + return {draft, ...(current ? {saved: structuredClone(current)} : {}), ...(asset ? {assetId: asset.id, provenance: provenanceLabel(asset)} : {}), + selected: 0, base: recorded ?? (palette === 'custom' ? 'lavender' : palette)}; +} + +/** Back-compatible name for a single editor (tests and callers that only edit). */ +export const createThemeStudioEditor = createThemeEditor; + +export function themeDefaults(): Record { + const ui = defaultUiColors(); + return {accent: hexColor(ui.accent), primary: hexColor(ui.primary), secondary: hexColor(ui.secondary), subtle: hexColor(ui.subtle), + separator: hexColor(ui.separator), selection: hexColor(ui.selection), success: hexColor(ui.success), warning: '#d99a3e', + failure: hexColor(ui.failure), info: '#4fb3c4'}; +} + +/** Where exports go: the NMSh config directory, never anywhere the user did not choose. */ +export function themesDirectory(env: NodeJS.ProcessEnv = process.env): string { + return join(nmshConfigDirectory(env), 'themes'); +} + +/** + * Portable NMSh Theme JSON for a theme: the Native theme itself. Library + * provenance (and so any local source path) is not part of the theme data, + * so it can never leak into an export. + */ +export function writeThemeExport(theme: CustomTheme, directory = themesDirectory()): string { + mkdirSync(directory, {recursive: true, mode: 0o700}); + const path = join(directory, `${themeSlug(theme.name)}.nmsh-theme.json`); + const temporary = `${path}.${process.pid}.tmp`; + writeFileSync(temporary, exportTheme(theme), {encoding: 'utf8', mode: 0o600}); + renameSync(temporary, path); + return path; +} + +export function expandPath(input: string, cwd: string): string { + const text = input.trim(); + if (text === '~' || text.startsWith('~/')) return join(homedir(), text.slice(1)); + return isAbsolute(text) ? text : resolve(cwd, text); +} + +/** Reads a local theme file for preview: bounded size, parsed as data only, never stored here. */ +export function readThemeImport(input: string, cwd: string, format: (typeof IMPORT_FORMAT_CHOICES)[number] = 'auto'): (ImportPreview & {path: string}) | {errors: string[]} { + const path = expandPath(input, cwd); + try { + const stat = statSync(path); + if (!stat.isFile()) return {errors: [`${path} is not a file.`]}; + if (stat.size > IMPORT_SIZE_LIMIT) return {errors: ['File is larger than 256 KiB.']}; + const outcome: ImportOutcome = importThemeSource(readFileSync(path, 'utf8'), basename(path), themeDefaults(), builtinTheme('lavender'), format); + return 'errors' in outcome ? outcome : {...outcome, path}; + } catch (error) { + return {errors: [`Could not read ${path}: ${(error as NodeJS.ErrnoException).code ?? 'error'}.`]}; + } +} + +/** Provenance recorded for an import; the path stays local (never exported). */ +export function importOrigin(preview: ImportPreview & {path: string}, now = new Date()): ThemeOrigin { + return {kind: preview.format, ...(preview.sourceName ? {sourceName: preview.sourceName} : {}), sourcePath: preview.path, + importerVersion: IMPORTER_VERSION, importedAt: now.toISOString()}; +} + +function baseTheme(state: ThemeEditorState): CustomTheme { + return cloneFromPalette(state.base, undefined, state.draft.name); +} + +/** Draft colors differ from the base: a reset would discard edits. */ +export function draftDiffersFromBase(state: ThemeEditorState): boolean { + const base = baseTheme(state); + return JSON.stringify([state.draft.prompt, state.draft.ui, state.draft.dark]) !== JSON.stringify([base.prompt, base.ui, base.dark]); +} + +/** Reset to base: only the draft changes; Save stays the one persistence point and Esc abandons everything. */ +export function resetDraftToBase(state: ThemeEditorState): void { + const base = baseTheme(state); + state.draft = {...state.draft, prompt: base.prompt, ui: base.ui, dark: base.dark, basedOn: base.basedOn}; + state.confirmReset = false; + state.message = `Draft reset to ${NATIVE_PROMPT_THEMES[state.base].label}. Save to keep it; Esc abandons the draft.`; +} + +export function resetRoleToBase(state: ThemeEditorState, row: EditorRow & {kind: 'role'}): void { + const base = baseTheme(state); + if (row.group === 'prompt') state.draft.prompt[row.role as PromptThemeRole] = base.prompt[row.role as PromptThemeRole]; + else state.draft.ui[row.role as UiThemeRole] = base.ui[row.role as UiThemeRole]; + state.message = `${ROLE_LABELS[row.role]} reset to ${NATIVE_PROMPT_THEMES[state.base].label}.`; +} + +export type EditorResult = {kind: 'cancel'} | {kind: 'save'; theme: CustomTheme} | undefined; + +function roleColor(theme: CustomTheme, row: EditorRow & {kind: 'role'}): string { + return row.group === 'prompt' ? theme.prompt[row.role as PromptThemeRole] : theme.ui[row.role as UiThemeRole]; +} + +export function editorKey(state: ThemeEditorState, key: Key, level: ColorLevel): EditorResult { + state.message = undefined; + if (state.picker) { + const outcome = colorPickerKey(state.picker.state, key, level); + if (outcome === 'confirm') { + const hex = hexColor(state.picker.state.color); + const row = state.picker.row; + if (row.group === 'prompt') state.draft.prompt[row.role as PromptThemeRole] = hex; + else state.draft.ui[row.role as UiThemeRole] = hex; + state.picker = undefined; + } else if (outcome === 'cancel') state.picker = undefined; + return undefined; + } + if (state.editingName !== undefined) { + if (key.kind === 'escape' || key.kind === 'interrupt') state.editingName = undefined; + else if (key.kind === 'enter') { + const name = state.editingName.trim(); + if (name && name.length <= 48) { state.draft.name = name; state.editingName = undefined; } + else state.message = 'Name must be 1–48 characters.'; + } else { + const next = editText(state.editingName, key); + if (next !== undefined) state.editingName = next.replace(/[\u0000-\u001f\u007f-\u009f]/gu, '').slice(0, 48); + } + return undefined; + } + if (state.confirmReset) { + if (key.kind === 'enter' || (key.kind === 'text' && key.value.toLowerCase() === 'y')) resetDraftToBase(state); + else if (key.kind === 'escape' || key.kind === 'interrupt' || (key.kind === 'text' && key.value.toLowerCase() === 'n')) state.confirmReset = false; + return undefined; + } + if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'cancel'}; + if (key.kind === 'text' && key.value.toLowerCase() === 'r' && STUDIO_ROWS[state.selected]?.kind === 'role') { + resetRoleToBase(state, STUDIO_ROWS[state.selected] as EditorRow & {kind: 'role'}); + return undefined; + } + if (key.kind === 'up') state.selected = (state.selected + STUDIO_ROWS.length - 1) % STUDIO_ROWS.length; + else if (key.kind === 'down') state.selected = (state.selected + 1) % STUDIO_ROWS.length; + const row = STUDIO_ROWS[state.selected]!; + if ((key.kind === 'left' || key.kind === 'right') && row.kind === 'basedOn') { + const sources: NativePaletteId[] = THEME_PALETTE_IDS.filter(id => id !== 'custom'); + state.base = sources[(sources.indexOf(state.base) + (key.kind === 'left' ? -1 : 1) + sources.length) % sources.length]!; + } else if ((key.kind === 'left' || key.kind === 'right' || (key.kind === 'text' && key.value === ' ')) && row.kind === 'dark') { + state.draft.dark = !state.draft.dark; + } else if (key.kind === 'enter') { + switch (row.kind) { + case 'name': state.editingName = state.draft.name; break; + case 'basedOn': + case 'reset': + if (draftDiffersFromBase(state)) state.confirmReset = true; + else resetDraftToBase(state); + break; + case 'dark': state.draft.dark = !state.draft.dark; break; + case 'role': state.picker = {row, state: createColorPicker(roleColor(state.draft, row), level)}; break; + case 'save': return {kind: 'save', theme: structuredClone(state.draft)}; + } + } + return undefined; +} + +const RESET = '\u001B[0m'; + +function swatches(colors: readonly string[], level: ColorLevel): string { + if (level === 'none') return ''; + return colors.map(hex => `${colorEscape(48, parseHexColor(hex)!, level)} ${RESET}`).join(''); +} + +export function renderThemeEditor(state: ThemeEditorState, columns: number, height: number, level: ColorLevel, preview: readonly string[], title = 'Theme Studio'): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const heading = state.assetId ? `Edit · ${state.draft.name}` : 'New custom theme'; + const out: string[] = [` ${primary}${title} › ${heading}${RESET} ${subtle}${state.provenance ?? 'NMSh Native theme · colors NMSh-owned UI only'}${RESET}`, '']; + if (state.picker) { + const label = `${ROLE_LABELS[state.picker.row.role]} ${subtle}(${state.picker.row.group === 'prompt' ? 'prompt' : 'interface'})${RESET}`; + out.push(...renderColorPicker(state.picker.state, label, columns, level, {columns: Math.min(40, columns - 8), rows: Math.max(3, Math.min(8, height - 16))})); + return out.map(row => truncateAnsi(row, columns)); + } + const lines = STUDIO_ROWS.map((row, index) => { + const pointer = index === state.selected ? `${accent}${GLYPHS.selection}${RESET}` : ' '; + const label = (text: string) => `${focusForeground(index === state.selected)}${padCells(text, 16)}${RESET}`; + switch (row.kind) { + case 'name': return `${pointer} ${label('Name')}${state.editingName !== undefined ? `${primary}${state.editingName}${accent}_${RESET}` : state.draft.name}`; + case 'basedOn': return `${pointer} ${label('Based on')}${NATIVE_PROMPT_THEMES[state.base].label} ${subtle}←→ choose · Enter reset draft to it${RESET}`; + case 'dark': return `${pointer} ${label('Text tiers')}${state.draft.dark ? 'Dark terminal' : 'Keep NMSh text colors'}`; + case 'role': { + const hex = roleColor(state.draft, row); + return `${pointer} ${label(`${row.group === 'ui' ? 'UI ' : ''}${ROLE_LABELS[row.role]}`)}${level === 'none' ? '' : `${swatches([hex], level)} `}${hex}`; + } + case 'reset': return `${pointer} ${label('Reset to base')}${subtle}draft colors back to ${NATIVE_PROMPT_THEMES[state.base].label}; saved theme unchanged${RESET}`; + case 'save': return `${pointer} ${label('Save')}${subtle}${state.assetId ? 'save this theme' : 'add to Custom themes'}${RESET}`; + } + }); + // A window around the selection keeps the list usable on short terminals. + const budget = Math.max(3, height - 5 - preview.length - (state.message ? 2 : 0)); + const start = Math.max(0, Math.min(state.selected - Math.floor(budget / 2), lines.length - budget)); + out.push(...lines.slice(start, start + budget)); + if (preview.length && height - 5 - preview.length >= 3) out.push('', ...preview); + if (state.confirmReset) { + out.push('', ` ${primary}Reset the draft to ${NATIVE_PROMPT_THEMES[state.base].label}? Unsaved color edits are discarded; the saved theme is unchanged.${RESET}`); + out.push('', renderControls([['Enter', 'reset draft'], ['Esc', 'keep editing']])); + return out.map(row => truncateAnsi(row, columns)); + } + if (state.message) out.push('', ` ${secondary}${state.message}${RESET}`); + const onRole = STUDIO_ROWS[state.selected]?.kind === 'role'; + out.push('', renderControls([['↑↓', 'select'], ['Enter', 'edit'], ['←→', 'change'], ...(onRole ? [['R', 'reset role'] as [string, string]] : []), ['Esc', 'back']])); + return out.map(row => truncateAnsi(row, columns)); +} + +// ---- Library (tabs) --------------------------------------------------------- + +export const STUDIO_TABS = ['Built-in', 'Imported', 'Custom', 'Import'] as const; +export type StudioTab = 'builtin' | 'imported' | 'custom' | 'import'; +const TAB_IDS: readonly StudioTab[] = ['builtin', 'imported', 'custom', 'import']; + +/** What the studio sees of the configuration; it never edits it. */ +export interface StudioContext { + themes: readonly ThemeAsset[]; + activeRef?: ThemeRef; + accent: CatppuccinAccent; + /** Theme Bridge targets pinned to each reference, for delete warnings and labels. */ + pinnedTo: (ref: ThemeRef) => BridgeTargetId[]; + /** The persisted global Chroma, described (for the local preview switch). */ + chroma?: string; + /** The active theme's display name (for Duplicate current). */ + activeName?: string; +} + +export interface ThemeStudioState { + tab: StudioTab; + focus: 'tabs' | 'list'; + /** Selected row per library tab. */ + selected: Record, number>; + editor?: ThemeEditorState; + rename?: {id: string; text: string}; + confirmDelete?: {id: string; name: string; pinned: BridgeTargetId[]}; + /** Import tab: format choice, path, and the parsed preview awaiting confirmation. */ + importFormat: number; + importField: 'format' | 'path'; + importPath: string; + importPreview?: ImportPreview & {path: string}; + message?: string; + /** + * Local preview only: render the preview through the user's current Chroma. + * Off by default so the real theme colors are visible; never persisted and + * never changes the global Chroma setting. + */ + previewChroma: boolean; +} + +export type StudioAction = + | {kind: 'close'} + | {kind: 'activate'; ref: ThemeRef} + | {kind: 'saveTheme'; id?: string; theme: CustomTheme} + | {kind: 'importTheme'; theme: CustomTheme; origin: ThemeOrigin} + | {kind: 'rename'; id: string; name: string} + | {kind: 'duplicate'; id: string} + | {kind: 'duplicateBuiltin'; ref: ThemeRef} + | {kind: 'duplicateCurrent'} + | {kind: 'delete'; id: string; confirmIndependent: boolean} + | {kind: 'export'; id: string}; + +const BUILTIN_PALETTES = THEME_PALETTE_IDS.filter((id): id is Exclude => id !== 'custom'); + +export function createThemeStudio(context: StudioContext, tab: StudioTab = 'builtin'): ThemeStudioState { + const state: ThemeStudioState = {tab, focus: 'list', selected: {builtin: 0, imported: 0, custom: 0}, importFormat: 0, importField: 'path', importPath: '', previewChroma: false}; + // Open on the active theme where it lives. + const active = context.activeRef; + const asset = active?.startsWith('asset:') ? findTheme(context.themes, active.slice(6)) : undefined; + if (asset) { + state.tab = categoryOf(asset); + state.selected[state.tab] = libraryItems(context, state.tab).findIndex(item => item.id === asset.id) + (state.tab === 'custom' ? CUSTOM_ACTIONS : 0); + } else if (active?.startsWith('builtin:')) { + const palette = active.slice(8).split('@')[0]; + state.selected.builtin = Math.max(0, BUILTIN_PALETTES.indexOf(palette as Exclude)); + } + if (tab !== 'builtin') state.tab = tab; + return state; +} + +function libraryItems(context: StudioContext, tab: 'imported' | 'custom'): ThemeAsset[] { + return context.themes.filter(asset => categoryOf(asset) === tab); +} + +function rowCount(state: ThemeStudioState, context: StudioContext): number { + if (state.tab === 'builtin') return BUILTIN_PALETTES.length; + if (state.tab === 'imported') return libraryItems(context, 'imported').length; + if (state.tab === 'custom') return libraryItems(context, 'custom').length + CUSTOM_ACTIONS; + return 0; +} + +/** The asset under the selection on the Imported/Custom tabs (Custom row 0 is "New custom theme"). */ +export function selectedAsset(state: ThemeStudioState, context: StudioContext): ThemeAsset | undefined { + if (state.tab === 'imported') return libraryItems(context, 'imported')[state.selected.imported]; + if (state.tab === 'custom') return state.selected.custom < CUSTOM_ACTIONS ? undefined : libraryItems(context, 'custom')[state.selected.custom - CUSTOM_ACTIONS]; + return undefined; +} + +export function selectedBuiltinRef(state: ThemeStudioState, context: StudioContext): ThemeRef { + return builtinRef(BUILTIN_PALETTES[state.selected.builtin] ?? 'lavender', context.accent); +} + +/** The theme the studio is showing: the editor draft, the import preview, or the selected row. */ +export function previewTheme(state: ThemeStudioState, context: StudioContext): {theme: CustomTheme; palette?: Exclude} | undefined { + if (state.editor) return {theme: state.editor.draft}; + if (state.tab === 'import') return state.importPreview ? {theme: state.importPreview.theme} : undefined; + if (state.tab === 'builtin') { + const palette = BUILTIN_PALETTES[state.selected.builtin] ?? 'lavender'; + return {theme: builtinTheme(palette, context.accent), palette}; + } + const asset = selectedAsset(state, context); + return asset ? {theme: asset.theme} : undefined; +} + +function switchTab(state: ThemeStudioState, delta: number): void { + state.tab = TAB_IDS[(TAB_IDS.indexOf(state.tab) + delta + TAB_IDS.length) % TAB_IDS.length]!; + state.message = undefined; +} + +/** Custom tab: two action rows (New, Duplicate current) before the themes. */ +const CUSTOM_ACTIONS = 2; + +/** C toggles the local preview Chroma wherever no text is being typed. */ +function typing(state: ThemeStudioState): boolean { + return Boolean(state.rename || state.editor?.picker || state.editor?.editingName !== undefined || (state.tab === 'import' && state.importField === 'path' && state.focus === 'list' && !state.importPreview && !state.editor)); +} + +export function studioKey(state: ThemeStudioState, key: Key, level: ColorLevel, cwd: string, context: StudioContext): StudioAction | undefined { + if (key.kind === 'text' && key.value.toLowerCase() === 'c' && !typing(state) && !state.confirmDelete) { + state.previewChroma = !state.previewChroma; + return undefined; + } + if (state.editor) { + const result = editorKey(state.editor, key, level); + if (result?.kind === 'cancel') { state.editor = undefined; return undefined; } + if (result?.kind === 'save') { + const id = state.editor.assetId; + state.editor = undefined; + return {kind: 'saveTheme', ...(id ? {id} : {}), theme: result.theme}; + } + return undefined; + } + if (state.rename) { + if (key.kind === 'escape' || key.kind === 'interrupt') state.rename = undefined; + else if (key.kind === 'enter') { + const {id, text} = state.rename; + state.rename = undefined; + return {kind: 'rename', id, name: text}; + } else { + const next = editText(state.rename.text, key); + if (next !== undefined) state.rename.text = next.replace(/[\u0000-\u001f\u007f-\u009f]/gu, '').slice(0, 48); + } + return undefined; + } + if (state.confirmDelete) { + const {id, pinned} = state.confirmDelete; + if (key.kind === 'enter' || (key.kind === 'text' && key.value.toLowerCase() === 'y')) { + state.confirmDelete = undefined; + return {kind: 'delete', id, confirmIndependent: pinned.length > 0}; + } + if (key.kind === 'escape' || key.kind === 'interrupt' || (key.kind === 'text' && key.value.toLowerCase() === 'n')) state.confirmDelete = undefined; + return undefined; + } + state.message = undefined; + if (state.tab === 'import') return importKey(state, key, cwd); + if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'close'}; + if (key.kind === 'left' || key.kind === 'right') { switchTab(state, key.kind === 'left' ? -1 : 1); return undefined; } + if (state.focus === 'tabs') { + if (key.kind === 'down' || key.kind === 'enter') state.focus = 'list'; + return undefined; + } + const count = rowCount(state, context); + const tab = state.tab; + if (key.kind === 'up' || key.kind === 'down') { + if (key.kind === 'up' && state.selected[tab] === 0) { state.focus = 'tabs'; return undefined; } + if (count) state.selected[tab] = Math.max(0, Math.min(count - 1, state.selected[tab] + (key.kind === 'up' ? -1 : 1))); + return undefined; + } + const letter = key.kind === 'text' ? key.value.toLowerCase() : ''; + if (tab === 'builtin') { + const ref = selectedBuiltinRef(state, context); + if (key.kind === 'enter') return {kind: 'activate', ref}; + if (letter === 'd') return {kind: 'duplicateBuiltin', ref}; + if (letter === 'e') { + // Built-ins are immutable: editing starts a new Custom theme from it. + const palette = BUILTIN_PALETTES[state.selected.builtin] ?? 'lavender'; + state.editor = createThemeEditor(undefined, palette); + state.editor.draft = {...builtinTheme(palette, context.accent), name: `My ${builtinTheme(palette, context.accent).name}`.slice(0, 48)}; + } + return undefined; + } + if (tab === 'custom' && state.selected.custom === 1) { + if (key.kind === 'enter') return {kind: 'duplicateCurrent'}; + return undefined; + } + if (tab === 'custom' && state.selected.custom === 0) { + if (key.kind === 'enter' || letter === 'n') { + const base = context.activeRef?.startsWith('builtin:') ? context.activeRef.slice(8).split('@')[0] as NativePaletteId : 'lavender'; + state.editor = createThemeEditor(undefined, base); + } + return undefined; + } + const asset = selectedAsset(state, context); + if (!asset) { + if (key.kind === 'enter' && tab === 'imported') { state.tab = 'import'; } + return undefined; + } + if (key.kind === 'enter') return {kind: 'activate', ref: assetRef(asset.id)}; + if (letter === 'e') state.editor = createThemeEditor(asset.theme, 'lavender', asset); + else if (letter === 'n') state.rename = {id: asset.id, text: asset.theme.name}; + else if (letter === 'd') return {kind: 'duplicate', id: asset.id}; + else if (letter === 'x') return {kind: 'export', id: asset.id}; + else if (key.kind === 'delete' || key.kind === 'backspace' || letter === 'r') { + if (context.activeRef === assetRef(asset.id)) state.message = `${asset.theme.name} is the active theme. Set another theme active first.`; + else state.confirmDelete = {id: asset.id, name: asset.theme.name, pinned: context.pinnedTo(assetRef(asset.id))}; + } + return undefined; +} + +function importKey(state: ThemeStudioState, key: Key, cwd: string): StudioAction | undefined { + if (state.importPreview) { + if (key.kind === 'enter') { + const preview = state.importPreview; + state.importPreview = undefined; + state.importPath = ''; + return {kind: 'importTheme', theme: preview.theme, origin: importOrigin(preview)}; + } + // Cancel stores nothing: the parsed preview is simply dropped. + if (key.kind === 'escape' || key.kind === 'interrupt') { state.importPreview = undefined; state.message = 'Import cancelled; nothing was saved.'; } + return undefined; + } + if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'close'}; + if (state.focus === 'tabs') { + if (key.kind === 'left' || key.kind === 'right') switchTab(state, key.kind === 'left' ? -1 : 1); + else if (key.kind === 'down' || key.kind === 'enter') { state.focus = 'list'; state.importField = 'format'; } + return undefined; + } + if (key.kind === 'up' || key.kind === 'down') { + if (key.kind === 'up' && state.importField === 'format') state.focus = 'tabs'; + else state.importField = key.kind === 'up' ? 'format' : 'path'; + return undefined; + } + if (state.importField === 'format') { + if (key.kind === 'left' || key.kind === 'right') { + state.importFormat = (state.importFormat + (key.kind === 'left' ? -1 : 1) + IMPORT_FORMAT_CHOICES.length) % IMPORT_FORMAT_CHOICES.length; + } else if (key.kind === 'enter') state.importField = 'path'; + return undefined; + } + if (key.kind === 'enter') { + if (!state.importPath.trim()) { state.message = 'Type the path of a local theme file.'; return undefined; } + const result = readThemeImport(state.importPath, cwd, IMPORT_FORMAT_CHOICES[state.importFormat]); + if ('errors' in result) state.message = result.errors.join(' '); + else state.importPreview = result; + return undefined; + } + if ((key.kind === 'left' || key.kind === 'right') && !state.importPath) { switchTab(state, key.kind === 'left' ? -1 : 1); return undefined; } + const next = editText(state.importPath, key); + if (next !== undefined) state.importPath = next.replace(/[\u0000-\u001f\u007f-\u009f]/gu, '').slice(0, 1024); + return undefined; +} + +/** + * The /theme panel. `preview` rows come from TerminalApp's real Native + * renderer for whatever previewTheme() returns, so every category previews + * identically. + */ +export function renderThemeStudio(state: ThemeStudioState, context: StudioContext, columns: number, height: number, level: ColorLevel, preview: readonly string[]): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const finish = (rows: string[]) => framePanel(rows.map(row => truncateAnsi(row, columns)), columns).slice(0, Math.max(1, height)); + const chromaNote = ` ${subtle}${chromaPreviewNote(state.previewChroma && (context.chroma ?? 'Off') !== 'Off')}${RESET}`; + const chromaLine = ` ${primary}\u001b[1mPreview Chroma${RESET} ${subtle}${state.previewChroma ? `${accent}On${RESET}${subtle}` : 'Off'} · Global Chroma ${context.chroma ?? 'Off'} · C toggles the preview only${RESET}`; + if (state.editor) return finish(renderThemeEditor(state.editor, columns, height - 2, level, preview.length ? [chromaLine, chromaNote, ...preview] : preview)); + const head = [renderTabStrip(STUDIO_TABS, TAB_IDS.indexOf(state.tab), columns, state.focus === 'tabs'), '']; + const body: string[] = []; + const controls: Array<[string, string]> = []; + const active = (ref: ThemeRef) => context.activeRef === ref ? ` ${accent}● active${RESET}` : ''; + const pinned = (ref: ThemeRef) => { + const targets = context.pinnedTo(ref); + return targets.length ? ` ${subtle}bridge: ${targets.map(target => BRIDGE_TARGET_LABELS[target]).join(', ')}${RESET}` : ''; + }; + const listBudget = Math.max(2, height - head.length - preview.length - 6); + const window = (items: readonly T[], selected: number) => { + const start = Math.max(0, Math.min(selected - Math.floor(listBudget / 2), items.length - listBudget)); + return {start, items: items.slice(start, start + listBudget)}; + }; + const mark = (selected: boolean) => selected && state.focus === 'list' ? `${accent}${GLYPHS.selection}${RESET}` : ' '; + + if (state.tab === 'builtin') { + body.push(` ${subtle}NMSh and bundled theme families. Built-ins are immutable; duplicate one to edit it.${RESET}`); + const {start, items} = window(BUILTIN_PALETTES, state.selected.builtin); + items.forEach((palette, offset) => { + const index = start + offset; + const theme = builtinTheme(palette, context.accent); + const ref = builtinRef(palette, context.accent); + body.push(`${mark(index === state.selected.builtin)} ${focusForeground(index === state.selected.builtin)}${padCells(theme.name, 26)}${RESET}${swatches(Object.values(theme.prompt).slice(0, 6), level)}${active(ref)}${pinned(ref)}`); + }); + controls.push(['Enter', 'set active'], ['D', 'duplicate to Custom'], ['E', 'edit a copy']); + } else if (state.tab === 'imported' || state.tab === 'custom') { + const items = libraryItems(context, state.tab); + const rows: Array<{label: string; detail: string; ref?: ThemeRef; colors?: string[]}> = state.tab === 'custom' + ? [{label: '+ New custom theme', detail: 'from the active built-in theme'}, + {label: `⧉ Duplicate current theme → Custom${context.activeName ? ` (${context.activeName})` : ''}`, detail: 'copies the active theme'}] : []; + rows.push(...items.map(asset => ({label: asset.theme.name, detail: provenanceLabel(asset), ref: assetRef(asset.id), colors: Object.values(asset.theme.prompt).slice(0, 6)}))); + if (!rows.length) body.push(` ${subtle}No imported themes yet. Open the Import tab to bring in a theme file.${RESET}`); + const selected = state.selected[state.tab]; + const {start, items: shown} = window(rows, selected); + shown.forEach((row, offset) => { + const index = start + offset; + body.push(`${mark(index === selected)} ${focusForeground(index === selected)}${padCells(truncateText(row.label, row.ref ? 26 : 60), row.ref ? 26 : 60)}${RESET}${row.colors ? swatches(row.colors, level) : ''}${row.ref ? `${active(row.ref)}${pinned(row.ref)}` : ''}`); + }); + const asset = selectedAsset(state, context); + if (asset) body.push('', ` ${subtle}${provenanceLabel(asset)}${RESET}`); + if (asset) controls.push(['Enter', 'set active'], ['E', 'edit'], ['N', 'rename'], ['D', 'duplicate'], ['X', 'export'], ['Del', 'delete']); + else if (state.tab === 'custom') controls.push(['Enter', 'create']); + } else { + const format = IMPORT_FORMAT_CHOICES[state.importFormat]!; + if (state.importPreview) { + const p = state.importPreview; + body.push(` ${primary}Import preview · ${p.theme.name}${RESET} ${subtle}${importFormatLabel(p.format)} · ${p.theme.dark ? 'dark' : 'light'}${RESET}`); + body.push(` ${swatches(PROMPT_THEME_ROLES.map(role => p.theme.prompt[role]), level)} ${subtle}prompt roles${RESET}`); + body.push(` ${swatches(UI_THEME_ROLES.map(role => p.theme.ui[role]), level)} ${subtle}interface roles${RESET}`); + for (const mapping of p.mapping.slice(0, 6)) body.push(` ${secondary}${padCells(mapping.role, 18)}${RESET}${subtle}← ${mapping.from}${RESET}`); + for (const warning of p.warnings) body.push(` ${subtle}• ${warning}${RESET}`); + controls.push(['Enter', 'save as Imported theme'], ['Esc', 'cancel (nothing saved)']); + } else { + body.push(` ${subtle}Parse a local theme file into an NMSh Native theme. Nothing is executed or fetched; you preview before anything is saved.${RESET}`, ''); + const focusFormat = state.focus === 'list' && state.importField === 'format'; + const focusPath = state.focus === 'list' && state.importField === 'path'; + body.push(`${mark(focusFormat)} ${focusFormat ? primary : secondary}${padCells('Format', 10)}${RESET}${focusFormat ? `${accent}‹ ${importFormatLabel(format)} ›${RESET}` : importFormatLabel(format)}`); + body.push(`${mark(focusPath)} ${focusPath ? primary : secondary}${padCells('Path', 10)}${RESET}${primary}${state.importPath}${RESET}${focusPath ? `${accent}_${RESET}` : ''}`); + body.push('', ` ${subtle}NMSh JSON · Base16/Base24 · Windows Terminal · Oh My Posh (JSON/YAML/TOML) · Kitty · Ghostty · iTerm2 · WezTerm TOML${RESET}`); + controls.push(['Enter', focusFormat ? 'to path' : 'preview'], ['↑↓', 'field'], ['Esc', 'close']); + } + } + if (state.rename) body.push('', ` ${primary}Rename${RESET} ${primary}${state.rename.text}${accent}_${RESET} ${subtle}Enter save · Esc cancel${RESET}`); + if (state.confirmDelete) { + const {name, pinned: targets} = state.confirmDelete; + body.push('', targets.length + ? ` ${primary}Delete ${name}? Theme Bridge ${targets.map(target => BRIDGE_TARGET_LABELS[target]).join(', ')} ${targets.length === 1 ? 'is' : 'are'} pinned to it and will become Independent.${RESET}` + : ` ${primary}Delete ${name}? This removes it from NMSh; exported files are kept.${RESET}`, ` ${subtle}Enter delete · Esc keep${RESET}`); + } + if (state.message) body.push('', ` ${secondary}${state.message}${RESET}`); + if (preview.length && !state.rename && !state.confirmDelete) { + body.push('', chromaLine, chromaNote, ...preview); + } + const help = state.focus === 'tabs' ? renderControls([['←→', 'switch'], ['↓', 'select'], ['Esc', 'close']]) + : renderControls([...controls, ['←→', 'tabs'], ...(state.tab === 'import' ? [] : [['Esc', 'close'] as [string, string]])]); + return finish([...head, ...body, '', help]); +} diff --git a/src/appearance/chromaNotes.ts b/src/appearance/chromaNotes.ts new file mode 100644 index 00000000..977c5b9b --- /dev/null +++ b/src/appearance/chromaNotes.ts @@ -0,0 +1,10 @@ +/** Shown wherever a theme preview includes Chroma, so it is never mistaken for the base theme. */ +export const CHROMA_PREVIEW_NOTE = 'Chroma is enabled · previews include Chroma. Turn Chroma Off to view the base theme colors.'; +/** What Chroma touches, said once (the Setup Cat Chroma row). */ +export const CHROMA_SCOPE_NOTE = 'Chroma recolors the Native prompt/effects. Theme/UI chrome and text follow their own appearance settings unless explicitly configured otherwise.'; + +/** Local preview state can differ from the saved Chroma setting. Always label what is visible. */ +export function chromaPreviewNote(on: boolean): string { + return on ? 'Previews are colorized. Turn Chroma Off to view the base theme colors. /chroma for details.' + : 'Showing base theme colors. /chroma for color and motion settings.'; +} diff --git a/src/appearance/customTheme.ts b/src/appearance/customTheme.ts new file mode 100644 index 00000000..1d8ddd8d --- /dev/null +++ b/src/appearance/customTheme.ts @@ -0,0 +1,238 @@ +import type {FamilyRoles, FamilyUi} from './themeFamilies.js'; + +/** + * Custom Native NMSh themes. The canonical format is NMSh Theme JSON: + * declarative semantic roles only, versioned, validated on every load and + * never executed. Explicit per-module colors in the prompt configuration stay + * authoritative over any theme. Unknown top-level fields survive a round trip + * so a future version's additions are not destroyed by an older NMSh. + */ + +export const THEME_SCHEMA = 'nmsh-theme'; +export const THEME_SCHEMA_VERSION = 1; + +/** Prompt semantic roles a custom theme defines (module fills). */ +export const PROMPT_THEME_ROLES = ['project', 'cwd', 'gitBranch', 'node', 'go', 'python', 'docker', 'kubernetes', 'success', 'failure'] as const; +export type PromptThemeRole = typeof PROMPT_THEME_ROLES[number]; + +/** NMSh chrome roles. Warning and Info are kept for completeness and future surfaces. */ +export const UI_THEME_ROLES = ['accent', 'primary', 'secondary', 'subtle', 'separator', 'selection', 'success', 'warning', 'failure', 'info'] as const; +export type UiThemeRole = typeof UI_THEME_ROLES[number]; + +export const ROLE_LABELS: Record = { + project: 'Project', cwd: 'Path', gitBranch: 'Git branch', node: 'Node', go: 'Go', python: 'Python', docker: 'Docker', kubernetes: 'Kubernetes', + success: 'Success', failure: 'Failure', accent: 'Accent', primary: 'Primary text', secondary: 'Secondary text', subtle: 'Muted text', + separator: 'Separator', selection: 'Selection', warning: 'Warning', info: 'Info', +}; + +export interface CustomTheme { + schema: typeof THEME_SCHEMA; + version: number; + name: string; + /** The theme it was cloned from, for display only. */ + basedOn?: string; + /** Whether text tiers were designed for a dark terminal; light themes keep NMSh's text tiers. */ + dark: boolean; + prompt: Record; + ui: Record; + /** + * Optional terminal palette facts kept from an imported terminal scheme + * (background, foreground, the 16 ANSI colors, selection, cursor). NMSh's + * own UI never paints a background from it; Theme Bridge adapters use it + * when present instead of deriving ANSI colors from the semantic roles. + */ + terminal?: TerminalPalette; + /** Unknown fields from a newer schema, preserved verbatim on export. */ + extra?: Record; +} + +export interface TerminalPalette { + background: string; + foreground: string; + /** black, red, green, yellow, blue, magenta, cyan, white, then the bright eight. */ + ansi: string[]; + selectionBackground?: string; + selectionForeground?: string; + cursor?: string; +} + +const HEX = /^#[0-9a-f]{6}$/iu; +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); + +export function validHex(value: unknown): value is string { + return typeof value === 'string' && HEX.test(value); +} + +/** A normalized hex (`#rrggbb`), accepting `rgb`/`rrggbb` with or without `#`; undefined when invalid. */ +export function parseHexInput(value: string): string | undefined { + const text = value.trim().replace(/^#/u, '').toLowerCase(); + if (/^[0-9a-f]{6}$/u.test(text)) return `#${text}`; + if (/^[0-9a-f]{3}$/u.test(text)) return `#${[...text].map(c => c + c).join('')}`; + return undefined; +} + +const NAME = /^[^\u0000-\u001f\u007f-\u009f]{1,48}$/u; + +/** Printable text only (no C0/C1 controls, so no escape sequences), trimmed to 48 characters. */ +export function sanitizeName(value: string): string { + return value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, '').trim().slice(0, 48); +} + +export type ThemeValidation = {ok: true; theme: CustomTheme; warnings: string[]} | {ok: false; errors: string[]}; + +/** + * Validates untrusted theme data. Missing roles are an error (no silent + * defaults), invalid colors are named, a newer schema version is accepted + * with a warning, and unknown fields are preserved under `extra`. + */ +export function validateTheme(value: unknown): ThemeValidation { + const errors: string[] = []; + const warnings: string[] = []; + if (!isRecord(value)) return {ok: false, errors: ['Not a JSON object.']}; + if (value.schema !== THEME_SCHEMA) errors.push(`Not an NMSh theme (schema must be "${THEME_SCHEMA}").`); + const version = typeof value.version === 'number' && Number.isInteger(value.version) ? value.version : NaN; + if (!Number.isFinite(version) || version < 1) errors.push('Missing or invalid version.'); + else if (version > THEME_SCHEMA_VERSION) warnings.push(`Theme version ${version} is newer than this NMSh (${THEME_SCHEMA_VERSION}); unknown fields are kept.`); + const name = typeof value.name === 'string' && NAME.test(value.name.trim()) ? value.name.trim() : ''; + if (!name) errors.push('Name must be 1–48 printable characters.'); + const prompt = isRecord(value.prompt) ? value.prompt : {}; + const ui = isRecord(value.ui) ? value.ui : {}; + for (const role of PROMPT_THEME_ROLES) if (!validHex(prompt[role])) errors.push(`prompt.${role} must be a #rrggbb color.`); + for (const role of UI_THEME_ROLES) if (!validHex(ui[role])) errors.push(`ui.${role} must be a #rrggbb color.`); + if (errors.length) return {ok: false, errors}; + const terminal = value.terminal === undefined ? undefined : normalizeTerminalPalette(value.terminal); + if (value.terminal !== undefined && !terminal) warnings.push('The terminal palette is incomplete or invalid and was not kept.'); + const known = new Set(['schema', 'version', 'name', 'basedOn', 'dark', 'prompt', 'ui', 'terminal', 'extra']); + const extra = Object.fromEntries(Object.entries(value).filter(([key]) => !known.has(key))); + const basedOn = typeof value.basedOn === 'string' && NAME.test(value.basedOn) ? value.basedOn : undefined; + return {ok: true, warnings, theme: { + schema: THEME_SCHEMA, version: Math.min(version, THEME_SCHEMA_VERSION), name, dark: value.dark !== false, + ...(basedOn ? {basedOn} : {}), + prompt: Object.fromEntries(PROMPT_THEME_ROLES.map(role => [role, (prompt[role] as string).toLowerCase()])) as CustomTheme['prompt'], + ui: Object.fromEntries(UI_THEME_ROLES.map(role => [role, (ui[role] as string).toLowerCase()])) as CustomTheme['ui'], + ...(terminal ? {terminal} : {}), + ...(Object.keys(extra).length ? {extra} : {}), + }}; +} + +/** A complete terminal palette (16 valid ANSI colors, background, foreground) or nothing. */ +export function normalizeTerminalPalette(value: unknown): TerminalPalette | undefined { + if (!isRecord(value) || !validHex(value.background) || !validHex(value.foreground) || !Array.isArray(value.ansi) + || value.ansi.length !== 16 || !value.ansi.every(validHex)) return undefined; + const optional = (key: 'selectionBackground' | 'selectionForeground' | 'cursor') => validHex(value[key]) ? {[key]: (value[key] as string).toLowerCase()} : {}; + return {background: value.background.toLowerCase(), foreground: value.foreground.toLowerCase(), ansi: (value.ansi as string[]).map(hex => hex.toLowerCase()), + ...optional('selectionBackground'), ...optional('selectionForeground'), ...optional('cursor')}; +} + +/** Configuration copies of a theme survive only if valid. */ +export function normalizeCustomTheme(value: unknown): CustomTheme | undefined { + const result = validateTheme(value); + return result.ok ? result.theme : undefined; +} + +/** NMSh Theme JSON, pretty and stable; unknown fields from import are written back. */ +export function exportTheme(theme: CustomTheme): string { + const {extra, ...known} = theme; + return `${JSON.stringify({...extra, ...known, version: THEME_SCHEMA_VERSION}, null, 2)}\n`; +} + +/** A starting point cloned from any theme's resolved colors. */ +export function cloneTheme(name: string, basedOn: string, prompt: Record, ui: Partial & Record<'accent' | 'separator' | 'success' | 'failure', string>, + defaults: Record, dark: boolean): CustomTheme { + return {schema: THEME_SCHEMA, version: THEME_SCHEMA_VERSION, name, basedOn, dark, + prompt: {...prompt}, + ui: {...defaults, ...Object.fromEntries(Object.entries(ui).filter(([, value]) => validHex(value))), warning: defaults.warning, info: defaults.info}}; +} + +export function themeFromFamilyRoles(roles: FamilyRoles): Record { + return {...roles}; +} + +// ---- Palette imports --------------------------------------------------------- + +export type ImportFormat = 'nmsh' | 'base16' | 'windowsTerminal'; +export interface ThemeImport {format: ImportFormat; theme: CustomTheme; warnings: string[]} + +function luminance(hex: string): number { + const [r, g, b] = [1, 3, 5].map(index => parseInt(hex.slice(index, index + 2), 16) / 255); + return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!; +} + +const MAPPED_NOTE = 'Imported from an ANSI palette: NMSh semantic roles were mapped from its colors. Review them in the preview; the mapping is not lossless.'; + +/** + * Base16 (YAML `baseXX: "hex"` lines or JSON) mapped explicitly onto NMSh + * roles. Only the scheme name and base00–base0F are read; nothing else is + * interpreted. + */ +export function importBase16(text: string, defaults: Record): ThemeImport | undefined { + const colors: Record = {}; + let name = ''; + try { + const json = JSON.parse(text) as unknown; + if (isRecord(json)) { + for (const [key, value] of Object.entries(isRecord(json.palette) ? json.palette : json)) { + const hex = typeof value === 'string' ? parseHexInput(value) : undefined; + if (/^base0[0-9a-f]$/iu.test(key) && hex) colors[key.toLowerCase()] = hex; + } + if (typeof json.scheme === 'string') name = json.scheme; else if (typeof json.name === 'string') name = json.name; + } + } catch { + for (const line of text.split('\n').slice(0, 200)) { + const match = /^\s*(base0[0-9a-fA-F])\s*:\s*["']?#?([0-9a-fA-F]{6})["']?\s*(?:#.*)?$/u.exec(line); + if (match) colors[match[1]!.toLowerCase()] = `#${match[2]!.toLowerCase()}`; + const scheme = /^\s*(?:scheme|name)\s*:\s*["']?([^"'\n]{1,48})["']?\s*$/u.exec(line); + if (scheme && !name) name = scheme[1]!.trim(); + } + } + const needed = ['base00', 'base01', 'base02', 'base03', 'base05', 'base08', 'base09', 'base0a', 'base0b', 'base0c', 'base0d', 'base0e']; + if (!needed.every(key => colors[key])) return undefined; + const c = colors as Record; + const dark = luminance(c.base00!) < 0.5; + // Names come from untrusted files: only printable text reaches the screen. + const safeName = sanitizeName(name) || 'Imported Base16'; + const theme: CustomTheme = {schema: THEME_SCHEMA, version: THEME_SCHEMA_VERSION, name: safeName, basedOn: 'Base16 import', dark, + prompt: {project: c.base0e!, cwd: c.base02!, gitBranch: c.base0d!, node: c.base0b!, go: c.base0c!, python: c.base0a!, docker: c.base0d!, + kubernetes: c.base09!, success: c.base0b!, failure: c.base08!}, + ui: {...defaults, accent: c.base0e!, separator: c.base03!, success: c.base0b!, warning: c.base0a!, failure: c.base08!, info: c.base0c!, + ...(dark ? {primary: c.base05!, secondary: c.base04 ?? c.base05!, subtle: c.base03!, selection: c.base02!} : {})}}; + return {format: 'base16', theme, warnings: [MAPPED_NOTE]}; +} + +/** Windows Terminal color scheme JSON (`name`, `background`, `foreground`, ANSI names). */ +export function importWindowsTerminal(text: string, defaults: Record): ThemeImport | undefined { + let json: unknown; + try { json = JSON.parse(text); } catch { return undefined; } + if (!isRecord(json)) return undefined; + const pick = (key: string) => typeof json[key] === 'string' ? parseHexInput(json[key] as string) : undefined; + const keys = ['background', 'foreground', 'black', 'red', 'green', 'yellow', 'blue', 'purple', 'cyan', 'brightBlack', 'brightPurple']; + const c = Object.fromEntries(keys.map(key => [key, pick(key)])); + if (!keys.every(key => c[key])) return undefined; + const dark = luminance(c.background!) < 0.5; + const selection = pick('selectionBackground') ?? c.brightBlack!; + const name = typeof json.name === 'string' ? sanitizeName(json.name) || 'Imported scheme' : 'Imported scheme'; + const theme: CustomTheme = {schema: THEME_SCHEMA, version: THEME_SCHEMA_VERSION, name, basedOn: 'Windows Terminal scheme', dark, + prompt: {project: c.purple!, cwd: c.brightBlack!, gitBranch: c.blue!, node: c.green!, go: c.cyan!, python: c.yellow!, docker: c.blue!, + kubernetes: c.brightPurple!, success: c.green!, failure: c.red!}, + ui: {...defaults, accent: c.purple!, separator: c.brightBlack!, success: c.green!, warning: c.yellow!, failure: c.red!, info: c.cyan!, + ...(dark ? {primary: c.foreground!, secondary: c.foreground!, subtle: c.brightBlack!, selection} : {})}}; + return {format: 'windowsTerminal', theme, warnings: [MAPPED_NOTE]}; +} + +/** Tries NMSh Theme JSON first, then the supported palette formats. Never evaluates anything. */ +export function importTheme(text: string, defaults: Record): ThemeImport | {errors: string[]} { + if (text.length > 256 * 1024) return {errors: ['File is larger than 256 KiB.']}; + let json: unknown; + try { json = JSON.parse(text); } catch { json = undefined; } + if (isRecord(json) && json.schema === THEME_SCHEMA) { + const result = validateTheme(json); + return result.ok ? {format: 'nmsh', theme: result.theme, warnings: result.warnings} : {errors: result.errors}; + } + const imported = importWindowsTerminal(text, defaults) ?? importBase16(text, defaults); + return imported ?? {errors: ['Not an NMSh theme, Base16 scheme or Windows Terminal color scheme.']}; +} + +/** A filesystem-safe slug for an exported theme file name. */ +export function themeSlug(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-+|-+$/gu, '').slice(0, 40) || 'theme'; +} diff --git a/src/appearance/semanticPalette.ts b/src/appearance/semanticPalette.ts new file mode 100644 index 00000000..57fddac7 --- /dev/null +++ b/src/appearance/semanticPalette.ts @@ -0,0 +1,110 @@ +import {mixRgb} from '../chroma/chroma.js'; +import {contrastRatio, hexColor, parseHexColor} from '../chroma/color.js'; +import type {RgbColor as Rgb} from '../ui/palette.js'; +import type {CustomTheme, PromptThemeRole} from './customTheme.js'; +import {themeForRef, themeRefLabel, type ThemeRef, type ThemeSource} from './themeRefs.js'; + +/** + * The one resolved, immutable semantic palette every Theme Bridge adapter + * consumes. It is static theme data: Chroma is a live presentation treatment + * and never leaks into generated configuration. Adapters translate these + * roles into their target's documented roles; none of them re-reads a theme. + */ +export interface SemanticPalette { + readonly ref: ThemeRef; + readonly name: string; + /** Whether the theme was designed for a dark terminal background. */ + readonly dark: boolean; + /** Present only when the theme carries a real terminal background (imported schemes). Otherwise adapters keep the terminal's own. */ + readonly background?: string; + readonly foreground: string; + readonly text: Readonly<{primary: string; secondary: string; subtle: string}>; + readonly accent: string; + readonly separator: string; + /** Selected/active surface and the text drawn on it. */ + readonly selection: string; + readonly selectionForeground: string; + /** A raised surface for bars and menus (status lines, popup menus), one step from the background. */ + readonly surface: string; + readonly cursor: string; + readonly success: string; + readonly warning: string; + readonly failure: string; + readonly info: string; + readonly prompt: Readonly>; + /** 16 ANSI-ish colors: the imported terminal palette when present, otherwise derived from the roles. */ + readonly ansi: readonly string[]; + /** Code roles for editor/pager targets, derived once here. */ + readonly syntax: Readonly<{comment: string; string: string; number: string; keyword: string; function: string; type: string; + constant: string; operator: string; special: string; preproc: string}>; +} + +export type PaletteResolution = {ok: true; palette: SemanticPalette} | {ok: false; reason: 'invalid' | 'missing'; label: string}; + +const BLACK: Rgb = {red: 0, green: 0, blue: 0}; +const WHITE: Rgb = {red: 255, green: 255, blue: 255}; +const rgb = (hex: string): Rgb => parseHexColor(hex)!; +const mix = (a: string, b: Rgb | string, amount: number): string => hexColor(mixRgb(rgb(a), typeof b === 'string' ? rgb(b) : b, amount)); + +/** A role color pushed until it reads against `against` (when there is a known background). */ +function legible(color: string, against: string | undefined, dark: boolean): string { + if (!against) return color; + let out = color; + for (let step = 0; step < 6 && contrastRatio(rgb(out), rgb(against)) < 3; step++) out = mix(out, dark ? WHITE : BLACK, 0.2); + return out; +} + +function deepFreeze(value: T): T { + if (value && typeof value === 'object') { + for (const item of Object.values(value as Record)) deepFreeze(item); + Object.freeze(value); + } + return value; +} + +/** The semantic palette for theme data (pure; exported for fixtures). */ +export function paletteFromTheme(theme: CustomTheme, ref: ThemeRef): SemanticPalette { + const terminal = theme.terminal; + const dark = theme.dark; + const background = terminal?.background; + // Light themes keep NMSh's dark-terminal text tiers in its own UI; external tools need text that reads on light. + const primary = terminal?.foreground ?? (dark ? theme.ui.primary : mix(theme.ui.accent, BLACK, 0.78)); + const secondary = dark ? theme.ui.secondary : mix(primary, WHITE, 0.25); + const subtle = dark ? theme.ui.subtle : mix(theme.ui.separator, BLACK, 0.2); + const surface = background ? mix(background, dark ? WHITE : BLACK, 0.08) : dark ? mix(theme.ui.selection, BLACK, 0.35) : mix(theme.ui.accent, WHITE, 0.88); + const selection = terminal?.selectionBackground ?? (dark ? theme.ui.selection : mix(theme.ui.accent, WHITE, 0.75)); + const roles = {success: legible(theme.ui.success, background, dark), warning: legible(theme.ui.warning, background, dark), + failure: legible(theme.ui.failure, background, dark), info: legible(theme.ui.info, background, dark), accent: legible(theme.ui.accent, background, dark)}; + const ansi = terminal?.ansi.slice() ?? (() => { + const base = [dark ? mix(surface, BLACK, 0.3) : subtle, roles.failure, roles.success, roles.warning, legible(theme.prompt.gitBranch, background, dark), + roles.accent, roles.info, dark ? secondary : mix(subtle, WHITE, 0.5)]; + const bright = base.map((color, index) => index === 0 ? subtle : index === 7 ? primary : mix(color, dark ? WHITE : BLACK, 0.2)); + return [...base, ...bright]; + })(); + const syntax = {comment: subtle, string: ansi[2]!, number: ansi[3]!, keyword: roles.accent, function: ansi[4]!, type: ansi[6]!, + constant: ansi[11]!, operator: secondary, special: ansi[13]!, preproc: ansi[5]!}; + return deepFreeze({ + ref, name: theme.name, dark, ...(background ? {background} : {}), foreground: primary, + text: {primary, secondary, subtle}, accent: roles.accent, separator: theme.ui.separator, + selection, selectionForeground: terminal?.selectionForeground ?? primary, surface, + cursor: terminal?.cursor ?? roles.accent, success: roles.success, warning: roles.warning, failure: roles.failure, info: roles.info, + prompt: {...theme.prompt}, ansi, syntax, + }); +} + +/** + * Resolves any selectable theme (built-in, family variant/accent, Imported, + * Custom) to its immutable semantic palette. A missing or invalid reference + * is reported; it never silently resolves to another theme. + */ +export function resolveSemanticPalette(ref: ThemeRef | undefined, source: Pick): PaletteResolution { + const resolution = themeForRef(ref, source); + if (!resolution.ok) return {ok: false, reason: resolution.reason, label: themeRefLabel(ref, source)}; + return {ok: true, palette: paletteFromTheme(resolution.theme, ref!)}; +} + +/** `#rrggbb` → `r;g;b` for SGR sequences in generated environment values. */ +export function sgrRgb(hex: string): string { + const color = rgb(hex); + return `${color.red};${color.green};${color.blue}`; +} diff --git a/src/appearance/themeColor.ts b/src/appearance/themeColor.ts new file mode 100644 index 00000000..57c4d895 --- /dev/null +++ b/src/appearance/themeColor.ts @@ -0,0 +1,25 @@ +import type {NativePaletteId} from '../prompt/configuration.js'; +import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js'; +import {parseHexColor} from '../chroma/color.js'; +import type {RgbColor} from '../ui/palette.js'; +import {accentedVariant, themeVariant, type CatppuccinAccent} from './themeFamilies.js'; +import type {CustomTheme} from './customTheme.js'; + +/** + * The accent color of any theme, resolved from the one theme system: a + * bundled family variant (with the Catppuccin accent applied), one of NMSh's + * own themes (its project color), or the person's Custom theme. Pure: it never + * reads the active theme, so a surface can follow a theme other than the + * prompt's (the cursor's Choose theme) or the prompt's own (Follow theme). + */ +export function themeAccentColor(palette: NativePaletteId, accent: CatppuccinAccent, custom: CustomTheme | undefined): RgbColor { + if (palette === 'custom') { + const parsed = custom ? parseHexColor(custom.ui.accent) : undefined; + if (parsed) return parsed; + return {...NATIVE_PROMPT_THEMES.lavender.colors('project').background}; + } + const variant = themeVariant(palette); + if (variant) return parseHexColor(accentedVariant(variant, accent).ui.accent) ?? {...NATIVE_PROMPT_THEMES.lavender.colors('project').background}; + const theme = NATIVE_PROMPT_THEMES[palette] ?? NATIVE_PROMPT_THEMES.lavender; + return {...theme.colors('project').background}; +} diff --git a/src/appearance/themeFamilies.ts b/src/appearance/themeFamilies.ts new file mode 100644 index 00000000..48cad325 --- /dev/null +++ b/src/appearance/themeFamilies.ts @@ -0,0 +1,199 @@ +/** + * Curated third-party theme families for NMSh-owned UI (prompt fills, syntax, + * chrome accents). Palette values are copied from each project's canonical + * upstream source under its license; see docs/design/theme-families.md for + * sources, versions and attribution. Selecting a family themes NMSh only: it + * never recolors the terminal window, editor or host configuration. + */ + +export const CATPPUCCIN_ACCENTS = ['rosewater', 'flamingo', 'pink', 'mauve', 'red', 'maroon', 'peach', 'yellow', 'green', 'teal', + 'sky', 'sapphire', 'blue', 'lavender'] as const; +export type CatppuccinAccent = typeof CATPPUCCIN_ACCENTS[number]; +export const CATPPUCCIN_ACCENT_LABELS: Record = { + rosewater: 'Rosewater', flamingo: 'Flamingo', pink: 'Pink', mauve: 'Mauve', red: 'Red', maroon: 'Maroon', peach: 'Peach', + yellow: 'Yellow', green: 'Green', teal: 'Teal', sky: 'Sky', sapphire: 'Sapphire', blue: 'Blue', lavender: 'Lavender', +}; + +/** Prompt module fills (hex); text on each fill is chosen for contrast. */ +export interface FamilyRoles { + project: string; cwd: string; gitBranch: string; node: string; go: string; python: string; + docker: string; kubernetes: string; success: string; failure: string; +} + +/** + * NMSh chrome colors for a variant. Text tiers are given only for dark + * variants: NMSh cannot read the terminal background, so light variants keep + * NMSh's own text tiers and recolor accents, rules and status only. + */ +export interface FamilyUi { + accent: string; separator: string; success: string; failure: string; + primary?: string; secondary?: string; subtle?: string; selection?: string; +} + +export interface ThemeVariant { + id: string; + family: ThemeFamilyId; + /** Variant name within the family, e.g. Mocha. */ + variant: string; + label: string; + description: string; + dark: boolean; + roles: FamilyRoles; + ui: FamilyUi; + /** Catppuccin only: the 14 accents of this flavor. */ + accents?: Record; +} + +export type ThemeFamilyId = 'nmsh' | 'catppuccin' | 'dracula' | 'tokyonight' | 'gruvbox' | 'rosepine' | 'nord' | 'solarized' | 'onedark' | 'custom'; + +export interface ThemeFamily { + id: ThemeFamilyId; + label: string; + /** What the family calls its variants ("Flavor", "Variant"). */ + variantLabel: string; + source?: string; + license?: string; +} + +export const THEME_FAMILIES: readonly ThemeFamily[] = [ + {id: 'nmsh', label: 'NMSh', variantLabel: 'Theme'}, + {id: 'catppuccin', label: 'Catppuccin', variantLabel: 'Flavor', source: 'https://github.com/catppuccin/palette', license: 'MIT'}, + {id: 'dracula', label: 'Dracula', variantLabel: 'Variant', source: 'https://github.com/dracula/spec', license: 'MIT'}, + {id: 'tokyonight', label: 'Tokyo Night', variantLabel: 'Style', source: 'https://github.com/folke/tokyonight.nvim', license: 'Apache-2.0'}, + {id: 'gruvbox', label: 'Gruvbox', variantLabel: 'Variant', source: 'https://github.com/morhetz/gruvbox', license: 'MIT/X11'}, + {id: 'rosepine', label: 'Rosé Pine', variantLabel: 'Variant', source: 'https://github.com/rose-pine/neovim', license: 'MIT'}, + {id: 'nord', label: 'Nord', variantLabel: 'Variant', source: 'https://github.com/nordtheme/nord', license: 'MIT'}, + {id: 'solarized', label: 'Solarized', variantLabel: 'Variant', source: 'https://github.com/altercation/solarized', license: 'MIT'}, + {id: 'onedark', label: 'One Dark', variantLabel: 'Variant', source: 'https://github.com/atom/one-dark-syntax', license: 'MIT'}, + {id: 'custom', label: 'Custom', variantLabel: 'Theme'}, +]; + +interface CatppuccinFlavor { + accents: Record; + text: string; subtext1: string; overlay1: string; overlay0: string; surface1: string; surface2: string; +} + +// catppuccin/palette palette.json (07d02aa110ef). +const CTP: Record<'latte' | 'frappe' | 'macchiato' | 'mocha', CatppuccinFlavor> = { + latte: {accents: {rosewater: '#dc8a78', flamingo: '#dd7878', pink: '#ea76cb', mauve: '#8839ef', red: '#d20f39', maroon: '#e64553', + peach: '#fe640b', yellow: '#df8e1d', green: '#40a02b', teal: '#179299', sky: '#04a5e5', sapphire: '#209fb5', blue: '#1e66f5', lavender: '#7287fd'}, + text: '#4c4f69', subtext1: '#5c5f77', overlay1: '#8c8fa1', overlay0: '#9ca0b0', surface1: '#bcc0cc', surface2: '#acb0be'}, + frappe: {accents: {rosewater: '#f2d5cf', flamingo: '#eebebe', pink: '#f4b8e4', mauve: '#ca9ee6', red: '#e78284', maroon: '#ea999c', + peach: '#ef9f76', yellow: '#e5c890', green: '#a6d189', teal: '#81c8be', sky: '#99d1db', sapphire: '#85c1dc', blue: '#8caaee', lavender: '#babbf1'}, + text: '#c6d0f5', subtext1: '#b5bfe2', overlay1: '#838ba7', overlay0: '#737994', surface1: '#51576d', surface2: '#626880'}, + macchiato: {accents: {rosewater: '#f4dbd6', flamingo: '#f0c6c6', pink: '#f5bde6', mauve: '#c6a0f6', red: '#ed8796', maroon: '#ee99a0', + peach: '#f5a97f', yellow: '#eed49f', green: '#a6da95', teal: '#8bd5ca', sky: '#91d7e3', sapphire: '#7dc4e4', blue: '#8aadf4', lavender: '#b7bdf8'}, + text: '#cad3f5', subtext1: '#b8c0e0', overlay1: '#8087a2', overlay0: '#6e738d', surface1: '#494d64', surface2: '#5b6078'}, + mocha: {accents: {rosewater: '#f5e0dc', flamingo: '#f2cdcd', pink: '#f5c2e7', mauve: '#cba6f7', red: '#f38ba8', maroon: '#eba0ac', + peach: '#fab387', yellow: '#f9e2af', green: '#a6e3a1', teal: '#94e2d5', sky: '#89dceb', sapphire: '#74c7ec', blue: '#89b4fa', lavender: '#b4befe'}, + text: '#cdd6f4', subtext1: '#bac2de', overlay1: '#7f849c', overlay0: '#6c7086', surface1: '#45475a', surface2: '#585b70'}, +}; + +function catppuccin(id: string, variant: string, flavor: CatppuccinFlavor, dark: boolean): ThemeVariant { + const a = flavor.accents; + return {id, family: 'catppuccin', variant, label: `Catppuccin ${variant}`, description: `Catppuccin ${variant} with a selectable accent`, dark, + accents: a, + roles: {project: a.mauve, cwd: dark ? flavor.surface1 : flavor.surface2, gitBranch: a.sapphire, node: a.green, go: a.sky, python: a.yellow, + docker: a.blue, kubernetes: a.lavender, success: a.green, failure: a.red}, + ui: {accent: a.mauve, separator: flavor.overlay0, success: a.green, failure: a.red, + ...(dark ? {primary: flavor.text, secondary: flavor.subtext1, subtle: flavor.overlay1, selection: flavor.surface1} : {})}}; +} + +/** Every bundled third-party variant, in family order. */ +export const THEME_VARIANTS: readonly ThemeVariant[] = [ + catppuccin('catppuccinLatte', 'Latte', CTP.latte, false), + catppuccin('catppuccinFrappe', 'Frappé', CTP.frappe, true), + catppuccin('catppuccinMacchiato', 'Macchiato', CTP.macchiato, true), + catppuccin('catppuccinMocha', 'Mocha', CTP.mocha, true), + // dracula/spec dracula-spec.md. Alucard is not part of the MIT spec repository and is not bundled. + {id: 'dracula', family: 'dracula', variant: 'Dracula', label: 'Dracula', description: 'Dracula purple, pink and green', dark: true, + roles: {project: '#bd93f9', cwd: '#44475a', gitBranch: '#ff79c6', node: '#50fa7b', go: '#8be9fd', python: '#f1fa8c', docker: '#6272a4', + kubernetes: '#ffb86c', success: '#50fa7b', failure: '#ff5555'}, + ui: {accent: '#bd93f9', separator: '#6272a4', success: '#50fa7b', failure: '#ff5555', primary: '#f8f8f2', secondary: '#d6d6d0', subtle: '#6272a4', selection: '#44475a'}}, + // folke/tokyonight.nvim lua/tokyonight/colors and generated extras (cdc07ac78467). + {id: 'tokyonightNight', family: 'tokyonight', variant: 'Night', label: 'Tokyo Night', description: 'Tokyo Night, the darkest style', dark: true, + roles: {project: '#7aa2f7', cwd: '#3b4261', gitBranch: '#bb9af7', node: '#9ece6a', go: '#7dcfff', python: '#e0af68', docker: '#2ac3de', + kubernetes: '#3d59a1', success: '#73daca', failure: '#f7768e'}, + ui: {accent: '#7aa2f7', separator: '#565f89', success: '#9ece6a', failure: '#f7768e', primary: '#c0caf5', secondary: '#a9b1d6', subtle: '#565f89', selection: '#292e42'}}, + {id: 'tokyonightStorm', family: 'tokyonight', variant: 'Storm', label: 'Tokyo Night Storm', description: 'Tokyo Night on a lighter storm background', dark: true, + roles: {project: '#7aa2f7', cwd: '#414868', gitBranch: '#bb9af7', node: '#9ece6a', go: '#7dcfff', python: '#e0af68', docker: '#2ac3de', + kubernetes: '#3d59a1', success: '#73daca', failure: '#f7768e'}, + ui: {accent: '#7aa2f7', separator: '#565f89', success: '#9ece6a', failure: '#f7768e', primary: '#c0caf5', secondary: '#a9b1d6', subtle: '#565f89', selection: '#292e42'}}, + {id: 'tokyonightMoon', family: 'tokyonight', variant: 'Moon', label: 'Tokyo Night Moon', description: 'Tokyo Night Moon, softer and warmer', dark: true, + roles: {project: '#82aaff', cwd: '#444a73', gitBranch: '#c099ff', node: '#c3e88d', go: '#86e1fc', python: '#ffc777', docker: '#65bcff', + kubernetes: '#3e68d7', success: '#4fd6be', failure: '#ff757f'}, + ui: {accent: '#82aaff', separator: '#636da6', success: '#c3e88d', failure: '#ff757f', primary: '#c8d3f5', secondary: '#828bb8', subtle: '#636da6', selection: '#2f334d'}}, + {id: 'tokyonightDay', family: 'tokyonight', variant: 'Day', label: 'Tokyo Night Day', description: 'Tokyo Night light style', dark: false, + roles: {project: '#2e7de9', cwd: '#a8aecb', gitBranch: '#9854f1', node: '#587539', go: '#007197', python: '#8c6c3e', docker: '#188092', + kubernetes: '#7890dd', success: '#387068', failure: '#f52a65'}, + ui: {accent: '#2e7de9', separator: '#848cb5', success: '#587539', failure: '#f52a65'}}, + // morhetz/gruvbox (MIT/X11). Contrast variants change only the editor background, which NMSh does not paint. + {id: 'gruvboxDark', family: 'gruvbox', variant: 'Dark', label: 'Gruvbox Dark', description: 'retro groove, warm and earthy', dark: true, + roles: {project: '#d79921', cwd: '#504945', gitBranch: '#689d6a', node: '#98971a', go: '#458588', python: '#fabd2f', docker: '#83a598', + kubernetes: '#b16286', success: '#b8bb26', failure: '#fb4934'}, + ui: {accent: '#fabd2f', separator: '#665c54', success: '#b8bb26', failure: '#fb4934', primary: '#ebdbb2', secondary: '#d5c4a1', subtle: '#928374', selection: '#504945'}}, + {id: 'gruvboxLight', family: 'gruvbox', variant: 'Light', label: 'Gruvbox Light', description: 'retro groove light', dark: false, + roles: {project: '#b57614', cwd: '#d5c4a1', gitBranch: '#427b58', node: '#79740e', go: '#076678', python: '#d79921', docker: '#458588', + kubernetes: '#8f3f71', success: '#79740e', failure: '#9d0006'}, + ui: {accent: '#af3a03', separator: '#a89984', success: '#79740e', failure: '#9d0006'}}, + // rose-pine/neovim lua/rose-pine/palette.lua (ff483051a47e). + {id: 'rosePine', family: 'rosepine', variant: 'Main', label: 'Rosé Pine', description: 'soho vibes: iris, rose and foam', dark: true, + roles: {project: '#c4a7e7', cwd: '#403d52', gitBranch: '#ebbcba', node: '#31748f', go: '#9ccfd8', python: '#f6c177', docker: '#524f67', + kubernetes: '#908caa', success: '#31748f', failure: '#eb6f92'}, + ui: {accent: '#c4a7e7', separator: '#6e6a86', success: '#9ccfd8', failure: '#eb6f92', primary: '#e0def4', secondary: '#908caa', subtle: '#6e6a86', selection: '#403d52'}}, + {id: 'rosePineMoon', family: 'rosepine', variant: 'Moon', label: 'Rosé Pine Moon', description: 'Rosé Pine on a lighter moon base', dark: true, + roles: {project: '#c4a7e7', cwd: '#44415a', gitBranch: '#ea9a97', node: '#3e8fb0', go: '#9ccfd8', python: '#f6c177', docker: '#56526e', + kubernetes: '#908caa', success: '#3e8fb0', failure: '#eb6f92'}, + ui: {accent: '#c4a7e7', separator: '#6e6a86', success: '#9ccfd8', failure: '#eb6f92', primary: '#e0def4', secondary: '#908caa', subtle: '#6e6a86', selection: '#44415a'}}, + {id: 'rosePineDawn', family: 'rosepine', variant: 'Dawn', label: 'Rosé Pine Dawn', description: 'Rosé Pine light', dark: false, + roles: {project: '#907aa9', cwd: '#cecacd', gitBranch: '#d7827e', node: '#286983', go: '#56949f', python: '#ea9d34', docker: '#797593', + kubernetes: '#9893a5', success: '#286983', failure: '#b4637a'}, + ui: {accent: '#907aa9', separator: '#9893a5', success: '#56949f', failure: '#b4637a'}}, + // nordtheme/nord src/nord.css. + {id: 'nord', family: 'nord', variant: 'Nord', label: 'Nord', description: 'arctic, north-bluish frost and aurora', dark: true, + roles: {project: '#88c0d0', cwd: '#4c566a', gitBranch: '#5e81ac', node: '#a3be8c', go: '#8fbcbb', python: '#ebcb8b', docker: '#81a1c1', + kubernetes: '#b48ead', success: '#a3be8c', failure: '#bf616a'}, + ui: {accent: '#88c0d0', separator: '#4c566a', success: '#a3be8c', failure: '#bf616a', primary: '#eceff4', secondary: '#d8dee9', subtle: '#7b88a1', selection: '#434c5e'}}, + // altercation/solarized README. + {id: 'solarizedDark', family: 'solarized', variant: 'Dark', label: 'Solarized Dark', description: 'precision colors on base03', dark: true, + roles: {project: '#268bd2', cwd: '#586e75', gitBranch: '#6c71c4', node: '#859900', go: '#2aa198', python: '#b58900', docker: '#d33682', + kubernetes: '#cb4b16', success: '#859900', failure: '#dc322f'}, + ui: {accent: '#268bd2', separator: '#586e75', success: '#859900', failure: '#dc322f', primary: '#93a1a1', secondary: '#839496', subtle: '#586e75', selection: '#073642'}}, + {id: 'solarizedLight', family: 'solarized', variant: 'Light', label: 'Solarized Light', description: 'precision colors on base3', dark: false, + roles: {project: '#268bd2', cwd: '#eee8d5', gitBranch: '#6c71c4', node: '#859900', go: '#2aa198', python: '#b58900', docker: '#d33682', + kubernetes: '#cb4b16', success: '#859900', failure: '#dc322f'}, + ui: {accent: '#268bd2', separator: '#93a1a1', success: '#859900', failure: '#dc322f'}}, + // atom/one-dark-syntax and one-light-syntax styles/colors.less (HSL converted to hex). + {id: 'oneDark', family: 'onedark', variant: 'Dark', label: 'One Dark', description: 'Atom One Dark', dark: true, + roles: {project: '#61afef', cwd: '#4b5263', gitBranch: '#c678dd', node: '#98c379', go: '#56b6c2', python: '#e5c07b', docker: '#d19a66', + kubernetes: '#5c6370', success: '#98c379', failure: '#e06c75'}, + ui: {accent: '#61afef', separator: '#5c6370', success: '#98c379', failure: '#e06c75', primary: '#abb2bf', secondary: '#9da5b4', subtle: '#5c6370', selection: '#3e4451'}}, + {id: 'oneLight', family: 'onedark', variant: 'Light', label: 'One Light', description: 'Atom One Light', dark: false, + roles: {project: '#4078f2', cwd: '#d4d4d6', gitBranch: '#a626a4', node: '#50a14f', go: '#0184bc', python: '#c18401', docker: '#986801', + kubernetes: '#a0a1a7', success: '#50a14f', failure: '#e45649'}, + ui: {accent: '#4078f2', separator: '#a0a1a7', success: '#50a14f', failure: '#e45649'}}, +]; + +export function themeVariant(id: string): ThemeVariant | undefined { + return THEME_VARIANTS.find(variant => variant.id === id); +} + +export function familyVariants(family: ThemeFamilyId): ThemeVariant[] { + return THEME_VARIANTS.filter(variant => variant.family === family); +} + +export function normalizeCatppuccinAccent(value: unknown): CatppuccinAccent { + return CATPPUCCIN_ACCENTS.includes(value as CatppuccinAccent) ? value as CatppuccinAccent : 'mauve'; +} + +/** A variant's roles and UI with the chosen accent applied (Catppuccin only; others ignore it). */ +export function accentedVariant(variant: ThemeVariant, accent: CatppuccinAccent): ThemeVariant { + if (!variant.accents || accent === 'mauve') return variant; + const color = variant.accents[accent]; + // A project fill equal to another module's would hide the boundary; that module keeps mauve. + const swap = (value: string) => value === color ? variant.accents!.mauve : value; + // Status fills keep their meaning whatever the accent is. + const roles = Object.fromEntries(Object.entries(variant.roles).map(([role, value]) => [role, + role === 'project' ? color : role === 'success' || role === 'failure' ? value : swap(value)])) as unknown as FamilyRoles; + return {...variant, roles, ui: {...variant.ui, accent: color}}; +} diff --git a/src/appearance/themeImporters.ts b/src/appearance/themeImporters.ts new file mode 100644 index 00000000..e2e4356e --- /dev/null +++ b/src/appearance/themeImporters.ts @@ -0,0 +1,498 @@ +import {parseDocument} from 'yaml'; +import {parse as parseToml} from 'smol-toml'; +import {XMLParser} from 'fast-xml-parser'; +import {mixRgb} from '../chroma/chroma.js'; +import {hexColor, parseHexColor} from '../chroma/color.js'; +import { + importBase16, importWindowsTerminal, parseHexInput, sanitizeName, THEME_SCHEMA, THEME_SCHEMA_VERSION, validateTheme, + PROMPT_THEME_ROLES, ROLE_LABELS, type CustomTheme, type PromptThemeRole, type TerminalPalette, type UiThemeRole, +} from './customTheme.js'; +import {THEME_SOURCE_LABELS, type ThemeSourceKind} from './themeLibrary.js'; + +/** + * Theme import: bounded local data parsing into one NMSh Native theme. + * + * Every format is parsed as data only. Nothing is executed, sourced, + * evaluated, templated, fetched or followed: no shell, no Lua, no Oh My Posh + * templates or binary, no includes, no remote schemas or inheritance, no XML + * entities. Unsupported or dynamic source concepts become preview warnings; + * no color is invented for them. The result is ordinary NMSh Theme JSON plus + * a mapping/loss disclosure, previewed before anything is stored. + */ + +export const IMPORT_SIZE_LIMIT = 256 * 1024; + +export interface RoleMapping {role: string; from: string} + +export interface ImportPreview { + format: ThemeSourceKind; + theme: CustomTheme; + /** The source's own scheme name, sanitized. */ + sourceName?: string; + /** Where each Native role came from, for the preview. */ + mapping: RoleMapping[]; + /** Lossy mapping, ignored settings and unsupported concepts, in plain words. */ + warnings: string[]; +} + +export type ImportOutcome = ImportPreview | {errors: string[]}; +export type ImportFormatChoice = ThemeSourceKind | 'auto'; + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); +const ANSI_NAMES = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', 'bright black', 'bright red', 'bright green', + 'bright yellow', 'bright blue', 'bright magenta', 'bright cyan', 'bright white']; +const LOSSY = 'A terminal palette has fewer concepts than NMSh roles; roles were mapped from ANSI colors and are not a lossless conversion.'; + +function luminance(hex: string): number { + const color = parseHexColor(hex)!; + return (0.2126 * color.red + 0.7152 * color.green + 0.0722 * color.blue) / 255; +} + +const mix = (a: string, b: string, amount: number) => hexColor(mixRgb(parseHexColor(a)!, parseHexColor(b)!, amount)); + +/** Strict `#rgb` / `#rrggbb` (and the same without `#`); names and functions are not colors here. */ +function hex(value: unknown): string | undefined { + return typeof value === 'string' && /^#?(?:[0-9a-f]{3}|[0-9a-f]{6})$/iu.test(value.trim()) ? parseHexInput(value) : undefined; +} + +function finish(format: ThemeSourceKind, theme: CustomTheme, mapping: RoleMapping[], warnings: string[], sourceName?: string): ImportOutcome { + // Every generated role is validated exactly like a hand-written NMSh theme. + const checked = validateTheme(theme); + if (!checked.ok) return {errors: checked.errors}; + return {format, theme: checked.theme, mapping, warnings: [...new Set(warnings)], ...(sourceName ? {sourceName} : {})}; +} + +/** + * One terminal palette → Native roles. The same documented mapping serves + * every terminal scheme format, so Kitty, Ghostty, iTerm2, WezTerm and + * Windows Terminal schemes with the same colors give the same theme. + */ +export function themeFromTerminal(name: string, kind: ThemeSourceKind, terminal: TerminalPalette, defaults: Record): {theme: CustomTheme; mapping: RoleMapping[]} { + const a = terminal.ansi; + const dark = luminance(terminal.background) < 0.5; + const prompt: Record = {project: a[5]!, cwd: a[8]!, gitBranch: a[4]!, node: a[2]!, go: a[6]!, python: a[3]!, + docker: a[12]!, kubernetes: a[13]!, success: a[2]!, failure: a[1]!}; + const source: Record = {project: 5, cwd: 8, gitBranch: 4, node: 2, go: 6, python: 3, docker: 12, kubernetes: 13, success: 2, failure: 1}; + const selection = terminal.selectionBackground ?? a[8]!; + const ui: Record = {...defaults, accent: a[5]!, separator: a[8]!, success: a[2]!, warning: a[3]!, failure: a[1]!, info: a[6]!, + // Light schemes keep NMSh's own text tiers in NMSh UI; the terminal palette below still carries the real foreground. + ...(dark ? {primary: terminal.foreground, secondary: mix(terminal.foreground, terminal.background, 0.25), subtle: a[8]!, selection} : {})}; + const mapping: RoleMapping[] = [ + ...PROMPT_THEME_ROLES.map(role => ({role: ROLE_LABELS[role], from: `${ANSI_NAMES[source[role]]} (color${source[role]})`})), + {role: 'Accent', from: 'magenta (color5)'}, {role: 'Separator / muted', from: 'bright black (color8)'}, + {role: 'Warning / Info', from: 'yellow (color3) / cyan (color6)'}, + {role: 'Text', from: dark ? 'foreground; secondary is foreground mixed toward background' : 'NMSh text tiers (light scheme)'}, + {role: 'Selection', from: terminal.selectionBackground ? 'selection background' : 'bright black (color8)'}, + ]; + return {mapping, theme: {schema: THEME_SCHEMA, version: THEME_SCHEMA_VERSION, name: sanitizeName(name) || 'Imported theme', + basedOn: `${THEME_SOURCE_LABELS[kind]} import`, dark, prompt, ui, terminal}}; +} + +function terminalResult(kind: ThemeSourceKind, name: string, terminal: TerminalPalette, defaults: Record, warnings: string[]): ImportOutcome { + const {theme, mapping} = themeFromTerminal(name, kind, terminal, defaults); + return finish(kind, theme, mapping, [LOSSY, ...warnings, theme.dark ? 'Interpreted as a dark scheme (dark background).' : 'Interpreted as a light scheme (light background).'], theme.name); +} + +function missingAnsi(ansi: Array): string[] { + return ansi.flatMap((value, index) => value ? [] : [`color${index}`]); +} + +// ---- Kitty ----------------------------------------------------------------- + +const KITTY_COLORS: Record = {foreground: 'foreground', background: 'background', selection_background: 'selectionBackground', + selection_foreground: 'selectionForeground', cursor: 'cursor'}; + +/** + * Kitty theme/config lines: only the documented color assignments are read + * (`foreground`, `background`, `selection_*`, `cursor`, `color0`–`color15`). + * `include`/`globinclude`/`envinclude` are never followed and every other + * directive is ignored and reported. + */ +export function importKitty(text: string, fileName: string, defaults: Record): ImportOutcome { + const ansi: Array = Array(16).fill(undefined); + const facts: Partial = {}; + const ignored = new Set(); + let includes = 0; + let name = ''; + for (const raw of text.split(/\r?\n/u).slice(0, 4000)) { + const line = raw.trim(); + const meta = /^##\s*name\s*:\s*(.+)$/iu.exec(line); + if (meta && !name) name = meta[1]!; + if (!line || line.startsWith('#')) continue; + const [key = '', value = ''] = line.split(/\s+/u, 2); + if (/^(?:include|globinclude|envinclude|geninclude)$/u.test(key)) { includes++; continue; } + const color = /^color(\d{1,3})$/u.exec(key); + if (color) { + const index = Number(color[1]); + if (index < 16) { const parsed = hex(value); if (parsed) ansi[index] = parsed; else ignored.add(`${key} (not a hex color)`); } + else ignored.add('color16–color255'); + continue; + } + const field = KITTY_COLORS[key]; + if (field) { const parsed = hex(value); if (parsed) (facts as Record)[field] = parsed; else ignored.add(`${key} (not a hex color)`); continue; } + ignored.add(key); + } + const missing = [...missingAnsi(ansi), ...(!facts.background ? ['background'] : []), ...(!facts.foreground ? ['foreground'] : [])]; + if (missing.length) return {errors: [`Not a complete Kitty color theme: missing ${missing.join(', ')}.`]}; + const warnings = [ + ...(includes ? [`${includes} include directive${includes === 1 ? ' was' : 's were'} not followed; only this file was read.`] : []), + ...(ignored.size ? [`Non-color Kitty settings were not imported: ${[...ignored].slice(0, 8).join(', ')}${ignored.size > 8 ? '…' : ''}.`] : []), + ]; + return terminalResult('kitty', name || fileStem(fileName), {...facts, ansi: ansi as string[]} as TerminalPalette, defaults, warnings); +} + +// ---- Ghostty --------------------------------------------------------------- + +const GHOSTTY_ALLOWED: Record = {background: 'background', foreground: 'foreground', 'selection-background': 'selectionBackground', + 'selection-foreground': 'selectionForeground', 'cursor-color': 'cursor'}; + +/** + * Ghostty theme files are ordinary Ghostty configuration, so a strict + * allowlist applies: `palette = N=#hex`, background/foreground, + * selection colors and cursor color. `config-file` is never followed; every + * other key is ignored and named in the preview. + */ +export function importGhostty(text: string, fileName: string, defaults: Record): ImportOutcome { + const ansi: Array = Array(16).fill(undefined); + const facts: Partial = {}; + const ignored = new Set(); + let includes = 0; + for (const raw of text.split(/\r?\n/u).slice(0, 4000)) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + const match = /^([a-z0-9-]+)\s*=\s*(.*)$/u.exec(line); + if (!match) { ignored.add('unrecognized lines'); continue; } + const key = match[1]!; + const value = match[2]!.trim().replace(/^"(.*)"$/u, '$1'); + if (key === 'config-file') { includes++; continue; } + if (key === 'palette') { + const entry = /^(\d{1,3})\s*=\s*(#?[0-9a-fA-F]{3,6})$/u.exec(value); + const index = entry ? Number(entry[1]) : NaN; + const parsed = entry ? hex(entry[2]) : undefined; + if (index < 16 && parsed) ansi[index] = parsed; + else if (index >= 16) ignored.add('palette 16–255'); + else ignored.add('palette (not a hex color)'); + continue; + } + const field = GHOSTTY_ALLOWED[key]; + if (field) { const parsed = hex(value); if (parsed) (facts as Record)[field] = parsed; else ignored.add(`${key} (not a hex color)`); continue; } + ignored.add(key); + } + const missing = [...missingAnsi(ansi), ...(!facts.background ? ['background'] : []), ...(!facts.foreground ? ['foreground'] : [])]; + if (missing.length) return {errors: [`Not a complete Ghostty theme: missing ${missing.join(', ')}.`]}; + const warnings = [ + ...(includes ? ['config-file includes were not followed; only this file was read.'] : []), + ...(ignored.size ? [`Ghostty non-color options were ignored: ${[...ignored].slice(0, 8).join(', ')}${ignored.size > 8 ? '…' : ''}.`] : []), + ]; + return terminalResult('ghostty', fileStem(fileName), {...facts, ansi: ansi as string[]} as TerminalPalette, defaults, warnings); +} + +// ---- iTerm2 ---------------------------------------------------------------- + +type OrderedNode = Record; + +/** The ordered key/value pairs of a plist `` (fast-xml-parser preserveOrder output). */ +function plistDict(children: OrderedNode[]): Array<[string, OrderedNode]> { + const pairs: Array<[string, OrderedNode]> = []; + let key: string | undefined; + for (const child of children.slice(0, 2000)) { + const tag = Object.keys(child).find(name => name !== ':@'); + if (!tag) continue; + if (tag === 'key') key = textOf(child.key as OrderedNode[]); + else if (key !== undefined) { pairs.push([key, child]); key = undefined; } + } + return pairs; +} + +function textOf(nodes: OrderedNode[] | string | undefined): string { + if (!Array.isArray(nodes)) return ''; + return nodes.map(node => typeof node['#text'] === 'string' ? node['#text'] : '').join('').trim(); +} + +/** + * `.itermcolors` property lists. The XML is bounded, a DOCTYPE internal subset + * or any ENTITY declaration is rejected outright (no entity expansion at all), + * nothing external is resolved, and only the color dictionaries are read. + */ +export function importITerm2(text: string, fileName: string, defaults: Record): ImportOutcome { + if (/]*\[/iu.test(text)) return {errors: ['XML entity declarations are not accepted in theme files.']}; + let root: OrderedNode[]; + try { + const parser = new XMLParser({preserveOrder: true, processEntities: false, htmlEntities: false, ignoreDeclaration: true, ignorePiTags: true, + ignoreAttributes: true, parseTagValue: false, trimValues: true}); + root = parser.parse(text) as OrderedNode[]; + } catch { + return {errors: ['Not a valid iTerm2 color scheme (.itermcolors) file.']}; + } + const plist = root.find(node => 'plist' in node)?.plist as OrderedNode[] | undefined; + const dict = plist?.find(node => 'dict' in node)?.dict as OrderedNode[] | undefined; + if (!dict) return {errors: ['Not an iTerm2 color scheme: no color dictionary.']}; + const warnings = new Set(); + const colors = new Map(); + for (const [key, value] of plistDict(dict)) { + if (!('dict' in value)) continue; + const components = new Map(plistDict(value.dict as OrderedNode[]).map(([name, node]) => [name, node])); + const channel = (name: string) => { + const node = components.get(`${name} Component`); + const number = node && ('real' in node || 'integer' in node) ? Number(textOf((node.real ?? node.integer) as OrderedNode[])) : NaN; + return Number.isFinite(number) ? Math.max(0, Math.min(255, Math.round(number * 255))) : undefined; + }; + const [red, green, blue] = [channel('Red'), channel('Green'), channel('Blue')]; + if (red === undefined || green === undefined || blue === undefined) continue; + const space = components.get('Color Space'); + const spaceName = space && 'string' in space ? textOf(space.string as OrderedNode[]) : ''; + if (spaceName && spaceName !== 'sRGB' && spaceName !== 'Calibrated') warnings.add(`Some colors use the ${sanitizeName(spaceName)} color space; their components were read as sRGB.`); + colors.set(key, hexColor({red, green, blue})); + } + const ansi = Array.from({length: 16}, (_, index) => colors.get(`Ansi ${index} Color`)); + const background = colors.get('Background Color'); + const foreground = colors.get('Foreground Color'); + const missing = [...missingAnsi(ansi), ...(!background ? ['Background Color'] : []), ...(!foreground ? ['Foreground Color'] : [])]; + if (missing.length) return {errors: [`Not a complete iTerm2 color scheme: missing ${missing.join(', ')}.`]}; + const terminal: TerminalPalette = {background: background!, foreground: foreground!, ansi: ansi as string[], + ...(colors.get('Selection Color') ? {selectionBackground: colors.get('Selection Color')!} : {}), + ...(colors.get('Selected Text Color') ? {selectionForeground: colors.get('Selected Text Color')!} : {}), + ...(colors.get('Cursor Color') ? {cursor: colors.get('Cursor Color')!} : {})}; + const known = new Set([...Array.from({length: 16}, (_, index) => `Ansi ${index} Color`), 'Background Color', 'Foreground Color', 'Selection Color', 'Selected Text Color', 'Cursor Color']); + const extra = [...colors.keys()].filter(key => !known.has(key)); + if (extra.length) warnings.add(`iTerm2-only colors have no NMSh role and were not imported: ${extra.slice(0, 6).map(sanitizeName).join(', ')}${extra.length > 6 ? '…' : ''}.`); + return terminalResult('iterm2', fileStem(fileName), terminal, defaults, [...warnings]); +} + +// ---- WezTerm --------------------------------------------------------------- + +export const WEZTERM_LUA_GUIDANCE = 'WezTerm Lua configuration is executable and is never evaluated. Export or choose a declarative WezTerm TOML color scheme (with a [colors] table) and import that instead.'; + +export function looksLikeLua(text: string, fileName: string): boolean { + return /\.lua$/iu.test(fileName) || /\brequire\s*\(?\s*["']wezterm["']/u.test(text) || /^\s*return\s*\{/mu.test(text) || /^\s*local\s+\w+\s*=/mu.test(text); +} + +/** Declarative WezTerm TOML color schemes: `[colors]` (ansi, brights, foreground, background, selection, cursor) and `[metadata] name`. */ +export function importWezTermToml(data: Record, fileName: string, defaults: Record): ImportOutcome { + const colors = isRecord(data.colors) ? data.colors : undefined; + if (!colors) return {errors: ['Not a WezTerm color scheme: no [colors] table.']}; + const list = (value: unknown) => Array.isArray(value) && value.length === 8 ? value.map(hex) : Array(8).fill(undefined); + const ansi = [...list(colors.ansi), ...list(colors.brights)]; + const background = hex(colors.background); + const foreground = hex(colors.foreground); + const missing = [...missingAnsi(ansi), ...(!background ? ['background'] : []), ...(!foreground ? ['foreground'] : [])]; + if (missing.length) return {errors: [`Not a complete WezTerm color scheme: missing ${missing.join(', ')}.`]}; + const known = new Set(['ansi', 'brights', 'foreground', 'background', 'selection_bg', 'selection_fg', 'cursor_bg', 'cursor_fg', 'cursor_border']); + const extra = Object.keys(colors).filter(key => !known.has(key)); + const metadata = isRecord(data.metadata) ? data.metadata : {}; + const name = typeof metadata.name === 'string' ? metadata.name : fileStem(fileName); + const terminal: TerminalPalette = {background: background!, foreground: foreground!, ansi: ansi as string[], + ...(hex(colors.selection_bg) ? {selectionBackground: hex(colors.selection_bg)!} : {}), + ...(hex(colors.selection_fg) ? {selectionForeground: hex(colors.selection_fg)!} : {}), + ...(hex(colors.cursor_bg) ? {cursor: hex(colors.cursor_bg)!} : {})}; + const ignoredTables = Object.keys(data).filter(key => key !== 'colors' && key !== 'metadata'); + return terminalResult('wezterm', name, terminal, defaults, [ + ...(extra.length ? [`WezTerm color keys without an NMSh role were not imported: ${extra.slice(0, 6).join(', ')}${extra.length > 6 ? '…' : ''}.`] : []), + ...(ignoredTables.length ? [`Non-color tables were ignored: ${ignoredTables.slice(0, 6).join(', ')}.`] : []), + ]); +} + +// ---- Base24 ---------------------------------------------------------------- + +// Base24 styling 0.1.3: the documented ANSI 0–15 sources. +const BASE24_ANSI = ['base00', 'base08', 'base0b', 'base0a', 'base0d', 'base0e', 'base0c', 'base05', 'base03', 'base12', 'base14', 'base13', 'base16', 'base17', 'base15', 'base07']; + +function schemeColors(data: Record): Record { + const source = isRecord(data.palette) ? data.palette : data; + const out: Record = {}; + for (const [key, value] of Object.entries(source)) { + const parsed = hex(value); + if (/^base[01][0-9a-f]$/iu.test(key) && parsed) out[key.toLowerCase()] = parsed; + } + return out; +} + +/** + * Base24 (tinted-theming): all 24 colors are required; ANSI comes from the + * spec's documented terminal mapping. Nothing missing is guessed. + */ +export function importBase24(data: Record, defaults: Record): ImportOutcome { + const colors = schemeColors(data); + const keys = [...Array.from({length: 16}, (_, index) => `base0${index.toString(16)}`), ...Array.from({length: 8}, (_, index) => `base1${index}`)]; + const missing = keys.filter(key => !colors[key]); + if (missing.length) return {errors: [`Not a complete Base24 scheme: missing ${missing.join(', ')}.`]}; + const name = typeof data.name === 'string' ? data.name : typeof data.scheme === 'string' ? data.scheme : 'Imported Base24'; + const terminal: TerminalPalette = {background: colors.base00!, foreground: colors.base05!, ansi: BASE24_ANSI.map(key => colors[key]!), + selectionBackground: colors.base02!, cursor: colors.base05!}; + const {theme, mapping} = themeFromTerminal(name, 'base24', terminal, defaults); + // Base24 names its semantic slots, so the dark-surface and comment roles come from them directly. + if (theme.dark) theme.ui = {...theme.ui, subtle: colors.base03!, separator: colors.base03!, selection: colors.base02!, secondary: colors.base04!}; + theme.prompt.cwd = colors.base02!; + return finish('base24', theme, [...mapping, {role: 'Path / selection / muted', from: 'base02 / base02 / base03'}], + [LOSSY, 'base09, base0F, base10, base11 and base06 have no NMSh role and were not imported.', + theme.dark ? 'Interpreted as a dark scheme (dark base00).' : 'Interpreted as a light scheme (light base00).'], theme.name); +} + +// ---- Oh My Posh ------------------------------------------------------------ + +const OMP_ROLE: Record = {path: 'cwd', git: 'gitBranch', node: 'node', go: 'go', python: 'python', docker: 'docker', + kubectl: 'kubernetes', session: 'project', project: 'project', os: 'project'}; + +/** + * Oh My Posh config (JSON, YAML or TOML, already parsed as data). Only + * deterministic appearance facts are read: literal hex colors, `p:name` + * references into the static `palette`, and segment types as role hints. + * Templates, `*_templates`, conditional `palettes`, named terminal colors, + * remote/inherited configs, commands and prompt logic are never evaluated; + * each becomes a warning, and roles without a source keep NMSh defaults. + */ +export function importOhMyPosh(data: Record, fileName: string, base: CustomTheme): ImportOutcome { + const warnings = new Set(['Oh My Posh prompt logic, segments and templates are not imported; only static colors become an NMSh Native theme.']); + const palette = isRecord(data.palette) ? data.palette : {}; + if (data.palettes !== undefined) warnings.add('Conditional palettes (palettes/template) are dynamic and were ignored; the static palette was used.'); + if (data.extends !== undefined || (typeof data.$schema === 'string' && !/oh-my-posh/u.test(data.$schema))) warnings.add('Inherited or remote configuration is never fetched or merged.'); + const unresolved = new Set(); + const resolve = (value: unknown, depth = 0): string | undefined => { + if (typeof value !== 'string' || depth > 4) return undefined; + const literal = hex(value); + if (literal) return literal; + const reference = /^p:([A-Za-z0-9_.-]{1,64})$/u.exec(value.trim()); + if (reference) { + const target = palette[reference[1]!]; + const resolved = resolve(target, depth + 1); + if (!resolved) unresolved.add(value.trim()); + return resolved; + } + if (value.trim() && !/^(?:transparent|parentBackground|parentForeground|background|foreground|accent)$/u.test(value.trim())) unresolved.add(sanitizeName(value.trim())); + return undefined; + }; + const prompt: Partial> = {}; + const mapping: RoleMapping[] = []; + let accent: string | undefined; + let dynamic = 0; + const blocks = Array.isArray(data.blocks) ? data.blocks.slice(0, 32) : []; + for (const block of blocks) { + if (!isRecord(block) || !Array.isArray(block.segments)) continue; + for (const segment of block.segments.slice(0, 64)) { + if (!isRecord(segment)) continue; + if (segment.foreground_templates !== undefined || segment.background_templates !== undefined) dynamic++; + const type = typeof segment.type === 'string' ? segment.type : ''; + const fill = resolve(segment.background) ?? resolve(segment.foreground); + if (!fill) continue; + accent ??= fill; + const role = OMP_ROLE[type]; + if (role && !prompt[role]) { prompt[role] = fill; mapping.push({role: ROLE_LABELS[role], from: `${sanitizeName(type)} segment`}); } + if ((type === 'status' || type === 'exit') && !prompt.success) { prompt.success = fill; mapping.push({role: 'Success', from: `${type} segment`}); } + } + } + if (dynamic) warnings.add(`${dynamic} dynamic color template${dynamic === 1 ? ' was' : 's were'} ignored (for example foreground_templates); their static colors were used.`); + if (unresolved.size) warnings.add(`Colors that are not static hex values were not imported: ${[...unresolved].slice(0, 6).join(', ')}${unresolved.size > 6 ? '…' : ''}.`); + // Palette entries can still name roles explicitly when segments use other types. + for (const [key, value] of Object.entries(palette)) { + const role = PROMPT_THEME_ROLES.find(candidate => candidate.toLowerCase() === key.toLowerCase()); + const color = resolve(value); + if (role && color && !prompt[role]) { prompt[role] = color; mapping.push({role: ROLE_LABELS[role], from: `palette.${sanitizeName(key)}`}); } + } + if (!Object.keys(prompt).length) return {errors: ['No static Oh My Posh segment colors were found to build an NMSh theme from.']}; + const kept = PROMPT_THEME_ROLES.filter(role => !prompt[role]); + if (kept.length) warnings.add(`No Oh My Posh source for ${kept.map(role => ROLE_LABELS[role]).join(', ')}; ${kept.length === 1 ? 'it keeps' : 'they keep'} ${base.name} colors.`); + warnings.add('Oh My Posh does not define a terminal background; text tiers assume a dark terminal (change it in the editor).'); + const name = sanitizeName(fileStem(fileName).replace(/\.omp$/u, '')) || 'Oh My Posh theme'; + const theme: CustomTheme = {...structuredClone(base), name, basedOn: 'Oh My Posh import', dark: true, + prompt: {...base.prompt, ...prompt}, ui: {...base.ui, ...(accent ? {accent} : {}), + ...(prompt.success ? {success: prompt.success} : {}), ...(prompt.failure ? {failure: prompt.failure} : {})}}; + delete theme.terminal; + if (accent) mapping.push({role: 'Accent', from: 'first colored segment'}); + return finish('oh-my-posh', theme, mapping, [...warnings], name); +} + +// ---- Dispatcher ------------------------------------------------------------ + +function fileStem(fileName: string): string { + const base = fileName.split(/[\\/]/u).pop() ?? fileName; + return sanitizeName(base.replace(/\.(?:json|ya?ml|toml|conf|itermcolors|theme)$/iu, '')) || 'Imported theme'; +} + +function parseYaml(text: string): unknown { + const document = parseDocument(text, {prettyErrors: false, uniqueKeys: false, logLevel: 'silent', customTags: []}); + if (document.errors.length) throw new Error('invalid YAML'); + return document.toJS({maxAliasCount: 64}); +} + +function isOhMyPosh(data: unknown): data is Record { + return isRecord(data) && (Array.isArray(data.blocks) || (typeof data.$schema === 'string' && /oh-my-posh/u.test(data.$schema))); +} + +function isScheme(data: unknown): data is Record { + return isRecord(data) && Boolean(schemeColors(data).base00); +} + +function schemeImport(data: Record, text: string, defaults: Record, forced?: ThemeSourceKind): ImportOutcome { + const colors = schemeColors(data); + const base24 = forced === 'base24' || (forced !== 'base16' && (data.system === 'base24' || Object.keys(colors).some(key => /^base1[0-7]$/u.test(key)))); + if (base24) return importBase24(data, defaults); + const result = importBase16(JSON.stringify(data), defaults) ?? importBase16(text, defaults); + if (!result) return {errors: ['Not a complete Base16 scheme: base00–base0E are required.']}; + return finish('base16', result.theme, [{role: 'Prompt and UI roles', from: 'Base16 slots (see the Base16 mapping in the docs)'}], + [...result.warnings, result.theme.dark ? 'Interpreted as a dark scheme (dark base00).' : 'Interpreted as a light scheme (light base00).'], result.theme.name); +} + +/** + * Parses one local theme file (already read, size-checked text) as the chosen + * format, or detects it. Data only; see the module comment for guarantees. + */ +export function importThemeSource(text: string, fileName: string, defaults: Record, base: CustomTheme, format: ImportFormatChoice = 'auto'): ImportOutcome { + if (text.length > IMPORT_SIZE_LIMIT) return {errors: ['File is larger than 256 KiB.']}; + if (text.includes('\u0000')) return {errors: ['Binary files are not theme files.']}; + const extension = /\.([a-z0-9]+)$/iu.exec(fileName)?.[1]?.toLowerCase() ?? ''; + const want = (kind: ThemeSourceKind) => format === 'auto' || format === kind; + if (format === 'wezterm' || extension === 'lua') { + if (looksLikeLua(text, fileName)) return {errors: [WEZTERM_LUA_GUIDANCE]}; + } + if (format === 'iterm2' || extension === 'itermcolors' || (format === 'auto' && /]/u.test(text))) return importITerm2(text, fileName, defaults); + if (format === 'kitty') return importKitty(text, fileName, defaults); + if (format === 'ghostty') return importGhostty(text, fileName, defaults); + let json: unknown; + try { json = JSON.parse(text); } catch { json = undefined; } + if (json !== undefined) { + if (isRecord(json) && json.schema === THEME_SCHEMA && want('nmsh')) { + const result = validateTheme(json); + return result.ok ? {format: 'nmsh', theme: result.theme, mapping: [], warnings: result.warnings, sourceName: result.theme.name} : {errors: result.errors}; + } + if (isOhMyPosh(json) && want('oh-my-posh')) return importOhMyPosh(json, fileName, base); + if (want('windows-terminal') && (format === 'windows-terminal' || (isRecord(json) && 'brightBlack' in json))) { + const result = importWindowsTerminal(text, defaults); + if (!result) return {errors: ['Not a complete Windows Terminal color scheme.']}; + const terminal = windowsTerminalPalette(json as Record); + return terminal ? terminalResult('windows-terminal', result.theme.name, terminal, defaults, []) + : finish('windows-terminal', result.theme, [], [...result.warnings], result.theme.name); + } + if (isScheme(json) && (want('base16') || want('base24'))) return schemeImport(json, text, defaults, format === 'auto' ? undefined : format); + return {errors: ['This JSON is not an NMSh theme, Oh My Posh config, Windows Terminal scheme or Base16/Base24 scheme.']}; + } + if (extension === 'toml' || format === 'wezterm' || (format === 'oh-my-posh' && /^\s*\[/mu.test(text))) { + let data: unknown; + try { data = parseToml(text); } catch { return {errors: [looksLikeLua(text, fileName) ? WEZTERM_LUA_GUIDANCE : 'Not valid TOML.']}; } + if (isOhMyPosh(data) && want('oh-my-posh')) return importOhMyPosh(data, fileName, base); + if (isRecord(data) && isRecord(data.colors) && want('wezterm')) return importWezTermToml(data, fileName, defaults); + return {errors: ['This TOML is not an Oh My Posh config or a WezTerm color scheme.']}; + } + if (format === 'auto' && looksLikeLua(text, fileName)) return {errors: [WEZTERM_LUA_GUIDANCE]}; + // Line-oriented terminal configs, detected by their own color syntax before YAML. + if (format === 'auto' && /^\s*palette\s*=\s*\d+\s*=/mu.test(text)) return importGhostty(text, fileName, defaults); + if (format === 'auto' && /^\s*color\d{1,2}\s+#?[0-9a-fA-F]{3,6}\s*$/mu.test(text)) return importKitty(text, fileName, defaults); + let yaml: unknown; + try { yaml = parseYaml(text); } catch { return {errors: ['The file is not a recognized theme format (NMSh, Base16/Base24, Windows Terminal, Oh My Posh, Kitty, Ghostty, iTerm2, WezTerm TOML).']}; } + if (isOhMyPosh(yaml) && want('oh-my-posh')) return importOhMyPosh(yaml, fileName, base); + if (isScheme(yaml) && (want('base16') || want('base24'))) return schemeImport(yaml, text, defaults, format === 'auto' ? undefined : format); + return {errors: ['The file is not a recognized theme format (NMSh, Base16/Base24, Windows Terminal, Oh My Posh, Kitty, Ghostty, iTerm2, WezTerm TOML).']}; +} + +function windowsTerminalPalette(json: Record): TerminalPalette | undefined { + const keys = ['black', 'red', 'green', 'yellow', 'blue', 'purple', 'cyan', 'white', 'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', + 'brightBlue', 'brightPurple', 'brightCyan', 'brightWhite']; + const ansi = keys.map(key => hex(json[key])); + const background = hex(json.background); + const foreground = hex(json.foreground); + if (ansi.some(value => !value) || !background || !foreground) return undefined; + return {background, foreground, ansi: ansi as string[], ...(hex(json.selectionBackground) ? {selectionBackground: hex(json.selectionBackground)!} : {}), + ...(hex(json.cursorColor) ? {cursor: hex(json.cursorColor)!} : {})}; +} + +export const IMPORT_FORMAT_CHOICES: readonly ImportFormatChoice[] = ['auto', 'nmsh', 'base16', 'base24', 'windows-terminal', 'oh-my-posh', 'kitty', 'ghostty', 'iterm2', 'wezterm']; +export const importFormatLabel = (format: ImportFormatChoice): string => format === 'auto' ? 'Detect automatically' : THEME_SOURCE_LABELS[format]; diff --git a/src/appearance/themeLibrary.ts b/src/appearance/themeLibrary.ts new file mode 100644 index 00000000..53a0bf17 --- /dev/null +++ b/src/appearance/themeLibrary.ts @@ -0,0 +1,194 @@ +import {randomBytes} from 'node:crypto'; +import {normalizeCustomTheme, sanitizeName, type CustomTheme} from './customTheme.js'; + +/** + * The Native theme library: every user-owned NMSh theme as one asset with a + * stable id. Imported is provenance, never another renderer: an imported + * asset is ordinary NMSh Theme JSON plus where it came from, and it keeps + * working when the source application, file or network is gone. + * + * Canonical state is `themes` (the assets) and `nmsh.themeId` (the asset a + * `custom` palette uses). `customTheme` is a deterministic mirror of that + * asset, rewritten by normalization on every load and save, so the existing + * renderers and older NMSh versions keep reading one theme. A stored + * `customTheme` is only ever read as input when migrating a configuration + * that has no library yet. + */ + +/** Bounded so a damaged or hostile configuration cannot grow without limit. */ +export const THEME_LIBRARY_LIMIT = 64; +/** The asset a pre-library `customTheme` migrates into. */ +export const LEGACY_THEME_ID = 'legacy-custom'; + +export const THEME_SOURCE_KINDS = ['nmsh', 'base16', 'base24', 'windows-terminal', 'oh-my-posh', 'kitty', 'ghostty', 'iterm2', 'wezterm'] as const; +export type ThemeSourceKind = typeof THEME_SOURCE_KINDS[number]; +export const THEME_SOURCE_LABELS: Record = { + nmsh: 'NMSh Theme JSON', base16: 'Base16', base24: 'Base24', 'windows-terminal': 'Windows Terminal', 'oh-my-posh': 'Oh My Posh', + kitty: 'Kitty', ghostty: 'Ghostty', iterm2: 'iTerm2', wezterm: 'WezTerm', +}; + +/** Where an imported asset came from. Local-only: never part of a portable export. */ +export interface ThemeOrigin { + kind: ThemeSourceKind; + /** The source's own scheme name or file name (no directories). */ + sourceName?: string; + /** The local file it was read from, for this machine's display only. */ + sourcePath?: string; + importerVersion?: number; + importedAt?: string; +} + +export interface ThemeAsset { + id: string; + theme: CustomTheme; + /** Present only for imported assets; its presence is what makes the category Imported. */ + origin?: ThemeOrigin; + createdAt?: string; + /** An imported asset edited in NMSh after import (the source file is never touched). */ + modified?: boolean; +} + +export type ThemeCategory = 'custom' | 'imported'; +export const categoryOf = (asset: ThemeAsset): ThemeCategory => asset.origin ? 'imported' : 'custom'; + +/** Current importer behavior version, recorded with each import. */ +export const IMPORTER_VERSION = 2; + +const ID = /^[a-z0-9][a-z0-9-]{2,39}$/u; +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); +const isoDate = (value: unknown): string | undefined => + typeof value === 'string' && value.length <= 40 && !Number.isNaN(Date.parse(value)) ? value : undefined; + +export function newThemeId(taken: ReadonlySet = new Set()): string { + for (;;) { + const id = `t-${randomBytes(6).toString('hex')}`; + if (!taken.has(id)) return id; + } +} + +function normalizeOrigin(value: unknown): ThemeOrigin | undefined { + if (!isRecord(value) || !THEME_SOURCE_KINDS.includes(value.kind as ThemeSourceKind)) return undefined; + const sourceName = typeof value.sourceName === 'string' ? sanitizeName(value.sourceName) : ''; + const sourcePath = typeof value.sourcePath === 'string' && value.sourcePath.length <= 1024 && !/[\u0000-\u001f\u007f-\u009f]/u.test(value.sourcePath) + ? value.sourcePath : undefined; + const importerVersion = typeof value.importerVersion === 'number' && Number.isInteger(value.importerVersion) && value.importerVersion > 0 + ? value.importerVersion : undefined; + const importedAt = isoDate(value.importedAt); + return {kind: value.kind as ThemeSourceKind, ...(sourceName ? {sourceName} : {}), ...(sourcePath ? {sourcePath} : {}), + ...(importerVersion ? {importerVersion} : {}), ...(importedAt ? {importedAt} : {})}; +} + +export function normalizeThemeAsset(value: unknown): ThemeAsset | undefined { + if (!isRecord(value) || typeof value.id !== 'string' || !ID.test(value.id)) return undefined; + const theme = normalizeCustomTheme(value.theme); + if (!theme) return undefined; + const origin = normalizeOrigin(value.origin); + const createdAt = isoDate(value.createdAt); + return {id: value.id, theme, ...(origin ? {origin} : {}), ...(createdAt ? {createdAt} : {}), + ...(origin && value.modified === true ? {modified: true} : {})}; +} + +export interface NormalizedLibrary { + themes: ThemeAsset[]; + /** The asset the `custom` palette uses, when it exists. */ + themeId?: string; +} + +/** + * Library normalization and the one-time migration: a configuration without + * `themes` turns a valid legacy `customTheme` into one Custom asset (kept + * active through the same `custom` palette). Malformed assets are dropped + * individually; duplicate ids keep the first; the library is bounded. + */ +export function normalizeThemeLibrary(themes: unknown, legacyCustom: unknown, themeId: unknown): NormalizedLibrary { + if (!Array.isArray(themes)) { + const legacy = normalizeCustomTheme(legacyCustom); + return legacy ? {themes: [{id: LEGACY_THEME_ID, theme: legacy}], themeId: LEGACY_THEME_ID} : {themes: []}; + } + const seen = new Set(); + const assets: ThemeAsset[] = []; + for (const item of themes.slice(0, THEME_LIBRARY_LIMIT * 2)) { + const asset = normalizeThemeAsset(item); + if (!asset || seen.has(asset.id)) continue; + seen.add(asset.id); + assets.push(asset); + if (assets.length >= THEME_LIBRARY_LIMIT) break; + } + const active = typeof themeId === 'string' && seen.has(themeId) ? themeId : undefined; + return {themes: assets, ...(active ? {themeId: active} : {})}; +} + +export function findTheme(themes: readonly ThemeAsset[], id: string | undefined): ThemeAsset | undefined { + return id === undefined ? undefined : themes.find(asset => asset.id === id); +} + +/** Display line for an asset's provenance: `Imported from Ghostty · Catppuccin`, with `· Modified` after edits. */ +export function provenanceLabel(asset: ThemeAsset): string { + if (!asset.origin) return asset.theme.basedOn ? `Custom · based on ${asset.theme.basedOn}` : 'Custom'; + const source = [THEME_SOURCE_LABELS[asset.origin.kind], asset.origin.sourceName].filter(Boolean).join(' · '); + return `Imported from ${source}${asset.modified ? ' · Modified' : ''}`; +} + +export function libraryCounts(themes: readonly ThemeAsset[]): {imported: number; custom: number} { + const imported = themes.filter(asset => asset.origin).length; + return {imported, custom: themes.length - imported}; +} + +/** `2 imported · 3 custom`, or undefined for an empty library. */ +export function librarySummary(themes: readonly ThemeAsset[]): string | undefined { + const {imported, custom} = libraryCounts(themes); + const parts = [imported ? `${imported} imported` : '', custom ? `${custom} custom` : ''].filter(Boolean); + return parts.length ? parts.join(' · ') : undefined; +} + +/** A unique display name in the library ("Name", "Name 2", ...); names never identify assets. */ +export function uniqueThemeName(themes: readonly ThemeAsset[], wanted: string, except?: string): string { + const base = sanitizeName(wanted) || 'Theme'; + const taken = new Set(themes.filter(asset => asset.id !== except).map(asset => asset.theme.name.toLowerCase())); + if (!taken.has(base.toLowerCase())) return base; + for (let index = 2; ; index++) { + const suffix = ` ${index}`; + const candidate = `${base.slice(0, 48 - suffix.length)}${suffix}`; + if (!taken.has(candidate.toLowerCase())) return candidate; + } +} + +export type LibraryResult = {ok: true; value: T} | {ok: false; error: string}; + +/** A new asset in the library; fails cleanly when the library is full. */ +export function addThemeAsset(themes: readonly ThemeAsset[], theme: CustomTheme, origin?: ThemeOrigin, now = new Date()): LibraryResult<{themes: ThemeAsset[]; asset: ThemeAsset}> { + if (themes.length >= THEME_LIBRARY_LIMIT) return {ok: false, error: `The theme library is full (${THEME_LIBRARY_LIMIT} themes). Delete one first.`}; + const asset: ThemeAsset = {id: newThemeId(new Set(themes.map(item => item.id))), + theme: {...structuredClone(theme), name: uniqueThemeName(themes, theme.name)}, + ...(origin ? {origin: {...origin}} : {}), createdAt: now.toISOString()}; + return {ok: true, value: {themes: [...themes, asset], asset}}; +} + +/** Saves edited colors/name; an imported asset becomes Modified (its source file is never touched). */ +export function updateThemeAsset(themes: readonly ThemeAsset[], id: string, theme: CustomTheme): LibraryResult { + const current = findTheme(themes, id); + if (!current) return {ok: false, error: 'That theme no longer exists.'}; + const changed = JSON.stringify(current.theme) !== JSON.stringify(theme); + const next: ThemeAsset = {...current, theme: {...structuredClone(theme), name: uniqueThemeName(themes, theme.name, id)}, + ...(current.origin && (changed || current.modified) ? {modified: true} : {})}; + return {ok: true, value: themes.map(asset => asset.id === id ? next : asset)}; +} + +export function renameThemeAsset(themes: readonly ThemeAsset[], id: string, name: string): LibraryResult { + const current = findTheme(themes, id); + if (!current) return {ok: false, error: 'That theme no longer exists.'}; + const clean = sanitizeName(name); + if (!clean) return {ok: false, error: 'Name must be 1–48 printable characters.'}; + return updateThemeAsset(themes, id, {...current.theme, name: clean}); +} + +/** A Custom copy (no provenance) whose Based on names the original. */ +export function duplicateThemeAsset(themes: readonly ThemeAsset[], id: string, now = new Date()): LibraryResult<{themes: ThemeAsset[]; asset: ThemeAsset}> { + const current = findTheme(themes, id); + if (!current) return {ok: false, error: 'That theme no longer exists.'}; + return addThemeAsset(themes, {...current.theme, name: `${current.theme.name.slice(0, 43)} copy`, basedOn: current.theme.name}, undefined, now); +} + +export function removeThemeAsset(themes: readonly ThemeAsset[], id: string): ThemeAsset[] { + return themes.filter(asset => asset.id !== id); +} diff --git a/src/appearance/themeLibraryActions.ts b/src/appearance/themeLibraryActions.ts new file mode 100644 index 00000000..ceea3f64 --- /dev/null +++ b/src/appearance/themeLibraryActions.ts @@ -0,0 +1,127 @@ +import type {PromptConfiguration} from '../prompt/configuration.js'; +import {normalizePromptConfiguration} from '../prompt/configuration.js'; +import {BRIDGE_TARGET_LABELS, globallyPinnedTo, targetsPinnedTo, type BridgeTargetId} from '../themeBridge/model.js'; +import type {CustomTheme} from './customTheme.js'; +import { + addThemeAsset, duplicateThemeAsset, findTheme, removeThemeAsset, renameThemeAsset, updateThemeAsset, type ThemeOrigin, +} from './themeLibrary.js'; +import {activeThemeRef, assetRef, builtinTheme, parseThemeRef, themeForRef, type ThemeRef} from './themeRefs.js'; + +/** + * Library operations over the whole configuration: the one place assets, + * the active selection, the `customTheme` mirror and Theme Bridge references + * change together. Every result is re-normalized so the mirror is always the + * active asset. Nothing here touches the filesystem. + */ + +export type ActionResult = {ok: true; config: PromptConfiguration; message: string; id?: string} | {ok: false; error: string}; + +const finish = (config: PromptConfiguration, message: string, id?: string): ActionResult => + ({ok: true, config: normalizePromptConfiguration(config), message, ...(id ? {id} : {})}); + +/** Make any selectable theme the active NMSh theme. A missing asset is an error, never a substitute. */ +export function setActiveTheme(config: PromptConfiguration, ref: ThemeRef): ActionResult { + const parsed = parseThemeRef(ref); + if (!parsed) return {ok: false, error: 'Not a theme reference.'}; + if (parsed.kind === 'builtin') { + return finish({...config, nmsh: {...config.nmsh, palette: parsed.palette, ...(parsed.accent ? {accent: parsed.accent} : {})}}, `Theme · ${builtinTheme(parsed.palette, parsed.accent).name}`); + } + const asset = findTheme(config.themes, parsed.id); + if (!asset) return {ok: false, error: 'That theme no longer exists.'}; + return finish({...config, nmsh: {...config.nmsh, palette: 'custom', themeId: asset.id}}, `Theme · ${asset.theme.name}`); +} + +/** A new Custom or Imported asset; `activate` also makes it the active theme. */ +export function addTheme(config: PromptConfiguration, theme: CustomTheme, origin?: ThemeOrigin, activate = false): ActionResult { + const added = addThemeAsset(config.themes, theme, origin); + if (!added.ok) return added; + const next = {...config, themes: added.value.themes}; + if (activate) next.nmsh = {...next.nmsh, palette: 'custom', themeId: added.value.asset.id}; + return finish(next, `${origin ? 'Imported' : 'Saved'} ${added.value.asset.theme.name}${activate ? ' · active' : ''}`, added.value.asset.id); +} + +export function saveTheme(config: PromptConfiguration, id: string, theme: CustomTheme): ActionResult { + const updated = updateThemeAsset(config.themes, id, theme); + if (!updated.ok) return updated; + return finish({...config, themes: updated.value}, `Saved ${findTheme(updated.value, id)!.theme.name}`, id); +} + +export function renameTheme(config: PromptConfiguration, id: string, name: string): ActionResult { + const renamed = renameThemeAsset(config.themes, id, name); + if (!renamed.ok) return renamed; + return finish({...config, themes: renamed.value}, `Renamed to ${findTheme(renamed.value, id)!.theme.name}`, id); +} + +export function duplicateTheme(config: PromptConfiguration, id: string): ActionResult { + const copied = duplicateThemeAsset(config.themes, id); + if (!copied.ok) return copied; + return finish({...config, themes: copied.value.themes}, `Duplicated as ${copied.value.asset.theme.name} (Custom)`, copied.value.asset.id); +} + +/** A built-in, immutable theme copied into the library as an editable Custom theme. */ +/** + * The current theme (Built-in, Imported or Custom) copied into a new Custom + * theme named " - Custom". Imported provenance is dropped, so the copy is + * genuinely Custom. The active theme does not change unless `activate`. + */ +export function duplicateCurrentToCustom(config: PromptConfiguration, activate = false): ActionResult { + const ref = activeThemeRef(config); + const resolved = themeForRef(ref, config); + if (!resolved.ok) return {ok: false, error: 'The current theme cannot be resolved.'}; + const name = `${resolved.theme.name.slice(0, 39)} - Custom`; + return addTheme(config, {...resolved.theme, name, basedOn: resolved.theme.name}, undefined, activate); +} + +/** Any theme reference copied to a new Custom theme (bat setup and Studio reuse this). */ +export function duplicateRefToCustom(config: PromptConfiguration, ref: ThemeRef): ActionResult { + const resolved = themeForRef(ref, config); + if (!resolved.ok) return {ok: false, error: 'That theme cannot be resolved.'}; + return addTheme(config, {...resolved.theme, name: `${resolved.theme.name.slice(0, 39)} - Custom`, basedOn: resolved.theme.name}); +} + +export function duplicateBuiltin(config: PromptConfiguration, ref: ThemeRef): ActionResult { + const parsed = parseThemeRef(ref); + if (parsed?.kind !== 'builtin') return {ok: false, error: 'Not a built-in theme.'}; + const source = builtinTheme(parsed.palette, parsed.accent); + return addTheme(config, {...source, name: `My ${source.name}`.slice(0, 48), basedOn: source.name}); +} + +export interface DeleteCheck { + /** Deleting the active NMSh theme is refused: choose another theme first, so nothing falls back silently. */ + active: boolean; + /** Theme Bridge targets pinned to this theme (Choose theme). */ + pinned: BridgeTargetId[]; + /** Theme Bridge's global Choose theme is this theme. */ + global: boolean; +} + +export function deleteCheck(config: PromptConfiguration, id: string): DeleteCheck { + return {active: config.nmsh.palette === 'custom' && config.nmsh.themeId === id, pinned: targetsPinnedTo(config.themeBridge, assetRef(id)), + global: globallyPinnedTo(config.themeBridge, assetRef(id))}; +} + +/** + * Deletes an asset. Pinned Theme Bridge targets block the delete unless the + * user explicitly confirmed that they become Independent; the active theme is + * never deleted. No reference is ever left dangling or re-pointed elsewhere. + */ +export function deleteTheme(config: PromptConfiguration, id: string, confirmIndependent = false): ActionResult { + const asset = findTheme(config.themes, id); + if (!asset) return {ok: false, error: 'That theme no longer exists.'}; + const check = deleteCheck(config, id); + if (check.active) return {ok: false, error: `${asset.theme.name} is the active theme. Choose another theme first.`}; + if ((check.pinned.length || check.global) && !confirmIndependent) { + const who = [...(check.global ? ['Theme Bridge (Choose theme for every tool)'] : []), ...check.pinned.map(target => `Theme Bridge ${BRIDGE_TARGET_LABELS[target]}`)]; + return {ok: false, error: `${who.join(', ')} ${who.length === 1 ? 'is' : 'are'} pinned to ${asset.theme.name}.`}; + } + const targets = structuredClone(config.themeBridge.targets); + // Explicitly confirmed: affected targets become Independent (never another theme). The old reference is dropped. + for (const target of check.pinned) targets[target] = {mode: 'independent'}; + // A deleted global pin goes back to Manual (never to another theme); the Manual state is untouched. + const {theme: _global, ...bridge} = config.themeBridge; + const themeBridge = check.global ? {...bridge, policy: config.themeBridge.policy === 'choose' ? 'manual' as const : config.themeBridge.policy, targets} : {...config.themeBridge, targets}; + const next: PromptConfiguration = {...config, themes: removeThemeAsset(config.themes, id), themeBridge, + nmsh: config.nmsh.themeId === id ? (({themeId: _removed, ...rest}) => rest)(config.nmsh) as PromptConfiguration['nmsh'] : config.nmsh}; + const suffix = check.pinned.length ? ` · ${check.pinned.map(target => BRIDGE_TARGET_LABELS[target]).join(', ')} now Independent` : ''; + return finish(next, `Deleted ${asset.theme.name}${suffix}`); +} diff --git a/src/appearance/themeRefs.ts b/src/appearance/themeRefs.ts new file mode 100644 index 00000000..2e72b2fc --- /dev/null +++ b/src/appearance/themeRefs.ts @@ -0,0 +1,105 @@ +import {CATPPUCCIN_ACCENTS, CATPPUCCIN_ACCENT_LABELS, accentedVariant, themeVariant, type CatppuccinAccent} from './themeFamilies.js'; +import {cloneTheme, PROMPT_THEME_ROLES, type CustomTheme, type PromptThemeRole, type UiThemeRole} from './customTheme.js'; +import {categoryOf, findTheme, type ThemeAsset} from './themeLibrary.js'; +import {defaultUiColors} from './uiTheme.js'; +import {hexColor} from '../chroma/color.js'; +import {NATIVE_PALETTE_IDS, THEME_PALETTE_IDS, type NativePaletteId} from '../prompt/configuration.js'; +import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js'; + +/** + * A stable reference to any selectable NMSh theme. Built-ins are referenced by + * palette id (plus the Catppuccin accent, which changes their colors); library + * themes by asset id, never by display name: + * + * builtin:gruvboxDark builtin:catppuccinMocha@peach asset:t-1a2b3c4d5e6f + */ +export type ThemeRef = string; +export type ParsedThemeRef = {kind: 'builtin'; palette: Exclude; accent?: CatppuccinAccent} | {kind: 'asset'; id: string}; + +const BUILTIN_IDS = new Set(THEME_PALETTE_IDS.filter(id => id !== 'custom')); + +export function parseThemeRef(ref: unknown): ParsedThemeRef | undefined { + if (typeof ref !== 'string' || ref.length > 80) return undefined; + const asset = /^asset:([a-z0-9][a-z0-9-]{2,39})$/u.exec(ref); + if (asset) return {kind: 'asset', id: asset[1]!}; + const builtin = /^builtin:([A-Za-z0-9]+)(?:@([a-z]+))?$/u.exec(ref); + if (!builtin || !BUILTIN_IDS.has(builtin[1]!)) return undefined; + const accent = builtin[2]; + if (accent !== undefined && !CATPPUCCIN_ACCENTS.includes(accent as CatppuccinAccent)) return undefined; + return {kind: 'builtin', palette: builtin[1] as Exclude, ...(accent ? {accent: accent as CatppuccinAccent} : {})}; +} + +export function builtinRef(palette: Exclude, accent?: CatppuccinAccent): ThemeRef { + // Only accented families carry the accent, and Mauve is their default. + return themeVariant(palette)?.accents && accent && accent !== 'mauve' ? `builtin:${palette}@${accent}` : `builtin:${palette}`; +} + +export const assetRef = (id: string): ThemeRef => `asset:${id}`; + +/** What a library-aware configuration exposes to theme resolution. */ +export interface ThemeSource { + nmsh: {palette: NativePaletteId; accent: CatppuccinAccent; themeId?: string}; + themes: readonly ThemeAsset[]; +} + +/** The active NMSh theme as a stable reference (what Follow NMSh follows). */ +export function activeThemeRef(config: ThemeSource): ThemeRef | undefined { + if (config.nmsh.palette === 'custom') return config.nmsh.themeId && findTheme(config.themes, config.nmsh.themeId) ? assetRef(config.nmsh.themeId) : undefined; + return builtinRef(config.nmsh.palette, config.nmsh.accent); +} + +const UI_DEFAULTS = (): Record => { + const ui = defaultUiColors(); + return {accent: hexColor(ui.accent), primary: hexColor(ui.primary), secondary: hexColor(ui.secondary), subtle: hexColor(ui.subtle), + separator: hexColor(ui.separator), selection: hexColor(ui.selection), success: hexColor(ui.success), warning: '#d99a3e', + failure: hexColor(ui.failure), info: '#4fb3c4'}; +}; + +/** + * A built-in palette as Native theme data, with the accent applied explicitly + * (never the live global theme context), so resolution is pure. + */ +export function builtinTheme(palette: Exclude, accent: CatppuccinAccent = 'mauve'): CustomTheme { + const defaults = UI_DEFAULTS(); + const variant = themeVariant(palette); + if (variant) { + const accented = accentedVariant(variant, accent); + return cloneTheme(accented.label, accented.label, {...accented.roles}, accented.ui, defaults, variant.dark); + } + const native = NATIVE_PROMPT_THEMES[palette] ?? NATIVE_PROMPT_THEMES.lavender; + const prompt = Object.fromEntries(PROMPT_THEME_ROLES.map(role => [role, hexColor(native.colors(role).background)])) as Record; + return cloneTheme(native.label, native.label, prompt, {accent: defaults.accent, separator: defaults.separator, success: defaults.success, failure: defaults.failure}, defaults, true); +} + +export type ThemeResolution = {ok: true; theme: CustomTheme; category: 'builtin' | 'imported' | 'custom'} | {ok: false; reason: 'invalid' | 'missing'}; + +/** Resolves a reference to theme data. A missing asset is reported, never replaced by another theme. */ +export function themeForRef(ref: ThemeRef | undefined, source: Pick): ThemeResolution { + const parsed = parseThemeRef(ref); + if (!parsed) return {ok: false, reason: 'invalid'}; + if (parsed.kind === 'builtin') return {ok: true, theme: builtinTheme(parsed.palette, parsed.accent), category: 'builtin'}; + const asset = findTheme(source.themes, parsed.id); + return asset ? {ok: true, theme: structuredClone(asset.theme), category: categoryOf(asset)} : {ok: false, reason: 'missing'}; +} + +export function themeRefLabel(ref: ThemeRef | undefined, source: Pick): string { + const parsed = parseThemeRef(ref); + if (!parsed) return '—'; + if (parsed.kind === 'asset') return findTheme(source.themes, parsed.id)?.theme.name ?? 'Missing theme'; + const label = (NATIVE_PROMPT_THEMES[parsed.palette] ?? NATIVE_PROMPT_THEMES.lavender).label; + return parsed.accent ? `${label} · ${CATPPUCCIN_ACCENT_LABELS[parsed.accent]}` : label; +} + +export interface SelectableTheme {ref: ThemeRef; label: string; category: 'builtin' | 'imported' | 'custom'} + +/** Every theme a picker can offer: built-ins (default accents), then imported, then custom. */ +export function selectableThemes(source: Pick): SelectableTheme[] { + const builtins = THEME_PALETTE_IDS.filter((id): id is Exclude => id !== 'custom') + .map(palette => ({ref: builtinRef(palette), label: NATIVE_PROMPT_THEMES[palette].label, category: 'builtin' as const})); + const library = (category: 'imported' | 'custom') => source.themes.filter(asset => categoryOf(asset) === category) + .map(asset => ({ref: assetRef(asset.id), label: asset.theme.name, category})); + return [...builtins, ...library('imported'), ...library('custom')]; +} + +/** NMSh's own (non-family) palettes, for grouping in pickers. */ +export const isNativePalette = (palette: NativePaletteId): boolean => NATIVE_PALETTE_IDS.includes(palette); diff --git a/src/appearance/themeSelection.ts b/src/appearance/themeSelection.ts new file mode 100644 index 00000000..4936dea7 --- /dev/null +++ b/src/appearance/themeSelection.ts @@ -0,0 +1,118 @@ +import {NATIVE_PALETTE_IDS, type NativePaletteId, type PromptConfiguration} from '../prompt/configuration.js'; +import {NATIVE_PROMPT_THEMES, themeContext} from '../prompt/prompt.js'; +import {hexColor} from '../chroma/color.js'; +import {accentedVariant, familyVariants, THEME_FAMILIES, themeVariant, type ThemeFamilyId} from './themeFamilies.js'; +import {cloneTheme, PROMPT_THEME_ROLES, type CustomTheme, type PromptThemeRole, type UiThemeRole} from './customTheme.js'; +import {defaultUiColors, uiThemeInput} from './uiTheme.js'; +import {categoryOf, findTheme, libraryCounts} from './themeLibrary.js'; + +/** + * Theme family / variant selection over the one stored palette id. NMSh's own + * themes are the NMSh family; each bundled family has its variants; Custom is + * the user's theme. Family and variant are derived, never stored separately. + */ +export function familyOf(palette: NativePaletteId): ThemeFamilyId { + if (palette === 'custom') return 'custom'; + return themeVariant(palette)?.family ?? 'nmsh'; +} + +export interface VariantOption {id: NativePaletteId; label: string} + +export function variantOptions(family: ThemeFamilyId): VariantOption[] { + if (family === 'nmsh') return NATIVE_PALETTE_IDS.map(id => ({id, label: NATIVE_PROMPT_THEMES[id].label})); + if (family === 'custom') return [{id: 'custom', label: 'Custom'}]; + return familyVariants(family).map(variant => ({id: variant.id as NativePaletteId, label: variant.variant})); +} + +/** The variant a family opens on: the documented default for each family. */ +const DEFAULT_VARIANT: Record = { + nmsh: 'lavender', catppuccin: 'catppuccinMocha', dracula: 'dracula', tokyonight: 'tokyonightNight', gruvbox: 'gruvboxDark', + rosepine: 'rosePine', nord: 'nord', solarized: 'solarizedDark', onedark: 'oneDark', custom: 'custom', +}; + +const UI_DEFAULT_HEX = (): Record => { + const ui = defaultUiColors(); + return {accent: hexColor(ui.accent), primary: hexColor(ui.primary), secondary: hexColor(ui.secondary), subtle: hexColor(ui.subtle), + separator: hexColor(ui.separator), selection: hexColor(ui.selection), success: hexColor(ui.success), warning: '#d99a3e', + failure: hexColor(ui.failure), info: '#4fb3c4'}; +}; + +/** A custom theme cloned from any theme's resolved colors (accent applied). */ +export function cloneFromPalette(palette: NativePaletteId, accent = themeContext().accent, name?: string): CustomTheme { + const defaults = UI_DEFAULT_HEX(); + if (palette === 'custom' && themeContext().custom) return structuredClone(themeContext().custom!); + const theme = NATIVE_PROMPT_THEMES[palette] ?? NATIVE_PROMPT_THEMES.lavender; + const prompt = Object.fromEntries(PROMPT_THEME_ROLES.map(role => [role, hexColor(theme.colors(role).background)])) as Record; + const variant = themeVariant(palette); + const ui = uiThemeInput(palette, accent, undefined) ?? {accent: defaults.accent, separator: defaults.separator, success: defaults.success, failure: defaults.failure}; + const label = variant ? accentedVariant(variant, accent).label : theme.label; + return cloneTheme(name ?? `My ${label}`.slice(0, 48), label, prompt, ui, defaults, variant ? variant.dark : true); +} + +/** The variant a theme family opens on (the same default Settings and Setup use). */ +export function defaultVariant(family: ThemeFamilyId): NativePaletteId { + return DEFAULT_VARIANT[family]; +} + +/** Choosing a family moves to its default variant; Custom clones the current theme when there is none yet. */ +export function selectFamily(config: PromptConfiguration, family: ThemeFamilyId): PromptConfiguration { + if (family === 'custom') { + const customTheme = config.customTheme ?? cloneFromPalette(config.nmsh.palette, config.nmsh.accent); + return {...config, customTheme, nmsh: {...config.nmsh, palette: 'custom'}}; + } + return {...config, nmsh: {...config.nmsh, palette: DEFAULT_VARIANT[family]}}; +} + +export const FAMILY_IDS: readonly ThemeFamilyId[] = THEME_FAMILIES.map(family => family.id); +export const FAMILY_LABELS: readonly string[] = THEME_FAMILIES.map(family => family.label); + +export function variantLabel(config: PromptConfiguration): string { + return THEME_FAMILIES.find(family => family.id === familyOf(config.nmsh.palette))?.variantLabel ?? 'Variant'; +} + +// ---- Library-aware selection (Setup and Settings) ----------------------------- + +/** Built-in families, then Imported and Custom library themes as their own groups. */ +export type SelectionFamily = Exclude | 'imported' | 'custom'; + +export function currentSelectionFamily(config: PromptConfiguration): SelectionFamily { + if (config.nmsh.palette !== 'custom') return familyOf(config.nmsh.palette) as SelectionFamily; + const asset = findTheme(config.themes, config.nmsh.themeId); + return asset ? categoryOf(asset) : 'custom'; +} + +/** Families offered: every built-in family, plus Imported/Custom when the library has such themes. */ +export function selectionFamilies(config: PromptConfiguration): SelectionFamily[] { + const builtins = FAMILY_IDS.filter((id): id is Exclude => id !== 'custom'); + const {imported, custom} = libraryCounts(config.themes); + return [...builtins, ...(imported ? ['imported' as const] : []), ...(custom ? ['custom' as const] : [])]; +} + +export function selectionFamilyLabel(family: SelectionFamily): string { + if (family === 'imported') return 'Imported'; + if (family === 'custom') return 'Custom'; + return THEME_FAMILIES.find(item => item.id === family)?.label ?? family; +} + +export interface SelectionVariant {label: string; apply: (config: PromptConfiguration) => PromptConfiguration; current: (config: PromptConfiguration) => boolean} + +/** Variants within a family: palette variants for built-ins, library themes (by stable id) for Imported/Custom. */ +export function selectionVariants(config: PromptConfiguration, family: SelectionFamily): SelectionVariant[] { + if (family === 'imported' || family === 'custom') { + return config.themes.filter(asset => categoryOf(asset) === family).map(asset => ({label: asset.theme.name, + apply: c => ({...c, nmsh: {...c.nmsh, palette: 'custom', themeId: asset.id}, customTheme: structuredClone(asset.theme)}), + current: c => c.nmsh.palette === 'custom' && c.nmsh.themeId === asset.id})); + } + return variantOptions(family).map(option => ({label: option.label, + apply: c => ({...c, nmsh: {...c.nmsh, palette: option.id}}), current: c => c.nmsh.palette === option.id})); +} + +/** Choosing a family moves to its default variant, or the first library theme of that category. */ +export function selectSelectionFamily(config: PromptConfiguration, family: SelectionFamily): PromptConfiguration { + if (currentSelectionFamily(config) === family) return config; + if (family === 'imported' || family === 'custom') { + const first = selectionVariants(config, family)[0]; + return first ? first.apply(config) : config; + } + return {...config, nmsh: {...config.nmsh, palette: DEFAULT_VARIANT[family]}}; +} diff --git a/src/appearance/uiChrome.ts b/src/appearance/uiChrome.ts new file mode 100644 index 00000000..c26e85b7 --- /dev/null +++ b/src/appearance/uiChrome.ts @@ -0,0 +1,160 @@ +import {fromOklch, hexColor, parseHexColor, toOklch} from '../chroma/color.js'; +import type {Rgb} from '../chroma/escape.js'; +import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js'; +import type {NativePaletteId} from '../prompt/configuration.js'; +import {UI_THEME_ROLES, validHex, type CustomTheme, type UiThemeRole} from './customTheme.js'; +import type {CatppuccinAccent} from './themeFamilies.js'; +import {defaultUiColors, uiThemeInput, type UiThemeInput} from './uiTheme.js'; + +/** + * UI chrome: the colors of NMSh-owned frames, rules, tabs, selection, focus + * accents and markers. One setting decides where they come from: + * + * - Follow theme (default): the active theme's chrome. Lavender Native uses + * the #A67CF3 accent with neutral text and surfaces, plus optional Text and + * Background tints (lavenderChrome); Brand / Semantic keeps the shipped + * chrome exactly; other NMSh themes derive chrome from their + * own module colors; bundled families and custom themes use their UI roles. + * - Custom: a preset (Native Lavender, Grayscale) or the user's own colors. + * + * Chroma never paints UI chrome; only the Native prompt and explicitly + * Chroma-aware surfaces use it. + */ +export const CHROME_SOURCES = ['theme', 'custom'] as const; +export type ChromeSource = typeof CHROME_SOURCES[number]; +export const CHROME_PRESETS = ['lavender', 'grayscale', 'custom'] as const; +export type ChromePreset = typeof CHROME_PRESETS[number]; +export const CHROME_PRESET_LABELS: Record = {lavender: 'Native Lavender', grayscale: 'Grayscale', custom: 'Custom colors'}; + +export interface UiChromeSettings { + source: ChromeSource; + preset: ChromePreset; + /** Used with preset `custom`; kept when another preset is chosen. */ + colors?: Record; + /** + * Theme text: the active theme supplies NMSh-owned text tiers (primary, + * secondary, muted). Off keeps NMSh's neutral text. Separators, selection + * and semantic status colors keep their own roles either way. + */ + themeText?: boolean; + /** Lavender Native only: lavender-tinted text tiers instead of NMSh's neutral text. Default Off. */ + lavenderText?: LavenderTint; + /** Lavender Native only: dark lavender/plum surfaces instead of the neutral ones. Default Off. */ + lavenderSurface?: LavenderTint; +} + +export type LavenderTint = 'off' | 'lavender'; +export const LAVENDER_TINTS: readonly LavenderTint[] = ['off', 'lavender']; +export const LAVENDER_TINT_LABELS: Record = {off: 'Off', lavender: 'Lavender'}; + +export const DEFAULT_UI_CHROME: UiChromeSettings = {source: 'theme', preset: 'lavender', themeText: true, lavenderText: 'off', lavenderSurface: 'off'}; + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); + +export function normalizeUiChrome(value: unknown): UiChromeSettings { + const v = isRecord(value) ? value : {}; + const colorsValue = isRecord(v.colors) ? v.colors : undefined; + const colors = colorsValue && UI_THEME_ROLES.every(role => validHex(colorsValue[role])) + ? Object.fromEntries(UI_THEME_ROLES.map(role => [role, (colorsValue[role] as string).toLowerCase()])) as Record : undefined; + const preset = CHROME_PRESETS.includes(v.preset as ChromePreset) ? v.preset as ChromePreset : 'lavender'; + return {source: CHROME_SOURCES.includes(v.source as ChromeSource) ? v.source as ChromeSource : 'theme', + preset: preset === 'custom' && !colors ? 'lavender' : preset, ...(colors ? {colors} : {}), themeText: v.themeText !== false, + // Older configurations have neither field: both tints start Off. + lavenderText: v.lavenderText === 'lavender' ? 'lavender' : 'off', lavenderSurface: v.lavenderSurface === 'lavender' ? 'lavender' : 'off'}; +} + +/** Neutral chrome: gray accents and rules; status colors keep their meaning. */ +const GRAYSCALE: UiThemeInput = {accent: '#c9cacf', separator: '#76787e', selection: '#45474c', success: '#74b59a', failure: '#cd737b'}; + +/** Brand / Semantic keeps the original shipped chrome exactly. */ +const SHIPPED_CHROME: ReadonlySet = new Set(['brand']); + +/** The brand accent is Lavender Native's identity in every combination. */ +export const LAVENDER_ACCENT = '#a67cf3'; +/** Opt-in lavender text tiers (Text tint: Lavender); >= 4.5:1 on dark lavender surfaces. */ +export const LAVENDER_TEXT = {primary: '#f1ebff', secondary: '#d8ccf2', subtle: '#a99bc6'} as const; +/** Opt-in dark plum surface (Background tint: Lavender); primary text on it is 11.5:1. */ +export const LAVENDER_SURFACE = {selection: '#352a47'} as const; + +/** + * Lavender Native chrome: the #A67CF3 accent always; NMSh's neutral text and + * surfaces unless the user turns on Text tint or Background tint, each + * independently. Separator and success/failure keep the shipped colors. + */ +export function lavenderChrome(chrome: Pick): UiThemeInput { + return {accent: LAVENDER_ACCENT, separator: '#8b84b2', success: '#74b59a', failure: '#cd737b', + ...(chrome.lavenderText === 'lavender' ? LAVENDER_TEXT : {}), ...(chrome.lavenderSurface === 'lavender' ? LAVENDER_SURFACE : {})}; +} + +const tone = (color: Rgb, l: number, minChroma: number, maxChroma = 0.2) => { + const lch = toOklch(color); + return hexColor(fromOklch({l, c: Math.max(minChroma, Math.min(maxChroma, lch.c)), h: lch.h})); +}; + +/** + * Chrome for an NMSh Native theme from its own module colors: the project + * color becomes the accent, rules and selection take that hue in quieter + * tones, and status keeps the theme's success and failure. + */ +export function nativeThemeChrome(palette: NativePaletteId): UiThemeInput | undefined { + if (SHIPPED_CHROME.has(palette)) return undefined; + const theme = NATIVE_PROMPT_THEMES[palette]; + if (!theme) return undefined; + const accent = theme.colors('project').background; + const neutral = toOklch(accent).c < 0.03; + return { + accent: tone(accent, 0.76, neutral ? 0 : 0.09), + separator: tone(accent, 0.55, neutral ? 0 : 0.05, 0.08), + selection: tone(accent, 0.34, neutral ? 0 : 0.05, 0.09), + success: tone(theme.colors('success').background, 0.72, 0.06), + failure: tone(theme.colors('failure').background, 0.66, 0.08), + }; +} + +export function chromeFromColors(colors: Record): UiThemeInput { + return {accent: colors.accent, separator: colors.separator, success: colors.success, failure: colors.failure, + primary: colors.primary, secondary: colors.secondary, subtle: colors.subtle, selection: colors.selection}; +} + +/** + * Text tiers for a theme that has no dark-terminal text roles of its own + * (light variants, NMSh Native themes): readable light tones of the theme's + * accent hue, in a clear primary → secondary → muted hierarchy. + */ +export function derivedThemeText(accentHex: string): Pick { + const lch = toOklch(parseHexColor(accentHex) ?? {red: 197, green: 185, blue: 232}); + const neutral = lch.c < 0.03; + const at = (l: number, c: number) => hexColor(fromOklch({l, c: neutral ? 0 : c, h: lch.h})); + return {primary: at(0.93, 0.022), secondary: at(0.8, 0.04), subtle: at(0.64, 0.045)}; +} + +/** The chrome to apply for a configuration; undefined means the shipped NMSh chrome. */ +export function resolveChrome(chrome: UiChromeSettings, palette: NativePaletteId, accent: CatppuccinAccent, custom: CustomTheme | undefined): UiThemeInput | undefined { + if (chrome.source === 'custom') { + if (chrome.preset === 'grayscale') return GRAYSCALE; + if (chrome.preset === 'custom' && chrome.colors) return chromeFromColors(chrome.colors); + return lavenderChrome(chrome); + } + // Lavender Native: neutral text unless Text tint is on (never derived from the accent). + if (palette === 'lavender') return lavenderChrome(chrome.themeText === false ? {...chrome, lavenderText: 'off'} : chrome); + const input = uiThemeInput(palette, accent, custom) ?? nativeThemeChrome(palette); + if (!input) return undefined; + if (chrome.themeText === false) { + // Theme text Off: NMSh's neutral text tiers; the theme still colors chrome roles. + const {primary: _primary, secondary: _secondary, subtle: _subtle, ...roles} = input; + return roles; + } + return input.primary ? input : {...input, ...derivedThemeText(input.accent)}; +} + +/** Editable starting colors for Custom chrome: whatever chrome is in effect now. */ +export function chromeColorsFrom(input: UiThemeInput | undefined): Record { + const ui = defaultUiColors(); + const base: Record = {accent: hexColor(ui.accent), primary: hexColor(ui.primary), secondary: hexColor(ui.secondary), + subtle: hexColor(ui.subtle), separator: hexColor(ui.separator), selection: hexColor(ui.selection), success: hexColor(ui.success), + warning: '#d99a3e', failure: hexColor(ui.failure), info: '#4fb3c4'}; + if (!input) return base; + return {...base, accent: input.accent, separator: input.separator, success: input.success, failure: input.failure, + ...(input.primary ? {primary: input.primary} : {}), ...(input.secondary ? {secondary: input.secondary} : {}), + ...(input.subtle ? {subtle: input.subtle} : {}), ...(input.selection ? {selection: input.selection} : {})}; +} diff --git a/src/appearance/uiTheme.ts b/src/appearance/uiTheme.ts new file mode 100644 index 00000000..da5fa9e1 --- /dev/null +++ b/src/appearance/uiTheme.ts @@ -0,0 +1,69 @@ +import {UI_COLORS, type RgbColor} from '../ui/palette.js'; +import {mixRgb} from '../chroma/chroma.js'; +import {parseHexColor, readableForeground} from '../chroma/color.js'; +import {accentedVariant, themeVariant, type CatppuccinAccent} from './themeFamilies.js'; +import type {CustomTheme} from './customTheme.js'; +import type {NativePaletteId} from '../prompt/configuration.js'; + +/** + * NMSh chrome colors (accent, rules, selection, status, text tiers) for the + * active theme. NMSh's own themes keep the shipped lavender chrome, so + * existing users see no change. Bundled families and custom themes recolor + * NMSh-owned UI only; the terminal window, editor and host stay untouched. + */ +type UiKey = keyof typeof UI_COLORS; +const DEFAULTS: Record = Object.fromEntries(Object.entries(UI_COLORS).map(([key, value]) => [key, {...value}])) as Record; +const WHITE: RgbColor = {red: 255, green: 255, blue: 255}; +const BLACK: RgbColor = {red: 0, green: 0, blue: 0}; + +export interface UiThemeInput { + accent: string; separator: string; success: string; failure: string; + primary?: string; secondary?: string; subtle?: string; selection?: string; +} + +/** Chrome overrides for a theme input; text tiers only when the theme provides them. */ +export function uiColorsFor(input: UiThemeInput | undefined): Partial> | undefined { + if (!input) return undefined; + const hex = (value: string | undefined) => value ? parseHexColor(value) : undefined; + const accent = hex(input.accent)!; + const separator = hex(input.separator)!; + const project = mixRgb(accent, BLACK, 0.45); + const out: Partial> = { + accent, separator, success: hex(input.success)!, failure: hex(input.failure)!, + workingBase: separator, workingPeak: mixRgb(accent, WHITE, 0.35), + projectBackground: project, projectForeground: readableForeground(project), + }; + const primary = hex(input.primary); + if (primary) { out.primary = primary; out.command = primary; } + const secondary = hex(input.secondary); + if (secondary) out.secondary = secondary; + const subtle = hex(input.subtle); + if (subtle) out.subtle = subtle; + const selection = hex(input.selection); + if (selection) out.selection = selection; + return out; +} + +/** The chrome input for a configuration's theme; undefined keeps NMSh's own chrome. */ +export function uiThemeInput(palette: NativePaletteId, accent: CatppuccinAccent, custom: CustomTheme | undefined): UiThemeInput | undefined { + if (palette === 'custom') { + if (!custom) return undefined; + const ui = custom.ui; + return {accent: ui.accent, separator: ui.separator, success: ui.success, failure: ui.failure, + ...(custom.dark ? {primary: ui.primary, secondary: ui.secondary, subtle: ui.subtle, selection: ui.selection} : {})}; + } + const variant = themeVariant(palette); + return variant ? accentedVariant(variant, accent).ui : undefined; +} + +/** Recolors UI_COLORS in place; undefined restores the shipped NMSh chrome. */ +export function applyUiTheme(colors: Partial> | undefined): void { + for (const key of Object.keys(DEFAULTS) as UiKey[]) { + Object.assign(UI_COLORS[key] as RgbColor, colors?.[key] ?? DEFAULTS[key]); + } +} + +/** The shipped NMSh chrome, for clones and tests. */ +export function defaultUiColors(): Record { + return structuredClone(DEFAULTS); +} diff --git a/src/ask/AskPanel.ts b/src/ask/AskPanel.ts new file mode 100644 index 00000000..d515fde2 --- /dev/null +++ b/src/ask/AskPanel.ts @@ -0,0 +1,487 @@ +import type {Key} from '../terminal/keys.js'; +import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; +import {GLYPHS} from '../ui/glyphs.js'; +import {renderControls} from '../ui/controls.js'; +import {displayWidth, repeatToWidth, truncateAnsi} from '../util/text.js'; +import {chatColumn} from '../output/TranscriptPresenter.js'; +import {filterOptions, pickOption} from './resolver.js'; +import type {AskAction, AskOption, AskOutcome, AskReferents, CommandBlock} from './types.js'; +import {renderCommand} from './gitAssist.js'; +import type {ShellId} from '../shell/adapters/ShellAdapter.js'; +import {CommandEditor} from '../input/CommandEditor.js'; +import {applyEditingKey} from '../input/editingKeys.js'; +import {graphemes} from '../input/inputLayout.js'; + +/** + * The Ask surface. Each turn advances structured state (pending outcome, + * options, confirmation, rejected interpretations); the conversation is + * bounded and in memory only. `/ask` and `/ask ` open this same state; + * with text, the request is submitted at once. + */ +export interface AskTurn {role: 'you' | 'ask'; text: string; block?: CommandBlock} + +export interface AskState { + /** The request being typed: a real editor (caret, selection, word movement), shared with the shell composer. */ + /** The invoked spelling is retained for a recorded/parked conversation. */ + command?: '/btw' | '/ask'; + editor: CommandEditor; + /** The editor's text; assigning replaces it with the caret at the end. */ + input: string; + turns: AskTurn[]; + pending?: AskOutcome; + selected: number; + /** Confirmation choice for a proposal; installs start on No. */ + confirm: 'yes' | 'no'; + rejected: Set; + /** The request clarifications refine, so a follow-up never starts from zero. */ + original?: string; + /** Waiting for the resolver. */ + busy: boolean; + /** Something was actually asked; an empty, abandoned Ask leaves no transcript. */ + submitted: boolean; + /** Conversation rows scrolled up from the newest (0 follows the conversation). */ + scroll: number; + /** What the conversation is about (files, command, branch): bounded, in memory only, never recorded. */ + referents?: AskReferents; + /** The repository the conversation's facts came from, so results refresh the same one. */ + repoRoot?: string; + /** The outcome a transient completion list replaced, restored once a completion is chosen. */ + previous?: AskOutcome; + /** An action is running inside Ask ("Running git add…"); input waits until its result turn arrives. */ + working?: string; +} + +export type AskEvent = + | {kind: 'resolve'; text: string} + | {kind: 'execute'; action: AskAction; outcome: AskOutcome} + /** Copy or insert a shown command block: never executes. */ + | {kind: 'copy'; block: CommandBlock} + | {kind: 'insert'; block: CommandBlock} + /** Tab in a request: complete the path or word at the caret from real files (never runs anything). */ + | {kind: 'complete'; text: string; caret: number} + | {kind: 'close'}; + +const MAX_TURNS = 24; +export const ASK_GREETING = 'What can I help you with?'; +export const ASK_STARTERS = ['open package.json', 'show my sessions', 'switch to fish', 'check git diff', 'find errors in the transcript', 'resume yesterday\'s session']; + +export function createAskState(): AskState { + const editor = new CommandEditor(); + const state = {editor, turns: [], selected: 0, confirm: 'yes', rejected: new Set(), busy: false, submitted: false, scroll: 0} as unknown as AskState; + Object.defineProperty(state, 'input', {enumerable: true, get: () => editor.text, set: (value: string) => editor.replaceText(value)}); + return state; +} + +/** Actions that change what this window shows or launch something outside NMSh are confirmed first. */ +export function needsConfirmation(outcome: AskOutcome): boolean { + if (outcome.kind !== 'proposal') return false; + if (outcome.direct && (outcome.safety === 'navigate' || outcome.safety === 'read')) return false; + if (outcome.action.kind === 'pickFile' || outcome.action.kind === 'taskOutput' || outcome.action.kind === 'watchControl') return false; + if (outcome.safety === 'read' || outcome.safety === 'mutate' || outcome.safety === 'install') return true; + return outcome.action.kind !== 'slash'; +} + +const NONE: AskOption = {key: 'none', label: 'None of these'}; + +/** The options on screen for the pending outcome, with None of these where Ask is guessing. */ +export function visibleOptions(state: AskState): AskOption[] { + const pending = state.pending; + if (!pending) return []; + const narrowed = (options: AskOption[]) => { + if (!state.input.trim() || /^\d+$/u.test(state.input.trim())) return options; + const keep = filterOptions(state.input, options); + return keep.length ? keep.map(index => options[index]!) : options; + }; + if (pending.kind === 'choose' && pending.question === COMPLETE_QUESTION) return pending.options; + if (pending.kind === 'choose') return pending.options.length ? [...narrowed(pending.options), ...(pending.reason === 'ambiguous' ? [NONE] : [])] : []; + if (pending.kind === 'unclear') return [...narrowed(pending.categories)]; + if ((pending.kind === 'unsafe' || pending.kind === 'unsupported' || pending.kind === 'answer')) { + const alternative = pending.kind === 'answer' ? pending.follow : pending.alternative; + const block = pending.kind === 'answer' ? pending.block : undefined; + const next = pending.kind === 'answer' ? pending.next ?? [] : []; + return narrowed([...(block ? blockOptions(block) : []), ...(alternative ? [alternative] : []), ...next]); + } + return []; +} + +/** + * The actions a shown command offers. Copy and Insert never execute. Run is + * offered only when NMSh's policy attached a typed action (never for + * destructive commands, placeholders or a command the person typed + * themselves), and choosing it only leads to the final Yes/No. + */ +export function blockOptions(block: CommandBlock): AskOption[] { + const options: AskOption[] = [{key: 'block:copy', label: 'Copy command'}, {key: 'block:insert', label: 'Insert into the composer, unsent'}]; + if (block.run && !block.literal && !block.placeholders?.length && block.risk !== 'destructive') { + options.push({key: 'block:run', label: block.risk === 'read' ? 'Run it' : 'Run it (you confirm the exact command next)'}); + } + return options; +} + +/** A command block's Run as a proposal: what the final Yes/No confirms. */ +export function runProposal(block: CommandBlock, shell: ShellId = 'zsh'): AskOutcome | undefined { + if (!block.run || block.literal || block.placeholders?.length || block.risk === 'destructive') return undefined; + // A file edit's diff and command are already on screen; the final question names the file. + if (block.run.kind === 'applyEdit') { + return {kind: 'proposal', capability: 'file.open', safety: 'mutate', confidence: 0.95, text: `Apply this edit to ${block.run.plan.path}?`, action: block.run}; + } + if (block.run.kind === 'format') { + return {kind: 'proposal', capability: 'file.open', safety: 'mutate', confidence: 0.95, text: 'Run this formatter? It rewrites the file.', command: renderCommand(block, shell), action: block.run}; + } + if (block.run.kind === 'project' || block.run.kind === 'startTask') { + return {kind: 'proposal', capability: 'project.run', safety: 'mutate', confidence: 0.95, text: block.run.kind === 'startTask' ? 'Start this in the background?' : 'Run this in the shell?', + command: renderCommand(block, shell), action: block.run}; + } + if (block.run.kind === 'recipe') { + return {kind: 'proposal', capability: 'help.command', safety: 'read', confidence: 0.95, text: block.run.risk === 'network' ? 'Run this? It contacts the network.' : 'Run this read-only command?', + command: renderCommand(block, shell), action: block.run}; + } + return {kind: 'proposal', capability: 'git.status', safety: block.risk === 'read' ? 'read' : 'mutate', confidence: 0.95, + text: block.risk === 'read' ? 'Run this read-only command?' : 'Run this command? It changes your repository.', command: renderCommand(block, shell), action: block.run}; +} + +/** Record a resolver outcome as the next Ask turn. */ +export function receiveOutcome(state: AskState, outcome: AskOutcome): AskEvent | undefined { + state.busy = false; + state.pending = outcome; + const referents = outcome.kind === 'answer' || outcome.kind === 'unsafe' || outcome.kind === 'proposal' || outcome.kind === 'choose' ? outcome.referents : undefined; + if (referents) state.referents = {...state.referents, ...referents}; + state.selected = 0; + state.input = ''; + const text = outcome.kind === 'choose' ? outcome.question : outcome.text; + pushTurn(state, 'ask', outcome.kind === 'proposal' && outcome.command ? `${text}\n ${outcome.command}` : text, outcome.kind === 'answer' ? outcome.block : undefined); + if (outcome.kind === 'answer' && outcome.block) state.referents = {...state.referents, block: outcome.block}; + if (outcome.kind === 'proposal') { + // Anything that changes state starts on No; reads and NMSh navigation start on Yes. + state.confirm = outcome.safety === 'install' || outcome.safety === 'mutate' ? 'no' : 'yes'; + // Plain navigation inside NMSh is obviously harmless and needs no extra Yes. + if (!needsConfirmation(outcome)) return {kind: 'execute', action: outcome.action, outcome}; + } + return undefined; +} + +export function pushTurn(state: AskState, role: AskTurn['role'], text: string, block?: CommandBlock): void { + state.turns.push({role, text, ...(block ? {block} : {})}); + if (state.turns.length > MAX_TURNS) state.turns.splice(0, state.turns.length - MAX_TURNS); +} + +/** Submit text: a new request, a reply that picks an option, or a clarification of the original request. */ +export function submitText(state: AskState, text: string): AskEvent | undefined { + const reply = text.trim(); + if (!reply) return undefined; + pushTurn(state, 'you', reply); + // Sending returns to the newest exchange. + state.scroll = 0; + state.submitted = true; + state.input = ''; + const pending = state.pending; + if (pending?.kind === 'proposal' && needsConfirmation(pending)) { + if (/^(?:y|yes|ok|sure|do it|go)$/iu.test(reply)) return confirmProposal(state, 'yes'); + if (/^(?:n|no|cancel|don't|dont|stop)$/iu.test(reply)) return confirmProposal(state, 'no'); + } + // "copy it", "insert that", "run it": the command block this conversation is about. + const block = state.referents?.block; + if (block && /^(?:please )?(?:copy|insert|paste|run|execute|do) (?:it|that|this|the command|that command)(?: please)?$/iu.test(reply)) { + const verb = reply.toLowerCase().replace(/^please /u, '').split(' ')[0]!; + if (verb === 'copy') return {kind: 'copy', block}; + if (verb === 'insert' || verb === 'paste') return {kind: 'insert', block}; + const proposal = runProposal(block); + if (proposal) return receiveOutcome(state, proposal); + pushTurn(state, 'ask', block.risk === 'destructive' ? 'Ask won\'t run that: it is destructive. Copy or insert it to run it yourself.' : 'That command can\'t be run from Ask; copy or insert it instead.'); + return undefined; + } + const options = visibleOptions(state); + if (options.length) { + const index = pickOption(reply, options); + if (index !== undefined) return chooseOption(state, options[index]!, options); + } + // "what does that command do": the shown command's own path. + if (block && /\b(?:that|this|the) command\b/iu.test(reply) && /\b(?:what|explain|how)\b/iu.test(reply) && !block.literal) { + state.busy = true; + return {kind: 'resolve', text: `what does ${block.argv.slice(0, block.argv[0] === 'git' ? 2 : 1).join(' ')} do`}; + } + // A follow-up keeps the original request: "open the old config" + "the bash one". + const clarifying = pending && (pending.kind === 'choose' || pending.kind === 'unclear'); + if (!state.original || !clarifying) state.original = reply; + state.busy = true; + return {kind: 'resolve', text: clarifying ? `${state.original} ${reply}` : reply}; +} + +function chooseOption(state: AskState, option: AskOption, shown: AskOption[]): AskEvent | undefined { + const block = state.pending?.kind === 'answer' ? state.pending.block : undefined; + if (block && option.key === 'block:copy') return {kind: 'copy', block}; + if (block && option.key === 'block:insert') return {kind: 'insert', block}; + if (block && option.key === 'block:run') { + // Choosing Run selects the action; anything that changes state still needs the final Yes/No. + const proposal = runProposal(block); + return proposal ? receiveOutcome(state, proposal) : undefined; + } + if (option.key === 'none') { + // Remember the rejection for this interaction only; ask for more detail. + for (const item of shown) if (item.key !== 'none') state.rejected.add(item.key); + state.pending = undefined; + pushTurn(state, 'ask', 'None of those, then. Tell me a little more about what you want.'); + return undefined; + } + if (option.fill !== undefined) { state.input = option.fill; state.pending = state.previous; state.previous = undefined; return undefined; } + if (option.outcome) return receiveOutcome(state, option.outcome); + if (option.refine !== undefined) { + if (option.refine.endsWith(' ')) { state.input = option.refine; state.pending = undefined; return undefined; } + state.original = option.refine; + state.busy = true; + return {kind: 'resolve', text: option.refine}; + } + return undefined; +} + +function confirmProposal(state: AskState, choice: 'yes' | 'no'): AskEvent | undefined { + const pending = state.pending; + if (pending?.kind !== 'proposal') return undefined; + if (choice === 'yes') return {kind: 'execute', action: pending.action, outcome: pending}; + pushTurn(state, 'ask', 'Okay, nothing was done.'); + state.pending = undefined; + return undefined; +} + +export function askKey(state: AskState, key: Key, columnsHint = 80): AskEvent | undefined { + if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'close'}; + if (state.busy || state.working) return undefined; + const pending = state.pending; + const options = visibleOptions(state); + const confirming = pending?.kind === 'proposal' && needsConfirmation(pending); + if (key.kind === 'text' && confirming && !state.input && /^[yn]$/iu.test(key.value)) return confirmProposal(state, key.value.toLowerCase() === 'y' ? 'yes' : 'no'); + // The editor wins whenever there is text to edit: ←→ move the caret, never the choice. + const empty = !state.input; + if ((key.kind === 'left' || key.kind === 'right') && empty && confirming) { state.confirm = state.confirm === 'yes' ? 'no' : 'yes'; return undefined; } + if ((key.kind === 'up' || key.kind === 'down' || ((key.kind === 'left' || key.kind === 'right') && empty)) && options.length) { + const back = key.kind === 'up' || key.kind === 'left'; + state.selected = (state.selected + (back ? -1 : 1) + options.length) % options.length; + return undefined; + } + const completing = pending?.kind === 'choose' && pending.question === COMPLETE_QUESTION; + if (completing && key.kind === 'enter' && options.length) return chooseOption(state, options[state.selected]!, options); + if (key.kind === 'complete') return state.input.trim() ? {kind: 'complete', text: state.input, caret: state.editor.cursorIndex} : undefined; + if (applyEditingKey(state.editor, key, Math.max(20, columnsHint - 6))) { + // Editing dismisses a completion list; the conversation's own choices come back. + if (completing) { state.pending = state.previous; state.previous = undefined; } + if (key.kind !== 'left' && key.kind !== 'right' && !key.kind.startsWith('select') && !key.kind.startsWith('word') && !key.kind.startsWith('line') && !key.kind.startsWith('buffer')) state.selected = 0; + return undefined; + } + // The conversation scrolls; the title, choices, input and controls stay put. + if (key.kind === 'pageUp' || key.kind === 'wheelUp') { state.scroll += key.kind === 'pageUp' ? ASK_PAGE : 3; return undefined; } + if (key.kind === 'pageDown' || key.kind === 'wheelDown') { state.scroll = Math.max(0, state.scroll - (key.kind === 'pageDown' ? ASK_PAGE : 3)); return undefined; } + if (key.kind === 'enter') { + if (state.input.trim()) return submitText(state, state.input); + if (confirming) return confirmProposal(state, state.confirm); + if (options.length) { + if (options[state.selected]!.fill !== undefined) return chooseOption(state, options[state.selected]!, options); + pushTurn(state, 'you', options[state.selected]!.label); + state.submitted = true; + state.scroll = 0; + return chooseOption(state, options[state.selected]!, options); + } + } + return undefined; +} + +/** The visible conversation as transcript text (the first request is the command line). Never model data. */ +export function askTranscriptText(state: AskState, shell: ShellId = 'zsh'): {request: string; body: string; turns: AskTurn[]} | undefined { + if (!state.submitted) return undefined; + const first = state.turns.findIndex(turn => turn.role === 'you'); + if (first === -1) return undefined; + // Only the visible role and text are kept: no outcomes, options, rejected keys or model data. + const turns = state.turns.slice(first + 1).map(turn => ({role: turn.role, text: turn.block ? `${turn.text}\n ${turn.block.literal ?? turn.block.script ?? renderCommand(turn.block, shell)}` : turn.text})); + const body = turns.map(turn => `${turn.role === 'you' ? 'You' : 'Ask'}: ${turn.text}`).join('\n'); + return {request: state.turns[first]!.text, body, turns}; +} + +/** Word wrap by display width; long words are left for truncation. */ +function wrapText(text: string, width: number): string[] { + const lines: string[] = []; + let line = ''; + for (const word of text.split(/(\s+)/u)) { + if (line && displayWidth(line + word) > width && word.trim()) { lines.push(line.trimEnd()); line = word.trimStart(); } + else line += word; + } + lines.push(line.trimEnd()); + return lines; +} + +const ASK_PAGE = 8; + +/** How Ask lays out its conversation: Chat puts your turns on the right; Normal keeps both on the left. */ +export type AskPresentation = 'chat' | 'normal'; +export const ASK_PRESENTATIONS: readonly AskPresentation[] = ['chat', 'normal']; +export const ASK_PRESENTATION_LABELS: Record = {chat: 'Chat', normal: 'Normal'}; + +export interface AskRenderOptions { + presentation?: AskPresentation; + /** The active shell, for quoting shown commands. */ + shell?: ShellId; + /** Rows the panel may use; the conversation gets what the pinned rows leave. */ + height?: number; + /** The live activity line while Ask works (already styled; transient, never recorded). */ + activity?: string; +} + +/** The conversation as exchanges: each of your turns opens one, and Ask's replies belong to it. */ +export function askExchanges(turns: readonly AskTurn[]): AskTurn[][] { + const exchanges: AskTurn[][] = []; + for (const turn of turns) { + if (turn.role === 'you' || !exchanges.length) exchanges.push([]); + exchanges[exchanges.length - 1]!.push(turn); + } + return exchanges; +} + +/** Conversation rows (no pinned chrome): role-labelled turns, your turns on the right in Chat, a faint rule between exchanges. */ +export function askConversationRows(turns: readonly AskTurn[], columns: number, presentation: AskPresentation = 'chat', shell: ShellId = 'zsh'): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const rule = foreground(UI_COLORS.separator); + const reset = '\u001b[0m'; + const inner = Math.max(20, columns - 4); + const column = presentation === 'chat' ? chatColumn(inner) : undefined; + const rows: string[] = []; + askExchanges(turns).forEach((exchange, index) => { + if (index > 0) rows.push(` ${rule}${repeatToWidth('─', inner)}${reset}`); + for (const turn of exchange) { + const you = turn.role === 'you'; + if (you && column) { + // Right-aligned block: its widest wrapped line sets the left edge, the label sits on that edge's right. + const lines = turn.text.split('\n').flatMap(line => wrapText(line, column)); + const widest = Math.max(3, ...lines.map(line => displayWidth(line))); + const left = 2 + inner - widest; + rows.push(`${' '.repeat(left + widest - 3)}${subtle}You${reset}`); + for (const line of lines) rows.push(`${' '.repeat(left + widest - displayWidth(line))}${primary}${line}${reset}`); + } else { + rows.push(` ${you ? subtle : accent}${you ? 'You' : 'Ask'}${reset}`); + for (const line of turn.text.split('\n').flatMap(part => wrapText(part, inner))) rows.push(` ${you ? primary : secondary}${line}${reset}`); + if (turn.block) rows.push(...commandBlockRows(turn.block, inner, shell)); + } + } + }); + return rows; +} + +/** A shown command: on its own indented line (not buried in prose), its note, and the facts that filled it. */ +export function commandBlockRows(block: CommandBlock, width: number, shell: ShellId = 'zsh'): string[] { + const primary = foreground(UI_COLORS.primary); + const subtle = foreground(UI_COLORS.subtle); + const reset = '\u001b[0m'; + const command = block.literal ?? block.script ?? renderCommand(block, shell); + const rows = ['', ...wrapText(command, width - 4).map(line => ` ${primary}${line}${reset}`)]; + if (block.note) rows.push(...wrapText(block.note, width - 4).map(line => ` ${subtle}${line}${reset}`)); + if (block.facts?.length) rows.push(` ${subtle}${block.facts.map(([key, value]) => `${key} ${value}`).join(' · ')}${reset}`); + if (block.risk === 'destructive') rows.push(` ${subtle}Destructive: Ask won't run it; copy or insert it to run it yourself.${reset}`); + return [...rows, '']; +} + +export function renderAsk(state: AskState, columns: number, options: AskRenderOptions = {}): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const reset = '\u001b[0m'; + const top = [`${primary} Ask NMSh${reset}`, '']; + const conversation = state.turns.length ? askConversationRows(state.turns, columns, options.presentation ?? 'chat', options.shell) + : [` ${secondary}${ASK_GREETING}${reset}`, ...(state.pending ? [] : ['', ` ${subtle}For example: ${ASK_STARTERS.slice(0, 4).join(' · ')}${reset}`])]; + // Choices and confirmations belong to the newest Ask reply: they follow it directly. + const bottom: string[] = []; + const choices = visibleOptions(state); + const pending = state.pending; + const confirming = pending?.kind === 'proposal' && needsConfirmation(pending); + if (choices.length) { + bottom.push(''); + // Long lists (a folder, search results) scroll around the selection; typing filters. + const limit = Math.max(4, Math.min(14, options.height === undefined ? 14 : Math.floor((options.height - 10) / 2))); + const start = choices.length <= limit ? 0 : Math.max(0, Math.min(choices.length - limit, state.selected - Math.floor(limit / 2))); + const end = Math.min(choices.length, start + limit); + if (start > 0) bottom.push(` ${subtle}↑ ${start} more${reset}`); + choices.forEach((option, index) => { + if (index < start || index >= end) return; + const selected = index === state.selected; + const number = option.key === 'none' ? ' ' : String(index + 1); + bottom.push(`${selected ? `${accent}${GLYPHS.selection}` : ' '} ${subtle}${number.padStart(String(choices.length).length)}${reset} ${focusForeground(selected)}${option.label}${reset}${option.detail ? ` ${subtle}${option.detail}${reset}` : ''}`); + }); + if (end < choices.length) bottom.push(` ${subtle}↓ ${choices.length - end} more · type to filter${reset}`); + } else if (confirming) { + const yes = pending.safety === 'read' ? 'Run' : pending.safety === 'install' ? 'Install' : 'Yes'; + bottom.push('', ` ${state.confirm === 'yes' ? `${accent}[ Y ${yes} ]${reset}` : `${subtle} Y ${yes} ${reset}`} ${state.confirm === 'no' ? `${accent}[ N Don't ]${reset}` : `${subtle} N Don't ${reset}`}`); + } + bottom.push('', ...(state.working ? [` ${subtle}${state.working}${reset}`] : askInputRows(state, columns - 4, options.activity))); + // The footer lists only what works right now. + const controls: Array<[string, string]> = [['Enter', 'send']]; + if (choices.length || confirming) controls.push(['←→/↑↓', 'choose']); + const room = options.height === undefined ? Infinity : Math.max(3, options.height - top.length - bottom.length - 2); + const overflow = conversation.length > room; + if (overflow) controls.push(['PgUp/PgDn', 'scroll']); + controls.push(['Esc', 'close']); + let visible = conversation; + if (overflow) { + // Offset from the newest row: redraws keep the reader's place; a resize clamps it. + state.scroll = Math.max(0, Math.min(state.scroll, conversation.length - (room - 2))); + const span = room - 1 - (state.scroll > 0 ? 1 : 0); + const end = conversation.length - state.scroll; + const start = Math.max(0, end - span); + visible = [start ? ` ${subtle}↑ ${start} earlier row${start === 1 ? '' : 's'} · PgUp${reset}` : '', ...conversation.slice(start, end), + ...(state.scroll > 0 ? [` ${subtle}↓ ${state.scroll} newer row${state.scroll === 1 ? '' : 's'} · PgDn${reset}`] : [])]; + } else state.scroll = 0; + return [...top, ...visible, ...bottom, '', renderControls(controls)].map(row => truncateAnsi(row, columns)); +} + +/** + * The Ask input as rows: the text with its real caret and selection (inverse), + * multiline text on its own rows. While Ask resolves, a live activity line + * replaces the caret (supplied by the app from the shared activity clock). + */ +export function askInputRows(state: AskState, width: number, activity?: string): string[] { + const primary = foreground(UI_COLORS.primary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const reset = '\u001b[0m'; + if (state.busy) return [` ${accent}›${reset} ${primary}${state.input.split('\n')[0]}${reset}`, ...(activity ? [` ${activity}`] : [])]; + const chars = graphemes(state.editor.text); + const caret = state.editor.cursorIndex; + const selection = state.editor.selection; + const rows: string[] = []; + let row = ''; + let rowWidth = 0; + const flush = () => { rows.push(row); row = ''; rowWidth = 0; }; + for (let index = 0; index <= chars.length; index += 1) { + if (index === caret && !selection) row += `${reset}\u001b[7m${index < chars.length && chars[index] !== '\n' ? chars[index] : ' '}\u001b[27m${primary}`; + if (index === chars.length) break; + const char = chars[index]!; + if (char === '\n') { if (index === caret && !selection) { /* caret drawn as a space above */ } flush(); continue; } + if (index === caret && !selection) { rowWidth += displayWidth(char); continue; } + const selected = selection && index >= selection.start && index < selection.end; + row += selected ? `\u001b[7m${char}\u001b[27m` : char; + rowWidth += displayWidth(char); + if (rowWidth >= width) flush(); + } + rows.push(row); + return rows.map((line, index) => ` ${index === 0 ? `${accent}›${reset}` : `${subtle}·${reset}`} ${primary}${line}${reset}`); +} + +/** + * Apply a path completion to the Ask input: the unique or common part is + * typed in; when several remain they are offered as a transient list (↑↓, + * Enter fills, typing narrows). Choosing never sends or opens anything. + */ +export function applyAskCompletion(state: AskState, completion: {text: string; caret: number; candidates: Array<{value: string; directory: boolean}>}): void { + state.editor.replaceText(completion.text); + state.editor.setCursor(completion.caret); + if (!completion.candidates.length) return; + const before = [...completion.text].slice(0, completion.caret).join(''); + const word = /(\S*)$/u.exec(before)?.[1] ?? ''; + const head = before.slice(0, before.length - word.length); + const tail = [...completion.text].slice(completion.caret).join(''); + if (state.pending?.kind !== 'choose' || state.pending.question !== COMPLETE_QUESTION) state.previous = state.pending; + state.pending = {kind: 'choose', reason: 'missing', question: COMPLETE_QUESTION, + options: completion.candidates.map(candidate => ({key: `path:${candidate.value}`, label: candidate.value, detail: candidate.directory ? 'folder' : undefined, + fill: `${head}${candidate.value}${candidate.directory ? '' : ' '}${tail.replace(/^ /u, '')}`}))} as AskOutcome; + state.selected = 0; +} +const COMPLETE_QUESTION = 'Complete the path'; diff --git a/src/ask/activity.ts b/src/ask/activity.ts new file mode 100644 index 00000000..1f7819b1 --- /dev/null +++ b/src/ask/activity.ts @@ -0,0 +1,75 @@ +import type {CommandEnvironment} from './commands.js'; +import type {AskContext, AskOption, AskOutcome, RecentCommand} from './types.js'; + +/** + * "What did I just do?" from facts NMSh recorded about recent commands (text, + * folder, branch, exit status, duration, line count), never their output. + * A command is explained only from local command knowledge; otherwise Ask + * says it has none rather than guessing from the name. + */ + +const LAST = /\bwhat (?:did|have) i (?:just )?(?:do|done|run|ran)\b(?! (?:today|recently|lately))|\b(?:last|previous|latest) command\b|\bwhat was that(?: command)?\b|\bwhat i just ran\b|\bmy last command\b/u; +const RECENT = /\bwhat (?:have i been doing|did i do (?:today|recently|lately))\b|\brecent (?:work|commands|activity|history)\b|\bwhat have i (?:run|ran) (?:recently|lately|today)\b/u; + +const home = (path: string | undefined, context: AskContext) => path && path.startsWith(`${context.home}/`) ? `~${path.slice(context.home.length)}` : path ?? context.cwd; +function duration(ms?: number): string { + if (ms === undefined) return ''; + const seconds = Math.round(ms / 1000); + return seconds < 1 ? '' : seconds < 60 ? ` after ${seconds}s` : ` after ${Math.floor(seconds / 60)}m ${seconds % 60}s`; +} +const status = (item: RecentCommand) => item.exitCode === 0 ? 'It exited successfully' : item.exitCode >= 128 ? `It was interrupted or killed (exit ${item.exitCode})` : `It failed with exit status ${item.exitCode}`; + +/** The command's words up to the first option or argument-looking word. */ +const pathWords = (command: string) => command.trim().split(/\s+/u).filter(word => /^[\w.+-]+$/u.test(word)).slice(0, 3); + +/** "what's broken", "where did npm fail", "what command produced this error": the newest failing command from recorded facts. */ +const FAILED = /\bwhat(?:'s| is) (?:broken|wrong|failing)\b|\bwhat (?:failed|broke)\b|\bwhy did (?:it|that|this) fail\b|\bwhere (?:did )?([\w.+-]+) fail(?:ed|s)?\b|\b([\w.+-]+) failed\b|\bwhat (?:command )?(?:produced|caused|gave|made|printed) (?:this|that|the) (?:error|failure)\b|\bwhich command failed\b/u; + +function failedOutcome(text: string, context: AskContext): AskOutcome | undefined { + const match = FAILED.exec(text); + if (!match) return undefined; + const recent = context.recent ?? []; + const program = (match[1] ?? match[2])?.toLowerCase(); + const named = program && !/^(?:it|that|this|what|which|command|something|anything)$/u.test(program) ? program : undefined; + const failed = recent.find(item => item.exitCode !== 0 && (!named || item.command.trim().split(/\s+/u)[0]?.toLowerCase() === named || item.command.toLowerCase().includes(`${named} `))); + const conflicts = context.git?.conflicted.length ?? 0; + if (!failed) { + const extra = conflicts ? ` Git reports ${conflicts} conflicted file${conflicts === 1 ? '' : 's'}.` : ''; + return {kind: 'answer', capability: 'help.command', text: named ? `No recent ${named} command failed in this session.${extra}` : `Nothing failed recently: the last ${recent.length || 'few'} command${recent.length === 1 ? '' : 's'} exited successfully.${extra}`}; + } + const where = `in ${home(failed.cwd, context)}${failed.branch ? ` on ${failed.branch}` : ''}`; + return {kind: 'answer', capability: 'help.command', + text: `${failed.command}\n${where} · ${status(failed).replace(/^It /u, '')}${duration(failed.durationMs)}${failed.lines ? ` · ${failed.lines} output line${failed.lines === 1 ? '' : 's'}` : ''}.\nIts output is in the transcript; Ask doesn't read command output.${conflicts ? `\nGit also reports ${conflicts} conflicted file${conflicts === 1 ? '' : 's'}.` : ''}`, + block: {argv: [failed.command], literal: failed.command, provenance: 'context', risk: 'informational'}, + next: [{key: 'failed:find', label: 'Find "error" in the transcript', refine: 'find error in the transcript'}, {key: 'failed:explain', label: 'Explain the command', refine: `what does ${pathWords(failed.command).slice(0, 2).join(' ')} do`}], + referents: {command: pathWords(failed.command)}}; +} + +export function resolveActivity(text: string, context: AskContext, commands?: CommandEnvironment): AskOutcome | undefined { + const failure = failedOutcome(text, context); + if (failure) return failure; + const recent = context.recent ?? []; + if (RECENT.test(text)) { + if (!recent.length) return {kind: 'answer', capability: 'help.command', text: 'No commands have finished in this session yet.'}; + const rows = recent.slice(0, 6).map(item => ` ${item.exitCode === 0 ? '✓' : '✗'} ${item.command.slice(0, 60)} ${home(item.cwd, context)}${item.branch ? ` · ${item.branch}` : ''}${item.exitCode ? ` · exit ${item.exitCode}` : ''}`); + return {kind: 'answer', capability: 'help.command', text: `Recent work (newest first)\n${rows.join('\n')}`, + next: [{key: 'activity:last', label: 'Explain the last command', refine: 'what did i just do'}]}; + } + if (!LAST.test(text)) return undefined; + const last = recent[0]; + if (!last) return {kind: 'answer', capability: 'help.command', text: 'No command has finished in this session yet.'}; + const where = `in ${home(last.cwd, context)}${last.branch ? ` on branch ${last.branch}` : ''}`; + const lines = [`You ran:\n ${last.command}\n${where}. ${status(last)}${duration(last.durationMs)}${last.lines ? ` (${last.lines} output line${last.lines === 1 ? '' : 's'})` : ''}.`]; + const words = pathWords(last.command); + const found = words.length ? commands?.reference.lookup(words) : undefined; + const next: AskOption[] = []; + if (found?.facts.description) { + const path = found.facts.path.join(' '); + lines.push('', `${path}: ${found.facts.description}`); + next.push({key: `syntax:${path}`, label: 'Explain syntax', refine: `how do i use ${path}`}); + if (found.facts.options.length) next.push({key: `options:${path}`, label: 'Show useful options', refine: `what flags does ${path} have`}); + } else lines.push('', 'NMSh does not have enough local command knowledge to explain what it does.'); + return {kind: 'answer', capability: 'help.command', text: lines.join('\n'), next, + block: {argv: [last.command], literal: last.command, provenance: 'context', risk: 'informational'}, + referents: {command: found?.facts.path ?? words}}; +} diff --git a/src/ask/commands.ts b/src/ask/commands.ts new file mode 100644 index 00000000..4776feaf --- /dev/null +++ b/src/ask/commands.ts @@ -0,0 +1,181 @@ +import {optionLabel, syntaxOf, type CommandFacts, type CommandReference} from '../shell/CommandReference.js'; +import {matchConcepts} from './concepts.js'; +import type {AskContext, AskOption, AskOutcome, CommandBlock} from './types.js'; + +/** + * Command questions answered from local command knowledge (the completion + * catalog, custom specs and NMSh's own facts) and the factual installed + * state. Explaining a command is knowledge, not action: these are answers + * only, so even `git clean` or `git push` can be explained. Nothing is + * guessed from a name; a command NMSh has no facts for is said to be unknown. + */ + +export type CommandQuestion = + | {intent: 'explain' | 'syntax' | 'options' | 'examples'; words: string[]} + | {intent: 'option'; words: string[]; option: string}; + +/** Tools NMSh integrates as providers: asking about them means the tool itself, with NMSh's note added. */ +const PROVIDER_TOOLS = new Set(['zoxide', 'fzf', 'tv', 'television', 'atuin', 'starship', 'fastfetch', 'neofetch', 'deja', 'llama-server', 'ollama']); + +const LEAD = /^(?:please |so |ok |hey )?(?:what(?: is| are|'s| does| do)|whats|explain|tell me about|describe|how (?:do|can|would|should) i|how to|how does|show me|give me|syntax (?:of|for)|usage (?:of|for)|options (?:of|for)|flags (?:of|for))\b\s*/u; +const TRAIL = /\s+(?:do|does|mean|means|command|commands|have|has|syntax|usage|flags|options|work|works|for|again|examples?)$/u; +/** Names people say for a tool whose executable is different. */ +export const COMMAND_ALIASES: Readonly> = {ripgrep: 'rg', 'silver-searcher': 'ag', 'fd-find': 'fd', fdfind: 'fd', tealdeer: 'tldr', + 'github-cli': 'gh', neovim: 'nvim', 'node.js': 'node', nodejs: 'node', golang: 'go', 'docker-compose': 'docker-compose', homebrew: 'brew', python: 'python3'}; +const FILLER = new Set(['the', 'a', 'an', 'use', 'using', 'command', 'cli', 'tool', 'program', 'this', 'my']); + +/** Parse a normalized request into a command question, or undefined. Pure: whether the command is known is checked by the caller. */ +export function parseCommandQuestion(text: string): CommandQuestion | undefined { + const lead = LEAD.exec(text); + const syntaxWords = /\b(?:syntax|usage)\b/u.test(text); + const optionWords = /\b(?:flags|options|switches)\b/u.test(text); + const exampleWords = /\bexamples?\b/u.test(text); + if (!lead && !syntaxWords && !optionWords && !exampleWords) return undefined; + let rest = lead ? text.slice(lead[0].length) : text.replace(/^(?:what|which) (?=(?:flags|options|switches)\b)/u, ''); + rest = rest.replace(/^(?:flags|options|switches) (?:does|do|can|for|of) /u, '').replace(/^(?:show|give|list)(?: me)? /u, '').replace(/^(?:some )?examples? (?:of|for|using) /u, ''); + rest = rest.replace(/^(?:the )?(?:syntax|usage|flags|options) (?:of|for) /u, ''); + for (let previous = ''; previous !== rest;) { previous = rest; rest = rest.replace(TRAIL, ''); } + const optionMatch = /(?:^|\s)(--?[a-z0-9][\w-]*(?:=\S*)?)(?=\s|$)/u.exec(rest); + const words = rest.replace(/(?:^|\s)--?[a-z0-9][\w-]*(?:=\S*)?(?=\s|$)/gu, ' ').split(/\s+/u).filter(word => word && !FILLER.has(word) && /^[\w.+-]+$/u.test(word)) + .map((word, index) => index === 0 ? COMMAND_ALIASES[word] ?? word : word); + if (optionMatch) return {intent: 'option', words, option: optionMatch[1]!}; + if (!words.length) return undefined; + const howTo = /^how (?:do|can|would|should) i|^how to/u.test(lead?.[0] ?? ''); + return {intent: exampleWords ? 'examples' : optionWords ? 'options' : syntaxWords || howTo ? 'syntax' : 'explain', words}; +} + +export interface CommandEnvironment { + reference: CommandReference; + /** The command's identity in this shell, when NMSh can tell (PATH lookup and the live shell's names); never runs it. */ + identity(name: string): {kind: 'executable' | 'alias' | 'function' | 'builtin'; path?: string} | undefined; + /** Optional TLDR examples for a command path (tealdeer's local cache); empty when unavailable. */ + examples?(path: readonly string[]): Array<{description: string; command: string}>; + /** NMSh's curated install for this exact executable name (the /tools catalog), never a guessed package. */ + install?(name: string): {tool: string; label: string} | undefined; +} + +/** Roots to search for a bare option ("what does --force-with-lease do"): this conversation's command, recent commands, then a few common tools. */ +function optionSearchPaths(context: AskContext): string[][] { + const paths = [context.referents?.command, ...context.recentCommands.map(command => command.split(/\s+/u).filter(word => /^[\w.+-]+$/u.test(word)).slice(0, 2))] + .filter((path): path is string[] => Boolean(path?.length)); + const seen = new Set(); + return [...paths, ...['git', 'docker', 'npm', 'kubectl', 'cargo', 'rg'].map(root => [root])].filter(path => !seen.has(path.join(' ')) && seen.add(path.join(' '))).slice(0, 10); +} + +/** Bounded: subcommands scanned for a bare option across the search roots (inline subcommands cost no extra catalog reads). */ +const MAX_OPTION_SCAN = 200; + +function installedLine(name: string, env: CommandEnvironment): string { + const identity = env.identity(name); + if (!identity) return `${name} is not installed here (not found in this shell).`; + return identity.kind === 'executable' ? `${name} is installed${identity.path ? ` at ${identity.path}` : ''}.` : `${name} is a ${identity.kind} in this shell.`; +} + +/** Useful options: those with descriptions, own before inherited, at most `limit`. */ +function usefulOptions(facts: CommandFacts, limit = 6): string[] { + const described = facts.options.filter(item => item.description); + const width = Math.min(28, Math.max(...described.slice(0, limit).map(item => optionLabel(item).length), 0)); + const brief = (text: string) => text.length > 96 ? `${text.slice(0, 95).replace(/\s+\S*$/u, '')}…` : text; + return described.slice(0, limit).map(item => ` ${optionLabel(item).padEnd(width)} ${brief(item.description!)}`); +} + +/** + * Answer a command question from facts, or undefined when the words don't + * name a command NMSh knows or this shell has (the caller keeps resolving). + */ +export function answerCommandQuestion(question: CommandQuestion, context: AskContext, env: CommandEnvironment): AskOutcome | undefined { + if (question.intent === 'option') { + const paths = question.words.length ? [question.words] : optionSearchPaths(context); + let scanned = 0; + for (const words of paths) { + const found = env.reference.lookup(words); + if (!found) continue; + let facts = found.facts; + let hit = env.reference.option(facts, question.option); + // A bare option usually belongs to a subcommand (git push --force-with-lease): scan a bounded number of them. + if (!hit && !question.words.length) { + for (const child of facts.subcommands) { + if (scanned++ >= MAX_OPTION_SCAN) break; + const sub = env.reference.lookup([...facts.path, child.names[0]!]); + const subHit = sub && env.reference.option(sub.facts, question.option); + if (subHit && !subHit.persistent) { facts = sub.facts; hit = subHit; break; } + } + } + if (hit) { + return {kind: 'answer', capability: 'help.command', text: `${facts.path.join(' ')} ${optionLabel(hit)}\n${hit.description ?? 'No description is available for this option.'}`, + block: commandBlock([...facts.path, question.option.split('=')[0]!], 'reference')}; + } + // Combined short flags (-rf = -r -f): each letter explained from the same facts. + if (question.words.length && /^-[A-Za-z]{2,6}$/u.test(question.option)) { + const letters = [...question.option.slice(1)].map(letter => [letter, env.reference.option(facts, `-${letter}`)] as const); + if (letters.every(([, option]) => option)) { + return {kind: 'answer', capability: 'help.command', text: `${facts.path.join(' ')} ${question.option} combines ${letters.map(([letter]) => `-${letter}`).join(' and ')}:\n${letters.map(([letter, option]) => ` -${letter} ${option!.description ?? 'no description'}`).join('\n')}`, + block: commandBlock([...facts.path, question.option], 'reference'), referents: {command: facts.path}}; + } + } + if (question.words.length) return {kind: 'answer', capability: 'help.command', text: `${facts.path.join(' ')} has no ${question.option} option in NMSh's local command knowledge.`}; + } + return {kind: 'answer', capability: 'help.command', text: `I don't know which command ${question.option} belongs to. Say the command too, e.g. "what does git push ${question.option} do".`}; + } + const name = question.words[0]!; + const concept = matchConcepts(question.words.join(' ')); + if ((concept.concepts.length || concept.ambiguous.length) && !PROVIDER_TOOLS.has(name)) return undefined; + const found = env.reference.lookup(question.words); + const identity = env.identity(name); + if (!found) { + if (!identity) return undefined; + return {kind: 'answer', capability: 'help.command', text: `${installedLine(name, env)} NMSh has no local documentation for it, so I can't say what it does or its syntax.`}; + } + const {facts, rest} = found; + const path = facts.path.join(' '); + const lines: string[] = []; + const unknownTail = rest.length && facts.subcommands.length ? ` (${rest[0]} is not a ${facts.path.join(' ')} subcommand NMSh knows)` : ''; + lines.push(`${path}: ${facts.description ?? 'no description in NMSh\'s local command knowledge'}${unknownTail}.`.replace(/\.\.$/u, '.')); + if (facts.path.length === 1 || question.intent !== 'explain') lines.push(installedLine(facts.path[0]!, env)); + const syntax = syntaxOf(facts); + if (question.intent !== 'explain' || facts.path.length > 1) { + if (syntax) lines.push('', 'Syntax', ` ${syntax}`); + else lines.push('', 'NMSh does not have enough local syntax knowledge to show its usage.'); + } + if (question.intent === 'options' || question.intent === 'syntax') { + const options = usefulOptions(facts, question.intent === 'options' ? 12 : 6); + if (options.length) lines.push('', question.intent === 'options' ? `Options (${facts.options.length})` : 'Useful options', ...options); + } + if (question.intent === 'explain' && facts.subcommands.length) { + lines.push('', `Subcommands include ${facts.subcommands.slice(0, 8).map(item => item.names[0]).join(', ')}${facts.subcommands.length > 8 ? ', …' : ''}.`); + } + // Practical examples (optional TLDR) when asked for, or when local syntax facts are sparse. + // Plain definitions ('explain') stay short and never carry examples. + if (question.intent === 'examples' || question.intent === 'syntax') { + const examples = env.examples?.(facts.path) ?? []; + const shown = question.intent === 'examples' ? 6 : facts.options.length ? 3 : 5; + if (examples.length) lines.push('', 'Examples from TLDR', ...examples.slice(0, shown).flatMap(example => [` ${example.description}`, ` ${example.command}`])); + else if (!env.examples) lines.push('', 'Local TLDR examples are unavailable because tealdeer is not installed.'); + else if (question.intent === 'examples') lines.push('', `No local TLDR examples for ${path}.`); + } + if (PROVIDER_TOOLS.has(name) && concept.concepts[0]) lines.push('', `In NMSh: ${concept.concepts[0].description}`); + // Next steps from facts: install only with a curated recipe; otherwise the next useful reference. + const next: AskOption[] = []; + const root = facts.path[0]!; + const install = !env.identity(root) ? env.install?.(root) : undefined; + if (!env.identity(root) && facts.path.length === 1) { + if (install) next.push({key: `install:${root}`, label: `Install ${root}`, outcome: installProposal(root, install)}); + else lines.push('NMSh has no curated install recipe for it.'); + } + if (question.intent === 'explain' && syntax) next.push({key: `syntax:${path}`, label: 'Show syntax and useful options', refine: `how do i use ${path}`}); + if ((question.intent === 'examples' || question.intent === 'syntax') && !env.examples && env.install?.('tldr')) next.push({key: 'install:tldr', label: 'Install TLDR (tealdeer)', outcome: installProposal('tldr', env.install('tldr')!)}); + if (question.intent === 'syntax' && facts.options.length > 6) next.push({key: `options:${path}`, label: `All ${facts.options.length} options`, refine: `what flags does ${path} have`}); + return {kind: 'answer', capability: 'help.command', text: lines.join('\n'), ...(syntax && question.intent !== 'explain' ? {block: commandBlock(facts.path, 'reference')} : {}), + ...(next.length ? {next: next.slice(0, 4)} : {}), referents: {command: facts.path}}; +} + +/** Install with the curated recipe: the exact command is shown, the choice starts on No, and Yes applies to this install only. */ +export function installProposal(name: string, install: {tool: string; label: string}): AskOutcome { + return {kind: 'proposal', capability: 'tools.open', safety: 'install', confidence: 0.95, command: install.label, + text: `Install ${name} with NMSh's curated recipe?`, action: {kind: 'installTool', tool: install.tool, label: install.label}}; +} + +/** A command block for the path only (a reference, not filled from context). */ +function commandBlock(argv: string[], provenance: CommandBlock['provenance']): CommandBlock { + return {argv, provenance, risk: 'informational'}; +} diff --git a/src/ask/concepts.ts b/src/ask/concepts.ts new file mode 100644 index 00000000..4d3d0e6d --- /dev/null +++ b/src/ask/concepts.ts @@ -0,0 +1,344 @@ +import {parseSlashCommand} from '../commands/slashCommands.js'; +import type {CapabilityId} from './types.js'; + +/** + * NMSh's own product vocabulary for Ask: one entry per public concept, with + * the words people use for it and where it actually lives. Resolution is + * deterministic (no model): the request's words pick concepts, the verb picks + * an intent, and the concept says what Ask may do (an existing capability, a + * slash surface to open, a typed setting) or only explain. Nothing here + * carries argv; a destination is an NMSh slash command parsed by NMSh's own + * parser, and settings are typed AskActions. + * + * Support levels (also the coverage audit's classification): + * - actionable: Ask can open or change it (with confirmation where needed). + * - settings: it is configured in Settings (or Setup Cat), which Ask opens. + * - no-ui: a real feature with no configuration surface yet; Ask says so. + * - unsupported: understood, but NMSh does not do this. + */ +export type ConceptSupport = 'actionable' | 'settings' | 'no-ui' | 'unsupported'; + +export interface Concept { + id: string; + label: string; + /** Lowercase phrases. A phrase shared by several concepts is ambiguous between exactly those. */ + aliases: string[]; + /** One or two factual sentences shown when someone asks what it is. */ + description: string; + support: ConceptSupport; + /** An existing Ask capability that acts on this concept. */ + capability?: CapabilityId; + /** The NMSh slash command that opens its surface. */ + open?: string; + /** Where it is configured, in words, when that is not a slash command of its own. */ + where?: string; + /** Where "change/configure" goes when that differs from where "open/show" goes (e.g. /dirs vs /providers). */ + configure?: string; + /** Within an ambiguous phrase, this concept is meant when the request has one of these intents ("open the palette"). */ + prefers?: ConceptIntent[]; + /** A typed on/off setting Ask may propose. */ + toggle?: {setting: 'suggestions' | 'composerDividers'; on: string; off: string}; + /** Capability matches this concept beats when both match (it names something more specific). */ + overrides?: CapabilityId[]; + /** Coverage: the public slash commands, Settings categories, provider families and planned areas this concept stands for. */ + covers?: string[]; +} + +const SUGGESTIONS_WHERE = 'Settings → Suggestions (provider and empty-prompt prediction), or /providers'; + +export const CONCEPTS: readonly Concept[] = [ + {id: 'completion', label: 'Tab completion / completion menu', support: 'no-ui', covers: [], + aliases: ['tab completion', 'tab complete', 'completion', 'completions', 'completion menu', 'complete menu', 'autocomplete menu', 'completion settings', + 'autocomplete', 'auto complete', 'auto-complete', 'autocompletion', 'auto completion'], + description: 'Tab completion opens a menu of structured candidates (commands, options, files, Git refs) from your shell\'s own completion, custom specs and NMSh\'s bundled catalog.', + where: 'Tab completion has no settings of its own yet; it follows your shell\'s completion configuration. Ghost suggestions (predictive text) are configurable.'}, + {id: 'suggestions', label: 'Ghost suggestions / predictive text', support: 'actionable', open: '/providers', prefers: ['on', 'off'], where: SUGGESTIONS_WHERE, + covers: ['settings:Suggestions', 'family:suggestions'], toggle: {setting: 'suggestions', on: 'nmsh', off: 'none'}, + aliases: ['suggestion', 'suggestions', 'autosuggestion', 'autosuggestions', 'auto suggestion', 'auto suggestions', 'auto-suggestions', 'ghost text', 'ghost suggestions', + 'ghost suggestion', 'prediction', 'predictions', 'predictive text', 'next command suggestion', 'next-command suggestion', 'deja', + 'autocomplete', 'auto complete', 'auto-complete', 'autocompletion', 'auto completion'], + description: 'Ghost suggestions are the dim predicted rest of a command shown as you type (→ accepts). The provider is NMSh Native, Deja, or None; empty-prompt prediction is optional.'}, + {id: 'prompt', label: 'Prompt', support: 'actionable', capability: 'prompt.open', open: '/prompt', covers: ['/prompt', 'settings:Prompt', 'family:prompt'], + aliases: ['prompt', 'prompts', 'prompt style', 'prompt modules', 'ps1', 'starship', 'powerlevel10k', 'p10k', 'rich git', 'git prompt', 'composer layout', 'one-line prompt', 'two-line prompt'], + description: 'The prompt above the composer: NMSh Native (themes, geometry, modules, Rich Git), Starship, Powerlevel10k, or None (composer only). /prompt previews and saves it.'}, + {id: 'theme', label: 'Theme and appearance', support: 'actionable', capability: 'theme.open', open: '/appearance', prefers: ['change', 'explain'], covers: ['/appearance', 'settings:Appearance', 'settings:Motion'], + aliases: ['theme', 'themes', 'appearance', 'colors', 'colours', 'color scheme', 'colour scheme', 'styling', 'vibrance', 'opacity', 'blur', 'transparency', 'palette'], + description: 'Appearance covers the Native theme, vibrance, UI chrome colors and terminal opacity/blur where the terminal supports it.'}, + {id: 'themeStudio', label: 'Theme Studio', support: 'actionable', open: '/theme', covers: ['/theme'], + aliases: ['theme studio', 'custom theme', 'custom themes', 'import theme', 'export theme', 'my own theme', 'imported theme', 'theme library'], + description: 'Theme Studio manages Native themes: browse built-ins, import a theme file (Base16/24, Windows Terminal, Oh My Posh, Kitty, Ghostty, iTerm2, WezTerm), edit, duplicate, export and select.'}, + {id: 'uiChrome', label: 'UI chrome', support: 'actionable', open: '/chrome', covers: ['/chrome'], + aliases: ['ui chrome', 'chrome', 'frames', 'panel colors', 'tab colors'], + description: 'UI chrome is NMSh\'s frames, rules, tabs, selection and accents; it follows the theme or a custom preset. Not Chroma (animated color treatment).'}, + {id: 'toolConfig', label: 'Tool Configuration', support: 'actionable', open: '/configure', covers: ['/configure', '/tmux'], + aliases: ['configure tmux', 'tmux config', 'tmux settings', 'tmux prefix', 'tmux mouse', 'tmux status bar', 'tool configuration', 'status studio'], + description: 'Tool Configuration edits supported settings of registered tools: tmux (settings, keys, Status Studio, new panes start NMSh) through one NMSh-managed file, Starship through its own CLI. /tmux opens tmux directly.'}, + {id: 'integrations', label: 'Integrations', support: 'actionable', open: '/integrations', covers: ['/integrations'], + aliases: ['integrations', 'integration health', 'managed integrations', 'update all integrations'], + description: 'Integrations shows every managed integration (Theme Bridge files, includes, bat cache, tmux) and applies what is missing after one combined review that starts on No.'}, + {id: 'dotfiles', label: 'Dotfiles import', support: 'actionable', open: '/dotfiles', covers: ['/dotfiles'], + aliases: ['dotfiles', 'dot files', 'import dotfiles', 'stow', 'chezmoi'], + description: 'Dotfiles import scans a local repository (plain, Git, Stow or chezmoi source) or a Git URL you confirm, and imports supported settings through the same adapters after a review. Nothing in the repository is run.'}, + {id: 'themeBridge', label: 'Theme Bridge', support: 'actionable', open: '/theme-bridge', covers: ['/theme-bridge'], + aliases: ['theme bridge', 'fzf colors', 'fzf colours', 'man page colors', 'less colors', 'ls colors', 'ls_colors', 'tmux theme', 'tmux colors', 'neovim theme', 'nvim colorscheme', 'vim colorscheme', 'helix theme'], + description: 'Theme Bridge extends NMSh themes to fzf, less/man, LS_COLORS, tmux, Neovim, Vim and Helix. Each tool is Independent until you choose Follow NMSh or a pinned theme.'}, + {id: 'chroma', label: 'Chroma', support: 'actionable', open: '/chroma', covers: ['/chroma', 'settings:Presentation'], + aliases: ['chroma', 'gradient', 'gradients', 'animated colors', 'animated colours', 'color motion', 'colour motion', 'prompt gradient', 'rainbow prompt', 'chroma palette', + 'animated prompt colors', 'animated prompt colours', 'animated prompt'], + description: 'Chroma paints NMSh-owned chrome (prompt, dividers) with a palette gradient, optionally animated (Travel, Breathe, Comet, Pulse). Palette Off turns it off.', + where: 'Chroma is in /chroma (or /prompt → Chroma); set Palette to Off there to turn it off.'}, + {id: 'transcript', label: 'Transcript', support: 'actionable', open: '/transcript', covers: ['/transcript', 'settings:Transcript', '/clear'], + aliases: ['transcript', 'output history', 'command output', 'past output', 'scrollback', 'history divider', 'history dividers', 'dividers', 'prompt snapshots', 'history colors', 'history colours'], + description: 'The transcript is this session\'s commands and their raw output. /transcript chooses Normal or Chat presentation and sets historical prompts, history colors, dividers and folding; /clear archives it and starts a fresh view.'}, + {id: 'folding', label: 'Output folding', support: 'settings', open: '/setup transcript', where: 'Settings → Transcript → Output folding (Off, Smart or Always), also in /setup transcript', + aliases: ['folding', 'output folding', 'fold output', 'fold', 'collapse output', 'collapsed output', 'collapsing', 'collapsing output', 'smart fold', 'smart folding', + 'hide noisy output', 'folded output', 'folds'], + description: 'Output folding collapses long command output behind a one-line disclosure (Ctrl+O expands). Smart folds long, repetitive successes and never hides errors; Always folds every long block.'}, + {id: 'composerDividers', label: 'Composer dividers', support: 'actionable', open: '/settings', prefers: ['on', 'off'], toggle: {setting: 'composerDividers', on: 'on', off: 'off'}, + where: 'Settings → Layout → Composer dividers (On/Off)', + aliases: ['composer dividers', 'composer divider', 'input dividers', 'input divider', 'input lines', 'lines around the input', 'divider lines', 'input box lines', 'composer lines', 'input separators'], + description: 'Composer dividers are the two thin rules above and below the input. Off removes them and gives their rows back to output.'}, + {id: 'layout', label: 'Layout', support: 'actionable', open: '/layout', covers: ['/layout', '/composer', 'settings:Layout'], + aliases: ['layout', 'transcript layout', 'transcript presentation', 'chat mode', 'chat layout', 'composer position', 'composer at the top', 'flow mode', 'classic mode'], + description: 'Layout chooses where the composer sits (bottom, top, or Flow after the newest output) and whether the transcript is Normal or Chat (commands on the right).'}, + {id: 'history', label: 'Command history', support: 'actionable', open: '/history', configure: '/providers', where: 'Settings → History → Command history provider, or /providers', + covers: ['/history', 'settings:History', 'family:history'], + aliases: ['command history', 'history provider', 'command history provider', 'shell history', 'atuin', 'history search', 'search history', 'history'], + description: 'Command history is what ↑ and /history search: NMSh\'s native journals and zsh history, or a read-only Atuin database.'}, + {id: 'picker', label: 'Picker', support: 'actionable', open: '/providers', where: 'Settings → History → Picker provider, or /providers', covers: ['family:picker'], + aliases: ['picker', 'fuzzy finder', 'fzf', 'television', 'picker provider'], + description: 'The picker is the search list for history and directories: native composer search, fzf, or Television. Selections never execute.'}, + {id: 'navigation', label: 'Directory navigation', support: 'actionable', open: '/dirs', configure: '/providers', where: 'Settings → History → Directory navigation, or /providers', + covers: ['/dirs', 'family:navigation'], + aliases: ['directory navigation', 'directories', 'dirs', 'folder navigation', 'cd history', 'zoxide', 'jump to directory', 'recent directories', 'recent directory', 'recent folders', 'recent folder'], + description: '/dirs finds a directory and inserts a visible cd command. Ranking comes from native command-history frecency or a read-only zoxide snapshot.'}, + {id: 'providers', label: 'Providers', support: 'actionable', capability: 'providers.open', open: '/providers', covers: ['/providers', '/picker', '/pickers', '/suggestions', '/navigation', '/welcome', '/history-provider'], + aliases: ['providers', 'provider', 'picker provider', 'history provider'], + description: 'Providers are what NMSh uses for prompt, welcome, suggestions, history, picker, directory navigation and local understanding. /providers shows, switches, detects and installs them.'}, + {id: 'shell', label: 'Shell', support: 'actionable', capability: 'shell.switch', open: '/shell', covers: ['/shell'], + aliases: ['shell', 'shells', 'shell backend', 'backend', 'default shell'], + description: 'Each NMSh session runs a real zsh, Fish or Bash. /shell switches this session (NMSh stays open), installs missing shells, and D sets the default for new sessions. It also controls visibility and Left/Right placement of the prompt shell indicator, shared with /prompt.'}, + {id: 'leave', label: 'Leave NMSh for an ordinary shell', support: 'actionable', capability: 'shell.leave', covers: ['/zsh', '/fish', '/bash', '/exit'], + aliases: ['leave nmsh', 'exit nmsh', 'quit nmsh', 'ordinary shell', 'plain shell', 'regular shell'], + description: '/zsh, /fish and /bash leave NMSh for an ordinary shell (the session waits; `nmsh` returns). /exit uses your default shell.'}, + {id: 'sessions', label: 'Live sessions', support: 'actionable', capability: 'session.list', open: '/sessions', covers: ['/sessions', '/rename', 'settings:Sessions'], + aliases: ['live sessions', 'running sessions', 'detached sessions', 'current sessions', 'open sessions', 'other sessions', 'sessions', 'session', 'other windows', 'startup restore'], + description: 'Live sessions are NMSh sessions running right now, attached or detached. /sessions switches to or ends one.'}, + {id: 'transcripts', label: 'Archived transcripts', support: 'actionable', capability: 'session.resume', open: '/resume', overrides: ['session.list'], covers: ['/resume'], + aliases: ['old sessions', 'past sessions', 'previous sessions', 'archived sessions', 'saved sessions', 'old transcripts', 'archived transcripts', 'saved transcripts', + 'past transcripts', 'transcript archive', 'old output', 'old terminal output'], + description: '/resume browses archived transcripts (earlier views, cleared or closed sessions) and live sessions, and reopens one.'}, + {id: 'find', label: 'Find and filter output', support: 'actionable', capability: 'transcript.find', covers: ['/find', '/filter'], + aliases: ['find bar', 'find in transcript', 'search the transcript', 'search output', 'filter output', 'filter lines', 'find and filter'], + description: '/find (Ctrl+F) highlights terms in the transcript; /filter shows only matching lines of the newest output. Terms AND together; -r regex, -c case.'}, + {id: 'cursor', label: 'Cursor & effects', support: 'actionable', open: '/cursor', covers: ['/cursor', 'settings:Cursor'], + aliases: ['cursor', 'caret', 'cursor blink', 'blinking cursor', 'cursor shape', 'blink', 'cursor effects', 'cursor trail', 'smooth cursor', 'smear cursor', 'cursor animation', 'fire cursor', 'cursor particles', 'cursor shader'], + description: 'The caret\'s shape and blink, plus optional motion (Smooth, Smear, Tail), effects (Fire, Sparks, Lightning, Railgun, Ripple, Wireframe) and idle effects. Portable everywhere NMSh owns its input; after a previewed setup Ghostty draws Smear, Tail, Fire, Sparks and Ripple natively and Kitty draws a Tail, and Portable covers the rest. Colors follow the current theme, a theme you choose, the NMSh accent, the host or a custom color. Off by default.'}, + {id: 'motion', label: 'Motion', support: 'actionable', open: '/motion', covers: ['/motion'], where: '/motion (also /appearance → Motion (Context transitions, Command launch, Completion highlight, Command completion, Event feedback)', + aliases: ['motion', 'animations', 'transitions', 'launch sweep', 'command launch', 'block seal', 'semantic echo', 'event feedback', 'completion highlight', 'context transitions', 'prompt morph'], + description: 'Short presentation transitions for real events: the command handoff on Enter, what completion inserted, a finished block settling, prompt modules changing, and meaningful events. Reduced Motion and Decorative Effects Off stop them all.'}, + {id: 'doctor', label: 'Doctor', support: 'actionable', open: '/doctor', covers: ['/doctor'], + aliases: ['doctor', 'health check', 'diagnostics', 'diagnose', 'check my setup', 'is everything ok', 'whats wrong with my setup', 'check environment'], + description: '/doctor checks NMSh, the shell, this project, Git, tools, the local model and the host terminal with local, read-only checks, and offers actions that open the right place; it never fixes anything silently.'}, + {id: 'watch', label: 'Watch', support: 'actionable', open: '/watch', covers: ['/watch'], + aliases: ['watch', 'watch a command', 'rerun every', 'repeat a command', 'keep running', 'monitor a command'], + description: '/watch runs a command on NMSh\'s own schedule and shows what changed in one live block (pause, run now, stop). Commands that install, modify or destroy are refused; unknown ones need a Yes.'}, + {id: 'pastePreview', label: 'Paste preview', support: 'settings', open: '/settings', where: 'Settings → Editor → Paste preview (Smart, Always, Off)', covers: ['settings:Editor'], + aliases: ['paste preview', 'paste guard', 'pasting', 'paste safety', 'pasted commands'], + description: 'Multiline, chained, mutating or risky pastes are shown (muted, above the composer) before they enter it; Insert keeps the exact text, and nothing runs until you press Enter.'}, + {id: 'syntax', label: 'Syntax highlighting', support: 'actionable', open: '/syntax', covers: ['/syntax', 'settings:Syntax'], + aliases: ['syntax highlighting', 'syntax', 'highlighting', 'command colors', 'command colours', 'syntax colors', 'syntax colours'], + description: 'Syntax highlighting colors what you type: known commands, unknown commands, strings, options and paths.'}, + {id: 'idle', label: 'Idle visuals (screensaver)', support: 'actionable', capability: 'screensaver.open', open: '/screensaver', covers: ['/screensaver', 'settings:Idle visuals'], + aliases: ['screensaver', 'screen saver', 'idle visuals', 'idle visual', 'idle animation', 'idle screen'], + description: 'Idle visuals play after a timeout while the composer is empty; any key or mouse stops them. /screensaver has the gallery, timeout and colors.'}, + {id: 'effects', label: 'Effects', support: 'actionable', open: '/effects', covers: ['/effects'], + aliases: ['effects', 'sparkles', 'confetti', 'rain effect'], + description: '/effects previews sparkles, rain or confetti in NMSh-owned chrome; /effects stop cancels.'}, + {id: 'activity', label: 'Live activity', support: 'actionable', open: '/activity', covers: ['/activity', 'settings:Live activity'], + aliases: ['live activity', 'activity colors', 'activity colours', 'running command line', 'spinner', 'elapsed time'], + description: 'Live activity is the animated line with elapsed time while a command runs; /activity sets its colors.'}, + {id: 'tools', label: 'Optional tools', support: 'actionable', capability: 'tools.open', open: '/tools', covers: ['/tools', 'settings:Tools'], + aliases: ['tools', 'optional tools', 'installs', 'install suggestions'], + description: '/tools lists optional tools NMSh can use, with previewed installs that start on No.'}, + {id: 'keepAwake', label: 'Keep Awake', support: 'actionable', open: '/caffeinate', covers: ['/caffeinate', '/awake', '/zoomies'], + aliases: ['keep awake', 'keep-awake', 'caffeinate', 'awake', 'zoomies', 'prevent sleep', 'stay awake'], + description: 'Keep Awake (/caffeinate, /awake, /zoomies) keeps the computer or display awake with the operating system\'s own mechanism (Apple caffeinate, the systemd inhibitor or the Windows execution-state API) until you stop it or its timeout ends. No power settings change.'}, + {id: 'presets', label: 'Session presets', support: 'actionable', open: '/presets', covers: ['/presets'], + aliases: ['presets', 'preset', 'session presets', 'named presets', 'session preset'], + description: 'Presets are named session setups (folder, shell, startup command) you can create, inspect and launch from /presets.'}, + {id: 'notices', label: 'Session notices', support: 'actionable', open: '/notices', covers: ['/notices'], + aliases: ['notices', 'session notices', 'cross-session notices', 'notifications', 'notification'], + prefers: ['explain'], + description: 'Session notices are up to three factual lines above the composer when other NMSh sessions finish, fail, ask for attention or end. /notices on/off/clear.'}, + {id: 'commandNotifications', label: 'Command notifications', support: 'settings', open: '/settings', where: 'Settings → Command notifications (Notify after, on success, on failure, when focused)', + covers: ['settings:Command notifications'], + aliases: ['command notifications', 'desktop notifications', 'notify when done', 'notify me', 'long command notifications', 'notifications', 'notification'], + description: 'Command notifications tell you when a long-running command finishes, after a minimum duration, on success and/or failure, optionally only when the terminal is not focused.'}, + {id: 'agentSessions', label: 'Agent sessions', support: 'actionable', open: '/ai', covers: ['/ai'], + aliases: ['agent sessions', 'ai sessions', 'background agent', 'background agents', 'claude session', 'codex session', 'agent session', 'start claude', 'run claude in the background'], + description: '/ai starts and supervises external agent harnesses (Claude Code today) in the background and shows other running agents it can see. The harness owns models, sign-in and tools; NMSh never calls model APIs. ← on an empty composer opens sessions, ↓ shows the agent shelf.'}, + {id: 'agents', label: 'Agent activity', support: 'actionable', open: '/agents', covers: ['/agents'], + aliases: ['agents', 'agent activity', 'agent stats', 'agent statistics', 'claude code activity', 'codex activity', 'agent runs'], + description: '/agents tracks local agent CLI activity (Claude Code, Codex CLI): durations, run counts and a heatmap. It never records prompts or output.'}, + {id: 'editor', label: 'Editor (open files)', support: 'actionable', capability: 'editor.status', where: 'Settings → Open with', covers: ['/open', '/open-diff'], + aliases: ['editor', 'open with', 'zed', 'vscode', 'vs code', 'visual studio code', 'open files in', 'open diff'], + description: '/open hands a path[:line[:column]] to your editor (Auto, Zed, VS Code, or VISUAL/EDITOR); /open-diff shows two files in its diff view.'}, + {id: 'keyboard', label: 'Keyboard', support: 'actionable', open: '/keyboard', covers: ['/keyboard', 'settings:Keyboard'], + aliases: ['keyboard', 'key bindings', 'keybindings', 'shortcuts', 'keyboard shortcuts', 'option key', 'kitty keyboard'], + description: '/keyboard configures terminal keyboard integration (enhanced keys, Option as Meta, Shift+Enter).'}, + {id: 'updates', label: 'Updates', support: 'actionable', open: '/update', covers: ['/update', 'settings:Updates'], + aliases: ['update', 'updates', 'new version', 'new release', 'upgrade nmsh', 'update nmsh', 'update checks', 'latest version'], + description: '/update checks GitHub for a newer NMSh release; installing it is a separate, explicit /update apply.'}, + {id: 'version', label: 'Version', support: 'actionable', open: '/version', covers: ['/version', '/about'], + aliases: ['version', 'which version', 'nmsh version', 'about nmsh'], + description: '/version shows this NMSh build\'s identity; /about adds the logo.'}, + {id: 'status', label: 'NMSh status', support: 'actionable', open: '/status', covers: ['/status'], + aliases: ['nmsh status', 'diagnostics', 'status page', 'status view'], + description: '/status shows NMSh\'s own state: session, shell, providers, local understanding and more.'}, + {id: 'statusStrip', label: 'Status strip', support: 'actionable', open: '/strip', where: '/strip (also Settings → Status strip)', covers: ['settings:Status strip', '/strip', '/status-strip'], + aliases: ['status strip', 'clock', 'battery', 'cpu', 'ram', 'uptime', 'status bar'], + description: 'The status strip is a compact row, top right: clock, battery, CPU, RAM and uptime, each optional.'}, + {id: 'palette', label: 'Command palette', support: 'actionable', open: '/palette', prefers: ['open'], covers: ['/palette'], + aliases: ['command palette', 'action palette', 'actions palette', 'palette', 'search actions'], + description: 'The palette (Ctrl+Shift+P or F1) searches every NMSh action.'}, + {id: 'help', label: 'Help and the guide', support: 'actionable', open: '/help', covers: ['/help', '/guide'], + aliases: ['slash commands', 'nmsh commands', 'nmsh help'], + description: '/help lists NMSh commands; /btw what can you do lists what Ask does.'}, + {id: 'settings', label: 'Settings', support: 'actionable', capability: 'settings.open', open: '/settings', covers: ['/settings', '/config', 'settings:General'], + aliases: ['settings', 'preferences'], + description: 'Settings (/settings or /config) holds every NMSh option, grouped by area.'}, + {id: 'setup', label: 'Setup Cat', support: 'actionable', open: '/setup', covers: ['/setup'], + aliases: ['setup cat', 'setup', 'guided setup', 'onboarding', 'first run'], + description: 'Setup Cat is the guided, rerunnable setup; it keeps your current choices.'}, + {id: 'welcome', label: 'Welcome', support: 'actionable', open: '/providers', where: 'Settings → Welcome, or /providers', covers: ['settings:Welcome', 'family:welcome'], + aliases: ['welcome', 'welcome screen', 'startup logo', 'banner', 'fastfetch', 'neofetch', 'vespyr', 'startup screen'], + description: 'The welcome is what a new session shows first: Vespyr (NMSh\'s own), fastfetch, neofetch, or none.'}, + {id: 'ask', label: 'Ask', support: 'settings', open: '/settings', where: 'Settings → Ask (Record Ask in transcript, Local understanding)', covers: ['/btw', '/ask', 'settings:Ask'], + aliases: ['ask', 'record ask', 'ask settings', 'ask transcript'], + description: '/btw answers plain-English questions about NMSh and proposes typed actions; it never runs arbitrary commands. Recording it in the transcript is optional.'}, + {id: 'understanding', label: 'Local understanding', support: 'actionable', capability: 'understanding.set', open: '/llm', covers: ['/llm'], + aliases: ['local understanding', 'local model', 'language model', 'llm', 'qwen', 'ai model', 'local intelligence', 'local llm'], + description: 'Local understanding is an optional small model that runs on this machine to help Ask and Smart Folding with loosely worded input. Auto (the default) asks it only when built-in understanding is unsure and a model is set up; nothing downloads without your Yes. Off never loads one.'}, + {id: 'glyphs', label: 'Glyph style', support: 'actionable', open: '/glyphs', where: '/glyphs (also Settings → Glyph style)', covers: ['/glyphs'], + aliases: ['glyphs', 'glyph style', 'nerd font', 'nerd fonts', 'icons', 'symbols'], + description: 'Glyph style picks Nerd Font symbols or safe terminal symbols for icons and prompt shapes.'}, + {id: 'copy', label: 'Copy output', support: 'actionable', open: '/copy', covers: ['/copy'], + aliases: ['copy output', 'copy the output', 'copy last output', 'copy latest output'], + description: '/copy copies the latest command output as plain text; /copy N copies an earlier one.'}, + {id: 'homebrew', label: 'Homebrew packages', support: 'actionable', open: '/tools', covers: [], + aliases: ['homebrew', 'brew packages', 'brew', 'formulae', 'casks', 'package manager'], + description: 'Ask can inspect Homebrew: installed packages, versions, search, info, dependencies and what is outdated, and install, upgrade or uninstall after showing the exact brew command and your Yes. Ownership is only claimed from Homebrew\'s own evidence.'}, + {id: 'otherShells', label: 'Other shells (Nushell, PowerShell)', support: 'unsupported', + aliases: ['nushell', 'nu shell', 'powershell', 'pwsh', 'elvish', 'xonsh'], + description: 'NMSh runs zsh, Fish and Bash. Nushell, PowerShell and other shells are not supported yet.'}, + {id: 'panes', label: 'Tabs and split panes', support: 'unsupported', + aliases: ['split pane', 'split panes', 'splits', 'tabs', 'new tab', 'tmux'], + description: 'NMSh doesn\'t manage tabs or split panes; your terminal does. Each NMSh window is one session (/sessions lists them).'}, +]; + +/** Public surfaces Ask deliberately has no concept for, with the reason (the coverage audit checks these too). */ +export const ASK_EXCLUDED: Readonly> = { +}; + +export type ConceptIntent = 'explain' | 'on' | 'off' | 'open' | 'change'; + +/** What the person wants to do, from the verb; "change" when no verb says otherwise. */ +export function conceptIntent(text: string): ConceptIntent { + if (/^(?:what|wat|whats)\b(?! (?:version|shell|branch|provider))|\bexplain\b|\bdifference\b|\btell me about\b|\bhow does\b|\bwhat (?:is|are|does|do)\b|\bmean(?:s|ing)?\b/u.test(text)) return 'explain'; + if (/\b(?:turn|switch|set)\b.*\boff\b|\b(?:disable|stop|hide|no more|get rid of|without|remove|drop)\b/u.test(text)) return 'off'; + if (/\b(?:turn|switch|set)\b.*\bon\b|\benable\b/u.test(text)) return 'on'; + if (/^(?:open|show|see|view|browse|list|check|inspect|display|go to|jump|launch|start)\b|\b(?:open|show me|check for|check|see)\b/u.test(text) + && !/\b(?:change|configure|customi[sz]e|tweak|edit|adjust|settings?|where)\b/u.test(text)) return 'open'; + return 'change'; +} + +const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); +const ALIAS_INDEX = CONCEPTS.flatMap(concept => concept.aliases.map(alias => ({alias, concept, pattern: new RegExp(`(?:^|[^a-z0-9-])${escape(alias)}(?:$|[^a-z0-9-])`, 'u')}))) + .sort((a, b) => b.alias.length - a.alias.length); + +/** Conservative single-typo tolerance for long one-word aliases (autocompelte, sugestions). */ +function typoMatch(word: string, alias: string): boolean { + if (alias.length < 7 || alias.includes(' ') || Math.abs(word.length - alias.length) > 1 || word[0] !== alias[0]) return false; + const [a, b] = [word, alias]; + const rows = Array.from({length: a.length + 1}, (_, i) => Array.from({length: b.length + 1}, (_, j) => (i === 0 ? j : j === 0 ? i : 0))); + for (let i = 1; i <= a.length; i += 1) for (let j = 1; j <= b.length; j += 1) { + rows[i]![j] = Math.min(rows[i - 1]![j]! + 1, rows[i]![j - 1]! + 1, rows[i - 1]![j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1)); + if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) rows[i]![j] = Math.min(rows[i]![j]!, rows[i - 2]![j - 2]! + 1); + } + return rows[a.length]![b.length]! <= 1; +} + +export interface ConceptMatch { + /** Concepts named by the request, in order of the longest phrase that named them. */ + concepts: Concept[]; + /** Groups that one shared phrase left ambiguous (e.g. "autocomplete": completion or suggestions). */ + ambiguous: Concept[][]; +} + +/** + * The concepts a normalized request names. Longer phrases win and consume + * their words ("tab completion" is completion, not also "completion"); a + * phrase several concepts share yields an ambiguous group unless another word + * already named one of them. + */ +export function matchConcepts(text: string): ConceptMatch { + let rest = ` ${text} `; + const found: Concept[] = []; + const groups: Concept[][] = []; + const consumed = new Set(); + for (const {alias, pattern} of ALIAS_INDEX) { + if (consumed.has(alias) || !pattern.test(rest)) continue; + consumed.add(alias); + const owners = ALIAS_INDEX.filter(entry => entry.alias === alias).map(entry => entry.concept); + rest = rest.replace(pattern, match => match.replace(alias, ' '.repeat(alias.length))); + if (owners.length === 1) { if (!found.includes(owners[0]!)) found.push(owners[0]!); } else groups.push(owners); + } + for (const word of rest.split(/[^a-z0-9-]+/u).filter(item => item.length >= 6)) { + const hit = ALIAS_INDEX.find(entry => typoMatch(word, entry.alias)); + if (!hit) continue; + const owners = ALIAS_INDEX.filter(entry => entry.alias === hit.alias).map(entry => entry.concept); + if (owners.length === 1) { if (!found.includes(owners[0]!)) found.push(owners[0]!); } else groups.push(owners); + } + // A group is settled when another phrase already named one of its members ("autocomplete ghost text"). + const ambiguous = groups.filter(group => !group.some(concept => found.includes(concept))) + .filter((group, index, all) => all.findIndex(other => other.map(item => item.id).join() === group.map(item => item.id).join()) === index); + return {concepts: found, ambiguous}; +} + +/** Parse a concept's own destination with NMSh's slash parser; never free text. */ +export function conceptDestination(concept: Concept) { + if (!concept.open) return undefined; + const slash = parseSlashCommand(concept.open); + return slash && slash.kind !== 'unknown' ? slash : undefined; +} + +/** + * The NMSh guide: sections over the same concept catalog, so /guide, /ask + * guide and /btw help explain exactly what Ask knows. Every public concept + * belongs to a section (a test enforces it); "Everything" lists them all. + */ +export interface GuideSection {id: string; title: string; why: string; concepts: string[]; notes?: string[]} + +export const GUIDE_SECTIONS: readonly GuideSection[] = [ + {id: 'start', title: 'Getting started', why: 'NMSh is a terminal frontend over a real, persistent shell: your shell keeps its state, and NMSh adds the editor, transcript and tools around it.', + concepts: ['setup', 'help', 'palette', 'settings', 'version', 'updates', 'status']}, + {id: 'look', title: 'Prompt & appearance', why: 'Make the prompt and colors yours without editing dotfiles.', concepts: ['prompt', 'theme', 'themeStudio', 'themeBridge', 'uiChrome', 'glyphs', 'syntax']}, + {id: 'cursorEffects', title: 'Cursor & effects', why: 'Cursor trails and motion for NMSh-owned chrome; Reduced Motion, Effects Off and NO_COLOR are always respected.', concepts: ['cursor', 'motion']}, + {id: 'chroma', title: 'Chroma', why: 'Optional gradients and motion for NMSh-owned chrome only; your command output is never recolored.', concepts: ['chroma', 'effects', 'activity']}, + {id: 'shells', title: 'Shells', why: 'One NMSh window can run zsh, Fish or Bash, and you can leave for a plain shell and come back.', concepts: ['shell', 'leave', 'otherShells']}, + {id: 'completion', title: 'Completion & suggestions', why: 'Tab completion lists real candidates; ghost suggestions predict the rest of the line.', concepts: ['completion', 'suggestions']}, + {id: 'history', title: 'History & transcripts', why: 'Find what you ran and what it printed, now or in earlier sessions.', concepts: ['history', 'picker', 'transcript', 'find', 'copy', 'transcripts']}, + {id: 'sessions', title: 'Sessions', why: 'Sessions keep running when a window closes; reattach, switch or get notified.', concepts: ['sessions', 'agentSessions', 'presets', 'notices', 'commandNotifications', 'panes', 'keepAwake']}, + {id: 'ask', title: 'Ask', why: 'Plain-English help that knows NMSh, your commands and this repository, and never runs anything you did not confirm.', concepts: ['ask']}, + {id: 'intelligence', title: 'Local intelligence', why: 'An optional local model helps Ask map vague requests to known actions. It runs on this machine, never writes commands, and Ask works fully without it.', concepts: ['understanding'], + notes: ['Modes: Off, Auto (used only when the deterministic resolver is unsure) and Always (consulted first). /llm shows the model, runtime and last inference.']}, + {id: 'projects', title: 'Project & dev tasks', why: 'Ask reads this project\'s scripts (package.json, Makefile, Cargo, …) and can run them for you.', concepts: [], + notes: ['Try: "run the tests" · "start the dev server" · "what scripts does this project have?"', 'Dev servers run as NMSh-managed background tasks: a live status row, detected URLs to open, and "stop the dev server" to end them.']}, + {id: 'files', title: 'Files, config & editor', why: 'Jump to folders, open what output mentions, and let Ask find and open config files or add/update a setting with a verified, previewed edit (it never removes settings).', concepts: ['navigation', 'editor', 'pastePreview']}, + {id: 'providers', title: 'Providers & tools', why: 'Choose what powers each part of NMSh, and install optional tools with previewed recipes.', concepts: ['providers', 'welcome', 'tools', 'toolConfig', 'integrations', 'dotfiles', 'homebrew', 'agents']}, + {id: 'doctorWatch', title: 'Doctor & watch', why: 'Check your setup with local, read-only checks, and watch a command change over time.', concepts: ['doctor', 'watch'], + notes: ['After a failure, ask "why did that fail?" for an explanation from the recorded output.']}, + {id: 'layout', title: 'Layout & folding', why: 'Decide where the composer sits and how much output stays in view.', concepts: ['layout', 'composerDividers', 'folding', 'statusStrip', 'idle']}, + {id: 'keyboard', title: 'Keyboard', why: 'Terminal key integration for Shift+Enter, Option as Meta and enhanced keys.', concepts: ['keyboard']}, +]; diff --git a/src/ask/configActions.ts b/src/ask/configActions.ts new file mode 100644 index 00000000..652c17a4 --- /dev/null +++ b/src/ask/configActions.ts @@ -0,0 +1,213 @@ +import type {AskOutcome} from './types.js'; +import {parseSlashCommand} from '../commands/slashCommands.js'; +import {describeTmuxChange, type TmuxChange} from '../tools/config/tmux.js'; +import {BRIDGE_TARGET_LABELS, type BridgeTargetId} from '../themeBridge/model.js'; +import {THEME_PALETTE_IDS} from '../prompt/configuration.js'; +import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js'; +import {detectOhMyZsh, previousZshrc} from '../tools/frameworks.js'; +import {TOOLS, toolInstall} from '../tools/catalog.js'; +import {resolveCommand} from '../providers/providers.js'; +import {installProposal} from './commands.js'; + +/** + * Deterministic Ask routing for NMSh surfaces and supported configuration. + * Requests map only onto typed actions (a registered tmux change, a Theme + * Bridge setting, or opening a canonical surface); model or request text + * never becomes a command, path mutation or config snippet. Changes are + * proposals behind Ask's final Yes/No; opening a surface is navigation. + */ + +const SURFACES: ReadonlyArray<[RegExp, string]> = [ + [/\b(?:check|update|review)\b.*\bintegrations?\b|\bintegrations? (?:health|status)\b|\bupdate (?:anything|everything) missing\b/u, '/integrations'], + [/\bmotion\b/u, '/motion'], + [/\bui chrome\b|\bchrome\b(?! ?browser)/u, '/chrome'], + [/\bglyphs?\b|\bsymbols\b|\bnerd font\b/u, '/glyphs'], + [/\bcomposer\b/u, '/composer'], + [/\bstatus strip\b|\bthe strip\b/u, '/strip'], + [/\btheme bridge\b/u, '/theme-bridge'], + [/\bpicker\b/u, '/picker'], + [/\bdirectory nav(?:igation)?\b|\bdir(?:ectory)? jump/u, '/navigation'], + [/\btmux\b/u, '/tmux'], +]; + +const OPEN = /^(?:please )?(?:open|show|change|configure|edit|set up|go to|take me to)\b/u; +const keyName = (text: string) => { + const match = /\b(?:ctrl|control|c)[ +-]?([a-z])\b/u.exec(text); + return match ? `C-${match[1]}` : undefined; +}; + +function themeRef(name: string): string | undefined { + const wanted = name.trim().toLowerCase(); + const id = THEME_PALETTE_IDS.filter(palette => palette !== 'custom').find(palette => { + const label = NATIVE_PROMPT_THEMES[palette].label.toLowerCase(); + return label === wanted || label.replace(/ native$/u, '') === wanted || label.startsWith(`${wanted} `) || label === `${wanted} dark`; + }); + return id ? `builtin:${id}` : undefined; +} + +const TARGET_WORDS: ReadonlyArray<[RegExp, BridgeTargetId]> = [[/\btmux\b/u, 'tmux'], [/\bneovim\b|\bnvim\b/u, 'neovim'], [/\bvim\b/u, 'vim'], [/\bhelix\b/u, 'helix'], + [/\bfzf\b/u, 'fzf'], [/\bbat\b/u, 'bat'], [/\bless\b|\bman pages?\b/u, 'pager'], [/\bls\b|\bfile listing/u, 'lsColors']]; + +function tmuxChanges(text: string): TmuxChange[] { + const changes: TmuxChange[] = []; + const mouse = /\bmouse\b.*\b(on|off)\b|\b(enable|disable)\b.*\bmouse\b/u.exec(text); + if (mouse) changes.push({kind: 'option', id: 'mouse', value: mouse[1] ?? (mouse[2] === 'enable' ? 'on' : 'off')}); + if (/\bprefix\b/u.test(text)) { const key = keyName(text.slice(text.indexOf('prefix'))); if (key) changes.push({kind: 'prefix', key}); } + const status = /\bstatus(?: bar| line)?\b.*\b(top|bottom)\b/u.exec(text); + if (status) changes.push({kind: 'option', id: 'status-position', value: status[1]!}); + if (/\bvi keys\b|\bvi mode\b|\bvim keys\b/u.test(text)) changes.push({kind: 'option', id: 'mode-keys', value: 'vi'}, {kind: 'option', id: 'status-keys', value: 'vi'}); + if (/\bnew (?:tmux )?(?:panes?|windows?)\b.*\b(?:start|run|open|launch)\b.*\bnmsh\b/u.test(text)) changes.push({kind: 'frontend', value: 'nmsh'}); + if (/\bnew (?:tmux )?(?:panes?|windows?)\b.*\b(?:start|run)\b.*\b(?:normal |default |my )?shell\b/u.test(text)) changes.push({kind: 'frontend', value: 'shell'}); + return changes; +} + +const OMZ = /\boh[ -]?my[ -]?zsh\b|\bomz\b/u; +const OMP = /\boh[ -]?my[ -]?posh\b|\bomp\b/u; +const P10K = /\bpowerlevel ?10k\b|\bp10k\b/u; +const view = (tool: string, which: 'detail' | 'guided' | 'previous' | 'p10kConfigure' | 'importAppearance', text: string): AskOutcome => + ({kind: 'proposal', capability: 'tools.open', safety: 'navigate', confidence: 0.92, direct: true, text, action: {kind: 'toolView', tool, view: which, label: text}}); +const promptSwitch = (value: 'ohMyPosh' | 'powerlevel10k', label: string): AskOutcome => + ({kind: 'proposal', capability: 'provider.switch', safety: 'mutate', confidence: 0.92, text: `Use ${label} as the prompt? NMSh renders it directly; no shell rc file changes.`, + action: {kind: 'setting', setting: 'prompt', value, label: `Prompt: ${label}`}}); + +/** Shell frameworks and prompt engines: facts, navigation to the exact /tools view, or a typed provider switch. Never a shell command or rc edit. */ +function resolveFrameworkRequest(text: string, env: NodeJS.ProcessEnv): AskOutcome | undefined { + if (/\bpre[ -]?oh[ -]?my[ -]?zsh\b|\b(?:old|previous)\b.*\bzshrc\b|\bzshrc\b.*\b(?:old|previous|before oh my zsh)\b|\bwhat happened to my\b.*\bzshrc\b/u.test(text)) { + const pair = previousZshrc(env); + if (!pair) return {kind: 'answer', capability: 'tools.open', text: 'There is no .zshrc.pre-oh-my-zsh here, so the Oh My Zsh installer did not save an earlier .zshrc in this home (or ZDOTDIR).'}; + return view('oh-my-zsh', 'previous', `Compare ${pair.current} with ${pair.previous} in /tools (restoring there backs up the current file and asks first)`); + } + if (OMZ.test(text)) { + if (/\binstalled\b|\bdo i have\b|\bis there\b/u.test(text)) { + const found = detectOhMyZsh(env); + return {kind: 'answer', capability: 'tools.open', text: found ? `Yes. Oh My Zsh is installed at ${found.path}${found.source ? ` (from ${found.source})` : ''}. It is a Zsh framework, used by Zsh only.` : 'No. No Oh My Zsh installation was found at $ZSH or ~/.oh-my-zsh.'}; + } + if (/\binstall\b/u.test(text)) return view('oh-my-zsh', 'guided', 'Open the Oh My Zsh guided install (keeps your .zshrc; you run the official installer yourself)'); + return view('oh-my-zsh', 'detail', 'Open Oh My Zsh in /tools'); + } + if (OMP.test(text)) { + if (/\bimport\b|\btheme studio\b|\binto nmsh\b/u.test(text)) return view('oh-my-posh', 'importAppearance', 'Import your Oh My Posh appearance into an NMSh Native theme (static colors only; the provider is not switched)'); + if (/\binstall\b/u.test(text)) { + const tool = TOOLS.find(item => item.id === 'oh-my-posh')!; + if (resolveCommand('oh-my-posh', env.PATH ?? '')) return {kind: 'answer', capability: 'tools.open', text: 'Oh My Posh is already installed.'}; + const recipe = toolInstall(tool); + return recipe ? installProposal('oh-my-posh', {tool: tool.id, label: recipe.label}) : view('oh-my-posh', 'detail', 'Open Oh My Posh in /tools (no curated install here)'); + } + if (/\b(?:use|switch to|set)\b.*\bprompt\b|\bas (?:my )?prompt\b/u.test(text)) return promptSwitch('ohMyPosh', 'Oh My Posh'); + return view('oh-my-posh', 'detail', 'Open Oh My Posh in /tools'); + } + if (P10K.test(text)) { + if (/\bconfigure\b|\bwizard\b|\bset up\b/u.test(text)) return view('powerlevel10k', 'p10kConfigure', 'Configure Powerlevel10k (backs up ~/.p10k.zsh and .zshrc, then runs p10k configure)'); + if (/\buse\b|\bswitch to\b/u.test(text)) return promptSwitch('powerlevel10k', 'Powerlevel10k'); + } + return undefined; +} + +/** + * Keep Awake: deterministic, typed through the /caffeinate action against the + * one controller. Status and opening run directly; starting, changing or + * stopping waits for Ask's Yes. No model is involved. + */ +const AWAKE_NAME = String.raw`(?:zoomies|caffeinate|keep[ -]?awake|awake)`; +function awakeDuration(text: string): string { + const match = /\bfor (?:an? |one )?(\d{1,3})?\s*(hours?|hrs?|h|minutes?|mins?|m)\b/u.exec(text); + if (!match) return ''; + return ` ${match[1] ?? '1'}${match[2]!.startsWith('h') ? 'h' : 'm'}`; +} +function resolveKeepAwakeRequest(text: string): AskOutcome | undefined { + const slash = (command: string, safety: 'navigate' | 'mutate', label: string): AskOutcome | undefined => { + const parsed = parseSlashCommand(command); + return parsed ? {kind: 'proposal', capability: 'feature.open', safety, confidence: 0.92, ...(safety === 'navigate' ? {direct: true} : {}), text: label, action: {kind: 'slash', slash: parsed, label: command}} : undefined; + }; + const name = new RegExp(String.raw`\b${AWAKE_NAME}\b`, 'u'); + // Status: "is zoomies on", "are we keeping the computer awake", "what awake mode is active". + if ((/^(?:is|are|what|which|how long)\b/u.test(text) && name.test(text) && /\b(?:on|running|active|mode|keeping|still)\b/u.test(text)) + || new RegExp(String.raw`\b${AWAKE_NAME} status\b`, 'u').test(text)) return slash('/caffeinate status', 'navigate', 'Keep Awake status'); + // Stop: "stop zoomies", "turn caffeinate off", "let my mac sleep". + if (new RegExp(String.raw`\b(?:stop|end|cancel|disable|turn off|switch off)\b.*\b${AWAKE_NAME}\b|\b${AWAKE_NAME}\b.*\b(?:off|stop)\b`, 'u').test(text) + || /\bstop\b.*\b(?:keeping|keep)\b.*\bawake\b|(? ` ${describeTmuxChange(change)}`).join('\n')}`, + action: {kind: 'tmux', changes, label: changes.map(describeTmuxChange).join(' · ')}}; + } + } + if (/\btheme bridge\b|\b(?:all )?(?:bridge )?targets\b/u.test(text) && /\bfollow nmsh\b|\bfollow (?:the )?(?:nmsh )?theme\b/u.test(text)) { + return {kind: 'proposal', capability: 'theme.open', safety: 'mutate', confidence: 0.9, text: 'Turn Theme Bridge On with Apply themes: Follow NMSh (every supported tool follows the active theme)', + action: {kind: 'themeBridge', enabled: true, policy: 'follow', label: 'Theme Bridge · Follow NMSh'}}; + } + // "use Tokyo Night for tmux but Lavender for Vim": per-tool pins (Manual). + const pins = [...text.matchAll(/\buse ([a-z][a-z ]{1,30}?) for ([a-z/ ]{2,20}?)(?= but|,| and|$)|\b(?:but|and) ([a-z][a-z ]{1,30}?) for ([a-z/ ]{2,20}?)(?= but|,| and|$)/gu)]; + if (pins.length) { + const targets: Partial> = {}; + for (const pin of pins) { + const theme = themeRef(pin[1] ?? pin[3] ?? ''); + const target = TARGET_WORDS.find(([pattern]) => pattern.test(pin[2] ?? pin[4] ?? ''))?.[1]; + if (theme && target) targets[target] = {mode: 'choose', theme}; + } + const entries = Object.entries(targets) as Array<[BridgeTargetId, {mode: 'choose'; theme: string}]>; + if (entries.length) { + const label = entries.map(([target, setting]) => `${BRIDGE_TARGET_LABELS[target]}: ${NATIVE_PROMPT_THEMES[setting.theme.slice(8) as 'lavender'].label}`).join(' · '); + return {kind: 'proposal', capability: 'theme.open', safety: 'mutate', confidence: 0.85, text: `Theme Bridge (Manual): ${label}`, + action: {kind: 'themeBridge', enabled: true, policy: 'manual', targets, label}}; + } + } + if (OPEN.test(text) || /\bsettings\b/u.test(text)) { + for (const [pattern, command] of SURFACES) { + if (!pattern.test(text)) continue; + const slash = parseSlashCommand(command); + if (slash) return {kind: 'proposal', capability: 'feature.open', safety: 'navigate', confidence: 0.9, direct: true, text: `Open ${command}`, action: {kind: 'slash', slash, label: command}}; + } + } + if (/\b(?:check|update)\b.*\bintegrations?\b|\bupdate (?:anything|everything) missing\b/u.test(text)) { + const slash = parseSlashCommand('/integrations')!; + return {kind: 'proposal', capability: 'feature.open', safety: 'navigate', confidence: 0.9, direct: true, text: 'Open /integrations (Review all; nothing is applied until you confirm)', action: {kind: 'slash', slash, label: '/integrations'}}; + } + return undefined; +} diff --git a/src/ask/configAssist.ts b/src/ask/configAssist.ts new file mode 100644 index 00000000..5b23b196 --- /dev/null +++ b/src/ask/configAssist.ts @@ -0,0 +1,351 @@ +import {basename, extname, isAbsolute, join, resolve} from 'node:path'; +import {displayConfigPath, type ConfigFormat, type ConfigTarget} from './configTargets.js'; +import {flattenJson, inspectFile, parseJsonc, planAppend, planCreate, planJsonSet, planKeyValueSet, planReplace, renderEditCommand, type FileEditPlan, type FileFacts, type PlanResult} from './fileEdit.js'; +import type {AskContext, AskOutcome, AskReferents, CommandBlock} from './types.js'; +import {chooseFormatter, jsonChecker, pythonChecker, repairJson, repairPythonBracket, repairPythonIndent} from './repair.js'; +import {installProposal} from './commands.js'; + +/** + * Config files and verified edits for Ask, all deterministic: + * resolve the file (request, conversation, domain, then asking) → inspect it → + * build a verified plan → preview the diff → render a guarded command → + * Run only after the final Yes. Opening is navigation. Removal is not offered. + */ + +export interface FileAssistEnvironment { + inspect(path: string): FileFacts; + /** Roots NMSh may write in: home and the project. */ + roots: readonly string[]; + runtimes: {python3?: string; node: string}; + /** Where an executable is, when installed (formatters); never runs it. */ + which?(name: string): string | undefined; + /** A curated install for an exact executable name (the /tools catalog). */ + install?(name: string): {tool: string; label: string} | undefined; +} + +type Target = ConfigTarget & {exists: boolean}; +const VAGUE = /^(?:my|the|that|this|our|a|its|it)?\s*$/u; + +function display(path: string, context: AskContext): string { return displayConfigPath(path, context.home); } + +/** The phrase that names the config: "open my zed config" → "zed"; "add this to my config" → "". */ +function configPhrase(text: string): string | undefined { + const match = /\b(?:config(?:uration)?|settings|rc file|dotfile)\b/u.exec(text); + if (!match) return undefined; + const stop = new Set(['open', 'show', 'view', 'edit', 'find', 'where', 'is', 'my', 'the', 'that', 'this', 'our', 'to', 'in', 'into', 'add', 'put', 'set', 'me', 'a', 'it', 'change', 'update', 'of']); + const words = text.slice(0, match.index).trim().split(/\s+/u).filter(Boolean); + const named: string[] = []; + for (let index = words.length - 1; index >= 0 && named.length < 2; index -= 1) { if (stop.has(words[index]!)) break; named.unshift(words[index]!); } + return named.join(' '); +} + +function formatOfPath(path: string): ConfigFormat | 'source' { + const extension = extname(path).toLowerCase(); + if (extension === '.json') return /tsconfig|jsconfig|settings|keymap|devcontainer/u.test(basename(path)) ? 'jsonc' : 'json'; + if (extension === '.jsonc') return 'jsonc'; + if (extension === '.toml') return 'toml'; + if (/^\.(?:zshrc|bashrc|bash_profile|profile|zprofile)$/u.test(basename(path)) || extension === '.fish' || extension === '.sh') return 'shell'; + if (/\.(?:py|ts|tsx|js|jsx|mjs|go|rs|rb|java|c|h|cpp|swift|kt|lua)$/u.test(extension)) return 'source'; + return 'text'; +} + +/** An explicit existing path in the request (relative to the working folder), if any. */ +function explicitPath(raw: string, context: AskContext, env: FileAssistEnvironment): string | undefined { + for (const token of raw.split(/\s+/u)) { + const word = token.replace(/^["'`(]+|["'`),:;.]+$/gu, ''); + if (!/[./]/u.test(word) || word.length < 3 || /^https?:/u.test(word)) continue; + const path = word.startsWith('~/') ? join(context.home, word.slice(2)) : isAbsolute(word) ? word : resolve(context.cwd, word); + if (env.inspect(path).content !== undefined || env.inspect(path).refusal !== 'does not exist') return path; + } + return undefined; +} + +type Resolution = {kind: 'target'; target: Target} | {kind: 'path'; path: string} | {kind: 'choose'; candidates: Target[]} | {kind: 'none'; reason: string}; + +/** + * Which config: an explicit path, a named target, the conversation's config, + * a strong domain (shell words → the shell's config), recent files; otherwise + * ask among the configs that actually exist (never everything NMSh knows). + */ +function resolveTarget(raw: string, text: string, context: AskContext, env: FileAssistEnvironment): Resolution { + const path = explicitPath(raw, context, env); + if (path) return {kind: 'path', path}; + const targets = context.configs ?? []; + const phrase = configPhrase(text) ?? ''; + const named = (words: string) => { + let best: Target[] = []; + let length = 0; + for (const target of targets) { + for (const alias of target.aliases) { + if (!new RegExp(`(?:^|\\s)${alias.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}(?:\\s|$)`, 'u').test(` ${words} `)) continue; + if (alias.length > length) { best = [target]; length = alias.length; } else if (alias.length === length && !best.includes(target)) best.push(target); + } + } + return best; + }; + const byName = named(text.replace(/\bconfig(?:uration)?\b|\bsettings\b/gu, ' ').trim() || phrase); + if (byName.length) { + const present = byName.filter(target => target.exists); + // "project config" with several project files, "git config" (user or repository): ask among the real ones. + if (byName.length > 1) { + const domain = (present.length ? present : byName).filter(target => target.domain?.test(text)); + if (domain.length === 1) return {kind: 'target', target: domain[0]!}; + const fromConversation = (present.length ? present : byName).find(target => target.id === context.referents?.config?.id); + if (fromConversation) return {kind: 'target', target: fromConversation}; + return present.length === 1 ? {kind: 'target', target: present[0]!} : {kind: 'choose', candidates: present.length ? present : byName}; + } + return {kind: 'target', target: byName[0]!}; + } + if (!VAGUE.test(phrase) && phrase) return {kind: 'none', reason: `I don't know a "${phrase}" config here.`}; + // Vague ("my config"): the conversation first, then strong domain words, then a recent config file. + const conversation = context.referents?.config && targets.find(target => target.id === context.referents!.config!.id); + if (conversation) return {kind: 'target', target: conversation}; + if (context.referents?.file) return {kind: 'path', path: context.referents.file}; + const domain = targets.filter(target => target.exists && target.domain && target.scope !== 'nmsh' && target.domain.test(text)); + if (domain.length === 1) return {kind: 'target', target: domain[0]!}; + const recent = targets.filter(target => target.exists && context.recentFiles.some(file => file === target.path)); + if (recent.length === 1) return {kind: 'target', target: recent[0]!}; + const present = targets.filter(target => target.exists && target.id !== 'zedKeymap' && target.scope !== 'git'); + if (present.length === 1) return {kind: 'target', target: present[0]!}; + if (!present.length) return {kind: 'none', reason: 'I couldn\'t find a config file here.'}; + return {kind: 'choose', candidates: present}; +} + +function remember(target: Target | undefined, path: string): AskReferents { + return {...(target ? {config: {id: target.id, label: target.label, path}} : {}), file: path}; +} + +function openOutcome(path: string, label: string, context: AskContext, target?: Target): AskOutcome { + return {kind: 'proposal', capability: 'file.open', safety: 'navigate', confidence: 0.95, text: `Opening ${label} (${display(path, context)}) in ${context.editor.label}.`, + action: {kind: 'openFile', path}, referents: remember(target, path)}; +} + +function missingOutcome(target: Target, context: AskContext, env: FileAssistEnvironment): AskOutcome { + const text = `${target.label} doesn't exist yet at ${display(target.path!, context)}.`; + if (!target.create) return {kind: 'answer', capability: 'file.open', text, referents: {config: {id: target.id, label: target.label, path: target.path!}}}; + const created = planCreate(target.path!, target.create, env.roots); + return {kind: 'answer', capability: 'file.open', text, referents: {config: {id: target.id, label: target.label, path: target.path!}}, + next: created.kind === 'plan' ? [{key: `create:${target.id}`, label: `Create it`, outcome: planOutcome(created.plan, context, env, '', target)}] : []}; +} + +function chooseTarget(candidates: Target[], context: AskContext, then: (target: Target) => AskOutcome): AskOutcome { + return {kind: 'choose', reason: 'ambiguous', capability: 'file.open', question: 'Which config do you mean?', + options: candidates.slice(0, 6).map(target => ({key: `config:${target.id}`, label: target.label, detail: display(target.path!, context), outcome: then(target)}))}; +} + +/** A verified plan as an answer: file (and real target for a symlink), what changes, the diff, and the guarded command. */ +function planOutcome(plan: FileEditPlan, context: AskContext, env: FileAssistEnvironment, content: string, target?: Target): AskOutcome { + const where = `${display(plan.path, context)}${plan.symlink ? ` → ${display(plan.resolvedPath, context)} (a symlink; the target file is what changes)` : ''}`; + const script = renderEditCommand(plan, content, env.runtimes); + const block: CommandBlock = {argv: [], script, provenance: 'context', risk: 'mutate', note: 'Checks the file is unchanged since Ask read it, then writes atomically; it changes nothing if the file changed.', + run: {kind: 'applyEdit', plan}}; + return {kind: 'answer', capability: 'file.open', text: `${where} · line ${plan.line}\n${plan.reason}\n\n${plan.preview.join('\n')}`, block, + referents: {...remember(target, plan.path), block}}; +} + +function resultOutcome(result: PlanResult, facts: FileFacts, context: AskContext, env: FileAssistEnvironment, target: Target | undefined, retry?: (start: number) => AskOutcome): AskOutcome { + if (result.kind === 'plan') return planOutcome(result.plan, context, env, facts.content ?? '', target); + if (result.kind === 'noop') return {kind: 'answer', capability: 'file.open', text: result.reason, referents: remember(target, facts.path)}; + if (result.kind === 'matches') { + return {kind: 'choose', reason: 'ambiguous', capability: 'file.open', question: `${result.reason} Which one?`, + options: result.matches.slice(0, 8).map(match => ({key: `match:${match.start}`, label: `line ${match.line} · ${match.preview}`, ...(retry ? {outcome: retry(match.start)} : {})}))}; + } + return {kind: 'answer', capability: 'file.open', text: result.reason, referents: remember(target, facts.path)}; +} + +/** The snippet a request carries: after a newline or colon, or a {...} object, or a quoted line. */ +export function snippetOf(raw: string): string | undefined { + const newline = raw.indexOf('\n'); + if (newline !== -1) return raw.slice(newline + 1).replace(/^\s*\n/u, '').replace(/\s+$/u, '') || undefined; + const object = /(\{[\s\S]*\})\s*$/u.exec(raw); + if (object) return object[1]; + const colon = /:\s+(\S[\s\S]*)$/u.exec(raw); + if (colon && !/^(?:\/\/|https?)/u.test(colon[1]!)) return colon[1]; + const quoted = /["“]([^"”]+)["”]/u.exec(raw) ?? /`([^`]+)`/u.exec(raw); + return quoted?.[1]; +} + +/** "under terminal", "in the [tool.ruff] section", "under scripts". */ +function placementOf(text: string): string[] | undefined { + const match = /\b(?:under|in(?:to)?|inside)\s+(?:the\s+)?["`[]?([\w.-]+)["`\]]?\s*(?:section|table|object|key|block)?\b/u.exec(text); + if (!match || /^(?:my|this|that|the|config|settings|file|it)$/u.test(match[1]!)) return undefined; + return match[1]!.split('.'); +} + +/** Plan an addition/update of a snippet or key=value into a file, by format. */ +function planAdd(facts: FileFacts, format: ConfigFormat | 'source', snippet: string, placement: string[] | undefined): PlanResult | {kind: 'where'; reason: string} { + if (format === 'json' || format === 'jsonc') { + let parsed: unknown; + const trimmed = snippet.trim(); + try { parsed = parseJsonc(trimmed.startsWith('{') ? trimmed : `{${trimmed.replace(/,\s*$/u, '')}}`); } catch { + return {kind: 'refuse', reason: 'That snippet isn\'t valid JSON, so I can\'t place it safely. Paste it as "key": value pairs or a {...} object.'}; + } + const assignments = flattenJson(parsed); + if (!assignments.length) return {kind: 'refuse', reason: 'That snippet has no settings to add.'}; + return planJsonSet(facts, format, assignments, placement ?? []); + } + if (format === 'toml' || format === 'keyvalue') { + const lines = snippet.split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('#')); + const pairs = lines.map(line => /^([\w.@-]+)\s*=\s*(.+)$/u.exec(line)); + if (!pairs.length || pairs.some(pair => !pair)) return {kind: 'where', reason: 'I can add key = value lines to this file; that snippet isn\'t in that form.'}; + if (pairs.length > 1) return {kind: 'refuse', reason: 'Add one key = value line at a time to this file, so each is verified.'}; + const [, key, value] = pairs[0]!; + return planKeyValueSet(facts, format, key!, value!, format === 'toml' ? placement?.join('.') : undefined); + } + if (format === 'shell' || format === 'text') return planAppend(facts, format, snippet); + return {kind: 'where', reason: 'For source files, say exactly what to replace ("replace A with B in file").'}; +} + +/** "set foo to true", "set terminal.font_size = 14". */ +function setRequest(raw: string): {key: string; value: string} | undefined { + const match = /\bset\s+["'`]?([\w.@-]+)["'`]?\s+(?:to|=)\s+(.+?)(?:\s+(?:in|on)\s+(?:my |the )?[\w.~/+-]+(?: config(?:uration)?| settings)?)?\s*$/iu.exec(raw.split('\n')[0]!); + return match ? {key: match[1]!, value: match[2]!.replace(/^["'`](.*)["'`]$/u, '$1')} : undefined; +} + +/** + * Config and file requests; undefined leaves the request to the rest of Ask. + */ +export function resolveFileRequest(raw: string, text: string, context: AskContext, env?: FileAssistEnvironment): AskOutcome | undefined { + if (!env) return undefined; + const repaired = resolveRepair(raw, text, context, env); + if (repaired) return repaired; + const mentionsConfig = /\b(?:config(?:uration)?|settings|rc file|dotfile|zshrc|bashrc|config\.fish|gitconfig)\b/u.test(text); + const refs = context.referents; + // Removal is deliberately not part of this feature. + if (/^(?:please )?(?:remove|delete|erase|unset|drop|get rid of)\b/u.test(text) && (mentionsConfig || refs?.config || /\b(?:setting|line|key|block)\b/u.test(text))) { + return {kind: 'unsupported', text: 'Removing settings or lines isn\'t something Ask\'s verified editor does. Open the file to remove it yourself.'}; + } + // "show me the command instead": the current plan's command. + if (refs?.block?.script && /\b(?:show|give)(?: me)? the command\b|\bcommand instead\b/u.test(text)) { + return {kind: 'answer', capability: 'file.open', text: 'Here is the command for that edit. Copying or inserting it runs nothing.', block: refs.block}; + } + + // Replace exact text: "in foo.py replace x = 5 with x = 10", or a block form across lines. + const replace = /^(?:in\s+(\S+)\s+)?replace\s+([\s\S]+?)\s+with\s+([\s\S]+?)(?:\s+in\s+(\S+))?$/iu.exec(raw.trim()); + const block = /^replace (?:this|the|a)? ?(?:block|text|lines?)(?: in (\S+))?:?\s*\n([\s\S]+?)\n\s*with:?\s*\n([\s\S]+)$/iu.exec(raw.trim()); + if (block || (replace && !/\n/u.test(raw))) { + const file = block ? block[1] : replace![1] ?? replace![4]; + const oldText = block ? block[2]!.replace(/\s+$/u, '') : replace![2]!.replace(/^["'`](.*)["'`]$/u, '$1'); + const newText = block ? block[3]!.replace(/\s+$/u, '') : replace![3]!.replace(/^["'`](.*)["'`]$/u, '$1'); + const path = file ? (isAbsolute(file) ? file : file.startsWith('~/') ? join(context.home, file.slice(2)) : resolve(context.cwd, file)) : refs?.file; + if (!path) return {kind: 'choose', reason: 'missing', capability: 'file.open', question: 'In which file? Say "in replace … with …".', options: []}; + const facts = env.inspect(path); + const make = (start?: number): AskOutcome => resultOutcome(planReplace(env.inspect(path), oldText, newText, start), env.inspect(path), context, env, undefined, make); + return {...resultOutcome(planReplace(facts, oldText, newText), facts, context, env, undefined, make)}; + } + + const set = setRequest(raw); + const adding = /^(?:please |yeah,? |ok,? )?(?:add|put|insert|append|paste|place)\b/u.test(text) || /\b(?:add|put) (?:this|it|that)\b/u.test(text); + const changing = /^(?:change|configure|tweak|make|customi[sz]e|adjust|update)\b/u.test(text); + const opening = /^(?:please )?(?:open|show|view|edit|where is|where's|find)\b/u.test(text); + if (!mentionsConfig && !set && !(adding && (refs?.config || refs?.file || snippetOf(raw))) && !(opening && (refs?.config && /\b(?:it|that|this)\b/u.test(text)))) return undefined; + if (!set && !adding && !opening && !(changing && mentionsConfig)) return undefined; + + const resolution = resolveTarget(raw, text, context, env); + const withTarget = (handle: (target: Target | undefined, path: string, format: ConfigFormat | 'source') => AskOutcome): AskOutcome => { + if (resolution.kind === 'none') return {kind: 'answer', capability: 'file.open', text: resolution.reason}; + if (resolution.kind === 'choose') return chooseTarget(resolution.candidates, context, target => target.exists ? handle(target, target.path!, target.format) : missingOutcome(target, context, env)); + if (resolution.kind === 'path') { + const known = (context.configs ?? []).find(target => target.path === resolution.path); + return handle(known, resolution.path, known?.format ?? formatOfPath(resolution.path)); + } + if (!resolution.target.exists) return missingOutcome(resolution.target, context, env); + return handle(resolution.target, resolution.target.path!, resolution.target.format); + }; + + if (opening && !adding && !set) return withTarget((target, path) => openOutcome(path, target?.label ?? basename(path), context, target)); + + if (set || adding) { + const snippet = set ? undefined : snippetOf(raw) ?? refs?.snippet; + const placement = placementOf(text); + return withTarget((target, path, format) => { + const facts = env.inspect(path); + if (facts.refusal && facts.refusal !== 'does not exist') return {kind: 'answer', capability: 'file.open', text: `${display(path, context)} ${facts.refusal}.`}; + if (set) { + const result = format === 'json' || format === 'jsonc' + ? planJsonSet(facts, format, [{path: set.key.split('.'), value: (() => { try { return JSON.parse(set.value); } catch { return set.value; } })()}]) + : format === 'toml' || format === 'keyvalue' ? planKeyValueSet(facts, format, set.key.split('.').at(-1)!, set.value, format === 'toml' && set.key.includes('.') ? set.key.split('.').slice(0, -1).join('.') : undefined) + : {kind: 'refuse' as const, reason: `I can't set keys in ${basename(path)} safely; give the exact line to add instead.`}; + return resultOutcome(result, facts, context, env, target); + } + if (!snippet) { + return {kind: 'answer', capability: 'file.open', text: `What should I add to ${target?.label ?? basename(path)}? Paste the exact setting or lines.`, referents: remember(target, path)}; + } + const planned = planAdd(facts, format, snippet, placement); + if (planned.kind === 'where') return {kind: 'answer', capability: 'file.open', text: planned.reason, referents: {...remember(target, path), snippet}}; + return resultOutcome(planned, facts, context, env, target); + }); + } + // "change my Zed terminal settings" without the setting: where the file is, and what NMSh does not know. + return withTarget((target, path) => ({kind: 'answer', capability: 'file.open', + text: `${target?.label ?? basename(path)} is at ${display(path, context)}.${target?.schema ? '' : ' NMSh doesn\'t have verified knowledge of its setting keys, so I won\'t guess one. Give me the exact setting or snippet and I can add it safely, or open the file.'}`, + next: [{key: `open:${path}`, label: 'Open it', outcome: openOutcome(path, target?.label ?? basename(path), context, target)}], referents: remember(target, path)})); +} + +export function systemFileAssistEnvironment(home: string, projectRoot: string | undefined, python3: string | undefined, node: string, + which?: (name: string) => string | undefined, install?: FileAssistEnvironment['install']): FileAssistEnvironment { + const roots = [home, ...(projectRoot ? [projectRoot] : [])]; + return {inspect: path => inspectFile(path, roots), roots, runtimes: {...(python3 ? {python3} : {}), node}, ...(which ? {which} : {}), ...(install ? {install} : {})}; +} + + +/* ---------- verified repairs and formatters ---------- */ + +function fileFromRequest(raw: string, context: AskContext, env: FileAssistEnvironment): string | undefined { + return explicitPath(raw, context, env) ?? context.referents?.file; +} + +/** + * "fix the JSON in x.json", "this Python indentation is broken", "fix the + * missing ) in parser.py", "format foo.py". Only JSON/JSONC and Python have + * verified repairs; other languages get their formatter or an honest answer. + */ +function resolveRepair(raw: string, text: string, context: AskContext, env: FileAssistEnvironment): AskOutcome | undefined { + const formatting = /^(?:please )?(?:format|reformat|prettify|tidy)\b/u.test(text); + const repairing = /\b(?:fix|repair|correct|close|balance)\b/u.test(text) && /\b(?:json|syntax|brackets?|braces?|parens?|parenthes[ie]s|indent(?:ation|ed)?|missing [)\]}])/u.test(text) + || /\bindentation (?:is )?(?:broken|wrong|off)\b/u.test(text); + if (!formatting && !repairing) return undefined; + const path = fileFromRequest(raw, context, env); + if (!path) return {kind: 'choose', reason: 'missing', capability: 'file.open', question: `Which file? Name it, e.g. "${formatting ? 'format' : 'fix the indentation in'} src/app.py".`, options: []}; + const facts = env.inspect(path); + if (facts.content === undefined || facts.refusal) { + return {kind: 'answer', capability: 'file.open', text: `${display(path, context)} ${facts.refusal ?? 'cannot be read'}.`}; + } + const extension = extname(path).toLowerCase(); + if (formatting) { + if (/\b(?:these|those|this) lines\b|\blines? \d+/u.test(text)) { + return {kind: 'answer', capability: 'file.open', text: 'Formatters here format whole files, not a range. Say "format the file" to format all of it, or "fix the indentation" for a verified small repair.', referents: {file: path}}; + } + const formatter = chooseFormatter(path, context.repoRoot ?? context.cwd, name => env.which?.(name)); + if (!formatter) return {kind: 'answer', capability: 'file.open', text: `I don't have a configured formatter for ${basename(path)} here (Prettier and clang-format are used only with a project config).`, referents: {file: path}}; + if ('missing' in formatter) { + const recipe = formatter.missing.map(name => ({name, install: env.install?.(name)})).find(item => item.install); + return {kind: 'answer', capability: 'file.open', text: `${formatter.missing.join(' or ')} ${formatter.missing.length > 1 ? 'are' : 'is'} not installed, so I can't format ${basename(path)}.`, + ...(recipe ? {next: [{key: `install:${recipe.name}`, label: `Install ${recipe.name}`, outcome: installProposal(recipe.name, recipe.install!)}]} : {}), referents: {file: path}}; + } + const block: CommandBlock = {argv: formatter.argv, provenance: 'context', risk: 'mutate', note: formatter.note, run: {kind: 'format', argv: formatter.argv}}; + return {kind: 'answer', capability: 'file.open', text: `${formatter.name} rewrites ${display(path, context)} in place.`, block, referents: {file: path, block}}; + } + const jsonc = extension === '.jsonc' || ((context.configs ?? []).find(target => target.path === path)?.format === 'jsonc') || /tsconfig|jsconfig|settings\.json|keymap\.json/u.test(basename(path)); + const decorate = (result: ReturnType, validate: 'json' | 'jsonc' | 'python'): AskOutcome => { + if (result.kind === 'plan') result.plan.validate = validate; + return resultOutcome(result, facts, context, env, undefined); + }; + if (extension === '.json' || extension === '.jsonc' || jsonc) return decorate(repairJson(facts, Boolean(jsonc)), jsonc ? 'jsonc' : 'json'); + if (extension === '.py') { + const checker = pythonChecker(env.runtimes.python3); + const diagnostic = checker?.(facts.content); + const wantsIndent = /\bindent/u.test(text) || (diagnostic && !diagnostic.ok && /indent/u.test(diagnostic.message)); + if (diagnostic?.ok && !wantsIndent) return {kind: 'answer', capability: 'file.open', text: `${basename(path)} compiles without syntax errors.`, referents: {file: path}}; + return decorate(wantsIndent ? repairPythonIndent(facts, checker) : repairPythonBracket(facts, checker), 'python'); + } + return {kind: 'answer', capability: 'file.open', text: `I can make verified syntax repairs in JSON and Python files. For ${basename(path)}, try "format ${basename(path)}" if it has a formatter, or open it.`, referents: {file: path}}; +} + +/** Re-check a repaired file after a Run wrote it. */ +export function validateAfterWrite(plan: FileEditPlan, content: string, python3?: string): string | undefined { + if (!plan.validate) return undefined; + const result = plan.validate === 'python' ? pythonChecker(python3)?.(content) : jsonChecker(plan.validate === 'jsonc')(content); + if (!result) return undefined; + return result.ok ? 'It now parses cleanly.' : `It still reports: ${result.message}${result.line ? ` (line ${result.line})` : ''}. Nothing else was changed.`; +} diff --git a/src/ask/configTargets.ts b/src/ask/configTargets.ts new file mode 100644 index 00000000..38b63452 --- /dev/null +++ b/src/ask/configTargets.ts @@ -0,0 +1,117 @@ +import {existsSync, readdirSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {isAbsolute, join} from 'node:path'; +import {promptConfigurationPath} from '../configuration/paths.js'; +import type {ShellId} from '../shell/adapters/ShellAdapter.js'; + +/** + * Configuration files Ask can find, open and (with a verified plan) add to or + * update. Each target says where it lives (from NMSh's own path helpers or a + * small, documented per-app rule), its format, whether creating it is known to + * be valid, and whether NMSh knows its keys. Knowing where a file lives is not + * knowing its schema: only NMSh's own config has schema knowledge here. + */ + +export type ConfigFormat = 'json' | 'jsonc' | 'toml' | 'keyvalue' | 'shell' | 'text'; + +export interface ConfigTarget { + id: string; + label: string; + /** Words that name it in a request ("zed", "zed settings", "zshrc"). */ + aliases: string[]; + scope: 'nmsh' | 'app' | 'shell' | 'project' | 'git'; + format: ConfigFormat; + /** The path NMSh expects, whether or not it exists; undefined when this target does not apply here. */ + path: string | undefined; + /** Initial content when NMSh knows creating the file there is valid. */ + create?: string; + /** NMSh ships knowledge of this file's keys (only its own config). */ + schema: boolean; + /** Words that make this target the subject of a conversation ("terminal" is Zed's or Ghostty's only with their name). */ + domain?: RegExp; +} + +export interface ConfigEnvironment { + home: string; + env: NodeJS.ProcessEnv; + platform: NodeJS.Platform; + shell: ShellId; + /** Repository root or working directory for project files. */ + projectRoot?: string; + exists(path: string): boolean; + list(directory: string): string[]; +} + +export function systemConfigEnvironment(shell: ShellId, projectRoot?: string): ConfigEnvironment { + return {home: homedir(), env: process.env, platform: process.platform, shell, ...(projectRoot ? {projectRoot} : {}), + exists: path => existsSync(path), list: directory => { try { return readdirSync(directory); } catch { return []; } }}; +} + +const xdg = (environment: ConfigEnvironment) => environment.env.XDG_CONFIG_HOME && isAbsolute(environment.env.XDG_CONFIG_HOME) + ? environment.env.XDG_CONFIG_HOME : join(environment.home, '.config'); + +/** The user's own startup file for a shell (never NMSh's private bootstrap). */ +export function shellConfigPath(shell: ShellId, environment: Pick): string { + if (shell === 'fish') return join(environment.env.XDG_CONFIG_HOME && isAbsolute(environment.env.XDG_CONFIG_HOME) ? environment.env.XDG_CONFIG_HOME : join(environment.home, '.config'), 'fish', 'config.fish'); + if (shell === 'bash') return join(environment.home, '.bashrc'); + const zdotdir = environment.env.ZDOTDIR && isAbsolute(environment.env.ZDOTDIR) && !environment.env.ZDOTDIR.includes('nmsh') ? environment.env.ZDOTDIR : environment.home; + return join(zdotdir, '.zshrc'); +} + +/** Project files recognized by exact name, only when they exist in the project root (bounded: one directory). */ +const PROJECT_FILES: ReadonlyArray<{name: string; format: ConfigFormat; aliases: string[]; domain?: RegExp}> = [ + {name: 'package.json', format: 'json', aliases: ['package', 'package.json', 'npm config', 'node config'], domain: /\b(?:npm|scripts?|dependencies|node)\b/u}, + {name: 'tsconfig.json', format: 'jsonc', aliases: ['tsconfig', 'tsconfig.json', 'typescript config'], domain: /\b(?:typescript|compiler ?options|tsc)\b/u}, + {name: 'jsconfig.json', format: 'jsonc', aliases: ['jsconfig', 'jsconfig.json']}, + {name: 'deno.json', format: 'jsonc', aliases: ['deno config', 'deno.json']}, + {name: 'pyproject.toml', format: 'toml', aliases: ['pyproject', 'pyproject.toml', 'python config'], domain: /\b(?:python|poetry|ruff|black|pytest)\b/u}, + {name: 'Cargo.toml', format: 'toml', aliases: ['cargo', 'cargo.toml', 'rust config'], domain: /\b(?:rust|cargo|crate)\b/u}, + {name: '.prettierrc', format: 'jsonc', aliases: ['prettier config', '.prettierrc']}, + {name: '.prettierrc.json', format: 'json', aliases: ['prettier config', '.prettierrc.json']}, + {name: '.editorconfig', format: 'text', aliases: ['editorconfig', '.editorconfig']}, + {name: '.npmrc', format: 'keyvalue', aliases: ['npmrc', '.npmrc']}, +]; + +/** Every target that applies here (existing or not), in a stable order. */ +export function configTargets(environment: ConfigEnvironment): ConfigTarget[] { + const config = xdg(environment); + const darwin = environment.platform === 'darwin'; + const targets: ConfigTarget[] = [ + {id: 'nmsh', label: 'NMSh config', aliases: ['nmsh', 'nmsh config', 'notmyshell', 'nmsh settings'], scope: 'nmsh', format: 'json', path: promptConfigurationPath(environment.env), schema: true, + domain: /\b(?:nmsh|notmyshell|chroma|prompt|transcript|composer)\b/u}, + {id: 'shell', label: `${environment.shell} config`, aliases: [environment.shell, `${environment.shell}rc`, `.${environment.shell}rc`, 'shell', 'shell config', 'rc file', 'dotfile', ...(environment.shell === 'fish' ? ['config.fish'] : [])], + scope: 'shell', format: 'shell', path: shellConfigPath(environment.shell, environment), schema: false, + domain: /\b(?:alias(?:es)?|export|path|shell|zsh|bash|fish|rc|env(?:ironment)? var)/u}, + // Zed reads ~/.config/zed on macOS and Linux; settings.json is JSON with comments. + {id: 'zed', label: 'Zed settings', aliases: ['zed', 'zed settings', 'zed config', 'zed terminal', 'zed terminal config', 'zed terminal settings'], scope: 'app', format: 'jsonc', + path: join(config, 'zed', 'settings.json'), create: '{\n}\n', schema: false, domain: /\bzed\b/u}, + {id: 'zedKeymap', label: 'Zed keybindings', aliases: ['zed keymap', 'zed keybindings', 'zed keys', 'zed shortcuts'], scope: 'app', format: 'jsonc', path: join(config, 'zed', 'keymap.json'), schema: false, domain: /\bzed\b.*\b(?:key|shortcut)/u}, + {id: 'vscode', label: 'VS Code settings', aliases: ['vscode', 'vs code', 'code settings', 'vscode settings', 'vs code settings'], scope: 'app', format: 'jsonc', + path: darwin ? join(environment.home, 'Library', 'Application Support', 'Code', 'User', 'settings.json') : join(config, 'Code', 'User', 'settings.json'), schema: false, domain: /\b(?:vs ?code)\b/u}, + {id: 'ghostty', label: 'Ghostty config', aliases: ['ghostty', 'ghostty config', 'ghostty settings'], scope: 'app', format: 'keyvalue', + path: [join(config, 'ghostty', 'config'), ...(darwin ? [join(environment.home, 'Library', 'Application Support', 'com.mitchellh.ghostty', 'config')] : [])].find(path => environment.exists(path)) ?? join(config, 'ghostty', 'config'), + schema: false, domain: /\bghostty\b/u}, + {id: 'gitUser', label: 'Git config (user)', aliases: ['git config', 'gitconfig', '.gitconfig', 'global git config', 'user git config'], scope: 'git', format: 'text', + path: environment.exists(join(environment.home, '.gitconfig')) || !environment.exists(join(config, 'git', 'config')) ? join(environment.home, '.gitconfig') : join(config, 'git', 'config'), schema: false}, + ]; + if (environment.projectRoot) { + if (environment.exists(join(environment.projectRoot, '.git', 'config'))) { + targets.push({id: 'gitRepo', label: 'Git config (this repository)', aliases: ['git config', 'repo git config', 'repository git config', 'local git config'], scope: 'git', format: 'text', + path: join(environment.projectRoot, '.git', 'config'), schema: false}); + } + const names = new Set(environment.list(environment.projectRoot)); + for (const file of PROJECT_FILES) { + if (!names.has(file.name)) continue; + targets.push({id: `project:${file.name}`, label: file.name, aliases: [...file.aliases, 'project config', 'project'], scope: 'project', format: file.format, + path: join(environment.projectRoot, file.name), schema: false, ...(file.domain ? {domain: file.domain} : {})}); + } + } + return targets; +} + +export const existing = (targets: readonly ConfigTarget[], environment: ConfigEnvironment) => targets.filter(target => target.path && environment.exists(target.path)); + +/** Shown paths: ~ for home. */ +export function displayConfigPath(path: string, home: string): string { + return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path; +} diff --git a/src/ask/failure.ts b/src/ask/failure.ts new file mode 100644 index 00000000..c490d966 --- /dev/null +++ b/src/ask/failure.ts @@ -0,0 +1,182 @@ +import {existsSync} from 'node:fs'; +import {isAbsolute, resolve} from 'node:path'; +import {redact} from '../understanding/tasks.js'; +import type {AskContext, AskOption, AskOutcome} from './types.js'; + +/** + * "Why did this fail?" from the failed block's own facts: the command, its + * exit status and a bounded, redacted excerpt of its output (never the whole + * log), read deterministically. Recognizers name what they actually see + * (a missing command, a permission error, a failing test, a compiler + * diagnostic, a Git refusal, a package-manager error). "Likely issue" appears + * only when a recognizer supports it; otherwise Ask says it can point at the + * failing diagnostic but not the root cause. Nothing is run. + */ +export interface FailureFacts { + command: string; + exitCode: number; + /** Bounded output tail (the app passes at most a few hundred lines). */ + output: string; + cwd?: string; + startId?: number; +} + +export interface Diagnosis { + kind: string; + /** What was seen, quoted from the output (bounded). */ + evidence: string[]; + /** Only when the evidence supports it. */ + likely?: string; + /** A real file the diagnostic points at (validated to exist), with a line. */ + location?: {path: string; line?: number}; + next?: AskOption[]; +} + +const MAX_LINES = 400; +const MAX_EVIDENCE = 8; + +const clean = (text: string) => text.replace(/\u001b\[[0-?]*[ -/]*[@-~]/gu, '').replace(/\r/gu, ''); + +/** The bounded, redacted tail a diagnosis may read. */ +export function failureExcerpt(output: string, lines = MAX_LINES): string[] { + return clean(output).split('\n').slice(-lines).map(line => redact(line).slice(0, 300)); +} + +function located(path: string, cwd: string | undefined, line?: number): Diagnosis['location'] | undefined { + const full = isAbsolute(path) ? path : resolve(cwd ?? '.', path); + return existsSync(full) ? {path: full, ...(line ? {line} : {})} : undefined; +} + +type Recognizer = (lines: string[], facts: FailureFacts) => Diagnosis | undefined; + +const RECOGNIZERS: Recognizer[] = [ + // zsh / bash / fish: command not found. + (lines, facts) => { + // zsh: "zsh: command not found: vhs"; bash: "bash: vhs: command not found"; fish: "Unknown command: vhs". + const hit = lines.map(line => /command not found: ([\w.+-]+)/u.exec(line) ?? /^(?:\S+: )?(?:line \d+: )?([\w.+-]+): command not found/u.exec(line) ?? /Unknown command:? '?([\w.+-]+)'?/u.exec(line)).find(Boolean); + const name = hit?.slice(1).find(Boolean); + if (!name && facts.exitCode !== 127) return undefined; + return {kind: 'command-not-found', evidence: hit ? [hit[0]] : [`exit 127`], ...(name ? {likely: `${name} isn't installed or isn't on PATH in this shell.`, + next: [{key: `fail:what:${name}`, label: `What is ${name}?`, refine: `what is ${name}`}, {key: `fail:install:${name}`, label: `Install ${name}`, refine: `install ${name}`}]} : {})}; + }, + // Permission denied. + (lines, facts) => { + const hit = lines.find(line => /permission denied|EACCES|Operation not permitted/iu.test(line)); + if (!hit && facts.exitCode !== 126) return undefined; + const path = /(?:permission denied:?\s*|open ')([^'\s]+)/iu.exec(hit ?? '')?.[1]; + return {kind: 'permission', evidence: [hit ?? 'exit 126'], likely: path ? `${path} can't be read or executed by you (check its permissions: ls -l, chmod).` : 'Something was not readable, writable or executable for you.'}; + }, + // npm/pnpm/yarn: missing script, missing module, dependency resolution. + (lines) => { + const script = lines.map(line => /Missing script: "?([\w:.-]+)"?/u.exec(line)).find(Boolean); + if (script) return {kind: 'missing-script', evidence: [script[0]], likely: `package.json has no "${script[1]}" script.`, next: [{key: 'fail:scripts', label: 'Show the project\'s scripts', refine: 'what scripts does this project have'}]}; + const module = lines.map(line => /Cannot find (?:module|package) '([^']+)'/u.exec(line)).find(Boolean); + if (module) return {kind: 'missing-module', evidence: [module[0]], likely: module[1]!.startsWith('.') ? `The import path ${module[1]} doesn't resolve to a file.` : `${module[1]} isn't installed (dependencies may be missing).`}; + const eresolve = lines.find(line => /ERESOLVE|peer dep(?:endency)? conflict/iu.test(line)); + if (eresolve) return {kind: 'dependency-conflict', evidence: [eresolve], likely: 'npm could not resolve a peer-dependency conflict.'}; + return undefined; + }, + // Node TAP / node --test and Jest-style failing tests. + (lines, facts) => { + const failing = lines.filter(line => /^\s*not ok \d+ - /u.test(line) || /^\s*● .+ › .+/u.test(line) || /^\s*✕ /u.test(line)).slice(0, MAX_EVIDENCE); + if (!failing.length) return undefined; + const expected = lines.findIndex(line => /^\s*(?:Expected|expected):/u.test(line)); + const evidence = [...failing.map(line => line.trim()), ...(expected >= 0 ? lines.slice(expected, expected + 4).map(line => line.trim()) : [])]; + const location = lines.map(line => /(?:location: '|at .*?\(|^\s+at )([^'()\s]+\.(?:[cm]?[jt]sx?)):(\d+)/u.exec(line)).find(Boolean); + const where = location ? located(location[1]!.replace(/^file:\/\//u, ''), facts.cwd, Number(location[2])) : undefined; + return {kind: 'tests', evidence, ...(where ? {location: where} : {}), + next: [{key: 'fail:find', label: 'Find "not ok" in the transcript', refine: 'find not ok in the transcript'}]}; + }, + // TypeScript compiler diagnostics. + (lines, facts) => { + const errors = lines.map(line => /^(.+?\.[cm]?tsx?)[(:](\d+)[,:](\d+)\)?:?\s*(?:-\s*)?error (TS\d+): (.+)$/u.exec(line)).filter((match): match is RegExpExecArray => Boolean(match)); + if (!errors.length) return undefined; + const first = errors[0]!; + const where = located(first[1]!, facts.cwd, Number(first[2])); + return {kind: 'typescript', evidence: errors.slice(0, MAX_EVIDENCE).map(match => `${match[1]}:${match[2]} ${match[4]} ${match[5]}`), ...(where ? {location: where} : {}), + likely: `${errors.length} TypeScript error${errors.length === 1 ? '' : 's'}; the first is ${first[4]} in ${first[1]} line ${first[2]}.`}; + }, + // Python traceback. + (lines, facts) => { + const start = lines.findIndex(line => line.startsWith('Traceback (most recent call last)')); + if (start < 0) return undefined; + const tail = lines.slice(start).filter(line => line.trim()); + const error = tail.at(-1) ?? ''; + const frames = tail.map(line => /File "([^"]+)", line (\d+)/u.exec(line)).filter((match): match is RegExpExecArray => Boolean(match)); + const frame = frames.at(-1); + const where = frame ? located(frame[1]!, facts.cwd, Number(frame[2])) : undefined; + return {kind: 'python', evidence: [...(frame ? [frame[0]] : []), error], ...(where ? {location: where} : {}), ...(/^\w+(?:Error|Exception): /u.test(error) ? {likely: error} : {})}; + }, + // Rust and Go compiler errors. + (lines, facts) => { + // Rust: an error code, or a plain "error:" followed by a source location arrow (not Git's own "error:" lines). + const rust = lines.findIndex((line, index) => /^error\[E\d+\]: /u.test(line) || (/^error: /u.test(line) && lines.slice(index + 1, index + 3).some(next => /^\s+-->\s/u.test(next)))); + if (rust >= 0) { + const at = lines.slice(rust, rust + 4).map(line => /-->\s+([^:\s]+):(\d+):(\d+)/u.exec(line)).find(Boolean); + const where = at ? located(at[1]!, facts.cwd, Number(at[2])) : undefined; + return {kind: 'rust', evidence: lines.slice(rust, rust + 3).map(line => line.trim()), ...(where ? {location: where} : {}), likely: lines[rust]!.replace(/^error(?:\[E\d+\])?: /u, '')}; + } + const go = lines.map(line => /^(\S+\.go):(\d+):(\d+): (.+)$/u.exec(line)).find(Boolean); + if (go) { const where = located(go[1]!, facts.cwd, Number(go[2])); return {kind: 'go', evidence: [go[0]], ...(where ? {location: where} : {}), likely: go[4]}; } + return undefined; + }, + // Git refusals. + (lines, facts) => { + if (!/^\s*git\b/u.test(facts.command)) return undefined; + const rules: Array<[RegExp, string]> = [ + [/not a git repository/u, 'This folder is not inside a Git repository.'], + [/\[rejected\].*\(fetch first\)|non-fast-forward|Updates were rejected/u, 'The remote has commits you don\'t have yet: pull (or fetch and rebase) before pushing.'], + [/would be overwritten by (?:merge|checkout)/u, 'Local changes would be overwritten: commit or stash them first.'], + [/CONFLICT \(/u, 'The merge stopped on conflicts; resolve the conflicted files, then git add them.'], + [/has no upstream branch/u, 'This branch has no upstream yet: push it with -u once.'], + [/Authentication failed|Permission denied \(publickey\)/u, 'Git could not authenticate with the remote.'], + [/pathspec '([^']+)' did not match/u, 'A path or branch name given to Git does not exist.'], + ]; + for (const [pattern, likely] of rules) { + const hit = lines.find(line => pattern.test(line)); + if (hit) return {kind: 'git', evidence: [hit.trim()], likely, next: [{key: 'fail:git', label: 'Show Git status', refine: 'what changed'}]}; + } + const fatal = lines.find(line => /^(?:fatal|error): /u.test(line)); + return fatal ? {kind: 'git', evidence: [fatal]} : undefined; + }, + // No such file. + (lines) => { + const hit = lines.find(line => /No such file or directory|ENOENT/u.test(line)); + return hit ? {kind: 'missing-path', evidence: [hit.trim()], likely: 'A file or directory it needed does not exist (check the path and the current folder).'} : undefined; + }, +]; + +/** The generic fallback: the lines that look like errors, nearest the end. */ +function genericEvidence(lines: string[]): string[] { + const errorish = lines.filter(line => /\b(?:error|failed|fatal|exception|denied|refused|cannot|can't|unable)\b/iu.test(line)); + return (errorish.length ? errorish : lines.filter(line => line.trim())).slice(-5).map(line => line.trim()); +} + +export function diagnose(facts: FailureFacts): Diagnosis { + const lines = failureExcerpt(facts.output); + for (const recognize of RECOGNIZERS) { + const found = recognize(lines, facts); + if (found) return {...found, evidence: found.evidence.slice(0, MAX_EVIDENCE)}; + } + return {kind: 'unknown', evidence: genericEvidence(lines)}; +} + +/** The Ask answer for a failed block. */ +export function failureOutcome(facts: FailureFacts, context: Pick): AskOutcome { + const diagnosis = diagnose(facts); + const lines = [`Why it failed`, '', `${facts.command}`, `exited with code ${facts.exitCode}.`]; + if (diagnosis.evidence.length) lines.push('', diagnosis.kind === 'tests' ? 'The test run reported:' : 'The output says:', ...diagnosis.evidence.map(line => ` ${line}`)); + lines.push('', diagnosis.likely ? `Likely issue\n ${diagnosis.likely}` : 'I can identify the failing diagnostic, but not the root cause yet.'); + const next: AskOption[] = [...(diagnosis.next ?? [])]; + if (diagnosis.location) { + const where = `${diagnosis.location.path}${diagnosis.location.line ? `:${diagnosis.location.line}` : ''}`; + next.unshift({key: `fail:open:${where}`, label: `Open ${where.split('/').slice(-2).join('/')} in ${context.editor.label}`, + outcome: {kind: 'proposal', capability: 'file.open', safety: 'navigate', confidence: 0.95, direct: true, text: `Opening ${where}.`, action: {kind: 'openFile', path: diagnosis.location.path}}}); + } + return {kind: 'answer', capability: 'help.command', text: lines.join('\n'), next: next.slice(0, 4), + block: {argv: [facts.command], literal: facts.command, provenance: 'context', risk: 'informational', note: 'The failed command (Copy/Insert to re-run it yourself).'}, + referents: {command: facts.command.split(/\s+/u).slice(0, 2), ...(diagnosis.location ? {file: diagnosis.location.path} : {})}}; +} + +/** Requests that ask why something failed. */ +export const WHY_FAILED = /\bwhy did (?:it|that|this|the (?:last )?command|[\w.+-]+) fail\b|\bwhat went wrong\b|\bexplain (?:this|that|the) (?:error|failure)\b|\bwhy (?:is|was) (?:it|that|this) (?:failing|broken)\b|\bexplain (?:the )?failure\b/u; diff --git a/src/ask/fileAssist.ts b/src/ask/fileAssist.ts new file mode 100644 index 00000000..732c5289 --- /dev/null +++ b/src/ask/fileAssist.ts @@ -0,0 +1,294 @@ +import {closeSync, openSync, readSync, statSync} from 'node:fs'; +import {basename, extname, isAbsolute, join, relative, resolve} from 'node:path'; +import {listDirectory, matchFiles, NOISY_DIRECTORIES} from './files.js'; +import type {AskContext, AskOption, AskOutcome} from './types.js'; + +/** + * The file capability family, all from real filesystem facts (names and + * kinds; contents only for an explicit "what is it" or "where is X in it"): + * + * file.list what is in a folder ("list files", "what files are here") + * file.browse a navigable list: folders open in place, files open in the editor + * file.find paths matching a name, extension or kind ("find tsconfig", "all typescript files") + * file.open one file, a folder (browsed), an ordinal referent, or bare "open" (the picker) + * + * Listed results become referents, so "open the second one" and "what is it" + * keep their meaning. Nothing here runs a command. + */ + +const PLACE = String.raw`(?:here|in here|(?:in |of )?(?:this|the|my|our|current|working)\s+(?:folder|directory|dir|repo|repository|project|codebase|workspace)|in (?:the )?repo|in (?:this|my) (?:repo|project)|current directory|cwd)`; +const FILE_NOUN = String.raw`(?:files?|everything|contents|stuff|things|entries|items)`; +const LIST_VERB = String.raw`(?:list|ls|show|see|display|view|print|what|which|tell me|browse|give me)`; +/** Words that make a request about Git state, transcripts or config rather than plain files. */ +const NOT_FILES = /\b(?:untracked|staged|unstaged|modified|changed|changes|diff|conflicts?|commits?|transcript|output|history|sessions?|config(?:uration)?|settings|providers?|brew|packages?|processes|running|ports?)\b/u; +const QUESTION = /^(?:please )?(?:how (?:do|can|would|should) i|how to|what does|what is the syntax|what's the syntax|explain)\b/u; + +const ORDINALS: Record = {first: 1, second: 2, third: 3, fourth: 4, fifth: 5, sixth: 6, seventh: 7, eighth: 8, ninth: 9, tenth: 10, + '1st': 1, '2nd': 2, '3rd': 3, '4th': 4, '5th': 5, '6th': 6, '7th': 7, '8th': 8, '9th': 9, '10th': 10}; + +/** "the second one", "number 3", "#2", "the last one" → 1-based index or 'last'. */ +export function ordinalIn(text: string): number | 'last' | undefined { + const word = /\bthe (first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth|\d+(?:st|nd|rd|th)) (?:one|file|item|entry|path|result|match)\b/u.exec(text) + ?? /\b(first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth) (?:one|file|item|entry|path|result|match)\b/u.exec(text); + if (word) return ORDINALS[word[1]!]; + const number = /\b(?:number|no\.?|#)\s*(\d{1,3})\b/u.exec(text) ?? /^(?:open|show|use|pick|take)\s+(\d{1,3})$/u.exec(text); + if (number) return Number(number[1]); + if (/\bthe last (?:one|file|item|entry|result)\b/u.test(text)) return 'last'; + return undefined; +} + +/** Kinds of file people name in words, by extension. */ +const KINDS: Array<{words: RegExp; label: string; extensions: string[]}> = [ + {words: /\btype ?script\b|\bts files?\b/u, label: 'TypeScript', extensions: ['.ts', '.tsx', '.mts', '.cts']}, + {words: /\bjava ?script\b|\bjs files?\b/u, label: 'JavaScript', extensions: ['.js', '.jsx', '.mjs', '.cjs']}, + {words: /\bpython\b|\bpy files?\b/u, label: 'Python', extensions: ['.py']}, + {words: /\brust\b|\brs files?\b/u, label: 'Rust', extensions: ['.rs']}, + {words: /\bgo files?\b|\bgolang\b/u, label: 'Go', extensions: ['.go']}, + {words: /\bmarkdown\b|\bmd files?\b|\bdocs?\b/u, label: 'Markdown', extensions: ['.md', '.mdx']}, + {words: /\bjson\b/u, label: 'JSON', extensions: ['.json', '.jsonc']}, + {words: /\byaml\b|\byml\b/u, label: 'YAML', extensions: ['.yaml', '.yml']}, + {words: /\btoml\b/u, label: 'TOML', extensions: ['.toml']}, + {words: /\bshell scripts?\b|\bsh files?\b/u, label: 'Shell', extensions: ['.sh', '.zsh', '.bash', '.fish']}, + {words: /\bimages?\b|\bpictures?\b/u, label: 'Image', extensions: ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp']}, + {words: /\btests?\b|\bspecs?\b/u, label: 'Test', extensions: []}, +]; + +const display = (path: string, context: AskContext): string => { + const base = context.repoRoot ?? context.cwd; + const rel = relative(base, path); + if (rel === '') return '.'; + if (!rel.startsWith('..') && !isAbsolute(rel)) return rel; + return path.startsWith(`${context.home}/`) ? `~${path.slice(context.home.length)}` : path; +}; +const home = (path: string, context: AskContext) => path === context.home ? '~' : path.startsWith(`${context.home}/`) ? `~${path.slice(context.home.length)}` : path; + +const isDirectory = (path: string) => { try { return statSync(path).isDirectory(); } catch { return false; } }; +const isFile = (path: string) => { try { return statSync(path).isFile(); } catch { return false; } }; + +export function openProposal(path: string, context: AskContext, direct = false): AskOutcome { + return {kind: 'proposal', capability: 'file.open', safety: 'navigate', confidence: 0.92, ...(direct ? {direct: true} : {}), + text: `Open ${display(path, context)} in ${context.editor.label}?`, action: {kind: 'openFile', path}, referents: {file: path}}; +} + +/** + * A folder as a navigable list (Ask's own picker): folders first, then files; + * ↑↓ select, Enter opens (a folder lists in place, a file opens in the + * editor), typing filters. The entries become the conversation's referents. + */ +export function browseOutcome(directory: string, context: AskContext, options: {hidden?: boolean; note?: string} = {}): AskOutcome { + const entries = listDirectory(directory, {hidden: options.hidden, limit: 400}) ?? []; + const paths = entries.map(entry => join(directory, entry.name)); + const where = home(directory, context); + const parent = resolve(directory, '..'); + const root = context.repoRoot ?? context.cwd; + const up: AskOption[] = directory !== root && directory !== '/' && !relative(root, directory).startsWith('..') + ? [{key: 'browse:..', label: '../', detail: 'up', refine: `browse ${parent}`}] : []; + if (!entries.length) { + return {kind: 'answer', capability: 'file.browse', text: `${where} is empty${options.hidden ? '' : ' (hidden files not shown)'}.`, referents: {files: {paths: [], kind: 'listed'}, directory}}; + } + const folders = entries.filter(entry => entry.directory).length; + const summary = `${entries.length - folders} file${entries.length - folders === 1 ? '' : 's'}, ${folders} folder${folders === 1 ? '' : 's'}${options.hidden ? ' (including hidden)' : ''}`; + return {kind: 'choose', reason: 'missing', capability: 'file.browse', question: `Files · ${where}\n${summary}${options.note ? ` · ${options.note}` : ''}`, + options: [...up, ...entries.map((entry, index) => { + const path = paths[index]!; + return entry.directory + ? {key: `browse:${path}`, label: `${entry.name}/`, refine: `browse ${path}`} + : {key: `file:${path}`, label: entry.name, outcome: openProposal(path, context, true)}; + })], + referents: {files: {paths, kind: 'listed'}, directory}}; +} + +/** Paths as a numbered, openable list (find results). */ +function listOutcome(title: string, paths: string[], context: AskContext, total = paths.length): AskOutcome { + return {kind: 'choose', reason: 'missing', capability: 'file.find', question: `${title}${total > paths.length ? ` · first ${paths.length} of ${total}` : ''}`, + options: paths.map(path => isDirectory(path) + ? {key: `browse:${path}`, label: `${display(path, context)}/`, refine: `browse ${path}`} + : {key: `file:${path}`, label: display(path, context), outcome: openProposal(path, context, true)}), + referents: {files: {paths, kind: 'listed'}}}; +} + +/** The folder a list request names: "here" is the working folder; "repo"/"project" is the repository root; "in src" is that folder. */ +function placeIn(text: string, raw: string, context: AskContext): string | undefined { + const named = /\b(?:in|inside|under|of)\s+([\w./~-]+\/?)\s*$/u.exec(text); + if (named && !/^(?:here|this|the|my|our|repo|repository|project|folder|directory|codebase|workspace)$/u.test(named[1]!)) { + const candidate = named[1]!.startsWith('~/') ? join(context.home, named[1]!.slice(2)) : resolve(context.cwd, named[1]!); + if (isDirectory(candidate)) return candidate; + const root = context.repoRoot ?? context.cwd; + const inRoot = resolve(root, named[1]!); + if (isDirectory(inRoot)) return inRoot; + } + void raw; + if (/\b(?:repo|repository|project|codebase|workspace)\b/u.test(text) && context.repoRoot) return context.repoRoot; + return undefined; +} + +/** Strip the verb and filler from a find/open request: what remains names the file. */ +function nameIn(text: string, verb: RegExp): string { + return text.replace(verb, ' ') + .replace(/\b(?:the|my|a|an|me|please|file|files|folder|directory|called|named|for|is|are|located|at|can you|could you|help|to|i|want|where|in (?:here|this (?:repo|project|folder))|in (?:zed|vs ?code|my editor|the editor))\b/gu, ' ') + .replace(/[?]/gu, '').replace(/\s+/gu, ' ').trim(); +} + +/** Bounded facts about one file for "what is it". */ +function describeFile(path: string, context: AskContext): AskOutcome { + let size = 0; + try { size = statSync(path).size; } catch { + return {kind: 'answer', capability: 'file.open', text: `${display(path, context)} no longer exists.`}; + } + if (isDirectory(path)) return browseOutcome(path, context); + const head = readHead(path, 64 * 1024); + const binary = head.includes('\u0000'); + const lines = binary ? undefined : head.split('\n').length - (head.endsWith('\n') ? 1 : 0); + const kind = KNOWN_FILES[basename(path)] ?? EXTENSIONS[extname(path).toLowerCase()] ?? (binary ? 'Binary file' : 'Text file'); + const sizeText = size < 1024 ? `${size} bytes` : size < 1024 * 1024 ? `${(size / 1024).toFixed(1)} KB` : `${(size / 1024 / 1024).toFixed(1)} MB`; + const lineText = lines === undefined ? '' : size > head.length ? ` · ${lines}+ lines` : ` · ${lines} line${lines === 1 ? '' : 's'}`; + return {kind: 'answer', capability: 'file.open', text: `${display(path, context)}\n${kind} · ${sizeText}${lineText}`, + follow: {key: `file:${path}`, label: `Open in ${context.editor.label}`, outcome: openProposal(path, context, true)}, referents: {file: path}}; +} + +function readHead(path: string, bytes: number): string { + let fd: number | undefined; + try { + fd = openSync(path, 'r'); + const buffer = Buffer.alloc(bytes); + const read = readSync(fd, buffer, 0, bytes, 0); + return buffer.subarray(0, read).toString('utf8'); + } catch { return ''; } finally { if (fd !== undefined) closeSync(fd); } +} + +const KNOWN_FILES: Record = {'package.json': 'Node.js package manifest (scripts, dependencies)', 'tsconfig.json': 'TypeScript compiler configuration', + 'README.md': 'Project readme (Markdown)', 'Cargo.toml': 'Rust package manifest', 'pyproject.toml': 'Python project configuration', 'go.mod': 'Go module definition', + 'Makefile': 'Make build rules', 'Dockerfile': 'Docker image definition', '.gitignore': 'Git ignore rules', 'package-lock.json': 'npm lockfile', + 'pnpm-lock.yaml': 'pnpm lockfile', 'yarn.lock': 'Yarn lockfile', 'bun.lockb': 'Bun lockfile', 'LICENSE': 'License text', 'AGENTS.md': 'Instructions for coding agents (Markdown)'}; +const EXTENSIONS: Record = {'.ts': 'TypeScript source', '.tsx': 'TypeScript (JSX) source', '.js': 'JavaScript source', '.mjs': 'JavaScript module', + '.json': 'JSON', '.md': 'Markdown', '.py': 'Python source', '.rs': 'Rust source', '.go': 'Go source', '.sh': 'Shell script', '.zsh': 'zsh script', '.fish': 'fish script', + '.toml': 'TOML', '.yaml': 'YAML', '.yml': 'YAML', '.css': 'CSS', '.html': 'HTML', '.png': 'PNG image', '.jpg': 'JPEG image', '.svg': 'SVG image', '.txt': 'Plain text'}; + +/** Lines in one file containing a literal term (bounded; the file is only read). */ +function searchIn(path: string, term: string, context: AskContext): AskOutcome { + const text = readHead(path, 2 * 1024 * 1024); + const needle = term.toLowerCase(); + const hits: string[] = []; + let count = 0; + text.split('\n').forEach((line, index) => { + if (!line.toLowerCase().includes(needle)) return; + count += 1; + if (hits.length < 12) hits.push(`${String(index + 1).padStart(5)} ${line.trim().slice(0, 120)}`); + }); + if (!count) return {kind: 'answer', capability: 'file.find', text: `"${term}" doesn't appear in ${display(path, context)}.`, referents: {file: path}}; + return {kind: 'answer', capability: 'file.find', text: `"${term}" in ${display(path, context)} · ${count} line${count === 1 ? '' : 's'}\n${hits.join('\n')}${count > hits.length ? `\n … ${count - hits.length} more` : ''}`, + follow: {key: `file:${path}`, label: `Open in ${context.editor.label}`, outcome: openProposal(path, context, true)}, referents: {file: path}}; +} + +/** Project files (bounded list) matching a kind or a name fragment. */ +function findPaths(context: AskContext, predicate: (relativePath: string) => boolean): {paths: string[]; total: number} { + const root = context.repoRoot ?? context.cwd; + const all = (context.files ?? []).filter(predicate); + return {paths: all.slice(0, 60).map(path => join(root, path)), total: all.length}; +} + +export function resolveFiles(text: string, raw: string, context: AskContext): AskOutcome | undefined { + if (QUESTION.test(text)) return undefined; + const referents = context.referents; + const listed = referents?.files?.paths ?? []; + + // "browse " (what folder entries refine to). + const browse = /^browse\s+(.+)$/u.exec(raw.trim()); + if (browse) { + const path = resolve(context.cwd, browse[1]!.trim()); + return isDirectory(path) ? browseOutcome(path, context) : undefined; + } + + // Ordinal referents: "open the second one", "what is the third one". + const ordinal = ordinalIn(text); + if (ordinal !== undefined && listed.length && /^(?:please )?(?:open|edit|view|show|use|pick|take|select|what(?: is)?|describe|go to|cd|browse|the|number)\b|\bone$/u.test(text)) { + const index = ordinal === 'last' ? listed.length - 1 : ordinal - 1; + const path = listed[index]; + if (!path) return {kind: 'answer', capability: 'file.open', text: `There are only ${listed.length} in the list.`}; + // Revalidated: the path must still exist before anything is done with it. + if (!isFile(path) && !isDirectory(path)) return {kind: 'answer', capability: 'file.open', text: `${display(path, context)} no longer exists.`}; + if (/^(?:what|describe)\b/u.test(text)) return describeFile(path, context); + if (isDirectory(path)) return browseOutcome(path, context); + return openProposal(path, context); + } + + // "show them": the files this conversation listed, again. + if (listed.length && /^(?:show|list|see|display)(?: me)? (?:them|those|these|the files|those files|these files)(?: again)?$/u.test(text)) return listOutcome(`${listed.length} file${listed.length === 1 ? '' : 's'}`, listed.slice(0, 60), context, listed.length); + // "what is it" / "what is that file": the file this conversation is about. + if (referents?.file && /^(?:what(?: is| does)?|describe|tell me about)\s+(?:it|that|this|that file|this file|it do)$/u.test(text)) return describeFile(referents.file, context); + // "show me where foo is in it", "find foo in it", "search it for foo". + if (referents?.file) { + const inside = /^(?:show(?: me)?|find|search|grep|where)\b.*?\b(?:where\s+)?["']?([^"'\s]+)["']?\s+(?:is|appears|shows up)?\s*(?:in|inside)\s+(?:it|that|this|that file|this file|there)$/u.exec(text) + ?? /^search (?:it|that|that file|this file) for\s+["']?([^"'\s]+)["']?$/u.exec(text); + if (inside && inside[1] && !/^(?:me|it|where)$/u.test(inside[1])) return searchIn(referents.file, inside[1], context); + } + + if (/\b(?:(?:can|could) (?:you|nmsh|ask) do|nmsh|features?|capabilit)/u.test(text)) return undefined; + if (NOT_FILES.test(text) && !/\b(?:named|called|matching)\b/u.test(text)) return undefined; + + // Bare "open", "open a file", "open something": the picker. + if (/^(?:please )?(?:open|pick|choose|edit)(?: (?:a|any|some) ?(?:file|thing)?|something| one| up)?$/u.test(text)) { + const root = context.repoRoot ?? context.cwd; + if (context.picker && context.picker !== 'native') { + return {kind: 'proposal', capability: 'file.open', safety: 'navigate', confidence: 0.9, direct: true, + text: `Pick a file in ${home(root, context)}.`, action: {kind: 'pickFile', root}}; + } + return browseOutcome(context.cwd, context, {note: 'type to filter'}); + } + + // Listing: "list files", "ls files", "show me the files here", "what files are in this folder", "list everything in this repo", "list hidden files". + const hidden = /\b(?:hidden|dot ?files|all)\b/u.test(text) && /\b(?:hidden|dot ?files)\b/u.test(text); + const listing = new RegExp(String.raw`^(?:please |can you |could you |help me |can you help me |i want to |let me )*(?:${LIST_VERB})\b.*\b${FILE_NOUN}\b`, 'u').test(text) + || new RegExp(String.raw`\b${FILE_NOUN}\b.*\b(?:are )?${PLACE}`, 'u').test(text) + || /^(?:ls|ll|la|dir|files)$/u.test(text) + || /^(?:open|show|see|view) (?:the |my )?files\b/u.test(text); + const kind = KINDS.find(item => item.words.test(text)); + if (listing && !kind) { + const directory = placeIn(text, raw, context) ?? context.cwd; + return browseOutcome(directory, context, {hidden}); + } + + // Finding by kind: "show me all typescript files", "find the tests". + if (kind && (listing || /^(?:find|locate|search for|where are)\b/u.test(text))) { + const predicate = kind.extensions.length ? (path: string) => kind.extensions.includes(extname(path).toLowerCase()) + : (path: string) => /(?:^|\/)(?:tests?|__tests__|spec)(?:\/|$)|\.(?:test|spec)\.[a-z]+$/u.test(path); + const {paths, total} = findPaths(context, predicate); + if (!total) return {kind: 'answer', capability: 'file.find', text: `No ${kind.label} files under ${home(context.repoRoot ?? context.cwd, context)}.`}; + return listOutcome(`${kind.label} files · ${total}`, paths, context, total); + } + + // "find files named config", "find tsconfig", "where is the readme", "locate package.json". + const find = /^(?:please )?(?:find|locate|search for|look for|where(?: is|'s| are)?|which file is)\b/u.test(text); + if (find) { + const explicitName = /\b(?:named|called|matching|with)\s+["']?([\w.*-]+)["']?/u.exec(text)?.[1]; + const query = explicitName ?? nameIn(text, /^(?:please )?(?:find|locate|search for|look for|where(?: is|'s| are)?|which file is)\b/u); + if (!query || query.split(' ').length > 3) return undefined; + const root = context.repoRoot ?? context.cwd; + const needle = query.toLowerCase().replace(/\*/gu, ''); + const byName = (context.files ?? []).filter(path => basename(path).toLowerCase().includes(needle)); + const matched = byName.length ? byName.map(path => join(root, path)) : matchFiles(query, context.files ?? [], context.cwd, root).map(match => match.path); + // Folders named like it too ("find src"). + const folders = (listDirectory(root) ?? []).filter(entry => entry.directory && entry.name.toLowerCase().includes(needle) && !NOISY_DIRECTORIES.has(entry.name)).map(entry => join(root, entry.name)); + const all = [...folders, ...matched]; + // A plain find that matches nothing is not a file request ("find error" searches the transcript). + if (!all.length) return explicitName ? {kind: 'answer', capability: 'file.find', text: `No file named like "${query}" under ${home(root, context)}.`} : undefined; + if (all.length === 1 && /^where/u.test(text)) { + return {kind: 'answer', capability: 'file.find', text: `${display(all[0]!, context)}`, follow: {key: `file:${all[0]}`, label: `Open in ${context.editor.label}`, outcome: openProposal(all[0]!, context, true)}, + referents: {file: all[0]!, files: {paths: all, kind: 'listed'}}}; + } + return listOutcome(`Matches for "${query}" · ${all.length}`, all.slice(0, 60), context, all.length); + } + + // "open src", "open the tests folder": a folder browses in place. + const open = /^(?:please )?(?:open|browse|go into|show)\s+(.+)$/u.exec(text); + if (open) { + const name = open[1]!.replace(/\b(?:the|my|folder|directory|dir)\b/gu, ' ').replace(/\s+/gu, ' ').trim(); + if (name && !/\s/u.test(name)) { + const candidate = name.startsWith('~/') ? join(context.home, name.slice(2)) : resolve(context.cwd, name); + const inRoot = context.repoRoot ? resolve(context.repoRoot, name) : candidate; + const directory = isDirectory(candidate) ? candidate : isDirectory(inRoot) ? inRoot : undefined; + if (directory) return browseOutcome(directory, context); + } + } + return undefined; +} diff --git a/src/ask/fileEdit.ts b/src/ask/fileEdit.ts new file mode 100644 index 00000000..af1a79b6 --- /dev/null +++ b/src/ask/fileEdit.ts @@ -0,0 +1,481 @@ +import {createHash} from 'node:crypto'; +import {chmodSync, mkdirSync, lstatSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync, accessSync, constants, unlinkSync} from 'node:fs'; +import {dirname, join} from 'node:path'; +import type {ConfigFormat} from './configTargets.js'; + +/** + * Verified file edits for Ask. A plan is built from the file's current + * content (exact spans, occurrence counts, parsed structure) before any + * command exists; the shown command and NMSh's own Run are both rendered from + * the plan, and both refuse to write if the file changed since it was read. + * Only additions, sets and updates: removal is not part of this feature. + */ + +export interface FileEdit {start: number; end: number; text: string} + +export interface FileEditPlan { + /** As named to the user (may be a symlink). */ + path: string; + /** The file actually written. */ + resolvedPath: string; + symlink: boolean; + format: ConfigFormat | 'source'; + operation: 'add' | 'set' | 'update' | 'replace' | 'append' | 'create'; + /** Non-overlapping edits against the inspected content (UTF-16 offsets). */ + edits: FileEdit[]; + /** sha256 of the inspected content: the stale-edit precondition. */ + expectedSha256: string; + /** sha256 the file has after the edit, to verify a Run. */ + resultSha256: string; + /** Bounded diff lines (" ", "- ", "+ " prefixes). */ + preview: string[]; + /** First changed line (1-based). */ + line: number; + /** Re-checked after a Run writes it (repairs). */ + validate?: 'json' | 'jsonc' | 'python'; + reason: string; +} + +export type PlanResult = + | {kind: 'plan'; plan: FileEditPlan} + | {kind: 'noop'; reason: string} + | {kind: 'matches'; reason: string; matches: Array<{line: number; preview: string; start: number; end: number}>} + | {kind: 'refuse'; reason: string}; + +export const sha256 = (text: string) => createHash('sha256').update(text, 'utf8').digest('hex'); +const SECRET = /(?:token|password|passwd|secret|api[_-]?key|auth|credential|private[_-]?key)/iu; + +/** Reading is bounded: config and source edits never need more. */ +export const MAX_EDIT_BYTES = 2 * 1024 * 1024; + +export interface FileFacts {path: string; resolvedPath: string; symlink: boolean; content?: string; writable: boolean; refusal?: string} + +/** What the file is, where a write would land, and whether NMSh may write it (home or project only, never escalating). */ +export function inspectFile(path: string, allowedRoots: readonly string[]): FileFacts { + let symlink = false; + let resolvedPath = path; + try { symlink = lstatSync(path).isSymbolicLink(); resolvedPath = realpathSync(path); } catch { return {path, resolvedPath, symlink, writable: false, refusal: 'does not exist'}; } + const inside = (candidate: string) => allowedRoots.some(root => candidate === root || candidate.startsWith(`${root}/`)); + if (!inside(resolvedPath)) return {path, resolvedPath, symlink, writable: false, refusal: `is outside your home folder and this project (${resolvedPath}); Ask won't edit it`}; + let content: string | undefined; + try { + if (statSync(resolvedPath).size > MAX_EDIT_BYTES) return {path, resolvedPath, symlink, writable: false, refusal: 'is too large for a verified edit'}; + content = readFileSync(resolvedPath, 'utf8'); + } catch { return {path, resolvedPath, symlink, writable: false, refusal: 'cannot be read'}; } + let writable = true; + try { accessSync(resolvedPath, constants.W_OK); accessSync(dirname(resolvedPath), constants.W_OK); } catch { writable = false; } + return {path, resolvedPath, symlink, content, writable, ...(writable ? {} : {refusal: 'is not writable by you; Ask won\'t change permissions or use sudo'})}; +} + +/* ---------- JSON / JSONC: a comment-aware scanner that records spans, never rewrites the file ---------- */ + +interface JsonMember {keyStart: number; valueStart: number; valueEnd: number} +interface JsonObjectSpan {open: number; close: number; members: number; lastValueEnd?: number} +export interface JsonScan {ok: boolean; error?: {offset: number; message: string}; members: Map; objects: Map} + +const keyOf = (path: readonly string[]) => path.join('\u0000'); + +export function scanJson(text: string, allowComments: boolean): JsonScan { + const members = new Map(); + const objects = new Map(); + let index = 0; + const fail = (message: string): JsonScan => ({ok: false, error: {offset: index, message}, members, objects}); + const skip = (): string | undefined => { + for (;;) { + while (index < text.length && /\s/u.test(text[index]!)) index += 1; + if (text.startsWith('//', index)) { + if (!allowComments) return 'comments are not allowed in strict JSON'; + while (index < text.length && text[index] !== '\n') index += 1; + } else if (text.startsWith('/*', index)) { + if (!allowComments) return 'comments are not allowed in strict JSON'; + const end = text.indexOf('*/', index + 2); + if (end === -1) return 'unterminated comment'; + index = end + 2; + } else return undefined; + } + }; + const string = (): string | undefined => { + const start = index; + index += 1; + while (index < text.length && text[index] !== '"') { if (text[index] === '\\') index += 1; if (text[index] === '\n') return undefined; index += 1; } + if (index >= text.length) return undefined; + index += 1; + try { return JSON.parse(text.slice(start, index)) as string; } catch { return undefined; } + }; + const value = (path: string[] | undefined): string | undefined => { + const error = skip(); + if (error) return error; + const char = text[index]; + if (char === '{') { + const open = index; + index += 1; + const span: JsonObjectSpan = {open, close: -1, members: 0}; + for (;;) { + const before = skip(); + if (before) return before; + if (text[index] === '}') break; + if (text[index] !== '"') return 'expected a property name'; + const keyStart = index; + const key = string(); + if (key === undefined) return 'malformed property name'; + const colon = skip(); + if (colon) return colon; + if (text[index] !== ':') return 'expected ":"'; + index += 1; + const leading = skip(); + if (leading) return leading; + const valueStart = index; + const child = path ? [...path, key] : undefined; + const nested = value(child); + if (nested) return nested; + if (child) { + const list = members.get(keyOf(child)) ?? []; + list.push({keyStart, valueStart, valueEnd: index}); + members.set(keyOf(child), list); + } + span.members += 1; + span.lastValueEnd = index; + const after = skip(); + if (after) return after; + if (text[index] === ',') { + index += 1; + const trailing = skip(); + if (trailing) return trailing; + if (text[index] === '}') { if (!allowComments) return 'trailing comma'; break; } + continue; + } + if (text[index] === '}') break; + return 'expected "," or "}"'; + } + span.close = index; + index += 1; + if (path) objects.set(keyOf(path), span); + return undefined; + } + if (char === '[') { + index += 1; + for (;;) { + const before = skip(); + if (before) return before; + if (text[index] === ']') { index += 1; return undefined; } + const item = value(undefined); + if (item) return item; + const after = skip(); + if (after) return after; + if (text[index] === ',') { index += 1; const trailing = skip(); if (trailing) return trailing; if (text[index] === ']') { if (!allowComments) return 'trailing comma'; index += 1; return undefined; } continue; } + if (text[index] === ']') { index += 1; return undefined; } + return 'expected "," or "]"'; + } + } + if (char === '"') return string() === undefined ? 'malformed string' : undefined; + const literal = /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/u.exec(text.slice(index)); + if (!literal) return 'unexpected token'; + index += literal[0].length; + return undefined; + }; + const error = value([]); + if (error) return fail(error); + const tail = skip(); + if (tail) return fail(tail); + if (index !== text.length) return fail('unexpected content after the top-level value'); + return {ok: true, members, objects}; +} + +/** Parse a JSONC fragment's value (comments and trailing commas removed by the scanner's rules). */ +export function parseJsonc(text: string): unknown { + let out = ''; + for (let index = 0; index < text.length; index += 1) { + const char = text[index]!; + if (char === '"') { const start = index; index += 1; while (index < text.length && text[index] !== '"') { if (text[index] === '\\') index += 1; index += 1; } out += text.slice(start, index + 1); continue; } + if (text.startsWith('//', index)) { while (index < text.length && text[index] !== '\n') index += 1; out += '\n'; continue; } + if (text.startsWith('/*', index)) { index = text.indexOf('*/', index + 2) + 1; continue; } + out += char; + } + return JSON.parse(out.replace(/,(\s*[}\]])/gu, '$1')); +} + +function indentUnit(text: string): string { + const match = /\n([ \t]+)\S/u.exec(text); + return match?.[1]?.startsWith('\t') ? '\t' : ' '.repeat(Math.min(8, match?.[1]?.length ?? 2)); +} +const lineIndentAt = (text: string, offset: number) => /^[ \t]*/u.exec(text.slice(text.lastIndexOf('\n', offset - 1) + 1))![0]; +const reindent = (json: string, indent: string) => json.split('\n').map((line, index) => index === 0 ? line : `${indent}${line}`).join('\n'); +const deepEqual = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); + +/** Leaf assignments of a snippet object: {"terminal": {"font_size": 14}} → [["terminal","font_size"], 14]. Arrays are values. */ +export function flattenJson(value: unknown, prefix: string[] = []): Array<{path: string[]; value: unknown}> { + if (value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length) { + return Object.entries(value as Record).flatMap(([key, child]) => flattenJson(child, [...prefix, key])); + } + return prefix.length ? [{path: prefix, value}] : []; +} + +/** Set one key path in JSON/JSONC text with a minimal span edit; the rest of the file is untouched. */ +function setJsonOnce(text: string, path: readonly string[], value: unknown, comments: boolean): {text: string; changed: 'add' | 'update' | 'none'} | {error: string} { + const scan = scanJson(text, comments); + if (!scan.ok) return {error: `it isn't valid ${comments ? 'JSON with comments' : 'JSON'} (${scan.error!.message} at line ${text.slice(0, scan.error!.offset).split('\n').length})`}; + if (!scan.objects.has('')) return {error: 'its top level is not an object'}; + const existing = scan.members.get(keyOf(path)) ?? []; + if (existing.length > 1) return {error: `"${path.join('.')}" appears ${existing.length} times; resolve the duplicates first`}; + const unit = indentUnit(text); + if (existing.length === 1) { + const member = existing[0]!; + let current: unknown; + try { current = parseJsonc(text.slice(member.valueStart, member.valueEnd)); } catch { current = undefined; } + if (deepEqual(current, value)) return {text, changed: 'none'}; + const replacement = reindent(JSON.stringify(value, null, unit), lineIndentAt(text, member.keyStart)); + return {text: text.slice(0, member.valueStart) + replacement + text.slice(member.valueEnd), changed: 'update'}; + } + // Deepest existing ancestor that is an object; a non-object ancestor value is a conflict. + let depth = path.length - 1; + while (depth > 0 && !scan.objects.has(keyOf(path.slice(0, depth)))) { + if (scan.members.has(keyOf(path.slice(0, depth)))) return {error: `"${path.slice(0, depth).join('.')}" exists but is not an object`}; + depth -= 1; + } + const parent = scan.objects.get(keyOf(path.slice(0, depth)))!; + let nested: unknown = value; + for (let index = path.length - 1; index > depth; index -= 1) nested = {[path[index]!]: nested}; + const parentIndent = lineIndentAt(text, parent.open); + const memberIndent = parentIndent + unit; + const member = `"${path[depth]}": ${reindent(JSON.stringify(nested, null, unit), memberIndent)}`; + if (!parent.members) { + return {text: `${text.slice(0, parent.open + 1)}\n${memberIndent}${member}\n${parentIndent}${text.slice(parent.close)}`, changed: 'add'}; + } + const between = text.slice(parent.lastValueEnd!, parent.close); + const trailingComma = /^\s*(?:\/\/[^\n]*\n\s*|\/\*[\s\S]*?\*\/\s*)*,/u.test(between); + // After an existing trailing comma (JSONC), the new member goes after it and keeps the file's trailing-comma style. + const insertAt = trailingComma ? parent.lastValueEnd! + between.indexOf(',') + 1 : parent.lastValueEnd!; + return {text: `${text.slice(0, insertAt)}${trailingComma ? '' : ','}\n${memberIndent}${member}${trailingComma ? ',' : ''}${text.slice(insertAt)}`, changed: 'add'}; +} + +/* ---------- key = value (Ghostty, .npmrc) and TOML tables ---------- */ + +function setKeyValueOnce(text: string, key: string, value: string, format: ConfigFormat, table?: string): {text: string; changed: 'add' | 'update' | 'none'} | {error: string} { + const lines = text.split('\n'); + const header = (line: string) => /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:#.*)?$/u.exec(line)?.[1]; + let start = 0; + let end = lines.length; + if (format === 'toml') { + if (table) { + start = lines.findIndex(line => header(line) === table); + if (start === -1) { + const block = `${text.endsWith('\n') || !text ? '' : '\n'}\n[${table}]\n${key} = ${value}\n`; + return {text: text + block, changed: 'add'}; + } + start += 1; + } + const next = lines.findIndex((line, index) => index >= start && header(line) !== undefined); + end = next === -1 ? lines.length : next; + } + const pattern = new RegExp(`^(\\s*${key.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}\\s*=\\s*)(.*?)(\\s*(?:#.*)?)$`, 'u'); + const hits = lines.map((line, index) => ({line, index})).filter(item => item.index >= start && item.index < end && pattern.test(item.line)); + // Ghostty allows repeatable keys; a second copy is ambiguous for "set". + if (hits.length > 1) return {error: `${key} is set ${hits.length} times; resolve that first`}; + if (hits.length === 1) { + const match = pattern.exec(hits[0]!.line)!; + if (match[2]!.trim() === value) return {text, changed: 'none'}; + lines[hits[0]!.index] = `${match[1]}${value}${match[3]}`; + return {text: lines.join('\n'), changed: 'update'}; + } + let insertAt = end; + while (insertAt > start && lines[insertAt - 1]!.trim() === '') insertAt -= 1; + lines.splice(insertAt, 0, `${key} = ${value}`); + return {text: lines.join('\n'), changed: 'add'}; +} + +export function tomlValue(raw: string): string { + const value = raw.trim(); + if (/^(?:true|false|-?\d+(?:\.\d+)?|".*"|'.*'|\[.*\]|\{.*\})$/u.test(value)) return value; + return JSON.stringify(value); +} + +/* ---------- plans ---------- */ + +/** One minimal edit from old → new text: the changed region only. */ +function minimalEdit(before: string, after: string): FileEdit { + let start = 0; + while (start < before.length && start < after.length && before[start] === after[start]) start += 1; + let endBefore = before.length; + let endAfter = after.length; + while (endBefore > start && endAfter > start && before[endBefore - 1] === after[endAfter - 1]) { endBefore -= 1; endAfter -= 1; } + return {start, end: endBefore, text: after.slice(start, endAfter)}; +} + +const mask = (line: string) => SECRET.test(line) ? line.replace(/([=:]\s*).*/u, '$1••••') : line; + +/** A bounded diff around the change: one context line each side, unrelated secret-looking values masked. */ +export function previewDiff(before: string, after: string, edit: FileEdit, limit = 24): {lines: string[]; line: number} { + const startLine = before.slice(0, edit.start).split('\n').length - 1; + const oldLines = before.split('\n'); + const newLines = after.split('\n'); + const oldEnd = before.slice(0, edit.end).split('\n').length; + const newEnd = after.slice(0, edit.start + edit.text.length).split('\n').length; + const out: string[] = []; + if (startLine > 0) out.push(` ${mask(oldLines[startLine - 1]!)}`); + for (const line of oldLines.slice(startLine, oldEnd)) out.push(`- ${line}`); + for (const line of newLines.slice(startLine, newEnd)) out.push(`+ ${line}`); + if (oldEnd < oldLines.length) out.push(` ${mask(oldLines[oldEnd]!)}`); + // Values that look secret are masked everywhere except the lines the person is adding. + for (let index = 0; index < out.length; index += 1) if (!out[index]!.startsWith('+ ')) out[index] = `${out[index]!.slice(0, 2)}${mask(out[index]!.slice(2))}`; + const bounded = out.length > limit ? [...out.slice(0, limit), ` … ${out.length - limit} more lines`] : out; + return {lines: bounded, line: startLine + 1}; +} + +function plan(facts: FileFacts, format: FileEditPlan['format'], operation: FileEditPlan['operation'], after: string, reason: string): PlanResult { + const before = facts.content!; + if (after === before) return {kind: 'noop', reason}; + const edit = minimalEdit(before, after); + const diff = previewDiff(before, after, edit); + return {kind: 'plan', plan: {path: facts.path, resolvedPath: facts.resolvedPath, symlink: facts.symlink, format, operation, edits: [edit], + expectedSha256: sha256(before), resultSha256: sha256(after), preview: diff.lines, line: diff.line, reason}}; +} + +const unusable = (facts: FileFacts): PlanResult | undefined => facts.content === undefined || facts.refusal ? {kind: 'refuse', reason: `${facts.path} ${facts.refusal ?? 'cannot be read'}.`} : undefined; + +/** Merge a JSON/JSONC snippet (or one key path) into a file: add, update or no-op per leaf; duplicates refuse. */ +export function planJsonSet(facts: FileFacts, format: 'json' | 'jsonc', assignments: Array<{path: string[]; value: unknown}>, prefix: string[] = []): PlanResult { + const blocked = unusable(facts); + if (blocked) return blocked; + let text = facts.content!; + const changes: string[] = []; + for (const assignment of assignments) { + const path = [...prefix, ...assignment.path]; + const result = setJsonOnce(text, path, assignment.value, format === 'jsonc'); + if ('error' in result) return {kind: 'refuse', reason: `${facts.path}: ${result.error}.`}; + if (result.changed !== 'none') changes.push(`${result.changed === 'add' ? 'adds' : 'updates'} ${path.join('.')}`); + text = result.text; + } + if (!changes.length) return {kind: 'noop', reason: `${facts.path} already has ${assignments.length === 1 ? 'that setting' : 'those settings'}; nothing to change.`}; + return plan(facts, format, changes.every(change => change.startsWith('adds')) ? 'add' : 'update', text, changes.join(', ')); +} + +export function planKeyValueSet(facts: FileFacts, format: ConfigFormat, key: string, value: string, table?: string): PlanResult { + const blocked = unusable(facts); + if (blocked) return blocked; + const result = setKeyValueOnce(facts.content!, key, format === 'toml' ? tomlValue(value) : value.trim(), format, table); + if ('error' in result) return {kind: 'refuse', reason: `${facts.path}: ${result.error}.`}; + if (result.changed === 'none') return {kind: 'noop', reason: `${facts.path} already sets ${key} = ${value.trim()}; nothing to change.`}; + return plan(facts, format, result.changed === 'add' ? 'add' : 'update', result.text, `${result.changed === 'add' ? 'adds' : 'updates'} ${table ? `${table}.` : ''}${key}`); +} + +/** Append a line or block (shell rc, plain text) unless it is already there exactly. */ +export function planAppend(facts: FileFacts, format: ConfigFormat, block: string): PlanResult { + const blocked = unusable(facts); + if (blocked) return blocked; + const content = facts.content!; + const wanted = block.replace(/\s+$/u, ''); + if (!wanted) return {kind: 'refuse', reason: 'There is nothing to add.'}; + const lines = content.split('\n').map(line => line.trimEnd()); + const blockLines = wanted.split('\n').map(line => line.trimEnd()); + const present = lines.some((_, index) => blockLines.every((line, offset) => lines[index + offset] === line)); + if (present) return {kind: 'noop', reason: `${facts.path} already contains that; nothing to add.`}; + const separator = content === '' || content.endsWith('\n\n') ? '' : content.endsWith('\n') ? '\n' : '\n\n'; + return plan(facts, format, 'append', `${content}${separator}${wanted}\n`, `appends ${blockLines.length} line${blockLines.length === 1 ? '' : 's'} at the end`); +} + +const lineOf = (text: string, offset: number) => text.slice(0, offset).split('\n').length; + +/** Replace exact text: one occurrence becomes a plan; none or several are reported, never guessed. */ +export function planReplace(facts: FileFacts, oldText: string, newText: string, occurrence?: number): PlanResult { + const blocked = unusable(facts); + if (blocked) return blocked; + const content = facts.content!; + if (!oldText) return {kind: 'refuse', reason: 'Say exactly what text to replace.'}; + const starts: number[] = []; + for (let at = content.indexOf(oldText); at !== -1; at = content.indexOf(oldText, at + 1)) starts.push(at); + if (!starts.length) return {kind: 'refuse', reason: `That exact text is not in ${facts.path}${/\n/u.test(oldText) ? ' (whitespace and indentation must match)' : ''}.`}; + if (starts.length > 1 && occurrence === undefined) { + return {kind: 'matches', reason: `I found ${starts.length} matches in ${facts.path}.`, matches: starts.map(start => ({line: lineOf(content, start), start, end: start + oldText.length, + preview: content.slice(content.lastIndexOf('\n', start - 1) + 1, content.indexOf('\n', start) === -1 ? undefined : content.indexOf('\n', start)).trim().slice(0, 80)}))}; + } + const start = occurrence !== undefined ? starts.find(item => item === occurrence) : starts[0]; + if (start === undefined) return {kind: 'refuse', reason: `${facts.path} changed; that match is no longer there.`}; + const after = content.slice(0, start) + newText + content.slice(start + oldText.length); + return plan(facts, 'source', 'replace', after, `replaces ${starts.length > 1 ? `the match at line ${lineOf(content, start)}` : 'the only match'}`); +} + +/** Create a missing file with known-valid initial content (only for targets that declare it); refuses if it now exists. */ +export function planCreate(path: string, content: string, allowedRoots: readonly string[]): PlanResult { + if (!allowedRoots.some(root => path.startsWith(`${root}/`))) return {kind: 'refuse', reason: `${path} is outside your home folder; Ask won't create it.`}; + const lines = content.replace(/\n$/u, '').split('\n').map(line => `+ ${line}`); + return {kind: 'plan', plan: {path, resolvedPath: path, symlink: false, format: 'json', operation: 'create', edits: [{start: 0, end: 0, text: content}], + expectedSha256: ABSENT, resultSha256: sha256(content), preview: lines, line: 1, reason: 'creates the file'}}; +} + +/** The precondition for a create: the file must still not exist. */ +export const ABSENT = 'absent'; + +/* ---------- applying and rendering ---------- */ + +/** NMSh's own Run: the same plan, applied without a shell. Refuses if the file changed; atomic rename; mode kept. */ +export function applyPlan(plan: FileEditPlan): {ok: true} | {ok: false; reason: string} { + if (plan.expectedSha256 === ABSENT) { + try { + mkdirSync(dirname(plan.resolvedPath), {recursive: true}); + writeFileSync(plan.resolvedPath, plan.edits[0]!.text, {encoding: 'utf8', flag: 'wx', mode: 0o644}); + return {ok: true}; + } catch (error) { + return {ok: false, reason: (error as NodeJS.ErrnoException).code === 'EEXIST' ? `${plan.path} now exists, so nothing was written. Ask again to inspect it.` : `Creating ${plan.path} failed; nothing was written.`}; + } + } + let current: string; + try { current = readFileSync(plan.resolvedPath, 'utf8'); } catch { return {ok: false, reason: `${plan.path} can no longer be read.`}; } + if (sha256(current) !== plan.expectedSha256) return {ok: false, reason: `${plan.path} changed since Ask read it, so nothing was written. Ask again to re-inspect it.`}; + let next = current; + for (const edit of [...plan.edits].sort((a, b) => b.start - a.start)) next = next.slice(0, edit.start) + edit.text + next.slice(edit.end); + if (sha256(next) !== plan.resultSha256) return {ok: false, reason: 'The edit did not produce the previewed result, so nothing was written.'}; + const temporary = join(dirname(plan.resolvedPath), `.nmsh-edit-${process.pid}-${Date.now()}`); + try { + const mode = statSync(plan.resolvedPath).mode & 0o7777; + writeFileSync(temporary, next, {encoding: 'utf8', mode}); + chmodSync(temporary, mode); + renameSync(temporary, plan.resolvedPath); + } catch (error) { + try { unlinkSync(temporary); } catch { /* nothing left behind */ } + return {ok: false, reason: `Writing ${plan.path} failed: ${error instanceof Error ? error.message : String(error)}. The file was not changed.`}; + } + return {ok: true}; +} + +/** UTF-16 offsets to code points, for the Python backend. */ +const codePoints = (text: string, offset: number) => [...text.slice(0, offset)].length; + +/** + * The command a person can copy or insert: a small script that checks the + * sha256 precondition, applies exactly the planned edits, keeps the mode and + * renames atomically; it exits non-zero without writing if the file changed. + * Single-quoted and free of quotes and backslashes inside, so it works the + * same in zsh, Bash and Fish. Python 3 when present, else NMSh's own Node. + */ +export function renderEditCommand(plan: FileEditPlan, content: string, runtimes: {python3?: string; node: string}): string { + if (runtimes.python3) { + const payload = Buffer.from(JSON.stringify({path: plan.resolvedPath, sha256: plan.expectedSha256, + edits: plan.edits.map(edit => ({start: codePoints(content, edit.start), end: codePoints(content, edit.end), text: edit.text}))})).toString('base64'); + return [`python3 -c '`, + 'import base64, hashlib, json, os, sys, tempfile', + `p = json.loads(base64.b64decode("${payload}"))`, + 'path = p["path"]', + 'if p["sha256"] == "absent" and os.path.exists(path): sys.exit("nmsh: " + path + " now exists; nothing was written")', + 'if p["sha256"] == "absent": os.makedirs(os.path.dirname(path), exist_ok=True); open(path, "x", encoding="utf-8").write(p["edits"][0]["text"]); print("nmsh: created " + path); sys.exit(0)', + 'data = open(path, "rb").read()', + 'if hashlib.sha256(data).hexdigest() != p["sha256"]: sys.exit("nmsh: " + path + " changed since it was inspected; nothing was written")', + 'text = data.decode("utf-8")', + 'for e in sorted(p["edits"], key=lambda e: e["start"], reverse=True): text = text[:e["start"]] + e["text"] + text[e["end"]:]', + 'mode = os.stat(path).st_mode & 0o7777', + 'fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), prefix=".nmsh-edit-")', + 'f = os.fdopen(fd, "w", encoding="utf-8", newline=""); f.write(text); f.close()', + 'os.chmod(tmp, mode); os.replace(tmp, path); print("nmsh: updated " + path)', + `'`].join('\n'); + } + const payload = Buffer.from(JSON.stringify({path: plan.resolvedPath, sha256: plan.expectedSha256, edits: plan.edits})).toString('base64'); + return [`${/^[\w@%+=:,./-]+$/u.test(runtimes.node) ? runtimes.node : `"${runtimes.node.replace(/["$`]/gu, '')}"`} -e '`, + 'const fs = require("fs"), path = require("path"), crypto = require("crypto");', + `const p = JSON.parse(Buffer.from("${payload}", "base64").toString());`, + 'if (p.sha256 === "absent") { fs.mkdirSync(path.dirname(p.path), {recursive: true}); fs.writeFileSync(p.path, p.edits[0].text, {flag: "wx"}); console.log("nmsh: created " + p.path); process.exit(0); }', + 'const data = fs.readFileSync(p.path, "utf8");', + 'if (crypto.createHash("sha256").update(data, "utf8").digest("hex") !== p.sha256) { console.error("nmsh: " + p.path + " changed since it was inspected; nothing was written"); process.exit(1); }', + 'let text = data; for (const e of [...p.edits].sort((a, b) => b.start - a.start)) text = text.slice(0, e.start) + e.text + text.slice(e.end);', + 'const mode = fs.statSync(p.path).mode & 0o7777, tmp = path.join(path.dirname(p.path), ".nmsh-edit-" + process.pid);', + 'fs.writeFileSync(tmp, text, {mode}); fs.chmodSync(tmp, mode); fs.renameSync(tmp, p.path); console.log("nmsh: updated " + p.path);', + `'`].join('\n'); +} diff --git a/src/ask/files.ts b/src/ask/files.ts new file mode 100644 index 00000000..07b43829 --- /dev/null +++ b/src/ask/files.ts @@ -0,0 +1,134 @@ +import {existsSync, readdirSync, statSync} from 'node:fs'; +import {isAbsolute, join, relative, resolve} from 'node:path'; + +/** Never descended into unless the request names them explicitly. */ +export const NOISY_DIRECTORIES = new Set(['.git', 'node_modules', 'dist', 'build', 'out', 'coverage', 'target', 'vendor', '.next', '.cache', + '.turbo', '.venv', 'venv', '__pycache__', '.idea', '.vscode', 'bower_components', '.gradle', 'Pods', '.terraform']); +const MAX_ENTRIES = 6000; +const MAX_DEPTH = 7; + +/** + * Files under one project root, breadth-first and bounded: never the whole + * home directory, never noisy trees. Names only; no file is read. + */ +export function listProjectFiles(root: string, limit = MAX_ENTRIES): string[] { + const files: string[] = []; + const queue: Array<[string, number]> = [[root, 0]]; + let seen = 0; + while (queue.length && seen < limit) { + const [directory, depth] = queue.shift()!; + let entries: import('node:fs').Dirent[]; + try { entries = readdirSync(directory, {withFileTypes: true}); } catch { continue; } + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + if (++seen > limit) break; + const path = join(directory, entry.name); + if (entry.isDirectory()) { + if (!NOISY_DIRECTORIES.has(entry.name) && depth < MAX_DEPTH) queue.push([path, depth + 1]); + } else if (entry.isFile()) files.push(relative(root, path)); + } + } + return files; +} + +export type FileMatch = {path: string; score: number; reason: 'exact' | 'basename' | 'case' | 'fuzzy'}; +/** A fuzzy match this far ahead of the next one is a clear winner. */ +export const CLEAR_LEAD = 0.08; + +const words = (text: string) => text.toLowerCase().split(/[^a-z0-9]+/u).filter(Boolean); + +/** + * Rank files for a spoken or typed name: exact path, exact file name, + * case-insensitive name, then strong fuzzy matches (every word of the query + * appears in the path, in order). Only files that exist are ever returned. + */ +export function matchFiles(query: string, files: readonly string[], cwd: string, root: string): FileMatch[] { + const trimmed = query.trim().replace(/^["']|["']$/gu, ''); + if (!trimmed) return []; + const direct = isAbsolute(trimmed) ? trimmed : resolve(cwd, trimmed); + try { if (existsSync(direct) && statSync(direct).isFile()) return [{path: direct, score: 1, reason: 'exact'}]; } catch { /* fall through */ } + const queryWords = words(trimmed); + const joined = queryWords.join(''); + const matches: FileMatch[] = []; + for (const file of files) { + const base = file.slice(file.lastIndexOf('/') + 1); + const absolute = join(root, file); + if (base === trimmed || file === trimmed) { matches.push({path: absolute, score: 0.95, reason: 'basename'}); continue; } + if (base.toLowerCase() === trimmed.toLowerCase()) { matches.push({path: absolute, score: 0.9, reason: 'case'}); continue; } + // "package json" names package.json: the words of the base name, joined, equal the query's. + if (words(base).join('') === joined) { matches.push({path: absolute, score: 0.88, reason: 'case'}); continue; } + const pathWords = words(file); + let position = 0; + let ok = queryWords.length > 0; + for (const word of queryWords) { + const found = pathWords.findIndex((candidate, index) => index >= position && candidate.startsWith(word)); + if (found === -1) { ok = false; break; } + position = found + 1; + } + if (ok) { + // Whole-word matches, matches in the file name and shorter paths rank higher. + const inBase = queryWords.some(word => words(base).some(candidate => candidate.startsWith(word))); + const whole = queryWords.filter(word => pathWords.includes(word)).length / queryWords.length; + matches.push({path: absolute, score: 0.5 + (inBase ? 0.15 : 0) + whole * 0.2 - Math.min(0.2, pathWords.length * 0.01), reason: 'fuzzy'}); + } + } + return matches.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path)).slice(0, 8); +} + +export interface DirectoryEntry {name: string; directory: boolean} + +/** One directory's entries (names and kinds only), directories first, hidden entries only when asked. Bounded. */ +export function listDirectory(path: string, options: {hidden?: boolean; limit?: number} = {}): DirectoryEntry[] | undefined { + let entries: import('node:fs').Dirent[]; + try { entries = readdirSync(path, {withFileTypes: true}); } catch { return undefined; } + return entries + .filter(entry => options.hidden || !entry.name.startsWith('.')) + .map(entry => { + let directory = entry.isDirectory(); + if (entry.isSymbolicLink()) { try { directory = statSync(join(path, entry.name)).isDirectory(); } catch { /* dangling link: a file */ } } + return {name: entry.name, directory}; + }) + .sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name)) + .slice(0, options.limit ?? 500); +} + +export interface PathCompletion { + /** The text with the completed word (unique match or longest common prefix). */ + text: string; + caret: number; + /** Every candidate when more than one remains (relative to the typed directory). */ + candidates: Array<{value: string; directory: boolean}>; +} + +/** + * Complete the path word before the caret from real directory entries, the + * same facts the shell composer's file completion lists. Only names are read; + * nothing is opened or run. + */ +export function completePath(text: string, caret: number, cwd: string, home: string): PathCompletion | undefined { + const chars = [...text]; + const before = chars.slice(0, caret).join(''); + const word = /(\S*)$/u.exec(before)?.[1] ?? ''; + const expanded = word.startsWith('~/') ? join(home, word.slice(2)) : word; + const slash = expanded.lastIndexOf('/'); + const directoryPart = slash >= 0 ? expanded.slice(0, slash + 1) : ''; + const prefix = slash >= 0 ? expanded.slice(slash + 1) : expanded; + const directory = isAbsolute(directoryPart) ? directoryPart || '/' : resolve(cwd, directoryPart || '.'); + const entries = listDirectory(directory, {hidden: prefix.startsWith('.')}); + if (!entries) return undefined; + const matches = entries.filter(entry => entry.name.startsWith(prefix)); + const loose = matches.length ? matches : entries.filter(entry => entry.name.toLowerCase().startsWith(prefix.toLowerCase())); + if (!loose.length) return undefined; + const typedDirectory = slash >= 0 ? word.slice(0, word.length - prefix.length) : ''; + let completion: string; + if (loose.length === 1) completion = loose[0]!.name + (loose[0]!.directory ? '/' : ' '); + else { + completion = loose[0]!.name; + for (const entry of loose) while (!entry.name.startsWith(completion)) completion = completion.slice(0, -1); + if (completion.length < prefix.length) completion = prefix; + } + const replaced = before.slice(0, before.length - word.length) + typedDirectory + completion; + const after = chars.slice(caret).join(''); + return {text: replaced + (loose.length === 1 && completion.endsWith(' ') && after.startsWith(' ') ? after.slice(1) : after), caret: [...replaced].length, + candidates: loose.length > 1 ? loose.slice(0, 40).map(entry => ({value: typedDirectory + entry.name + (entry.directory ? '/' : ''), directory: entry.directory})) : []}; +} diff --git a/src/ask/fuzzy.ts b/src/ask/fuzzy.ts new file mode 100644 index 00000000..401b6912 --- /dev/null +++ b/src/ask/fuzzy.ts @@ -0,0 +1,106 @@ +/** + * Conservative typo recovery for Ask: only against vocabulary NMSh already + * knows (command names and subcommands from the local catalog, NMSh concepts, + * slash commands, providers, shells, Ask's own verbs, and files in context). + * It never rewrites free text and never touches a word that is itself a + * known word or an existing file. A correction is used only when one + * candidate is clearly closer than every other. + */ + +/** Optimal-string-alignment distance (adjacent transpositions count once), stopping early past `limit`. */ +export function editDistance(a: string, b: string, limit = 3): number { + if (Math.abs(a.length - b.length) > limit) return limit + 1; + const rows: number[][] = Array.from({length: a.length + 1}, (_, i) => Array.from({length: b.length + 1}, (_, j) => (i === 0 ? j : j === 0 ? i : 0))); + for (let i = 1; i <= a.length; i += 1) { + let best = Infinity; + for (let j = 1; j <= b.length; j += 1) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + let value = Math.min(rows[i - 1]![j]! + 1, rows[i]![j - 1]! + 1, rows[i - 1]![j - 1]! + cost); + if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) value = Math.min(value, rows[i - 2]![j - 2]! + 1); + rows[i]![j] = value; + best = Math.min(best, value); + } + if (best > limit) return limit + 1; + } + return rows[a.length]![b.length]!; +} + +/** How many edits a word of this length may need: short words almost none ("gt" is never "git"). */ +export function allowedEdits(length: number): number { + return length <= 3 ? 1 : length <= 5 ? 1 : length <= 9 ? 2 : 3; +} + +/** + * The single clear correction for a word, or undefined. Three-letter words + * accept only a transposition ("gti" → "git"); longer words a small number of + * edits; the winner must beat the runner-up by at least one edit. + */ +export function correctWord(word: string, vocabulary: Iterable): string | undefined { + if (word.length < 3) return undefined; + const limit = allowedEdits(word.length); + let best: string | undefined; + let bestDistance = Infinity; + let second = Infinity; + for (const candidate of vocabulary) { + if (candidate === word) return undefined; + if (Math.abs(candidate.length - word.length) > limit || candidate[0] !== word[0] && !(word.length > 3 && candidate[1] === word[0] && candidate[0] === word[1])) continue; + const distance = editDistance(word, candidate, limit); + if (distance > limit) continue; + if (word.length === 3 && !(distance === 1 && candidate.length === 3 && [...candidate].sort().join('') === [...word].sort().join(''))) continue; + if (distance < bestDistance) { second = bestDistance; bestDistance = distance; best = candidate; } + else if (distance <= second && candidate !== best) second = distance; + } + return best && second > bestDistance ? best : undefined; +} + +export interface Correction {from: string; to: string} + +/** Ask's own words: verbs and nouns its capabilities understand (so "opne" is "open"). */ +export const ASK_WORDS = ['open', 'show', 'list', 'find', 'where', 'what', 'which', 'switch', 'change', 'install', 'uninstall', 'upgrade', 'search', 'resume', + 'files', 'file', 'folder', 'directory', 'status', 'branch', 'commit', 'push', 'pull', 'diff', 'staged', 'untracked', 'upstream', 'remote', 'sessions', 'session', + 'transcript', 'settings', 'theme', 'prompt', 'providers', 'provider', 'config', 'configuration', 'project', 'scripts', 'tests', 'server', 'running', 'processes', + 'memory', 'disk', 'usage', 'port', 'local', 'understanding', 'model', 'cursor', 'chroma', 'suggestions', 'completion', 'folding', 'homebrew', 'brew', 'packages', + 'outdated', 'syntax', 'examples', 'explain', 'stop', 'start', 'watch', 'doctor', 'appearance', 'dividers', 'history', 'everything', 'repository', 'repo']; + +export interface TypoVocabulary { + /** Words that are never corrected (known words, file names in context). */ + known: ReadonlySet; + /** Candidate corrections for natural-language words (Ask words, concept words, slash commands, providers, shells). */ + words: readonly string[]; + /** Known command names (catalog and specs) for the request's command word. */ + commands: () => readonly string[]; + /** Subcommands of a known command ("git" → status, push…). */ + subcommands: (command: string) => readonly string[]; +} + +/** + * Correct the words of a normalized request that are clearly typos of known + * vocabulary. Paths, flags, quoted text, numbers and any word that exists as + * a file in context are left alone. Returns undefined when nothing changed. + */ +export function correctRequest(text: string, vocabulary: TypoVocabulary): {text: string; corrections: Correction[]} | undefined { + if (/["'`]/u.test(text)) return undefined; + const words = text.split(' '); + const corrections: Correction[] = []; + const commandNames = new Set(); + words.forEach((word, index) => { + // "/provders": slash commands only against slash commands. + if (/^\/[a-z][a-z-]{2,30}$/u.test(word) && !vocabulary.known.has(word)) { + const slash = correctWord(word.slice(1), vocabulary.words.filter(item => item.startsWith('/')).map(item => item.slice(1))); + if (slash) { corrections.push({from: word, to: `/${slash}`}); words[index] = `/${slash}`; } + return; + } + if (!/^[a-z][a-z-]{2,30}$/u.test(word) || vocabulary.known.has(word)) return; + const previous = index > 0 ? words[index - 1]! : undefined; + // A subcommand of the command before it ("git statsu"), then a command name, then Ask's own words. + const subcommands = previous && (vocabulary.known.has(previous) || commandNames.has(previous)) ? vocabulary.subcommands(previous) : []; + let fixed = subcommands.length ? correctWord(word, subcommands) : undefined; + if (!fixed) fixed = correctWord(word, vocabulary.words.filter(item => !item.startsWith('/'))); + if (!fixed && (index === 0 || /^(?:run|use|what is|explain)$/u.test(previous ?? ''))) { + fixed = correctWord(word, vocabulary.commands()); + if (fixed) commandNames.add(fixed); + } + if (fixed) { corrections.push({from: word, to: fixed}); words[index] = fixed; } + }); + return corrections.length ? {text: words.join(' '), corrections} : undefined; +} diff --git a/src/ask/git.ts b/src/ask/git.ts new file mode 100644 index 00000000..570b697e --- /dev/null +++ b/src/ask/git.ts @@ -0,0 +1,90 @@ +import {execFile} from 'node:child_process'; +import type {AskWorktree} from './types.js'; + +/** Parse `git worktree list --porcelain`: factual paths and branches only. */ +export function parseWorktrees(porcelain: string, currentRoot?: string): AskWorktree[] { + const worktrees: AskWorktree[] = []; + let current: Partial | undefined; + for (const line of porcelain.split('\n')) { + if (line.startsWith('worktree ')) { + if (current?.path) worktrees.push(current as AskWorktree); + current = {path: line.slice(9), current: false}; + } else if (line.startsWith('branch ') && current) current.branch = line.slice(7).replace(/^refs\/heads\//u, ''); + } + if (current?.path) worktrees.push(current as AskWorktree); + for (const item of worktrees) item.current = item.path === currentRoot; + return worktrees; +} + +/** Read-only, fixed argv, bounded in time and size; failure means "no worktree facts". */ +export function gitWorktrees(cwd: string, currentRoot?: string): Promise { + return new Promise(resolve => { + execFile('git', ['-C', cwd, 'worktree', 'list', '--porcelain'], {timeout: 1500, maxBuffer: 256 * 1024, env: {...process.env, GIT_OPTIONAL_LOCKS: '0'}}, + (error, stdout) => resolve(error ? [] : parseWorktrees(stdout, currentRoot))); + }); +} + +/** Bounded, local Git facts for Ask. Nothing here contacts a remote or reads credentials. */ +export interface GitFacts { + branch?: string; + detached: boolean; + upstream?: string; + ahead?: number; + behind?: number; + remotes: string[]; + staged: string[]; + modified: string[]; + deleted: string[]; + renamed: string[]; + untracked: string[]; + conflicted: string[]; +} + +/** Paths kept per category; counts beyond this are reported as "and N more" by callers. */ +export const GIT_PATH_LIMIT = 200; + +/** + * Parse `git status --porcelain=v2 --branch -z` (machine format, not the + * human one). Each category is bounded; a staged-and-modified file appears in + * both, as Git reports it. + */ +export function parseStatusV2(output: string, remotes: readonly string[] = []): GitFacts { + const facts: GitFacts = {detached: false, remotes: [...remotes], staged: [], modified: [], deleted: [], renamed: [], untracked: [], conflicted: []}; + const push = (list: string[], path: string) => { if (list.length < GIT_PATH_LIMIT) list.push(path); }; + const records = output.split('\0'); + for (let index = 0; index < records.length; index += 1) { + const record = records[index]!; + if (record.startsWith('# branch.head ')) { + const head = record.slice(14); + if (head === '(detached)') facts.detached = true; else facts.branch = head; + } else if (record.startsWith('# branch.upstream ')) facts.upstream = record.slice(18); + else if (record.startsWith('# branch.ab ')) { + const match = /^\+(\d+) -(\d+)$/u.exec(record.slice(12)); + if (match) { facts.ahead = Number(match[1]); facts.behind = Number(match[2]); } + } else if (record.startsWith('1 ') || record.startsWith('2 ')) { + const fields = record.split(' '); + const xy = fields[1]!; + const path = fields.slice(record.startsWith('1 ') ? 8 : 9).join(' '); + if (record.startsWith('2 ')) { push(facts.renamed, path); index += 1; } + if (xy[0] !== '.') push(facts.staged, path); + if (xy[1] === 'M' || xy[1] === 'T') push(facts.modified, path); + if (xy[1] === 'D') push(facts.deleted, path); + } else if (record.startsWith('u ')) push(facts.conflicted, record.split(' ').slice(10).join(' ')); + else if (record.startsWith('? ')) push(facts.untracked, record.slice(2)); + } + return facts; +} + +const GIT_ENV = () => ({...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0'}); +function gitOutput(cwd: string, args: string[]): Promise { + return new Promise(resolve => { + execFile('git', ['-C', cwd, ...args], {timeout: 2000, maxBuffer: 1024 * 1024, env: GIT_ENV()}, (error, stdout) => resolve(error ? undefined : stdout)); + }); +} + +/** Local status and remote names only (fixed argv, read-only, no network); undefined outside a repository. */ +export async function readGitFacts(cwd: string): Promise { + const [status, remotes] = await Promise.all([gitOutput(cwd, ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=normal']), gitOutput(cwd, ['remote'])]); + if (status === undefined) return undefined; + return parseStatusV2(status, (remotes ?? '').split('\n').map(line => line.trim()).filter(line => /^[\w.@/-]+$/u.test(line))); +} diff --git a/src/ask/gitAssist.ts b/src/ask/gitAssist.ts new file mode 100644 index 00000000..3c22dd25 --- /dev/null +++ b/src/ask/gitAssist.ts @@ -0,0 +1,296 @@ +import {fishQuote, posixQuote, type ShellId} from '../shell/adapters/ShellAdapter.js'; +import type {CommandReference} from '../shell/CommandReference.js'; +import type {GitFacts} from './git.js'; +import type {AskContext, AskOption, AskOutcome, AskReferents, CommandBlock} from './types.js'; + +/** + * Git help from local facts: commands are built as argv from what Git + * reports (current branch, real remotes, listed paths) and rendered with the + * active shell's quoting. A value NMSh does not know is a visible placeholder, + * never a guess. Read-only commands may run when asked; add, commit, push, + * pull and fetch run only after the one final Yes/No for the exact command; + * destructive ones (clean, restore, reset, force push) are Copy/Insert only. + */ + +/** Git subcommands Ask may run, and how much each changes. Anything else is never run by Ask. */ +export const GIT_RUN_POLICY: Readonly> = { + status: 'read', diff: 'read', log: 'read', show: 'read', branch: 'read', remote: 'read', + add: 'mutate', commit: 'mutate', push: 'mutate', pull: 'mutate', fetch: 'mutate', switch: 'mutate', +}; + +/** A branch name Git accepts (git check-ref-format's main rules), so a created branch is never a guess at quoting. */ +export function validBranchName(name: string): boolean { + return /^[A-Za-z0-9._/-]{1,100}$/u.test(name) && !/^[-/.]|[/.]$|\.\.|\/\/|@\{|\.lock$|^HEAD$/u.test(name); +} + +/** Options that make an otherwise allowed subcommand destructive or history-rewriting. */ +const DESTRUCTIVE_OPTIONS = /^(?:-f|--force|--force-with-lease|--hard|-D|--delete|--prune|--mirror|--amend|--no-verify)$/u; + +/** True when Ask may run this argv (re-checked by the app right before running). */ +export function gitRunAllowed(argv: readonly string[]): 'read' | 'mutate' | undefined { + if (argv[0] !== 'git') return undefined; + const risk = GIT_RUN_POLICY[argv[1] ?? '']; + if (!risk || argv.some(arg => DESTRUCTIVE_OPTIONS.test(arg.split('=')[0]!))) return undefined; + if (argv[1] === 'branch' && argv.length > 2 && !argv.slice(2).every(arg => /^(?:-a|-r|-v|-vv|--list|--show-current)$/u.test(arg))) return undefined; + // switch only creates a new branch from HEAD (-c ) or moves to an existing one; nothing that discards work. + if (argv[1] === 'switch' && !((argv.length === 4 && argv[2] === '-c' && validBranchName(argv[3]!)) || (argv.length === 3 && validBranchName(argv[2]!)))) return undefined; + if (argv[1] === 'show' && !argv.slice(2).every(arg => /^(?:--stat|--oneline|--no-patch|-s|HEAD(?:~\d+)?|--format=[\w%:<>() -]+)$/u.test(arg))) return undefined; + if (argv[1] === 'remote' && argv.length > 2 && !argv.slice(2).every(arg => arg === '-v')) return undefined; + return risk; +} + +/** Render argv for the active shell: plain words stay bare, anything else is single-quoted; placeholders stay as written. */ +export function renderCommand(block: Pick, shell: ShellId): string { + const quote = shell === 'fish' ? fishQuote : posixQuote; + return block.argv.map((part, index) => block.placeholders?.includes(index) || /^[\w@%+=:,./-]+$/u.test(part) ? part : quote(part)).join(' '); +} + +const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`; + +function contextBlock(argv: string[], git: GitFacts | undefined, note: string, extra: Array<[string, string]> = [], placeholders?: number[]): CommandBlock { + const risk = gitRunAllowed(argv) ?? 'destructive'; + const facts: Array<[string, string]> = []; + if (git) { + facts.push(['branch', git.detached ? 'detached HEAD' : git.branch ?? 'unknown']); + if (/^(?:push|pull|fetch)$/u.test(argv[1] ?? '')) { + facts.push(['upstream', git.upstream ?? 'none']); + facts.push(['remotes', git.remotes.join(', ') || 'none']); + } + } + facts.push(...extra); + return {argv, ...(placeholders?.length ? {placeholders} : {}), provenance: 'context', risk, note, facts, + ...(risk !== 'destructive' && !placeholders?.length ? {run: {kind: 'git', argv, risk}} : {})}; +} + +/** The concise state summary Ask opens with for status questions. */ +export function gitSummary(git: GitFacts): string { + const head = git.detached ? 'HEAD is detached (no current branch).' : `You're on ${git.branch}${git.upstream ? `, tracking ${git.upstream}` : ', with no upstream'}.`; + const lines = [head]; + if (git.ahead || git.behind) lines.push(`${git.ahead ?? 0} ahead, ${git.behind ?? 0} behind ${git.upstream} (as last fetched).`); + const parts = [ + git.conflicted.length ? plural(git.conflicted.length, 'conflicted file') : '', + git.staged.length ? `${plural(git.staged.length, 'file')} staged` : 'nothing staged', + git.modified.length ? plural(git.modified.length, 'modified file') : '', + git.deleted.length ? plural(git.deleted.length, 'deleted file') : '', + git.untracked.length ? plural(git.untracked.length, 'untracked file') : '', + ].filter(Boolean); + const clean = !git.staged.length && !git.modified.length && !git.deleted.length && !git.untracked.length && !git.conflicted.length; + lines.push(clean ? 'The working tree is clean.' : parts.join(' · ')); + return lines.join('\n'); +} + +/** Next steps derived only from the state: no commit offer on a clean tree, conflicts first. */ +export function gitNextSteps(git: GitFacts): AskOption[] { + const next: AskOption[] = []; + if (git.conflicted.length) next.push({key: 'git:conflicts', label: 'Show conflicted files', refine: 'show conflicted files'}); + if (git.untracked.length) next.push({key: 'git:untracked', label: 'Show untracked files', refine: 'show untracked files'}); + if (git.modified.length || git.deleted.length) next.push({key: 'git:diff', label: 'Show diff', refine: 'git diff'}); + if (git.staged.length) next.push({key: 'git:staged', label: 'Review staged diff', refine: 'show staged diff'}, {key: 'git:commit', label: 'Commit staged changes', refine: 'commit staged changes'}); + else if (git.modified.length || git.untracked.length) next.push({key: 'git:stage', label: 'Stage changes', refine: 'stage all changes'}); + if (!next.length && !git.detached) { + next.push({key: 'git:log', label: 'Show recent commits', refine: 'show recent commits'}); + if (git.ahead) next.push({key: 'git:push', label: 'Push this branch', refine: 'push this branch'}); + } + return next.slice(0, 4); +} + +function listed(paths: readonly string[], limit = 20): string { + return `${paths.slice(0, limit).map(path => ` ${path}`).join('\n')}${paths.length > limit ? `\n … and ${paths.length - limit} more` : ''}`; +} + +/** A quoted message: "fix it", 'fix it', “fix it”, or `message fix it` at the end. */ +export function commitMessage(raw: string): string | undefined { + // Paired quotes, double forms first, so an apostrophe inside "it's done" is part of the message. + for (const pattern of [/"([^"]{1,200})"/u, /“([^”]{1,200})”/u, /‘([^’]{1,200})’/u, /(?:^|\s)'([^']{1,200})'(?=\s|$)/u]) { + const quoted = pattern.exec(raw); + if (quoted) return quoted[1]!.trim() || undefined; + } + const after = /\b(?:message|msg|-m)\s+(?!["“'‘])(.{1,200})$/iu.exec(raw); + return after?.[1]!.trim() || undefined; +} + +const ORDINALS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth', 'ninth', 'tenth']; + +/** "only the second one", "just the first two", "only notes.md": a subset of the files this conversation is about. */ +export function refineFiles(text: string, files: readonly string[]): string[] | undefined { + if (!/\b(?:only|just|except|but not|not)\b/u.test(text)) return undefined; + const picked = new Set(); + ORDINALS.forEach((word, index) => { if (new RegExp(`\\b${word}\\b`, 'u').test(text) && index < files.length) picked.add(index); }); + const firstN = /\bfirst (two|three|four|\d+)\b/u.exec(text); + if (firstN) { const count = {two: 2, three: 3, four: 4}[firstN[1] as 'two'] ?? Number(firstN[1]); for (let index = 0; index < Math.min(count, files.length); index += 1) picked.add(index); } + if (/\blast\b/u.test(text) && files.length) picked.add(files.length - 1); + files.forEach((file, index) => { const base = file.split('/').pop()!.toLowerCase(); if (base.length > 2 && text.includes(base)) picked.add(index); }); + if (!picked.size) return undefined; + const chosen = [...picked].sort((a, b) => a - b).map(index => files[index]!); + return /\b(?:except|but not|not)\b/u.test(text) ? files.filter(file => !chosen.includes(file)) : chosen; +} + +const CHANGES = /\b(?:stuff|things|files|what) i(?:'ve| have)? (?:changed|modified|edited|touched)\b|\bmy (?:changes|edits)\b|\bwhat(?:'s| is| did i)? (?:changed|change)\b/u; +const DIFF = /^(?:please )?(?:show|see|view|display)(?: me)? (?:the |my |a )?(?:git )?diff\b/u; +const PRONOUN = /\b(?:them|those|these|it|that|this stuff|these files|those files|the files)\b/u; + +function untrackedOutcome(git: GitFacts): AskOutcome { + if (!git.untracked.length) return {kind: 'answer', capability: 'git.status', text: 'There are no untracked files.', referents: {files: {paths: [], kind: 'untracked'}}}; + return {kind: 'answer', capability: 'git.status', text: `${plural(git.untracked.length, 'untracked file')}:\n${listed(git.untracked)}`, + referents: {files: {paths: [...git.untracked], kind: 'untracked'}}, + next: [{key: 'git:add-them', label: 'Stage them', refine: 'add them'}, {key: 'git:clean-preview', label: 'Preview what git clean would remove', refine: 'preview git clean'}, + {key: 'git:clean-command', label: 'Show the command to delete them', refine: 'what is the command to delete them'}]}; +} + +function remoteChoice(git: GitFacts, build: (remote: string) => AskOutcome, verb: string): AskOutcome { + return {kind: 'choose', reason: 'missing', capability: 'git.status', question: `This branch has no upstream. Which remote should it ${verb}?`, + options: git.remotes.map(remote => ({key: `remote:${remote}`, label: remote, outcome: build(remote)}))}; +} + +function pushOutcome(git: GitFacts, reference?: CommandReference): AskOutcome { + if (git.detached || !git.branch) return {kind: 'answer', capability: 'git.status', text: 'HEAD is detached, so there is no current branch to push. Switch to a branch first (git switch ).'}; + const branch = git.branch; + if (git.upstream) { + return answerWithBlock(`Your branch tracks ${git.upstream}, so a plain push sends it there.`, contextBlock(['git', 'push'], git, `Pushes ${branch} to ${git.upstream}.`), {branch}); + } + // -u is used only when local command knowledge confirms it. + const push = reference?.lookup(['git', 'push'])?.facts; + const upstreamFlag = !push || reference!.option(push, '-u') ? '-u' : reference!.option(push, '--set-upstream') ? '--set-upstream' : undefined; + const build = (remote: string): AskOutcome => { + const argv = ['git', 'push', ...(upstreamFlag ? [upstreamFlag] : []), remote, branch]; + return answerWithBlock(`${branch} has no upstream yet. This pushes it to ${remote}${upstreamFlag ? ` and sets ${remote}/${branch} as its upstream (${upstreamFlag}), so later a plain git push is enough` : ''}.`, + contextBlock(argv, git, `Pushes ${branch} to ${remote}${upstreamFlag ? ' and remembers it as the upstream' : ''}.`), {branch, remote}); + }; + if (git.remotes.length === 1) return build(git.remotes[0]!); + if (!git.remotes.length) { + const argv = ['git', 'push', ...(upstreamFlag ? [upstreamFlag] : []), '', branch]; + return answerWithBlock('This repository has no remotes configured, so there is nowhere to push yet (git remote add adds one).', + contextBlock(argv, git, 'Fill in a remote first.', [], [argv.indexOf('')]), {branch}); + } + return remoteChoice(git, build, 'push to'); +} + +function pullOutcome(git: GitFacts): AskOutcome { + if (git.detached || !git.branch) return {kind: 'answer', capability: 'git.status', text: 'HEAD is detached, so there is no current branch to pull into.'}; + const branch = git.branch; + if (git.upstream) return answerWithBlock(`Your branch tracks ${git.upstream}, so a plain pull uses it.`, contextBlock(['git', 'pull'], git, `Fetches ${git.upstream} and integrates it into ${branch}.`), {branch}); + const build = (remote: string) => answerWithBlock(`${branch} has no upstream, so name the remote and branch.`, + contextBlock(['git', 'pull', remote, branch], git, `Fetches ${remote}/${branch} and integrates it into ${branch}.`), {branch, remote}); + if (git.remotes.length === 1) return build(git.remotes[0]!); + if (!git.remotes.length) return {kind: 'answer', capability: 'git.status', text: 'This repository has no remotes configured, so there is nothing to pull from.'}; + return remoteChoice(git, build, 'pull from'); +} + +function answerWithBlock(text: string, block: CommandBlock, referents: AskReferents = {}): AskOutcome { + return {kind: 'answer', capability: 'git.status', text, block, referents: {...referents, command: block.argv.slice(0, 2), block}}; +} + +/** + * Git requests in plain language, resolved against facts. Undefined leaves the + * request to the rest of the resolver (capabilities, concepts, commands). + */ +export function resolveGit(text: string, raw: string, context: AskContext, reference?: CommandReference): AskOutcome | undefined { + const git = context.git; + const refs = context.referents; + const files = refs?.files; + const aboutGit = /\b(?:git|branch|upstream|remote|remotes|untracked|staged|stage|unstaged|commit|push|pull|fetch|conflict|conflicts|conflicted|working tree)\b/u.test(text) + || CHANGES.test(text) || DIFF.test(text); + const referring = Boolean(files) && PRONOUN.test(text); + if (!aboutGit && !referring && !(refs?.block && /^(?:actually |no )?(?:only|just|except)\b/u.test(text))) return undefined; + // Outside a repository only a question about this repository's state gets that answer; explanations and refusals resolve elsewhere. + if (!context.repoRoot || !git) return !context.repoRoot && /^(?:show|list|what|which|how)\b/u.test(text) && !/^what (?:is|does|do)\b/u.test(text) + && /\b(?:this branch|my branch|untracked|my changes|this repo|remote am i|remotes)\b/u.test(text) + ? {kind: 'answer', capability: 'git.status', text: 'This folder is not in a Git repository.'} : undefined; + + // "actually only the second one": refine the files the current command is about. + if (files?.paths.length && refs?.block) { + const subset = refineFiles(text, files.paths); + if (subset?.length) return addOutcome(git, subset, files.kind); + } + const question = /\b(?:command|how (?:do|can|would) i|how to|syntax|what would|what's the|what is the)\b/u.test(text); + // "show me the diff", "show me the stuff i changed": the read-only diff, with the facts first. + if (DIFF.test(text) || (CHANGES.test(text) && /^(?:show|see|view|what|list)\b/u.test(text))) { + const staged = /\bstaged\b/u.test(text); + const summary = gitSummary(git); + return {kind: 'proposal', capability: 'git.diff', safety: 'read', confidence: 0.95, text: `${summary}\nShow the ${staged ? 'staged' : 'working-tree'} diff?`, + action: {kind: 'read', command: {id: 'git.diff', ...(staged ? {staged: true} : {})}}, + referents: {files: {paths: [...new Set([...git.modified, ...git.deleted, ...git.untracked])], kind: 'modified'}}}; + } + if (/\bstaged\b/u.test(text) && /^(?:show|list|see|what|which)\b/u.test(text) && !/\bunstaged\b/u.test(text)) { + return git.staged.length ? {kind: 'answer', capability: 'git.status', text: `${plural(git.staged.length, 'staged file')}:\n${listed(git.staged)}`, referents: {files: {paths: [...git.staged], kind: 'staged'}}, + next: [{key: 'git:commit', label: 'Commit them', refine: 'commit with message "'}]} + : {kind: 'answer', capability: 'git.status', text: 'Nothing is staged.', next: gitNextSteps(git)}; + } + // "what did my last commit do": git show --stat HEAD, read-only. + if (/\b(?:last|latest|previous|most recent) commit\b/u.test(text) && /\b(?:what|show|did|do|change|changed|contain)\b/u.test(text)) { + return answerWithBlock('Your last commit, with the files it changed:', {...contextBlock(['git', 'show', '--stat', 'HEAD'], git, 'Shows the newest commit\'s message and changed files.'), risk: 'read'}); + } + // "make a new branch called test": git switch -c, a mutating action behind the final Yes. + const newBranch = /\b(?:make|create|start|new|add|open)\b.*\bbranch\b(?:.*\b(?:called|named)\b)?\s+["']?([\w./-]+)["']?$/u.exec(text); + if (newBranch && /\b(?:make|create|start|new)\b/u.test(text) && !/^(?:what|which)\b/u.test(text)) { + const name = newBranch[1]!; + if (name === 'branch' || !validBranchName(name)) return {kind: 'answer', capability: 'git.status', text: `"${name}" isn't a valid branch name.`}; + return answerWithBlock(`Create ${name} from ${git.detached ? 'the current commit' : git.branch ?? 'HEAD'} and switch to it:`, contextBlock(['git', 'switch', '-c', name], git, `Creates ${name} at the current commit; your working tree is kept.`), + {branch: name}); + } + if (/\bforce[- ]?push\b|\bpush\b.*\b(?:--force|-f)\b/u.test(text)) { + return answerWithBlock('A force push replaces the remote branch\'s history. Ask won\'t run it; here is the safer form to copy if you mean it.', + {...contextBlock(['git', 'push', '--force-with-lease'], git, 'Overwrites the remote branch only if it still matches what you last fetched.'), risk: 'destructive'}); + } + if (/\b(?:delete|remove|clean|get rid of|wipe)\b/u.test(text) && (/\buntracked\b/u.test(text) || (files?.kind === 'untracked' && (PRONOUN.test(text) || /\bstuff\b/u.test(text))))) { + if (!question && !/\bpreview\b/u.test(text)) return undefined; // an action request stays with the safety policy (refused, with a preview offered) + const count = git.untracked.length; + return {kind: 'answer', capability: 'git.status', + text: `${count ? `${plural(count, 'untracked file')} in this repository.` : 'There are no untracked files right now.'} Preview first; deleting is permanent and Ask won't run it.\n\nPreview only\n git clean -nd\n\nDelete untracked files (permanent)`, + block: {...contextBlock(['git', 'clean', '-fd'], git, 'Permanently removes untracked files and directories (ignored files are kept).'), risk: 'destructive'}, + referents: {files: {paths: [...git.untracked], kind: 'untracked'}, command: ['git', 'clean']}, + next: [{key: 'git:clean-preview', label: 'Preview what would be removed', refine: 'preview git clean'}]}; + } + if (/\bpreview\b.*\bclean\b|\bclean\b.*\b(?:-n|dry run|preview)\b/u.test(text)) { + return answerWithBlock('A dry run lists what git clean would remove, without removing anything.', + {...contextBlock(['git', 'clean', '-nd'], git, 'Lists untracked files and directories git clean -fd would remove.'), risk: 'read', run: {kind: 'git', argv: ['git', 'clean', '-nd'], risk: 'read'}}); + } + if (/\buntracked\b/u.test(text) || (files?.kind === 'untracked' && /^(?:show|list|see)\b/u.test(text) && PRONOUN.test(text))) return untrackedOutcome(git); + if (/\bconflict/u.test(text)) { + return git.conflicted.length ? {kind: 'answer', capability: 'git.status', text: `${plural(git.conflicted.length, 'conflicted file')}:\n${listed(git.conflicted)}\nResolve each, then git add it.`, + referents: {files: {paths: [...git.conflicted], kind: 'conflicted'}}} : {kind: 'answer', capability: 'git.status', text: 'There are no merge conflicts.'}; + } + if (/\b(?:add|stage)\b/u.test(text)) { + if (referring && files?.paths.length) return addOutcome(git, files.paths, files.kind); + if (/\b(?:all|everything|changes)\b/u.test(text)) { + const paths = [...new Set([...git.modified, ...git.deleted, ...git.untracked])]; + if (!paths.length) return {kind: 'answer', capability: 'git.status', text: 'There is nothing to stage.'}; + return answerWithBlock(`Stage all ${plural(paths.length, 'change')} (modified, deleted and untracked)?`, contextBlock(['git', 'add', '-A'], git, 'Stages every change in the working tree.'), + {files: {paths, kind: 'mentioned'}}); + } + } + if (/\bcommit\b/u.test(text) && !/\b(?:recent|last|latest|history|log)\b/u.test(text)) { + if (!git.staged.length) return {kind: 'answer', capability: 'git.status', text: 'Nothing is staged, so there is nothing to commit yet.', next: gitNextSteps(git)}; + const message = commitMessage(raw); + if (!message) return {kind: 'choose', reason: 'missing', capability: 'git.status', question: `${plural(git.staged.length, 'file')} staged. What should the commit message be?`, + options: [{key: 'commit:message', label: 'Type: commit with message "…"', refine: 'commit with message "'}]}; + return answerWithBlock(`Commit ${plural(git.staged.length, 'staged file')} on ${git.branch ?? 'detached HEAD'}:`, contextBlock(['git', 'commit', '-m', message], git, 'Records the staged changes as a new commit.')); + } + if (/\bpush\b/u.test(text) && /\b(?:branch|this|it|my|changes|commits|upstream)\b/u.test(text)) return pushOutcome(git, reference); + if (/\bpull\b/u.test(text) && /\b(?:branch|this|it|my|changes|latest|upstream)\b/u.test(text)) return pullOutcome(git); + // "how do i push", "what's the syntax to push": a plain push is not a force push; the syntax, then this branch's exact command. + if (/\b(?:push|pull)\b/u.test(text) && (question || /^(?:git )?(?:push|pull)$/u.test(text) || /^how\b/u.test(text))) { + const verb = /\bpush\b/u.test(text) ? 'push' : 'pull'; + const syntax = verb === 'push' ? 'git push [ []] sends your commits to a remote. It never rewrites history unless you add --force.' : 'git pull [ []] fetches a remote branch and integrates it into yours.'; + const contextual = verb === 'push' ? pushOutcome(git, reference) : pullOutcome(git); + return contextual.kind === 'answer' ? {...contextual, text: `${syntax}\n\nHere:\n${contextual.text}`} : contextual; + } + if (/\bfetch\b/u.test(text)) return git.remotes.length ? answerWithBlock('Fetching downloads new commits from remotes without changing your branch.', contextBlock(['git', 'fetch', '--all'].filter(arg => arg !== '--all' || git.remotes.length > 1), git, 'Updates remote-tracking branches only.')) + : {kind: 'answer', capability: 'git.status', text: 'This repository has no remotes to fetch from.'}; + if (/\bremotes?\b/u.test(text) || /\bupstream\b/u.test(text)) { + const upstream = git.upstream ? `${git.branch} tracks ${git.upstream}.` : git.detached ? 'HEAD is detached (no upstream).' : `${git.branch} has no upstream.`; + return {kind: 'answer', capability: 'git.status', text: `${git.remotes.length ? `Remotes: ${git.remotes.join(', ')}.` : 'No remotes are configured.'} ${upstream}`}; + } + if (/\b(?:git status|status of (?:the )?repo|what changed|what(?:'s| is) changed|working tree|repo status)\b/u.test(text) || /^(?:show |check )?(?:my )?git(?: status)?$/u.test(text)) { + // The facts first; the full status is the existing read-only action, shown in the transcript when confirmed. + return {kind: 'proposal', capability: 'git.status', safety: 'read', confidence: 0.95, text: `${gitSummary(git)}\nShow the full git status?`, + action: {kind: 'read', command: {id: 'git.status'}}}; + } + return undefined; +} + +function addOutcome(git: GitFacts, paths: readonly string[], kind: NonNullable['kind']): AskOutcome { + const argv = ['git', 'add', '--', ...paths]; + return answerWithBlock(`Stage ${paths.length === 1 ? paths[0] : plural(paths.length, 'file')}?`, contextBlock(argv, git, `Stages ${plural(paths.length, 'file')} for the next commit.`), + {files: {paths: [...paths], kind}}); +} diff --git a/src/ask/guide.ts b/src/ask/guide.ts new file mode 100644 index 00000000..7fa1710d --- /dev/null +++ b/src/ask/guide.ts @@ -0,0 +1,82 @@ +import {CONCEPTS, conceptDestination, GUIDE_SECTIONS, type Concept} from './concepts.js'; +import type {AskContext, AskOption, AskOutcome} from './types.js'; + +/** + * The interactive NMSh guide, built from the concept catalog (one source for + * /guide, /btw guide, /btw help and Ask's feature answers). Each section says + * what a feature is, why it exists, where it lives, and a few current facts; + * its choices open the real surfaces, whose own confirmation rules apply. + */ + +const byId = new Map(CONCEPTS.map(concept => [concept.id, concept])); +export const GUIDE_REQUEST = /^(?:\/?guide(?: me)?(?: through (?:nmsh|this))?|show me what (?:nmsh|you) can do|what can nmsh do|nmsh guide|tour|give me a tour)$/u; +export const HELP_REQUEST = /^(?:help|what can you do|what can ask do|how do i use ask|ask help|(?:show me |tell me |list )?(?:everything|all (?:the )?(?:things|stuff)|what) (?:nmsh|ask|you) can do|what (?:can|does) nmsh do|what are your (?:capabilities|features))$/u; + +function openOption(concept: Concept): AskOption | undefined { + const target = concept.configure ?? concept.open; + const slash = target ? conceptDestination({...concept, open: target}) : undefined; + if (!slash || concept.support === 'unsupported') return undefined; + return {key: `guide:open:${concept.id}`, label: `Open ${target} (${concept.label})`, + outcome: {kind: 'proposal', capability: 'feature.open', safety: 'navigate', confidence: 0.95, text: `Opening ${target}.`, action: {kind: 'slash', slash, label: target!}}}; +} + +export function guideOutcome(context?: Pick): AskOutcome { + return {kind: 'choose', reason: 'missing', capability: 'help.guide', question: 'NMSh Guide · what would you like to explore?', + options: [...GUIDE_SECTIONS.map(section => ({key: `guide:${section.id}`, label: section.title, outcome: sectionOutcome(section.id, context)})), + {key: 'guide:everything', label: 'Everything NMSh can do', outcome: everythingOutcome(context)}]}; +} + +export function sectionOutcome(id: string, context?: Pick): AskOutcome { + const section = GUIDE_SECTIONS.find(item => item.id === id)!; + const concepts = section.concepts.map(conceptId => byId.get(conceptId)!).filter(Boolean); + const lines = [section.title, section.why, ...(section.notes ?? []), '']; + for (const concept of concepts) { + const where = concept.support === 'unsupported' ? 'not supported' : concept.configure ?? concept.open ?? concept.where ?? ''; + const fact = context?.nmsh?.[concept.id]; + lines.push(`${concept.label}${where ? ` · ${where}` : ''}${fact ? ` · now: ${fact}` : ''}`, ` ${concept.description}`); + } + const next = concepts.map(openOption).filter((option): option is AskOption => Boolean(option)).slice(0, 4); + next.push({key: 'guide:back', label: 'Back to the guide', refine: 'guide'}); + return {kind: 'answer', capability: 'help.guide', text: lines.join('\n'), next}; +} + +/** Every public concept in one compact list, grouped by guide section. */ +export function everythingOutcome(_context?: Pick): AskOutcome { + const lines = ['Everything NMSh can do']; + for (const section of GUIDE_SECTIONS) { + const named = section.concepts.map(id => byId.get(id)!).filter(concept => concept && concept.support !== 'unsupported'); + if (named.length) lines.push('', section.title, ...named.map(concept => ` ${concept.label}${concept.open ? ` · ${concept.open}` : ''}`)); + } + return {kind: 'answer', capability: 'help.guide', text: lines.join('\n'), next: [{key: 'guide:back', label: 'Back to the guide', refine: 'guide'}]}; +} + +/** /btw help: what Ask itself does, with examples, and the way into the full guide. */ +export function askHelpOutcome(): AskOutcome { + return {kind: 'answer', capability: 'help.capabilities', text: [ + 'Ask can help with:', + ' Files list, browse, find and open files ("open", "find files named config")', + ' Commands syntax, flags and what a command does ("what does git clean -n do?")', + ' Git status, branches, push and pull in this repository', + ' Projects run scripts, start and stop dev servers ("run the tests")', + ' Config open config files; add or update a setting (shown first, never removed)', + ' Packages Homebrew search, info, install and upgrade (with your Yes)', + ' Sessions what you just ran, transcripts, "why did that fail?", watches', + ' NMSh settings prompt, appearance, cursor, motion, /doctor ("change my ghost text")', + ' Local understanding an optional local model for vague requests (/llm)', + '', + 'Try: "what did I just do?" · "how do I push this branch?" · "what is zoxide?" · "watch git status" · "check my setup"', + '', + 'Ask never runs destructive or arbitrary commands. Anything that changes something is shown first and needs your Yes.', + ].join('\n'), next: [{key: 'guide:open', label: 'Open the full NMSh guide', refine: 'guide'}]}; +} + +/** Strong, factual starters for an empty /ask: a dirty repository, a recent command; otherwise the general ones. */ +export function askStarters(context: Pick): AskOption[] { + const starters: AskOption[] = []; + if (context.repoRoot && context.dirty) starters.push({key: 'start:status', label: 'Show Git status', refine: 'git status'}); + if (context.recent?.length) starters.push({key: 'start:last', label: 'Explain my recent command', refine: 'what did i just do'}); + if (context.repoRoot && context.branch) starters.push({key: 'start:branch', label: 'Help with this branch', refine: 'how do i push this branch'}); + if (!context.repoRoot) starters.push({key: 'start:settings', label: 'Open settings', refine: 'open settings'}, {key: 'start:can', label: 'What can NMSh do?', refine: 'help'}); + starters.push({key: 'start:guide', label: 'Guide me through NMSh', refine: 'guide'}); + return starters.slice(0, 4); +} diff --git a/src/ask/localModel.ts b/src/ask/localModel.ts new file mode 100644 index 00000000..95bc58f3 --- /dev/null +++ b/src/ask/localModel.ts @@ -0,0 +1,42 @@ +import type {AskContext, AskOutcome} from './types.js'; + +/** + * Questions about NMSh's optional local model, answered from the same facts + * /llm shows (mode, configured model, service state, how the last Ask was + * routed). Actions go to /llm, where each one is previewed and confirmed. + */ +export interface LocalModelFacts { + mode: 'off' | 'auto' | 'always'; + model?: {label: string; runtime: string; owned: boolean}; + state?: string; + lastRoute?: 'deterministic' | 'model'; + lastInference?: string; + requests: number; +} + +const ABOUT = /\b(?:local (?:model|llm|understanding|intelligence)|qwen|llama(?:\.cpp)?|language model|the model|which model|what model|your model|model you)\b/u; + +function open(text: string, why: string): AskOutcome { + return {kind: 'proposal', capability: 'understanding.set', safety: 'navigate', confidence: 0.95, text: `${why} Opening /llm.`, + action: {kind: 'slash', slash: {kind: 'llm'}, label: '/llm'}}; +} + +export function resolveLocalModel(text: string, context: AskContext): AskOutcome | undefined { + const facts = context.llm; + if (!facts || !ABOUT.test(text)) return undefined; + const configured = facts.model ? `${facts.model.label} (${facts.model.runtime}${facts.model.owned ? ', NMSh managed' : ', found on this machine'})` : undefined; + if (/\b(?:remove|delete|uninstall|get rid of)\b/u.test(text)) { + return open(text, facts.model?.owned ? 'Removing the model NMSh downloaded is in /llm (it shows the exact file and asks first).' + : 'NMSh only removes a model it downloaded itself; models you or other tools installed stay.'); + } + if (/\b(?:find|look for|detect|search for|other|better|bigger|stronger)\b.*\bmodels?\b/u.test(text)) return open(text, 'Detecting compares the models on this machine with the recommended one.'); + if (/\b(?:stop|unload|kill|shut down)\b/u.test(text)) return open(text, facts.state && /ready|busy|loading/iu.test(facts.state) ? 'Stop model unloads it now; it loads again on next use.' : 'No model is loaded right now.'); + if (/\b(?:set ?up|install|download|configure|change|switch)\b/u.test(text)) return open(text, 'Model setup detects what is here first.'); + const state = facts.mode === 'off' ? 'Local understanding is Off: no model is used.' + : !configured ? `Local understanding is ${facts.mode === 'auto' ? 'Auto' : 'Always'}, but no model is set up yet, so Ask uses only its built-in understanding.` + : `${configured} · ${facts.state ?? 'not running (starts on first use)'} · mode ${facts.mode === 'auto' ? 'Auto (only when built-in understanding is unsure)' : 'Always'}.`; + const route = facts.lastRoute ? `\nThe last Ask ${facts.lastRoute === 'model' ? `used ${facts.model?.label ?? 'the model'}` : 'was resolved deterministically, without the model'}.` : ''; + const inference = facts.lastInference ? `\nLast inference: ${facts.lastInference}. Requests this window: ${facts.requests}.` : ''; + return {kind: 'answer', capability: 'understanding.set', text: `${state}${route}${inference}`, + follow: {key: 'llm:open', label: 'Open /llm', outcome: {kind: 'proposal', capability: 'understanding.set', safety: 'navigate', confidence: 1, text: 'Opening /llm.', action: {kind: 'slash', slash: {kind: 'llm'}, label: '/llm'}}}}; +} diff --git a/src/ask/packages.ts b/src/ask/packages.ts new file mode 100644 index 00000000..99ed326a --- /dev/null +++ b/src/ask/packages.ts @@ -0,0 +1,193 @@ +import {PACKAGE_NAME, type PackageInfo} from '../packages/homebrew.js'; +import type {AskContext, AskOption, AskOutcome} from './types.js'; + +/** + * Homebrew questions and typed package actions for Ask, answered from facts + * the app gathered for this request (Homebrew's JSON and lists, the + * executable's identity). Installs, upgrades and uninstalls are proposals + * whose exact command is shown and which run only after the final Yes. + */ + +export interface BrewFacts { + available: boolean; + installed?: {formulae: string[]; casks: string[]}; + outdated?: Array<{name: string; installed: string; current: string; kind: 'formula' | 'cask'}>; + info?: Record; + search?: Record; + uses?: Record; + prefix?: Record; + /** What PATH has for a name and who owns it (Homebrew only from Cellar evidence). */ + identity?: Record; +} + +export type PackageIntent = + | {kind: 'outdated'} | {kind: 'list'} + | {kind: 'installed' | 'info' | 'deps' | 'uses' | 'where' | 'install' | 'upgrade' | 'uninstall'; name: string} + | {kind: 'search'; query: string}; + +const NAME = '([a-z0-9][\\w@.+-]{0,63})'; +const STOP = new Set(['it', 'this', 'that', 'the', 'a', 'an', 'brew', 'homebrew', 'package', 'packages', 'something', 'stuff', 'them', 'all', 'everything', 'updates', 'nmsh', 'with', 'using', 'via']); +const SHELLS = new Set(['zsh', 'fish', 'bash']); + +/** Which package question this is, if any (Homebrew words or package verbs). */ +export function packageIntent(text: string): PackageIntent | undefined { + const name = (match: RegExpExecArray | null) => match?.slice(1).find(Boolean); + const valid = (value: string | undefined) => value && !STOP.has(value) && PACKAGE_NAME.test(value) ? value : undefined; + const brewish = /\b(?:brew|homebrew|formulae?|casks?|packages?)\b/u.test(text); + if (/\boutdated\b/u.test(text) || (brewish && /\b(?:updates?|updating|upgrades?|upgrading|need(?:s|ing)? (?:an? )?(?:update|upgrade))\b/u.test(text) && /\b(?:what|which|any|show|list|check)\b/u.test(text) && !/\bupgrade [a-z0-9]/u.test(text))) return {kind: 'outdated'}; + if (/\bwhat did (?:brew|homebrew) install\b|\bbrew (?:list|leaves)\b/u.test(text) || (brewish && /\b(?:show|list|what)\b.*\binstalled\b/u.test(text) && !/\bis \S+ installed\b/u.test(text))) return {kind: 'list'}; + const uses = valid(name(new RegExp(`\\bwhat (?:depends|relies) on ${NAME}`, 'u').exec(text))); + if (uses) return {kind: 'uses', name: uses}; + const deps = valid(name(new RegExp(`\\bwhat does ${NAME} depend on\\b|\\bdependencies (?:of|for) ${NAME}`, 'u').exec(text))); + if (deps) return {kind: 'deps', name: deps}; + const where = valid(name(new RegExp(`\\bwhere is ${NAME} installed\\b|\\bbrew --prefix ${NAME}`, 'u').exec(text))); + if (where) return {kind: 'where', name: where}; + const installed = valid(name(new RegExp(`\\bis ${NAME} installed\\b|\\bwhat version of ${NAME}\\b|\\bdo i have ${NAME}\\b|\\bwhich version of ${NAME}\\b`, 'u').exec(text))); + if (installed) return {kind: 'installed', name: installed}; + const search = valid(name(new RegExp(`\\b(?:search|look) (?:brew|homebrew) for ${NAME}|\\bbrew search ${NAME}|\\b(?:search|find|look for) (?:a |the )?(?:brew |homebrew )?(?:package|formula|cask)(?: for| called| named)? ${NAME}`, 'u').exec(text))); + if (search) return {kind: 'search', query: search}; + const info = valid(name(new RegExp(`\\bbrew info ${NAME}|\\b(?:info|information|details) (?:about|on|for) ${NAME}`, 'u').exec(text))); + if (info) return {kind: 'info', name: info}; + const verb = new RegExp(`^(?:please |can you |could you )?(?:brew )?(install|upgrade|update|uninstall|remove)(?: the)? ${NAME}(?: (?:with|using|via) (?:brew|homebrew))?$`, 'u').exec(text); + const target = valid(verb?.[2]); + if (verb && target && !SHELLS.has(target)) { + const action = verb[1] === 'update' ? 'upgrade' : verb[1] === 'remove' ? (brewish ? 'uninstall' : undefined) : verb[1] as 'install' | 'upgrade' | 'uninstall'; + if (action) return {kind: action, name: target}; + } + return undefined; +} + +/** What to fetch for an intent: bounded and request-specific. */ +export function packageQueries(intent: PackageIntent): {installed: boolean; outdated: boolean; info: string[]; search: string[]; uses: string[]; prefix: string[]} { + const name = 'name' in intent ? intent.name : undefined; + return { + installed: intent.kind !== 'search', + outdated: intent.kind === 'outdated', + info: name ? [name] : [], + search: intent.kind === 'search' ? [intent.query] : intent.kind === 'install' && name ? [name] : [], + uses: (intent.kind === 'uses' || intent.kind === 'uninstall') && name ? [name] : [], + prefix: intent.kind === 'where' && name ? [name] : [], + }; +} + +const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`; +const isInstalled = (facts: BrewFacts, name: string) => Boolean(facts.installed?.formulae.includes(name) || facts.installed?.casks.includes(name)); + +function brewProposal(verb: 'install' | 'upgrade' | 'uninstall', item: Pick, effect: string): AskOutcome { + const argv = ['brew', verb, ...(item.kind === 'cask' ? ['--cask'] : []), item.name]; + return {kind: 'proposal', capability: 'tools.open', safety: verb === 'install' ? 'install' : 'mutate', confidence: 0.95, command: argv.join(' '), + text: `${effect}`, action: {kind: 'brew', argv, name: item.name, expect: verb === 'install' ? 'installed' : verb === 'upgrade' ? 'upgraded' : 'absent'}}; +} + +function describe(item: PackageInfo): string { + const kind = item.kind === 'cask' ? 'Cask' : 'Formula'; + const state = item.installed.length ? `installed ${item.installed.join(', ')}${item.outdated && item.current ? ` (${item.current} available)` : ''}` : 'not installed'; + return `${item.name} · ${kind}${item.description ? ` — ${item.description}` : ''}\n${item.current ? `Current ${item.current} · ` : ''}${state}${item.homepage ? `\n${item.homepage}` : ''}`; +} + +function nextFor(item: PackageInfo, facts: BrewFacts): AskOption[] { + if (!item.installed.length) return [{key: `brew:install:${item.name}`, label: `Install ${item.name}`, outcome: brewProposal('install', item, `Install ${item.name}${item.description ? ` (${item.description})` : ''}?`)}]; + const next: AskOption[] = []; + if (item.outdated || facts.outdated?.some(entry => entry.name === item.name)) next.push({key: `brew:upgrade:${item.name}`, label: `Upgrade ${item.name}`, outcome: brewProposal('upgrade', item, `Upgrade ${item.name}${item.current ? ` to ${item.current}` : ''}?`)}); + if (item.kind === 'formula') next.push({key: `cmd:${item.name}`, label: `Show ${item.name} syntax`, refine: `how do i use ${item.name}`}); + return next; +} + +export function resolvePackage(text: string, context: AskContext): AskOutcome | undefined { + const intent = packageIntent(text); + if (!intent) return undefined; + const facts = context.brew; + if (!facts) return undefined; + if (!facts.available) { + // Installing a curated tool or a shell keeps its own paths; this is only for Homebrew-specific requests. + return /\b(?:brew|homebrew|formulae?|casks?)\b/u.test(text) || intent.kind === 'outdated' || intent.kind === 'list' + ? {kind: 'answer', capability: 'tools.open', text: 'Homebrew is not installed here, and NMSh does not guess other package managers.'} : undefined; + } + const info = (name: string) => facts.info?.[name] ?? []; + switch (intent.kind) { + case 'outdated': { + const outdated = facts.outdated ?? []; + if (!outdated.length) return {kind: 'answer', capability: 'tools.open', text: 'Everything Homebrew installed is up to date (as of Homebrew\'s last local index update).'}; + const width = Math.min(24, Math.max(...outdated.map(item => item.name.length))); + return {kind: 'answer', capability: 'tools.open', text: `${plural(outdated.length, 'Homebrew package')} ${outdated.length === 1 ? 'has' : 'have'} an update available:\n${outdated.slice(0, 30).map(item => ` ${item.name.padEnd(width)} ${item.installed} → ${item.current}${item.kind === 'cask' ? ' (cask)' : ''}`).join('\n')}`, + next: outdated.slice(0, 4).map(item => ({key: `brew:upgrade:${item.name}`, label: `Upgrade ${item.name}`, outcome: brewProposal('upgrade', {name: item.name, kind: item.kind}, `Upgrade ${item.name} from ${item.installed} to ${item.current}?`)}))}; + } + case 'list': { + const formulae = facts.installed?.formulae ?? []; + const casks = facts.installed?.casks ?? []; + const show = (names: string[]) => names.slice(0, 40).join(', ') + (names.length > 40 ? `, … (${names.length - 40} more)` : ''); + return {kind: 'answer', capability: 'tools.open', text: `Homebrew has ${plural(formulae.length, 'formula')} and ${plural(casks.length, 'cask')} installed.${formulae.length ? `\n\nFormulae\n ${show(formulae)}` : ''}${casks.length ? `\n\nCasks\n ${show(casks)}` : ''}`, + next: [{key: 'brew:outdated', label: 'What is outdated?', refine: 'what is outdated in brew'}]}; + } + case 'installed': case 'info': { + const items = info(intent.name); + const identity = facts.identity?.[intent.name]; + if (!items.length) { + const onPath = identity?.path ? `${intent.name} is on PATH at ${identity.path}, ${identity.owner === 'homebrew' ? 'inside Homebrew\'s Cellar' : 'not installed by Homebrew'}.` : `Homebrew has no package named ${intent.name}.`; + return {kind: 'answer', capability: 'tools.open', text: onPath, ...(identity?.path ? {} : {next: [{key: `brew:search:${intent.name}`, label: `Search Homebrew for ${intent.name}`, refine: `search brew for ${intent.name}`}]})}; + } + const lines = items.map(describe); + if (identity?.path && identity.owner !== 'homebrew' && !isInstalled(facts, intent.name)) lines.push(`The ${intent.name} on PATH (${identity.path}) was not installed by Homebrew.`); + return {kind: 'answer', capability: 'tools.open', text: lines.join('\n\n'), next: items.length === 1 ? nextFor(items[0]!, facts) : []}; + } + case 'deps': { + const item = info(intent.name)[0]; + if (!item) return {kind: 'answer', capability: 'tools.open', text: `Homebrew has no package named ${intent.name}.`}; + return {kind: 'answer', capability: 'tools.open', text: item.dependencies.length ? `${item.name} depends on: ${item.dependencies.join(', ')}.` : `${item.name} has no Homebrew dependencies.`}; + } + case 'uses': { + const uses = facts.uses?.[intent.name] ?? []; + return {kind: 'answer', capability: 'tools.open', text: uses.length ? `Installed packages that depend on ${intent.name}: ${uses.join(', ')}.` : `No installed Homebrew package depends on ${intent.name}.`}; + } + case 'where': { + const prefix = facts.prefix?.[intent.name]; + const identity = facts.identity?.[intent.name]; + return {kind: 'answer', capability: 'tools.open', text: [isInstalled(facts, intent.name) && prefix ? `Homebrew's ${intent.name} lives in ${prefix}.` : `${intent.name} is not installed with Homebrew.`, + identity?.path ? `On PATH: ${identity.path}${identity.owner === 'homebrew' ? ' (Homebrew)' : ' (not from Homebrew)'}.` : ''].filter(Boolean).join(' ')}; + } + case 'search': { + const found = facts.search?.[intent.query] ?? {formulae: [], casks: []}; + const options: AskOption[] = [...found.formulae.map(name => ({key: `brew:f:${name}`, label: `${name}`, detail: 'Formula', refine: `brew info ${name}`})), + ...found.casks.map(name => ({key: `brew:c:${name}`, label: `${name}`, detail: 'Cask', refine: `brew info ${name}`}))].slice(0, 8); + if (!options.length) return {kind: 'answer', capability: 'tools.open', text: `Homebrew found nothing for "${intent.query}".`}; + return {kind: 'choose', reason: 'missing', capability: 'tools.open', question: `Homebrew found ${plural(found.formulae.length, 'formula')} and ${plural(found.casks.length, 'cask')} for "${intent.query}". Which one?`, options}; + } + case 'install': { + const items = info(intent.name); + const installedItem = items.find(item => item.installed.length); + if (installedItem) return {kind: 'answer', capability: 'tools.open', text: `${intent.name} ${installedItem.installed.join(', ')} is already installed with Homebrew.`, next: nextFor(installedItem, facts)}; + const identity = facts.identity?.[intent.name]; + if (identity?.path) return {kind: 'answer', capability: 'tools.open', text: `${intent.name} is already on PATH at ${identity.path}${identity.owner === 'homebrew' ? '' : ' (not from Homebrew)'}, so I won't install a second copy.`}; + if (items.length > 1) { + return {kind: 'choose', reason: 'ambiguous', capability: 'tools.open', question: `${intent.name} exists as both a formula and a cask. Which one?`, + options: items.map(item => ({key: `brew:${item.kind}:${item.name}`, label: `${item.kind === 'cask' ? 'Cask' : 'Formula'} · ${item.description ?? item.name}`, + outcome: brewProposal('install', item, `Install the ${item.kind} ${item.name}?`)}))}; + } + if (items.length === 1) { + const item = items[0]!; + return brewProposal('install', item, `${item.name} isn't installed. Homebrew has the ${item.kind} ${item.name}${item.description ? ` — ${item.description}` : ''}. Install it?`); + } + const found = facts.search?.[intent.name]; + const options = [...(found?.formulae ?? []).map(name => ({key: `brew:f:${name}`, label: name, detail: 'Formula', refine: `install ${name}`})), + ...(found?.casks ?? []).map(name => ({key: `brew:c:${name}`, label: name, detail: 'Cask', refine: `brew info ${name}`}))].slice(0, 6); + return options.length ? {kind: 'choose', reason: 'ambiguous', capability: 'tools.open', question: `Homebrew has no package named exactly ${intent.name}. Did you mean:`, options} + : {kind: 'answer', capability: 'tools.open', text: `Homebrew has no package named ${intent.name}.`}; + } + case 'upgrade': case 'uninstall': { + const item = info(intent.name).find(entry => entry.installed.length) ; + if (!item || !isInstalled(facts, intent.name)) { + const identity = facts.identity?.[intent.name]; + return {kind: 'answer', capability: 'tools.open', text: identity?.path && identity.owner !== 'homebrew' + ? `${intent.name} at ${identity.path} wasn't installed by Homebrew, so NMSh can't safely ${intent.kind} it through Homebrew. Use whatever installed it.` + : `${intent.name} isn't installed with Homebrew.`}; + } + if (intent.kind === 'upgrade') { + const outdated = item.outdated || facts.outdated?.some(entry => entry.name === item.name); + return outdated ? brewProposal('upgrade', item, `Upgrade ${item.name} ${item.installed.at(-1)} to ${item.current ?? 'the latest'}?`) + : {kind: 'answer', capability: 'tools.open', text: `${item.name} ${item.installed.at(-1)} is already the newest version Homebrew knows about.`}; + } + const dependents = facts.uses?.[intent.name] ?? []; + return brewProposal('uninstall', item, `Uninstall ${item.name} ${item.installed.at(-1)}?${dependents.length ? ` These installed packages depend on it: ${dependents.join(', ')}.` : ''}`); + } + } +} diff --git a/src/ask/project.ts b/src/ask/project.ts new file mode 100644 index 00000000..ef54a5f9 --- /dev/null +++ b/src/ask/project.ts @@ -0,0 +1,218 @@ +import {existsSync, readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import type {AskContext, AskOutcome, CommandBlock} from './types.js'; + +/** + * Project awareness from the project's own files: package.json scripts and + * lockfiles (npm, pnpm, yarn, bun), Cargo.toml, go.mod, pyproject.toml, + * Makefile. Commands are built from those facts only; a script that doesn't + * exist is never invented. Finite scripts (tests, builds) run as a visible + * shell submission after the final Yes; long-lived ones (dev servers, + * watchers) start as NMSh-managed background tasks so the shell stays free. + */ + +export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'; + +export interface ProjectFacts { + root: string; + kind: 'node' | 'rust' | 'go' | 'python' | 'make'; + manager?: PackageManager; + /** How the manager was established (lockfile, packageManager field, or the npm default for a bare package.json). */ + managerSource?: string; + scripts: Record; + name?: string; + makeTargets?: string[]; +} + +export interface TaskSummary {id: string; label: string; status: string; urls: string[]; startedAt: number; lines: number; command: string} + +export function readProjectFacts(root: string): ProjectFacts | undefined { + const read = (file: string) => { try { return readFileSync(join(root, file), 'utf8'); } catch { return undefined; } }; + const pkg = read('package.json'); + if (pkg !== undefined) { + let json: {scripts?: Record; packageManager?: unknown; name?: unknown} = {}; + try { json = JSON.parse(pkg) as typeof json; } catch { /* malformed: no scripts */ } + const scripts = Object.fromEntries(Object.entries(json.scripts ?? {}).filter((entry): entry is [string, string] => typeof entry[1] === 'string' && /^[\w:.@/-]{1,80}$/u.test(entry[0]))); + const declared = typeof json.packageManager === 'string' ? /^(npm|pnpm|yarn|bun)@/u.exec(json.packageManager)?.[1] as PackageManager | undefined : undefined; + const lock: Array<[string, PackageManager]> = [['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'], ['bun.lockb', 'bun'], ['bun.lock', 'bun'], ['package-lock.json', 'npm']]; + const locked = lock.find(([file]) => existsSync(join(root, file))); + const manager = declared ?? locked?.[1] ?? 'npm'; + return {root, kind: 'node', manager, managerSource: declared ? 'packageManager in package.json' : locked ? locked[0] : 'package.json (no lockfile)', scripts, + ...(typeof json.name === 'string' ? {name: json.name} : {})}; + } + if (existsSync(join(root, 'Cargo.toml'))) return {root, kind: 'rust', scripts: {}}; + if (existsSync(join(root, 'go.mod'))) return {root, kind: 'go', scripts: {}}; + if (existsSync(join(root, 'pyproject.toml'))) return {root, kind: 'python', scripts: {}}; + const make = read('Makefile'); + if (make !== undefined) { + const targets = [...make.matchAll(/^([A-Za-z][\w.-]*):(?!=)/gmu)].map(match => match[1]!).filter((name, index, all) => all.indexOf(name) === index).slice(0, 30); + return {root, kind: 'make', scripts: {}, makeTargets: targets}; + } + return undefined; +} + +/** argv for one script with the project's own manager. */ +export function scriptArgv(project: ProjectFacts, script: string): string[] { + const manager = project.manager ?? 'npm'; + if (manager === 'npm') return script === 'test' || script === 'start' ? ['npm', script] : ['npm', 'run', script]; + return [manager, 'run', script]; +} + +/** True when argv is exactly a script this project defines, run by its manager (re-checked before running). */ +export function projectRunAllowed(argv: readonly string[], project: ProjectFacts | undefined): boolean { + if (!project) return false; + if (project.kind === 'node') return Object.keys(project.scripts).some(script => scriptArgv(project, script).join('\u0000') === argv.join('\u0000')); + const fixed: Record = {node: [], rust: [['cargo', 'run'], ['cargo', 'test'], ['cargo', 'build']], go: [['go', 'run', '.'], ['go', 'test', './...'], ['go', 'build', './...']], + python: [], make: (project.makeTargets ?? []).map(target => ['make', target])}; + return fixed[project.kind].some(item => item.join('\u0000') === argv.join('\u0000')); +} + +/** Long-lived scripts: started as managed background tasks, never in the shell's foreground. */ +export function isLongRunning(script: string, body = ''): boolean { + return /^(?:dev|start|serve|server|watch|preview|storybook)(?::|$)/u.test(script) || /\b(?:vite(?! build)|next dev|nuxt dev|webpack serve|nodemon|--watch|astro dev|remix dev|tsc -w|serve\b)/u.test(body); +} + +type Role = 'dev' | 'test' | 'build' | 'lint' | 'start'; +const ROLE_SCRIPTS: Record = {dev: ['dev', 'start', 'serve', 'develop', 'preview'], start: ['start', 'dev', 'serve'], test: ['test', 'tests', 'test:unit', 'check'], + build: ['build', 'compile'], lint: ['lint', 'typecheck', 'check']}; + +function scriptFor(project: ProjectFacts, role: Role): string | undefined { + return ROLE_SCRIPTS[role].find(name => project.scripts[name] !== undefined); +} + +function fixedFor(project: ProjectFacts, role: Role): string[] | undefined { + if (project.kind === 'rust') return role === 'test' ? ['cargo', 'test'] : role === 'build' ? ['cargo', 'build'] : role === 'dev' || role === 'start' ? ['cargo', 'run'] : undefined; + if (project.kind === 'go') return role === 'test' ? ['go', 'test', './...'] : role === 'build' ? ['go', 'build', './...'] : role === 'dev' || role === 'start' ? ['go', 'run', '.'] : undefined; + if (project.kind === 'make') { const target = (project.makeTargets ?? []).find(name => ROLE_SCRIPTS[role].includes(name)); return target ? ['make', target] : undefined; } + return undefined; +} + +const ROLE_WORDS: Array<[Role, RegExp]> = [ + ['test', /\b(?:tests?|specs?|test suite)\b/u], + ['build', /\b(?:build|compile)\b/u], + ['lint', /\b(?:lint|linter|typecheck|type check)\b/u], + ['dev', /\b(?:dev server|development server|dev mode|the server|local server|server|dev)\b/u], + ['start', /\b(?:the app|the project|this project|it|the site|the website|app|project)\b/u], +]; + +function block(argv: string[], note: string, run: CommandBlock['run'], facts: Array<[string, string]>): CommandBlock { + return {argv, provenance: 'context', risk: 'mutate', note, facts, ...(run ? {run} : {})}; +} + +export function resolveProject(text: string, context: AskContext): AskOutcome | undefined { + const tasks = context.tasks ?? []; + const live = tasks.filter(task => task.status === 'running' || task.status === 'starting' || task.status === 'waiting'); + const aboutTask = /\b(?:dev server|server|it|its|that|the task|background task|the app|the site)\b/u.test(text); + + // Managed tasks: URL, output, open, stop. Only tasks NMSh started are ever mentioned or stopped. + if (tasks.length && aboutTask) { + const task = live.at(-1) ?? tasks.at(-1)!; + if (/\b(?:url|address|port|link|where)\b/u.test(text) && /\b(?:what|which|where|show)\b/u.test(text)) { + return task.urls.length ? {kind: 'answer', capability: 'project.task', text: `${task.label} printed:\n${task.urls.map(url => ` ${url}`).join('\n')}`, + next: task.urls.slice(0, 2).map(url => ({key: `open:${url}`, label: `Open ${url}`, outcome: openUrlProposal(url)}))} + : {kind: 'answer', capability: 'project.task', text: `${task.label} hasn't printed a URL yet${task.status === 'running' ? '' : ` (it is ${task.status})`}.`}; + } + if (/^(?:please )?(?:open|visit|browse|launch)\b/u.test(text) && !/\b(?:file|folder|config)\b/u.test(text)) { + const url = task.urls[0]; + return url ? openUrlProposal(url) : {kind: 'answer', capability: 'project.task', text: `${task.label} hasn't printed a URL to open yet.`}; + } + if (/\b(?:output|logs?|show it|what did it (?:say|print))\b/u.test(text)) { + return {kind: 'proposal', capability: 'project.task', safety: 'navigate', confidence: 0.9, direct: true, text: `Output of ${task.label}`, action: {kind: 'taskOutput', id: task.id}}; + } + if (/\b(?:stop|kill|end|shut down|quit|terminate)\b/u.test(text)) { + if (!live.length) return {kind: 'answer', capability: 'project.task', text: `${task.label} isn't running (${task.status}).`}; + if (live.length > 1 && !/\b(?:dev server|server)\b/u.test(text)) { + return {kind: 'choose', reason: 'missing', capability: 'project.task', question: 'Stop which task?', options: live.map(item => ({key: `stop:${item.id}`, label: item.label, outcome: stopProposal(item)}))}; + } + return stopProposal(live.at(-1)!); + } + } + if (!tasks.length && /\b(?:dev server|server)\b/u.test(text) && /\b(?:url|address|port|open|visit|output|logs?)\b/u.test(text)) { + return {kind: 'answer', capability: 'project.task', text: 'NMSh isn\'t running a dev server for you right now. "run the dev server" starts one in the background, and its URL shows here once it prints one.'}; + } + if (!tasks.length && /^(?:show|see|view)(?: me)? (?:its|the) (?:output|logs?)$/u.test(text) && context.recent?.[0]) { + return {kind: 'answer', capability: 'project.task', text: `The output of ${context.recent[0].command} is in the transcript above (Esc closes Ask; Ctrl+O expands folded output).`}; + } + if (/\b(?:stop|kill)\b.*\b(?:dev server|server)\b/u.test(text) && !tasks.length) { + return {kind: 'answer', capability: 'project.task', text: 'NMSh isn\'t running a dev server for you. Ask only stops tasks it started; a server you started in the shell stops with Ctrl+C there.'}; + } + if (/\b(?:background tasks?|managed tasks?|what am i running|what(?:'s| is) running)\b/u.test(text) && tasks.length) { + return {kind: 'answer', capability: 'project.task', text: `Tasks NMSh started:\n${tasks.map(task => ` ${task.label} · ${task.status}${task.urls[0] ? ` · ${task.urls[0]}` : ''}`).join('\n')}`}; + } + + const project = context.project; + const projectWords = /\b(?:scripts?|run|start|launch|test|tests|build|lint|dev|server|app|project)\b/u.test(text); + if (!projectWords) return undefined; + if (/\bscripts?\b/u.test(text) && /\b(?:what|which|list|show|have|available)\b/u.test(text)) { + if (!project) return {kind: 'answer', capability: 'project.run', text: 'This folder has no package.json or other project file NMSh recognizes.'}; + if (project.kind !== 'node') return {kind: 'answer', capability: 'project.run', text: project.kind === 'make' ? `Make targets: ${(project.makeTargets ?? []).join(', ') || 'none found'}.` : `This is a ${project.kind} project; it has no package.json scripts.`}; + const names = Object.keys(project.scripts); + if (!names.length) return {kind: 'answer', capability: 'project.run', text: 'package.json defines no scripts.'}; + return {kind: 'choose', reason: 'missing', capability: 'project.run', question: `Scripts in package.json · ${project.manager} (${project.managerSource})`, + options: names.slice(0, 40).map(name => ({key: `script:${name}`, label: name, detail: project.scripts[name]!.slice(0, 60), outcome: scriptOutcome(project, name, false)}))}; + } + const runVerb = /^(?:please |can you |could you |go ahead and |let's |lets )*(?:run|start|launch|boot|spin up|fire up|serve|execute|kick off)\b/u.test(text); + const howVerb = /^(?:please )?(?:how (?:do|can|would|should) i|how to|what(?:'s| is) the command to)\s+(?:run|start|launch|test|build|serve|lint)\b/u.test(text); + if (!runVerb && !howVerb) return undefined; + const role = ROLE_WORDS.find(([, words]) => words.test(text))?.[0] ?? (/\b(?:run|start)\b/u.test(text) ? 'start' : undefined); + if (!role) return undefined; + if (!project) return {kind: 'answer', capability: 'project.run', text: 'This folder has no package.json, Cargo.toml, go.mod or Makefile, so NMSh doesn\'t know how this project runs.'}; + // "run only the ask tests": the test script plus a filter argument; whether it filters depends on the script, so it is shown, not run. + const only = /\bonly (?:the )?([\w.-]+) tests?\b|\b([\w.-]+) tests? only\b/u.exec(text); + if (role === 'test' && only && project.kind === 'node') { + const script = scriptFor(project, 'test'); + const filter = only[1] ?? only[2]!; + if (!script) return {kind: 'answer', capability: 'project.run', text: 'package.json has no test script.'}; + const argv = [...scriptArgv(project, script), '--', filter]; + return {kind: 'answer', capability: 'project.run', text: `Arguments after -- go to the ${script} script (${project.scripts[script]}). Whether "${filter}" filters tests depends on that script, so check before relying on it.`, + block: {argv, provenance: 'context', risk: 'mutate', note: `Passes "${filter}" to the ${script} script.`, facts: [['manager', `${project.manager}`], ['script', project.scripts[script]!]]}, + referents: {command: argv.slice(0, 2)}}; + } + if (project.kind === 'node') { + const script = scriptFor(project, role); + if (!script) { + const names = Object.keys(project.scripts); + return {kind: 'choose', reason: 'missing', capability: 'project.run', question: `package.json has no ${role} script. Its scripts:`, + options: names.slice(0, 20).map(name => ({key: `script:${name}`, label: name, detail: project.scripts[name]!.slice(0, 60), outcome: scriptOutcome(project, name, howVerb)}))}; + } + return scriptOutcome(project, script, howVerb, /\b(?:keep (?:it )?running|in the background|background)\b/u.test(text)); + } + const argv = fixedFor(project, role); + if (!argv) return {kind: 'answer', capability: 'project.run', text: `NMSh doesn't know a ${role} command for this ${project.kind} project.`}; + return commandOutcome(project, argv, argv.join(' '), howVerb, role === 'dev' || role === 'start'); +} + +function scriptOutcome(project: ProjectFacts, script: string, explainOnly: boolean, background = false): AskOutcome { + const body = project.scripts[script] ?? ''; + return commandOutcome(project, scriptArgv(project, script), script, explainOnly, background || isLongRunning(script, body), body); +} + +function commandOutcome(project: ProjectFacts, argv: string[], label: string, explainOnly: boolean, longRunning: boolean, body?: string): AskOutcome { + const facts: Array<[string, string]> = [...(project.manager ? [['manager', `${project.manager} · ${project.managerSource}`] as [string, string]] : []), ...(body ? [['script', body] as [string, string]] : [])]; + const run = longRunning ? {kind: 'startTask' as const, argv, cwd: project.root, label: taskLabel(label, body)} : {kind: 'project' as const, argv}; + const note = longRunning ? 'Keeps running; Ask starts it in the background so the shell stays free.' : 'Runs in the shell; its output goes to the transcript.'; + if (explainOnly) { + return {kind: 'answer', capability: 'project.run', text: longRunning ? `This starts ${taskLabel(label, body)}:` : `This runs ${label}:`, block: block(argv, note, run, facts), referents: {command: argv.slice(0, 2)}}; + } + return {kind: 'proposal', capability: 'project.run', safety: 'mutate', confidence: 0.9, command: argv.join(' '), + text: longRunning ? `Start ${taskLabel(label, body)} in the background? It keeps running; Ask shows its URL and output, and can stop it.` : `Run ${label}? It runs in the shell.`, + action: run, referents: {command: argv.slice(0, 2)}}; +} + +export function taskLabel(script: string, body = ''): string { + return /^(?:dev|serve|start|preview)/u.test(script) || /\b(?:vite|next|nuxt|webpack serve|astro|remix)\b/u.test(body) ? 'Dev server' : `${script} (background)`; +} + +function stopProposal(task: TaskSummary): AskOutcome { + return {kind: 'proposal', capability: 'project.task', safety: 'mutate', confidence: 0.95, command: task.command, + text: `Stop ${task.label}? NMSh started it; only that task (and the processes it started) stops.`, action: {kind: 'stopTask', id: task.id}}; +} + +export function openUrlProposal(url: string): AskOutcome { + return {kind: 'proposal', capability: 'project.task', safety: 'navigate', confidence: 0.95, text: `Open ${url} in your browser?`, action: {kind: 'openUrl', url}}; +} + +/** URLs Ask may hand to the system opener: http(s) only, no spaces or quotes. */ +export function openableUrl(url: string): boolean { + return /^https?:\/\/[\w.[\]:-]+(?::\d{1,5})?(?:\/[\w./%?=&#~+-]*)?$/u.test(url); +} diff --git a/src/ask/recipes.ts b/src/ask/recipes.ts new file mode 100644 index 00000000..72b370ec --- /dev/null +++ b/src/ask/recipes.ts @@ -0,0 +1,205 @@ +import {networkInterfaces, freemem, totalmem} from 'node:os'; +import type {CommandEnvironment} from './commands.js'; +import type {AskContext, AskOption, AskOutcome, CommandBlock} from './types.js'; + +/** + * Typed terminal recipes: common command-line tasks as concepts + * (archive.extract, network.ping, process.port…) whose commands NMSh builds + * from validated arguments and the platform it runs on. Request text never + * becomes command text: a host, port, pattern or path is accepted only after + * strict validation, otherwise it is a visible . An optional + * model may only choose a recipe id and those bounded arguments. + * + * Asking "how do I…" explains (Copy/Insert, Run offered behind the final + * Yes); asking for the thing itself runs local read-only recipes at once, + * network ones after the final Yes. Recipes never mutate or destroy. + */ + +export type RecipeRisk = 'read' | 'network'; +export type Platform = 'darwin' | 'linux' | 'other'; + +export interface RecipeArgs {host?: string; port?: number; pattern?: string; name?: string; archive?: 'zip' | 'tar.gz' | 'tar'; mode?: string; path?: string} + +interface Recipe { + id: string; + title: string; + /** Request shapes for this concept, matched against normalized text. */ + match(text: string): RecipeArgs | undefined; + /** An answer computed in-process from local facts (no command at all), when that is the honest answer. */ + fact?(args: RecipeArgs, context: AskContext): string | undefined; + build(args: RecipeArgs, platform: Platform, has: (name: string) => boolean): {argv: string[]; placeholders?: number[]; note: string; risk: RecipeRisk; also?: string} | undefined; +} + +const HOST = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$|^(?:\d{1,3}\.){3}\d{1,3}$|^localhost$/u; +/** Short names people use for well-known hosts; anything else needs the full host name. */ +const KNOWN_HOSTS: Record = {google: 'google.com', github: 'github.com', cloudflare: 'cloudflare.com', apple: 'apple.com', microsoft: 'microsoft.com', + amazon: 'amazon.com', wikipedia: 'wikipedia.org', npm: 'registry.npmjs.org', 'the internet': '1.1.1.1', internet: '1.1.1.1'}; +const SAFE_WORD = /^[\w.@%+=:,-]{1,80}$/u; + +export function validHost(value: string): string | undefined { + const host = value.toLowerCase().replace(/^https?:\/\//u, '').replace(/\/.*$/u, ''); + return HOST.test(host) ? host : KNOWN_HOSTS[host]; +} + +/** Unix permission digits → rwx text, computed (no lookup). */ +export function explainMode(mode: string): string | undefined { + if (!/^[0-7]{3,4}$/u.test(mode)) return undefined; + const digits = mode.slice(-3).split('').map(Number); + const rwx = (digit: number) => `${digit & 4 ? 'r' : '-'}${digit & 2 ? 'w' : '-'}${digit & 1 ? 'x' : '-'}`; + const words = (digit: number) => [digit & 4 ? 'read' : '', digit & 2 ? 'write' : '', digit & 1 ? 'execute' : ''].filter(Boolean).join(', ') || 'nothing'; + const special = mode.length === 4 && mode[0] !== '0' ? ` The leading ${mode[0]} sets ${[Number(mode[0]) & 4 ? 'setuid' : '', Number(mode[0]) & 2 ? 'setgid' : '', Number(mode[0]) & 1 ? 'sticky' : ''].filter(Boolean).join(' and ')}.` : ''; + return `chmod ${mode} sets ${digits.map(rwx).join('')}:\n owner ${rwx(digits[0]!)} ${words(digits[0]!)}\n group ${rwx(digits[1]!)} ${words(digits[1]!)}\n others ${rwx(digits[2]!)} ${words(digits[2]!)}\nEach digit adds read 4, write 2, execute 1.${special}`; +} + +const RECIPES: Recipe[] = [ + {id: 'archive.extract', title: 'Extract an archive', + match: text => /\b(?:unzip|extract|decompress|uncompress|unpack|open)\b/u.test(text) && /\b(?:zip|tar|tgz|tar\.gz|archive|tarball|gz)\b/u.test(text) && !/\b(?:make|create)\b/u.test(text) + ? {archive: /\bzip\b/u.test(text) && !/\btar\b/u.test(text) ? 'zip' : /\bgz|tgz\b/u.test(text) ? 'tar.gz' : /\btar\b/u.test(text) ? 'tar' : 'zip', ...pathArg(text)} : undefined, + build: (args) => args.archive === 'zip' + ? {argv: ['unzip', args.path ?? ''], placeholders: args.path ? [] : [1], note: 'Extracts into the current folder (-d picks another).', risk: 'read'} + : {argv: ['tar', args.archive === 'tar' ? '-xf' : '-xzf', args.path ?? ``], placeholders: args.path ? [] : [2], note: 'x extract, z gzip, f the archive file. Add -C to extract elsewhere.', risk: 'read'}}, + {id: 'archive.create', title: 'Create an archive', + match: text => /\b(?:make|create|build|compress|zip up|archive)\b/u.test(text) && /\b(?:zip|tar|tgz|tar\.gz|archive|tarball)\b/u.test(text) + ? {archive: /\btar\.gz|tgz|tarball|gz\b/u.test(text) ? 'tar.gz' : /\btar\b/u.test(text) ? 'tar' : 'zip'} : undefined, + build: (args) => args.archive === 'zip' + ? {argv: ['zip', '-r', '', ''], placeholders: [2, 3], note: '-r includes everything inside the folder.', risk: 'read'} + : {argv: ['tar', args.archive === 'tar' ? '-cf' : '-czf', ``, ''], placeholders: [2, 3], note: 'c create, z gzip, f the output file.', risk: 'read'}}, + {id: 'permissions.explain', title: 'Explain a permission mode', + match: text => { const mode = /\bchmod\s+([0-7]{3,4})\b/u.exec(text)?.[1] ?? (/\b(?:permissions?|mode)\b/u.test(text) ? /\b([0-7]{3,4})\b/u.exec(text)?.[1] : undefined); return mode ? {mode} : undefined; }, + fact: args => explainMode(args.mode!), + build: () => undefined}, + {id: 'filesystem.find', title: 'Find files by name', + match: text => /^(?:how (?:do|can) i|how to)\s+(?:find|locate|search for)\s+(?:a |the )?files?\b/u.test(text) ? {...nameArg(text)} : undefined, + build: (args, _platform, has) => has('fd') && args.name + ? {argv: ['fd', args.name], note: 'fd searches by name from here, skipping ignored files.', risk: 'read'} + : {argv: ['find', '.', '-name', args.name ? `*${args.name}*` : ''], placeholders: args.name ? [] : [3], note: 'Searches from this folder down; -iname ignores case.', risk: 'read', + also: 'Ask can also find files itself: "find files named config".'}}, + {id: 'search.text', title: 'Search file contents', + match: text => { const match = /^(?:please )?(?:grep|search|rg|ripgrep|look|find)\s+(?:for|the text|text|code for|the code for)\s+["']?([^"'\s]+)["']?(?:\s+in (?:here|this (?:repo|project|folder)|the (?:repo|project|code)))?$/u.exec(text) + ?? /^(?:how (?:do|can) i|how to)\s+(?:grep|search (?:for )?text|search (?:inside|in) files)\b/u.exec(text); return match ? {...(match[1] && SAFE_WORD.test(match[1]) ? {pattern: match[1]} : {})} : undefined; }, + build: (args, _platform, has) => has('rg') + ? {argv: ['rg', '-n', args.pattern ?? ''], placeholders: args.pattern ? [] : [2], note: 'Searches files under this folder (respecting .gitignore) and shows line numbers.', risk: 'read'} + : {argv: ['grep', '-rn', args.pattern ?? '', '.'], placeholders: args.pattern ? [] : [2], note: 'Searches files under this folder and shows line numbers.', risk: 'read'}}, + {id: 'network.ping', title: 'Check a host is reachable', + match: text => { const match = /\bping\s+([\w.:/-]+|the internet)\b/u.exec(text); return match || /\bping\b/u.test(text) ? {...(match && validHost(match[1]!) ? {host: validHost(match[1]!)} : {})} : undefined; }, + build: (args) => ({argv: ['ping', '-c', '4', args.host ?? ''], placeholders: args.host ? [] : [3], note: 'Sends 4 packets and reports round-trip times (-c stops it; plain ping runs until Ctrl+C).', risk: 'network'})}, + {id: 'system.diskUsage', title: 'Disk usage', + match: text => /\b(?:disk|storage|drive)\b.*\b(?:usage|space|full|free|left|used)\b|\b(?:free|used) (?:disk )?space\b|\bhow (?:much|full) (?:disk|space|storage)\b|\bdf\b/u.test(text) + ? {...(/\b(?:this|the|current) (?:folder|directory|repo|project)\b|\bfolder size\b/u.test(text) ? {path: '.'} : {})} : undefined, + build: (args) => args.path ? {argv: ['du', '-sh', '.'], note: 'Total size of this folder.', risk: 'read'} : {argv: ['df', '-h'], note: 'Size, used and available space on each mounted volume.', risk: 'read'}}, + {id: 'process.port', title: 'What is using a port', + match: text => { const port = /\bports?\s+(\d{1,5})\b|\b:(\d{1,5})\b|\b(\d{2,5})\s+port\b/u.exec(text); const value = Number(port?.[1] ?? port?.[2] ?? port?.[3]); + return port && /\b(?:using|on|listening|running|uses|who|what|which|taken|busy|occupied)\b/u.test(text) && value > 0 && value < 65536 ? {port: value} : undefined; }, + build: (args, platform, has) => has('lsof') || platform === 'darwin' + ? {argv: ['lsof', '-nP', `-iTCP:${args.port}`, '-sTCP:LISTEN'], note: `The process listening on TCP port ${args.port} (PID, command, user).`, risk: 'read'} + : has('ss') ? {argv: ['ss', '-ltnp', `sport = :${args.port}`], note: `Sockets listening on port ${args.port}; the process shows when it is yours.`, risk: 'read'} : undefined}, + {id: 'process.list', title: 'Running processes', + match: text => { const named = /\b(?:running|active)\s+([\w.-]+)\s+process(?:es)?\b|\b([\w.-]+)\s+process(?:es)?\b.*\brunning\b|\bprocess(?:es)? (?:named|called|for)\s+([\w.-]+)/u.exec(text); + if (named) { const name = named[1] ?? named[2] ?? named[3]; return name && !/^(?:the|all|my|every|any)$/u.test(name) ? {name} : {}; } + return /\b(?:what(?:'s| is)? running|running processes|list processes|show processes|what am i running|top processes)\b/u.test(text) ? {} : undefined; }, + build: (args, platform) => args.name + ? {argv: platform === 'darwin' ? ['pgrep', '-lf', args.name] : ['pgrep', '-af', args.name], note: `Processes whose command line contains "${args.name}", with their PIDs.`, risk: 'read'} + : {argv: platform === 'darwin' ? ['ps', '-Ao', 'pid,%cpu,%mem,etime,comm', '-r'] : ['ps', '-eo', 'pid,%cpu,%mem,etime,comm', '--sort=-%cpu'], note: 'Every process, busiest first.', risk: 'read'}}, + {id: 'network.localAddress', title: 'Local IP address', + match: text => /\b(?:my |local |internal |lan |private )?ip(?: address)?\b|\bip addr/u.test(text) && !/\bpublic|external\b/u.test(text) ? {} : undefined, + fact: () => { + const addresses = Object.entries(networkInterfaces()).flatMap(([name, list]) => (list ?? []).filter(item => item.family === 'IPv4' && !item.internal).map(item => `${item.address} ${name}`)); + return addresses.length ? `Local IPv4 address${addresses.length === 1 ? '' : 'es'}:\n${addresses.map(line => ` ${line}`).join('\n')}` : 'This machine has no non-loopback IPv4 address right now.'; + }, + build: (_args, platform) => platform === 'darwin' ? {argv: ['ipconfig', 'getifaddr', 'en0'], note: 'en0 is usually Wi-Fi on a Mac; ifconfig lists every interface.', risk: 'read'} + : {argv: ['hostname', '-I'], note: 'Every address assigned to this host.', risk: 'read'}}, + {id: 'system.memory', title: 'Memory use', + match: text => /\b(?:memory|ram)\b/u.test(text) && /\b(?:how much|using|used|free|usage|left|available|show|check)\b/u.test(text) ? {} : undefined, + fact: (_args, context) => { + const total = totalmem(); + const free = freemem(); + const gb = (bytes: number) => `${(bytes / 1024 ** 3).toFixed(1)} GB`; + return `Memory: ${gb(total - free)} used of ${gb(total)} (${gb(free)} free).${context.platform === 'darwin' ? '\nmacOS counts cache as used; Activity Monitor\'s Memory Pressure is the better signal.' : ''}`; + }, + build: (_args, platform) => platform === 'darwin' ? {argv: ['top', '-l', '1', '-s', '0', '-n', '0'], note: 'One snapshot of system memory and CPU.', risk: 'read'} + : {argv: ['free', '-h'], note: 'Used, free and cached memory.', risk: 'read'}}, + {id: 'filesystem.cwd', title: 'Current directory', + match: text => /\b(?:current|working|which|what) (?:directory|folder|dir)\b|\bwhere am i\b|\bpwd\b/u.test(text) && !/\bfiles?\b/u.test(text) ? {} : undefined, + fact: (_args, context) => `You're in ${context.cwd}${context.repoRoot && context.repoRoot !== context.cwd ? ` (inside ${context.repoRoot})` : ''}.`, + build: () => ({argv: ['pwd'], note: 'Prints the working directory.', risk: 'read'})}, +]; + +function pathArg(text: string): RecipeArgs { + const path = /\b([\w./~-]+\.(?:zip|tar\.gz|tgz|tar))\b/u.exec(text)?.[1]; + return path && SAFE_WORD.test(path.replace(/[/~]/gu, '')) ? {path} : {}; +} +function nameArg(text: string): RecipeArgs { + const name = /\b(?:named|called)\s+["']?([\w.*-]+)["']?/u.exec(text)?.[1]; + return name ? {name} : {}; +} + +const HOW = /^(?:please )?(?:how (?:do|can|would|should) i|how to|what(?:'s| is) the (?:command|syntax)|which command|what command|show me (?:how|the command)|tell me how|explain how)\b/u; +const DO_IT = /^(?:please |can you |could you |would you |go ahead and |just )*(?:ping|show|list|check|run|tell me|what(?:'s| is| are)?|how much|how full|which|who|find|grep|search|where)\b/u; + +export interface RecipeMatch {recipe: Recipe; args: RecipeArgs} + +export function matchRecipe(text: string): RecipeMatch | undefined { + for (const recipe of RECIPES) { + const args = recipe.match(text); + if (args) return {recipe, args}; + } + return undefined; +} + +/** Recipe argv Ask may run: only shapes this module builds, re-checked by the app right before running. */ +export function recipeRunAllowed(argv: readonly string[]): RecipeRisk | undefined { + const [command, ...rest] = argv; + if (argv.some(part => /^<.*>$/u.test(part) || /[\n\u0000]/u.test(part))) return undefined; + if (command === 'ping') return rest.length === 3 && rest[0] === '-c' && rest[1] === '4' && validHost(rest[2]!) === rest[2] ? 'network' : undefined; + const read: Record boolean> = { + df: args => args.join(' ') === '-h', du: args => args.join(' ') === '-sh .', pwd: args => !args.length, + lsof: args => args.length === 3 && args[0] === '-nP' && /^-iTCP:\d{1,5}$/u.test(args[1]!) && args[2] === '-sTCP:LISTEN', + ss: args => args.length === 2 && args[0] === '-ltnp' && /^sport = :\d{1,5}$/u.test(args[1]!), + pgrep: args => args.length === 2 && /^-(?:lf|af)$/u.test(args[0]!) && SAFE_WORD.test(args[1]!), + ps: args => /^(?:-Ao|-eo)$/u.test(args[0] ?? '') && args[1] === 'pid,%cpu,%mem,etime,comm', + rg: args => args.length === 2 && args[0] === '-n' && SAFE_WORD.test(args[1]!), + grep: args => args.length === 3 && args[0] === '-rn' && SAFE_WORD.test(args[1]!) && args[2] === '.', + fd: args => args.length === 1 && SAFE_WORD.test(args[0]!), + find: args => args.length === 3 && args[0] === '.' && args[1] === '-name' && /^\*?[\w.-]+\*?$/u.test(args[2]!), + 'ipconfig': args => args.join(' ') === 'getifaddr en0', hostname: args => args.join(' ') === '-I', free: args => args.join(' ') === '-h', + top: args => args.join(' ') === '-l 1 -s 0 -n 0', + }; + return command && read[command]?.(rest) ? 'read' : undefined; +} + +/** + * Resolve a terminal task. "how do I…" explains with the command (Run behind + * the final Yes); asking for the result runs a local read-only recipe at once + * and a network one after the Yes. Undefined leaves the request to the rest + * of the resolver. + */ +export function resolveRecipe(text: string, context: AskContext, commands?: CommandEnvironment): AskOutcome | undefined { + const found = matchRecipe(text); + if (!found) return undefined; + const {recipe, args} = found; + const platform: Platform = context.platform === 'darwin' ? 'darwin' : context.platform === 'linux' ? 'linux' : 'other'; + const has = (name: string) => Boolean(commands?.identity(name)); + const how = HOW.test(text); + const fact = recipe.fact?.(args, context); + const built = recipe.build(args, platform, has); + if (!built) return fact ? {kind: 'answer', capability: 'help.command', text: fact} : undefined; + // The executable must exist here; otherwise the command is shown as knowledge, never run. + const installed = has(built.argv[0]!); + const runnable = installed && !built.placeholders?.length && recipeRunAllowed(built.argv) === built.risk; + const block: CommandBlock = {argv: built.argv, ...(built.placeholders?.length ? {placeholders: built.placeholders} : {}), provenance: 'reference', risk: 'read', + note: `${built.note}${installed ? '' : ` (${built.argv[0]} isn't installed here)`}`, ...(runnable ? {run: {kind: 'recipe', argv: built.argv, risk: built.risk}} : {})}; + if (fact && (!how || !built)) { + return {kind: 'answer', capability: 'help.command', text: fact, block, referents: {command: built.argv.slice(0, 1), block}}; + } + // Asking for the thing itself: local reads run now; network reads wait for the final Yes. + if (!how && runnable && DO_IT.test(text)) { + return {kind: 'proposal', capability: 'help.command', safety: 'read', confidence: 0.9, direct: built.risk === 'read', + text: built.risk === 'network' ? `${recipe.title}: this contacts ${args.host ?? 'the network'}. Run it?` : `${recipe.title}:`, + command: built.argv.join(' '), action: {kind: 'recipe', argv: built.argv, risk: built.risk}, referents: {command: built.argv.slice(0, 1), block}}; + } + const next: AskOption[] = built.also ? [{key: `recipe:${recipe.id}:also`, label: built.also.replace(/^Ask can also /u, '').replace(/^find/u, 'Find'), refine: 'find files named '}] : []; + return {kind: 'answer', capability: 'help.command', text: `${recipe.title}:${built.risk === 'network' ? ' (contacts the network)' : ''}`, block, next, referents: {command: built.argv.slice(0, 1), block}}; +} + +/** Recipe ids and titles, for the guide and an optional model's inventory (ids only, never commands). */ +export const RECIPE_INVENTORY = RECIPES.map(recipe => ({id: recipe.id, title: recipe.title})); diff --git a/src/ask/repair.ts b/src/ask/repair.ts new file mode 100644 index 00000000..9edbd262 --- /dev/null +++ b/src/ask/repair.ts @@ -0,0 +1,247 @@ +import {spawnSync} from 'node:child_process'; +import {existsSync} from 'node:fs'; +import {basename, dirname, extname, join} from 'node:path'; +import {inspectFile, parseJsonc, scanJson, previewDiff, sha256, type FileEditPlan, type FileFacts, type PlanResult} from './fileEdit.js'; + +/** + * Small, verified repairs: JSON syntax, an unclosed Python bracket, a missing + * Python block indent, and formatter commands. A repair is offered only when + * exactly one minimal candidate fixes the reported problem and the candidate + * itself passes the same check (parse/compile) before it is proposed. Anything + * with several plausible fixes is reported, never guessed. This is not a + * coding agent: semantic changes are out of scope. + */ + +export type Checker = (text: string) => {ok: true} | {ok: false; message: string; line?: number}; + +/** Strict JSON (or JSONC) structure check. */ +export function jsonChecker(comments: boolean): Checker { + return text => { + const scan = scanJson(text, comments); + return scan.ok ? {ok: true} : {ok: false, message: scan.error!.message, line: text.slice(0, scan.error!.offset).split('\n').length}; + }; +} + +/** Python's own compiler when python3 exists (no execution: compile only, from stdin). */ +export function pythonChecker(python3: string | undefined): Checker | undefined { + if (!python3) return undefined; + return text => { + const result = spawnSync(python3, ['-c', 'import sys\ntry:\n compile(sys.stdin.read(), "", "exec")\nexcept SyntaxError as e:\n print(e.lineno or 0); print(e.msg); sys.exit(1)'], + {input: text, encoding: 'utf8', timeout: 5000}); + if (result.status === 0) return {ok: true}; + const [line, message] = (result.stdout ?? '').split('\n'); + return {ok: false, message: message || 'syntax error', line: Number(line) || undefined}; + }; +} + +function planFrom(facts: FileFacts, after: string, reason: string, format: FileEditPlan['format']): PlanResult { + const before = facts.content!; + let start = 0; + while (start < before.length && before[start] === after[start]) start += 1; + let endBefore = before.length; + let endAfter = after.length; + while (endBefore > start && endAfter > start && before[endBefore - 1] === after[endAfter - 1]) { endBefore -= 1; endAfter -= 1; } + const edit = {start, end: endBefore, text: after.slice(start, endAfter)}; + const diff = previewDiff(before, after, edit); + return {kind: 'plan', plan: {path: facts.path, resolvedPath: facts.resolvedPath, symlink: facts.symlink, format, operation: 'replace', edits: [edit], + expectedSha256: sha256(before), resultSha256: sha256(after), preview: diff.lines, line: diff.line, reason}}; +} + +/** JSON: try the minimal single-character fixes at the reported error; exactly one that parses is the repair. */ +export function repairJson(facts: FileFacts, comments: boolean): PlanResult { + const text = facts.content!; + const check = jsonChecker(comments); + const first = check(text); + if (first.ok) return {kind: 'noop', reason: `${basename(facts.path)} is already valid JSON${comments ? ' (with comments)' : ''}.`}; + const scan = scanJson(text, comments); + const at = scan.error!.offset; + // The end of the previous token: where a missing comma or closer belongs. + let previous = at; + while (previous > 0 && /\s/u.test(text[previous - 1]!)) previous -= 1; + const candidates = new Map(); + // The previous token's end first: a fix there is the conventional place for a missing comma or closer. + for (const position of new Set([previous, at])) { + for (const insert of [',', '}', ']', ':']) candidates.set(`${insert}@${position}`, text.slice(0, position) + insert + text.slice(position)); + if (position < text.length) candidates.set(`del@${position}`, text.slice(0, position) + text.slice(position + 1)); + } + // A trailing comma before a closer (strict JSON). + const trailing = /,(\s*)$/u.exec(text.slice(0, at)); + if (trailing) candidates.set('trailing', text.slice(0, at - trailing[0].length) + trailing[1] + text.slice(at)); + // Unclosed containers at end of file: close them in order, on their own lines. + if (at >= text.trimEnd().length) { + const stack: string[] = []; + let inString = false; + for (let index = 0; index < text.length; index += 1) { + const char = text[index]!; + if (inString) { if (char === '\\') index += 1; else if (char === '"') inString = false; continue; } + if (char === '"') inString = true; else if (char === '{' || char === '[') stack.push(char === '{' ? '}' : ']'); else if (char === '}' || char === ']') stack.pop(); + } + if (stack.length) candidates.set('close', `${text.trimEnd()}\n${stack.reverse().join('\n')}\n`); + } + // Fixes that mean the same document (same parsed value) are one repair; the first, most conventional, is used. + const meanings = new Map(); + for (const candidate of candidates.values()) { + if (!check(candidate).ok) continue; + let meaning: string; + try { meaning = JSON.stringify(parseJsonc(candidate)); } catch { continue; } + if (!meanings.has(meaning)) meanings.set(meaning, candidate); + } + const working = [...meanings.values()]; + if (working.length !== 1) { + return {kind: 'refuse', reason: `${basename(facts.path)} has a JSON error at line ${first.line}: ${first.message}. ${working.length ? 'More than one small fix would make it parse, so I won\'t pick one.' : 'No single small fix makes it valid; open it to fix by hand.'}`}; + } + return planFrom(facts, working[0]!, `fixes the JSON error at line ${first.line} (${first.message})`, comments ? 'jsonc' : 'json'); +} + +interface Opener {char: string; line: number; offset: number} + +/** Python tokens that matter for brackets: strings (incl. triple-quoted) and comments are skipped. */ +export function pythonBrackets(text: string): {unclosed: Opener[]; stray: Opener[]} { + const stack: Opener[] = []; + const stray: Opener[] = []; + let line = 1; + for (let index = 0; index < text.length; index += 1) { + const char = text[index]!; + if (char === '\n') { line += 1; continue; } + if (char === '#') { while (index < text.length && text[index] !== '\n') index += 1; index -= 1; continue; } + if (char === '"' || char === '\'') { + const triple = text.startsWith(char.repeat(3), index); + const quote = triple ? char.repeat(3) : char; + index += quote.length; + while (index < text.length && !text.startsWith(quote, index)) { if (text[index] === '\\') index += 1; if (text[index] === '\n') { if (!triple) break; line += 1; } index += 1; } + index += quote.length - 1; + continue; + } + if ('([{'.includes(char)) stack.push({char, line, offset: index}); + else if (')]}'.includes(char)) { + const top = stack.at(-1); + if (top && '([{'.indexOf(top.char) === ')]}'.indexOf(char)) stack.pop(); else stray.push({char, line, offset: index}); + } + } + return {unclosed: stack, stray}; +} + +const indentOf = (line: string) => /^[ \t]*/u.exec(line)![0]; + +/** + * One unclosed Python bracket whose opener ends its line and whose contents + * are the following more-indented lines: close it right after them, at the + * opener line's indentation. Anything else (two unclosed, inline opener) is + * ambiguous and only reported. + */ +export function repairPythonBracket(facts: FileFacts, checker?: Checker): PlanResult { + const text = facts.content!; + const {unclosed, stray} = pythonBrackets(text); + if (stray.length) return {kind: 'refuse', reason: `${basename(facts.path)} has an unmatched "${stray[0]!.char}" at line ${stray[0]!.line}; removing or matching it needs your judgement.`}; + if (!unclosed.length) return {kind: 'noop', reason: `${basename(facts.path)} has no unclosed brackets.`}; + if (unclosed.length > 1) return {kind: 'refuse', reason: `${basename(facts.path)} has ${unclosed.length} unclosed brackets (lines ${unclosed.map(item => item.line).join(', ')}); there are several plausible places to close them, so I won't guess.`}; + const opener = unclosed[0]!; + const lines = text.split('\n'); + const openerLine = lines[opener.line - 1]!; + const lineEnd = text.indexOf('\n', opener.offset); + if (text.slice(opener.offset + 1, lineEnd === -1 ? undefined : lineEnd).replace(/#.*$/u, '').trim() !== '') { + return {kind: 'refuse', reason: `The "${opener.char}" on line ${opener.line} is unclosed, but its contents continue on the same line, so where it should close isn't clear.`}; + } + const base = indentOf(openerLine).length; + let last = opener.line; + for (let index = opener.line; index < lines.length; index += 1) { + const line = lines[index]!; + if (!line.trim()) continue; + if (indentOf(line).length <= base) break; + last = index + 1; + } + if (last === opener.line) return {kind: 'refuse', reason: `The "${opener.char}" on line ${opener.line} is unclosed and has no indented contents to close after.`}; + const closer = ')]}'['([{'.indexOf(opener.char)]!; + const after = [...lines.slice(0, last), `${indentOf(openerLine)}${closer}`, ...lines.slice(last)].join('\n'); + if (checker) { + const result = checker(after); + if (!result.ok) return {kind: 'refuse', reason: `Closing the "${opener.char}" from line ${opener.line} still leaves a syntax error (${result.message}), so I won't propose it.`}; + } else if (pythonBrackets(after).unclosed.length) return {kind: 'refuse', reason: 'That repair would not balance the brackets.'}; + return planFrom(facts, after, `closes the "${opener.char}" opened on line ${opener.line}`, 'source'); +} + +/** + * A block header ("if foo:") followed by a line that is not indented: indent + * that one line when it is the only possible body (the next line returns to + * the header's level or lower). Several candidate lines means the block's + * extent is unclear: ask instead. + */ +export function repairPythonIndent(facts: FileFacts, checker?: Checker): PlanResult { + const lines = facts.content!.split('\n'); + const unit = /\n( +|\t)\S/u.exec(facts.content!)?.[1] ?? ' '; + for (let index = 0; index < lines.length - 1; index += 1) { + const header = lines[index]!; + if (!/:\s*(?:#.*)?$/u.test(header) || !/^\s*(?:if|elif|else|for|while|def|class|with|try|except|finally|async|match|case)\b/u.test(header)) continue; + let bodyIndex = index + 1; + while (bodyIndex < lines.length && !lines[bodyIndex]!.trim()) bodyIndex += 1; + if (bodyIndex >= lines.length) continue; + const headerIndent = indentOf(header).length; + if (indentOf(lines[bodyIndex]!).length > headerIndent) continue; + let following = bodyIndex + 1; + while (following < lines.length && !lines[following]!.trim()) following += 1; + const nextIndent = following < lines.length ? indentOf(lines[following]!).length : -1; + const bodyIndent = indentOf(lines[bodyIndex]!).length; + // A following line at the same level could also belong to the body: the block's extent is not clear. + if (following < lines.length && nextIndent === bodyIndent && !/^\s*(?:elif|else|except|finally)\b/u.test(lines[following]!)) { + return {kind: 'refuse', reason: `Line ${bodyIndex + 1} should be indented under line ${index + 1}, but line ${following + 1} could belong to that block too. Which lines should be inside it?`}; + } + const after = [...lines.slice(0, bodyIndex), `${indentOf(header)}${unit}${lines[bodyIndex]!.trimStart()}`, ...lines.slice(bodyIndex + 1)].join('\n'); + if (checker) { + const result = checker(after); + if (!result.ok) return {kind: 'refuse', reason: `Indenting line ${bodyIndex + 1} still leaves a syntax error (${result.message}), so I won't propose it.`}; + } + return planFrom(facts, after, `indents line ${bodyIndex + 1} under the block on line ${index + 1}`, 'source'); + } + return {kind: 'noop', reason: `I didn't find a block header missing its indented body in ${basename(facts.path)}.`}; +} + +/* ---------- formatters ---------- */ + +export interface Formatter {name: string; argv: string[]; note: string} + +/** Formatters Ask may run: their exact argv shapes (the file is the last argument). */ +export const FORMATTER_ARGV: Readonly> = { + ruff: ['ruff', 'format'], black: ['black'], prettier: ['prettier', '--write'], gofmt: ['gofmt', '-w'], rustfmt: ['rustfmt'], 'clang-format': ['clang-format', '-i'], +}; + +export function formatterAllowed(argv: readonly string[]): boolean { + const shape = Object.values(FORMATTER_ARGV).find(prefix => prefix.every((part, index) => argv[index] === part) && argv.length === prefix.length + 1); + return Boolean(shape) && !argv.at(-1)!.startsWith('-'); +} + +/** Find the project config a formatter would use, walking up to the project root. */ +function hasProjectFile(start: string, root: string, names: readonly string[], exists: (path: string) => boolean): boolean { + for (let directory = start; ; directory = dirname(directory)) { + if (names.some(name => exists(join(directory, name)))) return true; + if (directory === root || dirname(directory) === directory) return false; + } +} + +/** The trusted formatter for a file, if it is installed; project configuration decides between candidates. */ +export function chooseFormatter(path: string, root: string, which: (name: string) => string | undefined, exists: (path: string) => boolean = existsSync): Formatter | {missing: string[]} | undefined { + const extension = extname(path).toLowerCase(); + const dir = dirname(path); + if (extension === '.py') { + const ruffConfigured = hasProjectFile(dir, root, ['ruff.toml', '.ruff.toml'], exists); + if (which('ruff')) return {name: 'ruff', argv: [...FORMATTER_ARGV.ruff!, path], note: ruffConfigured ? 'Uses the project\'s Ruff settings.' : 'Formats the whole file with Ruff\'s defaults (pyproject.toml settings apply if present).'}; + if (which('black')) return {name: 'black', argv: [...FORMATTER_ARGV.black!, path], note: 'Formats the whole file with Black (pyproject.toml settings apply if present).'}; + return {missing: ['ruff', 'black']}; + } + if (/^\.(?:[cm]?[jt]sx?|json|css|scss|md|ya?ml|html)$/u.test(extension)) { + const configured = hasProjectFile(dir, root, ['.prettierrc', '.prettierrc.json', '.prettierrc.js', '.prettierrc.cjs', '.prettierrc.yaml', '.prettierrc.yml', 'prettier.config.js', 'prettier.config.cjs', 'prettier.config.mjs'], exists); + const local = join(root, 'node_modules', '.bin', 'prettier'); + // Without a project Prettier config, reformatting a project with defaults is not what the project chose. + if (!configured) return undefined; + if (exists(local) || which('prettier')) return {name: 'prettier', argv: [...FORMATTER_ARGV.prettier!, path], note: 'Uses the project\'s Prettier config.'}; + return {missing: ['prettier']}; + } + if (extension === '.go') return which('gofmt') ? {name: 'gofmt', argv: [...FORMATTER_ARGV.gofmt!, path], note: 'gofmt has no options; it formats the whole file.'} : {missing: ['gofmt']}; + if (extension === '.rs') return which('rustfmt') ? {name: 'rustfmt', argv: [...FORMATTER_ARGV.rustfmt!, path], note: 'Uses rustfmt.toml if the project has one.'} : {missing: ['rustfmt']}; + if (/^\.(?:c|h|cc|cpp|hpp|m|mm)$/u.test(extension)) { + if (!hasProjectFile(dir, root, ['.clang-format', '_clang-format'], exists)) return undefined; + return which('clang-format') ? {name: 'clang-format', argv: [...FORMATTER_ARGV['clang-format']!, path], note: 'Uses the project\'s .clang-format.'} : {missing: ['clang-format']}; + } + return undefined; +} + +export function inspectForRepair(path: string, roots: readonly string[]): FileFacts { return inspectFile(path, roots); } diff --git a/src/ask/resolver.ts b/src/ask/resolver.ts new file mode 100644 index 00000000..8080de29 --- /dev/null +++ b/src/ask/resolver.ts @@ -0,0 +1,788 @@ +import {resolveConfigRequest} from './configActions.js'; +import {basename, relative} from 'node:path'; +import {slashCommands} from '../commands/slashCommands.js'; +import type {ShellId} from '../shell/adapters/ShellAdapter.js'; +import {CLEAR_LEAD, matchFiles} from './files.js'; +import {resolveGit} from './gitAssist.js'; +import {browseOutcome, resolveFiles} from './fileAssist.js'; +import {resolveRecipe} from './recipes.js'; +import {resolveProject} from './project.js'; +import {resolveLocalModel} from './localModel.js'; +import {resolveWatch} from './watchAssist.js'; +import {failureOutcome, WHY_FAILED} from './failure.js'; +import {ASK_WORDS, correctRequest, correctWord, type TypoVocabulary} from './fuzzy.js'; +import {resolveActivity} from './activity.js'; +import {resolvePackage} from './packages.js'; +import {resolveFileRequest, type FileAssistEnvironment} from './configAssist.js'; +import {askHelpOutcome, GUIDE_REQUEST, guideOutcome, HELP_REQUEST} from './guide.js'; +import {answerCommandQuestion, COMMAND_ALIASES, parseCommandQuestion, type CommandEnvironment} from './commands.js'; +import {CONCEPTS, conceptDestination, conceptIntent, matchConcepts, type Concept, type ConceptIntent} from './concepts.js'; + +const CONCEPTS_BY_ID = new Map(CONCEPTS.map(concept => [concept.id, concept])); +import type {AskAction, AskContext, AskOption, AskOutcome, AskTranscript, CapabilityId, ReadCommand, SafetyClass} from './types.js'; + +/** + * The deterministic Ask resolver: the primary product, useful with no model. + * Text is normalized, matched against a typed capability registry, and + * arguments are resolved only to factual objects from AskContext (files that + * exist, real worktrees, sessions, transcripts, providers). Nothing here runs + * anything; executable outcomes carry typed AskActions. + */ + +export interface Capability { + id: CapabilityId; + title: string; + safety: SafetyClass; + /** Phrases a person might use; shown by "what can you do" and given to an optional model as the inventory. */ + examples: string[]; + /** Strong patterns: a match is high confidence. */ + patterns: RegExp[]; + /** Words that suggest the capability: partial overlap is medium confidence. */ + keywords: string[]; +} + +const SHELL = '(zsh|fish|bash)'; +export const CAPABILITIES: readonly Capability[] = [ + {id: 'shell.current', title: 'Which shell this session runs', safety: 'answer', examples: ['what shell am i using'], + patterns: [/\bwh(?:at|ich) shell\b/u, /\bshell (?:am i|is this|are we)\b/u, /\bcurrent shell\b/u], keywords: ['shell', 'using', 'current', 'which']}, + {id: 'shell.switch', title: 'Switch this session to another shell', safety: 'navigate', examples: ['switch to fish', 'use bash here'], + patterns: [new RegExp(`\\b(?:switch|change|swap|move|go)\\b.*\\b(?:to|into)\\s+${SHELL}\\b`, 'u'), new RegExp(`\\b(?:use|run|start)\\s+${SHELL}\\b(?!.*\\bdefault\\b)`, 'u'), new RegExp(`^${SHELL}$`, 'u')], + keywords: ['switch', 'change', 'shell', 'fish', 'bash', 'zsh']}, + {id: 'shell.install', title: 'Install a missing shell', safety: 'install', examples: ['install fish'], + patterns: [new RegExp(`\\binstall\\s+${SHELL}\\b`, 'u'), new RegExp(`\\bget\\s+${SHELL}\\b`, 'u')], keywords: ['install', 'shell']}, + {id: 'shell.default', title: 'Default shell for new sessions', safety: 'navigate', examples: ['make fish my default shell'], + patterns: [new RegExp(`\\bdefault\\b.*\\b${SHELL}\\b`, 'u'), new RegExp(`\\b${SHELL}\\b.*\\bdefault\\b`, 'u'), /\bdefault shell\b/u], keywords: ['default', 'shell']}, + {id: 'shell.leave', title: 'Leave NMSh for an ordinary shell (and come back)', safety: 'answer', examples: ['how do i leave nmsh'], + patterns: [/\b(?:leave|exit|quit|get out of|escape)\b.*\b(?:nmsh|this)\b/u, /\bordinary shell\b/u, /\bcome back\b/u], keywords: ['leave', 'exit', 'quit', 'nmsh']}, + {id: 'session.list', title: 'Show live sessions', safety: 'navigate', examples: ['show my sessions'], + patterns: [/\b(?:show|list|see|what|which|open)\b.*\b(?:live )?sessions\b/u, /^sessions$/u], keywords: ['sessions', 'session', 'live', 'windows']}, + {id: 'session.resume', title: 'Resume a session or transcript', safety: 'navigate', examples: ['resume yesterday\'s session', 'show old terminal output'], + patterns: [/\b(?:resume|restore|reopen|continue)\b.*\b(?:session|transcript|terminal)\b/u, /\bold (?:terminal )?output\b/u, /\b(?:yesterday|this morning|last night)\b.*\b(?:session|transcript|output)\b/u, + /\b(?:session|transcript)\b.*\b(?:yesterday|this morning|last night|earlier)\b/u, /\bprevious (?:session|transcript)\b/u], keywords: ['resume', 'transcript', 'session', 'yesterday', 'old', 'output', 'previous']}, + {id: 'transcript.find', title: 'Find text in the transcript', safety: 'navigate', examples: ['find error in the transcript'], + patterns: [/\b(?:find|search|look for|grep)\b.+\b(?:in|through) (?:the )?(?:transcript|output|history|terminal)\b/u, /\b(?:find|search for)\s+\S+/u], keywords: ['find', 'search', 'transcript', 'output']}, + {id: 'transcript.filter', title: 'Show only matching output lines', safety: 'navigate', examples: ['only show lines with warning'], + patterns: [/\bonly show\b.*\blines?\b/u, /\bfilter\b.*\b(?:output|transcript|lines|for|by)\b/u, /\bhide (?:lines|everything)\b/u], keywords: ['filter', 'only', 'lines']}, + {id: 'file.open', title: 'Open a file or folder in your editor', safety: 'navigate', examples: ['open package.json', 'open src config', 'open this in zed'], + patterns: [/^(?:please )?(?:open|edit|show me|view)\s+(?!.*\b(?:settings|sessions|theme|tools|prompt|providers|screensaver)\b)\S+/u], keywords: ['open', 'file', 'edit']}, + {id: 'editor.status', title: 'Editor bridge status', safety: 'answer', examples: ['why can\'t i open files'], + patterns: [/\bwhy\b.*\b(?:open|editor)\b/u, /\bwhich editor\b/u, /\beditor\b.*\b(?:work|working|set up|detected)\b/u], keywords: ['editor', 'open', 'zed', 'vscode']}, + {id: 'git.status', title: 'Git status', safety: 'read', examples: ['check git status', 'what changed'], + patterns: [/\bgit status\b/u, /\b(?:what|which) (?:files )?(?:changed|is modified|did i change)\b/u, /\buntracked\b/u, /\bstatus of (?:the )?repo\b/u], keywords: ['git', 'status', 'changed', 'modified']}, + {id: 'git.diff', title: 'Show the working-tree diff', safety: 'read', examples: ['show git diff', 'show changes in my other worktree'], + patterns: [/\bgit diff\b/u, /\b(?:show|see|view)\b.*\b(?:diff|changes)\b/u, /\bwhat (?:did i|have i) (?:change|changed)\b/u, /\bsince (?:my |the )?last commit\b/u], + keywords: ['diff', 'changes', 'changed', 'worktree', 'commit']}, + {id: 'git.branch', title: 'Current Git branch', safety: 'answer', examples: ['what branch am i on'], + patterns: [/\bwh(?:at|ich) branch\b/u, /\bcurrent branch\b/u, /\bbranch am i\b/u], keywords: ['branch']}, + {id: 'git.log', title: 'Recent commits', safety: 'read', examples: ['show recent commits'], + patterns: [/\bgit log\b/u, /\b(?:recent|last|latest) commits?\b/u, /\bcommit history\b/u], keywords: ['log', 'commits', 'history']}, + {id: 'git.worktrees', title: 'Git worktrees', safety: 'answer', examples: ['show my worktrees'], + patterns: [/\bworktrees?\b(?!.*\b(?:diff|changes)\b)/u], keywords: ['worktree', 'worktrees']}, + {id: 'settings.open', title: 'Open Settings', safety: 'navigate', examples: ['open settings'], + patterns: [/\b(?:open|show|change)\b.*\bsettings\b/u, /^settings$/u, /\bpreferences\b/u], keywords: ['settings', 'preferences', 'config']}, + {id: 'theme.open', title: 'Change the theme', safety: 'navigate', examples: ['change theme'], + patterns: [/\b(?:change|switch|pick|choose|open)\b.*\b(?:theme|colou?rs|appearance|palette)\b/u, /^(?:theme|appearance)$/u], keywords: ['theme', 'colors', 'appearance']}, + {id: 'prompt.open', title: 'Configure the prompt', safety: 'navigate', examples: ['change my prompt'], + patterns: [/\b(?:change|configure|edit|customi[sz]e|open)\b.*\bprompt\b(?!.*provider)/u], keywords: ['prompt']}, + {id: 'tools.open', title: 'Optional tools and installs', safety: 'navigate', examples: ['install fastfetch', 'show optional tools'], + patterns: [/\binstall\s+(?!zsh\b|fish\b|bash\b)\S+/u, /\b(?:optional )?tools\b/u], keywords: ['tools', 'install']}, + {id: 'screensaver.open', title: 'Screensaver', safety: 'navigate', examples: ['open the screensaver'], + patterns: [/\bscreen ?saver\b/u, /\bidle visuals?\b/u], keywords: ['screensaver', 'idle']}, + {id: 'providers.open', title: 'Providers', safety: 'navigate', examples: ['what providers are installed'], + patterns: [/\bproviders?\b(?!.*\b(?:using|switch|use)\b)/u], keywords: ['providers', 'provider']}, + {id: 'provider.status', title: 'Which provider is active', safety: 'answer', examples: ['what prompt provider am i using'], + patterns: [/\bwh(?:at|ich)\b.*\b(?:prompt|suggestions?|history|welcome|picker|navigation)\b.*\bprovider\b/u, /\bprovider am i\b/u], keywords: ['provider', 'using']}, + {id: 'provider.switch', title: 'Switch a provider', safety: 'navigate', examples: ['switch suggestions to deja'], + patterns: [/\b(?:switch|change|set|use)\b.*\b(?:suggestions?|history|welcome|picker|navigation)\b.*\b(?:to|with)\b\s+\S+/u, /\buse\s+(?:deja|atuin|fzf|television|zoxide|fastfetch|neofetch|starship|powerlevel10k)\b/u], + keywords: ['switch', 'provider', 'use']}, + {id: 'understanding.set', title: 'Local understanding (optional local model)', safety: 'navigate', examples: ['turn local understanding off', 'use my existing local model'], + patterns: [/\blocal (?:understanding|model)\b/u, /\buse (?:my )?(?:existing )?(?:local )?model\b/u, /\bqwen\b/u], keywords: ['local', 'model', 'understanding']}, + {id: 'help.capabilities', title: 'What Ask can do', safety: 'answer', examples: ['what can you do'], + patterns: [/\bwhat can (?:you|ask|nmsh) do\b/u, /^help$/u, /\bwhat (?:are|is) (?:your|the) (?:commands|options)\b/u], keywords: ['help', 'can', 'do']}, + {id: 'help.command', title: 'Explain an NMSh command', safety: 'answer', examples: ['what does /resume do'], + patterns: [/\/[a-z][\w-]*/u], keywords: []}, + // Product vocabulary (concepts.ts): matched by the concept catalog, not by these patterns. + {id: 'help.guide', title: 'The NMSh guide', safety: 'answer', examples: ['guide me through nmsh'], patterns: [], keywords: []}, + {id: 'help.feature', title: 'Explain an NMSh feature', safety: 'answer', examples: ['what is chroma', 'what is the difference between completion and suggestions'], + patterns: [], keywords: []}, + {id: 'feature.open', title: 'Open where an NMSh feature is configured', safety: 'navigate', examples: ['change cursor blink', 'stop folding my output', 'change my ghost text'], + patterns: [], keywords: []}, +]; + +/** Destructive or authority-escalating requests Ask understands but never performs. */ +const UNSAFE = /\b(?:delete|remove|rm|wipe|erase|purge|destroy|nuke|reset --hard|hard reset|git reset|git clean|clean up untracked|force push|push|commit|chmod|chown|sudo|kill|uninstall|drop|format|overwrite|truncate|rewrite history|rebase|checkout --|discard)\b/u; +/** Requests understood as writing or authoring, which Ask has no capability for. */ +const AUTHORING = /\b(?:write|create|generate|make|build|compile|refactor|fix|implement|send|email|translate)\b/u; +/** Vague references that need context to resolve. */ +const VAGUE = /\b(?:thing|that one|the old one|the other one|from earlier|earlier|before|previous one|last one|it again)\b/u; + +export const CONFIDENCE = {high: 0.85, medium: 0.5} as const; + +export function normalizeRequest(text: string): string { + return text.toLowerCase() + .replace(/[’`]/gu, '\'') + .replace(/\bcan't\b/gu, 'cannot').replace(/\bwhat's\b/gu, 'what is').replace(/\bi'm\b/gu, 'i am').replace(/\bdon't\b/gu, 'do not') + .replace(/[?!,;]+/gu, ' ') + .replace(/\s+/gu, ' ') + .trim(); +} + +export function scoreCapabilities(text: string): Array<{capability: Capability; score: number}> { + const words = new Set(text.split(/[^a-z0-9/.-]+/u).filter(Boolean)); + return CAPABILITIES.map(capability => { + if (capability.patterns.some(pattern => pattern.test(text))) return {capability, score: 0.95}; + const hits = capability.keywords.filter(keyword => words.has(keyword)).length; + return {capability, score: capability.keywords.length ? Math.min(0.7, hits * 0.3) : 0}; + }).filter(item => item.score > 0).sort((a, b) => b.score - a.score); +} + +export interface ResolveState { + /** Interpretation keys the person already rejected in this interaction. */ + rejected?: ReadonlySet; +} + +const shellIn = (text: string): ShellId | undefined => (/\b(zsh|fish|bash)\b/u.exec(text)?.[1] as ShellId | undefined); +const shellLabel = (context: AskContext, id: ShellId) => context.shells.find(shell => shell.id === id)?.label ?? id; + +/** + * One resolved request. Exact words and aliases first; only when that leaves + * Ask unsure are clear typos of known vocabulary corrected (fuzzy.ts) and the + * request resolved again. A correction that changes a command or an action is + * shown ("Interpreted as: git status"); the original wording wins otherwise. + */ +export function resolveRequest(raw: string, context: AskContext, state: ResolveState = {}, commands?: CommandEnvironment, files?: FileAssistEnvironment): AskOutcome { + const exact = resolveExact(raw, context, state, commands, files); + // A broad fallback (a folder list, a transcript search) can hide a typo of a more specific request ("show untrackd files"). + const broad = (outcome: AskOutcome) => (outcome.kind === 'choose' || outcome.kind === 'proposal' || outcome.kind === 'answer') && ['file.browse', 'transcript.find'].includes(outcome.capability ?? ''); + if (!weak(exact) && !broad(exact)) return exact; + const corrected = correctRequest(normalizeRequest(raw), typoVocabulary(context, commands)); + if (!corrected) return exact; + const retry = resolveExact(corrected.text, context, state, commands, files); + if (weak(retry) || (broad(exact) && (broad(retry) || retry.kind === exact.kind && (retry as {capability?: string}).capability === (exact as {capability?: string}).capability))) return exact; + const note = `Interpreted as: ${corrected.text}`; + if (retry.kind === 'proposal') return {...retry, text: `${retry.text}\n${note}`}; + if (retry.kind === 'answer') return {...retry, text: `${note}\n${retry.text}`}; + if (retry.kind === 'choose') return {...retry, question: `${note}\n${retry.question}`}; + return retry; +} + +const weak = (outcome: AskOutcome) => outcome.kind === 'unclear' || (outcome.kind === 'answer' && /is not an NMSh command/u.test(outcome.text)) || (outcome.kind === 'choose' && outcome.reason === 'ambiguous') + || (outcome.kind === 'answer' && /^No file matching|doesn't know|I don't know|not a .* subcommand NMSh knows/u.test(outcome.text)) + || (outcome.kind === 'answer' && /\(\S+ is not a .+ subcommand NMSh knows\)/u.test(outcome.text)); + +let typoWords: string[] | undefined; +function typoVocabulary(context: AskContext, commands?: CommandEnvironment): TypoVocabulary { + typoWords ??= [...new Set([...ASK_WORDS, ...CONCEPTS.flatMap(concept => concept.aliases).flatMap(alias => alias.split(/\s+/u)).filter(word => /^[a-z][a-z-]{2,}$/u.test(word)), + ...slashCommands.map(command => command.name.split(' ')[0]!), ...Object.keys(COMMAND_ALIASES), 'zsh', 'fish', 'bash', 'deja', 'atuin', 'fzf', 'television', 'zoxide', 'starship', 'fastfetch', 'neofetch'])]; + const files = (context.files ?? []).map(path => basename(path).toLowerCase()); + const commandNames = () => commands?.reference.commandNames() ?? []; + const known = new Set([...typoWords, ...files, ...files.map(name => name.replace(/\.[^.]+$/u, ''))]); + // Command names are known words too (many are English: make, find, open), so they are never "corrected" away. + const knownCommands = new Set(commandNames()); + return {known: {has: (word: string) => known.has(word) || knownCommands.has(word) || COMMON.has(word)} as ReadonlySet, words: typoWords, commands: commandNames, + subcommands: command => commands?.reference.lookup([command])?.facts.subcommands.flatMap(item => item.names) ?? []}; +} + +/** Everyday words that are never typo candidates. */ +const COMMON = new Set(['the', 'and', 'for', 'you', 'can', 'how', 'this', 'that', 'with', 'from', 'into', 'about', 'please', 'there', 'here', 'them', 'they', 'are', 'was', + 'have', 'has', 'does', 'did', 'not', 'all', 'any', 'some', 'one', 'two', 'other', 'again', 'just', 'like', 'want', 'need', 'make', 'get', 'set', 'use', 'run', 'see', + 'thing', 'stuff', 'mine', 'yours', 'more', 'less', 'last', 'first', 'second', 'third', 'next', 'previous', 'new', 'old', 'off', 'turn', 'remove', 'add', 'put', 'help', + 'doing', 'done', 'tell', 'give', 'keep', 'every', 'each', 'what', 'when', 'why', 'who', 'way', 'today', 'yesterday', 'broken', 'failed', 'fail', 'error', 'errors']); + +function resolveExact(raw: string, context: AskContext, state: ResolveState = {}, commands?: CommandEnvironment, files?: FileAssistEnvironment): AskOutcome { + const text = normalizeRequest(raw); + if (!text) return unclear(context, 'What can I help you with?'); + const scored = scoreCapabilities(text); + // Explaining an NMSh command wins over acting on it. + const explain = /\b(?:what|how) (?:does|do|is)\b/u.test(text) && /\/[a-z][\w-]*/u.exec(text); + if (explain) return build('help.command', text, context, raw); + // Homebrew from its own facts; install/upgrade/uninstall are typed proposals behind the final Yes. + const packages = resolvePackage(text, context); + if (packages) return packages; + // Config files and verified edits (resolve → inspect → plan → preview → confirm); removal is answered, never planned. + // "find files named config" is a file search, not a config request. + if (/^(?:please )?(?:find|locate|search for|look for|show(?: me)?|list)\b.*\b(?:files?|folders?)\s+(?:named|called|matching)\b/u.test(text)) { + const named = resolveFiles(text, raw, context); + if (named) return named; + } + const file = resolveFileRequest(raw, text, context, files); + if (file) return file; + // Command knowledge: explaining git push or git clean is an answer, not an action, so it comes before the action-safety check. + // "how do i X" still lets a strong typed capability act ("how do i open package.json"). + // One guide: /guide, "guide me through nmsh", and /ask help all come from the concept catalog. + // NMSh surfaces and supported configuration: deterministic, typed actions only. + const configured = resolveConfigRequest(text); + if (configured) return configured; + if (GUIDE_REQUEST.test(text)) return guideOutcome(context); + if (HELP_REQUEST.test(text)) return askHelpOutcome(); + // "why did that fail": the failed block's own evidence, read deterministically. + if (context.failure && WHY_FAILED.test(text)) return failureOutcome(context.failure, context); + // The optional local model: status and where its actions live (/llm). + const llm = resolveLocalModel(text, context); + if (llm) return llm; + // Recent activity from recorded facts ("what did I just do"). + const activity = resolveActivity(text, context, commands); + if (activity) return activity; + // Watches: typed /watch actions, the watched command classified like /watch. + const watch = resolveWatch(text, raw, context); + if (watch) return watch; + // Git from local facts (current branch, real remotes, listed files) and this conversation's referents. + const git = resolveGit(text, raw, context, commands?.reference); + if (git) return git; + // Files from real directory facts: list, browse, find, open, ordinals over listed results. + const fileResult = resolveFiles(text, raw, context); + if (fileResult) return fileResult; + // Project scripts and NMSh-managed background tasks (dev servers). + const project = resolveProject(text, context); + if (project) return project; + // Terminal tasks as typed recipes (archives, ping, disk, ports, processes, addresses, memory, search). + const recipe = resolveRecipe(text, context, commands); + if (recipe) return recipe; + // "what does this command do": the command this conversation shows, else the last one run. + if (commands && /\b(?:this|that|the|my) (?:last |previous )?command\b/u.test(text) && /^(?:what|explain|how)\b/u.test(text) && !/\b(?:produced|caused|failed|made)\b/u.test(text)) { + const words = context.referents?.block && !context.referents.block.literal ? context.referents.block.argv : context.recent?.[0]?.command.split(/\s+/u); + const path = words?.filter(word => /^[\w.+-]+$/u.test(word) && !word.startsWith('-')).slice(0, 2) ?? []; + if (path.length) { + const found = commands.reference.lookup(path); + const target = found ? found.facts.path : path.slice(0, 1); + const answer = answerCommandQuestion({intent: 'explain', words: target}, context, commands); + if (answer) return answer; + } + } + const question = commands ? parseCommandQuestion(text) : undefined; + if (question && commands) { + const strongAction = scored.find(item => item.score >= CONFIDENCE.high && !item.capability.id.startsWith('help.')); + const howTo = /^(?:please )?how (?:do|can|would|should) i|^how to/u.test(text); + if (!(howTo && strongAction && question.intent !== 'option')) { + const answer = answerCommandQuestion(question, context, commands); + if (answer) return answer; + } + } + // "remove the input dividers" is a typed NMSh setting, not a deletion: a toggle concept wins over the safety refusal. + const toggled = matchConcepts(text).concepts.find(concept => concept.toggle); + if (toggled && (conceptIntent(text) === 'off' || conceptIntent(text) === 'on')) { const product = resolveConcepts(text, context, raw, scored); if (product) return product; } + if (UNSAFE.test(text) && !scored.some(item => item.score >= CONFIDENCE.high && item.capability.safety === 'answer')) return unsafe(text, context); + const product = resolveConcepts(text, context, raw, scored); + if (product) return product; + const top = scored[0]; + if (top && top.score >= CONFIDENCE.high) { + const close = scored.filter(item => item.score >= CONFIDENCE.high); + if (close.length > 1 && !preferFirst(close.map(item => item.capability.id))) { + return interpretations(close.map(item => build(item.capability.id, text, context, raw)), context, state, 'I can read that a few ways. Did you mean:'); + } + return build(top.capability.id, text, context, raw); + } + if (VAGUE.test(text)) return vague(context, state); + const medium = scored.filter(item => item.score >= CONFIDENCE.medium); + if (medium.length) return interpretations(medium.slice(0, 4).map(item => build(item.capability.id, text, context, raw)), context, state, 'I\'m not completely sure what you mean. Did you mean:'); + if (AUTHORING.test(text)) { + return {kind: 'unsupported', text: 'Ask doesn\'t write or change code or files; it finds, opens, shows and switches things in NMSh.', + alternative: {key: 'files', label: 'Open a file in your editor', refine: 'open '}}; + } + return unclear(context, 'I\'m not sure what you mean yet.'); +} + +/** Some strong matches overlap by design; the more specific one wins. */ +function preferFirst(ids: CapabilityId[]): boolean { + const pairs: Array<[CapabilityId, CapabilityId]> = [['shell.install', 'tools.open'], ['shell.default', 'shell.switch'], ['git.diff', 'git.worktrees'], + ['provider.switch', 'providers.open'], ['provider.status', 'providers.open'], ['session.resume', 'session.list'], ['transcript.find', 'file.open'], + ['editor.status', 'file.open'], ['understanding.set', 'providers.open'], ['git.status', 'git.diff'], ['shell.leave', 'shell.current']]; + return pairs.some(([first, second]) => ids[0] === first && ids.includes(second)) || ids.length === 1; +} + +function interpretations(outcomes: AskOutcome[], context: AskContext, state: ResolveState, question: string): AskOutcome { + const options = dedupe(outcomes.map(optionFor)).filter(option => !state.rejected?.has(option.key)); + if (options.length === 1) return options[0]!.outcome!; + if (!options.length) return unclear(context, 'None of those then. Tell me a little more about what you want.', state); + return {kind: 'choose', reason: 'ambiguous', question, options: options.slice(0, 5)}; +} + +function optionFor(outcome: AskOutcome): AskOption { + const key = outcome.kind === 'proposal' || outcome.kind === 'answer' ? outcome.capability + : outcome.kind === 'choose' ? `${outcome.capability ?? 'choose'}:${outcome.question}` : outcome.kind; + const label = outcome.kind === 'proposal' ? outcome.text : outcome.kind === 'answer' ? CAPABILITIES.find(item => item.id === outcome.capability)!.title + : outcome.kind === 'choose' ? (CAPABILITIES.find(item => item.id === outcome.capability)?.title ?? outcome.question) : outcome.text; + return {key, label: firstLine(label), outcome}; +} + +const dedupe = (options: AskOption[]) => options.filter((option, index) => options.findIndex(other => other.key === option.key) === index); +const firstLine = (text: string) => text.split('\n')[0]!; + +/** Low confidence: factual categories from what exists here, not a canned menu. */ +function unclear(context: AskContext, text: string, state: ResolveState = {}): AskOutcome { + const categories: AskOption[] = []; + if (context.repoRoot) categories.push({key: 'cat:git', label: 'Git changes in this repository', refine: 'show git diff'}); + categories.push({key: 'cat:files', label: `Files in ${context.repoRoot ? basename(context.repoRoot) : 'this folder'}`, refine: 'list files'}); + if (context.sessions.length > 1 || context.transcripts.length) categories.push({key: 'cat:sessions', label: 'Sessions and transcripts', refine: 'show my sessions'}); + categories.push({key: 'cat:shell', label: 'Shell or provider settings', refine: 'what providers are installed'}); + return {kind: 'unclear', text: `${text} Based on what you're working on, I can help with:`, categories: categories.filter(option => !state.rejected?.has(option.key))}; +} + +/** "The thing from earlier": rank real candidates from recent factual context. */ +function vague(context: AskContext, state: ResolveState): AskOutcome { + const options: AskOption[] = []; + if (context.repoRoot && context.dirty !== false) options.push({key: 'vague:diff', label: 'Show the latest Git diff in this repository', outcome: build('git.diff', 'show git diff', context, 'show git diff')}); + const here = context.transcripts.filter(item => item.startCwd === context.cwd || item.finalCwd === context.cwd)[0] ?? context.transcripts[0]; + if (here) options.push({key: `vague:transcript:${here.id}`, label: `Resume the most recent transcript (${transcriptLabel(here, context)})`, outcome: resumeProposal(here, context)}); + const file = context.recentFiles[0]; + if (file) options.push({key: `vague:file:${file}`, label: `Reopen ${displayPath(file, context)}`, outcome: openProposal(file, context)}); + const remaining = options.filter(option => !state.rejected?.has(option.key)); + if (!remaining.length) return unclear(context, 'I\'m not sure which one you mean.', state); + return {kind: 'choose', reason: 'ambiguous', question: 'I\'m not completely sure what you mean. Did you mean:', options: remaining}; +} + +function unsafe(text: string, context: AskContext): AskOutcome { + const git = /\b(?:git|untracked|commit|push|branch|rebase|reset|clean|checkout|discard|changes)\b/u.test(text); + const what = /\buntracked\b/u.test(text) ? 'delete untracked files' : /\bpush\b/u.test(text) ? 'push commits' : /\bcommit\b/u.test(text) ? 'commit changes' + : /\bkill\b/u.test(text) ? 'end processes' : /\bsudo\b/u.test(text) ? 'run commands as root' : 'change or delete things'; + if (git && context.repoRoot) { + // The affected files become what "them" means next ("show them", "what's the command to delete them"). + if (/\buntracked\b/u.test(text) && context.git) { + return {kind: 'unsafe', text: `I understand that you want to ${what}, but Ask won't run destructive or history-changing Git commands. I can show the affected files first.`, + alternative: {key: 'safe:untracked', label: 'Show the untracked files', refine: 'show untracked files'}, + referents: {files: {paths: [...context.git.untracked], kind: 'untracked'}}}; + } + return {kind: 'unsafe', text: `I understand that you want to ${what}, but Ask won't run destructive or history-changing Git commands. I can show the affected files first.`, + alternative: {key: 'safe:status', label: 'Show Git status', outcome: build('git.status', 'git status', context, 'git status')}}; + } + if (/\bkill\b.*\bsession\b/u.test(text)) { + return {kind: 'unsafe', text: 'Ask won\'t end sessions itself. /sessions can, with its own confirmation (Ctrl+K on a detached session).', + alternative: {key: 'safe:sessions', label: 'Open /sessions', outcome: build('session.list', 'show sessions', context, 'show sessions')}}; + } + return {kind: 'unsafe', text: `I understand that you want to ${what}, but that is outside what Ask will do: it never runs destructive, privileged or arbitrary commands.`}; +} + +const READ_COMMANDS: Record string[]> = { + 'git.status': command => ['git', ...(command.cwd ? ['-C', command.cwd] : []), 'status'], + 'git.diff': command => ['git', ...(command.cwd ? ['-C', command.cwd] : []), 'diff', ...((command as {staged?: boolean}).staged ? ['--staged'] : [])], + 'git.log': command => ['git', ...(command.cwd ? ['-C', command.cwd] : []), 'log', '--oneline', '-n', '20'], +}; + +/** The fixed argv for a read-only command; arguments other than a factual path never come from the request. */ +export function readArgv(command: ReadCommand): string[] { + return READ_COMMANDS[command.id](command); +} + +function displayPath(path: string, context: AskContext): string { + const base = context.repoRoot ?? context.cwd; + const rel = relative(base, path); + if (!rel.startsWith('..') && rel !== '') return rel; + return path.startsWith(`${context.home}/`) ? `~${path.slice(context.home.length)}` : path; +} + +function transcriptLabel(item: AskTranscript, context: AskContext): string { + const date = new Date(item.createdAt); + const day = sameDay(date, new Date(context.now)) ? 'today' : sameDay(date, new Date(context.now - 86_400_000)) ? 'yesterday' : date.toISOString().slice(0, 10); + const time = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; + return `${day} ${time} · ${displayPath(item.finalCwd || item.startCwd, context)} · ${item.commandCount} command${item.commandCount === 1 ? '' : 's'}`; +} + +const sameDay = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); + +function resumeProposal(item: AskTranscript, context: AskContext): AskOutcome { + return {kind: 'proposal', capability: 'session.resume', safety: 'navigate', confidence: 0.9, + text: `Resume the transcript from ${transcriptLabel(item, context)}?`, action: {kind: 'resumeTranscript', id: item.id}}; +} + +function openProposal(path: string, context: AskContext): AskOutcome { + return {kind: 'proposal', capability: 'file.open', safety: 'navigate', confidence: 0.92, + text: `Open ${displayPath(path, context)} in ${context.editor.label}?`, action: {kind: 'openFile', path}, referents: {file: path}}; +} + +function readProposal(capability: CapabilityId, command: ReadCommand, description: string): AskOutcome { + return {kind: 'proposal', capability, safety: 'read', confidence: 0.9, text: description, action: {kind: 'read', command}}; +} + +const navigate = (capability: CapabilityId, text: string, action: AskAction): AskOutcome => + ({kind: 'proposal', capability, safety: 'navigate', confidence: 0.95, text, action}); + +/** The request with filler words and the verb removed, for argument extraction. */ +function argumentText(raw: string, verb: RegExp): string { + return raw.replace(verb, '').replace(/\b(?:the|my|a|an|file|folder|please|in (?:zed|vs ?code|my editor|the editor))\b/giu, ' ').replace(/\s+/gu, ' ').trim(); +} + +export function build(id: CapabilityId, text: string, context: AskContext, raw: string): AskOutcome { + const shell = shellIn(text); + switch (id) { + case 'shell.current': { + const current = shellLabel(context, context.shell); + return {kind: 'answer', capability: id, text: context.shell === context.defaultShell + ? `This session runs ${current}, which is also your default for new sessions.` + : `This session runs ${current}. New sessions start ${shellLabel(context, context.defaultShell)} (your default).`}; + } + case 'shell.switch': { + if (!shell) return {kind: 'choose', reason: 'missing', capability: id, question: 'Switch this session to which shell?', + options: context.shells.filter(item => item.id !== context.shell).map(item => ({key: `shell:${item.id}`, label: item.label, refine: `switch to ${item.id}`}))}; + if (shell === context.shell) return {kind: 'answer', capability: id, text: `This session already runs ${shellLabel(context, shell)}.`}; + const info = context.shells.find(item => item.id === shell); + if (info && !info.installed) return build('shell.install', `install ${shell}`, context, raw); + return {kind: 'proposal', capability: id, safety: 'navigate', confidence: 0.95, command: `/shell ${shell}`, + text: `Yes. NMSh can switch this session to ${shellLabel(context, shell)}. Same session and folder; ${shellLabel(context, context.shell)} aliases and variables stay behind.`, + action: {kind: 'switchShell', shell}}; + } + case 'shell.install': { + if (!shell) return {kind: 'choose', reason: 'missing', capability: id, question: 'Install which shell?', + options: context.shells.filter(item => !item.installed).map(item => ({key: `install:${item.id}`, label: item.label, refine: `install ${item.id}`}))}; + const info = context.shells.find(item => item.id === shell); + if (info?.installed) return {kind: 'answer', capability: id, text: `${info.label} is already installed. /shell ${shell} switches this session to it.`, + follow: {key: `switch:${shell}`, label: `Switch to ${info.label}`, outcome: build('shell.switch', `switch to ${shell}`, context, raw)}}; + if (!info?.installable) return {kind: 'answer', capability: id, text: `${shellLabel(context, shell)} is not installed, and NMSh has no safe install recipe here. Install it with your system's package manager, then /shell lists it.`}; + return {kind: 'proposal', capability: id, safety: 'install', confidence: 0.95, + text: `${shellLabel(context, shell)} is not installed. /shell can install it with its previewed recipe; nothing runs until you confirm there.`, + action: {kind: 'installShell', shell}}; + } + case 'shell.default': { + if (!shell) return {kind: 'answer', capability: id, text: `New sessions start ${shellLabel(context, context.defaultShell)}. Say "make fish my default shell" to change it.`}; + if (shell === context.defaultShell) return {kind: 'answer', capability: id, text: `${shellLabel(context, shell)} is already your default shell.`}; + return {kind: 'proposal', capability: id, safety: 'navigate', confidence: 0.9, + text: `Make ${shellLabel(context, shell)} the default for new sessions? This session keeps ${shellLabel(context, context.shell)} until /shell changes it.`, + action: {kind: 'setting', setting: 'shellBackend', value: shell, label: `Default shell: ${shellLabel(context, shell)}`}}; + } + case 'shell.leave': + return {kind: 'answer', capability: id, text: `Use /${context.shell === 'zsh' ? 'zsh' : context.shell} (or /exit for your default shell). It leaves NMSh for an ordinary shell` + + (context.sessionMode === 'service' ? ' and keeps this session; running `nmsh` there returns to it.' : '; this in-process session ends.')}; + case 'session.list': return navigate(id, 'Opening /sessions.', {kind: 'slash', slash: {kind: 'sessions'}, label: '/sessions'}); + case 'session.resume': return resolveTranscript(text, context); + case 'transcript.find': { + const query = argumentText(raw, /^(?:please )?(?:find|search(?: for)?|look for|grep)\s+/iu).replace(/\b(?:in|through) (?:the )?(?:transcript|output|history|terminal)\b.*$/iu, '').trim().replace(/^-+/u, ''); + if (!query) return {kind: 'choose', reason: 'missing', capability: id, question: 'Find what?', options: []}; + return navigate(id, `Finding "${query}" in the transcript.`, {kind: 'slash', slash: {kind: 'find', arguments: query}, label: `/find ${query}`}); + } + case 'transcript.filter': { + const query = argumentText(raw, /^.*?\b(?:only show(?: lines)?(?: with| containing)?|filter(?: (?:the )?(?:output|transcript))?(?: (?:for|by))?)\s+/iu).replace(/^lines? (?:with|containing)\s+/iu, '').replace(/^-+/u, ''); + if (!query) return {kind: 'choose', reason: 'missing', capability: id, question: 'Show only lines with what?', options: []}; + return navigate(id, `Showing only lines with "${query}" in the latest output.`, {kind: 'slash', slash: {kind: 'filter', arguments: query}, label: `/filter ${query}`}); + } + case 'file.open': return resolveFile(raw, text, context); + case 'file.list': case 'file.browse': return resolveFiles(text, raw, context) ?? browseOutcome(context.cwd, context); + case 'project.run': case 'project.task': return resolveProject(text, context) ?? {kind: 'answer', capability: id, text: 'Say which script to run, e.g. "run the tests".'}; + case 'file.find': return resolveFiles(text, raw, context) ?? {kind: 'choose', reason: 'missing', capability: id, question: 'Find which file?', options: []}; + case 'editor.status': + return {kind: 'answer', capability: id, text: context.editor.available ? `Files open in ${context.editor.label} (/open, Settings → Open with).` : context.editor.reason ?? 'No editor is available for /open here.'}; + case 'git.status': case 'git.diff': case 'git.log': { + if (!context.repoRoot) return {kind: 'answer', capability: id, text: 'This folder is not in a Git repository.'}; + const worktree = /\bworktree\b/u.test(text) ? resolveWorktree(text, context, id) : undefined; + if (worktree && 'kind' in worktree) return worktree; + const cwd = worktree?.path; + const command: ReadCommand = id === 'git.diff' ? {id, ...(cwd ? {cwd} : {}), ...(/\bstaged\b/u.test(text) ? {staged: true} : {})} : {id, ...(cwd ? {cwd} : {})}; + const where = cwd ? ` in ${displayPath(cwd, context)}` : ''; + const description = id === 'git.status' ? `I can show Git status${where}.` : id === 'git.diff' ? `I can show the ${/\bstaged\b/u.test(text) ? 'staged' : 'working-tree'} diff${where}.` : `I can show the last 20 commits${where}.`; + return readProposal(id, command, description); + } + case 'git.branch': + return {kind: 'answer', capability: id, text: context.branch ? `You are on ${context.branch}.` : context.repoRoot ? 'HEAD is detached (no branch).' : 'This folder is not in a Git repository.'}; + case 'git.worktrees': + if (!context.repoRoot) return {kind: 'answer', capability: id, text: 'This folder is not in a Git repository.'}; + return {kind: 'answer', capability: id, text: context.worktrees.length <= 1 ? 'This repository has one worktree (this one).' + : `Worktrees:\n${context.worktrees.map(item => ` ${item.current ? '›' : ' '} ${displayPath(item.path, context)}${item.branch ? ` ${item.branch}` : ''}`).join('\n')}`}; + case 'settings.open': return navigate(id, 'Opening Settings.', {kind: 'slash', slash: {kind: 'settings', view: 'config'}, label: '/settings'}); + case 'theme.open': return navigate(id, 'Opening Appearance (themes).', {kind: 'slash', slash: {kind: 'appearance'}, label: '/appearance'}); + case 'prompt.open': return navigate(id, 'Opening /prompt.', {kind: 'slash', slash: {kind: 'prompt'}, label: '/prompt'}); + case 'tools.open': return navigate(id, 'Opening /tools: installs there are previewed and start on No.', {kind: 'slash', slash: {kind: 'tools'}, label: '/tools'}); + case 'screensaver.open': return navigate(id, 'Opening /screensaver.', {kind: 'slash', slash: {kind: 'screensaver', start: false}, label: '/screensaver'}); + case 'providers.open': return navigate(id, 'Opening /providers.', {kind: 'slash', slash: {kind: 'providers'}, label: '/providers'}); + case 'provider.status': return providerStatus(text, context); + case 'provider.switch': return providerSwitch(text, context); + case 'understanding.set': return understanding(text, context); + case 'help.capabilities': + return {kind: 'answer', capability: id, text: `Ask finds, opens, shows and switches things in NMSh. For example:\n${['open package.json', 'show my sessions', 'switch to fish', + 'check git diff', 'find error in the transcript', 'resume yesterday\'s session', 'what providers are installed'].map(example => ` ${example}`).join('\n')}\nIt never runs destructive or arbitrary commands.`}; + case 'help.guide': return guideOutcome(context); + case 'help.feature': case 'feature.open': + return resolveConcepts(text, context, raw, []) ?? unclear(context, 'I\'m not sure which part of NMSh you mean.'); + case 'help.command': { + const name = /\/[a-z][\w-]*/u.exec(text)?.[0]; + const known = slashCommands.find(command => command.name === name); + return {kind: 'answer', capability: id, text: known ? `${known.name}: ${known.description}.` : `${name ?? 'That'} is not an NMSh command. /help lists them.`}; + } + } +} + +/** Capabilities that only open a broad surface: a named product concept is more specific than they are. */ +const GENERIC: ReadonlySet = new Set(['settings.open', 'theme.open', 'prompt.open', 'providers.open', 'file.open', 'tools.open', 'screensaver.open', 'help.capabilities']); +/** Broad concepts: when a request names one of these and something more specific, the specific one is meant. */ +const UMBRELLA = new Set(['settings', 'theme', 'prompt', 'transcript', 'help', 'providers', 'shell', 'sessions', 'history']); + +/** + * Concept first, then intent: which NMSh feature the request names (from the + * product vocabulary), then what to do with it. Undefined leaves the request + * to the capability patterns: a strong, specific capability (switch to fish, + * git diff, find X) keeps priority unless a concept explicitly overrides it. + */ +function resolveConcepts(text: string, context: AskContext, raw: string, scored: ReadonlyArray<{capability: Capability; score: number}>): AskOutcome | undefined { + const match = matchConcepts(text); + if (!match.concepts.length && !match.ambiguous.length) return undefined; + const intent = conceptIntent(text); + const strong = scored.find(item => item.score >= CONFIDENCE.high); + const named = [...match.concepts, ...match.ambiguous.flat()]; + // "open X" is a file request unless X itself names an NMSh feature ("open the palette", not "open this in zed"). + if (strong?.capability.id === 'file.open') { + const object = matchConcepts(normalizeRequest(argumentText(raw, /^(?:please )?(?:open|edit|show me|view)\s+/iu))); + if (!object.concepts.length && !object.ambiguous.length) return undefined; + } + if (strong && !GENERIC.has(strong.capability.id) && !named.some(concept => concept.overrides?.includes(strong.capability.id)) + && (intent !== 'explain' || strong.capability.safety === 'answer')) return undefined; + // An ambiguous phrase settles by intent ("open the palette"), else it is the question, unless only broad concepts compete with it. + const settled: Concept[] = []; + const open: Concept[][] = []; + for (const group of match.ambiguous) { + const preferred = group.filter(concept => concept.prefers?.includes(intent)); + if (preferred.length === 1) settled.push(preferred[0]!); else open.push(group); + } + const specific = [...match.concepts, ...settled].filter(concept => !UMBRELLA.has(concept.id)); + if (intent === 'explain') { + const explained = [...(specific.length ? specific : [...match.concepts, ...settled]), ...open.flat()].filter((concept, index, all) => all.indexOf(concept) === index).slice(0, 3); + const single = explained.length === 1 ? explained[0]! : undefined; + const follow = single && single.support !== 'unsupported' && conceptDestination(single) ? {key: `concept:${single.id}`, label: `Open ${single.configure ?? single.open}`, outcome: actOn(single, 'change', text, context, raw)} : undefined; + return {kind: 'answer', capability: 'help.feature', text: explained.map(concept => explained.length === 1 ? concept.description : `${concept.label}: ${concept.description}`).join('\n'), ...(follow ? {follow} : {})}; + } + if (open.length && !specific.length) { + const group = open[0]!; + const verb = intent === 'off' ? 'turn off' : intent === 'on' ? 'turn on' : intent === 'open' ? 'open' : 'change'; + return {kind: 'choose', reason: 'ambiguous', capability: 'feature.open', question: `What do you want to ${verb}?`, + options: group.map(concept => ({key: `concept:${concept.id}`, label: concept.label, outcome: actOn(concept, intent, text, context, raw)}))}; + } + const concept = specific[0] ?? match.concepts[0] ?? settled[0]!; + return actOn(concept, intent, text, context, raw); +} + +/** What Ask does with one concept for one intent: only existing capabilities, NMSh's own slash surfaces, or typed settings. */ +function actOn(concept: Concept, intent: ConceptIntent, text: string, context: AskContext, raw: string): AskOutcome { + if (concept.support === 'unsupported') return {kind: 'unsupported', text: concept.description}; + if ((intent === 'on' || intent === 'off') && concept.toggle) { + const value = intent === 'on' ? concept.toggle.on : concept.toggle.off; + return {kind: 'proposal', capability: 'feature.open', safety: 'navigate', confidence: 0.9, + text: `Turn ${concept.label.split(' /')[0]!.toLowerCase()} ${intent}?`, action: {kind: 'setting', setting: concept.toggle.setting, value, label: `${concept.label}: ${intent === 'on' ? 'On' : 'Off'}`}}; + } + if (concept.support === 'no-ui') { + const related = concept.id === 'completion' ? CONCEPT_FOLLOW.suggestions : undefined; + return {kind: 'answer', capability: 'help.feature', text: `${concept.description} ${concept.where ?? ''}`.trim(), + ...(related ? {follow: {key: 'concept:suggestions', label: 'Change ghost suggestions instead', outcome: actOn(related, 'change', text, context, raw)}} : {})}; + } + if (concept.capability && intent !== 'off' && intent !== 'on' && !GENERIC.has(concept.capability)) return build(concept.capability, text, context, raw); + const target = intent === 'open' ? concept.open ?? concept.configure : concept.configure ?? concept.open; + const slash = target ? conceptDestination({...concept, open: target}) : undefined; + if (intent === 'off' || intent === 'on' || concept.support === 'settings') { + const where = concept.where ?? `${concept.label} is in ${target ?? 'Settings'}.`; + return {kind: 'answer', capability: 'help.feature', text: where, + ...(slash ? {follow: {key: `concept:${concept.id}`, label: `Open ${target}`, outcome: navigate('feature.open', `Opening ${target}.`, {kind: 'slash', slash, label: target!})}} : {})}; + } + if (slash) return navigate('feature.open', `Opening ${target}${concept.where && target === '/providers' ? ` (${concept.label})` : ''}.`, {kind: 'slash', slash, label: target!}); + if (concept.capability) return build(concept.capability, text, context, raw); + return {kind: 'answer', capability: 'help.feature', text: concept.where ?? concept.description}; +} + +const CONCEPT_FOLLOW = {get suggestions() { return CONCEPTS_BY_ID.get('suggestions')!; }}; + +function resolveFile(raw: string, text: string, context: AskContext): AskOutcome { + const query = argumentText(raw, /^(?:please )?(?:open|edit|show me|view)\s+/iu); + // "this", "that" and "it" mean the most recent file NMSh saw in output. + if (/^(?:this|that|it|the last one)?$/iu.test(query)) { + const recent = context.recentFiles[0]; + if (recent) return openProposal(recent, context); + return {kind: 'choose', reason: 'missing', capability: 'file.open', question: 'Open which file?', options: []}; + } + const root = context.repoRoot ?? context.cwd; + const matches = matchFiles(query, context.files ?? [], context.cwd, root); + const recent = context.recentFiles.filter(file => basename(file).toLowerCase().includes(query.toLowerCase().replace(/\s+/gu, ''))); + const strong = matches.filter(match => match.score >= 0.88); + if (strong.length === 1 || (matches.length === 1 && matches[0]!.score >= 0.5)) return openProposal((strong[0] ?? matches[0])!.path, context); + // Several words that pick one file clearly ("src config") resolve; a single word ("config") asks. + if (!strong.length && query.trim().split(/\s+/u).length > 1 && matches.length > 1 && matches[0]!.score - matches[1]!.score >= CLEAR_LEAD) return openProposal(matches[0]!.path, context); + const candidates = [...new Set([...recent, ...(strong.length ? strong : matches).map(match => match.path)])].slice(0, 6); + if (!candidates.length) { + // A clear near miss of a real file is offered, never substituted: "Did you mean package.json?" + const names = (context.files ?? []).map(path => basename(path)); + const near = correctWord(query.toLowerCase().replace(/\s+/gu, ''), names.map(name => name.toLowerCase())); + const match = near ? (context.files ?? []).find(path => basename(path).toLowerCase() === near) : undefined; + if (match) { + const path = `${root}/${match}`; + return {kind: 'choose', reason: 'missing', capability: 'file.open', question: `No file named "${query}". Did you mean ${displayPath(path, context)}?`, + options: [{key: `file:${path}`, label: displayPath(path, context), outcome: openProposal(path, context)}]}; + } + return {kind: 'answer', capability: 'file.open', text: `No file matching "${query}" under ${displayPath(root, context) || root}. /open opens a path directly.`}; + } + return {kind: 'choose', reason: 'ambiguous', capability: 'file.open', question: `I found ${candidates.length} matches. Which one?`, + options: candidates.map(path => ({key: `file:${path}`, label: displayPath(path, context), outcome: openProposal(path, context)}))}; +} + +function resolveWorktree(text: string, context: AskContext, id: CapabilityId): AskOutcome | {path: string} | undefined { + const others = context.worktrees.filter(item => !item.current); + const word = (value: string) => new RegExp(`(?:^|[\\s/])${value.toLowerCase().replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}(?:$|[\\s/])`, 'u').test(text); + const named = context.worktrees.find(item => !item.current && (word(basename(item.path)) || (item.branch && word(item.branch)))); + if (named) return {path: named.path}; + if (!/\bother\b/u.test(text) && !others.length) return undefined; + if (others.length === 1) return {path: others[0]!.path}; + if (!others.length) return {kind: 'answer', capability: id, text: 'This repository has no other worktrees.'}; + return {kind: 'choose', reason: 'missing', capability: id, question: 'Which worktree?', + options: others.map(item => ({key: `worktree:${item.path}`, label: `${displayPath(item.path, context)}${item.branch ? ` ${item.branch}` : ''}`, + outcome: build(id, `${id.replace('git.', 'git ')} worktree ${basename(item.path).toLowerCase()}`, context, '')}))}; +} + +function resolveTranscript(text: string, context: AskContext): AskOutcome { + const now = new Date(context.now); + const yesterday = new Date(context.now - 86_400_000); + let candidates = context.transcripts; + let when = ''; + if (/\byesterday|last night\b/u.test(text)) { candidates = candidates.filter(item => sameDay(new Date(item.createdAt), yesterday)); when = ' from yesterday'; } + else if (/\bthis morning\b/u.test(text)) { candidates = candidates.filter(item => sameDay(new Date(item.createdAt), now) && new Date(item.createdAt).getHours() < 12); when = ' from this morning'; } + else if (/\btoday\b/u.test(text)) { candidates = candidates.filter(item => sameDay(new Date(item.createdAt), now)); when = ' from today'; } + if (/\b(?:this|the) (?:repo|repository|project|folder)\b|\bhere\b/u.test(text)) { + const root = context.repoRoot ?? context.cwd; + candidates = candidates.filter(item => [item.startCwd, item.finalCwd].some(path => path === root || path.startsWith(`${root}/`))); + when += ' in this project'; + } + // A live detached session named by its shell ("the bash one") is a factual match too. + const shell = shellIn(text); + const live = context.sessions.filter(session => !session.current && session.state === 'detached' && (!shell || session.shell === shell)); + if (shell && live.length) { + if (live.length === 1) return {kind: 'proposal', capability: 'session.resume', safety: 'navigate', confidence: 0.9, + text: `Switch this window to the detached ${shell} session in ${displayPath(live[0]!.cwd, context)}?`, action: {kind: 'attachSession', id: live[0]!.id}}; + } + if (!/\bresume|restore|reopen|continue|yesterday|morning|today|last|latest|previous\b/u.test(text)) { + return navigate('session.resume', 'Opening /resume (live sessions and archived transcripts).', {kind: 'slash', slash: {kind: 'resume'}, label: '/resume'}); + } + if (/\b(?:last|latest|previous|most recent)\b/u.test(text)) candidates = candidates.slice(0, 1); + if (candidates.length === 1) return resumeProposal(candidates[0]!, context); + if (!candidates.length) return {kind: 'answer', capability: 'session.resume', text: `No archived transcript${when}. /resume lists them all.`, + follow: {key: 'open:resume', label: 'Open /resume', outcome: navigate('session.resume', 'Opening /resume.', {kind: 'slash', slash: {kind: 'resume'}, label: '/resume'})}}; + return {kind: 'choose', reason: 'ambiguous', capability: 'session.resume', question: `${candidates.length} transcripts${when}. Which one?`, + options: candidates.slice(0, 5).map(item => ({key: `transcript:${item.id}`, label: transcriptLabel(item, context), outcome: resumeProposal(item, context)}))}; +} + +const FAMILY_WORDS: Record = {prompt: 'prompt', suggestion: 'suggestions', suggestions: 'suggestions', history: 'history', welcome: 'welcome', picker: 'picker', navigation: 'navigation'}; + +function providerStatus(text: string, context: AskContext): AskOutcome { + const family = Object.entries(FAMILY_WORDS).find(([word]) => new RegExp(`\\b${word}\\b`, 'u').test(text))?.[1]; + const active = context.providers.filter(provider => provider.active && (!family || provider.family === family)); + if (!active.length) return {kind: 'answer', capability: 'provider.status', text: 'No provider information is available. /providers lists them.'}; + return {kind: 'answer', capability: 'provider.status', text: active.map(provider => `${provider.family}: ${provider.label}`).join('\n')}; +} + +function providerSwitch(text: string, context: AskContext): AskOutcome { + const target = context.providers.find(provider => new RegExp(`\\b${provider.id.toLowerCase()}\\b|\\b${provider.label.toLowerCase().replace(/[^a-z0-9 ]/gu, '')}\\b`, 'u').test(text) + && !provider.active); + if (!target) return {kind: 'answer', capability: 'provider.switch', text: 'I couldn\'t match that to a provider NMSh knows. /providers lists them.', + follow: {key: 'open:providers', label: 'Open /providers', outcome: build('providers.open', '', context, '')}}; + if (!target.available) return {kind: 'answer', capability: 'provider.switch', text: `${target.label} is not installed. /providers can install it with a previewed recipe.`, + follow: {key: 'open:providers', label: 'Open /providers', outcome: build('providers.open', '', context, '')}}; + return {kind: 'proposal', capability: 'provider.switch', safety: 'navigate', confidence: 0.9, text: `Use ${target.label} for ${target.family}?`, + action: {kind: 'setting', setting: target.family as 'suggestions', value: target.id, label: `${target.family}: ${target.label}`}}; +} + +function understanding(text: string, context: AskContext): AskOutcome { + const mode = /\b(?:off|disable|stop|never|no)\b/u.test(text) ? 'off' : /\balways\b/u.test(text) ? 'always' : /\b(?:on|auto|enable|use|turn on)\b/u.test(text) ? 'auto' : undefined; + if (!mode) return navigate('understanding.set', 'Opening /llm (Local Intelligence).', {kind: 'slash', slash: {kind: 'llm'}, label: '/llm'}); + return {kind: 'proposal', capability: 'understanding.set', safety: 'navigate', confidence: 0.9, + text: mode === 'off' ? 'Turn local understanding off? Ask and Smart Folding keep working without a model.' : `Set local understanding to ${mode === 'auto' ? 'Auto' : 'Always'}? It is used only for the features you enable in /providers, and only locally.`, + action: {kind: 'setting', setting: 'localUnderstanding', value: mode, label: `Local understanding: ${mode}`}}; +} + +/** Pick an option from a reply: a number, an ordinal, or words that match exactly one label. */ +export function pickOption(reply: string, options: readonly AskOption[]): number | undefined { + const text = normalizeRequest(reply); + const number = /^(\d+)$/u.exec(text); + if (number) { const index = Number(number[1]) - 1; return index >= 0 && index < options.length ? index : undefined; } + const ordinals = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']; + const ordinal = ordinals.findIndex(word => new RegExp(`\\b${word}\\b`, 'u').test(text)); + if (ordinal !== -1 && ordinal < options.length) return ordinal; + if (/\blast\b/u.test(text) && options.length) return options.length - 1; + const words = text.replace(/\b(?:the|one|please|that|this|use|open|pick)\b/gu, ' ').split(/\s+/u).filter(word => word.length > 1); + if (!words.length) return undefined; + const hits = options.map((option, index) => ({index, ok: words.every(word => `${option.label} ${option.detail ?? ''}`.toLowerCase().includes(word))})).filter(item => item.ok); + return hits.length === 1 ? hits[0]!.index : undefined; +} + +/** Options whose label contains every typed word: typing narrows a picker. */ +export function filterOptions(filter: string, options: readonly AskOption[]): number[] { + const words = normalizeRequest(filter).split(/\s+/u).filter(Boolean); + return options.flatMap((option, index) => words.every(word => option.label.toLowerCase().includes(word)) ? [index] : []); +} + +/** What the optional model returned, already strictly validated (see understanding/tasks.ts). */ +export interface ValidatedInterpretation { + capability: CapabilityId | null; + confidence: number; + arguments: Partial>; +} + +export const MODEL_CONFIDENCE = 0.6; + +/** + * Typed intents the optional model may choose besides capability ids: each + * maps to a canonical request NMSh's deterministic resolver already handles, + * with only bounded arguments (a file name, host, port, branch, script) slotted + * in and then validated by that resolver. The model never supplies a command. + */ +type IntentArgs = ValidatedInterpretation['arguments']; +const WORD = /^[\w./@:-]{1,80}$/u; +const arg = (value: string | undefined) => value && WORD.test(value) ? value : undefined; +export const MODEL_INTENTS: ReadonlyArray<{id: string; title: string; phrase: (args: IntentArgs) => string | undefined}> = [ + {id: 'files.list', title: 'List the files in this folder or repository', phrase: () => 'list files'}, + {id: 'files.find', title: 'Find files by name (target: name)', phrase: args => arg(args.target) ? `find files named ${arg(args.target)}` : undefined}, + {id: 'files.pick', title: 'Pick a file to open', phrase: () => 'open'}, + {id: 'git.changes', title: 'Show what changed (the diff)', phrase: () => 'show me the diff'}, + {id: 'git.push', title: 'Push the current branch', phrase: () => 'push this branch'}, + {id: 'git.pull', title: 'Pull the current branch', phrase: () => 'pull this branch'}, + {id: 'git.newBranch', title: 'Create a branch (target: name)', phrase: args => arg(args.target) ? `make a new branch called ${arg(args.target)}` : undefined}, + {id: 'git.lastCommit', title: 'Show the last commit', phrase: () => 'what did my last commit do'}, + {id: 'project.dev', title: 'Start the dev server / run the app', phrase: () => 'run the dev server'}, + {id: 'project.test', title: 'Run the tests', phrase: () => 'run the tests'}, + {id: 'project.scripts', title: 'List project scripts', phrase: () => 'what scripts does this project have'}, + {id: 'task.stop', title: 'Stop the background task NMSh started', phrase: () => 'stop the dev server'}, + {id: 'network.ping', title: 'Check a host is reachable (target: host)', phrase: args => arg(args.target) ? `ping ${arg(args.target)}` : undefined}, + {id: 'system.disk', title: 'Disk usage', phrase: () => 'show my disk usage'}, + {id: 'system.memory', title: 'Memory use', phrase: () => 'how much memory am i using'}, + {id: 'process.port', title: 'What is using a port (target: port number)', phrase: args => /^\d{1,5}$/u.test(args.target ?? '') ? `what is using port ${args.target}` : undefined}, + {id: 'process.list', title: 'Running processes (target: optional name)', phrase: args => arg(args.target) ? `show running ${arg(args.target)} processes` : 'show running processes'}, + {id: 'search.text', title: 'Search file contents (query: text)', phrase: args => arg(args.query) ? `grep for ${arg(args.query)}` : undefined}, + {id: 'activity.failed', title: 'What failed recently', phrase: () => 'what\'s broken'}, + {id: 'activity.recent', title: 'What I did recently', phrase: () => 'what have i been doing'}, + {id: 'llm.status', title: 'Local model status', phrase: () => 'show local model status'}, +]; + +/** The capability inventory handed to the model: capabilities plus the typed intents above (ids and titles only). */ +export function modelInventory(): Array<{id: string; title: string}> { + return [...CAPABILITIES.filter(item => item.patterns.length).map(item => ({id: item.id, title: item.title})), ...MODEL_INTENTS.map(item => ({id: item.id, title: item.title}))]; +} + +/** + * A typed intent from the model, resolved by the deterministic resolver + * through its canonical phrase. Undefined when the phrase can't be built from + * the arguments or the resolver isn't sure either. + */ +export function resolveModelIntent(interpretation: {capability: string | null; confidence: number; arguments: IntentArgs}, context: AskContext, state: ResolveState, + commands?: CommandEnvironment, files?: FileAssistEnvironment): AskOutcome | undefined { + if (!interpretation.capability || interpretation.confidence < MODEL_CONFIDENCE) return undefined; + const intent = MODEL_INTENTS.find(item => item.id === interpretation.capability); + const phrase = intent?.phrase(interpretation.arguments); + if (!phrase) return undefined; + const outcome = resolveRequest(phrase, context, state, commands, files); + return outcome.kind === 'unclear' ? undefined : outcome; +} + +/** + * Turn a model interpretation into an outcome without trusting it with + * objects: the capability must exist, and its arguments are words that the + * deterministic builders resolve against facts (files that exist, real + * worktrees, sessions, providers). A model cannot introduce a path, session + * or command; a low-confidence or empty interpretation yields undefined, so + * the caller keeps the deterministic outcome. + */ +export function resolveWithInterpretation(raw: string, interpretation: ValidatedInterpretation, context: AskContext, state: ResolveState = {}): AskOutcome | undefined { + const id = interpretation.capability; + if (!id || interpretation.confidence < MODEL_CONFIDENCE || !CAPABILITIES.some(capability => capability.id === id)) return undefined; + const args = interpretation.arguments; + const words = [args.shell, args.worktree ? `worktree ${args.worktree}` : undefined, args.when, args.provider].filter(Boolean).join(' '); + const text = normalizeRequest(`${raw} ${words}`); + const phrased = id === 'file.open' && args.target ? `open ${args.target}` + : id === 'transcript.find' && args.query ? `find ${args.query}` + : id === 'transcript.filter' && args.query ? `only show lines with ${args.query}` + : raw; + const outcome = build(id, text, context, phrased); + if (outcome.kind === 'proposal' || outcome.kind === 'answer') { + const option = {key: outcome.capability, label: ''}; + if (state.rejected?.has(option.key)) return undefined; + } + return outcome; +} diff --git a/src/ask/transcriptSummary.ts b/src/ask/transcriptSummary.ts new file mode 100644 index 00000000..1f18795c --- /dev/null +++ b/src/ask/transcriptSummary.ts @@ -0,0 +1,40 @@ +/** + * A deterministic topic summary for a recorded Ask conversation's fold row + * ("Ask · 23 turns · files, Git & command help"), from the person's own + * requests only. No model is used to title anything; with no clear topic + * the row stays generic. + */ +const TOPICS: Array<[string, RegExp]> = [ + ['files', /\b(?:files?|folders?|directory|open|readme|package\.json|tsconfig|typescript files|ls)\b/u], + ['Git', /\b(?:git|branch|commit|push|pull|diff|staged|untracked|upstream|remote|merge|rebase)\b/u], + ['command help', /\b(?:what (?:is|does)|how (?:do|to)|syntax|options|flags|examples?|explain|mean)\b/u], + ['project', /\b(?:scripts?|tests?|dev server|build|run the|start the|project)\b/u], + ['config', /\b(?:config|settings?|zshrc|\.json|set \w+ to)\b/u], + ['packages', /\b(?:brew|homebrew|install|upgrade|uninstall|packages?)\b/u], + ['system', /\b(?:ping|disk|memory|port|processes|ip)\b/u], + ['sessions', /\b(?:sessions?|transcripts?|resume)\b/u], + ['NMSh settings', /\b(?:prompt|theme|chroma|cursor|suggestions|ghost text|providers?|folding|dividers?)\b/u], + ['local model', /\b(?:local model|qwen|llm|local understanding)\b/u], +]; + +export function askTopics(requests: readonly string[], limit = 3): string[] { + const counts = new Map(); + for (const request of requests) { + const text = request.toLowerCase(); + for (const [topic, pattern] of TOPICS) if (pattern.test(text)) counts.set(topic, (counts.get(topic) ?? 0) + 1); + } + return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit).map(([topic]) => topic); +} + +export function joinTopics(topics: readonly string[]): string { + return topics.length <= 1 ? topics.join('') : `${topics.slice(0, -1).join(', ')} & ${topics.at(-1)}`; +} + +/** The fold row text: topical when the requests say what it was about, generic otherwise. */ +export function askFoldLabel(turns: ReadonlyArray<{role: 'you' | 'ask'; text: string}>, request?: string): string { + const count = turns.length; + const requests = [...(request ? [request] : []), ...turns.filter(turn => turn.role === 'you').map(turn => turn.text)]; + const topics = askTopics(requests); + const counted = `${count} turn${count === 1 ? '' : 's'}`; + return topics.length ? `Ask · ${counted} · ${joinTopics(topics)} · Ctrl+O` : `Ask conversation · ${counted} · Ctrl+O`; +} diff --git a/src/ask/types.ts b/src/ask/types.ts new file mode 100644 index 00000000..012e1003 --- /dev/null +++ b/src/ask/types.ts @@ -0,0 +1,230 @@ +import type {TmuxChange} from '../tools/config/tmux.js'; +import type {BridgePolicy, BridgeTargetId} from '../themeBridge/model.js'; +import type {ParsedSlashCommand} from '../commands/slashCommands.js'; +import type {ShellId} from '../shell/adapters/ShellAdapter.js'; +import type {GitFacts} from './git.js'; +import type {ConfigTarget} from './configTargets.js'; +import type {FileEditPlan} from './fileEdit.js'; +import type {BrewFacts} from './packages.js'; + +/** + * Ask: plain-English requests resolved to NMSh's own typed capabilities. + * Nothing here executes text: every executable outcome is an AskAction whose + * handler is an existing NMSh implementation, and read-only commands are + * built from fixed argv by NMSh, never taken from the request or a model. + */ + +/** How much authority a capability needs; it decides confirmation and whether Ask may act at all. */ +export type SafetyClass = 'answer' | 'navigate' | 'read' | 'mutate' | 'install' | 'refused'; + +export type CapabilityId = + | 'shell.current' | 'shell.switch' | 'shell.leave' | 'shell.default' | 'shell.install' + | 'session.list' | 'session.resume' | 'transcript.find' | 'transcript.filter' + | 'project.run' | 'project.task' + | 'file.open' | 'file.list' | 'file.browse' | 'file.find' | 'editor.status' + | 'git.status' | 'git.diff' | 'git.branch' | 'git.log' | 'git.worktrees' + | 'settings.open' | 'theme.open' | 'prompt.open' | 'tools.open' | 'screensaver.open' | 'providers.open' + | 'provider.status' | 'provider.switch' | 'understanding.set' + | 'help.capabilities' | 'help.command' | 'help.feature' | 'feature.open' | 'help.guide'; + +/** Fixed read-only commands. The argv is built by NMSh; the request contributes at most a factual path. */ +export type ReadCommand = + | {id: 'git.status'; cwd?: string} + | {id: 'git.diff'; cwd?: string; staged?: boolean} + | {id: 'git.log'; cwd?: string}; + +export type AskAction = + | {kind: 'slash'; slash: ParsedSlashCommand; label: string} + | {kind: 'switchShell'; shell: ShellId} + | {kind: 'installShell'; shell: ShellId} + | {kind: 'openFile'; path: string} + /** The configured picker (fzf, Television…) over the project's files; Native falls back to Ask's own list. */ + | {kind: 'pickFile'; root: string} + | {kind: 'read'; command: ReadCommand} + | {kind: 'resumeTranscript'; id: string} + | {kind: 'attachSession'; id: string} + /** A Git command NMSh built from facts (never request or model text); the allowlist and risk are checked again before it runs. */ + | {kind: 'git'; argv: string[]; risk: 'read' | 'mutate'} + /** A finite project script (tests, build) run as a visible shell submission; argv must be a script the project defines. */ + | {kind: 'project'; argv: string[]} + /** A long-lived project script started as an NMSh-managed background task. */ + | {kind: 'startTask'; argv: string[]; cwd: string; label: string} + | {kind: 'stopTask'; id: string} + | {kind: 'taskOutput'; id: string} + /** Start a /watch of a command (classified again before it starts). */ + | {kind: 'watch'; command: string; intervalMs?: number} + | {kind: 'watchControl'; op: 'stop' | 'stopAll' | 'pause' | 'resume' | 'show'} + /** Open an http(s) URL a task printed, in the system browser. */ + | {kind: 'openUrl'; url: string} + /** A terminal recipe NMSh built (recipes.ts); re-checked against the recipe allowlist right before running, as a visible submission. */ + | {kind: 'recipe'; argv: string[]; risk: 'read' | 'network'} + /** A Homebrew install/upgrade/uninstall of a validated name; verified with Homebrew afterwards. */ + | {kind: 'brew'; argv: string[]; name: string; expect: 'installed' | 'upgraded' | 'absent'} + /** Run an installed formatter with its allowlisted argv on one file (visible submission). */ + | {kind: 'format'; argv: string[]} + /** Apply a verified file edit plan (NMSh writes it; no shell). */ + | {kind: 'applyEdit'; plan: FileEditPlan} + /** A curated tool install (the /tools recipe, shown exactly before the Yes); never a guessed package. */ + | {kind: 'installTool'; tool: string; label: string} + /** Typed tmux changes through the tmux Tool Configuration adapter (NMSh's managed tmux file only). */ + | {kind: 'tmux'; changes: TmuxChange[]; label: string} + /** Theme Bridge settings: switch, policy and per-target Manual pins (stable theme references only). */ + | {kind: 'themeBridge'; enabled?: boolean; policy?: BridgePolicy; targets?: Partial>; label: string} + /** Open /tools at one curated tool, optionally at its guided install, previous-zshrc comparison, p10k configurator or appearance import (navigation only). */ + | {kind: 'toolView'; tool: string; view: 'detail' | 'guided' | 'previous' | 'p10kConfigure' | 'importAppearance'; label: string} + | {kind: 'setting'; setting: 'suggestions' | 'history' | 'welcome' | 'picker' | 'navigation' | 'prompt' | 'localUnderstanding' | 'shellBackend' | 'composerDividers'; value: string; label: string}; + +/** + * How much an action changes, which decides what Ask may do with it: + * informational and read run when asked; navigate (what NMSh shows) runs when + * asked; configure, mutate and install always need the one final Yes/No for + * the exact action shown; destructive is never run by Ask (Copy/Insert only). + * There is no lasting approval of any kind. + */ +export type ActionRisk = 'informational' | 'read' | 'navigate' | 'configure' | 'mutate' | 'destructive' | 'install'; + +/** + * A command Ask shows, held as structured argv and rendered with the active + * shell's quoting. Values come from facts (current branch, real remotes, + * listed files) or are explicit placeholders like ; nothing is free + * text from the request or a model. Copy and Insert never execute; Run exists + * only when `run` is a typed NMSh action Ask's policy allows. + */ +export interface CommandBlock { + argv: string[]; + /** Indices of argv that are placeholders, never filled by guessing. */ + placeholders?: number[]; + provenance: 'reference' | 'context'; + risk: ActionRisk; + /** One line under the command: what it does here. */ + note?: string; + /** The facts used to fill it, shown compactly so stale assumptions are visible. */ + facts?: Array<[string, string]>; + run?: AskAction; + /** Text the person themselves ran, shown verbatim: Copy/Insert only, never Run. */ + literal?: string; + /** A generated script rendered from a verified FileEditPlan (shown, copied and inserted as is); Run applies the plan itself. */ + script?: string; +} + +/** + * What this Ask conversation is currently about, so "them", "that command" + * and "this branch" keep their meaning between turns. Bounded, per + * interaction, never persisted, and never model reasoning. + */ +export interface AskReferents { + concept?: string; + /** The command path the conversation is about, e.g. ['git', 'push']. */ + command?: string[]; + files?: {paths: string[]; kind: 'untracked' | 'modified' | 'staged' | 'conflicted' | 'mentioned' | 'listed'}; + /** The folder most recently listed. */ + directory?: string; + branch?: string; + remote?: string; + /** The command block most recently shown. */ + block?: CommandBlock; + /** The config file the conversation is about. */ + config?: {id: string; label: string; path: string}; + /** The file most recently opened, shown or edited. */ + file?: string; + /** Text the person pasted to add ("add this"), kept so "put it under terminal" can follow. */ + snippet?: string; +} + +export interface AskOption { + label: string; + detail?: string; + /** A stable key, so a rejected interpretation is not offered again in this interaction. */ + key: string; + /** What choosing this option means: a ready outcome, or text that refines the request. */ + outcome?: AskOutcome; + refine?: string; + /** Put this text in the Ask input (caret at the end) without sending it: path completion, "open " prefixes. */ + fill?: string; +} + +/** + * Structured results. Uncertain is not unsupported: each case is its own kind. + * - proposal: understood and supported; may need confirmation. + * - answer: understood; nothing to execute. + * - choose: several plausible interpretations (ambiguous) or a missing argument (missing). + * - unsupported: understood, but NMSh has no such capability. + * - unsafe: understood, but Ask's safety policy refuses it. + * - unclear: not enough to go on; ask for more, with factual categories. + */ +export type AskOutcome = + | {kind: 'proposal'; capability: CapabilityId; safety: SafetyClass; text: string; action: AskAction; command?: string; confidence: number; referents?: AskReferents; + /** The person already chose exactly this (a file picked from a list): navigation runs without another Yes. */ + direct?: boolean} + | {kind: 'answer'; capability: CapabilityId; text: string; follow?: AskOption; block?: CommandBlock; next?: AskOption[]; referents?: AskReferents} + | {kind: 'choose'; reason: 'ambiguous' | 'missing'; capability?: CapabilityId; question: string; options: AskOption[]; referents?: AskReferents} + | {kind: 'unsupported'; text: string; alternative?: AskOption} + | {kind: 'unsafe'; text: string; alternative?: AskOption; referents?: AskReferents} + | {kind: 'unclear'; text: string; categories: AskOption[]}; + +export interface RecentCommand { + command: string; + cwd?: string; + branch?: string; + exitCode: number; + durationMs?: number; + /** Output line count (the output itself is never given to Ask). */ + lines: number; +} + +export interface AskSession {id: string; state: 'attached' | 'detached'; current: boolean; cwd: string; shell?: string; running?: string; createdAt: number} +export interface AskTranscript {id: string; createdAt: string; startCwd: string; finalCwd: string; project: string; commandCount: number; live?: boolean} +export interface AskWorktree {path: string; branch?: string; current: boolean} +export interface AskProvider {family: string; id: string; label: string; active: boolean; available: boolean} + +/** + * Bounded facts Ask may use. Built from existing NMSh services; no + * environment, file contents or command output beyond these facts. + */ +export interface AskContext { + cwd: string; + home: string; + repoRoot?: string; + branch?: string; + dirty?: boolean; + worktrees: AskWorktree[]; + shell: ShellId; + defaultShell: ShellId; + shells: Array<{id: ShellId; label: string; installed: boolean; installable: boolean}>; + sessions: AskSession[]; + transcripts: AskTranscript[]; + /** File references NMSh already extracted from recent output, newest first. */ + recentFiles: string[]; + /** Recent submitted commands (program word and short text only). */ + recentCommands: string[]; + editor: {label: string; available: boolean; reason?: string}; + providers: AskProvider[]; + sessionMode: 'service' | 'in-process'; + now: number; + /** Bounded project file list (relative paths), filled lazily by the file resolver. */ + files?: readonly string[]; + /** What this conversation is about so far (see AskReferents). */ + referents?: AskReferents; + /** Local Git facts, gathered only for requests that need them. */ + git?: GitFacts; + /** A few current NMSh facts by concept id (e.g. shell: "Fish (default zsh)", chroma: "Aurora"), for the guide. */ + nmsh?: Record; + /** Homebrew facts gathered for a package request. */ + brew?: BrewFacts; + /** Config targets that apply here (from configTargets.ts), with whether each exists. */ + configs?: Array; + /** The project's own facts (package.json scripts, manager, other project files). */ + project?: import('./project.js').ProjectFacts; + /** Background tasks NMSh started (never other processes). */ + tasks?: import('./project.js').TaskSummary[]; + /** The failed block a 'why did this fail' request is about (bounded, redacted excerpt; never the whole log). */ + failure?: import('./failure.js').FailureFacts; + /** Local model facts for "what model are you using" (the same facts /llm shows). */ + llm?: import('./localModel.js').LocalModelFacts; + /** The platform recipes are built for (process.platform). */ + platform?: string; + /** The configured picker provider (native, fzf, television). */ + picker?: string; + /** Recent completed shell commands, newest first: factual metadata, never their output. */ + recent?: RecentCommand[]; +} diff --git a/src/ask/watchAssist.ts b/src/ask/watchAssist.ts new file mode 100644 index 00000000..930b97cb --- /dev/null +++ b/src/ask/watchAssist.ts @@ -0,0 +1,53 @@ +import {parseWatch, watchSafety} from '../tasks/WatchTasks.js'; +import {scriptArgv} from './project.js'; +import type {AskContext, AskOutcome} from './types.js'; + +/** + * Watch requests in plain language, mapped to the typed /watch actions. The + * watched command is the person's own words after "watch", the project's own + * test script, or the command they just ran; it is classified exactly like + * /watch (refused, confirmed or allowed) and never assembled from model text. + */ +const SECONDS = /\bevery (\d+)\s*(s|sec|secs|seconds?|m|min|minutes?)\b/u; + +function interval(text: string): number | undefined { + const match = SECONDS.exec(text); + return match ? Number(match[1]) * (/^m/u.test(match[2]!) ? 60_000 : 1000) : undefined; +} + +function proposal(command: string, intervalMs: number | undefined): AskOutcome { + const safety = watchSafety(command); + if (safety.kind === 'refused') return {kind: 'unsafe', text: safety.reason}; + return {kind: 'proposal', capability: 'project.task', safety: 'read', confidence: 0.9, command: `/watch ${intervalMs ? `--every ${intervalMs / 1000}s ` : ''}${command}`, + text: `Watch ${command}${intervalMs ? ` every ${intervalMs / 1000}s` : ''}? NMSh runs it on its own schedule and shows what changes${safety.kind === 'confirm' ? ' (it can\'t tell whether this command changes anything; you confirm once more)' : ''}.`, + action: {kind: 'watch', command, ...(intervalMs ? {intervalMs} : {})}}; +} + +export function resolveWatch(text: string, raw: string, context: AskContext): AskOutcome | undefined { + if (/^(?:please )?(?:stop|end|cancel) (?:the |all )?watch(?:ing|es)?\b/u.test(text)) return {kind: 'proposal', capability: 'project.task', safety: 'navigate', confidence: 0.95, direct: true, text: 'Stopping the watch.', action: {kind: 'watchControl', op: /\ball\b/u.test(text) ? 'stopAll' : 'stop'}}; + if (/^(?:please )?pause (?:the )?watch/u.test(text)) return {kind: 'proposal', capability: 'project.task', safety: 'navigate', confidence: 0.95, direct: true, text: 'Pausing the watch.', action: {kind: 'watchControl', op: 'pause'}}; + if (/^(?:please )?(?:resume|unpause) (?:the )?watch/u.test(text)) return {kind: 'proposal', capability: 'project.task', safety: 'navigate', confidence: 0.95, direct: true, text: 'Resuming the watch.', action: {kind: 'watchControl', op: 'resume'}}; + if (/^(?:show |view |open )?(?:me )?(?:the |my )?watch(?:es)?(?: output| results?| status)$|^(?:show|list) (?:the |my )?watch(?:es)?$/u.test(text)) return {kind: 'proposal', capability: 'project.task', safety: 'navigate', confidence: 0.9, direct: true, text: 'Opening /watch.', action: {kind: 'watchControl', op: 'show'}}; + // "keep running the tests every 5 seconds", "watch the tests". + if (/\b(?:keep running|watch|rerun|re-run|keep checking)\b.*\btests?\b/u.test(text)) { + const project = context.project; + const script = project?.kind === 'node' ? ['test', 'tests', 'test:unit'].find(name => project.scripts[name] !== undefined) : undefined; + const command = script && project ? scriptArgv(project, script).join(' ') : project?.kind === 'rust' ? 'cargo test' : project?.kind === 'go' ? 'go test ./...' : undefined; + if (!command) return {kind: 'answer', capability: 'project.task', text: 'This project has no test command NMSh recognizes. /watch watches any command you name.'}; + return proposal(command, interval(text)); + } + // "tell me when this health check changes": the command the person just ran. + if (/\b(?:tell me|let me know|notify me|alert me) when\b.*\b(?:changes?|is up|is down|passes|fails|recovers)\b/u.test(text)) { + const last = context.recent?.[0]?.command; + return last ? proposal(last, interval(text)) : {kind: 'answer', capability: 'project.task', text: 'Run the command once first; then I can watch it for changes.'}; + } + // "watch git status", "watch npm test every 10 seconds". + const named = /^(?:please |can you |could you )?(?:watch|keep watching|monitor)\s+(.+)$/u.exec(raw.trim().replace(/[?.!]+$/u, '')); + if (named) { + const words = named[1]!.replace(SECONDS, '').trim(); + const parsed = parseWatch(words); + if (!parsed.command || /^(?:it|this|that|the (?:output|transcript))$/iu.test(parsed.command)) return undefined; + return proposal(parsed.command, interval(text) ?? parsed.intervalMs); + } + return undefined; +} diff --git a/src/chroma/color.ts b/src/chroma/color.ts new file mode 100644 index 00000000..2d397534 --- /dev/null +++ b/src/chroma/color.ts @@ -0,0 +1,181 @@ +import type {Rgb} from './escape.js'; + +/** + * Small perceptual color toolkit (OKLab / OKLCH) for prompt themes, vibrance + * and Chroma. Pure functions, no dependencies; sRGB in, sRGB out, clamped. + */ + +function linearize(channel: number): number { + const value = channel / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; +} + +function gamma(channel: number): number { + const value = channel <= 0.0031308 ? channel * 12.92 : 1.055 * channel ** (1 / 2.4) - 0.055; + return Math.max(0, Math.min(255, Math.round(value * 255))); +} + +export type Oklab = [lightness: number, a: number, b: number]; +export interface Oklch {l: number; c: number; h: number} + +export function toOklab(color: Rgb): Oklab { + const r = linearize(color.red), g = linearize(color.green), b = linearize(color.blue); + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + return [0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s]; +} + +function rawFromOklab(l: number, a: number, b: number): [number, number, number] { + const lc = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const mc = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const sc = (l - 0.0894841775 * a - 1.291485548 * b) ** 3; + return [4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc, + -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc, + -0.0041960863 * lc - 0.7034186147 * mc + 1.707614701 * sc]; +} + +export function fromOklab(l: number, a: number, b: number): Rgb { + const [red, green, blue] = rawFromOklab(l, a, b); + return {red: gamma(red), green: gamma(green), blue: gamma(blue)}; +} + +export function toOklch(color: Rgb): Oklch { + const [l, a, b] = toOklab(color); + const c = Math.hypot(a, b); + return {l, c, h: c < 1e-6 ? 0 : (Math.atan2(b, a) * 180 / Math.PI + 360) % 360}; +} + +/** + * OKLCH to sRGB. Out-of-gamut colors keep lightness and hue and lose chroma + * until they fit, so saturation boosts never shift hue or clip to white. + */ +export function fromOklch({l, c, h}: Oklch): Rgb { + const lightness = Math.max(0, Math.min(1, l)); + const radians = h * Math.PI / 180; + let chroma = Math.max(0, c); + for (let step = 0; step < 24; step += 1) { + const raw = rawFromOklab(lightness, chroma * Math.cos(radians), chroma * Math.sin(radians)); + if (raw.every(channel => channel >= -0.0005 && channel <= 1.0005)) break; + chroma *= 0.88; + } + return fromOklab(lightness, chroma * Math.cos(radians), chroma * Math.sin(radians)); +} + +/** Perceptual interpolation; hue follows the shorter way round. */ +export function mixOklch(from: Rgb, to: Rgb, amount: number): Rgb { + const t = Math.max(0, Math.min(1, amount)); + if (t === 0) return from; + if (t === 1) return to; + const a = toOklch(from), b = toOklch(to); + // Near-gray ends borrow the other end's hue so the path does not swing through unrelated hues. + const ha = a.c < 0.02 ? b.h : a.h; + const hb = b.c < 0.02 ? a.h : b.h; + let delta = hb - ha; + if (delta > 180) delta -= 360; + if (delta < -180) delta += 360; + return fromOklch({l: a.l + (b.l - a.l) * t, c: a.c + (b.c - a.c) * t, h: (ha + delta * t + 360) % 360}); +} + +/** WCAG relative luminance. */ +export function relativeLuminance(color: Rgb): number { + return 0.2126 * linearize(color.red) + 0.7152 * linearize(color.green) + 0.0722 * linearize(color.blue); +} + +export function contrastRatio(a: Rgb, b: Rgb): number { + const la = relativeLuminance(a), lb = relativeLuminance(b); + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); +} + +// Pure extremes: for any background one of them reaches 4.5:1. +const DARK_TEXT: Rgb = {red: 0, green: 0, blue: 0}; +const LIGHT_TEXT: Rgb = {red: 255, green: 255, blue: 255}; + +/** + * Text for a filled surface: keep `preferred` when it already reads (>= 4.5:1), + * else a tinted near-white or near-black, whichever contrasts more. + */ +export function readableForeground(background: Rgb, preferred?: Rgb, minimum = 4.5): Rgb { + if (preferred && contrastRatio(preferred, background) >= minimum) return preferred; + const hue = toOklch(background).h; + const light = fromOklch({l: 0.97, c: 0.015, h: hue}); + const dark = fromOklch({l: 0.2, c: 0.03, h: hue}); + const best = contrastRatio(light, background) >= contrastRatio(dark, background) ? light : dark; + return contrastRatio(best, background) >= minimum ? best + : contrastRatio(LIGHT_TEXT, background) >= contrastRatio(DARK_TEXT, background) ? LIGHT_TEXT : DARK_TEXT; +} + +/** + * `color` made readable on `background` while keeping its hue: lightness moves + * away from the background until the contrast holds (chroma eases slightly), + * so a selected row keeps its distinctions instead of turning uniformly white. + */ +export function contrastOn(color: Rgb, background: Rgb, minimum = 4.5): Rgb { + if (contrastRatio(color, background) >= minimum) return color; + const lch = toOklch(color); + const lighten = toOklch(background).l < 0.6; + for (let step = 1; step <= 24; step += 1) { + const amount = step / 24; + const candidate = fromOklch({l: lighten ? lch.l + (0.985 - lch.l) * amount : lch.l * (1 - 0.85 * amount), c: lch.c * (1 - 0.4 * amount), h: lch.h}); + if (contrastRatio(candidate, background) >= minimum) return candidate; + } + return readableForeground(background, undefined, minimum); +} + +/** Soft / Standard / Vibrant: how strongly theme-derived colors separate. Standard is identity. */ +export type Vibrance = 'soft' | 'standard' | 'vibrant'; +export const VIBRANCE_LEVELS: readonly Vibrance[] = ['soft', 'standard', 'vibrant']; +export const VIBRANCE_LABELS: Record = {soft: 'Soft', standard: 'Standard', vibrant: 'Vibrant'}; + +/** + * `fill` constrained so the fixed `text` stays readable on it: lightness moves + * away from the text (darker under light text, lighter under dark text) until + * the contrast holds. Animated surfaces use this so motion changes the fill, + * never the text's light/dark polarity. + */ +export function surfaceFor(fill: Rgb, text: Rgb, minimum = 4.5): Rgb { + if (contrastRatio(text, fill) >= minimum) return fill; + const lch = toOklch(fill); + const darken = relativeLuminance(text) >= relativeLuminance(fill); + for (let step = 1; step <= 32; step += 1) { + const amount = step / 32; + const candidate = fromOklch({l: darken ? lch.l * (1 - amount) : lch.l + (1 - lch.l) * amount, c: lch.c * (1 - 0.5 * amount), h: lch.h}); + if (contrastRatio(text, candidate) >= minimum) return candidate; + } + return darken ? DARK_TEXT : LIGHT_TEXT; +} + +export function normalizeVibrance(value: unknown): Vibrance { + return VIBRANCE_LEVELS.includes(value as Vibrance) ? value as Vibrance : 'standard'; +} + +/** + * A segment background under a vibrance level. Hue is always kept. Soft + * quiets chroma and pulls lightness toward the middle; Vibrant raises chroma + * and spreads lightness so adjacent modules separate clearly. Near-neutral + * colors (grayscale themes) only spread in lightness. + */ +export function applyVibrance(color: Rgb, vibrance: Vibrance): Rgb { + if (vibrance === 'standard') return color; + const lch = toOklch(color); + const neutral = lch.c < 0.025; + if (vibrance === 'soft') { + return fromOklch({l: 0.62 + (lch.l - 0.62) * 0.7, c: neutral ? lch.c : lch.c * 0.62, h: lch.h}); + } + // Colors already at the gamut edge (neon) have nothing left to give. + if (lch.c >= 0.22) return color; + // Spread lightness away from the middle, never compressing an already extreme color. + const l = Math.max(Math.min(lch.l, 0.3), Math.min(Math.max(lch.l, 0.86), 0.6 + (lch.l - 0.6) * 1.3)); + return fromOklch({l, c: neutral ? lch.c : Math.max(lch.c, Math.min(0.32, lch.c * 1.45 + 0.03)), h: lch.h}); +} + +export function hexColor(color: Rgb): string { + return `#${[color.red, color.green, color.blue].map(channel => channel.toString(16).padStart(2, '0')).join('')}`; +} + +export function parseHexColor(value: string): Rgb | undefined { + if (!/^#[0-9a-f]{6}$/iu.test(value)) return undefined; + return {red: parseInt(value.slice(1, 3), 16), green: parseInt(value.slice(3, 5), 16), blue: parseInt(value.slice(5, 7), 16)}; +} 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..ed8b1dcb --- /dev/null +++ b/src/chroma/treatment.ts @@ -0,0 +1,341 @@ +import {applyCurve, mixRgb, sampleGradient, solid, theme, type ColorRef, type Curve} from './chroma.js'; +import {UI_COLORS} from '../ui/palette.js'; +import {colorEscape, type Rgb} from './escape.js'; +import {fromOklch, mixOklch, parseHexColor, toOklch} from './color.js'; +import {colorLevel, type ColorLevel} from '../presentation/capabilities.js'; +import {isReducedMotion} from '../presentation/environment.js'; +import {wrappedPhase} from '../motion/motion.js'; +import {graphemes} from '../input/inputLayout.js'; +import {displayWidth} from '../util/text.js'; + +/** `theme` is "Current Theme": stops derived from the active Native prompt theme. */ +export const TREATMENT_PRESETS = ['off', 'lavender', 'aurora', 'theme', 'rainbow', 'nebula', 'blackhole', 'warm', 'cool', 'monochrome', 'custom'] as const; +export const TREATMENT_PRESET_LABELS: Record = { + off: 'Off', lavender: 'Lavender', aurora: 'Aurora', theme: 'Current Theme', rainbow: 'Rainbow', nebula: 'Nebula', + blackhole: 'Black Hole', warm: 'Warm', cool: 'Cool', monochrome: 'Monochrome', custom: 'Custom', +}; +/** Spatial layout of the gradient; independent of motion, so a static gradient can run either way. */ +export const TREATMENT_GEOMETRIES = ['linear', 'linear-reverse', 'center-out', 'outside-in'] as const; +export const TREATMENT_GEOMETRY_LABELS: Record = { + linear: 'Left → Right', 'linear-reverse': 'Right → Left', 'center-out': 'Center → Outward', 'outside-in': 'Outside → Center', +}; +export const TREATMENT_MOTIONS = ['static', 'breathe', 'comet', 'pulse', 'travel'] as const; +export const TREATMENT_MOTION_LABELS: Record = { + static: 'Static', travel: 'Travel', breathe: 'Breathe', comet: 'Comet', pulse: 'Pulse', +}; +export const TREATMENT_SPEEDS = ['very-slow', 'slow', 'normal', 'fast'] as const; +export const TREATMENT_SPEED_LABELS: Record = { + 'very-slow': 'Very Slow', slow: 'Slow', normal: 'Normal', fast: 'Fast', +}; +export const TREATMENT_CURVES: readonly Curve[] = ['linear', 'ease-in', 'ease-out', 'ease-in-out']; +export const TREATMENT_CURVE_LABELS: Record = {linear: 'Linear', 'ease-in': 'Ease In', 'ease-out': 'Ease Out', 'ease-in-out': 'Ease In-Out'}; +/** Travel and Comet only: which way the motion runs (the spatial layout is Geometry). */ +export const TREATMENT_DIRECTIONS = ['forward', 'reverse'] as const; +export const TREATMENT_DIRECTION_LABELS: Record = {forward: 'Forward', reverse: 'Reverse'}; +/** Identity: project, path and toolchains. Prompt: every module except protected status/Git-state meaning. */ +export const TREATMENT_SCOPES = ['identity', 'prompt'] as const; +export const TREATMENT_SCOPE_LABELS: Record = {identity: 'Identity modules', prompt: 'Whole prompt'}; +/** Palette influence is stored as intensity, so pre-v2 values (0.65 default) keep their exact look. */ +export const TREATMENT_INFLUENCES = [ + {id: 'theme-aware', label: 'Theme-aware', intensity: 0.35}, + {id: 'mixed', label: 'Mixed', intensity: 0.65}, + {id: 'full', label: 'Full Chroma', intensity: 0.9}, +] as const; +export type TreatmentInfluence = typeof TREATMENT_INFLUENCES[number]['id']; + +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; + /** Animation cycle length; Normal is the pre-v2 timing. */ + speed?: typeof TREATMENT_SPEEDS[number]; + /** Easing of the animation phase. */ + curve?: Curve; + /** Travel and Comet only: which way the color moves. */ + direction?: typeof TREATMENT_DIRECTIONS[number]; + scope?: typeof TREATMENT_SCOPES[number]; + /** Explicit per-module colors are authoritative unless this is on. */ + customColors?: boolean; + /** Restrained automatic effects on real milestones (install, update, onboarding). */ + autoEffects?: boolean; + /** + * Semantic module colors (success, failure, Git state): Preserve keeps their + * meaning colors; Override lets Chroma recolor them too (text, symbols and + * contrast correction still carry the meaning). + */ + semantic?: typeof SEMANTIC_MODES[number]; + /** + * Divider lines (stored as `rules`): true follows Chroma on the composer and + * command-history divider lines, false follows the UI theme separator. + */ + rules?: boolean; + /** The event-driven light sweep (selection, value change, submit, confirmation, live working text). */ + shimmer?: 'on' | 'off'; +} + +export const DIVIDER_LINES_HELP = 'Colors the composer and command-history divider lines. Panel borders follow UI chrome.'; +export const dividerLinesLabel = (followChroma: boolean): string => followChroma ? 'Follow Chroma' : 'Follow UI theme'; + +export const SEMANTIC_MODES = ['preserve', 'override'] as const; +export const SEMANTIC_MODE_LABELS: Record = {preserve: 'Preserve', override: 'Override'}; + +/** Full Chroma is the default influence; saved influences load unchanged. */ +export const DEFAULT_INTENSITY = 0.9; + +export const DEFAULT_TREATMENT_SETTINGS: TreatmentSettings = { + preset: 'off', geometry: 'linear', motion: 'static', intensity: DEFAULT_INTENSITY, semantic: 'override', rules: true, + shimmer: 'on', + customStops: [], reducedMotion: false, effectsOff: false, + speed: 'normal', curve: 'linear', direction: 'forward', scope: 'prompt', customColors: false, autoEffects: true, +}; + +export const MIN_CUSTOM_STOPS = 2; +export const MAX_CUSTOM_STOPS = 8; + +const pick = (values: readonly T[], value: unknown, fallback: T): T => values.includes(value as T) ? value as T : fallback; + +export function validCustomStops(value: unknown): value is string[] { + return Array.isArray(value) && value.length >= MIN_CUSTOM_STOPS && value.length <= MAX_CUSTOM_STOPS + && value.every(stop => typeof stop === 'string' && /^#[0-9a-f]{6}$/iu.test(stop)); +} + +/** 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 = validCustomStops(v.customStops) ? v.customStops.map(stop => stop.toLowerCase()) : []; + const preset = pick(TREATMENT_PRESETS, v.preset, 'off'); + const intensity = typeof v.intensity === 'number' && Number.isFinite(v.intensity) ? Math.max(0, Math.min(1, v.intensity)) : DEFAULT_INTENSITY; + // An explicit semantic choice is kept; otherwise Full Chroma overrides and gentler influences preserve. + const semantic = pick(SEMANTIC_MODES, v.semantic, treatmentInfluence({intensity}) === 'full' ? 'override' : 'preserve'); + return { + // Saves write every field, so a missing value is a new configuration: new defaults apply, saved choices stay. + semantic, rules: v.rules !== false, + shimmer: v.shimmer === 'off' ? 'off' : 'on', + preset: preset === 'custom' && !customStops.length ? 'off' : preset, + geometry: pick(TREATMENT_GEOMETRIES, v.geometry, 'linear'), + motion: pick(TREATMENT_MOTIONS, v.motion, 'static'), + intensity, + customStops, reducedMotion: v.reducedMotion === true, effectsOff: v.effectsOff === true, + speed: pick(TREATMENT_SPEEDS, v.speed, 'normal'), + curve: pick(TREATMENT_CURVES, v.curve, 'linear'), + direction: pick(TREATMENT_DIRECTIONS, v.direction, 'forward'), + scope: pick(TREATMENT_SCOPES, v.scope, 'prompt'), + customColors: v.customColors === true, + autoEffects: v.autoEffects !== false, + }; +} + +/** The influence step nearest the stored intensity. */ +export function treatmentInfluence(settings: Pick): TreatmentInfluence { + return TREATMENT_INFLUENCES.reduce((best, entry) => + Math.abs(entry.intensity - settings.intensity) < Math.abs(best.intensity - settings.intensity) ? entry : best).id; +} + +/** Whether the selected motion uses Direction (only moving crests have one). */ +export function motionHasDirection(motion: TreatmentSettings['motion']): boolean { + return motion === 'travel' || motion === 'comet'; +} + +export interface Treatment { + stops: readonly ColorRef[]; + geometry: TreatmentSettings['geometry']; + motion: TreatmentSettings['motion']; + intensity: number; + speed?: TreatmentSettings['speed']; + curve?: Curve; + direction?: TreatmentSettings['direction']; + /** + * Current Theme on prompt cells: every cell keeps its own hue and the + * sweep varies lightness and chroma, so the theme stays recognizable. + */ + own?: boolean; +} +export interface TreatmentContext { + role: TreatmentRole; + base: Rgb; + reducedMotion?: boolean; + effectsOff?: boolean; + level?: ColorLevel; +} + +const hex = (value: string): ColorRef => solid(parseHexColor(value) ?? {red: 0, green: 0, blue: 0}); + +/** + * Each preset is its own palette family, designed to be told apart at a + * glance: Lavender stays within violet; Aurora sweeps green → cyan → violet; + * Black Hole runs from a deep violet core to a hot accretion rim. + */ +export const PRESET_STOPS: Readonly, readonly string[]>> = { + lavender: ['#a67cf3', '#c5a4fa', '#9979d9'], + aurora: ['#3ee8b5', '#45b8f0', '#8f6cf5', '#e46cc8'], + rainbow: ['#ff5f6d', '#ffa647', '#f2e35b', '#5be584', '#4fb8f7', '#9575f0'], + nebula: ['#7b2fbf', '#c2389e', '#5a4ff0', '#3fc2f0'], + blackhole: ['#3d1f6e', '#7a2a8c', '#e0566b', '#ffb36b'], + warm: ['#e8573c', '#f29e4c', '#f1c453', '#d9486b'], + cool: ['#2ec4b6', '#3a86ff', '#5e60ce', '#48bfe3'], + monochrome: ['#6a6e78', '#d4d7dd', '#f4f5f7', '#9a9ea8'], +}; + +/** + * Current Theme stops for surfaces without prompt context (dividers, panel + * frames). The app keeps them in step with the active Native theme. + */ +let activeThemeStops: readonly Rgb[] | undefined; +export function setActiveThemeStops(stops: readonly Rgb[] | undefined): void { + activeThemeStops = stops && stops.length ? [...stops] : undefined; +} + +/** `themeStops` overrides the active theme stops (a preview of another theme, a prompt's own colors). */ +export function treatmentFor(settings: TreatmentSettings, themeStops?: readonly Rgb[]): Treatment | undefined { + if (settings.preset === 'off') return undefined; + const themed = themeStops?.length ? themeStops : activeThemeStops; + const stops: readonly ColorRef[] = settings.preset === 'custom' ? settings.customStops.map(hex) + : settings.preset === 'theme' ? (themed?.length ? themed.map(solid) : [theme('accent'), theme('primary'), theme('secondary')]) + : PRESET_STOPS[settings.preset].map(hex); + if (!stops.length) return undefined; + return {stops, geometry: settings.geometry, motion: settings.motion, intensity: settings.intensity, + speed: settings.speed, curve: settings.curve, direction: settings.direction}; +} + +/** Cycle lengths at Normal speed; travel and breathe are the pre-v2 timings. */ +const MOTION_CYCLE_MS: Record, number> = {travel: 6000, breathe: 4000, comet: 5000, pulse: 3200}; +const SPEED_FACTOR: Record, number> = {'very-slow': 2, slow: 1.5, normal: 1, fast: 0.5}; + +export function motionCycleMs(motion: TreatmentSettings['motion'], speed: TreatmentSettings['speed'] = 'normal'): number { + return motion === 'static' ? 0 : MOTION_CYCLE_MS[motion] * SPEED_FACTOR[speed]; +} + +/** Gradient position and intensity factor at one cell and time; pure. */ +function animate(treatment: Treatment, position: number, time: number, still: boolean): {position: number; factor: number} { + const motion = still ? 'static' : treatment.motion; + if (motion === 'static') return {position, factor: 1}; + const phase = applyCurve(treatment.curve ?? 'linear', wrappedPhase(time, motionCycleMs(motion, treatment.speed))); + const signed = treatment.direction === 'reverse' ? -phase : phase; + switch (motion) { + case 'travel': { + // The gradient flows as a seamless loop (out and back, so the last stop never snaps to the first), + // with a gentle brightness crest riding along so the movement reads even across few cells. + const flowing = ((position + signed) % 1 + 1) % 1; + const loop = 1 - Math.abs(2 * flowing - 1); + return {position: loop, factor: 0.85 + 0.15 * (1 + Math.cos(2 * Math.PI * flowing)) / 2}; + } + case 'breathe': return {position, factor: 0.65 + 0.35 * (1 + Math.cos(2 * Math.PI * phase)) / 2}; + case 'comet': { + // A bright crest sweeps across; cells away from it settle to a quieter base. + const crest = (signed % 1 + 1) % 1; + const distance = Math.min(Math.abs(position - crest), 1 - Math.abs(position - crest)); + return {position, factor: 0.5 + 0.5 * Math.max(0, 1 - distance / 0.22)}; + } + case 'pulse': { + const wave = phase < 0.25 ? applyCurve('ease-out', phase / 0.25) : 1 - applyCurve('ease-in-out', (phase - 0.25) / 0.75); + return {position, factor: 0.6 + 0.4 * wave}; + } + } +} + +function geometryPosition(geometry: Treatment['geometry'], position: number): number { + if (geometry === 'linear-reverse') return 1 - position; + if (geometry === 'center-out') return Math.abs(2 * position - 1); + if (geometry === 'outside-in') return 1 - Math.abs(2 * position - 1); + return position; +} + +function stopsGradient(treatment: Treatment) { + return {stops: treatment.stops.map((color, index) => ({color, at: treatment.stops.length <= 1 ? 0 : index / (treatment.stops.length - 1)}))}; +} + +/** 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; + const raw = context.width <= 1 ? 0 : Math.max(0, Math.min(1, context.column / (context.width - 1))); + const animated = animate(treatment, geometryPosition(treatment.geometry, raw), time, Boolean(context.reducedMotion || context.effectsOff)); + return mixRgb(context.base, sampleGradient(stopsGradient(treatment), animated.position), + Math.max(0, Math.min(1, treatment.intensity * animated.factor))); +} + +/** + * The treated color of a prompt cell at `position` (0..1 along the prompt). + * Perceptual blend of the cell's own color toward the gradient, so theme + * hue identity survives at Theme-aware and Mixed influence. + */ +export function samplePromptTreatment(treatment: Treatment, base: Rgb, position: number, time: number, still: boolean): Rgb { + if (!treatment.stops.length) return base; + const animated = animate(treatment, geometryPosition(treatment.geometry, Math.max(0, Math.min(1, position))), time, still); + if (treatment.own) { + // A lightness/chroma wave over the cell's own color: never another hue, never toward white. + const lch = toOklch(base); + const wave = Math.cos(2 * Math.PI * animated.position); + const target = fromOklch({l: Math.max(0.25, Math.min(0.78, lch.l + 0.13 * wave)), c: lch.c < 0.02 ? lch.c : lch.c * (1.15 + 0.25 * wave) + 0.02, h: lch.h}); + return mixOklch(base, target, Math.max(0, Math.min(1, treatment.intensity * animated.factor))); + } + const target = sampleGradient(stopsGradient(treatment), animated.position); + return mixOklch(base, target, Math.max(0, Math.min(1, treatment.intensity * animated.factor))); +} + +/** + * Text on an unknown, usually dark, terminal background: keep hue, hold a + * lightness floor so treated text never sinks into the background. + */ +export function readableTextTone(color: Rgb): Rgb { + const lch = toOklch(color); + return lch.l >= 0.68 ? color : fromOklch({...lch, l: 0.68}); +} + +/** Whether a treatment animates (and so needs presentation frames). */ +export function treatmentAnimated(settings: TreatmentSettings): boolean { + return settings.preset !== 'off' && settings.motion !== 'static' && !settings.reducedMotion && !settings.effectsOff && !isReducedMotion(); +} + +/** 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}`; +} + +/** + * Every NMSh-owned divider line (composer top and bottom, the prompt row's + * divider fill, command-history dividers) takes its color here, so they can + * never disagree. Follow Chroma paints the Chroma gradient over the UI + * separator; `live` surfaces may move, history is always static. + * Follow UI theme (or Chroma Off) is the UI separator role. + */ +export function paintDivider(text: string, settings: TreatmentSettings, time = 0, live = true, base: Rgb = UI_COLORS.separator): string { + if (!text || colorLevel() === 'none') return text; + if (settings.preset !== 'off' && settings.rules !== false) { + return `${paintTreatment(text, live ? settings : {...settings, motion: 'static'}, 'divider', {...UI_COLORS.separator}, time)}\u001B[39m`; + } + return `${colorEscape(38, base)}${text}\u001B[39m`; +} + +/** Whether divider lines follow a moving Chroma (and so need presentation frames while live). */ +export function dividerAnimated(settings: TreatmentSettings): boolean { + return settings.rules !== false && treatmentAnimated(settings); +} + +/** Preview swatch: the preset's own gradient across `width` cells. */ +export function treatmentSwatch(settings: TreatmentSettings, width: number, themeStops?: readonly Rgb[], glyph = '█'): string { + const treatment = treatmentFor({...settings, intensity: 1, motion: 'static'}, themeStops); + if (!treatment) return ''; + return Array.from({length: Math.max(1, width)}, (_, index) => + `${colorEscape(38, sampleGradient(stopsGradient(treatment), width <= 1 ? 0 : index / (width - 1)))}${glyph}`).join('') + '\u001B[39m'; +} diff --git a/src/cli/configCommand.ts b/src/cli/configCommand.ts new file mode 100644 index 00000000..e2818f55 --- /dev/null +++ b/src/cli/configCommand.ts @@ -0,0 +1,84 @@ +import {existsSync, writeFileSync} from 'node:fs'; +import {createInterface} from 'node:readline/promises'; +import {loadPromptConfiguration, savePromptConfiguration} from '../prompt/configuration.js'; +import {promptConfigurationPath} from '../configuration/paths.js'; +import {CATEGORY_IDS, exportSettings, formatImportPlan, parseCategories, planImport, PORTABLE_CATEGORIES, readPortableFile} from '../configuration/portability.js'; + +export interface CliIo { + out: (text: string) => void; + err: (text: string) => void; + /** Ask a yes/no question; resolves false on anything but an explicit yes. Undefined when not interactive. */ + confirm?: (question: string) => Promise; + env?: NodeJS.ProcessEnv; + version?: string; +} + +export const CONFIG_USAGE = `Usage: + nmsh config categories + nmsh config export [--categories a,b] [--output FILE [--force]] + nmsh config import FILE [--categories a,b] [--yes] + nmsh config path + +Exports are human-readable, versioned JSON. They never include history, +transcripts, credentials, onboarding progress or machine-specific paths. +Import previews every change first and starts on No. +`; + +function option(args: string[], name: string): string | undefined { + const index = args.indexOf(name); + return index === -1 ? undefined : args[index + 1]; +} + +/** Interactive confirmation that defaults to No. */ +export async function ttyConfirm(question: string): Promise { + const rl = createInterface({input: process.stdin, output: process.stderr}); + try { return /^(?:y|yes)$/iu.test((await rl.question(`${question} [y/N] `)).trim()); } finally { rl.close(); } +} + +export async function runConfigCommand(args: string[], io: CliIo): Promise { + const env = io.env ?? process.env; + const path = promptConfigurationPath(env); + const [action, ...rest] = args; + try { + if (action === 'path') { io.out(`${path}\n`); return 0; } + if (action === 'categories') { + for (const id of CATEGORY_IDS) io.out(`${id.padEnd(14)} ${PORTABLE_CATEGORIES[id].join(', ')}\n`); + return 0; + } + if (action === 'export') { + const categories = parseCategories(option(rest, '--categories')); + const document = exportSettings(loadPromptConfiguration(path), categories, {version: io.version, now: new Date()}); + const text = `${JSON.stringify(document, null, 2)}\n`; + const output = option(rest, '--output'); + if (!output) { io.out(text); return 0; } + if (existsSync(output) && !rest.includes('--force')) { io.err(`${output} exists; add --force to replace it.\n`); return 1; } + writeFileSync(output, text, {mode: 0o600}); + io.err(`Exported ${categories.length} categor${categories.length === 1 ? 'y' : 'ies'} to ${output}\n`); + return 0; + } + if (action === 'import') { + const file = rest.find(item => !item.startsWith('--') && item !== option(rest, '--categories')); + if (!file) { io.err(CONFIG_USAGE); return 2; } + const categories = parseCategories(option(rest, '--categories')); + const current = loadPromptConfiguration(path); + const plan = planImport(current, readPortableFile(file), categories); + io.out(formatImportPlan(plan)); + if (plan.changes.length === 0) return 0; + let agreed = rest.includes('--yes'); + if (!agreed) { + if (!io.confirm) { io.err('Not applied. Re-run with --yes to apply this preview non-interactively.\n'); return 2; } + agreed = await io.confirm('Apply these changes?'); + } + if (!agreed) { io.err('Not applied; nothing changed.\n'); return 1; } + // Same atomic, merge-preserving save every Settings change uses. + savePromptConfiguration(plan.next, path, current); + io.err(`Applied ${plan.changes.length} change${plan.changes.length === 1 ? '' : 's'} to ${path}\n`); + return 0; + } + io.err(CONFIG_USAGE); + return 2; + } catch (error) { + io.err(`${error instanceof Error ? error.message : String(error)}\n`); + return 1; + } +} diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts new file mode 100644 index 00000000..75b4170c --- /dev/null +++ b/src/cli/doctor.ts @@ -0,0 +1,46 @@ +import {installProvenanceLabel} from '../update/update.js'; +import {homedir} from 'node:os'; +import {promptConfigurationPath} from '../configuration/paths.js'; +import {detectPlatform} from '../host/platform.js'; +import {detectTerminalHost} from '../host/terminalHost.js'; +import {defaultRuntimeDir} from '../session/runtimeDir.js'; +import {detectShellEnvironment, OWNERSHIP_NOTE, shellEnvironmentRows} from '../shell/ShellEnvironment.js'; +import {loadPromptConfiguration} from '../prompt/configuration.js'; +import {shellAdapter} from '../shell/adapters/registry.js'; +import {formatBuildIdentity, readBuildIdentity} from '../buildInfo.js'; + +/** + * `nmsh doctor`: a short, paste-able diagnostic for issue reports. No secrets, + * no environment dump, no command history; paths are shown with ~ for home. + */ +export function doctorReport(env: NodeJS.ProcessEnv = process.env, extra: Array<[string, string]> = []): string { + const home = homedir(); + const tilde = (path: string) => (path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path); + const platform = detectPlatform(); + const host = detectTerminalHost(env); + const config = loadPromptConfiguration(); + // The default backend for new sessions (doctor has no live session); never $SHELL. + const resolved = shellAdapter(config.shellBackend).resolveExecutable(process.env); + const activeShell = {id: config.shellBackend, ...(resolved ? {path: resolved} : {})}; + const rows: Array<[string, string]> = [ + ['NMSh', formatBuildIdentity(readBuildIdentity())], + ['Installed', installProvenanceLabel()], + ['Node', process.version], + ['Platform', `${process.platform} ${process.arch} · kernel ${platform.kernel}`], + ['Support', platform.support], + ['Terminal host', host.name || 'not identified'], + ['Host capabilities', Object.entries(host.capabilities).filter(([, value]) => value === true).map(([key]) => key).join(', ') || 'baseline'], + ...extra, + ['Prompt provider', config.provider], + ['History provider', config.history], + ['Navigation provider', config.navigation], + ['Picker provider', config.picker], + ['Session notices', config.sessionNotices ? 'On' : 'Off'], + ['Agent activity', config.agentActivity ? 'On (local only)' : 'Off'], + ...shellEnvironmentRows(detectShellEnvironment(), activeShell), + ['Config file', tilde(promptConfigurationPath(env))], + ['Runtime directory', tilde(defaultRuntimeDir(env))], + ]; + const width = Math.max(...rows.map(([label]) => label.length)) + 2; + return `${rows.map(([label, value]) => `${label.padEnd(width)}${value.replace(/[\u0000-\u001f\u007f]/gu, ' ')}`).join('\n')}\n\n${OWNERSHIP_NOTE}\n`; +} diff --git a/src/cli/uninstall.ts b/src/cli/uninstall.ts new file mode 100644 index 00000000..bec05179 --- /dev/null +++ b/src/cli/uninstall.ts @@ -0,0 +1,140 @@ +import {existsSync, lstatSync, readdirSync, readFileSync, realpathSync, rmSync, unlinkSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {delimiter, dirname, isAbsolute, join, resolve} from 'node:path'; +import {nmshConfigDirectory} from '../configuration/paths.js'; +import {defaultRuntimeDir} from '../session/runtimeDir.js'; + +/** + * NMSh self-uninstall: preview first, remove only what is provably NMSh's. + * + * Provable: `nmsh` launcher symlinks (as `npm link` creates) and npm global + * package links whose target resolves into this NMSh installation. Never + * removed automatically: the source checkout itself (you cloned it), shell + * config, terminal config, or anything a symlink does not prove is ours. + * NMSh's local data (settings, transcripts, history deletions, agent stats, + * install records) is kept unless separately requested. + */ + +export interface UninstallOptions { + /** NMSh package root (the directory containing bin/nmsh). */ + root: string; + env: NodeJS.ProcessEnv; + /** npm global prefix, when known (npm config get prefix). */ + npmPrefix?: string; +} + +export interface UninstallPlan { + root: string; + layout: 'git-checkout' | 'package' | 'unknown'; + /** Symlinks that resolve into `root`; removed on uninstall. */ + links: string[]; + /** Kept unless --delete-data: path and what it holds. */ + data: Array<{path: string; description: string}>; + /** Things NMSh cannot prove it owns, with what to do by hand. */ + manual: string[]; + /** Live sessions block uninstall: their shells would lose their frontend program. */ + runtimeDir: string; +} + +const GHOSTTY_KEYBIND_LINES = ['keybind = cmd+a=text:\\x1b[97;9u', 'keybind = cmd+up=text:\\x1b[1;9A', 'keybind = cmd+down=text:\\x1b[1;9B', + 'keybind = cmd+shift+up=text:\\x1b[1;10A', 'keybind = cmd+shift+down=text:\\x1b[1;10B', 'keybind = alt+backspace=text:\\x1b[127;3u']; + +function realOrUndefined(path: string): string | undefined { + try { return realpathSync(path); } catch { return undefined; } +} + +function inside(child: string, parent: string): boolean { + return child === parent || child.startsWith(parent.endsWith('/') ? parent : `${parent}/`); +} + +/** Symlinks named `nmsh` on PATH (and in the npm prefix) whose target lives inside root. */ +export function findOwnedLinks(root: string, env: NodeJS.ProcessEnv, npmPrefix?: string): string[] { + const realRoot = realOrUndefined(root) ?? resolve(root); + const candidates = new Set(); + for (const directory of (env.PATH ?? '').split(delimiter)) if (directory && isAbsolute(directory)) candidates.add(join(directory, 'nmsh')); + if (npmPrefix && isAbsolute(npmPrefix)) { + candidates.add(join(npmPrefix, 'bin', 'nmsh')); + candidates.add(join(npmPrefix, 'lib', 'node_modules', 'nmsh')); + candidates.add(join(npmPrefix, 'node_modules', 'nmsh')); + } + const owned: string[] = []; + for (const path of candidates) { + let stat; + try { stat = lstatSync(path); } catch { continue; } + if (!stat.isSymbolicLink()) continue; // A real file could be anything; never removed. + const target = realOrUndefined(path); + if (target && inside(target, realRoot)) owned.push(path); + } + return owned.sort(); +} + +export function planUninstall(options: UninstallOptions): UninstallPlan { + const {root, env} = options; + const layout = existsSync(join(root, '.git')) ? 'git-checkout' : existsSync(join(root, 'package.json')) ? 'package' : 'unknown'; + const config = nmshConfigDirectory(env); + const data: UninstallPlan['data'] = []; + if (existsSync(config)) data.push({path: config, description: 'settings, transcripts, presets, history deletions, agent stats, install records'}); + const manual: string[] = []; + if (layout === 'git-checkout') manual.push(`The NMSh source checkout stays at ${root}; delete it yourself when you no longer want it.`); + else if (layout === 'package') manual.push(`NMSh files at ${root} belong to the package manager that installed them; remove NMSh with it (for example npm uninstall -g nmsh).`); + const home = [env.HOME].find(value => value && isAbsolute(value)) ?? homedir(); + const ghostty = [join(env.XDG_CONFIG_HOME && isAbsolute(env.XDG_CONFIG_HOME) ? env.XDG_CONFIG_HOME : join(home, '.config'), 'ghostty', 'config'), + join(env.XDG_CONFIG_HOME && isAbsolute(env.XDG_CONFIG_HOME) ? env.XDG_CONFIG_HOME : join(home, '.config'), 'ghostty', 'config.ghostty'), + join(home, 'Library', 'Application Support', 'com.mitchellh.ghostty', 'config'), + join(home, 'Library', 'Application Support', 'com.mitchellh.ghostty', 'config.ghostty')]; + for (const path of ghostty) { + let text = ''; + try { text = readFileSync(path, 'utf8'); } catch { continue; } + const present = GHOSTTY_KEYBIND_LINES.filter(line => text.split('\n').includes(line)); + if (present.length) manual.push(`${path} contains ${present.length} keybind line(s) that /keyboard can add (for example \`${present[0]}\`). They have no NMSh marker, so they are left for you to remove if you added them through NMSh.`); + } + return {root, layout, links: findOwnedLinks(root, env, options.npmPrefix), data, manual, runtimeDir: defaultRuntimeDir(env)}; +} + +/** Live session sockets mean shells still depend on this installation. */ +export function liveServiceSockets(runtimeDir: string): string[] { + try { return readdirSync(runtimeDir).filter(name => /^nmshd(?:-v\d+)?\.sock$/u.test(name)).map(name => join(runtimeDir, name)); } catch { return []; } +} + +export function formatUninstallPlan(plan: UninstallPlan, deleteData: boolean): string { + const lines = [`NMSh installation: ${plan.root} (${plan.layout === 'git-checkout' ? 'source checkout' : plan.layout})`, '']; + lines.push(plan.links.length ? 'Will remove (symlinks into this installation):' : 'No launcher symlinks into this installation were found.'); + for (const link of plan.links) lines.push(` - ${link}`); + if (plan.data.length) { + lines.push('', deleteData ? 'Will also delete NMSh local data (--delete-data):' : 'Kept (your NMSh data; add --delete-data to remove):'); + for (const item of plan.data) lines.push(` ${deleteData ? '-' : '='} ${item.path} (${item.description})`); + } + lines.push('', 'Not touched: your shell config (.zshrc, .bashrc, config.fish), shell history, and installed tools.'); + if (plan.manual.length) { lines.push('', 'Manual cleanup, if you want it:'); for (const item of plan.manual) lines.push(` * ${item}`); } + return `${lines.join('\n')}\n`; +} + +export interface UninstallResult { removed: string[]; failed: Array<{path: string; error: string}> } + +/** + * Apply a reviewed plan. Each link is re-verified just before removal, so a + * path that changed since the preview is skipped rather than removed. + */ +export function applyUninstall(plan: UninstallPlan, options: {deleteData: boolean; env: NodeJS.ProcessEnv}): UninstallResult { + const result: UninstallResult = {removed: [], failed: []}; + const realRoot = realOrUndefined(plan.root) ?? resolve(plan.root); + for (const link of plan.links) { + try { + const target = realOrUndefined(link); + if (!lstatSync(link).isSymbolicLink() || !target || !inside(target, realRoot)) throw new Error('changed since the preview; skipped'); + unlinkSync(link); + result.removed.push(link); + } catch (error) { result.failed.push({path: link, error: error instanceof Error ? error.message : String(error)}); } + } + if (options.deleteData) { + for (const item of plan.data) { + // Only ever the NMSh config directory computed for this environment. + if (item.path !== nmshConfigDirectory(options.env) || dirname(item.path) === item.path) { result.failed.push({path: item.path, error: 'unexpected path; skipped'}); continue; } + try { rmSync(item.path, {recursive: true, force: true}); result.removed.push(item.path); } + catch (error) { result.failed.push({path: item.path, error: error instanceof Error ? error.message : String(error)}); } + } + } + return result; +} + +export const GOODBYE = 'NMSh is uninstalled. Your shell is exactly as you left it.'; diff --git a/src/cli/uninstallCommand.ts b/src/cli/uninstallCommand.ts new file mode 100644 index 00000000..a6cba7a2 --- /dev/null +++ b/src/cli/uninstallCommand.ts @@ -0,0 +1,45 @@ +import {execFileSync} from 'node:child_process'; +import {installRoot} from '../update/update.js'; +import type {CliIo} from './configCommand.js'; +import {applyUninstall, formatUninstallPlan, GOODBYE, liveServiceSockets, planUninstall} from './uninstall.js'; + +export const UNINSTALL_USAGE = `Usage: nmsh uninstall [--dry-run] [--delete-data] [--yes] + +Removes the nmsh launcher links that point into this installation after a +preview. Your settings, transcripts and stats are kept unless --delete-data. +Shell config files are never changed. +`; + +function npmGlobalPrefix(env: NodeJS.ProcessEnv): string | undefined { + try { return execFileSync('npm', ['prefix', '-g'], {env, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore']}).trim() || undefined; } + catch { return undefined; } +} + +export async function runUninstallCommand(args: string[], io: CliIo & {root?: string; npmPrefix?: string | null}): Promise { + if (args.some(arg => !['--dry-run', '--delete-data', '--yes'].includes(arg))) { io.err(UNINSTALL_USAGE); return 2; } + const env = io.env ?? process.env; + const deleteData = args.includes('--delete-data'); + const plan = planUninstall({root: io.root ?? installRoot(), env, + npmPrefix: io.npmPrefix === null ? undefined : io.npmPrefix ?? npmGlobalPrefix(env)}); + io.out(formatUninstallPlan(plan, deleteData)); + if (args.includes('--dry-run')) return 0; + const sockets = liveServiceSockets(plan.runtimeDir); + if (sockets.length) { + io.err('\nA live-session service is running. End your NMSh sessions first (see nmsh --sessions); nothing was changed.\n'); + return 1; + } + if (plan.links.length === 0 && !(deleteData && plan.data.length)) { io.err('\nNothing to remove automatically.\n'); return 0; } + let agreed = args.includes('--yes'); + if (!agreed) { + if (!io.confirm) { io.err('\nNot applied. Re-run with --yes to apply this preview non-interactively.\n'); return 2; } + agreed = await io.confirm('\nUninstall NMSh as previewed?'); + if (agreed && deleteData) agreed = await io.confirm('Also permanently delete your NMSh data listed above?'); + } + if (!agreed) { io.err('Not uninstalled; nothing changed.\n'); return 1; } + const result = applyUninstall(plan, {deleteData, env}); + for (const path of result.removed) io.out(`removed ${path}\n`); + for (const failure of result.failed) io.err(`could not remove ${failure.path}: ${failure.error}\n`); + if (result.failed.length) return 1; + io.out(`\n${GOODBYE}\n`); + return 0; +} 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..40b4ab33 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -1,71 +1,283 @@ +import {IDLE_MODES, type IdleMode} from '../idle/scenes.js'; +export type CommandGroup = 'Appearance' | 'Composer & transcript' | 'Providers' | 'Tools & integration'; + export interface SlashCommand { name: string; insertion: string; description: string; + /** /help section for substantial surfaces; ungrouped commands are listed under "More commands". */ + group?: CommandGroup; + /** Human palette label ("Open Motion"); the command name is shown beside it. */ + title?: string; + /** Another spelling of a canonical command: parsed to the same action, not listed twice. */ + alias?: string; } -export const slashCommands: readonly SlashCommand[] = [ +/** Group, palette title and alias metadata for the substantial surfaces, in one place. */ +const META: Record> = { + '/appearance': {group: 'Appearance', title: 'Open Appearance'}, '/theme': {group: 'Appearance', title: 'Open Theme Studio'}, + '/theme-bridge': {group: 'Appearance', title: 'Open Theme Bridge'}, '/chroma': {group: 'Appearance', title: 'Open Chroma'}, + '/chrome': {group: 'Appearance', title: 'Open UI Chrome'}, '/cursor': {group: 'Appearance', title: 'Open Cursor & effects'}, + '/motion': {group: 'Appearance', title: 'Open Motion'}, '/glyphs': {group: 'Appearance', title: 'Configure Glyph Style'}, + '/strip': {group: 'Appearance', title: 'Configure Status Strip'}, '/status-strip': {alias: '/strip'}, + '/screensaver': {group: 'Appearance', title: 'Open Idle visuals'}, '/activity': {group: 'Appearance', title: 'Open Live activity colors'}, + '/prompt': {group: 'Composer & transcript', title: 'Open Prompt'}, '/layout': {group: 'Composer & transcript', title: 'Configure Composer Layout'}, + '/composer': {alias: '/layout'}, '/syntax': {group: 'Composer & transcript', title: 'Open Syntax highlighting'}, + '/transcript': {group: 'Composer & transcript', title: 'Open Transcript appearance'}, '/keyboard': {group: 'Composer & transcript', title: 'Open Keyboard'}, + '/providers': {group: 'Providers', title: 'Open Providers'}, '/picker': {group: 'Providers', title: 'Configure Picker Provider'}, '/pickers': {alias: '/picker'}, + '/suggestions': {group: 'Providers', title: 'Configure Suggestions Provider'}, '/navigation': {group: 'Providers', title: 'Configure Directory Navigation'}, + '/welcome': {group: 'Providers', title: 'Configure Welcome'}, '/history-provider': {group: 'Providers', title: 'Configure History Provider'}, + '/tools': {group: 'Tools & integration', title: 'Open Tools'}, '/configure': {group: 'Tools & integration', title: 'Open Tool Configuration'}, + '/tmux': {group: 'Tools & integration', title: 'Configure tmux'}, '/integrations': {group: 'Tools & integration', title: 'Check Integrations'}, + '/dotfiles': {group: 'Tools & integration', title: 'Import Dotfiles'}, + '/btw': {title: 'Open local intelligence'}, '/ask': {alias: '/btw'}, + '/caffeinate': {group: 'Tools & integration', title: 'Open Keep Awake'}, '/awake': {alias: '/caffeinate'}, '/zoomies': {alias: '/caffeinate'}, +}; + +const RAW_COMMANDS: readonly SlashCommand[] = [ + {name: '/effects', insertion: '/effects ', description: 'Preview sparkles, rain or confetti 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: '/motion', insertion: '/motion', description: 'Motion: context transitions, command launch, completion highlight and effects (same as /appearance → Motion)'}, {name: '/prompt', insertion: '/prompt', description: 'Configure prompt provider and composer layout'}, + {name: '/cursor', insertion: '/cursor', description: 'Text caret shape and blink while NMSh owns the composer'}, + {name: '/activity', insertion: '/activity', description: 'Live activity colors for the running-command line'}, + {name: '/screensaver', insertion: '/screensaver', description: 'Idle visuals: live gallery, timeout and colors'}, + {name: '/screensaver start', insertion: '/screensaver start', description: 'Start the selected idle visual now; any key or mouse stops it'}, + {name: '/theme', insertion: '/theme', description: 'Theme Studio: built-in, imported and custom Native themes; create, edit, import, export, select'}, + {name: '/theme-bridge', insertion: '/theme-bridge', description: 'Theme Bridge: extend NMSh themes to fzf, less/man, LS_COLORS, tmux, Neovim, Vim and Helix (opt-in per tool)'}, + {name: '/chroma', insertion: '/chroma', description: 'Chroma palettes, motion and custom gradients for the Native prompt'}, {name: '/settings', insertion: '/settings', description: 'Open NMSh settings (Config view)'}, + {name: '/setup', insertion: '/setup', description: 'Setup Cat: guided, rerunnable setup; keeps your current choices'}, + {name: '/setup prompt', insertion: '/setup prompt', description: 'Setup Cat: prompt provider and style'}, + {name: '/setup appearance', insertion: '/setup appearance', description: 'Setup Cat: theme, vibrance and Chroma'}, + {name: '/setup transcript', insertion: '/setup transcript', description: 'Setup Cat: transcript presentation, history colors, dividers and folding'}, + {name: '/setup cursor', insertion: '/setup cursor', description: 'Setup Cat: cursor shape, effects and colors, with a live preview'}, + {name: '/setup syntax', insertion: '/setup syntax', description: 'Setup Cat: editor, syntax colors and suggestions'}, + {name: '/setup tools', insertion: '/setup tools', description: 'Setup Cat: optional tools, update checks and install suggestions'}, + {name: '/caffeinate', insertion: '/caffeinate', description: 'Keep Awake: keep the computer or display awake (idle, display, system, all; optional 30m/2h; status, stop). Uses the OS mechanism'}, + {name: '/awake', insertion: '/awake', description: 'Same as /caffeinate (Keep Awake)'}, + {name: '/zoomies', insertion: '/zoomies', description: 'Same as /caffeinate (Keep Awake)'}, + {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'}, {name: '/layout', insertion: '/layout', description: 'Preview and choose composer position and transcript presentation'}, - {name: '/transcript', insertion: '/transcript', description: 'Configure historical prompts and dividers'}, + {name: '/composer', insertion: '/composer', description: 'Composer position and transcript presentation (same as /layout)'}, + {name: '/chrome', insertion: '/chrome', description: 'UI chrome: NMSh frames, rules, tabs, selection and accents (not Chroma)'}, + {name: '/glyphs', insertion: '/glyphs', description: 'Glyph style: compare Nerd Font and Safe / ASCII symbols and icons'}, + {name: '/strip', insertion: '/strip', description: 'Status strip: clock, battery, CPU, RAM and uptime, with a live preview'}, + {name: '/status-strip', insertion: '/status-strip', description: 'Same as /strip'}, + {name: '/configure', insertion: '/configure ', description: 'Tool Configuration: supported settings for tmux, Starship and other registered tools'}, + {name: '/tmux', insertion: '/tmux', description: 'Configure tmux: settings, keys, Status Studio, new panes start NMSh, theme'}, + {name: '/integrations', insertion: '/integrations', description: 'Integrations health: review and update every managed integration'}, + {name: '/dotfiles', insertion: '/dotfiles ', description: 'Import supported settings from a dotfiles repository (reviewed, nothing executed)'}, + {name: '/transcript', insertion: '/transcript', description: 'Choose Normal/Chat presentation, historical prompts, dividers and folding'}, {name: '/keyboard', insertion: '/keyboard', description: 'Configure keyboard integration'}, - {name: '/zsh', insertion: '/zsh', description: 'Return to an ordinary interactive zsh'}, + {name: '/shell', insertion: '/shell', description: 'Managed backend switcher: NMSh stays open; install missing shells; D sets the default'}, + {name: '/shell zsh', insertion: '/shell zsh', description: 'Switch this NMSh session to zsh (NMSh stays open)'}, + {name: '/shell fish', insertion: '/shell fish', description: 'Switch this NMSh session to Fish (NMSh stays open)'}, + {name: '/shell bash', insertion: '/shell bash', description: 'Switch this NMSh session to Bash (NMSh stays open)'}, + {name: '/zsh', insertion: '/zsh', description: 'Leave NMSh for an ordinary zsh; this session waits, and `nmsh` there returns to it'}, + {name: '/fish', insertion: '/fish', description: 'Leave NMSh for an ordinary Fish; this session waits, and `nmsh` there returns to it'}, + {name: '/bash', insertion: '/bash', description: 'Leave NMSh for an ordinary Bash; this session waits, and `nmsh` there returns to it'}, + {name: '/exit', insertion: '/exit', description: 'Leave NMSh for your configured default shell (Settings → Default shell)'}, {name: '/version', insertion: '/version', description: 'Show this compiled NMSh build identity'}, {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: '/btw', insertion: '/btw ', description: 'Local intelligence: ask NMSh in plain English'}, + {name: '/ask', insertion: '/ask ', description: 'Compatibility alias for /btw'}, + {name: '/ai', insertion: '/ai', description: 'Agent sessions: Claude Code and other harnesses running in the background; /ai claude starts one'}, + {name: '/guide', insertion: '/guide', description: 'Interactive guide to everything NMSh can do'}, + {name: '/rename', insertion: '/rename ', description: 'Name this live session (display only); /rename alone returns to its familiar signature'}, + {name: '/watch', insertion: '/watch ', description: 'Run a command repeatedly and show what changed (/watch git status · --every 10s · /watch stop|pause|resume)'}, + {name: '/doctor', insertion: '/doctor', description: 'Health check: NMSh, shell, project, Git, tools, local model and host (local, read-only)'}, + {name: '/llm', insertion: '/llm', description: 'Local Intelligence: the optional local model for Ask and Smart Folding (status, setup, stop, remove)'}, + {name: '/providers', insertion: '/providers', description: 'What NMSh uses for prompt, welcome, suggestions, history and more; switch, install, detect'}, + {name: '/picker', insertion: '/picker', description: 'Picker provider (NMSh Native, fzf, Television) in /providers'}, + {name: '/pickers', insertion: '/pickers', description: 'Same as /picker'}, + {name: '/suggestions', insertion: '/suggestions', description: 'Ghost-text suggestions provider in /providers'}, + {name: '/navigation', insertion: '/navigation', description: 'Directory navigation provider (NMSh Native, zoxide) in /providers'}, + {name: '/welcome', insertion: '/welcome', description: 'Welcome provider (Vespyr, fastfetch, …) in /providers'}, + {name: '/presets', insertion: '/presets', description: 'Create, inspect and launch named session presets'}, + {name: '/sessions', insertion: '/sessions', description: 'Live NMSh sessions right now: switch to a detached one, kill one (nmsh --sessions outside)'}, {name: '/resume', insertion: '/resume', description: 'Browse archived NMSh transcripts'}, + {name: '/find', insertion: '/find ', description: 'Add a find term (Ctrl+F); terms AND together. -r regex, -c case; /find remove N, /find clear'}, + {name: '/filter', insertion: '/filter ', description: 'Add a filter term to the newest/focused output (terms AND together; -v, -C N, -r, -c); /filter remove N, /filter clear'}, + {name: '/open', insertion: '/open', description: 'Open a path[:line[:column]] in your editor; alone, pick a reference from recent output'}, + {name: '/open-diff', insertion: '/open-diff ', description: 'Show two files in your editor\'s diff view (Zed, VS Code); nothing is rebuilt here'}, + {name: '/about', insertion: '/about', description: 'About NMSh: build identity and logo (inline image where the terminal supports it)'}, + {name: '/agents', insertion: '/agents', description: 'Local agent CLI activity: durations, runs and a heatmap (on/off/reset)'}, + {name: '/notices', insertion: '/notices', description: 'Cross-session notices above the composer (on/off/clear)'}, {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'}, + {name: '/history-provider', insertion: '/history-provider', description: 'History provider (NMSh Native, Atuin) in /providers; /history is history search'}, ]; +export const slashCommands: readonly SlashCommand[] = RAW_COMMANDS.map(command => ({...command, ...META[command.name]})); + + export type ParsedSlashCommand = + | {kind: 'effects'; effect: 'sparkles' | 'rain' | 'confetti' | 'stop' | 'help'; placement: 'top' | 'bottom'} | {kind: 'copy'; index: number} | {kind: 'appearance'} + | {kind: 'motion'} | {kind: 'prompt'} + | {kind: 'chroma'} + | {kind: 'theme'} + | {kind: 'themeBridge'} + | {kind: 'cursor'} + | {kind: 'activity'} + | {kind: 'screensaver'; start: boolean; mode?: IdleMode} + | {kind: 'tools'} + /** /caffeinate, /awake and /zoomies: one Keep Awake action. A timeout is a validated number of seconds; `invalid` names bad input. */ + | {kind: 'keepAwake'; op: 'panel' | 'status' | 'stop' | 'start'; mode?: 'idle' | 'display' | 'system' | 'all'; timeoutSeconds?: number; invalid?: string} + | {kind: 'setup'; entry?: string} | {kind: 'settings'; view: 'config' | 'status'} | {kind: 'transcript'} | {kind: 'syntax'} | {kind: 'layout'} + | {kind: 'chrome'} + | {kind: 'glyphs'} + | {kind: 'statusStrip'} + | {kind: 'configure'; tool?: string} + | {kind: 'integrations'} + | {kind: 'dotfiles'; source?: string} | {kind: 'keyboard'} - | {kind: 'zsh'} + /** Leave NMSh for an ordinary shell; no shell means the configured default (/exit). */ + | {kind: 'handoff'; shell?: 'zsh' | 'fish' | 'bash'} | {kind: 'version'} | {kind: 'update'; apply: boolean} | {kind: 'clear'} + | {kind: 'presets'} | {kind: 'resume'} + | {kind: 'sessions'} | {kind: 'help'} + | {kind: 'about'} + | {kind: 'find'; arguments: string} + | {kind: 'open'; target: string} + | {kind: 'openDiff'; left: string; right: string} + | {kind: 'filter'; arguments: string} + | {kind: 'shell'; shell?: 'zsh' | 'fish' | 'bash'} + | {kind: 'agents'; action: 'show' | 'on' | 'off' | 'reset'} + | {kind: 'notices'; action: 'show' | 'on' | 'off' | 'clear'} | {kind: 'palette'} + | {kind: 'ask'; request: string} + /** Agent sessions: /ai opens the list; /ai starts one in the background. */ + | {kind: 'ai'; target?: string} + | {kind: 'providers'; family?: 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation'} + | {kind: 'llm'} + | {kind: 'doctor'} + | {kind: 'watch'; op: 'list' | 'stop' | 'pause' | 'resume' | 'now' | 'start'; arguments: string} + | {kind: 'rename'; name: string} + | {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|confetti|stop))?(?:\s+(top|bottom))?\s*$/u.exec(input); + if (effect) return {kind: 'effects', effect: (effect[1] ?? 'help') as 'sparkles' | 'rain' | 'confetti' | '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 (/^\/motion\s*$/u.test(input)) return {kind: 'motion'}; if (/^\/prompt\s*$/u.test(input)) return {kind: 'prompt'}; + if (/^\/chroma\s*$/u.test(input)) return {kind: 'chroma'}; + if (/^\/theme\s*$/u.test(input)) return {kind: 'theme'}; + if (/^\/theme-bridge\s*$/u.test(input)) return {kind: 'themeBridge'}; + if (/^\/cursor\s*$/u.test(input)) return {kind: 'cursor'}; + if (/^\/activity\s*$/u.test(input)) return {kind: 'activity'}; + const screensaver = /^\/screensaver(?:\s+(start)(?:\s+(\w+))?)?\s*$/u.exec(input); + if (screensaver && (!screensaver[2] || (IDLE_MODES as readonly string[]).includes(screensaver[2]))) { + return {kind: 'screensaver', start: screensaver[1] === 'start', ...(screensaver[2] ? {mode: screensaver[2] as IdleMode} : {})}; + } + if (/^\/tools\s*$/u.test(input)) return {kind: 'tools'}; + // Unlisted compatibility spelling; /caffeinate stop is the documented form. + if (/^\/caffeinate-stop\s*$/u.test(input)) return {kind: 'keepAwake', op: 'stop'}; + const awake = /^\/(?:caffeinate|awake|zoomies)(?:\s+(\S+))?(?:\s+(\S+))?\s*$/u.exec(input); + if (awake) { + const [, word, duration] = awake; + if (!word) return {kind: 'keepAwake', op: 'panel'}; + if ((word === 'status' || word === 'stop') && !duration) return {kind: 'keepAwake', op: word}; + if (word === 'idle' || word === 'display' || word === 'system' || word === 'all') { + if (!duration) return {kind: 'keepAwake', op: 'start', mode: word}; + const match = /^([1-9]\d{0,5})([smh])$/u.exec(duration); + const seconds = match ? Number(match[1]) * (match[2] === 'h' ? 3600 : match[2] === 'm' ? 60 : 1) : 0; + return seconds && seconds <= 7 * 24 * 3600 ? {kind: 'keepAwake', op: 'start', mode: word, timeoutSeconds: seconds} : {kind: 'keepAwake', op: 'panel', invalid: duration}; + } + return {kind: 'keepAwake', op: 'panel', invalid: word}; + } + const setup = /^\/setup(?:\s+(prompt|appearance|chroma|tools|editor|transcript|cursor|syntax|motion|sessions|shell|ask))?\s*$/u.exec(input); + if (setup) return setup[1] ? {kind: 'setup', entry: setup[1]} : {kind: 'setup'}; 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'}; if (/^\/syntax\s*$/u.test(input)) return {kind: 'syntax'}; - if (/^\/layout\s*$/u.test(input)) return {kind: 'layout'}; + // Aliases normalize to one action kind: /composer is /layout, /glyph(s) one panel, /strip and /status-strip one panel. + if (/^\/(?:layout|composer)\s*$/u.test(input)) return {kind: 'layout'}; + if (/^\/chrome\s*$/u.test(input)) return {kind: 'chrome'}; + if (/^\/glyphs?\s*$/u.test(input)) return {kind: 'glyphs'}; + if (/^\/(?:strip|status-strip)\s*$/u.test(input)) return {kind: 'statusStrip'}; + if (/^\/tmux\s*$/u.test(input)) return {kind: 'configure', tool: 'tmux'}; + const configure = /^\/configure(?:\s+([A-Za-z0-9_.+-]{1,40}))?\s*$/u.exec(input); + if (configure) return configure[1] ? {kind: 'configure', tool: configure[1].toLowerCase()} : {kind: 'configure'}; + if (/^\/integrations\s*$/u.test(input)) return {kind: 'integrations'}; + const dotfiles = /^\/dotfiles(?:\s+(.{1,1024}))?\s*$/u.exec(input); + if (dotfiles) return dotfiles[1]?.trim() ? {kind: 'dotfiles', source: dotfiles[1].trim()} : {kind: 'dotfiles'}; if (/^\/keyboard\s*$/u.test(input)) return {kind: 'keyboard'}; - if (/^\/zsh\s*$/u.test(input)) return {kind: 'zsh'}; + const handoff = /^\/(zsh|fish|bash|exit)\s*$/u.exec(input); + if (handoff) return handoff[1] === 'exit' ? {kind: 'handoff'} : {kind: 'handoff', shell: handoff[1] as 'zsh' | 'fish' | 'bash'}; if (/^\/version\s*$/u.test(input)) return {kind: 'version'}; 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 (/^\/sessions\s*$/u.test(input)) return {kind: 'sessions'}; if (/^\/help\s*$/u.test(input)) return {kind: 'help'}; + if (/^\/about\s*$/u.test(input)) return {kind: 'about'}; + const openDiff = /^\/open-diff(?:\s+("[^"]+"|'[^']+'|\S+))?(?:\s+("[^"]+"|'[^']+'|\S+))?\s*$/u.exec(input); + if (openDiff) return {kind: 'openDiff', left: (openDiff[1] ?? '').replace(/^["']|["']$/gu, ''), right: (openDiff[2] ?? '').replace(/^["']|["']$/gu, '')}; + const open = /^\/open(?:\s+([\s\S]*))?$/u.exec(input); + if (open) return {kind: 'open', target: (open[1] ?? '').trim()}; + const find = /^\/find(?:\s+([\s\S]*))?$/u.exec(input); + if (find) return {kind: 'find', arguments: find[1] ?? ''}; + const filter = /^\/filter(?:\s+([\s\S]*))?$/u.exec(input); + if (filter) return {kind: 'filter', arguments: filter[1] ?? ''}; + const shell = /^\/shell(?:\s+(zsh|fish|bash))?\s*$/u.exec(input); + if (shell) return shell[1] ? {kind: 'shell', shell: shell[1] as 'zsh' | 'fish' | 'bash'} : {kind: 'shell'}; + const agents = /^\/agents(?:\s+(on|off|reset))?\s*$/u.exec(input); + if (agents) return {kind: 'agents', action: (agents[1] ?? 'show') as 'show' | 'on' | 'off' | 'reset'}; + const notices = /^\/notices(?:\s+(on|off|clear))?\s*$/u.exec(input); + if (notices) return {kind: 'notices', action: (notices[1] ?? 'show') as 'show' | 'on' | 'off' | 'clear'}; if (/^\/palette\s*$/u.test(input)) return {kind: 'palette'}; + if (/^\/guide\s*$/u.test(input)) return {kind: 'ask', request: 'guide'}; + const ai = /^\/ai(?:\s+([\w.-]{1,40}))?\s*$/u.exec(input); + if (ai) return ai[1] ? {kind: 'ai', target: ai[1]} : {kind: 'ai'}; + const ask = /^\/(?:btw|ask)(?:\s+([\s\S]*))?$/u.exec(input); + if (ask) return {kind: 'ask', request: (ask[1] ?? '').trim()}; + if (/^\/providers\s*$/u.test(input)) return {kind: 'providers'}; + // Family shortcuts are aliases of one action: /providers focused on that family. + const family = /^\/providers\s+(prompt|welcome|suggestions|history|picker|pickers|navigation)\s*$/u.exec(input)?.[1] + ?? {'/picker': 'picker', '/pickers': 'picker', '/suggestions': 'suggestions', '/navigation': 'navigation', '/welcome': 'welcome', '/history-provider': 'history'}[input.trim()]; + if (family) return {kind: 'providers', family: (family === 'pickers' ? 'picker' : family) as 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation'}; + if (/^\/(?:llm|localllm)\s*$/u.test(input)) return {kind: 'llm'}; + if (/^\/doctor\s*$/u.test(input)) return {kind: 'doctor'}; + const watch = /^\/watch(?:\s+(.*))?$/u.exec(input); + if (watch) { + const rest = (watch[1] ?? '').trim(); + const op = /^(stop|pause|resume|now)(?:\s+(.*))?$/u.exec(rest); + return op ? {kind: 'watch', op: op[1] as 'stop' | 'pause' | 'resume' | 'now', arguments: (op[2] ?? '').trim()} : {kind: 'watch', op: rest ? 'start' : 'list', arguments: rest}; + } + const rename = /^\/rename(?:\s+(.*))?$/u.exec(input); + if (rename) return {kind: 'rename', name: (rename[1] ?? '').trim()}; + 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}; @@ -73,7 +285,7 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined export function slashSuggestions(input: string): SlashCommand[] { if (!input.startsWith('/') || input.includes('\n')) return []; - return slashCommands.filter(command => command.name.startsWith(input) || command.insertion.startsWith(input)); + return slashCommands.filter(command => !(command.name === '/ask' && input === '/') && (command.name.startsWith(input) || command.insertion.startsWith(input))); } export function suggestionWindow(values: readonly T[], selected: number, height: number): {items: T[]; start: number} { 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/configuration/portability.ts b/src/configuration/portability.ts new file mode 100644 index 00000000..f7063dca --- /dev/null +++ b/src/configuration/portability.ts @@ -0,0 +1,217 @@ +import {readFileSync} from 'node:fs'; +import {DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration, type PromptConfiguration} from '../prompt/configuration.js'; + +/** + * Versioned, human-readable NMSh settings transfer. + * + * One source of truth: categories are views over the existing + * PromptConfiguration keys, and imports go through the same normalizer and + * atomic save as every Settings change. Exports never contain onboarding + * state, machine-specific absolute paths, history, transcripts, credentials + * or anything outside config.json. + */ + +export const PORTABLE_FORMAT = 'nmsh-settings'; +export const PORTABLE_VERSION = 1; + +/** Category id → dotted configuration paths it owns. Order is the export order. */ +export const PORTABLE_CATEGORIES = { + prompt: ['provider', 'promptSymbol', 'promptSymbolCustom', 'modules', 'separator', 'gap', 'spacing', 'placement', + 'nmsh.gapEnabled', 'nmsh.startStyle', 'nmsh.connector', 'nmsh.endStyle', 'nmsh.icons', 'nmsh.style', 'nmsh.connectorFade', + 'nmsh.connectorFadeColors', 'nmsh.gitEnabled', 'nmsh.gitColors', 'nmsh.gitGeometry', 'nmsh.gitConnectorFade', 'nmsh.mirrorRight', 'nmsh.styleProfiles'], + theme: ['nmsh.palette', 'nmsh.vibrance', 'nmsh.accent', 'nmsh.themeId', 'themes', 'customTheme'], + themeBridge: ['themeBridge'], + chroma: ['presentation'], + chrome: ['uiChrome', 'glyphStyle', 'cursor'], + syntax: ['syntax'], + transcript: ['transcript', 'outputFolding'], + layout: ['composerLayout', 'composerPosition', 'transcriptPresentation'], + suggestions: ['suggestions', 'suggestionsOnEmpty'], + providers: ['history', 'picker', 'navigation', 'welcome'], + statusStrip: ['statusStrip'], + idle: ['idleVisuals', 'liveActivity'], + notifications: ['notifications', 'sessionNotices'], + tools: ['toolUpdateChecks', 'installSuggestions', 'ignoredInstallSuggestions', 'updateMode', 'updateFrequency'], + sessions: ['liveSessionStartup', 'liveSessionMultiple', 'sessionRetention'], + agents: ['agentActivity'], + shell: ['shellBackend'], + editor: ['openWith'], +} as const satisfies Record; + +export type PortableCategory = keyof typeof PORTABLE_CATEGORIES; +export const CATEGORY_IDS = Object.keys(PORTABLE_CATEGORIES) as PortableCategory[]; + +/** + * Never exported: per-machine onboarding progress and absolute paths to + * provider config files (Starship, Powerlevel10k), which rarely exist at the + * same place elsewhere and can reveal a home directory layout. + */ +export const NEVER_EXPORTED = ['onboardingComplete', 'toolsSetupComplete', 'glyphChoiceComplete', 'starship', 'powerlevel10k', 'ohMyPosh'] as const; + +export interface PortableDocument { + format: typeof PORTABLE_FORMAT; + version: number; + /** Informational only. */ + exportedBy?: string; + exportedAt?: string; + categories: Partial>>; +} + +function getPath(value: unknown, path: string): unknown { + let current = value; + for (const key of path.split('.')) { + if (!current || typeof current !== 'object') return undefined; + current = (current as Record)[key]; + } + return current; +} + +function setPath(target: Record, path: string, value: unknown): void { + const keys = path.split('.'); + let current = target; + for (const key of keys.slice(0, -1)) { + if (!current[key] || typeof current[key] !== 'object') current[key] = {}; + current = current[key] as Record; + } + current[keys.at(-1)!] = structuredClone(value); +} + +function portableThemes(value: unknown): unknown { + if (!Array.isArray(value)) return value; + return value.map(asset => { + const copy = structuredClone(asset) as {origin?: {sourcePath?: string}}; + if (copy.origin) delete copy.origin.sourcePath; + return copy; + }); +} + +export function parseCategories(text: string | undefined): PortableCategory[] { + if (!text || text === 'all') return [...CATEGORY_IDS]; + const requested = text.split(',').map(item => item.trim()).filter(Boolean); + const unknown = requested.filter(item => !(CATEGORY_IDS as string[]).includes(item)); + if (unknown.length) throw new Error(`Unknown setting categor${unknown.length === 1 ? 'y' : 'ies'}: ${unknown.join(', ')}. Known: ${CATEGORY_IDS.join(', ')}`); + return requested as PortableCategory[]; +} + +export function exportSettings(configuration: PromptConfiguration, categories: readonly PortableCategory[] = CATEGORY_IDS, + meta: {version?: string; now?: Date} = {}): PortableDocument { + const normalized = normalizePromptConfiguration(configuration); + const document: PortableDocument = {format: PORTABLE_FORMAT, version: PORTABLE_VERSION, + ...(meta.version ? {exportedBy: `nmsh ${meta.version}`} : {}), ...(meta.now ? {exportedAt: meta.now.toISOString()} : {}), categories: {}}; + for (const category of categories) { + const values: Record = {}; + for (const path of PORTABLE_CATEGORIES[category]) { + const value = getPath(normalized, path); + // Imported themes keep their source path on this machine only; a transfer never carries it. + if (value !== undefined) values[path] = path === 'themes' ? portableThemes(value) : structuredClone(value); + } + document.categories[category] = values; + } + return document; +} + +export interface ImportChange { + category: PortableCategory; + path: string; + before: unknown; + after: unknown; +} + +export interface ImportPlan { + /** The configuration that would be saved. */ + next: PromptConfiguration; + changes: ImportChange[]; + /** Values present in the file but rejected by validation (left as they were). */ + rejected: Array<{path: string; reason: string}>; + /** Fields or categories this NMSh does not know; ignored, never written. */ + ignored: string[]; + /** Selected categories absent from the file. */ + missing: PortableCategory[]; +} + +export class PortableFormatError extends Error {} + +/** Strict document validation: wrong format or a newer major version is refused, not guessed at. */ +export function parsePortableDocument(text: string): PortableDocument { + let raw: unknown; + try { raw = JSON.parse(text); } catch { throw new PortableFormatError('The file is not valid JSON.'); } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new PortableFormatError('The file is not an NMSh settings export.'); + const record = raw as Record; + if (record.format !== PORTABLE_FORMAT) throw new PortableFormatError(`Expected format "${PORTABLE_FORMAT}"; this file is not an NMSh settings export.`); + if (!Number.isInteger(record.version) || (record.version as number) < 1) throw new PortableFormatError('The export has no valid version.'); + if ((record.version as number) > PORTABLE_VERSION) { + throw new PortableFormatError(`This export uses settings format v${String(record.version)}; this NMSh reads up to v${PORTABLE_VERSION}. Update NMSh to import it.`); + } + if (!record.categories || typeof record.categories !== 'object' || Array.isArray(record.categories)) throw new PortableFormatError('The export has no categories.'); + return record as unknown as PortableDocument; +} + +/** + * Plan an import without touching disk: apply the selected categories over the + * current configuration, run the normal normalizer, and report what changes, + * what validation rejected and what was ignored. + */ +export function planImport(current: PromptConfiguration, document: PortableDocument, categories: readonly PortableCategory[] = CATEGORY_IDS): ImportPlan { + const base = normalizePromptConfiguration(current); + const draft = structuredClone(base) as unknown as Record; + const ignored: string[] = []; + const missing: PortableCategory[] = []; + for (const name of Object.keys(document.categories)) if (!(CATEGORY_IDS as string[]).includes(name)) ignored.push(`category ${name}`); + const rejected: ImportPlan['rejected'] = []; + const accepted: Array<{category: PortableCategory; path: string; value: unknown}> = []; + for (const category of categories) { + const values = document.categories[category]; + if (!values || typeof values !== 'object') { missing.push(category); continue; } + const allowed = new Set(PORTABLE_CATEGORIES[category]); + for (const [path, value] of Object.entries(values)) { + if (!allowed.has(path)) { ignored.push(`${category}.${path}`); continue; } + // A scalar the normalizer would not keep as given is invalid here: reject it + // instead of letting it silently become a default. + if (value === null || typeof value !== 'object') { + const probe = structuredClone(base) as unknown as Record; + setPath(probe, path, value); + if (JSON.stringify(getPath(normalizePromptConfiguration(probe), path)) !== JSON.stringify(value)) { + rejected.push({path, reason: 'not a valid value for this NMSh; kept the current one'}); + continue; + } + } + setPath(draft, path, value); + accepted.push({category, path, value}); + } + } + const next = normalizePromptConfiguration(draft); + // Onboarding state and machine-local paths always stay as they are here. + for (const key of NEVER_EXPORTED) (next as unknown as Record)[key] = structuredClone((base as unknown as Record)[key]); + const changes: ImportChange[] = []; + for (const {category, path} of accepted) { + const before = getPath(base, path); + const after = getPath(next, path); + if (JSON.stringify(before) !== JSON.stringify(after)) changes.push({category, path, before, after}); + } + return {next, changes, rejected, ignored, missing}; +} + +function brief(value: unknown): string { + const text = JSON.stringify(value) ?? 'unset'; + return text.length > 60 ? `${text.slice(0, 57)}...` : text; +} + +/** Plain-text preview for terminals and logs. */ +export function formatImportPlan(plan: ImportPlan): string { + const lines: string[] = []; + if (plan.changes.length === 0) lines.push('No settings would change.'); + else { + lines.push(`${plan.changes.length} setting${plan.changes.length === 1 ? '' : 's'} would change:`); + for (const change of plan.changes) lines.push(` ${change.category.padEnd(13)} ${change.path}: ${brief(change.before)} -> ${brief(change.after)}`); + } + for (const item of plan.rejected) lines.push(` rejected ${item.path}: ${item.reason}`); + if (plan.ignored.length) lines.push(`Ignored (unknown to this NMSh): ${plan.ignored.join(', ')}`); + if (plan.missing.length) lines.push(`Not in this file: ${plan.missing.join(', ')}`); + return `${lines.join('\n')}\n`; +} + +export function readPortableFile(path: string): PortableDocument { + return parsePortableDocument(readFileSync(path, 'utf8')); +} + +export {DEFAULT_PROMPT_CONFIGURATION}; diff --git a/src/context/facts.ts b/src/context/facts.ts new file mode 100644 index 00000000..b1ded580 --- /dev/null +++ b/src/context/facts.ts @@ -0,0 +1,84 @@ +import type {PromptContext, GitStatus, ToolchainId} from '../shell/ShellContext.js'; +import {truncateText} from '../util/text.js'; + +export type FactTrust = 'session' | 'workspace' | 'user-metadata' | 'local-inventory'; +export type FactSensitivity = 'public' | 'private' | 'secret'; +export type FactPersistence = 'snapshot-safe' | 'display-only' | 'never-store'; +export interface ContextFact { + readonly value: T; + readonly source: {capability: string; evidence: string}; + readonly collectedAt: number; + readonly freshness: 'fresh' | 'stale' | 'unknown'; + readonly trust: FactTrust; + readonly sensitivity: FactSensitivity; + readonly persistence: FactPersistence; + readonly resolution: 'cheap' | 'bounded-async'; +} + +export interface FactValues { + cwd: string; + project: string; + root: string; + pathAbbreviations: Record; + branch: string; + git: GitStatus; + exitStatus: number; + toolchains: ToolchainId[]; + discovery: NonNullable; + kubeContext: string; + dockerContext: string; + shell: NonNullable; +} +export type FactId = keyof FactValues; +export type ContextFacts = {readonly [K in FactId]?: ContextFact}; + +/** Named core operations, not commands supplied by modules or packs. */ +export const FACT_CAPABILITIES = { + cwd: 'session.cwd', project: 'workspace.identity', root: 'workspace.git.root', + pathAbbreviations: 'workspace.path.abbreviations', branch: 'workspace.git.branch', git: 'workspace.git.status', + exitStatus: 'session.exit-status', toolchains: 'workspace.toolchain.markers', discovery: 'inventory.local', + kubeContext: 'context.kubernetes.current', dockerContext: 'context.docker.current', shell: 'session.shell', +} as const satisfies Record; +export type ContextCapabilityId = typeof FACT_CAPABILITIES[FactId]; + +/** Literal contextual data only. Bound work before cell measurement; neutralize bidi overrides/isolates too. */ +export function safeContextText(value: string, maxCells = 512): string { + return truncateText(value.slice(0, 1024).replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/gu, '�'), maxCells); +} + +/** Adapt an already resolved legacy snapshot; this never collects or refreshes facts. */ +export function promptFacts(context: PromptContext, collectedAt = 0): ContextFacts { + const facts: ContextFacts = {}; + for (const id of Object.keys(FACT_CAPABILITIES) as FactId[]) { + const value = id === 'exitStatus' ? context.exitStatus ?? 0 : context[id]; + if (value === undefined) continue; + const session = id === 'cwd' || id === 'exitStatus' || id === 'shell'; + const metadata = id === 'kubeContext' || id === 'dockerContext'; + const at = id === 'discovery' ? context.discovery!.discoveredAt : collectedAt; + Object.assign(facts, {[id]: {value, source: {capability: FACT_CAPABILITIES[id], evidence: 'legacy resolved PromptContext'}, + collectedAt: at, freshness: at ? 'fresh' : 'unknown', trust: session ? 'session' : metadata ? 'user-metadata' : id === 'discovery' ? 'local-inventory' : 'workspace', + sensitivity: 'public', persistence: 'snapshot-safe', resolution: session ? 'cheap' : 'bounded-async'}}); + } + return {...facts, ...context.facts}; +} + +export function factAllowed(fact: ContextFact, purpose: 'display' | 'snapshot'): boolean { + return fact.sensitivity !== 'secret' && fact.persistence !== 'never-store' + && (purpose !== 'snapshot' || fact.persistence === 'snapshot-safe'); +} + +/** A module receives only declared facts, plus the existing visibility inputs. No I/O handles. */ +export function moduleFactContext(context: PromptContext, facts: ContextFacts, fields: readonly FactId[], purpose: 'display' | 'snapshot'): PromptContext { + const result: PromptContext = {cwd: '', project: '', commandWords: context.commandWords, home: context.home}; + for (const id of new Set([...fields, 'branch', 'exitStatus', 'shell'])) { + const fact = facts[id]; + if (fact && factAllowed(fact, purpose)) Object.assign(result, {[id]: fact.value}); + } + return result; +} + +/** Refresh a value without weakening a previously established privacy policy. */ +export function updateFact(next: ContextFact | undefined, previous: ContextFact | undefined): ContextFact | undefined { + if (!next || !previous) return next ?? previous; + return {...next, sensitivity: previous.sensitivity, persistence: previous.persistence}; +} diff --git a/src/context/surfaceRouter.ts b/src/context/surfaceRouter.ts new file mode 100644 index 00000000..141c5bd8 --- /dev/null +++ b/src/context/surfaceRouter.ts @@ -0,0 +1,10 @@ +import {CONTEXT_MODULE_REGISTRY, modulePlacement, type ContextModuleConfig, type ContextSurface} from '../prompt/configuration.js'; + +/** Placement is policy over descriptions/config, never over filesystem or process state. */ +export function routeModule(module: ContextModuleConfig): ContextSurface | 'hidden' { + if (!module.visible || module.surface === 'hidden') return 'hidden'; + const definition = CONTEXT_MODULE_REGISTRY[module.id]; + const requested = module.surface === 'auto' ? definition.preferredSurface + : module.surface ?? (modulePlacement(module) === 'right' ? 'rightContext' : 'mainPrompt'); + return definition.supportedSurfaces.includes(requested) ? requested : 'hidden'; +} diff --git a/src/context/trustedServices.ts b/src/context/trustedServices.ts new file mode 100644 index 00000000..adce3807 --- /dev/null +++ b/src/context/trustedServices.ts @@ -0,0 +1,72 @@ +import {execFile} from 'node:child_process'; +import {constants} from 'node:fs'; +import {open, realpath} from 'node:fs/promises'; +import {homedir, devNull} from 'node:os'; +import {promisify} from 'node:util'; + +const exec = promisify(execFile); +let gitExecutable: Promise | undefined; +function trustedGit(): Promise { + return gitExecutable ??= (async () => { + if (process.platform === 'darwin') { + // Apple's /usr/bin/git is an xcrun shim. Prefer a known local installation, + // validating its resolved prefix; never use workspace/PATH candidates. + for (const prefix of ['/opt/homebrew', '/usr/local']) try { + const path = await realpath(`${prefix}/bin/git`); + if (path.startsWith(`${prefix}/Cellar/git/`)) return path; + } catch { /* OS Git is the fallback. */ } + } + return '/usr/bin/git'; + })(); +} +const MAX_METADATA_BYTES = 256 * 1024; + +/** Core-only bounded local data access. Symlinks/special files are not contextual metadata. */ +export async function readContextMetadata(path: string): Promise { + let file; + try { + file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const stat = await file.stat(); + if (!stat.isFile() || stat.size > MAX_METADATA_BYTES) return undefined; + const buffer = Buffer.alloc(MAX_METADATA_BYTES + 1); + let used = 0; + while (used < buffer.length) { + const {bytesRead} = await file.read(buffer, used, buffer.length - used, null); + if (!bytesRead) break; + used += bytesRead; + } + return used <= MAX_METADATA_BYTES ? buffer.subarray(0, used).toString('utf8') : undefined; + } catch { return undefined; } + finally { await file?.close(); } +} + +/** Trusted OS Git only; PATH inventory is never an execution authority. */ +export async function runContextGit(cwd: string, args: string[]): Promise { + const allowed = [ + ['rev-parse', '--absolute-git-dir'], ['rev-parse', '--show-toplevel'], ['rev-parse', '--short', 'HEAD'], + ['symbolic-ref', '--quiet', '--short', 'HEAD'], ['status', '--porcelain=v1', '--branch', '--untracked-files=normal'], + ]; + if (!allowed.some(operation => operation.length === args.length && operation.every((arg, i) => arg === args[i]))) { + throw new Error('Unsupported contextual Git operation'); + } + const executable = await trustedGit(); + const options = {encoding: 'utf8' as const, timeout: 2000, maxBuffer: 1024 * 1024, + env: {PATH: '/usr/bin:/bin', HOME: homedir(), LANG: 'C', + ...(process.env.TMPDIR ? {TMPDIR: process.env.TMPDIR} : {}), GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: devNull, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', GIT_NO_LAZY_FETCH: '1'}}; + const base = ['-c', 'core.fsmonitor=false', '-c', `core.hooksPath=${devNull}`, '-C', cwd]; + if (args[0] === 'status') { + // Status can invoke filters or lazy-fetch missing objects. Fail closed on filters, includes, partial clones or + // extra worktree config; branch/root still work. Submodules are not inspected. No project config values are logged. + try { + const {stdout} = await exec(executable, [...base, 'config', '--local', '--no-includes', '--name-only', + '--get-regexp', '^(filter\\.|include\\.|includeif\\.|extensions\\.(worktreeconfig|partialclone)$|remote\\..*\\.promisor$)'], options); + if (stdout.trim()) throw new Error('Repository configuration requires a future safe status capability'); + } catch (error) { + // Git exits 1 when no matching config exists; every other failure is unknown. + if ((error as {code?: number}).code !== 1) throw error; + } + } + const {stdout} = await exec(executable, [...base, ...args, ...(args[0] === 'status' ? ['--ignore-submodules=all'] : [])], options); + return stdout.trim(); +} diff --git a/src/cursor/CursorEngine.ts b/src/cursor/CursorEngine.ts new file mode 100644 index 00000000..b63bd027 --- /dev/null +++ b/src/cursor/CursorEngine.ts @@ -0,0 +1,372 @@ +import type {CursorSettings} from '../prompt/configuration.js'; +import type {RgbColor} from '../ui/palette.js'; +import {mixRgb} from '../chroma/chroma.js'; +import type {CellPaint} from '../presentation/cellOverlay.js'; + +/** + * The portable cursor-effects engine: terminal cells only (ANSI color, + * partial blocks, small glyphs), no GPU, no framebuffer. It never moves the + * logical caret: `target()` records where the caret IS; the visual caret and + * its trail/particles catch up over a few frames. Input never waits for it. + * + * Phases are deterministic: + * MOVEMENT the visual caret is travelling to the target + * SETTLING it arrived; the trail collapses and particles expire + * IDLE nothing moves; an opt-in idle effect may run at a low cadence + * A new target during MOVEMENT retargets from the current visual position; + * movements are never queued. + */ +export type CursorPhase = 'idle' | 'movement' | 'settling'; + +export interface CursorPoint {row: number; column: number} +/** What a particle is, so Fire, Sparks, Embers, Lightning and Railgun each keep their own look and motion. */ +export type ParticleStyle = 'fire' | 'sparks' | 'embers' | 'bolt' | 'beam'; +export interface Particle {x: number; y: number; vx: number; vy: number; age: number; life: number; heat: number; style: ParticleStyle; + /** A fixed glyph (bolt segments, beam cells); the others are chosen from their energy. */ + glyph?: string} + +/** Why the caret moved: adjacent typing barely animates; jumps travel the full distance. */ +export type MoveCause = 'typing' | 'jump'; + +export interface EffectPalette { + caret: RgbColor; + trail: RgbColor[]; + particles: RgbColor[]; +} + +/** Where the engine may draw (the input rows), so effects never paint over the transcript or other chrome. */ +export interface DrawBounds {top: number; bottom: number; columns: number} + +const SPEED = {low: 1.45, medium: 1, high: 0.65} as const; +const INTENSITY = {low: 0.55, medium: 0.8, high: 1} as const; +const TRAIL = {low: 0.5, medium: 1, high: 1.6} as const; +const AMOUNT = {low: 0.45, medium: 1, high: 1.9} as const; +export const MAX_PARTICLES = 48; +/** Bolt and beam cells are placed whole when a jump starts, so they have their own cap. */ +const MAX_FIXED = 110; +/** Idle effects never need more than this (frames per second). */ +export const IDLE_FPS = 15; + +const easeOut = (t: number, easing: CursorSettings['advanced']['easing']) => { + const x = Math.min(1, Math.max(0, t)); + if (easing === 'linear') return x; + if (easing === 'out-expo') return x === 1 ? 1 : 1 - 2 ** (-10 * x); + if (easing === 'spring') return 1 - Math.cos(x * Math.PI * 2.2) * Math.exp(-6 * x); + return 1 - (1 - x) ** 3; +}; + +/** A seeded generator (mulberry32), so tests replay exactly; production seeds from time. */ +export function seededRandom(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6D2B79F5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export class CursorEngine { + phase: CursorPhase = 'idle'; + /** The logical caret (always exact). */ + private goal?: CursorPoint; + /** The visual head, in fractional cells. */ + private head?: {row: number; column: number}; + /** The trailing end of a smear/tail, lagging the head. */ + private tail?: {row: number; column: number}; + private from?: {row: number; column: number}; + private moveStart = 0; + private moveDuration = 0; + private lastFrame = 0; + private restSince = 0; + readonly particles: Particle[] = []; + /** Ripple rings (center + start), bounded. */ + private rings: Array<{row: number; column: number; start: number}> = []; + /** Wireframe: the box around the last travelled span, held briefly after arrival. */ + private wire?: {row: number; left: number; right: number; until: number; start: number}; + /** A bolt strikes twice, a moment apart, so it flickers. */ + private restrike?: {from: CursorPoint; to: CursorPoint; at: number}; + + constructor(private settings: CursorSettings, private readonly random: () => number = seededRandom(Date.now())) {} + + configure(settings: CursorSettings): void { + this.settings = settings; + if (settings.motion === 'off' && settings.effect === 'none') { this.particles.length = 0; this.rings = []; this.wire = undefined; this.restrike = undefined; this.tail = undefined; if (this.goal) this.head = {...this.goal}; this.phase = 'idle'; } + } + + /** Effects that need frames at all (Off + None + no idle effect schedules nothing). */ + get enabled(): boolean { return this.settings.motion !== 'off' || this.settings.effect !== 'none' || this.settings.idleEffect !== 'off'; } + + /** The logical caret moved (or was first placed). Never delays anything; retargets from the current visual state. */ + target(point: CursorPoint, now: number, cause: MoveCause = 'jump'): void { + if (this.goal && this.goal.row === point.row && this.goal.column === point.column) return; + const first = !this.goal || !this.head; + this.goal = {...point}; + if (first || !this.enabled) { this.head = {...point}; this.tail = undefined; this.restSince = now; this.phase = 'idle'; return; } + const distance = Math.hypot(point.column - this.head!.column, (point.row - this.head!.row) * 2); + const advanced = this.settings.advanced; + const short = cause === 'typing' && distance <= advanced.moveThreshold + 0.01; + this.from = {...this.head!}; + this.tail ??= {...this.head!}; + this.moveStart = now; + this.moveDuration = (short ? advanced.shortMoveMs : advanced.longMoveMs) * SPEED[this.settings.speed]; + this.phase = 'movement'; + this.lastFrame ||= now; + const effect = this.settings.effect; + if (effect === 'ripple' && !short) this.rings.push({row: point.row, column: point.column, start: now}); + if (this.rings.length > 4) this.rings.shift(); + if (!short && effect === 'lightning') { this.strike(this.from, point); this.restrike = {from: {...this.from}, to: {...point}, at: now + 70}; } + if (!short && effect === 'railgun') this.beam(this.from, point); + if (!short && effect === 'wireframe') this.wire = {row: point.row, left: Math.round(Math.min(this.from.column, point.column)), right: Math.round(Math.max(this.from.column, point.column)), + start: now, until: now + this.moveDuration + 260}; + } + + /** Forget everything (passthrough began, the composer was hidden, a resize invalidated positions). */ + reset(): void { + this.goal = undefined; this.head = undefined; this.tail = undefined; this.from = undefined; + this.particles.length = 0; this.rings = []; this.wire = undefined; this.restrike = undefined; this.phase = 'idle'; this.lastFrame = 0; + } + + /** Advance to `now`. Returns the phase after the step. */ + step(now: number): CursorPhase { + if (!this.goal || !this.head) return this.phase; + const dt = Math.min(100, Math.max(0, now - (this.lastFrame || now))); + this.lastFrame = now; + const advanced = this.settings.advanced; + if (this.phase === 'movement') { + const t = this.moveDuration <= 0 ? 1 : (now - this.moveStart) / this.moveDuration; + const k = easeOut(t, advanced.easing); + const previous = {...this.head}; + this.head = {row: this.from!.row + (this.goal.row - this.from!.row) * k, column: this.from!.column + (this.goal.column - this.from!.column) * k}; + this.emit(previous, this.head, dt); + if (t >= 1) { this.head = {...this.goal}; this.phase = 'settling'; } + } + // The tail chases the head (stiffness per 16 ms), so the smear stretches while moving and collapses after. + if (this.tail) { + const follow = 1 - (1 - advanced.tailStiffness) ** (dt / 16); + this.tail = {row: this.tail.row + (this.head.row - this.tail.row) * follow, column: this.tail.column + (this.head.column - this.tail.column) * follow}; + if (Math.hypot(this.tail.column - this.head.column, this.tail.row - this.head.row) < 0.2) this.tail = undefined; + } + // Particles: drag, gravity by style (fire rises, sparks fall, embers drift up), a flame's sideways wobble, age. + const gravityOf = (style: ParticleStyle) => advanced.gravity * (style === 'fire' ? -0.00003 : style === 'sparks' ? 0.00006 : style === 'embers' ? -0.00001 : 0); + for (let index = this.particles.length - 1; index >= 0; index -= 1) { + const particle = this.particles[index]!; + particle.age += dt; + if (particle.age >= particle.life) { this.particles.splice(index, 1); continue; } + const drag = advanced.drag ** (dt / 16); + if (particle.style === 'fire') particle.vx += Math.sin(particle.age / 70 + particle.heat * 7) * 0.0005 * (dt / 16); + particle.vx *= drag; particle.vy = particle.vy * drag + gravityOf(particle.style) * dt; + particle.x += particle.vx * dt; particle.y += particle.vy * dt; + } + if (this.restrike && now >= this.restrike.at) { this.strike(this.restrike.from, this.restrike.to); this.restrike = undefined; } + if (this.wire && now >= this.wire.until) this.wire = undefined; + this.rings = this.rings.filter(ring => now - ring.start < 420); + if (this.phase === 'settling' && !this.tail && !this.particles.length && !this.rings.length && !this.wire && !this.restrike) { this.phase = 'idle'; this.restSince = now; } + // Idle effects: a small, slow source at the resting caret, after the dwell time. + if (this.phase === 'idle' && this.settings.idleEffect !== 'off' && now - this.restSince >= advanced.dwellMs) this.idleEmit(dt); + return this.phase; + } + + /** + * Movement emission. Fire licks up from the path with a flame's wobble; Sparks are sparse bright + * points thrown sideways that fall. Lightning, Railgun, Ripple and Wireframe are drawn as shapes + * when the jump starts, not as shed particles. + */ + private emit(from: {row: number; column: number}, to: {row: number; column: number}, dt: number): void { + const effect = this.settings.effect; + if (effect !== 'fire' && effect !== 'sparks') return; + const advanced = this.settings.advanced; + const travelled = Math.hypot(to.column - from.column, (to.row - from.row) * 2); + const rate = (effect === 'sparks' ? 0.35 : 1.1) * advanced.particleDensity * AMOUNT[this.settings.particleAmount]; + let count = Math.min(effect === 'sparks' ? 3 : 6, Math.round(travelled * rate + (dt > 0 ? this.random() * 0.6 : 0))); + const spread = advanced.spread * advanced.particleSpeed; + while (count-- > 0 && this.particles.length < MAX_PARTICLES) { + // Some are born where the caret is going, which is often past the end of the text, where a glyph can show. + const t = this.random() < 0.4 ? 1 : this.random(); + const x = from.column + (to.column - from.column) * t + (t === 1 ? this.random() * 3 - 0.5 : 0); + if (effect === 'fire') { + // Born on the caret's row and the one above, so the flame is seen reaching up. + const y = from.row + (to.row - from.row) * t - this.random() * 0.9; + this.particles.push({x, y, vx: (this.random() - 0.5) * 0.004 * spread, vy: -0.0006 - this.random() * 0.0012, + age: 0, life: advanced.particleLifetimeMs * (0.5 + this.random() * 0.5), heat: 0.7 + this.random() * 0.3, style: 'fire'}); + } else { + const y = from.row + (to.row - from.row) * t; + const side = this.random() < 0.5 ? -1 : 1; + this.particles.push({x, y, vx: side * (0.012 + this.random() * 0.03) * spread, vy: -0.0012 - this.random() * 0.003, + age: 0, life: advanced.particleLifetimeMs * (0.35 + this.random() * 0.3), heat: 0.7 + this.random() * 0.3, style: 'sparks'}); + } + } + } + + private pushFixed(particle: Particle): void { + if (this.particles.length < MAX_FIXED) this.particles.push(particle); + } + + /** Lightning: a jagged bolt of slanted segments from where the caret was to where it is, with the odd fork. */ + private strike(from: CursorPoint, to: CursorPoint): void { + const direction = to.column >= from.column ? 1 : -1; + const steps = Math.min(40, Math.abs(Math.round(to.column - from.column))); + const life = this.settings.advanced.particleLifetimeMs * 0.4; + let offset = 0; + for (let index = 0; index <= steps; index += 1) { + const next = this.random() < 0.5 ? (this.random() < 0.5 ? -1 : 1) : 0; + const delta = next - offset; + const glyph = delta === 0 ? '─' : (delta < 0) === (direction > 0) ? '╱' : '╲'; + this.pushFixed({x: from.column + direction * index, y: to.row + next, vx: 0, vy: 0, age: 0, life: life * (0.7 + this.random() * 0.5), heat: 1, style: 'bolt', glyph}); + if (this.random() < 0.12) { + const fork = this.random() < 0.5 ? -1 : 1; + this.pushFixed({x: from.column + direction * (index + 1), y: to.row + next + fork, vx: 0, vy: 0, age: 0, life: life * 0.6, heat: 0.8, style: 'bolt', glyph: fork < 0 ? '╱' : '╲'}); + } + offset = next; + } + } + + /** Railgun: one straight heavy beam along the caret's row, with a flash where it lands. */ + private beam(from: CursorPoint, to: CursorPoint): void { + const direction = to.column >= from.column ? 1 : -1; + const steps = Math.min(60, Math.abs(Math.round(to.column - from.column))); + const life = this.settings.advanced.particleLifetimeMs * 0.5; + for (let index = 0; index <= steps; index += 1) { + this.pushFixed({x: from.column + direction * index, y: to.row, vx: 0, vy: 0, age: 0, life, heat: 1, style: 'beam', glyph: '━'}); + } + this.pushFixed({x: to.column, y: to.row, vx: 0, vy: 0, age: 0, life: life * 1.4, heat: 1, style: 'beam', glyph: '◉'}); + } + + private idleEmit(dt: number): void { + const idle = this.settings.idleEffect; + if (!this.goal || idle === 'glow') return; + const chance = (idle === 'flame' ? 0.02 : idle === 'embers' ? 0.006 : 0.005) * dt * AMOUNT[this.settings.particleAmount]; + if (this.random() >= chance || this.particles.length >= MAX_PARTICLES / 3) return; + const x = this.goal.column + (this.random() - 0.5) * (idle === 'embers' ? 3 : idle === 'flame' ? 2.6 : 1.6); + if (idle === 'flame') this.particles.push({x, y: this.goal.row - 0.1, vx: (this.random() - 0.5) * 0.002, vy: -0.0008 - this.random() * 0.0012, age: 0, life: 280 + this.random() * 320, heat: 0.6 + this.random() * 0.4, style: 'fire'}); + else if (idle === 'embers') this.particles.push({x, y: this.goal.row - 0.2, vx: (this.random() - 0.5) * 0.001, vy: -0.0003 - this.random() * 0.0004, age: 0, life: 800 + this.random() * 700, heat: 0.5 + this.random() * 0.4, style: 'embers'}); + else this.particles.push({x, y: this.goal.row - 0.3, vx: (this.random() < 0.5 ? -1 : 1) * (0.008 + this.random() * 0.02), vy: -0.002 - this.random() * 0.003, age: 0, life: 220 + this.random() * 220, heat: 0.7 + this.random() * 0.3, style: 'sparks'}); + } + + /** Frames per second needed now: 0 when nothing animates (no scheduler at all). */ + cadence(now: number): number { + if (this.phase === 'movement' || this.phase === 'settling') return Math.min(this.settings.advanced.fps, 60); + if (this.settings.idleEffect !== 'off' && this.goal) return now - this.restSince >= this.settings.advanced.dwellMs ? Math.min(IDLE_FPS, this.settings.advanced.fps) : 4; + return 0; + } + + /** Where the logical caret is (undefined before the first target). */ + get caretCell(): CursorPoint | undefined { return this.goal ? {...this.goal} : undefined; } + + /** True while the visual caret is away from the logical one (the host caret is hidden and drawn by NMSh instead). */ + get drawsCaret(): boolean { + return this.settings.motion !== 'off' && this.phase === 'movement'; + } + + /** + * The overlay for this frame: foreground tints over text cells and shading + * glyphs on blank cells (trail, ripple, particles, caret tip). It never + * fills a background behind text, so a transparent terminal stays + * transparent; only the travelling caret and the resting caret's glow fill + * the single caret cell. Never outside `bounds`; text glyphs never change. + */ + paints(palette: EffectPalette, bounds: DrawBounds, now: number): Map> { + const paints = new Map>(); + if (!this.goal || !this.head) return paints; + const put = (row: number, column: number, paint: CellPaint) => { + if (row < bounds.top || row > bounds.bottom || column < 0 || column >= bounds.columns) return; + const line = paints.get(row) ?? new Map(); + const existing = line.get(column); + line.set(column, {...existing, ...paint}); + paints.set(row, line); + }; + const intensity = INTENSITY[this.settings.intensity]; + const motion = this.settings.motion; + const base = palette.trail[0] ?? palette.caret; + // Trail: Smear stretches a uniform band; Tail tapers (exponent) toward the end. + if (this.tail && (motion === 'smear' || motion === 'tail')) { + const dx = this.head.column - this.tail.column; + const dy = this.head.row - this.tail.row; + const length = Math.min(this.settings.advanced.maxTrail * TRAIL[this.settings.trailLength], Math.ceil(Math.hypot(dx, dy * 2)) + 1); + for (let index = 0; index <= length; index += 1) { + const t = length ? index / length : 0; + const column = Math.round(this.tail.column + dx * t); + const row = Math.round(this.tail.row + dy * t); + const strength = motion === 'tail' ? t ** this.settings.advanced.trailExponent : 0.55 + 0.45 * t; + const color = gradientAt(palette.trail.length ? palette.trail : [base], 1 - t); + const amount = Math.max(0.08, strength * intensity); + // Smear is a solid band of shading; Tail is a comet: dots that shrink away from the head. + put(row, column, {tint: {color, amount}, glyph: motion === 'tail' ? taper(t) : shade(amount), foreground: color}); + } + } + // Ripple: a wave that opens outward from the destination, ( ) at its crest and dots on the rows around it. + for (const ring of this.rings) { + const progress = (now - ring.start) / 420; + const radius = Math.max(1, Math.round(progress * 5)); + const fade = 1 - progress; + const crest = fade > 0.5 ? ['(', ')'] : fade > 0.2 ? ['‹', '›'] : ['·', '·']; + const amount = 0.7 * fade * intensity; + put(ring.row, ring.column - radius, {tint: {color: palette.caret, amount}, glyph: crest[0]!, foreground: palette.caret}); + put(ring.row, ring.column + radius, {tint: {color: palette.caret, amount}, glyph: crest[1]!, foreground: palette.caret}); + const lean = Math.round(radius * 0.6); + for (const row of [ring.row - 1, ring.row + 1]) { + put(row, ring.column - lean, {tint: {color: palette.caret, amount: amount * 0.7}, glyph: '·', foreground: palette.caret}); + put(row, ring.column + lean, {tint: {color: palette.caret, amount: amount * 0.7}, glyph: '·', foreground: palette.caret}); + } + } + // Wireframe: a bracket around the travelled span (corners above, posts on the caret row), fading after arrival. + if (this.wire && now < this.wire.until) { + const { row, left, right } = this.wire; + const fade = Math.min(1, (this.wire.until - now) / 260); + const style = {foreground: palette.caret, tint: {color: palette.caret, amount: 0.6 * fade}, ...(fade < 0.4 ? {dim: true} : {})}; + for (let column = left; column <= right; column += 1) put(row - 1, column, {...style, glyph: column === left ? '┌' : column === right ? '┐' : '─'}); + put(row, left, {...style, glyph: '│'}); + if (right !== left) put(row, right, {...style, glyph: '│'}); + } + // Particles: energy → glyph for the style, age → gradient position. Bolt and beam cells keep their own glyph and flicker out. + for (const particle of this.particles) { + const life = 1 - particle.age / particle.life; + const glyph = particle.glyph ? (life < 0.25 ? (particle.style === 'beam' ? '·' : particle.glyph) : particle.style === 'beam' && life < 0.55 && particle.glyph === '━' ? '─' : particle.glyph) : particleGlyph(particle.style, life * particle.heat); + const color = gradientAt(palette.particles, 1 - life); + // A glyph where the cell is blank; over text, the text itself takes the particle's color. + put(Math.round(particle.y), Math.round(particle.x), {glyph, foreground: color, tint: {color, amount: Math.max(0.3, life * intensity)}, ...(intensity < 0.7 || life < 0.3 ? {dim: true} : {})}); + } + // Idle glow: the cell beside the resting caret breathes slowly (bounded, 15 fps). Glyph and foreground only: the caret's own cell stays the host's. + if (this.phase === 'idle' && this.settings.idleEffect !== 'off') { + const pulse = 0.35 + 0.25 * Math.sin(now / 520); + if (this.settings.idleEffect === 'glow' || this.settings.idleEffect === 'flame') { + const level = Math.max(0.15, Math.min(1, pulse * intensity)); + put(this.goal.row, this.goal.column, {tint: {color: palette.caret, amount: level}}); + put(this.goal.row, this.goal.column + 1, {glyph: shade(level), foreground: palette.caret, tint: {color: palette.caret, amount: level}, ...(level < 0.4 ? {dim: true} : {})}); + } + } + // The visual caret while travelling: a block at the rounded head with a partial block for the fractional column. + if (this.drawsCaret) { + const row = Math.round(this.head.row); + const column = Math.floor(this.head.column); + const fraction = this.head.column - column; + // Drawn in the chosen shape (Host default has none of its own: a block), so the travelling caret matches the one that settles. + put(row, column, {caret: true, caretShape: this.settings.shape === 'host' ? 'block' : this.settings.shape, color: palette.caret}); + if (fraction > 0.15 && motion !== 'smooth' && (this.settings.shape === 'block' || this.settings.shape === 'host')) put(row, column + 1, {glyph: PARTIAL[Math.min(7, Math.floor(fraction * 8))], foreground: palette.caret}); + } + return paints; + } +} + +/** Trail shading for a blank cell: light texture, never a solid block, so the host background stays visible. */ +const shade = (amount: number) => amount > 0.6 ? '▒' : amount > 0.3 ? '░' : '·'; +const PARTIAL = ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█']; + +/** Energy (0 spent … 1 fresh) → glyph, per style: flames climb ▲ ^ ˄, sparks twinkle ✦ * ·, embers glow • · ˙. */ +function particleGlyph(style: ParticleStyle, energy: number): string { + if (style === 'sparks') return energy > 0.6 ? '✦' : energy > 0.3 ? '*' : '·'; + if (style === 'embers') return energy > 0.55 ? '•' : energy > 0.25 ? '·' : '˙'; + if (style === 'fire') return energy > 0.75 ? '▲' : energy > 0.55 ? '▴' : energy > 0.35 ? '^' : energy > 0.18 ? '˄' : '·'; + return '·'; +} + +/** A comet's tail: dots that shrink away from the head (t = 1 at the head). */ +const taper = (t: number) => t > 0.7 ? '•' : t > 0.35 ? '·' : '˙'; + +export function gradientAt(colors: readonly RgbColor[], t: number): RgbColor { + if (colors.length <= 1) return colors[0] ?? {red: 255, green: 255, blue: 255}; + const x = Math.min(1, Math.max(0, t)) * (colors.length - 1); + const index = Math.min(colors.length - 2, Math.floor(x)); + return mixRgb(colors[index]!, colors[index + 1]!, x - index); +} diff --git a/src/cursor/CursorPanel.ts b/src/cursor/CursorPanel.ts new file mode 100644 index 00000000..7949214f --- /dev/null +++ b/src/cursor/CursorPanel.ts @@ -0,0 +1,419 @@ +import type {Key} from '../terminal/keys.js'; +import {CURSOR_BLINKS, CURSOR_EASINGS, CURSOR_EFFECTS, CURSOR_IDLE_EFFECTS, CURSOR_LEVELS, CURSOR_MOTIONS, CURSOR_RENDERERS, CURSOR_SHAPES, + type CursorAdvanced, type CursorSettings} from '../prompt/configuration.js'; +import {CATPPUCCIN_ACCENTS, CATPPUCCIN_ACCENT_LABELS, normalizeCatppuccinAccent} from '../appearance/themeFamilies.js'; +import {defaultVariant, FAMILY_IDS, FAMILY_LABELS, familyOf, variantOptions} from '../appearance/themeSelection.js'; +import {hexColor, parseHexColor} from '../chroma/color.js'; +import type {ColorLevel} from '../presentation/capabilities.js'; +import {colorPickerKey, createColorPicker, renderColorPicker, type ColorPickerState} from '../ui/ColorPicker.js'; +import {gradientEditorControls, gradientEditorKey, renderGradientEditorRows, type GradientEditorState} from '../ui/GradientEditor.js'; +import {renderControls} from '../ui/controls.js'; +import {GLYPHS} from '../ui/glyphs.js'; +import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; +import {colorEscape} from '../chroma/escape.js'; +import {padCells, truncateAnsi} from '../util/text.js'; +import {availabilityOf, availableValues, caretColorNote, unavailableReason, type BackendChoice, type CursorFeature, type HostCursorFacts} from './backends.js'; +import {describeCursorColor, resolveCursorSettings, type CursorColorContext} from './colors.js'; +import {effectPalette} from './palette.js'; +import {renderCursorPreview, type PreviewScene} from './CursorPreview.js'; + +/** + * /cursor: the canonical cursor & effects surface (also embedded in Setup + * over its draft). Normal rows cover what people change; Advanced (A) holds + * colors beyond the basics and the physics. Rows are capability-aware: a value + * the effective renderer cannot draw is not offered, a row with nothing to + * offer says Unavailable and why, and Auto says plainly when Portable is + * drawing something the host backend does not. The preview below the title + * demonstrates the SELECTED row, restarts when the selection or a value + * changes, replays on R, and always has the same height. + */ +export interface CursorPanelState { + draft: CursorSettings; + selected: number; + advanced: boolean; + /** When the preview (re)started; selecting, changing a value and R all restart it. */ + started: number; + message?: string; + /** Host-native setup awaiting the person's Yes: the exact file, the one line, what else touches the feature. */ + native?: {host: 'ghostty' | 'kitty'; configPath: string; line?: string; related: string[]; blocked?: string; choice: 'yes' | 'no'}; + /** The shared color picker, while a color is being chosen. Nothing is kept until Enter. */ + picker?: {target: 'cursor' | 'trail' | 'particles'; state: ColorPickerState}; + /** The shared gradient editor for trail or particle colors. */ + gradient?: {target: 'trail' | 'particles'; state: GradientEditorState}; + /** Setup embeds this panel over its draft: applying changes the draft, and host setup is not offered here. */ + embedded?: boolean; + /** Opened straight on Advanced (from Setup): Esc leaves the panel instead of returning to the basic rows. */ + embeddedAdvancedOnly?: boolean; + /** Set by each render: whether the preview is still animating (the app keeps a frame clock only while true). */ + previewBusy?: boolean; +} + +/** Everything the panel needs to know about the world; supplied by the app per render and per key. */ +export interface CursorPanelEnv { + choice: BackendChoice; + facts: HostCursorFacts; + context: CursorColorContext; + /** Decorative motion is not allowed right now. */ + still: boolean; + level: ColorLevel; +} + +interface Option {value: string; label: string} +interface Row { + key: string; + label: string; + scene: PreviewScene; + /** The setting's own name for the preview title. */ + options?: (draft: CursorSettings, env: CursorPanelEnv) => Option[]; + get?: (draft: CursorSettings, env: CursorPanelEnv) => string; + set?: (draft: CursorSettings, value: string, env: CursorPanelEnv) => void; + /** A reason, when the row has nothing it can do here: shown as Unavailable, without arrows. */ + unavailable?: (draft: CursorSettings, env: CursorPanelEnv) => string | undefined; + when?: (draft: CursorSettings) => boolean; + action?: 'native' | 'advanced' | 'customColor' | 'trailColors' | 'particleColors'; + /** The text shown for an action row. */ + display?: (draft: CursorSettings, env: CursorPanelEnv) => string; + /** One line shown under the list while the row is selected. */ + detail?: (draft: CursorSettings, env: CursorPanelEnv) => string | undefined; +} + +const LABELS: Record = {host: 'Host default', block: 'Block', bar: 'Bar', underline: 'Underline', on: 'On', off: 'Off', auto: 'Auto', portable: 'Portable', native: 'Host native', + smooth: 'Smooth', smear: 'Smear', tail: 'Tail', none: 'None', fire: 'Fire', sparks: 'Sparks', lightning: 'Lightning', railgun: 'Railgun', ripple: 'Ripple', wireframe: 'Wireframe', + glow: 'Glow', embers: 'Embers', flame: 'Flame', accent: 'NMSh accent', theme: 'Follow current theme', chosen: 'Choose theme', custom: 'Custom', low: 'Low', medium: 'Medium', high: 'High', + cursor: 'Follow cursor', gradient: 'Gradient', trail: 'Follow trail', 'out-cubic': 'Ease out', 'out-expo': 'Ease out (sharp)', linear: 'Linear', spring: 'Spring'}; +export const cursorLabel = (value: string) => LABELS[value] ?? value; + +const COLOR_SOURCE_ORDER = ['theme', 'chosen', 'accent', 'host', 'custom'] as const; +const COLOR_SOURCE_LABELS: Record = {theme: 'Follow current theme', chosen: 'Choose theme', accent: 'NMSh accent', host: 'Host', custom: 'Custom'}; +const plain = (values: readonly string[], label: (value: string) => string = cursorLabel): Option[] => values.map(value => ({value, label: label(value)})); + +/** A feature's values that can work now, labelled with how they are delivered (Portable fallback, after setup). */ +function featureOptions(feature: CursorFeature, values: readonly string[], draft: CursorSettings, env: CursorPanelEnv): Option[] { + return availableValues(feature, values, draft.renderer, env.facts).map(value => { + const availability = availabilityOf(feature, value, draft.renderer, env.facts); + return {value, label: `${cursorLabel(value)}${availability.available && availability.suffix ? ` · ${availability.suffix}` : ''}`}; + }); +} + +const FEATURE_VALUES: Record = {motion: CURSOR_MOTIONS, effect: CURSOR_EFFECTS, idleEffect: CURSOR_IDLE_EFFECTS}; + +function featureRow(key: string, label: string, feature: CursorFeature, scene: PreviewScene, get: (d: CursorSettings) => string, set: (d: CursorSettings, value: string) => void): Row { + return {key, label, scene, options: (draft, env) => featureOptions(feature, FEATURE_VALUES[feature], draft, env), get, set, + unavailable: (draft, env) => unavailableReason(feature, FEATURE_VALUES[feature], draft.renderer, env.facts), + detail: (draft, env) => { + const current = get(draft); + const availability = availabilityOf(feature, current, draft.renderer, env.facts); + if (!availability.available) return availability.reason; + if (availability.via === 'portable-fallback') return `${env.facts.host === 'ghostty' ? 'Ghostty' : 'The host'} native does not draw this; Portable draws it instead.`; + if (availability.via === 'native-pending') return `Drawn by ${availability.suffix.replace('after ', '').replace(' setup', '')} native once it is set up.`; + return undefined; + }}; +} + +const chosenTheme = (draft: CursorSettings, env: CursorPanelEnv) => draft.color.theme ?? env.context.palette; +const hexOf = (draft: CursorSettings, env: CursorPanelEnv) => describeCursorColor(resolveCursorSettings(draft, env.context), env.context).hex; + +const NORMAL: Row[] = [ + {key: 'shape', label: 'Shape', scene: 'shape', options: () => plain(CURSOR_SHAPES), get: d => d.shape, set: (d, v) => { d.shape = v as CursorSettings['shape']; }, + detail: d => d.shape === 'host' ? 'Host default keeps your terminal\'s own cursor; NMSh sends no shape or blink.' : 'Applied while NMSh owns the composer; full-screen programs get your normal cursor.'}, + {key: 'blink', label: 'Blink', scene: 'blink', options: () => plain(CURSOR_BLINKS), get: d => d.blink, set: (d, v) => { d.blink = v as CursorSettings['blink']; }, + unavailable: d => d.shape === 'host' ? 'Blink needs an explicit shape; Host default keeps your terminal\'s own cursor.' : undefined, + detail: d => d.blink === 'host' ? 'Host default blink: the terminal\'s own, at its own speed.' : undefined}, + {key: 'renderer', label: 'Renderer', scene: 'jump', options: () => plain(CURSOR_RENDERERS), get: d => d.renderer, set: (d, v) => { d.renderer = v as CursorSettings['renderer']; }, + detail: (_d, env) => env.choice.reason}, + featureRow('motion', 'Motion', 'motion', 'jump', d => d.motion, (d, v) => { d.motion = v as CursorSettings['motion']; }), + featureRow('effect', 'Effect', 'effect', 'jump', d => d.effect, (d, v) => { d.effect = v as CursorSettings['effect']; }), + featureRow('idle', 'Idle effect', 'idleEffect', 'idle', d => d.idleEffect, (d, v) => { d.idleEffect = v as CursorSettings['idleEffect']; }), + {key: 'color', label: 'Color', scene: 'jump', options: () => plain(COLOR_SOURCE_ORDER, value => COLOR_SOURCE_LABELS[value]!), get: d => d.color.source, + set: (d, v, env) => { + const source = v as CursorSettings['color']['source']; + // Each source starts from what is shown now, so choosing it never makes the color jump. + const current = hexOf(d, env); + d.color = {...d.color, source, + ...(source === 'chosen' ? {theme: d.color.theme ?? env.context.palette, themeAccent: d.color.themeAccent ?? env.context.accent} : {}), + ...(source === 'custom' && !d.color.custom ? {custom: current ?? '#a67cf3'} : {})}; + }, + detail: (d, env) => caretColorNote(env.facts, d.color.source)}, + {key: 'colorFamily', label: 'Theme family', scene: 'jump', when: d => d.color.source === 'chosen', + options: (_d, env) => FAMILY_IDS.filter(id => id !== 'custom' || env.context.customTheme).map(id => ({value: id, label: FAMILY_LABELS[FAMILY_IDS.indexOf(id)]!})), + get: (d, env) => familyOf(chosenTheme(d, env)), + set: (d, v) => { d.color = {...d.color, theme: defaultVariant(v as Parameters[0])}; }}, + {key: 'colorVariant', label: 'Variant', scene: 'jump', when: d => d.color.source === 'chosen', + options: (d, env) => { const options = variantOptions(familyOf(chosenTheme(d, env))); return options.length > 1 ? options.map(option => ({value: option.id, label: option.label})) : []; }, + get: (d, env) => chosenTheme(d, env), set: (d, v) => { d.color = {...d.color, theme: v as NonNullable}; }, + unavailable: (d, env) => variantOptions(familyOf(chosenTheme(d, env))).length > 1 ? undefined : 'This theme family has a single variant.'}, + {key: 'colorAccent', label: 'Accent', scene: 'jump', when: d => d.color.source === 'chosen' && familyOf(d.color.theme ?? 'lavender') === 'catppuccin', + options: () => CATPPUCCIN_ACCENTS.map(accent => ({value: accent, label: CATPPUCCIN_ACCENT_LABELS[accent]})), + get: (d, env) => d.color.themeAccent ?? env.context.accent, set: (d, v) => { d.color = {...d.color, themeAccent: normalizeCatppuccinAccent(v)}; }}, + {key: 'colorCustom', label: 'Custom color', scene: 'jump', when: d => d.color.source === 'custom', action: 'customColor', + display: (d, env) => hexOf(d, env)?.toUpperCase() ?? '#A67CF3', detail: () => 'Enter opens the color picker; type a #RRGGBB value or pick one.'}, + {key: 'speed', label: 'Speed', scene: 'jump', options: () => plain(CURSOR_LEVELS), get: d => d.speed, set: (d, v) => { d.speed = v as CursorSettings['speed']; }, detail: () => 'How long the caret takes to travel.'}, + {key: 'intensity', label: 'Intensity', scene: 'jump', options: () => plain(CURSOR_LEVELS), get: d => d.intensity, set: (d, v) => { d.intensity = v as CursorSettings['intensity']; }, detail: () => 'How strong the trail and effect colors are.'}, + {key: 'trailLength', label: 'Trail length', scene: 'jump', options: () => plain(CURSOR_LEVELS), get: d => d.trailLength, set: (d, v) => { d.trailLength = v as CursorSettings['trailLength']; }, detail: () => 'How far Smear and Tail stretch.'}, + {key: 'particles', label: 'Particles', scene: 'jump', options: () => plain(CURSOR_LEVELS), get: d => d.particleAmount, set: (d, v) => { d.particleAmount = v as CursorSettings['particleAmount']; }, detail: () => 'How many particles an effect sheds.'}, + {key: 'nativeSetup', label: 'Host native setup', scene: 'jump', action: 'native', display: (_d, env) => env.choice.native ? (env.facts.integrated ? env.choice.reason : `${env.choice.native.host}: set up its GPU cursor (shown before any change)`) + : 'Ghostty / Kitty: set up their GPU cursor (shown before any change)'}, + {key: 'advancedRow', label: 'Advanced', scene: 'jump', action: 'advanced', display: () => 'Colors and physics ›'}, +]; + +const numeric = (key: keyof CursorAdvanced, label: string, step: number, min: number, max: number, unit = ''): Row => ({key, label, scene: 'jump', + get: d => `${d.advanced[key]}${unit}`, + set: (d, direction) => { const current = d.advanced[key] as number; d.advanced = {...d.advanced, [key]: Math.round(Math.min(max, Math.max(min, current + (direction === '+' ? step : -step))) * 1000) / 1000}; }}); + +const ADVANCED: Row[] = [ + {key: 'trailColor', label: 'Trail color', scene: 'jump', options: () => plain(['cursor', 'custom', 'gradient']), get: d => d.trail.source, + set: (d, v) => { d.trail = {...d.trail, source: v as CursorSettings['trail']['source']}; }, + detail: () => 'Follow cursor uses the cursor color; Custom is one color; Gradient fades along the trail.'}, + {key: 'trailEdit', label: 'Edit trail color', scene: 'jump', when: d => d.trail.source !== 'cursor', action: 'trailColors', + display: d => d.trail.source === 'custom' ? (d.trail.colors[0] ?? 'choose a color').toUpperCase() : `${d.trail.colors.length || 2} stops ›`}, + {key: 'particleColor', label: 'Particle color', scene: 'jump', options: () => plain(['trail', 'custom', 'gradient']), get: d => d.particles.source, + set: (d, v) => { d.particles = {...d.particles, source: v as CursorSettings['particles']['source']}; }, + detail: () => 'Follow trail shares the trail color; Custom is one color; Gradient runs from young to old particles.'}, + {key: 'particleEdit', label: 'Edit particle colors', scene: 'jump', when: d => d.particles.source !== 'trail', action: 'particleColors', + display: d => d.particles.source === 'custom' ? (d.particles.colors[0] ?? 'choose a color').toUpperCase() : `${d.particles.colors.length || 2} stops ›`}, + numeric('shortMoveMs', 'Short-move duration', 10, 0, 200, ' ms'), numeric('longMoveMs', 'Long-move duration', 10, 40, 600, ' ms'), + {key: 'easing', label: 'Easing', scene: 'jump', options: () => plain(CURSOR_EASINGS), get: d => d.advanced.easing, set: (d, v) => { d.advanced = {...d.advanced, easing: v as CursorAdvanced['easing']}; }}, + numeric('stiffness', 'Stiffness', 0.05, 0.05, 1), numeric('tailStiffness', 'Tail stiffness', 0.05, 0.05, 1), numeric('damping', 'Damping', 0.05, 0.1, 1), + numeric('trailExponent', 'Trail exponent', 0.1, 0.5, 4), numeric('maxTrail', 'Max trail', 2, 2, 80, ' cells'), numeric('moveThreshold', 'Movement threshold', 1, 0, 8, ' cells'), + numeric('dwellMs', 'Dwell threshold', 100, 0, 5000, ' ms'), numeric('particleDensity', 'Particle density', 0.1, 0, 4), numeric('particleLifetimeMs', 'Particle lifetime', 20, 100, 2000, ' ms'), + numeric('spread', 'Spread', 0.1, 0, 2), numeric('particleSpeed', 'Particle speed', 0.1, 0.1, 4), numeric('drag', 'Drag', 0.02, 0.5, 1), numeric('gravity', 'Gravity', 0.1, -2, 2), + numeric('fps', 'Frame cadence', 6, 12, 120, ' fps'), +]; + +export interface CursorPanelOptions {embedded?: boolean; advanced?: boolean; /** Start with this row selected (by key). */ row?: string} + +export function createCursorPanel(settings: CursorSettings, now = Date.now(), options: CursorPanelOptions = {}): CursorPanelState { + const state: CursorPanelState = {draft: structuredClone(settings), selected: 0, advanced: Boolean(options.advanced), started: now, ...(options.embedded ? {embedded: true} : {}), + ...(options.advanced ? {embeddedAdvancedOnly: true} : {})}; + if (options.row) state.selected = Math.max(0, visibleRows(state).findIndex(row => row.key === options.row)); + return state; +} + +export type CursorPanelAction = {kind: 'close'} | {kind: 'apply'; settings: CursorSettings} | {kind: 'native'} | {kind: 'nativeConfirm'}; + +function visibleRows(state: CursorPanelState): Row[] { + return (state.advanced ? ADVANCED : NORMAL).filter(row => (!row.when || row.when(state.draft)) && !(state.embedded && row.action === 'native')); +} + +export const cursorPanelRowKeys = (advanced: boolean, draft: CursorSettings): string[] => visibleRows({draft, selected: 0, advanced, started: 0}).map(row => row.key); + +/** The selected row, and the preview scene it demonstrates. */ +export function selectedCursorRow(state: CursorPanelState): Row | undefined { + const rows = visibleRows(state); + return rows[Math.min(state.selected, rows.length - 1)]; +} + +function optionsOf(row: Row, state: CursorPanelState, env: CursorPanelEnv): Option[] { + return row.options?.(state.draft, env) ?? []; +} + +/** Whether ←/→/Enter would change this row's value (an Unavailable row only offers to reset a stale value to Off). */ +function offValue(row: Row): string | undefined { + return row.key === 'motion' ? 'off' : row.key === 'effect' ? 'none' : row.key === 'idle' ? 'off' : undefined; +} + +const commit = (state: CursorPanelState): CursorPanelAction => ({kind: 'apply', settings: structuredClone(state.draft)}); + +function pickerStart(state: CursorPanelState, target: 'cursor' | 'trail' | 'particles', env: CursorPanelEnv): void { + const draft = state.draft; + const hex = target === 'cursor' ? hexOf(draft, env) : target === 'trail' ? draft.trail.colors[0] : draft.particles.colors[0]; + state.picker = {target, state: createColorPicker(hex ?? '#a67cf3', env.level)}; +} + +function gradientStart(state: CursorPanelState, target: 'trail' | 'particles', env: CursorPanelEnv): void { + const existing = target === 'trail' ? state.draft.trail.colors : state.draft.particles.colors; + const palette = effectPalette(resolveCursorSettings(state.draft, env.context)); + const fallback = (target === 'trail' ? palette.trail : palette.particles).map(color => hexColor(color)); + const stops = existing.length >= 2 ? [...existing] : fallback.length >= 2 ? fallback : [fallback[0] ?? '#a67cf3', '#4a8cff']; + state.gradient = {target, state: {stops, index: 0}}; +} + +export function cursorPanelKey(state: CursorPanelState, key: Key, env: CursorPanelEnv, now = Date.now()): CursorPanelAction | undefined { + if (state.picker) { + const outcome = colorPickerKey(state.picker.state, key, env.level); + if (outcome === 'cancel') { state.picker = undefined; state.started = now; return undefined; } + if (outcome === 'confirm') { + const hex = hexColor(state.picker.state.color); + const {target} = state.picker; + state.picker = undefined; + if (target === 'cursor') state.draft.color = {...state.draft.color, source: 'custom', custom: hex}; + else if (target === 'trail') state.draft.trail = {source: 'custom', colors: [hex]}; + else state.draft.particles = {source: 'custom', colors: [hex]}; + state.started = now; + return commit(state); + } + return undefined; + } + if (state.gradient) { + const editor = state.gradient; + if (editor.state.editing === undefined && (key.kind === 'escape' || key.kind === 'interrupt')) { + const stops = editor.state.stops.filter(stop => parseHexColor(stop)).slice(0, 6); + state.gradient = undefined; + if (editor.target === 'trail') state.draft.trail = {source: 'gradient', colors: stops}; + else state.draft.particles = {source: 'gradient', colors: stops}; + state.started = now; + return commit(state); + } + gradientEditorKey(editor.state, key, () => editor.state.stops); + return undefined; + } + if (state.native) { + const native = state.native; + if (key.kind === 'escape' || key.kind === 'interrupt') { state.native = undefined; return undefined; } + if (key.kind === 'left' || key.kind === 'right') native.choice = native.choice === 'yes' ? 'no' : 'yes'; + else if (key.kind === 'text' && /^[yn]$/iu.test(key.value)) native.choice = key.value.toLowerCase() === 'y' ? 'yes' : 'no'; + else if (key.kind === 'enter') { + const yes = native.choice === 'yes' && !native.blocked; + if (!yes) { state.native = undefined; state.message = 'Nothing was changed.'; return undefined; } + return {kind: 'nativeConfirm'}; + } + return undefined; + } + const rows = visibleRows(state); + if (key.kind === 'escape' || key.kind === 'interrupt') { + if (state.advanced && !state.embeddedAdvancedOnly) { state.advanced = false; state.selected = NORMAL.length - 1; state.started = now; return undefined; } + return {kind: 'close'}; + } + if (key.kind === 'up' || key.kind === 'down') { + state.selected = (Math.min(state.selected, rows.length - 1) + (key.kind === 'up' ? -1 : 1) + rows.length) % rows.length; + state.message = undefined; + state.started = now; + return undefined; + } + if (key.kind === 'text' && key.value.toLowerCase() === 'r') { state.started = now; return undefined; } + if (key.kind === 'text' && key.value.toLowerCase() === 'a' && !state.advanced) { state.advanced = true; state.selected = 0; state.started = now; return undefined; } + const row = rows[Math.min(state.selected, rows.length - 1)]; + if (!row) return undefined; + if (row.action && (key.kind === 'enter' || key.kind === 'right')) { + if (row.action === 'advanced') { state.advanced = true; state.selected = 0; state.started = now; return undefined; } + if (row.action === 'customColor') { pickerStart(state, 'cursor', env); return undefined; } + if (row.action === 'trailColors') { if (state.draft.trail.source === 'custom') pickerStart(state, 'trail', env); else gradientStart(state, 'trail', env); return undefined; } + if (row.action === 'particleColors') { if (state.draft.particles.source === 'custom') pickerStart(state, 'particles', env); else gradientStart(state, 'particles', env); return undefined; } + return {kind: 'native'}; + } + if ((key.kind === 'left' || key.kind === 'right' || key.kind === 'enter') && row.set) { + const back = key.kind === 'left'; + const reason = row.unavailable?.(state.draft, env); + if (reason) { + // Nothing here can work; the only change offered is putting a stale value back to Off. + const off = offValue(row); + if (!off || row.get!(state.draft, env) === off) return undefined; + row.set(state.draft, off, env); + state.started = now; + return commit(state); + } + if (row.options) { + const options = optionsOf(row, state, env); + if (!options.length) return undefined; + const current = row.get!(state.draft, env); + const index = options.findIndex(option => option.value === current); + // A stale value (no longer offered) moves to the first value that is. + const next = index < 0 ? options[0]! : options[(index + (back ? -1 : 1) + options.length) % options.length]!; + row.set(state.draft, next.value, env); + } else row.set(state.draft, back ? '-' : '+', env); + // Changes preview at once and apply at once (the composer follows); nothing is ever hidden behind Save. + state.started = now; + return commit(state); + } + return undefined; +} + +/** The text of a row's value, and whether it is editable (arrows) right now. */ +function rowValue(row: Row, state: CursorPanelState, env: CursorPanelEnv): {text: string; editable: boolean; swatch?: string} { + const draft = state.draft; + if (row.action) { + const hex = row.key === 'colorCustom' ? hexOf(draft, env) : undefined; + return {text: row.display?.(draft, env) ?? '', editable: false, ...(hex ? {swatch: hex} : {})}; + } + if (row.unavailable?.(draft, env)) return {text: 'Unavailable', editable: false}; + const current = row.get!(draft, env); + const options = row.options ? optionsOf(row, state, env) : []; + const match = options.find(option => option.value === current); + const hex = row.key === 'color' ? hexOf(draft, env) : undefined; + if (!row.options) return {text: current, editable: true}; + return {text: match ? match.label : `${cursorLabel(current)} · unavailable`, editable: options.length > 1, ...(hex ? {swatch: hex} : {})}; +} + +/** The preview scene and title for the selected row. */ +export function previewFor(state: CursorPanelState, env: CursorPanelEnv): {scene: PreviewScene; title: string; unavailable?: string} { + const row = selectedCursorRow(state); + if (!row) return {scene: 'shape', title: 'Shape'}; + const value = row.action ? row.label : `${row.label}: ${rowValue(row, state, env).text.replace(' · unavailable', '')}`; + // An effect/motion/idle value that the renderer cannot draw demonstrates nothing but the reason. + const reason = row.options && !row.action ? (row.unavailable?.(state.draft, env) ?? (() => { + const current = row.get!(state.draft, env); + const feature: CursorFeature | undefined = row.key === 'motion' ? 'motion' : row.key === 'effect' ? 'effect' : row.key === 'idle' ? 'idleEffect' : undefined; + const availability = feature ? availabilityOf(feature, current, state.draft.renderer, env.facts) : undefined; + return availability && !availability.available ? availability.reason : undefined; + })()) : undefined; + return {scene: row.scene, title: value, ...(reason && row.key !== 'blink' ? {unavailable: reason} : {})}; +} + +export function renderCursorPanel(state: CursorPanelState, columns: number, now: number, env: CursorPanelEnv, height = Number.POSITIVE_INFINITY): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const reset = '\u001b[0m'; + state.previewBusy = false; + if (state.picker) { + const label = state.picker.target === 'cursor' ? 'Cursor color' : state.picker.target === 'trail' ? 'Trail color' : 'Particle color'; + return [`${primary} Cursor & effects › ${label}${reset}`, '', ...renderColorPicker(state.picker.state, label, columns, env.level)].map(row => truncateAnsi(row, columns)); + } + if (state.gradient) { + const target = state.gradient.target === 'trail' ? 'Trail gradient' : 'Particle gradient'; + return [`${primary} Cursor & effects › ${target}${reset}`, '', ...renderGradientEditorRows(state.gradient.state, target), '', renderControls(gradientEditorControls(state.gradient.state))].map(row => truncateAnsi(row, columns)); + } + if (state.native) { + const native = state.native; + const name = native.host === 'ghostty' ? 'Ghostty' : 'Kitty'; + const lines = [`${primary} Cursor & effects › ${name} native${reset}`, '', + ` ${secondary}${name} draws the cursor effect on the GPU. NMSh adds one line to your ${name} config, once; after that it only updates its own files.${reset}`, '', + ` ${subtle}File${reset} ${primary}${native.configPath}${reset}`]; + if (native.line) lines.push(` ${subtle}Adds${reset} ${foreground(UI_COLORS.success)}+ ${native.line}${reset}`); + lines.push(` ${subtle}Managed${reset} ${secondary}NMSh's own fragment${native.host === 'ghostty' ? ' and shader' : ''} under its config folder${reset}`); + if (native.related.length) lines.push(` ${subtle}Kept${reset} ${secondary}your existing ${native.related.length === 1 ? 'line' : 'lines'}: ${native.related.map(line => line.trim()).join(' · ')}${reset}`); + lines.push('', ` ${subtle}Native effects apply to the whole ${name} surface, including vim and other programs, not only NMSh's input. Reload ${name}'s config to see them.${reset}`); + if (native.blocked) lines.push('', ` ${secondary}${native.blocked}${reset}`); + const yes = native.choice === 'yes'; + lines.push('', ` ${!yes ? `${accent}${GLYPHS.selection} No${reset}` : `${subtle} No${reset}`} ${yes ? `${accent}${GLYPHS.selection} Yes${reset}` : `${subtle} Yes${reset}`}`, '', + renderControls([['←→', 'choose'], ['Enter', 'confirm'], ['Esc', 'cancel']])); + return lines.map(row => truncateAnsi(row, columns)); + } + const rows = visibleRows(state); + const selected = Math.min(state.selected, rows.length - 1); + const resolved = resolveCursorSettings(state.draft, env.context); + const focus = previewFor(state, env); + const shown = renderCursorPreview({scene: focus.scene, title: focus.title, settings: resolved, choice: env.choice, columns, elapsed: now - state.started, still: env.still, + ...(focus.unavailable ? {unavailable: focus.unavailable} : {})}); + state.previewBusy = shown.busy; + + const head = [`${primary} ${state.advanced ? 'Cursor & effects › Advanced' : 'Cursor & effects'}${reset}`, + ` ${subtle}Presentation only: the caret always moves at once. Effects are Off by default.${reset}`, '']; + const detail = rows[selected] ? (rows[selected]!.unavailable?.(state.draft, env) ?? rows[selected]!.detail?.(state.draft, env)) : undefined; + const tail = ['', ` ${secondary}${state.message ?? detail ?? ''}${reset}`, '', renderControls(state.advanced ? [['↑↓', 'select'], ['←→', 'adjust'], ['R', 'replay'], ['Esc', state.embeddedAdvancedOnly ? 'done' : 'back']] + : [['↑↓', 'select'], ['←→', 'change'], ['Enter', 'edit'], ['R', 'replay'], ['A', 'advanced'], ['Esc', state.embedded ? 'done' : 'close']])]; + // Height budget: the preview keeps its size while it fits; a short terminal drops the intro first, then the preview's note and title, then the preview, before the list shrinks. + const layouts: Array<{head: string[]; preview: string[]; tail: string[]}> = [ + {head, preview: [...shown.rows, ''], tail}, + {head: [head[0]!], preview: [...shown.rows, ''], tail}, + {head: [head[0]!], preview: [shown.rows[2]!, shown.rows[3]!], tail: [tail[1]!, tail[3]!]}, + {head: [head[0]!], preview: [], tail: [tail[1]!, tail[3]!]}, + ]; + const need = (layout: typeof layouts[number]) => layout.head.length + layout.preview.length + layout.tail.length + rows.length; + const layout = layouts.find(item => need(item) <= height) ?? layouts[layouts.length - 1]!; + const room = Math.max(3, height - layout.head.length - layout.preview.length - layout.tail.length); + const start = Math.max(0, Math.min(rows.length - room, selected - Math.floor(room / 2))); + const list = rows.slice(start, start + room).map((row, offset) => { + const index = start + offset; + const isSelected = index === selected; + const value = rowValue(row, state, env); + const swatch = value.swatch ? `${colorEscape(38, parseHexColor(value.swatch)!)}■${reset} ` : ''; + const text = row.action ? `${isSelected ? accent : subtle}${value.text}${reset}` + : !value.editable ? `${subtle}${value.text}${reset}` + : isSelected ? `${accent}‹ ${value.text} ›${reset}` : `${secondary}${value.text}${reset}`; + const indent = ['colorFamily', 'colorVariant', 'colorAccent', 'colorCustom', 'trailEdit', 'particleEdit'].includes(row.key) ? ' ' : ''; + return `${isSelected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${focusForeground(isSelected)}${padCells(`${indent}${row.label}`, 22)}${reset}${swatch}${text}`; + }); + return [...layout.head, ...layout.preview, ...list, ...layout.tail].map(row => truncateAnsi(row, columns)); +} diff --git a/src/cursor/CursorPresenter.ts b/src/cursor/CursorPresenter.ts new file mode 100644 index 00000000..fa5ef173 --- /dev/null +++ b/src/cursor/CursorPresenter.ts @@ -0,0 +1,65 @@ +import type {CursorSettings} from '../prompt/configuration.js'; +import {overlayFrame} from '../presentation/cellOverlay.js'; +import {presentationClock} from '../motion/PresentationClock.js'; +import {CursorEngine, type DrawBounds, type MoveCause} from './CursorEngine.js'; +import {effectPalette} from './palette.js'; +import type {BackendChoice} from './backends.js'; + +/** + * Glue between NMSh's frame and the portable cursor engine: one engine, one + * clock subscription that exists only while something animates, and an + * overlay applied to the input rows of an already-built frame. Disabled + * (Reduced Motion, Decorative Effects Off, NO_COLOR, passthrough, a panel) + * means no overlay and no clock at all. + */ +export class CursorPresenter { + readonly engine: CursorEngine; + private stopClock?: () => void; + private interval = 0; + + constructor(private settings: () => CursorSettings, private readonly repaint: () => void, random?: () => number) { + this.engine = new CursorEngine(settings(), random); + } + + /** + * What the Portable engine is asked to draw: everything under Portable, only + * what the host backend does not draw under Auto (never drawn twice), and + * nothing under a forced host renderer. Shape and blink are never the engine's. + */ + private effective(choice?: BackendChoice): CursorSettings { + const settings = this.settings(); + if (!choice) return settings; + const draws = choice.portableDraws; + return {...settings, ...(draws.motion ? {} : {motion: 'off' as const}), ...(draws.effect ? {} : {effect: 'none' as const}), ...(draws.idle ? {} : {idleEffect: 'off' as const})}; + } + + /** + * Overlay the current frame. `caret` is the logical caret's screen cell (or undefined when + * NMSh does not own an editable input right now, which resets everything). + */ + apply(rows: string[], caret: {row: number; column: number} | undefined, bounds: DrawBounds, cause: MoveCause, allowed: boolean, now: number, choice?: BackendChoice): + {rows: string[]; hideCaret: boolean} { + const settings = this.effective(choice); + this.engine.configure(settings); + if (!allowed || !caret || !this.engine.enabled) { this.engine.reset(); this.sync(0); return {rows, hideCaret: false}; } + this.engine.target(caret, now, cause); + this.engine.step(now); + const paints = this.engine.paints(effectPalette(settings), bounds, now); + this.sync(this.engine.cadence(now)); + return {rows: overlayFrame(rows, paints, bounds.columns), hideCaret: this.engine.drawsCaret}; + } + + /** Keep exactly one clock subscription at the needed cadence; none when nothing animates. */ + private sync(fps: number): void { + const interval = fps > 0 ? Math.round(1000 / fps) : 0; + if (interval === this.interval) return; + this.stopClock?.(); + this.stopClock = undefined; + this.interval = interval; + if (interval) this.stopClock = presentationClock.subscribe(() => this.repaint(), interval, 16); + } + + get scheduled(): boolean { return Boolean(this.stopClock); } + + dispose(): void { this.engine.reset(); this.sync(0); } +} diff --git a/src/cursor/CursorPreview.ts b/src/cursor/CursorPreview.ts new file mode 100644 index 00000000..ae546ad9 --- /dev/null +++ b/src/cursor/CursorPreview.ts @@ -0,0 +1,163 @@ +import type {CursorSettings} from '../prompt/configuration.js'; +import {overlayRow, type CellPaint} from '../presentation/cellOverlay.js'; +import {GLYPHS} from '../ui/glyphs.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {truncateAnsi} from '../util/text.js'; +import {CursorEngine, seededRandom} from './CursorEngine.js'; +import {effectPalette} from './palette.js'; +import type {BackendChoice} from './backends.js'; + +/** + * The /cursor (and Setup) preview: a fixed-size, finite, replayable + * demonstration of the SELECTED setting, built from the real pieces. + * + * shape / blink the synthetic caret drawn in the real shape (Block fills + * the cell, Bar and Underline draw their thin glyphs; Host + * default has no shape of its own and is labelled as such) + * idle effect the caret settles, then the idle effect runs + * everything else the CursorEngine, driven through a scripted jump forward + * and back, with the same overlay the composer uses + * + * Pure: the frame is a function of (settings, elapsed), so a selection or a + * value change just restarts the clock, R replays, and the same inputs always + * give the same frame. After `PREVIEW_END_MS` the last frame is held and no + * frame clock is needed. It never touches the composer, a transcript or the + * terminal's real cursor. + */ +export type PreviewScene = 'shape' | 'blink' | 'idle' | 'jump'; + +/** Title, the rule above the composer, the sample row, a caption and a note: always this many, so the panel never changes height. */ +export const PREVIEW_ROWS = 5; +export const PREVIEW_END_MS = 2800; +export const BLINK_PERIOD_MS = 530; +export const BLINK_DEMO_MS = 3200; + +const SAMPLE = 'git commit -m "ship it"'; +const BASE = 6; +const START = 3; +/** Row 0 is the rule above the composer, as in the real frame (effects may reach one row above the input). */ +const SAMPLE_ROW = 1; +const JUMPS: Array<{at: number; column: number}> = [{at: 0, column: START}, {at: 250, column: SAMPLE.length}, {at: 1400, column: START}]; + +export interface CursorPreviewInput { + scene: PreviewScene; + /** The setting being demonstrated, for the title ("Shape: Bar"). */ + title: string; + /** Draft settings with theme-derived colors already resolved. */ + settings: CursorSettings; + choice: BackendChoice; + columns: number; + /** Milliseconds since the preview was (re)started. */ + elapsed: number; + /** Decorative motion is not allowed (Reduced Motion, Decorative Effects Off, NO_COLOR): show the settled frame. */ + still: boolean; + /** Why this scene cannot demonstrate (an unavailable value): the caption says so and nothing animates. */ + unavailable?: string; +} + +export interface CursorPreview { + rows: string[]; + busy: boolean; + /** The caret cell as painted, for tests and captions. */ + caption: string; +} + +/** The synthetic caret in the draft's shape, or the host-default placeholder. */ +export function shapePaint(settings: CursorSettings, color: {red: number; green: number; blue: number}, hostAsBlock = false): CellPaint { + // Host default has no shape NMSh can draw. The shape and blink scenes label it with a placeholder; the others + // (motion, effects, colors) draw the caret NMSh itself draws while it moves: a block. + if (settings.shape === 'host') return hostAsBlock ? {caret: true, caretShape: 'block', color} : {glyph: '▯', foreground: UI_COLORS.subtle}; + return {caret: true, caretShape: settings.shape, color}; +} + +const SHAPE_CAPTION: Record = { + host: 'Host default · your terminal draws its own cursor; NMSh sends no shape (▯ is only a placeholder)', + block: 'Block · fills the cell', + bar: 'Bar · a thin line at the cell\'s left edge', + underline: 'Underline · a line under the cell', +}; + +function simulate(settings: CursorSettings, scene: PreviewScene, elapsed: number): CursorEngine { + // The idle demo starts at once instead of waiting out the real dwell time. + const demo: CursorSettings = scene === 'idle' ? {...settings, advanced: {...settings.advanced, dwellMs: 0}} : settings; + const engine = new CursorEngine(demo, seededRandom(7)); + const end = Math.min(elapsed, PREVIEW_END_MS); + const jumps = scene === 'jump' ? JUMPS : [{at: 0, column: SAMPLE.length}]; + let next = 0; + for (let time = 0; time <= end; time += 16) { + while (next < jumps.length && jumps[next]!.at <= time) { + engine.target({row: SAMPLE_ROW, column: BASE + jumps[next]!.column}, time, next === 0 ? 'typing' : 'jump'); + next += 1; + } + engine.step(time); + } + return engine; +} + +export function renderCursorPreview(input: CursorPreviewInput): CursorPreview { + const {scene, settings, choice, columns, still} = input; + const subtle = foreground(UI_COLORS.subtle); + const secondary = foreground(UI_COLORS.secondary); + const primary = foreground(UI_COLORS.primary); + const accent = foreground(UI_COLORS.accent); + const reset = '\u001b[0m'; + const palette = effectPalette(settings); + const sample = ` ${accent}${GLYPHS.prompt}${reset} ${primary}${SAMPLE}${reset}`; + const width = Math.max(10, columns - 2); + const rule = ` ${subtle}${(GLYPHS.separator || '─').repeat(Math.max(4, Math.min(width - 4, 40)))}${reset}`; + const elapsed = Math.max(0, input.elapsed); + const settled = {row: SAMPLE_ROW, column: BASE + SAMPLE.length}; + const caretPaint = shapePaint(settings, palette.caret, scene !== 'shape' && scene !== 'blink'); + let caption = ''; + let note = ''; + let busy = false; + let paints = new Map(); + let above = new Map(); + + if (input.unavailable) { + paints.set(settled.column, caretPaint); + caption = `Unavailable · ${input.unavailable}`; + note = 'Nothing is drawn for this setting with the current renderer.'; + } else if (scene === 'shape') { + paints.set(settled.column, caretPaint); + caption = SHAPE_CAPTION[settings.shape]; + note = 'The real cursor is the terminal\'s own; NMSh sets only its shape while it owns the composer.'; + } else if (scene === 'blink') { + const blinking = settings.shape !== 'host' && settings.blink === 'on' && !still; + const visible = !blinking || elapsed >= BLINK_DEMO_MS || Math.floor(elapsed / BLINK_PERIOD_MS) % 2 === 0; + if (visible) paints.set(settled.column, caretPaint); + busy = blinking && elapsed < BLINK_DEMO_MS; + caption = settings.shape === 'host' ? 'Host default · your terminal decides whether the cursor blinks' + : settings.blink === 'host' ? 'Blink as the terminal does by default (steady shown here)' + : settings.blink === 'on' ? `Blink On · blinks for a moment here${still ? ' (Reduced Motion: steady)' : ''}; your terminal paces the real blink` + : 'Blink Off · steady'; + note = elapsed >= BLINK_DEMO_MS || !busy ? 'R replays' : 'The demo stops by itself.'; + } else if (still) { + paints.set(settled.column, caretPaint); + caption = 'Effects stay still (Reduced Motion, Decorative Effects Off or no color): the settled caret'; + note = 'Turn motion back on to see this setting move.'; + } else { + const engine = simulate(settings, scene, elapsed); + const frames = engine.paints(palette, {top: 0, bottom: SAMPLE_ROW, columns: width}, Math.min(elapsed, PREVIEW_END_MS)); + paints = new Map(frames.get(SAMPLE_ROW) ?? new Map()); + above = new Map(frames.get(0) ?? new Map()); + if (!engine.drawsCaret) { + const column = engine.caretCell?.column ?? settled.column; + paints.set(column, {...paints.get(column), ...caretPaint}); + } + busy = elapsed < PREVIEW_END_MS; + const phase = engine.phase === 'movement' ? 'Movement' : engine.phase === 'settling' ? 'Settling' : 'Resting'; + const cap = (value: string) => `${value[0]!.toUpperCase()}${value.slice(1)}`; + const handled = [...(choice.nativeHandles.motion ? [cap(settings.motion)] : []), ...(choice.nativeHandles.effect ? [cap(settings.effect)] : [])]; + const portable = choice.portableDraws.motion || choice.portableDraws.effect || choice.portableDraws.idle; + const source = handled.length && choice.native ? `Portable rendering; ${choice.native.host} also draws its own GPU ${handled.join(' + ')}` + : portable ? 'Portable renderer' : 'No renderer draws effects with this selection'; + caption = `${busy ? (scene === 'idle' ? 'Idle' : phase) : scene === 'idle' ? 'Idle effect running' : 'Settled'} · ${source}`; + note = busy ? 'Replays on every change; R replays now.' : 'R replays'; + } + + const row = paints.size ? overlayRow(sample, paints, width) : sample; + const ruleRow = above.size ? overlayRow(rule, above, width) : rule; + const rows = [` ${subtle}Preview · ${input.title}${reset}`, ruleRow, row, ` ${secondary}${caption}${reset}`, ` ${subtle}${note}${reset}`]; + return {rows: rows.map(line => truncateAnsi(line, columns)), busy, caption}; +} diff --git a/src/cursor/backends.ts b/src/cursor/backends.ts new file mode 100644 index 00000000..d0406290 --- /dev/null +++ b/src/cursor/backends.ts @@ -0,0 +1,184 @@ +import type {CursorEffect, CursorIdleEffect, CursorMotion, CursorRenderer, CursorSettings} from '../prompt/configuration.js'; + +/** + * Cursor-effect backends. Portable works wherever NMSh owns its input + * (terminal cells only). Host-native backends use a terminal's own supported + * GPU features and only where they are installed and configured; nothing + * here injects anything into a host that does not document the feature. + */ +export type CursorFeature = 'motion' | 'effect' | 'idleEffect'; + +/** + * What a backend REALLY implements, by named value. This is the capability + * matrix: a feature is listed only when the generated implementation (the + * Portable engine, NMSh's Ghostty shader, Kitty's cursor_trail) draws that + * named thing. Broad flags such as "custom shader" prove nothing about Fire, + * Lightning or an idle glow, so they are never consulted. + */ +export interface CursorSupport { + motion: readonly CursorMotion[]; + effect: readonly CursorEffect[]; + idleEffect: readonly CursorIdleEffect[]; +} + +export interface CursorEffectBackend { + id: 'portable' | 'ghostty' | 'kitty'; + label: string; + /** The host's own name for messages ("Ghostty"); Portable has none. */ + host?: string; + support: CursorSupport; + /** Native effects apply to the whole terminal surface (vim, less…), not only NMSh's input. */ + surfaceWide: boolean; +} + +export const PORTABLE_BACKEND: CursorEffectBackend = {id: 'portable', label: 'Portable', surfaceWide: false, + support: {motion: ['smooth', 'smear', 'tail'], effect: ['fire', 'sparks', 'lightning', 'railgun', 'ripple', 'wireframe'], idleEffect: ['glow', 'embers', 'flame', 'sparks']}}; +/** + * Ghostty ≥ 1.2: custom-shader with the cursor uniforms (iCurrentCursor, iPreviousCursor, iTimeCursorChange); shaders stack. + * NMSh's shader draws Smear and Tail, and the Fire, Sparks and Ripple effects. It does not draw Smooth (the real caret + * jumps), Lightning, Railgun, Wireframe, or any idle effect, and says so instead of pretending. + */ +export const GHOSTTY_BACKEND: CursorEffectBackend = {id: 'ghostty', label: 'Ghostty native', host: 'Ghostty', surfaceWide: true, + support: {motion: ['smear', 'tail'], effect: ['fire', 'sparks', 'ripple'], idleEffect: []}}; +/** Kitty ≥ 0.37: the built-in cursor_trail (a tail with decay and color); no custom cursor shaders, so no effects. */ +export const KITTY_BACKEND: CursorEffectBackend = {id: 'kitty', label: 'Kitty native', host: 'Kitty', surfaceWide: true, support: {motion: ['tail'], effect: [], idleEffect: []}}; + +export interface HostCursorFacts { + host: 'ghostty' | 'kitty' | 'other'; + /** A friendly name for the terminal when it is recognised ("Zed", "Terminal.app"); messages use it. */ + hostName?: string; + /** + * Who decides the color of the PHYSICAL caret. NMSh never sets it (no terminal cursor-color sequence is sent), so it is the + * host's own; Zed paints it from the active theme's cursor color and ignores a terminal's color request. + * NMSh's cursor color tints its own effects, trails and previews. + */ + caretColor: 'host-controlled' | 'theme-controlled'; + version?: string; + /** NMSh's managed native integration is installed in the host's config (include line + managed file). */ + integrated: boolean; +} + +const KNOWN_HOSTS: Record = {zed: 'Zed', Apple_Terminal: 'Terminal.app', 'iTerm.app': 'iTerm2', WezTerm: 'WezTerm', vscode: 'VS Code', ghostty: 'Ghostty'}; + +export function hostCursorFacts(env: NodeJS.ProcessEnv, integrated: (host: 'ghostty' | 'kitty') => boolean): HostCursorFacts { + const hostName = KNOWN_HOSTS[env.TERM_PROGRAM ?? '']; + const caretColor = env.TERM_PROGRAM === 'zed' ? 'theme-controlled' as const : 'host-controlled' as const; + if (env.TERM_PROGRAM === 'ghostty' || env.TERM === 'xterm-ghostty') return {host: 'ghostty', hostName: 'Ghostty', caretColor, ...(env.TERM_PROGRAM_VERSION ? {version: env.TERM_PROGRAM_VERSION} : {}), integrated: integrated('ghostty')}; + if (env.KITTY_WINDOW_ID || env.TERM === 'xterm-kitty') return {host: 'kitty', hostName: 'Kitty', caretColor, integrated: integrated('kitty')}; + return {host: 'other', ...(hostName ? {hostName} : {}), caretColor, integrated: false}; +} + +/** + * What "Cursor color" does and does not do on this host, in one sentence. It colors NMSh's own effects, trails and + * previews (and, once set up, Ghostty's or Kitty's native trail); the physical caret keeps the host's color. + */ +export function caretColorNote(facts: HostCursorFacts, source: string): string { + const who = facts.hostName ?? 'Your terminal'; + if (source === 'host') return `Host: ${who} draws the caret in its own color; effects borrow a neutral tone.`; + const how = facts.caretColor === 'theme-controlled' ? `in your ${who} theme's cursor color` : 'in its own color'; + const native = facts.integrated && facts.host !== 'other' ? ' and the native trail' : ''; + return `Colors NMSh's effects, trails${native} and previews. ${who} draws the physical caret ${how}; NMSh does not change it.`; +} + +const atLeast = (version: string | undefined, major: number, minor: number) => { + const match = /^(\d+)\.(\d+)/u.exec(version ?? ''); + return Boolean(match && (Number(match[1]) > major || (Number(match[1]) === major && Number(match[2]) >= minor))); +}; + +/** Whether this host can do native cursor effects at all (documented feature present), regardless of setup. */ +export function nativeBackendFor(facts: HostCursorFacts): CursorEffectBackend | undefined { + if (facts.host === 'ghostty' && (facts.version === undefined || atLeast(facts.version, 1, 2))) return GHOSTTY_BACKEND; + if (facts.host === 'kitty') return KITTY_BACKEND; + return undefined; +} + +export interface BackendChoice { + backend: CursorEffectBackend; + /** Shown in /cursor: why this backend is active. */ + reason: string; + /** What the native backend draws, so the portable engine skips it (no double rendering). */ + nativeHandles: {motion: boolean; effect: boolean}; + /** What the Portable engine is asked to draw: everything, only what native does not, or nothing (a forced host renderer). */ + portableDraws: {motion: boolean; effect: boolean; idle: boolean}; + /** The host's native backend when this terminal has one (set up or not), for messages. */ + native?: CursorEffectBackend; +} + +const supports = (backend: CursorEffectBackend, feature: CursorFeature, value: string): boolean => (backend.support[feature] as readonly string[]).includes(value); +const isOff = (value: string) => value === 'off' || value === 'none'; + +/** + * Auto uses native only when NMSh's managed integration is present on a + * supporting host; Portable draws whatever native does not. Forced Host + * native draws only what the host backend does, and nothing at all where the + * host has none. Portable is always available. The person never ends up + * unsure which renderer is drawing. + */ +export function chooseBackend(settings: CursorSettings, facts: HostCursorFacts): BackendChoice { + const native = nativeBackendFor(facts); + const all = {motion: true, effect: true, idle: true}; + const nothing = {motion: false, effect: false, idle: false}; + const none = {motion: false, effect: false}; + if (settings.renderer === 'portable') return {backend: PORTABLE_BACKEND, reason: 'Portable (chosen)', nativeHandles: none, portableDraws: all, ...(native ? {native} : {})}; + if (!native) { + if (settings.renderer === 'native') return {backend: PORTABLE_BACKEND, reason: 'Host native is not available in this terminal; no effects are drawn', nativeHandles: none, portableDraws: nothing}; + return {backend: PORTABLE_BACKEND, reason: 'Portable (this terminal has no native cursor effects)', nativeHandles: none, portableDraws: all}; + } + if (!facts.integrated) { + return {backend: PORTABLE_BACKEND, native, nativeHandles: none, + reason: settings.renderer === 'native' ? `${native.label} needs setup (shown before anything changes); no effects are drawn until then` : `Portable · ${native.label} is available after setup`, + portableDraws: settings.renderer === 'native' ? nothing : all}; + } + const motion = !isOff(settings.motion) && supports(native, 'motion', settings.motion); + const effect = !isOff(settings.effect) && supports(native, 'effect', settings.effect); + const forced = settings.renderer === 'native'; + const fallback = !forced && ((!isOff(settings.motion) && !motion) || (!isOff(settings.effect) && !effect) || !isOff(settings.idleEffect)); + return {backend: native, native, nativeHandles: {motion, effect}, + reason: `${native.label}${fallback ? ' with Portable fallback for what it does not draw' : ''}`, + portableDraws: forced ? nothing : {motion: !motion, effect: !effect, idle: true}}; +} + +/** How one value of one feature is delivered, or why it cannot be. */ +export type Availability = + | {available: true; via: 'none' | 'portable' | 'native' | 'native-pending' | 'portable-fallback'; suffix: string} + | {available: false; reason: string}; + +const FEATURE_WORD: Record = {motion: 'motion', effect: 'effect', idleEffect: 'idle effect'}; + +/** + * Whether a value can actually work under the effective renderer. Forced Host + * native offers only what the host backend draws; Auto offers everything and + * says plainly when a native host is active but Portable draws it instead. + */ +export function availabilityOf(feature: CursorFeature, value: string, renderer: CursorRenderer, facts: HostCursorFacts): Availability { + if (isOff(value)) return {available: true, via: 'none', suffix: ''}; + if (renderer === 'portable') return {available: true, via: 'portable', suffix: ''}; + const native = nativeBackendFor(facts); + if (renderer === 'native') { + if (!native) return {available: false, reason: 'Host native is not available in this terminal.'}; + if (!supports(native, feature, value)) return {available: false, reason: `${native.host ?? native.label} Native does not provide this ${FEATURE_WORD[feature]}.`}; + return facts.integrated ? {available: true, via: 'native', suffix: ''} : {available: true, via: 'native-pending', suffix: `after ${native.host} setup`}; + } + if (native && facts.integrated) return supports(native, feature, value) ? {available: true, via: 'native', suffix: ''} : {available: true, via: 'portable-fallback', suffix: 'Portable fallback'}; + return {available: true, via: 'portable', suffix: ''}; +} + +/** The values of a feature that can work right now, in their registry order. */ +export function availableValues(feature: CursorFeature, values: readonly T[], renderer: CursorRenderer, facts: HostCursorFacts): T[] { + return values.filter(value => availabilityOf(feature, value, renderer, facts).available); +} + +/** Why a feature offers nothing beyond Off/None under this renderer (undefined when something works). */ +export function unavailableReason(feature: CursorFeature, values: readonly T[], renderer: CursorRenderer, facts: HostCursorFacts): string | undefined { + if (availableValues(feature, values, renderer, facts).some(value => !isOff(value))) return undefined; + const sample = values.find(value => !isOff(value)); + const result = sample ? availabilityOf(feature, sample, renderer, facts) : undefined; + return result && !result.available ? result.reason : undefined; +} + +/** The facts the app currently knows; Setup and Settings rows read them without owning host detection. */ +let currentFacts: HostCursorFacts | undefined; +let factsProvider: () => HostCursorFacts = () => ({host: 'other', caretColor: 'host-controlled', integrated: false}); +export function setCursorHostProvider(provider: (() => HostCursorFacts) | undefined): void { factsProvider = provider ?? (() => ({host: 'other', caretColor: 'host-controlled', integrated: false})); currentFacts = undefined; } +export function currentCursorHost(): HostCursorFacts { return currentFacts ?? factsProvider(); } +export function setCursorHostFacts(facts: HostCursorFacts | undefined): void { currentFacts = facts; } diff --git a/src/cursor/colors.ts b/src/cursor/colors.ts new file mode 100644 index 00000000..b5b2ea68 --- /dev/null +++ b/src/cursor/colors.ts @@ -0,0 +1,57 @@ +import type {CursorSettings, NativePaletteId, PromptConfiguration} from '../prompt/configuration.js'; +import {hexColor} from '../chroma/color.js'; +import type {CatppuccinAccent} from '../appearance/themeFamilies.js'; +import type {CustomTheme} from '../appearance/customTheme.js'; +import {themeAccentColor} from '../appearance/themeColor.js'; +import {UI_COLORS, type RgbColor} from '../ui/palette.js'; + +/** + * Cursor colors resolved against the real theme system. The stored source + * says WHERE a color comes from; this turns it into a color once, so every + * consumer (Portable engine, preview, Ghostty shader, Kitty fragment) sees + * the same value and nothing falls back to a hard-coded accent. + * + * host the terminal's own caret color (no color of its own) + * theme Follow current theme: the prompt's theme accent + * chosen Choose theme: a theme (and Catppuccin accent) picked for the cursor alone + * accent NMSh's UI accent as it is drawn now (follows UI chrome) + * custom a validated #RRGGBB + */ +export interface CursorColorContext { + palette: NativePaletteId; + accent: CatppuccinAccent; + customTheme?: CustomTheme | undefined; + /** NMSh's UI accent right now (UI_COLORS.accent when omitted). */ + chrome?: RgbColor; +} + +export const contextFor = (config: Pick): CursorColorContext => + ({palette: config.nmsh.palette, accent: config.nmsh.accent, customTheme: config.customTheme}); + +/** The selected color, or undefined for Host (the terminal draws its own). */ +export function resolveCursorColor(settings: CursorSettings, context: CursorColorContext): RgbColor | undefined { + const {color} = settings; + switch (color.source) { + case 'host': return undefined; + case 'accent': return {...(context.chrome ?? UI_COLORS.accent)}; + case 'theme': return themeAccentColor(context.palette, context.accent, context.customTheme); + case 'chosen': return themeAccentColor(color.theme ?? 'lavender', color.themeAccent ?? 'mauve', context.customTheme); + case 'custom': return color.custom ? hexRgb(color.custom) : undefined; + } +} + +const hexRgb = (hex: string): RgbColor => ({red: parseInt(hex.slice(1, 3), 16), green: parseInt(hex.slice(3, 5), 16), blue: parseInt(hex.slice(5, 7), 16)}); + +/** Settings whose color is concrete: theme-derived sources become `custom` with the resolved hex. Downstream code never resolves themes. */ +export function resolveCursorSettings(settings: CursorSettings, context: CursorColorContext): CursorSettings { + const resolved = resolveCursorColor(settings, context); + if (settings.color.source === 'host' || settings.color.source === 'custom' || !resolved) return settings; + return {...settings, color: {source: 'custom', custom: hexColor(resolved)}}; +} + +/** The color to show as the swatch for a settings value, and what it is called. */ +export function describeCursorColor(settings: CursorSettings, context: CursorColorContext): {hex?: string; label: string} { + const resolved = resolveCursorColor(settings, context); + const labels = {host: 'Host (your terminal draws its own)', theme: 'Follow current theme', chosen: 'Chosen theme', accent: 'NMSh accent', custom: 'Custom'} as const; + return {...(resolved ? {hex: hexColor(resolved)} : {}), label: labels[settings.color.source]}; +} diff --git a/src/cursor/native.ts b/src/cursor/native.ts new file mode 100644 index 00000000..efbb8d11 --- /dev/null +++ b/src/cursor/native.ts @@ -0,0 +1,206 @@ +import {existsSync, mkdirSync, readFileSync, renameSync, writeFileSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {dirname, join} from 'node:path'; +import type {CursorSettings} from '../prompt/configuration.js'; +import {nmshConfigDirectory} from '../configuration/paths.js'; +import {inspectFile, planAppend, planCreate, type PlanResult} from '../ask/fileEdit.js'; +import {effectPalette} from './palette.js'; +import {GHOSTTY_BACKEND} from './backends.js'; + +/** + * Host-native cursor effects through each terminal's own documented + * features, set up once with a verified edit: + * + * Ghostty one optional include line in its config (`config-file = ?…`) + * pointing at an NMSh-managed fragment with `custom-shader` + * (shaders stack, so the person's own shaders keep working). + * Kitty one `include …` line pointing at an NMSh-managed fragment with + * the built-in `cursor_trail` options. + * + * After that first edit (shown and confirmed), NMSh only rewrites its own + * fragment and shader files. It never removes or replaces the person's own + * shader, theme, font or keybinding lines. Native effects apply to the whole + * terminal surface (including vim); the setup screen says so. + */ +export type NativeHost = 'ghostty' | 'kitty'; + +export const nativeHostLabel = (host: NativeHost) => host === 'ghostty' ? 'Ghostty' : 'Kitty'; + +export function managedDirectory(env: NodeJS.ProcessEnv = process.env): string { + return join(nmshConfigDirectory(env), 'cursor'); +} + +export function fragmentPath(host: NativeHost, env: NodeJS.ProcessEnv = process.env): string { + return join(managedDirectory(env), host === 'ghostty' ? 'ghostty-cursor.conf' : 'kitty-cursor.conf'); +} + +export function shaderPath(env: NodeJS.ProcessEnv = process.env): string { + return join(managedDirectory(env), 'nmsh-cursor.glsl'); +} + +/** The host's main config file (the one place the single include line goes). */ +export function hostConfigPath(host: NativeHost, env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, home = homedir()): string { + const xdg = env.XDG_CONFIG_HOME || join(home, '.config'); + if (host === 'kitty') return join(env.KITTY_CONFIG_DIRECTORY || join(xdg, 'kitty'), 'kitty.conf'); + const xdgPath = join(xdg, 'ghostty', 'config'); + const macPath = join(home, 'Library', 'Application Support', 'com.mitchellh.ghostty', 'config'); + return platform === 'darwin' && !existsSync(xdgPath) && existsSync(macPath) ? macPath : xdgPath; +} + +export function includeLine(host: NativeHost, env: NodeJS.ProcessEnv = process.env): string { + // Ghostty's "?" prefix makes the include optional: removing NMSh's folder never breaks the person's config. + return host === 'ghostty' ? `config-file = ?${fragmentPath('ghostty', env)}` : `include ${fragmentPath('kitty', env)}`; +} + +export function nativeCursorIntegrated(host: NativeHost, env: NodeJS.ProcessEnv = process.env): boolean { + try { + const config = readFileSync(hostConfigPath(host, env), 'utf8'); + return config.split('\n').some(line => line.trim() === includeLine(host, env)) && existsSync(fragmentPath(host, env)); + } catch { return false; } +} + +/** Lines in the person's own config that touch the same feature: shown before setup, never edited. */ +export function relatedSettings(host: NativeHost, env: NodeJS.ProcessEnv = process.env): string[] { + try { + const pattern = host === 'ghostty' ? /^\s*custom-shader(?:-animation)?\s*=/u : /^\s*cursor_trail\w*\s/u; + return readFileSync(hostConfigPath(host, env), 'utf8').split('\n').filter(line => pattern.test(line)).slice(0, 8); + } catch { return []; } +} + +/** + * The one verified edit setup needs: append the include line (or create the + * config with only that line). Everything else NMSh writes is its own file. + */ +export function setupPlan(host: NativeHost, env: NodeJS.ProcessEnv = process.env, home = homedir()): {configPath: string; plan: PlanResult; related: string[]} { + const configPath = hostConfigPath(host, env, process.platform, home); + const roots = [home]; + const facts = inspectFile(configPath, roots); + const line = includeLine(host, env); + const plan = facts.refusal === 'does not exist' ? planCreate(configPath, `${line}\n`, roots) : planAppend(facts, 'text', `# NMSh cursor effects (managed file; remove this line to turn them off)\n${line}`); + return {configPath, plan, related: relatedSettings(host, env)}; +} + +const hex = (color: {red: number; green: number; blue: number}) => `#${[color.red, color.green, color.blue].map(value => Math.round(value).toString(16).padStart(2, '0')).join('')}`; +const vec3 = (color: {red: number; green: number; blue: number}) => `vec3(${(color.red / 255).toFixed(3)}, ${(color.green / 255).toFixed(3)}, ${(color.blue / 255).toFixed(3)})`; + +/** The managed fragment for a host, from the person's cursor settings. */ +export function fragmentContent(host: NativeHost, settings: CursorSettings, env: NodeJS.ProcessEnv = process.env): string { + const header = '# Managed by NMSh (/cursor). Edits here are replaced; your own config is never changed by NMSh after setup.\n'; + if (host === 'ghostty') { + // Only what NMSh's shader really draws counts: Smooth, Lightning, Railgun, Wireframe and idle effects leave the shader off. + const active = GHOSTTY_BACKEND.support.motion.includes(settings.motion) || GHOSTTY_BACKEND.support.effect.includes(settings.effect); + return `${header}${active ? `custom-shader = ${shaderPath(env)}\ncustom-shader-animation = true\n` : ''}`; + } + const palette = effectPalette(settings); + const decay = {low: '0.15 0.5', medium: '0.1 0.4', high: '0.05 0.25'}[settings.speed]; + const trail = settings.motion === 'off' ? 0 : 3; + return `${header}cursor_trail ${trail}\ncursor_trail_decay ${decay}\ncursor_trail_start_threshold ${Math.max(1, Math.round(settings.advanced.moveThreshold + 1))}\n` + + (settings.trail.source !== 'cursor' || settings.color.source !== 'host' ? `cursor_trail_color ${hex(palette.trail[0] ?? palette.caret)}\n` : ''); +} + +/** + * NMSh's own Ghostty cursor shader (GPL-3.0-only, written for NMSh; no + * third-party shader code). It uses Ghostty's documented cursor uniforms: + * the smear/tail is a quad between the previous and current cursor rects + * fading after the change; Fire adds a short flickering glow above the + * caret; Sparks scatter along the path; Ripple a ring at the destination. + * Nothing else is drawn (see GHOSTTY_BACKEND.support). + */ +export function shaderSource(settings: CursorSettings): string { + const palette = effectPalette(settings); + const duration = (settings.advanced.longMoveMs * {low: 1.45, medium: 1, high: 0.65}[settings.speed] / 1000).toFixed(3); + const tail = settings.motion === 'tail' ? 1 : 0; + const smear = settings.motion === 'smear' || settings.motion === 'tail' ? 1 : 0; + const fire = settings.effect === 'fire' ? 1 : 0; + const ripple = settings.effect === 'ripple' ? 1 : 0; + const sparks = settings.effect === 'sparks' ? 1 : 0; + return `// NMSh cursor effect for Ghostty. Generated by NMSh from /cursor settings; GPL-3.0-only. +// Uses Ghostty's cursor uniforms: iCurrentCursor, iPreviousCursor (xy = top-left, zw = size, pixels) and iTimeCursorChange. +const float DURATION = ${duration}; +const vec3 TRAIL = ${vec3(palette.trail[0] ?? palette.caret)}; +const vec3 HOT = ${vec3(palette.particles[0] ?? palette.caret)}; +const vec3 COOL = ${vec3(palette.particles[palette.particles.length - 1] ?? palette.caret)}; + +float segmentDistance(vec2 p, vec2 a, vec2 b) { + vec2 pa = p - a, ba = b - a; + float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-4), 0.0, 1.0); + return length(pa - ba * h); +} +float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec4 base = texture(iChannel0, fragCoord / iResolution.xy); + vec2 current = iCurrentCursor.xy + vec2(iCurrentCursor.z * 0.5, -iCurrentCursor.w * 0.5); + vec2 previous = iPreviousCursor.xy + vec2(iPreviousCursor.z * 0.5, -iPreviousCursor.w * 0.5); + float age = iTime - iTimeCursorChange; + float progress = clamp(age / DURATION, 0.0, 1.0); + float fade = 1.0 - progress; + vec3 color = base.rgb; + float width = max(iCurrentCursor.z, 1.0) * 0.5; + if (${smear} == 1 && fade > 0.0) { + vec2 head = mix(previous, current, smoothstep(0.0, 1.0, progress * 1.6)); + float d = segmentDistance(fragCoord, previous, head); + float along = clamp(dot(fragCoord - previous, head - previous) / max(dot(head - previous, head - previous), 1e-4), 0.0, 1.0); + float taper = ${tail} == 1 ? pow(along, 1.6) : 1.0; + float band = smoothstep(width * (0.4 + 0.6 * taper), 0.0, d) * fade * taper; + color = mix(color, TRAIL, band * 0.75); + } + if (${fire} == 1) { + vec2 rel = fragCoord - current; + float flame = smoothstep(iCurrentCursor.w * 1.4, 0.0, length(rel * vec2(1.6, 0.8) - vec2(0.0, iCurrentCursor.w * 0.4))); + float flicker = 0.75 + 0.25 * sin(iTime * 23.0 + hash(floor(fragCoord / 3.0)) * 6.28); + float heat = flame * flicker * (0.35 + 0.65 * fade); + color = mix(color, mix(COOL, HOT, flame), heat * 0.6); + } + if (${sparks} == 1 && fade > 0.0) { + vec2 cell = floor(fragCoord / 4.0); + float spark = step(0.985, hash(cell + floor(iTime * 30.0))) * smoothstep(iCurrentCursor.z * 4.0, 0.0, segmentDistance(fragCoord, previous, current)); + color = mix(color, HOT, spark * fade); + } + if (${ripple} == 1 && fade > 0.0) { + float ring = abs(length(fragCoord - current) - progress * iCurrentCursor.z * 6.0); + color = mix(color, TRAIL, smoothstep(2.0, 0.0, ring) * fade * 0.6); + } + fragColor = vec4(color, base.a); +} +`; +} + +export type ManagedFile = 'fragment' | 'shader'; + +/** What actually changed on disk when managed files were refreshed. Identical files are not rewritten. */ +export interface ManagedWrite {changed: ManagedFile[]} + +/** Write NMSh's own managed files (fragment and shader), only the ones whose content differs. Never touches the host's main config. */ +export function writeManagedFiles(host: NativeHost, settings: CursorSettings, env: NodeJS.ProcessEnv = process.env): ManagedWrite { + const changed: ManagedFile[] = []; + const write = (kind: ManagedFile, path: string, content: string) => { + try { if (readFileSync(path, 'utf8') === content) return; } catch { /* missing: written below */ } + mkdirSync(dirname(path), {recursive: true, mode: 0o700}); + const temporary = `${path}.${process.pid}.tmp`; + writeFileSync(temporary, content, {mode: 0o644}); + renameSync(temporary, path); + changed.push(kind); + }; + if (host === 'ghostty') write('shader', shaderPath(env), shaderSource(settings)); + write('fragment', fragmentPath(host, env), fragmentContent(host, settings, env)); + return {changed}; +} + +/** + * The one line to show after managed files changed, or undefined when nothing + * needs doing. NMSh never restarts the terminal and never signals its process + * (an unhandled signal would end the window): a changed fragment is part of + * the host's configuration, so the person reloads it with the host's own + * documented shortcut; a shader-only change leaves the configuration alone, + * and the line says what to do if the terminal has not picked it up. + */ +export function reloadInstruction(host: NativeHost, write: ManagedWrite, platform: NodeJS.Platform = process.platform): string | undefined { + if (!write.changed.length) return undefined; + const mac = platform === 'darwin'; + if (host === 'ghostty') { + if (write.changed.includes('fragment')) return `Reload Ghostty config: ${mac ? '⌘⇧,' : 'Ctrl+Shift+,'}`; + return `Cursor shader updated · if it does not apply, reload Ghostty config: ${mac ? '⌘⇧,' : 'Ctrl+Shift+,'}`; + } + return `Reload Kitty config: ${mac ? '⌃⌘,' : 'Ctrl+Shift+F5'}`; +} diff --git a/src/cursor/palette.ts b/src/cursor/palette.ts new file mode 100644 index 00000000..9bebe2e3 --- /dev/null +++ b/src/cursor/palette.ts @@ -0,0 +1,38 @@ +import type {CursorSettings} from '../prompt/configuration.js'; +import {UI_COLORS, type RgbColor} from '../ui/palette.js'; +import type {EffectPalette} from './CursorEngine.js'; + +/** + * Colors for cursor effects. Each effect has a default gradient that reads + * as itself (fire hot→cool, sparks bright→cool), but none is fixed: the trail + * and particles follow the caret color, a custom color, or a custom gradient, + * so purple fire or green sparks are one setting away. + */ +export const hexToRgb = (hex: string): RgbColor => ({red: parseInt(hex.slice(1, 3), 16), green: parseInt(hex.slice(3, 5), 16), blue: parseInt(hex.slice(5, 7), 16)}); + +const DEFAULT_GRADIENTS: Record = { + none: [], fire: ['#ffe08a', '#ff9a3c', '#e8452c', '#5a1a14'], sparks: ['#ffffff', '#9ae6ff', '#4a8cff'], + lightning: ['#f2f7ff', '#8fb8ff', '#5a5cff'], railgun: ['#e8fbff', '#55d6ff', '#2b6cff'], ripple: [], wireframe: [], +}; + +/** + * Palette for a settings value. Theme-derived color sources are resolved + * beforehand (see colors.ts), so a `custom` color here is already the real + * selected theme color; a source that was never resolved falls back to NMSh's + * accent, and Host stands in with the primary text color (the terminal draws + * its own caret, so Portable effects only borrow a neutral tone). + */ +export function effectPalette(settings: CursorSettings, accent: RgbColor = UI_COLORS.accent): EffectPalette { + const caret = settings.color.source === 'custom' && settings.color.custom ? hexToRgb(settings.color.custom) + : settings.color.source === 'host' ? UI_COLORS.primary : accent; + // Idle effects borrow the matching movement gradient when there is no movement effect (Flame and Embers read as fire, Sparks as sparks). + const look: CursorSettings['effect'] = settings.effect !== 'none' ? settings.effect : settings.idleEffect === 'flame' || settings.idleEffect === 'embers' ? 'fire' : settings.idleEffect === 'sparks' ? 'sparks' : 'none'; + const effectGradient = DEFAULT_GRADIENTS[look].map(hexToRgb); + const trail = settings.trail.source === 'custom' && settings.trail.colors[0] ? [hexToRgb(settings.trail.colors[0])] + : settings.trail.source === 'gradient' && settings.trail.colors.length ? settings.trail.colors.map(hexToRgb) + : effectGradient.length && settings.color.source === 'host' ? effectGradient.slice(0, 3) : [caret]; + const particles = settings.particles.source === 'custom' && settings.particles.colors[0] ? [hexToRgb(settings.particles.colors[0])] + : settings.particles.source === 'gradient' && settings.particles.colors.length ? settings.particles.colors.map(hexToRgb) + : effectGradient.length ? (settings.trail.source === 'cursor' ? effectGradient : trail) : trail; + return {caret, trail, particles}; +} diff --git a/src/doctor/DoctorPanel.ts b/src/doctor/DoctorPanel.ts new file mode 100644 index 00000000..db06a4fb --- /dev/null +++ b/src/doctor/DoctorPanel.ts @@ -0,0 +1,60 @@ +import type {Key} from '../terminal/keys.js'; +import {renderControls} from '../ui/controls.js'; +import {GLYPHS, getCurrentGlyphMode} from '../ui/glyphs.js'; +import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; +import {truncateAnsi} from '../util/text.js'; +import {liveLine} from '../status/liveLine.js'; +import {doctorSummary, type DoctorAction, type DoctorCheck, type DoctorState} from './doctor.js'; + +/** The /doctor surface: sections of checks, a live line while checking, and row actions that only open things. */ +export interface DoctorPanelState { + checks?: DoctorCheck[]; + selected: number; + startedAt: number; + title: string; +} + +export function createDoctorPanel(title: string, now = Date.now()): DoctorPanelState { return {selected: 0, startedAt: now, title}; } + +export type DoctorPanelAction = {kind: 'close'} | {kind: 'rerun'} | {kind: 'action'; action: DoctorAction}; + +export function doctorKey(state: DoctorPanelState, key: Key): DoctorPanelAction | undefined { + if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'close'}; + if (key.kind === 'text' && key.value.toLowerCase() === 'r') return {kind: 'rerun'}; + const checks = state.checks ?? []; + if (!checks.length) return undefined; + if (key.kind === 'up' || key.kind === 'down') { state.selected = (state.selected + (key.kind === 'up' ? -1 : 1) + checks.length) % checks.length; return undefined; } + const action = checks[state.selected]?.action; + if ((key.kind === 'enter' || key.kind === 'right') && action) return {kind: 'action', action}; + return undefined; +} + +export function stateGlyph(state: DoctorState): string { + const safe = getCurrentGlyphMode() === 'safe'; + return state === 'ok' ? (safe ? '+' : '✓') : state === 'attention' ? '!' : state === 'failure' ? (safe ? 'x' : '✗') : (safe ? '-' : '·'); +} + +export function renderDoctorPanel(state: DoctorPanelState, columns: number, now: number, still: boolean): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const reset = '\u001b[0m'; + const color = (value: DoctorState) => foreground(value === 'ok' ? UI_COLORS.success : value === 'failure' ? UI_COLORS.failure : value === 'attention' ? UI_COLORS.accent : UI_COLORS.subtle); + const rows = [`${primary} Doctor · ${state.title}${reset}`]; + if (!state.checks) { + rows.push('', ` ${liveLine('Checking', undefined, state.startedAt, now, {still})}`); + return rows.map(row => truncateAnsi(row, columns)); + } + const {failures, attention} = doctorSummary(state.checks); + rows.push(` ${subtle}${failures ? `${failures} failure${failures === 1 ? '' : 's'} · ` : ''}${attention ? `${attention} to look at · ` : ''}${!failures && !attention ? 'Healthy · ' : ''}local checks only; nothing was changed${reset}`); + let section = ''; + state.checks.forEach((check, index) => { + if (check.section !== section) { rows.push('', ` ${secondary}${check.section}${reset}`); section = check.section; } + const selected = index === state.selected; + rows.push(`${selected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${color(check.state)}${stateGlyph(check.state)}${reset} ${focusForeground(selected)}${check.label}${reset}` + + `${check.detail ? ` ${subtle}${check.detail}${reset}` : ''}${check.action ? ` ${selected ? accent : subtle}${check.action.label} ›${reset}` : ''}`); + }); + rows.push('', renderControls([['↑↓', 'select'], ['Enter', 'action'], ['R', 'check again'], ['Esc', 'close']])); + return rows.map(row => truncateAnsi(row, columns)); +} diff --git a/src/doctor/doctor.ts b/src/doctor/doctor.ts new file mode 100644 index 00000000..50e08c64 --- /dev/null +++ b/src/doctor/doctor.ts @@ -0,0 +1,169 @@ +import {readProjectFacts, type ProjectFacts} from '../ask/project.js'; +import type {GitFacts} from '../ask/git.js'; + +/** + * /doctor: "is this environment healthy, and if not, what exactly looks + * wrong?" Every check is local, bounded and read-only; nothing is sent to a + * model or the network, and nothing is fixed silently. Checks are small + * functions over an injectable environment, grouped by section; only + * sections relevant to the current context appear. A dirty tree is not a + * failure; severity is honest. + */ +export type DoctorState = 'ok' | 'attention' | 'failure' | 'info'; +export type DoctorAction = {label: string; kind: 'slash'; command: string} | {label: string; kind: 'ask'; request: string}; + +export interface DoctorCheck { + section: string; + label: string; + state: DoctorState; + detail?: string; + action?: DoctorAction; +} + +export interface DoctorEnvironment { + cwd: string; + platform: NodeJS.Platform; + /** Absolute executable for a name on PATH, if any. */ + which(name: string): string | undefined; + /** Whether a path exists / is a writable directory (bounded fs checks). */ + exists(path: string): boolean; + writable(path: string): boolean; + /** NMSh facts the app already holds. */ + nmsh: {configurationLoaded: boolean; configurationError?: string; sessionMode: 'service' | 'in-process'; serviceReachable?: boolean; transcriptDirectory: string; + shell: {id: string; label: string; executable?: string; promptSeen: boolean}; host: {name: string; truecolor: boolean; keyboard: boolean}}; + git?: GitFacts; + repoRoot?: string; + project?: ProjectFacts; + /** Configured providers that point at an external tool (picker fzf, history atuin…). */ + providers: Array<{family: string; label: string; executable?: string; available: boolean}>; + understanding: {mode: string; model?: {label: string; runtime: string; path?: string; owned: boolean}; runtimeAvailable: boolean; state?: string}; + agents: Array<{label: string; installed: boolean}>; + virtualEnv?: string; +} + +const join = (directory: string, name: string) => `${directory.replace(/\/$/u, '')}/${name}`; + +function nmshChecks(env: DoctorEnvironment): DoctorCheck[] { + const section = 'NMSh'; + const checks: DoctorCheck[] = [env.nmsh.configurationLoaded + ? {section, label: 'Configuration loads', state: 'ok'} + : {section, label: 'Configuration has problems', state: 'failure', ...(env.nmsh.configurationError ? {detail: env.nmsh.configurationError} : {}), action: {label: 'Open settings', kind: 'slash', command: '/settings'}}]; + if (env.nmsh.sessionMode === 'service') { + checks.push(env.nmsh.serviceReachable === false ? {section, label: 'Session service not answering', state: 'attention', detail: 'This session keeps working; /sessions may be empty until it answers.'} + : {section, label: 'Session service healthy', state: 'ok'}); + } else checks.push({section, label: 'Running in-process (no session service)', state: 'info'}); + checks.push(env.writable(env.nmsh.transcriptDirectory) ? {section, label: 'Transcript store writable', state: 'ok'} + : {section, label: 'Transcript store is not writable', state: 'failure', detail: env.nmsh.transcriptDirectory}); + return checks; +} + +function shellChecks(env: DoctorEnvironment): DoctorCheck[] { + const section = 'Shell'; + const {shell} = env.nmsh; + return [shell.executable ? {section, label: `${shell.label} detected`, state: 'ok', detail: shell.executable} : {section, label: `${shell.label} not found on PATH`, state: 'failure'}, + shell.promptSeen ? {section, label: 'Prompt handshake healthy', state: 'ok'} : {section, label: 'No prompt handshake yet', state: 'attention', detail: 'The shell has not reported a prompt; a startup file may be waiting.'}]; +} + +/** Script words that are not tools: shell builtins and keywords that may start a script body. */ +const NOT_TOOLS = new Set(['cd', 'echo', 'exit', 'true', 'false', 'test', 'export', 'env', 'set', 'node', 'npm', 'npx', 'pnpm', 'yarn', 'bun', 'sh', 'bash', 'exec', 'run-p', 'run-s']); + +function projectChecks(env: DoctorEnvironment, project: ProjectFacts): DoctorCheck[] { + const section = 'Project'; + const checks: DoctorCheck[] = []; + if (project.kind === 'node') { + checks.push({section, label: 'package.json found', state: 'ok', detail: `${project.manager} · ${project.managerSource}`}); + const manager = project.manager ?? 'npm'; + checks.push(env.which(manager) ? {section, label: `${manager} available`, state: 'ok'} : {section, label: `${manager} is not installed`, state: 'failure', detail: `The project uses ${manager} (${project.managerSource}).`}); + const modules = join(project.root, 'node_modules'); + checks.push(env.exists(modules) ? {section, label: 'Dependencies present', state: 'ok'} + : {section, label: 'Dependencies not installed', state: 'attention', detail: 'node_modules is missing.', action: {label: 'How do I install them?', kind: 'ask', request: `how do i run ${manager} install`}}); + // Scripts whose first word is a tool neither on PATH nor in node_modules/.bin. + const missing = Object.entries(project.scripts).flatMap(([name, body]) => { + const tool = /^\s*([\w.@/-]+)/u.exec(body.replace(/^(?:\w+=\S+\s+)+/u, ''))?.[1]; + if (!tool || NOT_TOOLS.has(tool) || tool.includes('/')) return []; + return env.which(tool) || env.exists(join(join(modules, '.bin'), tool)) ? [] : [`${name} (${tool})`]; + }); + if (missing.length) checks.push({section, label: `${missing.length} script${missing.length === 1 ? '' : 's'} reference${missing.length === 1 ? 's' : ''} a missing tool`, state: 'attention', detail: missing.slice(0, 4).join(', ')}); + if (project.scripts.test) checks.push({section, label: 'Test script', state: 'info', detail: project.scripts.test, action: {label: 'Run tests', kind: 'ask', request: 'run the tests'}}); + } else if (project.kind === 'python') { + checks.push({section, label: 'pyproject.toml found', state: 'ok'}); + const python = env.which('python3') ?? env.which('python'); + checks.push(python ? {section, label: 'Python available', state: 'ok', detail: python} : {section, label: 'Python is not installed', state: 'failure'}); + checks.push(env.virtualEnv ? {section, label: 'Virtual environment active', state: 'ok', detail: env.virtualEnv} + : env.exists(join(project.root, '.venv')) ? {section, label: '.venv exists but is not active', state: 'attention', detail: 'source .venv/bin/activate'} : {section, label: 'No virtual environment active', state: 'info'}); + } else if (project.kind === 'rust') { + checks.push({section, label: 'Cargo.toml found', state: 'ok'}); + checks.push(env.which('cargo') ? {section, label: 'cargo available', state: 'ok'} : {section, label: 'cargo is not installed', state: 'failure'}); + } else if (project.kind === 'go') { + checks.push({section, label: 'go.mod found', state: 'ok'}); + checks.push(env.which('go') ? {section, label: 'go available', state: 'ok'} : {section, label: 'go is not installed', state: 'failure'}); + } else if (project.kind === 'make') { + checks.push({section, label: 'Makefile found', state: 'ok', detail: `${project.makeTargets?.length ?? 0} targets`}); + checks.push(env.which('make') ? {section, label: 'make available', state: 'ok'} : {section, label: 'make is not installed', state: 'failure'}); + } + return checks; +} + +function gitChecks(git: GitFacts): DoctorCheck[] { + const section = 'Git'; + const checks: DoctorCheck[] = [{section, label: git.detached ? 'Repository detected · detached HEAD' : `Repository detected · ${git.branch}`, state: git.detached ? 'info' : 'ok'}]; + const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`; + if (git.conflicted.length) checks.push({section, label: plural(git.conflicted.length, 'conflicted file'), state: 'attention', action: {label: 'Show them', kind: 'ask', request: 'show conflicts'}}); + const changed = git.modified.length + git.deleted.length + git.staged.length; + if (changed) checks.push({section, label: `${plural(changed, 'changed file')}${git.staged.length ? ` (${git.staged.length} staged)` : ''}`, state: 'info', action: {label: 'Show the diff', kind: 'ask', request: 'show me the diff'}}); + if (git.untracked.length) checks.push({section, label: plural(git.untracked.length, 'untracked file'), state: 'info', action: {label: 'Show them', kind: 'ask', request: 'show untracked files'}}); + if (!git.detached) { + checks.push(git.upstream ? {section, label: `Upstream ${git.upstream}${git.ahead ? ` · ${git.ahead} ahead` : ''}${git.behind ? ` · ${git.behind} behind` : ''}`, state: git.behind ? 'attention' : 'ok'} + : git.remotes.length ? {section, label: 'No upstream for this branch', state: 'info', action: {label: 'How do I push it?', kind: 'ask', request: 'how do i push this branch'}} + : {section, label: 'No remotes configured', state: 'info'}); + } + return checks; +} + +function toolChecks(env: DoctorEnvironment): DoctorCheck[] { + const section = 'Tools'; + const checks: DoctorCheck[] = []; + if (env.platform === 'darwin') checks.push(env.which('brew') ? {section, label: 'Homebrew', state: 'ok'} : {section, label: 'Homebrew not installed', state: 'info'}); + for (const provider of env.providers) { + checks.push(provider.available ? {section, label: `${provider.label} (${provider.family})`, state: 'ok'} + : {section, label: `${provider.label} unavailable (${provider.family} uses NMSh Native meanwhile)`, state: 'attention', action: {label: 'Open /providers', kind: 'slash', command: '/providers'}}); + } + return checks; +} + +function understandingChecks(env: DoctorEnvironment): DoctorCheck[] { + const section = 'Local intelligence'; + const u = env.understanding; + if (u.mode === 'off') return [{section, label: 'Off (Ask and Smart Folding use built-in understanding)', state: 'info'}]; + if (!u.model) return [{section, label: 'On, but no model is set up', state: 'attention', action: {label: 'Open /llm', kind: 'slash', command: '/llm'}}]; + const checks: DoctorCheck[] = []; + checks.push(u.model.path && !env.exists(u.model.path) ? {section, label: `${u.model.label} file is missing`, state: 'failure', detail: u.model.path, action: {label: 'Open /llm', kind: 'slash', command: '/llm'}} + : {section, label: u.model.label, state: 'ok', detail: u.model.owned ? 'NMSh managed' : 'found on this machine'}); + checks.push(u.runtimeAvailable ? {section, label: u.model.runtime, state: 'ok'} : {section, label: `${u.model.runtime} runtime not found`, state: 'failure', action: {label: 'Open /llm', kind: 'slash', command: '/llm'}}); + checks.push({section, label: `Model service ${u.state ?? 'idle (starts on first use)'}`, state: u.state === 'Error' ? 'attention' : 'info'}); + return checks; +} + +function agentChecks(env: DoctorEnvironment): DoctorCheck[] { + const installed = env.agents.filter(agent => agent.installed); + return installed.length ? [{section: 'Agents', label: installed.map(agent => agent.label).join(', '), state: 'ok', action: {label: 'Open /ai', kind: 'slash', command: '/ai'}}] : []; +} + +function hostChecks(env: DoctorEnvironment): DoctorCheck[] { + const section = 'Host terminal'; + const host = env.nmsh.host; + return [{section, label: host.name, state: 'info'}, + host.truecolor ? {section, label: 'Truecolor', state: 'ok'} : {section, label: 'No truecolor (colors are approximated)', state: 'info'}, + host.keyboard ? {section, label: 'Enhanced keyboard', state: 'ok'} : {section, label: 'Basic keyboard (Shift+Enter may need setup; /keyboard)', state: 'info', action: {label: 'Open /keyboard', kind: 'slash', command: '/keyboard'}}]; +} + +/** Run every relevant check (synchronous facts gathered by the caller; this is pure and bounded). */ +export function runDoctor(env: DoctorEnvironment): DoctorCheck[] { + const project = env.project ?? readProjectFacts(env.repoRoot ?? env.cwd); + return [...nmshChecks(env), ...shellChecks(env), ...(project ? projectChecks(env, project) : []), ...(env.git ? gitChecks(env.git) : []), + ...toolChecks(env), ...understandingChecks(env), ...agentChecks(env), ...hostChecks(env)]; +} + +export function doctorSummary(checks: readonly DoctorCheck[]): {failures: number; attention: number} { + return {failures: checks.filter(check => check.state === 'failure').length, attention: checks.filter(check => check.state === 'attention').length}; +} diff --git a/src/dotfiles/DotfilesPanel.ts b/src/dotfiles/DotfilesPanel.ts new file mode 100644 index 00000000..fbd21ff4 --- /dev/null +++ b/src/dotfiles/DotfilesPanel.ts @@ -0,0 +1,140 @@ +import type {Key} from '../terminal/keys.js'; +import {framePanel} from '../ui/PanelShell.js'; +import {renderControls} from '../ui/controls.js'; +import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; +import {GLYPHS} from '../ui/glyphs.js'; +import {editText} from '../ui/formControls.js'; +import {padCells, truncateAnsi} from '../util/text.js'; +import {SOURCE_LABELS, type ScanResult} from './scan.js'; +import type {DotfilesItem, ItemMode} from './plan.js'; + +/** + * /dotfiles: source → (remote: confirm clone) → what NMSh understands, with + * per-tool choices and per-field current-vs-dotfiles choices inline → one + * combined review (default No) → per-item results. The panel holds the + * draft choices only; scanning, cloning and applying are app actions. + */ +export interface DotfilesState { + step: 'source' | 'clone' | 'items' | 'review' | 'result'; + source: string; + scan?: ScanResult; + items: DotfilesItem[]; + selected: number; + expanded?: number; + /** Selected field row inside the expanded item. */ + field: number; + review?: {lines: string[]; yes: boolean}; + clone?: {url: string; target: string; yes: boolean}; + results: string[]; + message?: string; +} + +export type DotfilesAction = {kind: 'close'} | {kind: 'scan'; source: string} | {kind: 'clone'} | {kind: 'review'} | {kind: 'apply'}; + +export function createDotfilesPanel(source = ''): DotfilesState { + return {step: 'source', source, items: [], selected: 0, field: 0, results: []}; +} + +const MODE_LABELS: Record = {import: 'Import supported settings', copy: 'Copy exact file', skip: 'Skip / keep current'}; + +export function dotfilesKey(state: DotfilesState, key: Key): DotfilesAction | undefined { + state.message = undefined; + if (state.step === 'source') { + if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'close'}; + if (key.kind === 'enter') return state.source.trim() ? {kind: 'scan', source: state.source.trim()} : (state.message = 'Type a local path such as ~/dotfiles, or a Git URL.', undefined); + const next = editText(state.source, key); + if (next !== undefined) state.source = next.replace(/[\u0000-\u001f\u007f-\u009f]/gu, '').slice(0, 1024); + return undefined; + } + const choose = (target: {yes: boolean}): 'yes' | 'no' | undefined => { + if (key.kind === 'left' || key.kind === 'right') { target.yes = !target.yes; return undefined; } + if (key.kind === 'enter') return target.yes ? 'yes' : 'no'; + if (key.kind === 'escape' || key.kind === 'interrupt') return 'no'; + return undefined; + }; + if (state.step === 'clone' && state.clone) { + const answer = choose(state.clone); + if (answer === 'yes') return {kind: 'clone'}; + if (answer === 'no') { state.step = 'source'; state.clone = undefined; state.message = 'Nothing was downloaded.'; } + return undefined; + } + if (state.step === 'review' && state.review) { + const answer = choose(state.review); + if (answer === 'yes') return {kind: 'apply'}; + if (answer === 'no') { state.step = 'items'; state.review = undefined; state.message = 'Nothing was changed.'; } + return undefined; + } + if (state.step === 'result') return key.kind === 'escape' || key.kind === 'interrupt' || key.kind === 'enter' ? {kind: 'close'} : undefined; + // items + const item = state.items[state.selected]; + if (state.expanded !== undefined) { + const fields = state.items[state.expanded]?.fields ?? []; + if (key.kind === 'escape' || key.kind === 'interrupt') { state.expanded = undefined; return undefined; } + if (key.kind === 'up' || key.kind === 'down') { state.field = (state.field + (key.kind === 'up' ? -1 : 1) + fields.length) % Math.max(1, fields.length); return undefined; } + const field = fields[state.field]; + if (field && (key.kind === 'left' || key.kind === 'right' || key.kind === 'enter' || (key.kind === 'text' && key.value === ' '))) field.use = !field.use; + return undefined; + } + if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'close'}; + if (key.kind === 'up' || key.kind === 'down') { state.selected = (state.selected + (key.kind === 'up' ? -1 : 1) + state.items.length + 1) % (state.items.length + 1); return undefined; } + if (state.selected === state.items.length) return key.kind === 'enter' ? {kind: 'review'} : undefined; + if (!item) return undefined; + if (key.kind === 'left' || key.kind === 'right') { + const index = item.modes.indexOf(item.mode); + item.mode = item.modes[(index + (key.kind === 'left' ? -1 : 1) + item.modes.length) % item.modes.length]!; + return undefined; + } + if (key.kind === 'enter' && item.fields?.length) { state.expanded = state.selected; state.field = 0; } + return undefined; +} + +export function renderDotfilesPanel(state: DotfilesState, columns: number, height: number): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const warning = foreground(UI_COLORS.failure); + const reset = '\u001B[0m'; + const yesNo = (yes: boolean) => yes ? `${subtle}No${reset} ${accent}‹ Yes ›${reset}` : `${accent}‹ No ›${reset} ${subtle}Yes${reset}`; + const lines: string[] = [` ${primary}Dotfiles import${reset} ${subtle}supported settings only · nothing in the repository is run · the repository is not changed${reset}`, '']; + let controls: Array<[string, string]> = [['Esc', 'close']]; + if (state.step === 'source') { + lines.push(` ${secondary}Source${reset} ${primary}${state.source}${accent}_${reset}`, '', + ` ${subtle}A local directory or Git checkout (plain, GNU Stow packages or a chezmoi source), or a Git URL (cloned only after you confirm).${reset}`); + controls = [['Enter', 'scan'], ['Esc', 'close']]; + } else if (state.step === 'clone' && state.clone) { + lines.push(` ${primary}Clone ${state.clone.url}?${reset}`, ` ${subtle}into ${state.clone.target} · depth 1 · no submodules · Git hooks disabled · nothing in it is run${reset}`, '', + ` ${primary}Download?${reset} ${yesNo(state.clone.yes)}`); + controls = [['←→', 'No / Yes'], ['Enter', 'confirm']]; + } else if (state.step === 'review' && state.review) { + lines.push(` ${primary}Dotfiles import plan${reset}`, '', ...state.review.lines.map(line => ` ${line.startsWith(' +') ? accent : line.startsWith(' ~') ? primary : subtle}${line}${reset}`), '', + ` ${primary}Apply?${reset} ${yesNo(state.review.yes)}`); + controls = [['←→', 'No / Yes'], ['Enter', 'confirm'], ['Esc', 'back']]; + } else if (state.step === 'result') { + lines.push(` ${primary}Done${reset}`, '', ...state.results.map(line => ` ${secondary}• ${line}${reset}`), '', ` ${subtle}Edit imported tmux settings any time in /tmux.${reset}`); + controls = [['Enter', 'close']]; + } else if (state.scan) { + lines.push(` ${secondary}Source${reset} ${state.scan.root}`, ` ${secondary}Type${reset} ${SOURCE_LABELS[state.scan.type]}${state.scan.packages.length ? ` · packages: ${state.scan.packages.join(', ')}` : ''}`); + if (state.scan.scripts.length) lines.push(` ${subtle}${state.scan.scripts.length} script${state.scan.scripts.length === 1 ? '' : 's'} found and never run (for example ${state.scan.scripts[0]})${reset}`); + lines.push('', ` ${subtle}Found${reset}`); + if (!state.items.length) lines.push(` ${subtle}No configuration NMSh understands was found.${reset}`); + state.items.forEach((item, index) => { + const selected = index === state.selected && state.expanded === undefined; + const glyph = item.kind === 'fields' || item.kind === 'copy' ? `${accent}✓${reset}` : item.kind === 'inspect' ? `${subtle}○${reset}` : `${warning}!${reset}`; + lines.push(`${selected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${glyph} ${focusForeground(selected)}${padCells(item.file.tool.label, 10)}${reset}${padCells(item.file.repoPath, 32)}${selected && item.modes.length > 1 ? `${accent}‹ ${MODE_LABELS[item.mode]} ›${reset}` : MODE_LABELS[item.mode]}`); + lines.push(` ${subtle}${item.note}${item.fields?.length ? ' · Enter reviews each value' : ''}${reset}`); + if (state.expanded === index && item.fields) { + lines.push(` ${subtle}${padCells('Setting', 24)}${padCells('Dotfiles', 16)}${padCells('Current', 16)}Use${reset}`); + item.fields.forEach((field, fieldIndex) => { + const on = fieldIndex === state.field; + lines.push(` ${on ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${padCells(field.label, 24)}${padCells(field.repo, 16)}${padCells(field.current, 16)}${on ? accent : field.conflict ? warning : subtle}${field.use ? 'Use dotfiles value' : 'Keep current value'}${reset}`); + }); + } + }); + const reviewSelected = state.selected === state.items.length && state.expanded === undefined; + lines.push('', `${reviewSelected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${reviewSelected ? accent : subtle}Review all changes ›${reset}`); + controls = state.expanded !== undefined ? [['↑↓', 'value'], ['←→', 'dotfiles / current'], ['Esc', 'back']] : [['↑↓', 'select'], ['←→', 'choose'], ['Enter', 'details / review'], ['Esc', 'close']]; + } + if (state.message) lines.push('', ` ${secondary}${state.message}${reset}`); + return framePanel([...lines.slice(0, Math.max(3, height - 3)), '', renderControls(controls)].map(line => truncateAnsi(line, columns)), columns).slice(0, Math.max(1, height)); +} diff --git a/src/dotfiles/plan.ts b/src/dotfiles/plan.ts new file mode 100644 index 00000000..9672aa83 --- /dev/null +++ b/src/dotfiles/plan.ts @@ -0,0 +1,178 @@ +import {copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {dirname} from 'node:path'; +import {parse as parseToml} from 'smol-toml'; +import {sha256} from '../ask/fileEdit.js'; +import { + applyTmuxChange, describeTmuxChange, loadTmuxModel, optionProvenance, parseTmuxConfig, readUserTmuxConfig, saveTmuxModel, TMUX_ACTIONS, TMUX_OPTIONS, + type TmuxChange, +} from '../tools/config/tmux.js'; +import {writeTmuxManaged} from '../tools/config/tmuxManaged.js'; +import {destinationFor, readFound, type FoundFile, type ScanResult} from './scan.js'; + +/** + * Turning a scan into reviewed choices. Structured or command-style config + * goes through its adapter at field level (current vs dotfiles, never + * silently the repository's value on a conflict). A file is copied exactly + * only when its registry entry declares an exactCopy validator and the + * content passes it (fail-closed: being parseable is not being safe); + * everything else, including all executable config, is inspect only. The + * repository itself is never modified. + */ + +export type ItemKind = 'fields' | 'copy' | 'inspect' | 'templated' | 'unreadable'; +export type ItemMode = 'import' | 'copy' | 'skip'; + +export interface FieldChoice {change: TmuxChange; label: string; repo: string; current: string; source: string; use: boolean; conflict: boolean} + +export interface DotfilesItem { + file: FoundFile; + kind: ItemKind; + mode: ItemMode; + modes: readonly ItemMode[]; + note: string; + fields?: FieldChoice[]; + destination?: string; + /** sha256 of the destination when reviewed ('absent' when missing): copying refuses if it changed since. */ + destinationSha?: string; + content?: string; + diff?: string[]; +} + +export function shortDiff(before: string | undefined, after: string): string[] { + if (before === undefined) return [`+ new file (${after.split('\n').length} lines)`]; + const a = before.split('\n'); + const b = after.split('\n'); + const out: string[] = []; + for (let index = 0; index < Math.max(a.length, b.length) && out.length < 12; index++) { + if (a[index] === b[index]) continue; + if (a[index] !== undefined) out.push(`- ${a[index]}`); + if (b[index] !== undefined) out.push(`+ ${b[index]}`); + } + return out.length ? out : [' identical to the current file']; +} + +function tmuxFields(text: string, env: NodeJS.ProcessEnv): FieldChoice[] { + const repo = parseTmuxConfig(text); + const model = loadTmuxModel(env); + const mine = readUserTmuxConfig(env); + const user = mine ? parseTmuxConfig(mine.text) : undefined; + const fields: FieldChoice[] = []; + for (const [id, value] of Object.entries(repo.options)) { + const option = TMUX_OPTIONS.find(item => item.id === id)!; + const current = optionProvenance(option, model, user); + if (current.effective === value) continue; + const conflict = current.source !== 'tmux default'; + fields.push({change: {kind: 'option', id, value}, label: option.label, repo: value, current: current.effective, source: current.source, use: !conflict, conflict}); + } + if (repo.prefix) { + const current = model.prefix ?? user?.prefix ?? 'C-b'; + if (current !== repo.prefix) { + const conflict = Boolean(model.prefix ?? user?.prefix); + fields.push({change: {kind: 'prefix', key: repo.prefix}, label: 'Prefix', repo: repo.prefix, current, source: model.prefix ? 'NMSh managed file' : user?.prefix ? 'your tmux config' : 'tmux default', use: !conflict, conflict}); + } + } + for (const binding of repo.bindings) { + if (binding.action === 'send-prefix') continue; + const existing = [...model.bindings, ...(user?.bindings ?? [])].find(item => item.key === binding.key && item.table === binding.table); + if (existing?.action === binding.action) continue; + fields.push({change: {kind: 'binding', binding}, label: `Key ${binding.key}`, repo: TMUX_ACTIONS[binding.action].label, current: existing ? TMUX_ACTIONS[existing.action].label : '—', + source: existing ? 'existing binding' : 'unbound', use: !existing, conflict: Boolean(existing)}); + } + return fields; +} + +export function buildPlan(scan: ScanResult, env: NodeJS.ProcessEnv = process.env): DotfilesItem[] { + const items: DotfilesItem[] = []; + const seen = new Set(); + for (const file of scan.found) { + const base = {file}; + if (file.templated) { items.push({...base, kind: 'templated', mode: 'skip', modes: ['skip'], note: 'chezmoi template: not literal config; review it with chezmoi (NMSh does not render templates)'}); continue; } + if (file.tool.configClass === 'executable') { items.push({...base, kind: 'inspect', mode: 'skip', modes: ['skip'], note: 'Executable config: inspect only; never sourced, copied or rewritten'}); continue; } + if (seen.has(file.tool.id)) { items.push({...base, kind: 'inspect', mode: 'skip', modes: ['skip'], note: `Another ${file.tool.label} file was already selected from this repository`}); continue; } + const content = readFound(scan, file); + if (typeof content !== 'string') { items.push({...base, kind: 'unreadable', mode: 'skip', modes: ['skip'], note: content.error}); continue; } + seen.add(file.tool.id); + if (file.tool.id === 'tmux') { + const fields = tmuxFields(content, env); + const parsed = parseTmuxConfig(content); + items.push({...base, kind: 'fields', mode: fields.length ? 'import' : 'skip', modes: fields.length ? ['import', 'skip'] : ['skip'], fields, content, + note: `${fields.length} supported value${fields.length === 1 ? '' : 's'} differ · ${parsed.unsupported.length} other lines stay out · ${parsed.ignored.length} dynamic lines never run`}); + continue; + } + if (file.target.endsWith('.toml')) { try { parseToml(content); } catch { items.push({...base, kind: 'unreadable', mode: 'skip', modes: ['skip'], note: 'Not valid TOML; skipped'}); continue; } } + const exact = file.tool.exactCopy?.(content); + if (exact && !exact.ok) { items.push({...base, kind: 'inspect', mode: 'skip', modes: ['skip'], note: `Inspect only: ${exact.reason}`}); continue; } + if (exact?.ok) { + const destination = destinationFor(file.tool, env); + let before: string | undefined; + try { before = readFileSync(destination, 'utf8'); } catch { before = undefined; } + items.push({...base, kind: 'copy', mode: 'skip', modes: ['skip', 'copy'], content, destination, destinationSha: before === undefined ? 'absent' : sha256(before), diff: shortDiff(before, content), + note: `Copy exact file to ${destination}${before === undefined ? '' : ' (the current file is backed up first)'}`}); + continue; + } + items.push({...base, kind: 'inspect', mode: 'skip', modes: ['skip'], note: file.tool.dotfilesNote ?? 'Inspect only: no reviewed import for this tool; never copied'}); + } + return items; +} + +/** The combined review text: everything that would change, and what is skipped. */ +export function reviewLines(items: readonly DotfilesItem[], include: readonly string[]): string[] { + const lines: string[] = []; + let fragments = 0; + let copies = 0; + for (const item of items) { + lines.push(`${item.file.tool.label} ${item.file.repoPath}`); + if (item.mode === 'import' && item.fields) { + const used = item.fields.filter(field => field.use); + if (used.length) fragments = 1; + for (const field of used) lines.push(` + ${describeTmuxChange(field.change)}`); + if (!used.length) lines.push(' (no values selected)'); + } else if (item.mode === 'copy' && item.kind === 'copy') { copies++; lines.push(` ~ copy to ${item.destination}`, ...item.diff!.slice(0, 6).map(line => ` ${line}`)); } + else lines.push(` · ${item.kind === 'inspect' || item.kind === 'templated' ? item.note : 'skipped'}`); + } + if (include.length) lines.push('', 'tmux.conf gains one include of the NMSh-managed tmux file:', ...include.map(line => ` ${line}`)); + lines.push('', `Files: ${fragments} managed fragment${fragments === 1 ? '' : 's'} updated · ${include.length ? 1 : 0} reviewed include · ${copies} file${copies === 1 ? '' : 's'} copied (backed up first) · 0 scripts run · the repository is not changed`); + return lines; +} + +/** Backup then atomic replace, refusing if the destination changed since review or is a symlink. */ +function copyExact(item: DotfilesItem, now: Date): string { + const destination = item.destination!; + const verdict = item.file.tool.exactCopy?.(item.content!); + if (!verdict?.ok) return 'no exact-copy authority for this tool; nothing was written.'; + if (existsSync(destination)) { + if (lstatSync(destination).isSymbolicLink()) return `${destination} is a symlink (to ${realpathSync(destination)}); not replaced.`; + if (sha256(readFileSync(destination, 'utf8')) !== item.destinationSha) return `${destination} changed since review; nothing was written.`; + copyFileSync(destination, `${destination}.nmsh-backup-${now.toISOString().replace(/[:.]/gu, '-')}`); + } else if (item.destinationSha !== 'absent') return `${destination} disappeared since review; nothing was written.`; + mkdirSync(dirname(destination), {recursive: true}); + const staged = `${destination}.nmsh-${process.pid}.tmp`; + writeFileSync(staged, item.content!, {encoding: 'utf8', mode: 0o644}); + renameSync(staged, destination); + return `copied to ${destination}`; +} + +/** Applies the reviewed choices through the tool adapters. Per-item results; one failure does not stop the others. */ +export function applyPlan(items: readonly DotfilesItem[], env: NodeJS.ProcessEnv = process.env, now = new Date()): string[] { + const results: string[] = []; + for (const item of items) { + try { + if (item.mode === 'import' && item.fields) { + let model = loadTmuxModel(env); + for (const field of item.fields.filter(choice => choice.use)) { + const next = applyTmuxChange(model, field.change); + if ('error' in next) results.push(`${item.file.tool.label}: ${next.error}`); else model = next; + } + saveTmuxModel(model, env); + const written = writeTmuxManaged(model, env); + results.push(`${item.file.tool.label}: ${written.ok ? 'supported values saved to the NMSh-managed tmux file' : written.error}`); + } else if (item.mode === 'copy') results.push(`${item.file.tool.label}: ${copyExact(item, now)}`); + } catch (error) { + results.push(`${item.file.tool.label}: ${error instanceof Error ? error.message : String(error)}`); + } + } + return results; +} + +export const homeOf = (env: NodeJS.ProcessEnv = process.env) => env.HOME || homedir(); diff --git a/src/dotfiles/scan.ts b/src/dotfiles/scan.ts new file mode 100644 index 00000000..7d1d6d98 --- /dev/null +++ b/src/dotfiles/scan.ts @@ -0,0 +1,138 @@ +import {existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {isAbsolute, join, relative, resolve} from 'node:path'; +import {TOOL_CONFIG_REGISTRY, configLocations, type ToolConfigEntry} from '../tools/config/registry.js'; + +/** + * Dotfiles discovery: a repository is untrusted data. The scan only lists + * and reads bounded regular files; it never runs install scripts, Make + * targets, chezmoi scripts or templates, Git hooks, Stow or any shell, Lua or + * Vimscript. Recognized files are routed to the first-party tool registry. + */ + +export type SourceType = 'plain' | 'git' | 'stow' | 'chezmoi'; +export const SOURCE_LABELS: Record = {plain: 'Plain directory', git: 'Git checkout', stow: 'GNU Stow-style repository', chezmoi: 'chezmoi source state'}; + +export interface FoundFile { + /** Path inside the repository. */ + repoPath: string; + /** The home-relative path it represents (Stow package and chezmoi prefixes removed). */ + target: string; + tool: ToolConfigEntry; + /** Stow package, when the source is Stow-style. */ + package?: string; + /** chezmoi template: not literal config; reviewed with chezmoi, never rendered here. */ + templated?: boolean; + symlink?: boolean; + size: number; +} + +export interface ScanResult { + root: string; + type: SourceType; + packages: string[]; + found: FoundFile[]; + /** Scripts and generators that exist but are never run. */ + scripts: string[]; + truncated: boolean; +} + +const SKIP = new Set(['.git', 'node_modules', '.cache', '.venv', 'vendor', '__pycache__']); +const MAX_ENTRIES = 5000; +const MAX_DEPTH = 7; +export const MAX_FILE = 512 * 1024; +const SCRIPT = /(?:^|\/)(?:install|bootstrap|setup)(?:\.[a-z]+)?$|(?:^|\/)Makefile$|(?:^|\/)run_(?:once_|onchange_)?(?:before_|after_)?[^/]+$|\.(?:sh|bash|zsh|py|rb|pl|js|mjs)$/u; + +export function expandSource(input: string, cwd: string, env: NodeJS.ProcessEnv = process.env): string { + const text = input.trim(); + const home = env.HOME || homedir(); + if (text === '~' || text.startsWith('~/')) return join(home, text.slice(1)); + return isAbsolute(text) ? text : resolve(cwd, text); +} + +export const isRemoteSource = (input: string): boolean => /^(?:https:\/\/|git@|ssh:\/\/)[^\s'"]+$/u.test(input.trim()); + +/** chezmoi source names → target names (dot_, private_, executable_, readonly_ and .tmpl). */ +export function chezmoiTarget(path: string): {target: string; templated: boolean} { + let templated = false; + const segments = path.split('/').map(segment => { + let name = segment; + if (name.endsWith('.tmpl')) { templated = true; name = name.slice(0, -5); } + for (;;) { + const next = name.replace(/^(?:private_|executable_|readonly_|empty_|exact_|create_|modify_|encrypted_|symlink_)/u, ''); + if (next === name) break; + name = next; + } + return name.startsWith('dot_') ? `.${name.slice(4)}` : name; + }); + return {target: segments.join('/'), templated}; +} + +function detectType(root: string, top: string[]): {type: SourceType; packages: string[]} { + if (top.includes('.chezmoiroot') || top.some(name => /^\.chezmoi/u.test(name)) || top.some(name => /^dot_/u.test(name))) return {type: 'chezmoi', packages: []}; + // Stow: top-level package directories whose contents are home-relative (dotfiles or .config). + const packages = top.filter(name => !name.startsWith('.') && !SKIP.has(name)).filter(name => { + try { + const full = join(root, name); + return lstatSync(full).isDirectory() && readdirSync(full).some(entry => entry.startsWith('.')); + } catch { return false; } + }); + if (packages.length >= 1 && packages.length >= top.filter(name => !name.startsWith('.')).length / 2) return {type: 'stow', packages}; + return {type: top.includes('.git') ? 'git' : 'plain', packages: []}; +} + +/** Bounded listing and recognition. Nothing found is executed or followed outside the root. */ +export function scanDotfiles(root: string): ScanResult | {error: string} { + let real: string; + try { real = realpathSync(root); } catch { return {error: `${root} does not exist.`}; } + if (!statSync(real).isDirectory()) return {error: `${root} is not a directory.`}; + const top = readdirSync(real); + const {type, packages} = detectType(real, top); + const found: FoundFile[] = []; + const scripts: string[] = []; + let entries = 0; + let truncated = false; + const walk = (directory: string, depth: number) => { + if (depth > MAX_DEPTH || truncated) return; + let names: string[]; + try { names = readdirSync(directory); } catch { return; } + for (const name of names) { + if (++entries > MAX_ENTRIES) { truncated = true; return; } + if (SKIP.has(name)) continue; + const full = join(directory, name); + const repoPath = relative(real, full); + let info; + try { info = lstatSync(full); } catch { continue; } + if (info.isDirectory()) { walk(full, depth + 1); continue; } + const symlink = info.isSymbolicLink(); + if (!info.isFile() && !symlink) continue; + if (SCRIPT.test(repoPath)) { scripts.push(repoPath); } + let target = repoPath; + let templated = false; + let pkg: string | undefined; + if (type === 'stow') { const [first, ...rest] = repoPath.split('/'); if (packages.includes(first!)) { pkg = first; target = rest.join('/'); } } + if (type === 'chezmoi') ({target, templated} = chezmoiTarget(repoPath)); + const tool = TOOL_CONFIG_REGISTRY.find(entry => entry.dotfiles.test(target) || entry.dotfiles.test(repoPath)); + if (!tool) continue; + found.push({repoPath, target, tool, size: symlink ? 0 : info.size, ...(pkg ? {package: pkg} : {}), ...(templated ? {templated} : {}), ...(symlink ? {symlink} : {})}); + } + }; + walk(real, 0); + return {root: real, type, packages, found, scripts: scripts.slice(0, 50), truncated}; +} + +/** Reads one found file as text, bounded; symlinks are not dereferenced (they are shown, not trusted). */ +export function readFound(scan: ScanResult, file: FoundFile): string | {error: string} { + if (file.symlink) return {error: 'Symlink inside the repository; not followed. Review it yourself.'}; + if (file.size > MAX_FILE) return {error: 'Larger than 512 KiB; skipped.'}; + try { + const text = readFileSync(join(scan.root, file.repoPath), 'utf8'); + return text.includes('\u0000') ? {error: 'Binary file; skipped.'} : text; + } catch { return {error: 'Could not be read.'}; } +} + +/** The destination this machine uses for a tool (its first existing config location, else the first). */ +export function destinationFor(tool: ToolConfigEntry, env: NodeJS.ProcessEnv = process.env): string { + const locations = configLocations(tool, env); + return locations.find(path => existsSync(path)) ?? locations[0]!; +} diff --git a/src/help/helpContent.ts b/src/help/helpContent.ts index cd64c7aa..a1388b21 100644 --- a/src/help/helpContent.ts +++ b/src/help/helpContent.ts @@ -3,15 +3,74 @@ import {authoredMarkdown, type AuthoredMarkdown} from './markdown.js'; /** The /help page. Every input is NMSh source (command table, fixed guidance); nothing comes from the shell. */ export function helpMarkdown(): AuthoredMarkdown { - const commands = slashCommands.map(item => `| \`${item.name}\` | ${item.description} |`).join('\n'); + // Substantial surfaces by area, then everything else; aliases sit beside their command, not in a second row. + const aliasesOf = (name: string) => slashCommands.filter(item => item.alias === name).map(item => `\`${item.name}\``); + const row = (item: typeof slashCommands[number]) => `| \`${item.name}\`${aliasesOf(item.name).length ? ` (also ${aliasesOf(item.name).join(', ')})` : ''} | ${item.description} |`; + const groups = (['Appearance', 'Composer & transcript', 'Providers', 'Tools & integration'] as const).map(group => + `### ${group}\n\n| Command | What it does |\n| --- | --- |\n${slashCommands.filter(item => item.group === group && !item.alias).map(row).join('\n')}`).join('\n\n'); + const commands = slashCommands.filter(item => !item.group && !item.alias).map(row).join('\n'); return authoredMarkdown(`# NMSh help ## Commands +${groups} + +### More commands + | Command | What it does | | --- | --- | ${commands} +## Setup Cat and optional tools + +NMSh is complete out of the box. No external shell tools are required. Optional providers and integrations can be added later, and you can switch between Native and external providers anytime from Settings or Setup Cat. + +Run /setup (or /setup prompt, appearance, chroma, editor, tools) to revisit settings. Setup Cat starts from your current settings, changes nothing until you apply on its last step, and Esc discards the draft. /tools lists optional tools by tier (Recommended, Enhanced CLI); every install or upgrade shows its exact command and asks first. Optional tool update checks are Off unless you choose Daily or Weekly. + +When a submitted command is missing in your zsh and exactly names a curated tool, NMSh may offer to install it; aliases, functions, builtins and executables always win, and your command stays in the composer. The command inspector (palette: Toggle command inspector) shows what a command word resolves to: builtin, alias, function or executable path. + +## Appearance + +Settings → Theme (and /setup appearance) picks a Built-in theme (NMSh themes and bundled families: Catppuccin with flavor and accent, Dracula, Tokyo Night, Gruvbox, Rosé Pine, Nord, Solarized, One Dark), an Imported theme or a Custom theme. Themes color NMSh-owned UI only; your terminal and editor keep their colors unless you opt a tool into Theme Bridge. /theme opens Theme Studio (Built-in · Imported · Custom · Import): set any theme active, duplicate a built-in, edit, rename, duplicate, export (NMSh Theme JSON) or delete library themes, and import a local file from NMSh Theme JSON, Base16, Base24, Windows Terminal, Oh My Posh (JSON, YAML, TOML), Kitty, Ghostty, iTerm2 (.itermcolors) or WezTerm TOML. Imports are parsed as data only (nothing is executed, sourced, templated, followed or fetched), previewed with their mapping and what was lost, and saved only when you confirm; once imported a theme is an ordinary Native theme that no longer needs the source app or file. Cursor shape and blink (/cursor), the prompt symbol and the optional status strip are in Settings. UI chrome (frames, rules, tabs, selection) follows the theme by default, or a Custom preset (Native Lavender, Grayscale, your colors). Chroma colors the Native prompt; Full Chroma is the default influence, and Semantic colors decides whether success, failure and Git state are recolored too. Shimmer (On by default) plays one soft sweep of light when you select or change something, submit, or confirm; Decorative effects Off or Reduced Motion turn it off. /motion (also /appearance → Motion) sets the general motion: context transitions, command launch, completion highlight, command completion and event feedback. + +## Prompt None and history + +/prompt → None keeps only the composer and its input marker: no prompt row, modules or right prompt. Editing, suggestions, syntax colors, history, themes and Theme Bridge keep working, and commands submitted under None store no prompt snapshot. /transcript → Historical prompt shows past prompts Full, Compact (place, branch, marker), Minimal (marker) or Off; stored snapshots are never changed. + +## Theme Bridge + +/theme-bridge (also /appearance, Settings and Setup) extends NMSh themes to terminal tools. It is Off by default: one switch plus Apply themes Manual, Follow NMSh or Choose theme. Under Manual each tool is Independent (NMSh injects and changes nothing for it), Follow NMSh or Choose theme (a pinned Built-in, Imported or Custom theme). fzf colors apply only to fzf launched by NMSh (FZF_DEFAULT_OPTS and rc files are untouched). less/man colors and File listing colors (GNU ls/gls through LS_COLORS, macOS/BSD ls through CLICOLOR and LSCOLORS) reach NMSh shells (zsh, Bash, Fish) at their next prompt through an NMSh-owned environment file; Independent restores what was there. tmux, Neovim, Vim, Helix and bat get NMSh-generated themes (bat after a reviewed cache build, selected with BAT_THEME in NMSh shells); loading them in new instances needs one include line (for Helix, a theme = "nmsh-bridge" assignment) that NMSh shows exactly and adds only after you confirm, and removes exactly. Running editors are not recolored live; tmux can reload on request. delta is detected only: NMSh does not change git config. Details: [Theme Bridge](https://github.com/raiseCatError/notMyShell/blob/dev/docs/design/theme-bridge.md). + +Keep Awake (/caffeinate, also /awake and /zoomies) keeps the computer awake with the operating system's own mechanism: Apple caffeinate on macOS, the systemd inhibitor on Linux, the execution-state API on Windows. Modes are Idle, Display, System and All, optionally for a time (/zoomies display 2h, /caffeinate idle 30m); /caffeinate status shows it and /caffeinate stop ends it. It keeps running after the NMSh window closes, never changes power settings, and shows a mode as unavailable when the platform cannot honour it (Display on Linux). It runs as an NMSh-owned background process, never in your shell, so the composer comes straight back (typing caffeinate yourself stays an ordinary shell command). While it is active NMSh shows Awake · on a free composer edge (or a row next to the composer), in the Status Strip when the strip is on, and optionally on the screensaver; after 30 seconds without NMSh input it adds the time and a muted /zoomies stop. Placement, display, the idle reminder and the screensaver status are set in the Keep Awake panel. Off shows nothing. + +Prompt providers (/prompt, /providers): NMSh Native, None, Starship, Powerlevel10k and Oh My Posh. NMSh keeps the editor, composer, transcript and history; an external provider supplies only the prompt content, and NMSh falls back to Native, saying so, when it cannot render. Starship and Oh My Posh are cross-shell prompt engines that NMSh runs directly, with no shell rc change; Powerlevel10k is a Zsh theme rendered in an isolated helper. In /tools, Oh My Zsh is a Zsh framework (not a command): its guided install keeps your .zshrc and NMSh never runs the installer itself; if you have .zshrc.pre-oh-my-zsh, NMSh can compare it with .zshrc and restore it after a backup and a confirmation, but never merges shell code. Oh My Zsh themes and plugins, ~/.p10k.zsh and Oh My Posh configs are inspect-only for dotfiles. + +## Idle visuals + +Optional and off by default (Never). /screensaver previews each mode live: Aurora Drift, Deep Space, Warp Starfield, Rain, Sparkles, Fireworks and Bouncing Vespyr. They run only inside NMSh at a quiet prompt; any key, mouse, focus return or new output ends them and leaves everything exactly as it was. Reduced Motion shows a still frame; Decorative effects Off keeps them off. + +## 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/HostActions.ts b/src/host/HostActions.ts new file mode 100644 index 00000000..0bb3eb38 --- /dev/null +++ b/src/host/HostActions.ts @@ -0,0 +1,283 @@ +import {spawn} from 'node:child_process'; +import {accessSync, constants, statSync} from 'node:fs'; +import {spawnSync} from 'node:child_process'; +import {basename, delimiter, isAbsolute, join, resolve} from 'node:path'; +import {homedir} from 'node:os'; + +/** + * HostActions: delegate editor-native work to the editor around NMSh. + * + * NMSh owns terminal-native interaction (composer, transcript, find/filter, + * sessions). Opening a file at a location, opening a folder, or showing a + * rich diff belong to an editor, so NMSh hands them over: + * + * Zed `zed :[:]`, `zed `, `zed --diff ` (when its --help lists --diff) + * VS Code `code --goto :[:]`, `code `, `code --diff ` + * Editor $VISUAL / $EDITOR (or the configured editor): a visible command + * placed in the composer for you to run; never executed silently + * + * Capabilities, not brands, are the contract. A terminal emulator is never + * treated as an editor (Ghostty, Terminal.app and others have no adapter). + * Every launch is argv-based: paths are single argv elements, never shell text. + */ + +export type OpenWith = 'auto' | 'zed' | 'vscode' | 'editor'; +export const OPEN_WITH_IDS: readonly OpenWith[] = ['auto', 'zed', 'vscode', 'editor']; + +export interface SourceLocation { + path: string; + line?: number; + column?: number; +} + +export interface HostCapabilities { + /** The editor whose integrated terminal NMSh runs in, when it is known. */ + integratedEditor?: 'zed' | 'vscode'; + /** The integrated editor is known but its command-line tool was not found. */ + cliMissing?: 'zed' | 'code'; + nativeFileOpen: boolean; + nativeDirectoryOpen: boolean; + nativeDiff: boolean; +} + +export type HostAction = + /** Launch a GUI editor process (argv; detached). */ + | {kind: 'spawn'; command: string; args: string[]; label: string} + /** Put a reviewed command in the composer for a terminal editor. */ + | {kind: 'compose'; argv: string[]; label: string} + | {kind: 'unsupported'; reason: string}; + +export interface HostActionAdapter { + readonly id: 'zed' | 'vscode' | 'editor' | 'none'; + readonly label: string; + readonly capabilities: HostCapabilities; + openFile(location: SourceLocation): HostAction; + openDirectory(path: string): HostAction; + openDiff(left: string, right: string): HostAction; +} + +export interface HostEnvironment { + env: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + /** Absolute executable for a name on PATH, if any. */ + which(name: string): string | undefined; + /** First lines of ` --help`, to check for documented flags before using them. */ + helpText(executable: string): string; +} + +export function systemHostEnvironment(env: NodeJS.ProcessEnv = process.env): HostEnvironment { + const help = new Map(); + return { + env, + which: name => { + for (const directory of (env.PATH ?? '').split(delimiter)) { + if (!isAbsolute(directory)) continue; + const candidate = join(directory, name); + try { if (statSync(candidate).isFile()) { accessSync(candidate, constants.X_OK); return candidate; } } catch { /* next */ } + } + return undefined; + }, + helpText: executable => { + if (!help.has(executable)) { + try { + const result = spawnSync(executable, ['--help'], {encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'pipe']}); + help.set(executable, `${result.stdout ?? ''}${result.stderr ?? ''}`.slice(0, 64 * 1024)); + } catch { help.set(executable, ''); } + } + return help.get(executable)!; + }, + }; +} + +const location = (target: SourceLocation) => `${target.path}${target.line ? `:${target.line}${target.column ? `:${target.column}` : ''}` : ''}`; + +function zedAdapter(executable: string, host: HostEnvironment, integrated: boolean): HostActionAdapter { + const diff = /--diff\b/u.test(host.helpText(executable)); + return { + id: 'zed', label: 'Zed', + capabilities: {...(integrated ? {integratedEditor: 'zed' as const} : {}), nativeFileOpen: true, nativeDirectoryOpen: true, nativeDiff: diff}, + openFile: target => ({kind: 'spawn', command: executable, args: [location(target)], label: `zed ${location(target)}`}), + openDirectory: path => ({kind: 'spawn', command: executable, args: [path], label: `zed ${path}`}), + openDiff: (left, right) => diff + ? {kind: 'spawn', command: executable, args: ['--diff', left, right], label: `zed --diff ${left} ${right}`} + : {kind: 'unsupported', reason: 'This Zed CLI does not list --diff in `zed --help`; update Zed for diff support, or run git diff here.'}, + }; +} + +function vscodeAdapter(executable: string, integrated: boolean): HostActionAdapter { + return { + id: 'vscode', label: 'VS Code', + capabilities: {...(integrated ? {integratedEditor: 'vscode' as const} : {}), nativeFileOpen: true, nativeDirectoryOpen: true, nativeDiff: true}, + // --goto takes path:line[:character]; without a line the plain path is opened. + openFile: target => (target.line + ? {kind: 'spawn', command: executable, args: ['--goto', location(target)], label: `code --goto ${location(target)}`} + : {kind: 'spawn', command: executable, args: [target.path], label: `code ${target.path}`}), + openDirectory: path => ({kind: 'spawn', command: executable, args: [path], label: `code ${path}`}), + openDiff: (left, right) => ({kind: 'spawn', command: executable, args: ['--diff', left, right], label: `code --diff ${left} ${right}`}), + }; +} + +/** How common terminal editors take a line (and column): only forms their documentation describes. */ +export function editorArguments(editor: readonly string[], target: SourceLocation): string[] { + const name = basename(editor[0] ?? ''); + if (!target.line) return [...editor, target.path]; + if (['hx', 'helix'].includes(name)) return [...editor, location(target)]; + if (name === 'micro') return [...editor, `+${target.line}${target.column ? `:${target.column}` : ''}`, target.path]; + if (['vi', 'vim', 'nvim', 'view', 'nano', 'emacs', 'emacsclient', 'kak', 'pico', 'mg', 'joe'].includes(name)) return [...editor, `+${target.line}`, target.path]; + // Unknown editor: no guessed location flag; the file opens at its start. + return [...editor, target.path]; +} + +function editorAdapter(editor: string[]): HostActionAdapter { + const label = editor[0] ?? 'editor'; + return { + id: 'editor', label: basename(label), + capabilities: {nativeFileOpen: true, nativeDirectoryOpen: false, nativeDiff: false}, + openFile: target => ({kind: 'compose', argv: editorArguments(editor, target), label: basename(label)}), + openDirectory: () => ({kind: 'unsupported', reason: `${basename(label)} is a terminal editor; NMSh does not open folders in it. Use cd here, or an editor with a folder view.`}), + openDiff: () => ({kind: 'unsupported', reason: 'No editor with a native diff view is configured. Run git diff (or diff -u) here, or set Open with to Zed or VS Code.'}), + }; +} + +const NONE: HostActionAdapter = { + id: 'none', label: 'none', + capabilities: {nativeFileOpen: false, nativeDirectoryOpen: false, nativeDiff: false}, + openFile: () => ({kind: 'unsupported', reason: 'No editor is known here: not inside Zed or VS Code, and neither VISUAL nor EDITOR is set. Set one, or choose Open with in /settings.'}), + openDirectory: () => ({kind: 'unsupported', reason: 'No editor with a folder view is known here.'}), + openDiff: () => ({kind: 'unsupported', reason: 'No editor with a native diff view is known here. git diff works in the transcript as usual.'}), +}; + +/** + * Inside Zed or VS Code without its CLI on PATH: the editor IS known, so say + * exactly what is missing and how the editor itself installs it. NMSh does not + * install it: Zed's installer symlinks into /usr/local/bin and may ask for + * administrator rights; VS Code's is a command in its own palette. + */ +export function missingCliMessage(editor: 'zed' | 'vscode', platform: NodeJS.Platform = process.platform, detected = true): string { + const who = detected ? (editor === 'zed' ? 'Zed detected' : 'VS Code detected') : (editor === 'zed' ? 'Open with is Zed' : 'Open with is VS Code'); + if (editor === 'zed') { + return platform === 'darwin' + ? `${who}, but its CLI (zed) is not on PATH. In Zed: Cmd+Shift+P → "cli: install cli binary", then open a new terminal (or refresh PATH).` + : `${who}, but its CLI is not on PATH (zed, or zeditor / zedit / zed-editor from some packages). Official Zed releases put it in ~/.local/bin: add that to PATH, or run "cli: install cli binary" from Zed's command palette (Ctrl+Shift+P), then open a new terminal.`; + } + return platform === 'darwin' + ? `${who}, but its CLI (code) is not on PATH. In VS Code: Cmd+Shift+P → "Shell Command: Install 'code' command in PATH", then open a new terminal.` + : `${who}, but its CLI (code) is not on PATH. VS Code's Linux packages normally provide it; check that its bin directory is on PATH, then open a new terminal.`; +} + +function missingCliAdapter(editor: 'zed' | 'vscode', platform: NodeJS.Platform, detected: boolean): HostActionAdapter { + const reason = missingCliMessage(editor, platform, detected); + return {id: 'none', label: editor === 'zed' ? 'Zed (CLI not found)' : 'VS Code (CLI not found)', + capabilities: {...(detected ? {integratedEditor: editor} : {}), cliMissing: editor === 'zed' ? 'zed' : 'code', nativeFileOpen: false, nativeDirectoryOpen: false, nativeDiff: false}, + openFile: () => ({kind: 'unsupported', reason}), openDirectory: () => ({kind: 'unsupported', reason}), openDiff: () => ({kind: 'unsupported', reason})}; +} + +/** VISUAL then EDITOR, split on whitespace (no shell parsing; quotes are not interpreted). */ +export function configuredEditor(env: NodeJS.ProcessEnv): string[] | undefined { + const value = (env.VISUAL || env.EDITOR || '').trim(); + return value ? value.split(/\s+/u) : undefined; +} + +/** + * Pick the adapter: an explicit choice wins; otherwise the editor NMSh runs + * inside (from its own terminal environment), then VISUAL/EDITOR. + */ +export function resolveHostActions(openWith: OpenWith, host: HostEnvironment = systemHostEnvironment()): HostActionAdapter { + const program = host.env.TERM_PROGRAM; + const inZed = program === 'zed' || Boolean(host.env.ZED_TERM); + const inVsCode = program === 'vscode'; + const zed = () => host.which('zed') ?? host.which('zeditor') ?? host.which('zedit') ?? host.which('zed-editor'); + const code = () => host.which('code'); + if (openWith === 'zed' || (openWith === 'auto' && inZed)) { + const executable = zed(); + if (executable) return zedAdapter(executable, host, inZed); + if (openWith === 'zed' || !configuredEditor(host.env)) return missingCliAdapter('zed', host.platform ?? process.platform, inZed); + } + if (openWith === 'vscode' || (openWith === 'auto' && inVsCode)) { + const executable = code(); + if (executable) return vscodeAdapter(executable, inVsCode); + if (openWith === 'vscode' || !configuredEditor(host.env)) return missingCliAdapter('vscode', host.platform ?? process.platform, inVsCode); + } + const editor = configuredEditor(host.env); + if (editor) { + // A GUI editor named in EDITOR keeps its native integration. + const name = basename(editor[0]!); + if ((name === 'code' || name === 'zed') && editor.length <= 2 && !editor.slice(1).some(arg => arg !== '-w' && arg !== '--wait')) { + const executable = host.which(name) ?? (isAbsolute(editor[0]!) ? editor[0] : undefined); + if (executable) return name === 'code' ? vscodeAdapter(executable, inVsCode) : zedAdapter(executable, host, inZed); + } + return editorAdapter(editor); + } + return NONE; +} + +/** Launch a GUI editor detached, argv only. Resolves with a failure reason, never throws. */ +export function runHostAction(action: Extract): Promise { + return new Promise(resolve => { + try { + const child = spawn(action.command, action.args, {detached: true, stdio: 'ignore', shell: false}); + child.once('error', error => resolve(error.message)); + child.once('spawn', () => { child.unref(); resolve(undefined); }); + } catch (error) { resolve(error instanceof Error ? error.message : String(error)); } + }); +} + +// ------------------------------------------------------------ source references + +/** + * `path:line[:column]` references as compilers, test runners and linters + * print them. Paths may be relative, absolute or ~-prefixed; quoted forms may + * contain spaces. Bare words without a line are not references. + */ +const QUOTED = /(["'`])((?:\/|~\/|\.{1,2}\/|[\w@.+-])[^"'`\n]*?):(\d{1,7})(?::(\d{1,5}))?\1/gu; +const BARE = /(?:^|[\s(\[<])((?:\/|~\/|\.{1,2}\/)?[\w@.+-][\w@.+\-/]*\.[\w]+|(?:\/|~\/|\.{1,2}\/)[\w@.+\-/]+):(\d{1,7})(?::(\d{1,5}))?(?=$|[\s:,;)\]>])/gu; + +export interface SourceReference extends SourceLocation { + /** The reference exactly as printed. */ + text: string; +} + +export function findSourceReferences(line: string): SourceReference[] { + const references: SourceReference[] = []; + const seen = new Set(); + const add = (path: string, lineText: string, columnText: string | undefined, text: string) => { + const key = `${path}:${lineText}:${columnText ?? ''}`; + if (seen.has(key) || /^https?$/iu.test(path) || /^\d+$/u.test(path)) return; + seen.add(key); + references.push({path, line: Number(lineText), ...(columnText ? {column: Number(columnText)} : {}), text}); + }; + const quoted: Array<[number, number]> = []; + for (const match of line.matchAll(QUOTED)) { + quoted.push([match.index, match.index + match[0].length]); + add(match[2]!, match[3]!, match[4], match[0]); + } + for (const match of line.matchAll(BARE)) { + // Part of a quoted reference already taken whole (a path with spaces). + if (quoted.some(([start, end]) => match.index < end && match.index + match[0].length > start)) continue; + add(match[1]!, match[2]!, match[3], match[0].trimStart().replace(/^[(\[<]/u, '')); + } + return references; +} + +/** `/open` argument: the whole text is the path (spaces allowed); a trailing :line[:column] is the location. */ +export function parseOpenArgument(text: string): SourceLocation | undefined { + const trimmed = text.trim().replace(/^(["'])(.*)\1$/u, '$2'); + if (!trimmed) return undefined; + const match = /^(.*?):(\d{1,7})(?::(\d{1,5}))?$/u.exec(trimmed); + if (match && match[1]) return {path: match[1], line: Number(match[2]), ...(match[3] ? {column: Number(match[3])} : {})}; + return {path: trimmed}; +} + +export type ResolvedLocation = {ok: true; location: SourceLocation; kind: 'file' | 'directory'} | {ok: false; reason: string}; + +/** Resolve against the command's own cwd (not NMSh's), expand ~, and require the target to exist. */ +export function resolveLocation(target: SourceLocation, cwd: string, home = homedir()): ResolvedLocation { + if (/[\u0000-\u001f\u007f]/u.test(target.path)) return {ok: false, reason: 'The path contains control characters.'}; + const expanded = target.path === '~' ? home : target.path.startsWith('~/') ? join(home, target.path.slice(2)) : target.path; + const path = isAbsolute(expanded) ? expanded : resolve(cwd, expanded); + let stat; + try { stat = statSync(path); } catch { return {ok: false, reason: `${path} does not exist.`}; } + if (stat.isDirectory()) return {ok: true, kind: 'directory', location: {path}}; + if (!stat.isFile()) return {ok: false, reason: `${path} is not a regular file.`}; + return {ok: true, kind: 'file', location: {...target, path}}; +} diff --git a/src/host/OpenPanel.ts b/src/host/OpenPanel.ts new file mode 100644 index 00000000..afa40050 --- /dev/null +++ b/src/host/OpenPanel.ts @@ -0,0 +1,43 @@ +import type {Key} from '../terminal/keys.js'; +import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; +import {GLYPHS} from '../ui/glyphs.js'; +import {renderControls} from '../ui/controls.js'; +import {truncateAnsi} from '../util/text.js'; +import type {SourceReference} from './HostActions.js'; + +/** /open with no argument: source references found in recent command output, newest first. Keyboard-first. */ +export interface OpenPanelState { + references: Array; + selected: number; + editor: string; + message?: string; +} + +export function openPanelKey(state: OpenPanelState, key: Key): 'close' | 'open' | undefined { + if (key.kind === 'escape' || key.kind === 'interrupt') return 'close'; + if (key.kind === 'up' || key.kind === 'down') { + const count = Math.max(1, state.references.length); + state.selected = (state.selected + (key.kind === 'up' ? count - 1 : 1)) % count; + } + if (key.kind === 'enter' && state.references.length) return 'open'; + return undefined; +} + +export function renderOpenPanel(state: OpenPanelState, columns: number, height: number): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const reset = '\u001b[0m'; + const rows = [`${primary} Open source reference${reset} ${subtle}from recent output · opens in ${state.editor}${reset}`, '']; + if (!state.references.length) rows.push(` ${subtle}No path:line references in recent output. /open [:line[:column]] opens one directly.${reset}`); + const budget = Math.max(3, height - 7); + const start = Math.max(0, Math.min(state.selected - Math.floor(budget / 2), state.references.length - budget)); + state.references.slice(start, start + budget).forEach((reference, offset) => { + const selected = start + offset === state.selected; + rows.push(`${selected ? `${accent}${GLYPHS.selection}` : ' '} ${focusForeground(selected)}${reference.text}${reset} ${subtle}from ${reference.command.slice(0, 40)}${reset}`); + }); + if (state.message) rows.push('', ` ${secondary}${state.message}${reset}`); + rows.push('', renderControls([['↑↓', 'select'], ['Enter', 'open'], ['Esc', 'close']])); + return rows.map(row => truncateAnsi(row, columns)); +} diff --git a/src/host/capabilities.ts b/src/host/capabilities.ts new file mode 100644 index 00000000..16c3bcd2 --- /dev/null +++ b/src/host/capabilities.ts @@ -0,0 +1,101 @@ +/** 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 type TerminalProfile = 'ghostty' | 'iterm2' | 'kitty' | 'wezterm' | 'zed' | 'windows-terminal' | 'baseline'; + +export function terminalProfile(env: NodeJS.ProcessEnv): TerminalProfile { + // 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'; + case 'zed': return 'zed'; + 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'; + if (env.ZED_TERM) return 'zed'; + // Windows Terminal exports WT_SESSION and forwards it into WSL through WSLENV. + if (env.WT_SESSION) return 'windows-terminal'; + return 'baseline'; +} + +/** + * Zed's integrated terminal (alacritty_terminal) implements the standard + * xterm button and SGR mouse modes, so wheel and click reports reach NMSh + * while it owns the alternate screen; Shift keeps Zed's own selection. + * Movement tracking is not needed. Hyperlinks stay opt-in (NMSH_HYPERLINKS) + * until physical QA confirms them; truecolor follows COLORTERM, which Zed sets. + */ +const ZED_PROFILE = { + mouseReporting: true, mouseMovement: false, clickSupport: true, textSelectionInteraction: 'shift' as const, +}; + +/** + * Windows Terminal (seen from WSL through WT_SESSION): standard SGR mouse + * reports with Shift selection, truecolor and OSC 8 hyperlinks. No movement + * tracking, graphics or enhanced keyboard is assumed; probes decide the rest. + */ +const WINDOWS_TERMINAL_PROFILE = { + mouseReporting: true, mouseMovement: false, clickSupport: true, textSelectionInteraction: 'shift' as const, hyperlinks: true, truecolor: true, +}; + +/** + * Hosts whose documentation describes Kitty graphics support: Kitty, Ghostty + * and WezTerm. For these NMSh sends the graphics query; support is still only + * assumed from the reply (Kitty itself keeps its long-standing hint). + */ +export function shouldProbeGraphics(env: NodeJS.ProcessEnv): boolean { + const nested = Boolean(env.TMUX || env.STY || env.ZELLIJ) || /^(tmux|screen)/u.test(env.TERM ?? ''); + return !nested && env.NMSH_IMAGES !== '0' && ['kitty', 'ghostty', 'wezterm'].includes(terminalProfile(env)); +} + +/** 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 === 'zed') Object.assign(result, ZED_PROFILE); + else if (profile === 'windows-terminal') Object.assign(result, WINDOWS_TERMINAL_PROFILE); + else 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/desktop.ts b/src/host/desktop.ts new file mode 100644 index 00000000..1c02d2e0 --- /dev/null +++ b/src/host/desktop.ts @@ -0,0 +1,12 @@ +import {resolveCommand} from '../providers/providers.js'; + +/** + * The system "open this URL/file" helper. Optional everywhere: when none is + * found the caller reports the target as text instead. Nothing is installed. + * macOS: `open`. Linux: xdg-open, then wslview (WSL with wslu installed). + */ +export function selectOpener(platform: NodeJS.Platform = process.platform, resolve: (name: string) => string | undefined = name => resolveCommand(name)): string | undefined { + if (platform === 'darwin') return '/usr/bin/open'; + if (platform !== 'linux') return undefined; + return resolve('xdg-open') ?? resolve('wslview'); +} 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/platform.ts b/src/host/platform.ts new file mode 100644 index 00000000..9b0ea738 --- /dev/null +++ b/src/host/platform.ts @@ -0,0 +1,83 @@ +import {readFileSync} from 'node:fs'; +import {release} from 'node:os'; + +/** + * The machine NMSh's Node process runs on (the guest, under WSL), separate + * from the terminal host that draws it (which, under WSL, is a Windows app). + * + * Supported architecture on Windows: + * Windows terminal host → WSL 2 → Linux Node.js → NMSh → shell + * WSL 1 is detected and reported, never claimed as supported: it has no real + * Linux kernel, and PTY, process-group and Unix-socket behavior differ. + * Native Windows (ConPTY) is not supported. + */ + +export type WslVersion = 1 | 2 | 'unknown'; + +export interface PlatformInfo { + os: 'macos' | 'linux' | 'windows' | 'other'; + /** Kernel release, e.g. 6.6.87.2-microsoft-standard-WSL2. */ + kernel: string; + wsl?: {version: WslVersion; distro?: string; interop: boolean}; + /** One plain sentence about support status, factual. */ + support: string; +} + +export interface PlatformProbe { + platform: NodeJS.Platform; + release: string; + env: NodeJS.ProcessEnv; + read(path: string): string | undefined; +} + +export function systemPlatformProbe(env: NodeJS.ProcessEnv = process.env): PlatformProbe { + return {platform: process.platform, release: release(), env, + read: path => { try { return readFileSync(path, 'utf8').slice(0, 4096); } catch { return undefined; } }}; +} + +/** + * WSL evidence, strongest first: the kernel release (WSL 2 kernels are + * "...-microsoft-standard-WSL2"; WSL 1 reports a Windows build like + * "4.4.0-19041-Microsoft"), then /proc/version, then WSL environment variables. + */ +export function detectWsl(probe: PlatformProbe): PlatformInfo['wsl'] { + if (probe.platform !== 'linux') return undefined; + const kernel = probe.release; + const procVersion = probe.read('/proc/version') ?? ''; + const envSaysWsl = Boolean(probe.env.WSL_DISTRO_NAME || probe.env.WSL_INTEROP); + const microsoft = /microsoft/iu.test(kernel) || /microsoft/iu.test(procVersion); + if (!microsoft && !envSaysWsl) return undefined; + let version: WslVersion = 'unknown'; + if (/WSL2|microsoft-standard/iu.test(kernel) || /WSL2|microsoft-standard/iu.test(procVersion)) version = 2; + else if (/-Microsoft$/u.test(kernel.trim()) || /^4\.4\.0-\d+-Microsoft/u.test(kernel)) version = 1; + // WSL_INTEROP (a per-instance interop socket) is set by WSL 2; WSL 1 does not set it. + else if (probe.env.WSL_INTEROP) version = 2; + const distro = probe.env.WSL_DISTRO_NAME?.replace(/[\u0000-\u001f]/gu, '').slice(0, 64) || undefined; + return {version, ...(distro ? {distro} : {}), interop: Boolean(probe.env.WSL_INTEROP) || probe.read('/proc/sys/fs/binfmt_misc/WSLInterop') !== undefined}; +} + +export function detectPlatform(probe: PlatformProbe = systemPlatformProbe()): PlatformInfo { + const kernel = probe.release; + if (probe.platform === 'darwin') return {os: 'macos', kernel, support: 'macOS: supported platform.'}; + if (probe.platform === 'win32') return {os: 'windows', kernel, support: 'Native Windows is not supported. Run NMSh inside WSL 2 from your Windows terminal.'}; + if (probe.platform !== 'linux') return {os: 'other', kernel, support: `${probe.platform} is not a supported platform.`}; + const wsl = detectWsl(probe); + if (!wsl) return {os: 'linux', kernel, support: 'Linux: supported by automated tests; physical terminal validation is pending.'}; + const where = wsl.distro ? ` (${wsl.distro})` : ''; + if (wsl.version === 2) return {os: 'linux', kernel, wsl, support: `WSL 2${where}: the supported Windows path; physical validation is pending.`}; + if (wsl.version === 1) { + return {os: 'linux', kernel, wsl, + support: `WSL 1${where} detected: not supported. WSL 1 translates Linux calls without a Linux kernel, so PTY, signal and socket behavior can differ; convert with \`wsl --set-version 2\`.`}; + } + return {os: 'linux', kernel, wsl, support: `WSL${where} detected, version unknown: WSL 2 is the supported path.`}; +} + +/** + * Windows paths visible from WSL (/mnt/c/...) live on a 9p/drvfs mount: slow + * for metadata scans and without Unix permission semantics. NMSh keeps its + * own private state on the Linux filesystem; this flags cwd scans that would + * be slow, so callers can bound work. + */ +export function isWindowsMount(path: string, wsl: PlatformInfo['wsl']): boolean { + return Boolean(wsl) && /^\/mnt\/[a-z](?:\/|$)/u.test(path); +} diff --git a/src/host/probe.ts b/src/host/probe.ts new file mode 100644 index 00000000..7e76ba10 --- /dev/null +++ b/src/host/probe.ts @@ -0,0 +1,65 @@ +import type {TerminalCapabilities} from './capabilities.js'; + +export const HOST_PROBE_TIMEOUT_MS = 80; +export const HOST_QUERY = '\u001b[?u\u001b[?2026$p'; +/** + * Kitty graphics support query: a 1×1 RGB direct-data query (a=q) that the + * host answers with OK or an error, and never displays. Sent only to hosts + * whose profile suggests graphics, so others never see an APC sequence. + */ +export const GRAPHICS_QUERY = '\u001b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\u001b\\'; + +/** 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|\u001b_Gi=31;([^\u001b]{0,128})\u001b\\/gu, + (match, flags: string | undefined, status: string | undefined, graphics: string | undefined) => { + if (match === '\u001b[200~') { pasted = true; return match; } + if (match === '\u001b[201~') { pasted = false; return match; } + if (pasted) return match; + if (graphics !== undefined) { + // Protocol evidence wins over profile hints in both directions. + if (graphics === 'OK') capabilities.graphicsProtocol = 'kitty'; + else if (capabilities.graphicsProtocol === 'kitty') capabilities.graphicsProtocol = 'none'; + return ''; + } + 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, options: {graphics?: boolean} = {}): 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(options.graphics ? `${GRAPHICS_QUERY}${HOST_QUERY}` : HOST_QUERY); + } catch { finish(); } + }); +} diff --git a/src/host/semanticMarks.ts b/src/host/semanticMarks.ts new file mode 100644 index 00000000..73d8b95d --- /dev/null +++ b/src/host/semanticMarks.ts @@ -0,0 +1,109 @@ +import {hostname as osHostname} from 'node:os'; +import {terminalProfile} from './capabilities.js'; + +/** + * Host semantic cooperation: OSC 7 (current directory) and OSC 133 (command + * zones), projected from NMSh's own authoritative lifecycle (the + * authenticated private OSC 777 markers). They are an enhancement for capable + * hosts and multiplexers; NMSh never reads them back or depends on them. + * + * ready (prompt) → [D;status if a command was running] OSC 7 (if cwd changed) A B + * exec (command) → C + * shell ends → D (no status: unknown) if a command was still running + * + * Markers are never written while a fullscreen program owns the terminal; + * anything due then is held and written when NMSh owns the screen again. + */ + +const ST = '\u001B\\'; + +export interface SemanticSupport { + /** OSC 133 command zones. */ + marks: boolean; + /** OSC 7 current working directory. */ + cwd: boolean; +} + +/** + * Hosts documented to understand these sequences (and multiplexers, which + * consume them for their own pane state). Unknown hosts get nothing. + * NMSH_SEMANTIC=0 turns both off; NMSH_SEMANTIC=1 forces both on. + */ +export function semanticSupport(env: NodeJS.ProcessEnv = process.env): SemanticSupport { + if (env.NMSH_SEMANTIC === '0' || env.TERM === 'dumb') return {marks: false, cwd: false}; + if (env.NMSH_SEMANTIC === '1') return {marks: true, cwd: true}; + if (env.TMUX || /^(tmux|screen)/u.test(env.TERM ?? '')) return {marks: Boolean(env.TMUX), cwd: true}; + if (env.TERM_PROGRAM === 'Apple_Terminal') return {marks: false, cwd: true}; + const profile = terminalProfile(env); + const known = profile === 'ghostty' || profile === 'kitty' || profile === 'wezterm' || profile === 'iterm2' || profile === 'windows-terminal'; + return {marks: known, cwd: known}; +} + +const UNRESERVED = /[A-Za-z0-9\-._~/]/u; + +/** `file://host/path` with every byte outside the unreserved set (and `/`) percent-encoded. */ +export function osc7(cwd: string, host = osHostname()): string | undefined { + if (!cwd.startsWith('/') || cwd.length > 4096 || /[\u0000-\u001f\u007f]/u.test(cwd)) return undefined; + const safeHost = /^[A-Za-z0-9.-]{1,253}$/u.test(host) ? host : ''; + let path = ''; + for (const byte of Buffer.from(cwd, 'utf8')) { + const character = String.fromCharCode(byte); + path += byte < 0x80 && UNRESERVED.test(character) ? character : `%${byte.toString(16).toUpperCase().padStart(2, '0')}`; + } + return `\u001B]7;file://${safeHost}${path}${ST}`; +} + +export const osc133 = (kind: 'A' | 'B' | 'C' | 'D', status?: number): string => + `\u001B]133;${kind}${kind === 'D' && status !== undefined && Number.isInteger(status) ? `;${status}` : ''}${ST}`; + +export class HostSemantics { + private zone: 'none' | 'prompt' | 'running' = 'none'; + private lastCwd?: string; + private pending = ''; + + constructor(private readonly support: SemanticSupport, private readonly write: (data: string) => void, + private readonly owned: () => boolean, private readonly host = osHostname()) {} + + get state(): 'none' | 'prompt' | 'running' { return this.zone; } + + private emit(data: string): void { + if (!data) return; + this.pending += data; + this.flush(); + } + + /** Writes anything held back once NMSh owns the screen (never mid fullscreen program). */ + flush(): void { + if (!this.pending || !this.owned()) return; + const data = this.pending; + this.pending = ''; + this.write(data); + } + + /** The shell reported readiness (OSC 777 status;cwd). */ + prompt(cwd: string, status: number): void { + let out = ''; + if (this.support.marks && this.zone === 'running') out += osc133('D', status); + if (this.support.cwd && cwd !== this.lastCwd) { + const sequence = osc7(cwd, this.host); + if (sequence) { out += sequence; this.lastCwd = cwd; } + } + if (this.support.marks) out += `${osc133('A')}${osc133('B')}`; + this.zone = 'prompt'; + this.emit(out); + } + + /** The shell reported a command about to run (OSC 777 exec). */ + exec(): void { + if (this.zone !== 'prompt') return; + this.zone = 'running'; + if (this.support.marks) this.emit(osc133('C')); + } + + /** The shell ended or was replaced: a still-open command zone is closed without inventing a status. */ + end(): void { + if (this.zone === 'running' && this.support.marks) this.emit(osc133('D')); + this.zone = 'none'; + this.lastCwd = undefined; + } +} diff --git a/src/host/terminalHost.ts b/src/host/terminalHost.ts index 3d4d897b..0f370262 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,12 @@ 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; + /** Factual /appearance copy for hosts NMSh does not configure. */ + appearanceGuidance?: string; /** How to open a new window running `argv`, if this host supports it. */ newWindow?: (argv: readonly string[]) => {command: string; args: string[]}; } @@ -41,24 +49,43 @@ 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) { + if (program === 'iTerm.app' && platform === 'darwin') { + // Fixed AppleScript in the running iTerm2; the command is one quoted shell string, like Terminal.app. + return {capabilities, integration, keyboardGuidance: guidance, name: 'iTerm2', newWindow: argv => ({command: 'osascript', args: ['-e', + `tell application "iTerm" to create window with default profile command ${appleScriptString(argv.map(shellQuote).join(' '))}`]})}; + } + if (program === 'WezTerm' || env.WEZTERM_PANE) { + // `wezterm cli` talks to the running mux; failure falls back like any unsupported host. + return {capabilities, integration, keyboardGuidance: guidance, name: 'WezTerm', newWindow: argv => ({command: 'wezterm', args: ['cli', 'spawn', '--new-window', '--', ...argv]})}; + } + if (env.WT_SESSION && !env.TERM_PROGRAM) { + // Reachable from WSL when Windows Terminal forwards WT_SESSION; NMSh does not drive Windows from Linux. + return {capabilities, integration, keyboardGuidance: guidance, name: 'Windows Terminal'}; + } + if (env.KITTY_WINDOW_ID || program === 'kitty') { // 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'}; + // Zed exposes no documented way to open a command in a new integrated + // terminal, so sessions beyond the first use /resume or `nmsh --attach`. + if (program === 'zed' || env.ZED_TERM) return {capabilities, integration, keyboardGuidance: guidance, name: 'Zed', + appearanceGuidance: 'Appearance is configured by Zed.'}; + return {capabilities, integration, keyboardGuidance: guidance, name: program || 'this terminal'}; } export type Spawner = (command: string, args: string[], timeoutMs?: number) => Promise; diff --git a/src/idle/CellGrid.ts b/src/idle/CellGrid.ts new file mode 100644 index 00000000..c3f74adb --- /dev/null +++ b/src/idle/CellGrid.ts @@ -0,0 +1,101 @@ +import {colorEscape, type Rgb} from '../chroma/escape.js'; +import type {ColorLevel} from '../presentation/capabilities.js'; + +/** + * A reusable frame buffer for idle visuals: one glyph and packed 0xRRGGBB + * foreground/background per cell. It is reallocated only when the size + * changes, so animation frames do not allocate per cell. Rows are serialized + * with an SGR only where color changes; the renderer then rewrites only the + * rows that differ from the previous frame. + */ +export const NO_COLOR_VALUE = -1; + +export class CellGrid { + width = 0; + height = 0; + glyphs: string[] = []; + fg = new Int32Array(0); + bg = new Int32Array(0); + + resize(width: number, height: number): void { + const w = Math.max(1, Math.min(1024, Math.floor(width))); + const h = Math.max(1, Math.min(512, Math.floor(height))); + if (w === this.width && h === this.height) return; + this.width = w; + this.height = h; + this.glyphs = new Array(w * h).fill(' '); + this.fg = new Int32Array(w * h).fill(NO_COLOR_VALUE); + this.bg = new Int32Array(w * h).fill(NO_COLOR_VALUE); + } + + clear(background: number): void { + this.glyphs.fill(' '); + this.fg.fill(NO_COLOR_VALUE); + this.bg.fill(background); + } + + set(x: number, y: number, glyph: string, fg: number, bg?: number): void { + const cx = Math.floor(x), cy = Math.floor(y); + if (cx < 0 || cy < 0 || cx >= this.width || cy >= this.height) return; + const index = cy * this.width + cx; + this.glyphs[index] = glyph; + this.fg[index] = fg; + if (bg !== undefined) this.bg[index] = bg; + } + + /** Brighter wins: used so overlapping particles never dim each other. */ + plot(x: number, y: number, glyph: string, fg: number): void { + const cx = Math.floor(x), cy = Math.floor(y); + if (cx < 0 || cy < 0 || cx >= this.width || cy >= this.height) return; + const index = cy * this.width + cx; + if (this.fg[index] !== NO_COLOR_VALUE && luminance(this.fg[index]!) > luminance(fg)) return; + this.glyphs[index] = glyph; + this.fg[index] = fg; + } + + getBg(x: number, y: number): number { + return this.bg[y * this.width + x] ?? NO_COLOR_VALUE; + } + + toRows(level: ColorLevel): string[] { + const rows: string[] = []; + for (let y = 0; y < this.height; y++) { + let line = ''; + let lastFg = Number.NaN, lastBg = Number.NaN; + for (let x = 0; x < this.width; x++) { + const index = y * this.width + x; + const fg = this.fg[index]!, bg = this.bg[index]!; + if (level !== 'none') { + if (bg !== lastBg) { line += bg === NO_COLOR_VALUE ? '\u001B[49m' : colorEscape(48, unpack(bg), level); lastBg = bg; } + // A blank cell shows only its background: no foreground escape is needed. + if (fg !== lastFg && this.glyphs[index] !== ' ') { line += fg === NO_COLOR_VALUE ? '\u001B[39m' : colorEscape(38, unpack(fg), level); lastFg = fg; } + } + line += this.glyphs[index]!; + } + rows.push(level === 'none' ? line : `${line}\u001B[0m`); + } + return rows; + } +} + +export function pack(color: Rgb): number { + return ((Math.max(0, Math.min(255, Math.round(color.red))) << 16) | (Math.max(0, Math.min(255, Math.round(color.green))) << 8) + | Math.max(0, Math.min(255, Math.round(color.blue)))) >>> 0 & 0xffffff; +} + +export function unpack(value: number): Rgb { + return {red: (value >> 16) & 0xff, green: (value >> 8) & 0xff, blue: value & 0xff}; +} + +export function luminance(value: number): number { + return (0.2126 * ((value >> 16) & 0xff) + 0.7152 * ((value >> 8) & 0xff) + 0.0722 * (value & 0xff)) / 255; +} + +/** Linear mix of packed colors, 0..1. */ +export function mixPacked(a: number, b: number, t: number): number { + const k = Math.max(0, Math.min(1, t)); + const r = ((a >> 16) & 0xff) + ((((b >> 16) & 0xff) - ((a >> 16) & 0xff)) * k); + const g = ((a >> 8) & 0xff) + ((((b >> 8) & 0xff) - ((a >> 8) & 0xff)) * k); + const bl = (a & 0xff) + (((b & 0xff) - (a & 0xff)) * k); + return ((Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(bl)) & 0xffffff; +} diff --git a/src/idle/IdleVisuals.ts b/src/idle/IdleVisuals.ts new file mode 100644 index 00000000..423a6402 --- /dev/null +++ b/src/idle/IdleVisuals.ts @@ -0,0 +1,180 @@ +import type {Key} from '../terminal/keys.js'; +import type {IdleVisualSettings, PromptConfiguration} from '../prompt/configuration.js'; +import {IDLE_COLOR_LABELS, IDLE_COLOR_SOURCES, IDLE_TIMEOUTS} from '../prompt/configuration.js'; +import {PRESET_STOPS} from '../chroma/treatment.js'; +import {hexColor, parseHexColor} from '../chroma/color.js'; +import type {Rgb} from '../chroma/escape.js'; +import {themeChromaStops} from '../prompt/prompt.js'; +import {isDeterministicPresentation} from '../presentation/environment.js'; +import type {ColorLevel} from '../presentation/capabilities.js'; +import {CellGrid} from './CellGrid.js'; +import type {ScreenCapture} from './screenCapture.js'; +import { + HIGH_MOTION, IDLE_FRAME_MS, IDLE_MODE_LABELS, IDLE_MODE_NOTES, IDLE_MODES, idlePalette, reducedMotionScene, renderScene, + type IdleMode, type IdlePalette, +} from './scenes.js'; +import {framePanel} from '../ui/PanelShell.js'; +import {renderControls} from '../ui/controls.js'; +import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; +import {GLYPHS} from '../ui/glyphs.js'; +import {truncateAnsi} from '../util/text.js'; + +/** + * Idle visuals ("Screensaver"): a presentation-only overlay NMSh draws while + * it owns the terminal and nothing is happening. It never touches transcript, + * draft, selection, scroll position or session state; dismissing it repaints + * the exact presentation underneath. + */ + +/** The seed used for every idle scene; deterministic captures also fix time. */ +export const IDLE_SEED = 0x4e4d5348; + +export function timeoutLabel(minutes: number): string { + return minutes === 0 ? 'Never' : `${minutes} minute${minutes === 1 ? '' : 's'}`; +} + +/** + * Follow Chroma / Theme: the active Chroma palette when Chroma is on, + * otherwise the current theme's own colors. Theme only always uses the + * theme. Custom uses the idle visuals' own stops. + */ +export function idleStops(configuration: PromptConfiguration, source = configuration.idleVisuals.colorSource): Rgb[] { + const presentation = configuration.presentation; + const theme = () => themeChromaStops(configuration.nmsh.palette, configuration.nmsh.vibrance); + if (source === 'custom') { + const custom = configuration.idleVisuals.customStops.map(hex => parseHexColor(hex)).filter((color): color is Rgb => Boolean(color)); + if (custom.length) return custom; + } + if (source === 'theme' || presentation.preset === 'off' || presentation.preset === 'theme') return theme(); + const hexes = presentation.preset === 'custom' ? presentation.customStops : PRESET_STOPS[presentation.preset]; + const stops = hexes.map(hex => parseHexColor(hex)).filter((color): color is Rgb => Boolean(color)); + return stops.length ? stops : theme(); +} + +export function idlePaletteFor(configuration: PromptConfiguration, source?: IdleVisualSettings['colorSource']): IdlePalette { + return idlePalette(idleStops(configuration, source)); +} + +/** Reduced Motion holds scenes still (high-motion modes become a still star field); Effects Off disables idle visuals. */ +export interface IdleMotion {still: boolean; disabled: boolean} + +export function idleMotion(configuration: PromptConfiguration, env: NodeJS.ProcessEnv = process.env): IdleMotion { + return {still: configuration.presentation.reducedMotion || env.NMSH_REDUCED_MOTION === '1', disabled: configuration.presentation.effectsOff}; +} + +/** The scene actually drawn for a mode under the motion preference. */ +export function effectiveMode(mode: IdleMode, motion: IdleMotion): IdleMode { + return motion.still ? reducedMotionScene(mode) : mode; +} + +/** + * Scene time. Ordinary runs use elapsed wall time; NMSH_DETERMINISTIC uses + * the frame count at the mode's cadence plus NMSH_IDLE_START_MS, so demos and + * tests capture identical frames without monkeypatching clocks. + */ +export function sceneTime(elapsedMs: number, frame: number, mode: IdleMode, env: NodeJS.ProcessEnv = process.env): number { + if (!isDeterministicPresentation()) return Math.max(0, elapsedMs); + const start = Number(env.NMSH_IDLE_START_MS ?? 0); + return (Number.isFinite(start) ? Math.max(0, start) : 0) + frame * IDLE_FRAME_MS[mode]; +} + +export interface IdleFrameInput { + mode: IdleMode; width: number; height: number; time: number; palette: IdlePalette; level: ColorLevel; nerd: boolean; + /** The captured visible screen, for screen-saver modes. */ + capture?: ScreenCapture; +} + +/** One frame as terminal rows, through the shared grid. */ +export function idleFrameRows(grid: CellGrid, input: IdleFrameInput): string[] { + grid.resize(input.width, input.height); + renderScene(input.mode, grid, {time: input.time, seed: IDLE_SEED, palette: input.palette, level: input.level, nerd: input.nerd, ...(input.capture ? {capture: input.capture} : {})}); + return grid.toRows(input.level); +} + +// ---- /screensaver gallery -------------------------------------------------------- + +type GalleryRow = 'mode' | 'color' | 'timeout' | 'preview'; +const ROWS: readonly GalleryRow[] = ['mode', 'color', 'timeout', 'preview']; + +export interface ScreensaverPanelState {selected: number; startedAt: number} + +export const SCREENSAVER_MIN_SIZE = {columns: 48, rows: 16} as const; + +export function createScreensaverPanel(now: number): ScreensaverPanelState { + return {selected: 0, startedAt: now}; +} + +export type ScreensaverAction = {kind: 'close'} | {kind: 'start'} | {kind: 'replay'} | {kind: 'editColors'} | {kind: 'change'; settings: IdleVisualSettings} | undefined; + +/** Choosing Custom starts from the colors in effect, so nothing jumps; existing custom stops are kept. */ +export function withIdleColorSource(configuration: PromptConfiguration, colorSource: IdleVisualSettings['colorSource']): IdleVisualSettings { + const settings = configuration.idleVisuals; + const customStops = colorSource === 'custom' && !settings.customStops.length + ? idleStops(configuration, 'appearance').slice(0, 8).map(hexColor) : settings.customStops; + return {...settings, colorSource, customStops: colorSource === 'custom' && customStops.length < 2 ? [...PRESET_STOPS.lavender] : customStops}; +} + +const cycle = (values: readonly T[], value: T, delta: number): T => values[(values.indexOf(value) + delta + values.length) % values.length]!; + +/** ↑↓ rows, ←→ change (saved immediately, like Config rows), Enter on Start preview runs it full screen. */ +export function screensaverKey(state: ScreensaverPanelState, key: Key, settings: IdleVisualSettings, configuration?: PromptConfiguration): ScreensaverAction { + if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'close'}; + // Lowercase r restarts the visible preview from a pristine source (the gallery has no free-text field). + if (key.kind === 'text' && key.value === 'r') return {kind: 'replay'}; + if (key.kind === 'up') state.selected = (state.selected + ROWS.length - 1) % ROWS.length; + else if (key.kind === 'down') state.selected = (state.selected + 1) % ROWS.length; + else if (key.kind === 'enter' || (key.kind === 'text' && key.value.toLowerCase() === 'p')) { + if (ROWS[state.selected] === 'preview' || key.kind === 'text') return {kind: 'start'}; + if (ROWS[state.selected] === 'color' && settings.colorSource === 'custom') return {kind: 'editColors'}; + state.selected = ROWS.indexOf('preview'); + } else if (key.kind === 'left' || key.kind === 'right') { + const delta = key.kind === 'left' ? -1 : 1; + switch (ROWS[state.selected]) { + case 'mode': return {kind: 'change', settings: {...settings, mode: cycle(IDLE_MODES, settings.mode, delta)}}; + case 'color': { + const colorSource = cycle(IDLE_COLOR_SOURCES, settings.colorSource, delta); + return {kind: 'change', settings: configuration ? withIdleColorSource({...configuration, idleVisuals: settings}, colorSource) : {...settings, colorSource}}; + } + case 'timeout': return {kind: 'change', settings: {...settings, timeout: cycle(IDLE_TIMEOUTS, settings.timeout, delta)}}; + default: return undefined; + } + } + return undefined; +} + +export interface ScreensaverPanelContext { + settings: IdleVisualSettings; + motion: IdleMotion; + /** The real renderer's rows for the selected mode at this moment. */ + preview: readonly string[]; +} + +export function renderScreensaverPanel(state: ScreensaverPanelState, columns: number, height: number, context: ScreensaverPanelContext): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const reset = '\u001B[0m'; + const {settings, motion} = context; + const out: string[] = [` ${primary}Screensaver${reset} ${subtle}idle visuals inside NMSh only · not an OS screensaver${reset}`, '']; + const row = (index: number, label: string, value: string) => { + const selected = state.selected === index; + return ` ${selected ? `${accent}${GLYPHS.selection}` : ' '} ${focusForeground(selected)}${label.padEnd(14)}${reset}${selected && index < 3 ? `${accent}‹ ${value} ›` : `${secondary}${value}`}${reset}`; + }; + out.push(row(0, 'Mode', IDLE_MODE_LABELS[settings.mode])); + out.push(row(1, 'Colors', `${IDLE_COLOR_LABELS[settings.colorSource]}${settings.colorSource === 'custom' ? ' · Enter edits' : ''}`)); + out.push(row(2, 'Start after', timeoutLabel(settings.timeout))); + out.push(row(3, 'Start preview', 'Enter · any key or mouse stops it')); + out.push('', ` ${subtle}${IDLE_MODE_NOTES[settings.mode]}${reset}`); + if (motion.disabled) out.push(` ${subtle}Decorative effects are Off: idle visuals stay off until they are On again.${reset}`); + else if (motion.still) out.push(` ${subtle}Reduced Motion: shown still${HIGH_MOTION.has(settings.mode) ? ' as a calm star field' : ''}.${reset}`); + if (context.preview.length) out.push('', ...context.preview.map(line => ` ${line}`)); + const replayable = !motion.disabled && !motion.still; + out.push('', renderControls([['↑↓', 'select'], ['←→', 'change'], ...(replayable ? [['r', 'replay'] as [string, string]] : []), ['Enter', 'preview'], ['Esc', 'close']])); + return framePanel(out.map(line => truncateAnsi(line, columns)), columns).slice(0, Math.max(1, height)); +} + +/** Preview size inside the panel: bounded so it stays cheap at any terminal size. */ +export function previewSize(columns: number, height: number): {width: number; height: number} { + return {width: Math.max(10, Math.min(72, columns - 6)), height: Math.max(3, Math.min(12, height - 14))}; +} diff --git a/src/idle/catSprite.ts b/src/idle/catSprite.ts new file mode 100644 index 00000000..e6583d8f --- /dev/null +++ b/src/idle/catSprite.ts @@ -0,0 +1,62 @@ +/** + * The NMSh cat (Vespyr) as pixels: the approved Welcome/idle sprite, one + * source for both the bouncing-Vespyr scene and raiseCatError. L is body, E an + * eye; every two pixel rows make one terminal row (half blocks). Poses are + * derived from the same pixels so the silhouette (ears, eyes, body, tail, + * four legs) is always recognizably the same cat. + */ +export const CAT_PIXELS: readonly string[] = [ + '.L...L........', + '.LLLLL........', + '.LELEL.......L', + '.LELEL......L.', + '.LLLLLLLLLLL..', + '..LLLLLLLLLL..', + '..LLLLLLLLLL..', + '..L.L....L.L..', +]; +export const CAT_CELL_WIDTH = 14; +export const CAT_CELL_HEIGHT = 4; +export const CAT_BODY = 0xac96e6; +export const CAT_EYE = 0x161220; +/** The plain-character cat for NO_COLOR / no-color terminals (7 × 3). */ +export const CAT_ASCII: readonly string[] = [' /\\_/\\ ', '( o.o )', ' > ^ < ']; +export const CAT_ASCII_BLINK: readonly string[] = [' /\\_/\\ ', '( -.- )', ' > ^ < ']; + +export type CatPose = 'idle' | 'blink' | 'walkA' | 'walkB' | 'tail' | 'crouch' | 'jump' | 'sit' | 'land' | 'paw'; + +const set = (row: string, column: number, value: string) => `${row.slice(0, column)}${value}${row.slice(column + 1)}`; + +export function catPixels(pose: CatPose): string[] { + const base = [...CAT_PIXELS]; + switch (pose) { + case 'blink': return base.map((row, i) => (i === 2 || i === 3 ? row.replace(/E/gu, 'L') : row)); + case 'walkA': base[7] = '..LL.....LL...'; return base; + case 'walkB': base[7] = '...L.L..L.L...'; return base; + case 'tail': base[0] = set(base[0]!, 13, 'L'); base[1] = set(base[1]!, 12, 'L'); base[2] = '.LELEL......L.'; base[3] = '.LELEL........'; return base; + case 'paw': base[7] = '..L.L.....LLL.'; base[4] = '.LLLLLLLLLLLLL'; return base; + case 'sit': base[7] = '..LL......LL..'; base[3] = '.LELEL.......L'; return base; + case 'jump': base[7] = '..............'; base[6] = '..L.LLLLLL.L..'; base[0] = set(base[0]!, 13, 'L'); return base; + case 'crouch': case 'land': return ['..............', ...base.slice(0, 6), '..L.L....L.L..']; + default: return base; + } +} + +export interface CatCell {dx: number; dy: number; glyph: '▀' | '▄'; fg: number; bg?: number} + +/** Terminal cells for a pose; the sprite faces left unless mirrored. */ +export function catCells(pose: CatPose, facingRight: boolean): CatCell[] { + const pixels = catPixels(pose); + const color = (pixel: string) => (pixel === 'L' ? CAT_BODY : pixel === 'E' ? CAT_EYE : undefined); + const cells: CatCell[] = []; + for (let row = 0; row < CAT_CELL_HEIGHT; row += 1) { + for (let column = 0; column < CAT_CELL_WIDTH; column += 1) { + const source = facingRight ? CAT_CELL_WIDTH - 1 - column : column; + const top = color(pixels[row * 2]![source]!), bottom = color(pixels[row * 2 + 1]![source]!); + if (top === undefined && bottom === undefined) continue; + if (top !== undefined) cells.push({dx: column, dy: row, glyph: '▀', fg: top, ...(bottom !== undefined ? {bg: bottom} : {})}); + else cells.push({dx: column, dy: row, glyph: '▄', fg: bottom!}); + } + } + return cells; +} diff --git a/src/idle/scenes.ts b/src/idle/scenes.ts new file mode 100644 index 00000000..1eba2e70 --- /dev/null +++ b/src/idle/scenes.ts @@ -0,0 +1,576 @@ +import {CellGrid, mixPacked, NO_COLOR_VALUE, pack} from './CellGrid.js'; +import {fromOklch, toOklch} from '../chroma/color.js'; +import type {Rgb} from '../chroma/escape.js'; +import type {ColorLevel} from '../presentation/capabilities.js'; +import {UI_COLORS} from '../ui/palette.js'; +import {CAT_PIXELS} from './catSprite.js'; +import type {ScreenCapture} from './screenCapture.js'; +import {effectLoops, renderScreenEffect, type ScreenEffectId} from './screenEffects.js'; + +/** + * Idle visuals: deterministic, bounded, terminal-native scenes. Every scene is + * a pure function of (size, time, seed, palette): no randomness, no wall + * clock, no allocation beyond the reused CellGrid. Motion is calm (no rapid + * flashing) and particle counts are capped independently of terminal size. + */ +export const IDLE_MODES = ['aurora', 'deepSpace', 'warp', 'rain', 'sparkles', 'fireworks', 'vespyr', 'random', 'blackHole', 'screenFireworks', 'circletastic', 'raiseCatError'] as const; +export type IdleMode = typeof IDLE_MODES[number]; +export const IDLE_MODE_LABELS: Record = { + aurora: 'Aurora Drift', deepSpace: 'Deep Space', warp: 'Warp Starfield', rain: 'Rain', sparkles: 'Sparkles', + fireworks: 'Night Fireworks', vespyr: 'Bouncing Vespyr', + random: 'Random', blackHole: 'Black Hole', screenFireworks: 'Fireworks', circletastic: 'Circletastic', raiseCatError: 'raiseCatError', +}; +export const IDLE_MODE_NOTES: Record = { + aurora: 'slow aurora curtains over a starry night sky', + deepSpace: 'a calm field of distant stars with gentle parallax', + warp: 'stars streaking outward from a vanishing point', + rain: 'soft falling light streaks', + sparkles: 'sparse sparkles that brighten and fade', + fireworks: 'occasional bursts with gentle gravity', + vespyr: 'Vespyr the NMSh cat bouncing around', + random: 'one of all available screen savers, changing only after a full loop', + blackHole: 'your screen text spirals into a black hole, then rebuilds', + screenFireworks: 'shells launch across your screen and burst through its text', + circletastic: 'your screen text gathers into spinning rings, flies apart, and reforms', + raiseCatError: 'a cat wanders your screen and raises silly, fictional errors about what it finds', +}; +/** Calm scenes repaint less often; the ceiling is 10 frames per second. */ +export const IDLE_FRAME_MS: Record = { + aurora: 160, deepSpace: 250, warp: 100, rain: 100, sparkles: 150, fireworks: 100, vespyr: 125, + random: 66, blackHole: 66, screenFireworks: 66, circletastic: 66, raiseCatError: 66, +}; +/** Modes that become a still calm field under Reduced Motion. */ +export const HIGH_MOTION: ReadonlySet = new Set(['warp', 'rain', 'sparkles', 'fireworks', 'vespyr', 'random', 'blackHole', 'screenFireworks', 'circletastic', 'raiseCatError']); +/** Modes that animate a capture of the visible screen. */ +export const SCREEN_MODE_EFFECT: Partial> = {blackHole: 'blackHole', screenFireworks: 'fireworks', circletastic: 'circletastic', raiseCatError: 'raiseCatError'}; + +export interface IdlePalette { + /** Accent stops, packed 0xRRGGBB. */ + stops: number[]; + /** Night sky near the horizon and at the top. */ + sky: number; + skyTop: number; + star: number; + warm: number; +} + +export interface SceneOptions { + /** Elapsed scene time in ms (deterministic under NMSH_DETERMINISTIC). */ + time: number; + seed: number; + palette: IdlePalette; + level: ColorLevel; + /** Nerd/Unicode glyphs, or Safe ASCII-leaning glyphs. */ + nerd: boolean; + /** The captured visible screen; present for screen-saver modes. */ + capture?: ScreenCapture; +} + +/** Palette for idle visuals from accent stops: a dark sky tinted toward their hue. */ +export function idlePalette(stops: readonly Rgb[]): IdlePalette { + const list = stops.length ? stops : [{red: 166, green: 124, blue: 243}]; + const hues = list.map(color => toOklch(color)); + const hue = hues[0]!.h; + return { + stops: list.slice(0, 6).map(pack), + sky: pack(fromOklch({l: 0.17, c: 0.035, h: hue})), + skyTop: pack(fromOklch({l: 0.1, c: 0.025, h: hue})), + star: pack(fromOklch({l: 0.96, c: 0.015, h: hue})), + warm: pack(fromOklch({l: 0.86, c: 0.07, h: 70})), + }; +} + +/** Integer hash to [0, 1). */ +export function noise(seed: number, index: number, salt = 0): number { + let n = (seed ^ Math.imul(index + 1, 0x9e3779b1) ^ Math.imul(salt + 7, 0x85ebca6b)) >>> 0; + n = Math.imul(n ^ (n >>> 16), 0x85ebca6b); + n = Math.imul(n ^ (n >>> 13), 0xc2b2ae35); + return ((n ^ (n >>> 16)) >>> 0) / 0x100000000; +} + +/** Smooth 1-D value noise in [0, 1). */ +function smooth(x: number, seed: number): number { + const i = Math.floor(x), f = x - i; + const u = f * f * (3 - 2 * f); + return noise(seed, i) * (1 - u) + noise(seed, i + 1) * u; +} + +const smoothstep = (a: number, b: number, x: number) => { + const t = Math.max(0, Math.min(1, (x - a) / (b - a))); + return t * t * (3 - 2 * t); +}; +const TAU = Math.PI * 2; + +function skyRow(palette: IdlePalette, y: number, height: number): number { + return mixPacked(palette.skyTop, palette.sky, Math.pow(y / Math.max(1, height - 1), 1.3)); +} + +function fillSky(grid: CellGrid, palette: IdlePalette, level: ColorLevel): void { + if (level === 'none') { grid.clear(NO_COLOR_VALUE); return; } + grid.clear(NO_COLOR_VALUE); + for (let y = 0; y < grid.height; y++) { + const color = skyRow(palette, y, grid.height); + for (let x = 0; x < grid.width; x++) grid.bg[y * grid.width + x] = color; + } +} + +/** Twinkle stays gentle: amplitude 15 %, periods of several seconds, never a blink. */ +function drawStars(grid: CellGrid, options: SceneOptions, density: number, maxStars: number, salt: number, drift = 0): void { + const {seed, palette, nerd, time} = options; + const count = Math.min(maxStars, Math.round(grid.width * grid.height * density)); + for (let i = 0; i < count; i++) { + const layer = noise(seed, i, salt + 3); + const speed = drift * (0.25 + layer); + const x = ((noise(seed, i, salt) * grid.width + time / 1000 * speed) % grid.width + grid.width) % grid.width; + const y = noise(seed, i, salt + 1) * grid.height; + const base = 0.3 + 0.7 * Math.pow(noise(seed, i, salt + 2), 1.6) * (0.45 + 0.55 * layer); + const twinkle = 0.85 + 0.15 * Math.sin(time / (2200 + 2600 * noise(seed, i, salt + 4)) * TAU + noise(seed, i, salt + 5) * TAU); + const brightness = base * twinkle; + const big = noise(seed, i, salt + 6) > 0.985 && layer > 0.5; + const tint = noise(seed, i, salt + 7); + const hue = tint > 0.94 ? palette.warm : tint > 0.75 ? palette.stops[i % palette.stops.length]! : palette.star; + const sky = grid.getBg(Math.floor(x), Math.floor(y)); + const color = mixPacked(sky === NO_COLOR_VALUE ? 0 : sky, hue, Math.min(1, brightness)); + const glyph = big ? (nerd ? '✦' : '*') : brightness > 0.7 ? (nerd ? '•' : '+') : (nerd ? '·' : '.'); + grid.plot(x, y, glyph, options.level === 'none' ? NO_COLOR_VALUE : color); + } +} + +// ---- Aurora Drift ------------------------------------------------------------- + +/** + * Aurora as a few layered bands across the sky. Each band has a folding lower + * edge, vertical rays that fade upward from that edge, and bright patches + * that drift slowly sideways. Colors run from one stop at the edge to the + * next stop at the top of the rays; light adds over the night sky. + */ +interface AuroraLayer {base: number; fold1: number; fold2: number; height: number; drift: number; bottom: number; top: number; breathe: number; salt: number; strength: number} + +function auroraLayers(options: SceneOptions): AuroraLayer[] { + const {seed, palette} = options; + const t = options.time / 1000; + const n = palette.stops.length; + return Array.from({length: 3}, (_, l) => ({ + base: 0.5 + 0.12 * l - 0.06 * noise(seed, l, 111), + fold1: t / (26 + 10 * noise(seed, l, 112)) * TAU + l * 2.3, + fold2: t / (17 + 8 * noise(seed, l, 113)) * TAU + l * 0.9, + height: 0.34 - 0.06 * l, + drift: t * (0.9 + 0.7 * noise(seed, l, 114)) * (l % 2 ? -1 : 1), + bottom: palette.stops[l % n]!, + top: palette.stops[(l + 1) % n]!, + breathe: 0.78 + 0.22 * Math.sin(t / (13 + 6 * noise(seed, l, 115)) * TAU + l * 1.9), + salt: seed + 131 * (l + 1), + strength: 1 - 0.22 * l, + })); +} + +interface AuroraColumn {edge: number; height: number; patch: number; ray: number} + +/** Per-column layer geometry, computed once per frame per column. */ +function auroraColumn(x: number, width: number, layer: AuroraLayer): AuroraColumn { + const xn = x / Math.max(1, width); + const edge = layer.base + 0.07 * Math.sin(xn * 5.1 + layer.fold1) + 0.035 * Math.sin(xn * 13.7 - layer.fold2) + + 0.03 * (smooth(x * 0.08 + layer.drift * 0.3, layer.salt) - 0.5); + const height = layer.height * (0.65 + 0.55 * smooth(x * 0.05 + layer.drift * 0.2, layer.salt + 1)); + // Patches: bright stretches of curtain that drift sideways, separated by quieter sky. + const patch = smoothstep(0.28, 0.72, smooth(x * 0.03 + layer.drift * 0.08, layer.salt + 2)) * 0.85 + + 0.15 * smooth(x * 0.11 - layer.drift * 0.05, layer.salt + 3); + // Rays: fine vertical streaks, mostly a function of x, changing slowly. + const ray = Math.pow(0.35 + 0.65 * smooth(x * 0.75 + layer.drift * 0.25, layer.salt + 4), 1.4); + return {edge, height, patch, ray}; +} + +function auroraLight(yn: number, layer: AuroraLayer, column: AuroraColumn): {intensity: number; mix: number} { + const d = (column.edge - yn) / column.height; + let curtain = 0; + // Rays fade upward from the edge; a luminous rim sits right at it; light falls off softly below. + if (d >= 0 && d <= 1) curtain = (Math.pow(1 - d, 1.8) + 0.55 * Math.exp(-Math.pow(d / 0.07, 2))) * column.ray; + else if (d < 0) curtain = 1.55 * Math.exp(-Math.pow((yn - column.edge) / 0.035, 2)) * column.ray; + const glow = 0.12 * Math.exp(-Math.pow((yn - column.edge + column.height * 0.35) / (column.height * 0.8), 2)); + return {intensity: (curtain + glow) * column.patch * layer.breathe * layer.strength, mix: Math.max(0, Math.min(1, d))}; +} + +/** Rounds each channel to a multiple of 4: invisible in a gradient, fewer distinct escapes. */ +function quantize(color: number): number { + return color & 0xfcfcfc; +} + +function aurora(grid: CellGrid, options: SceneOptions): void { + const {width, height} = grid; + const layers = auroraLayers(options); + const columns = layers.map(layer => Array.from({length: width}, (_, x) => auroraColumn(x, width, layer))); + const sample = (x: number, yn: number, sky: number): {color: number; light: number} => { + let r = (sky >> 16) & 0xff, g = (sky >> 8) & 0xff, b = sky & 0xff, light = 0; + for (let l = 0; l < layers.length; l++) { + const layer = layers[l]!; + const {intensity, mix} = auroraLight(yn, layer, columns[l]![x]!); + if (intensity < 0.004) continue; + light += intensity; + const tone = mixPacked(layer.bottom, layer.top, mix); + r += ((tone >> 16) & 0xff) * intensity; + g += ((tone >> 8) & 0xff) * intensity; + b += (tone & 0xff) * intensity; + } + return {color: ((Math.min(255, Math.round(r)) << 16) | (Math.min(255, Math.round(g)) << 8) | Math.min(255, Math.round(b))) & 0xffffff, light}; + }; + const h2 = height * 2; + if (options.level === 'none') { + grid.clear(NO_COLOR_VALUE); + drawStars(grid, options, 0.012, 160, 11); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const {light} = sample(x, (y * 2 + 1) / h2, 0); + if (light > 0.6) grid.set(x, y, '|', NO_COLOR_VALUE); + else if (light > 0.32) grid.set(x, y, ':', NO_COLOR_VALUE); + else if (light > 0.16) grid.set(x, y, '.', NO_COLOR_VALUE); + } + } + return; + } + fillSky(grid, options.palette, options.level); + const lights = new Float32Array(width * height); + for (let y = 0; y < height; y++) { + const sky = skyRow(options.palette, y, height); + for (let x = 0; x < width; x++) { + const top = sample(x, (y * 2) / h2, sky); + const bottom = sample(x, (y * 2 + 1) / h2, sky); + const index = y * width + x; + // Light quantization lets neighbouring cells share escapes; equal halves need no glyph at all. + const upper = quantize(top.color), lower = quantize(bottom.color); + grid.glyphs[index] = upper === lower ? ' ' : '▀'; + grid.fg[index] = upper; + grid.bg[index] = lower; + lights[index] = (top.light + bottom.light) / 2; + } + } + // Stars show between and behind faint curtain light. + const {seed, palette, nerd, time} = options; + const count = Math.min(240, Math.round(width * height * 0.022)); + for (let i = 0; i < count; i++) { + const x = Math.floor(noise(seed, i, 11) * width), y = Math.floor(noise(seed, i, 12) * height * 0.92); + const index = y * width + x; + const behind = lights[index]!; + if (behind > 0.35) continue; + const brightness = (0.35 + 0.65 * Math.pow(noise(seed, i, 13), 2)) * (0.85 + 0.15 * Math.sin(time / (2500 + 2500 * noise(seed, i, 14)) * TAU + i)) * (1 - behind * 2); + const bg = mixPacked(grid.fg[index]!, grid.bg[index]!, 0.5); + grid.glyphs[index] = noise(seed, i, 15) > 0.975 ? (nerd ? '✦' : '*') : (nerd ? '·' : '.'); + grid.fg[index] = mixPacked(bg, noise(seed, i, 16) > 0.9 ? palette.warm : palette.star, Math.max(0, brightness)); + grid.bg[index] = bg; + } +} + +// ---- Deep Space ----------------------------------------------------------------- + +function deepSpace(grid: CellGrid, options: SceneOptions): void { + fillSky(grid, {...options.palette, sky: mixPacked(options.palette.skyTop, 0, 0.3), skyTop: mixPacked(options.palette.skyTop, 0, 0.5)}, options.level); + // Three depth layers drift at different speeds for a slow parallax. + drawStars(grid, options, 0.06, 420, 21, 0.15); + drawStars(grid, options, 0.03, 220, 41, 0.4); + drawStars(grid, options, 0.012, 90, 61, 0.85); +} + +// ---- Warp Starfield ----------------------------------------------------------------- + +const lineGlyph = (dx: number, dy: number, nerd: boolean): string => { + if (Math.abs(dx) > Math.abs(dy) * 2) return nerd ? '─' : '-'; + if (Math.abs(dy) * 2 > Math.abs(dx) * 1.2) return nerd ? '│' : '|'; + return (dx > 0) === (dy > 0) ? (nerd ? '╲' : '\\') : (nerd ? '╱' : '/'); +}; + +function warpPosition(i: number, time: number, seed: number, width: number, height: number): {col: number; row: number; z: number} { + const speed = 0.16 + 0.22 * noise(seed, i, 201); + const cycle = time / 1000 * speed + noise(seed, i, 202); + const k = Math.floor(cycle); + const z = 1 - (cycle - k) * 0.94; + const angle = noise(seed, i * 977 + k, 203) * TAU; + const radius = 0.06 + 0.94 * Math.sqrt(noise(seed, i * 977 + k, 204)); + const scale = Math.min(width / 2, height) * 0.5; + return {col: width / 2 + (Math.cos(angle) * radius / z) * scale * 2, row: height / 2 + (Math.sin(angle) * radius / z) * scale, z}; +} + +function warp(grid: CellGrid, options: SceneOptions): void { + const {width, height} = grid; + fillSky(grid, {...options.palette, sky: mixPacked(options.palette.skyTop, 0, 0.4), skyTop: mixPacked(options.palette.skyTop, 0, 0.6)}, options.level); + const count = Math.min(320, Math.round(width * height * 0.07)); + const {seed, palette, nerd, time, level} = options; + for (let i = 0; i < count; i++) { + const head = warpPosition(i, time, seed, width, height); + const tail = warpPosition(i, Math.max(0, time - 220), seed, width, height); + if (tail.z < head.z) continue; // Respawned this frame: no streak across the screen. + const brightness = Math.pow(1 - head.z, 0.8); + const tint = noise(seed, i, 205) > 0.7 ? palette.stops[i % palette.stops.length]! : palette.star; + const dx = head.col - tail.col, dy = head.row - tail.row; + const length = Math.min(12, Math.max(Math.abs(dx), Math.abs(dy) * 2)); + const steps = Math.max(1, Math.round(length)); + const glyph = lineGlyph(dx, dy, nerd); + for (let s = 0; s <= steps; s++) { + const u = s / steps; + const x = tail.col + dx * u, y = tail.row + dy * u; + const fade = brightness * (0.25 + 0.75 * u); + const sky = grid.getBg(Math.max(0, Math.min(width - 1, Math.floor(x))), Math.max(0, Math.min(height - 1, Math.floor(y)))); + const color = level === 'none' ? NO_COLOR_VALUE : mixPacked(sky === NO_COLOR_VALUE ? 0 : sky, tint, Math.min(1, fade + 0.15)); + grid.plot(x, y, s === steps ? (length < 1.2 ? (head.z > 0.55 ? (nerd ? '·' : '.') : (nerd ? '•' : '*')) : glyph) : glyph, color); + } + } +} + +// ---- Rain ------------------------------------------------------------------------- + +function rain(grid: CellGrid, options: SceneOptions): void { + const {width, height} = grid; + fillSky(grid, options.palette, options.level); + const {seed, palette, nerd, time, level} = options; + const drops = Math.min(160, Math.max(4, Math.round(width * 0.4))); + for (let i = 0; i < drops; i++) { + const depth = noise(seed, i, 301); + const speed = 5 + 16 * depth; + const length = 2 + Math.round(depth * 4); + const travel = height + length; + const y = ((time / 1000 * speed + noise(seed, i, 302) * travel) % travel) - length; + const cycle = Math.floor((time / 1000 * speed + noise(seed, i, 302) * travel) / travel); + const x = Math.floor(noise(seed, i * 31 + cycle, 303) * width); + const tint = palette.stops[i % palette.stops.length]!; + for (let s = 0; s < length; s++) { + const row = Math.floor(y) - s; + if (row < 0 || row >= height) continue; + const fade = (1 - s / length) * (0.35 + 0.65 * depth); + const sky = grid.getBg(x, row); + const color = level === 'none' ? NO_COLOR_VALUE + : mixPacked(sky === NO_COLOR_VALUE ? 0 : sky, s === 0 ? mixPacked(tint, palette.star, 0.5) : tint, fade); + grid.plot(x, row, depth < 0.25 ? (nerd ? '╎' : ':') : (nerd ? '│' : '|'), color); + } + } +} + +// ---- Sparkles --------------------------------------------------------------------- + +function sparkles(grid: CellGrid, options: SceneOptions): void { + const {width, height} = grid; + fillSky(grid, options.palette, options.level); + const {seed, palette, nerd, time, level} = options; + const count = Math.min(200, Math.max(6, Math.round(width * height * 0.025))); + for (let i = 0; i < count; i++) { + const period = 2600 + 2600 * noise(seed, i, 401); + const cycle = time / period + noise(seed, i, 402); + const k = Math.floor(cycle); + const u = cycle - k; + const intensity = Math.pow(Math.sin(Math.PI * u), 2); + if (intensity < 0.12) continue; + const x = noise(seed, i * 53 + k, 403) * width + Math.sin(u * TAU) * 0.4; + const y = noise(seed, i * 53 + k, 404) * height - u * 1.2; + const glyph = intensity > 0.78 ? (nerd ? '✦' : '*') : intensity > 0.45 ? (nerd ? '✧' : '+') : (nerd ? '·' : '.'); + const tint = mixPacked(palette.stops[i % palette.stops.length]!, palette.star, intensity * 0.35); + const sky = grid.getBg(Math.max(0, Math.min(width - 1, Math.floor(x))), Math.max(0, Math.min(height - 1, Math.floor(y)))); + grid.plot(x, y, glyph, level === 'none' ? NO_COLOR_VALUE : mixPacked(sky === NO_COLOR_VALUE ? 0 : sky, tint, intensity)); + } +} + +// ---- Fireworks -------------------------------------------------------------------- + +const LAUNCH_PERIOD = 1700; +const RISE = 1100; +const BURST = 1800; + +function fireworks(grid: CellGrid, options: SceneOptions): void { + const {width, height} = grid; + fillSky(grid, {...options.palette, sky: mixPacked(options.palette.skyTop, 0, 0.3)}, options.level); + drawStars(grid, options, 0.004, 40, 501); + const {seed, palette, nerd, time, level} = options; + const latest = Math.floor(time / LAUNCH_PERIOD); + for (let k = latest - 2; k <= latest; k++) { + if (k < 0) continue; + const start = k * LAUNCH_PERIOD + noise(seed, k, 502) * 600; + const tau = time - start; + if (tau < 0 || tau > RISE + BURST) continue; + const x0 = width * (0.15 + 0.7 * noise(seed, k, 503)); + const burstY = height * (0.15 + 0.35 * noise(seed, k, 504)); + const color = palette.stops[k % palette.stops.length]!; + const second = palette.stops[(k + 1) % palette.stops.length]!; + const sky = (x: number, y: number) => { + const value = grid.getBg(Math.max(0, Math.min(width - 1, Math.floor(x))), Math.max(0, Math.min(height - 1, Math.floor(y)))); + return value === NO_COLOR_VALUE ? 0 : value; + }; + if (tau < RISE) { + const p = 1 - Math.pow(1 - tau / RISE, 2); + const y = height - 1 - (height - 1 - burstY) * p; + const x = x0 + Math.sin(p * 3) * 0.6; + grid.plot(x, y, nerd ? '•' : '*', level === 'none' ? NO_COLOR_VALUE : mixPacked(color, palette.star, 0.6)); + for (let trail = 1; trail <= 2; trail++) { + grid.plot(x, y + trail, nerd ? '·' : '.', level === 'none' ? NO_COLOR_VALUE : mixPacked(sky(x, y + trail), color, 0.55 / trail)); + } + continue; + } + const burstTime = (tau - RISE) / 1000; + const geometry = Math.floor(noise(seed, k, 505) * 3); // ring, sphere, willow + const particles = geometry === 0 ? 28 : geometry === 1 ? 36 : 24; + const gravity = geometry === 2 ? 9 : 6; + const reach = Math.min(width / 2, height) * (geometry === 2 ? 0.28 : 0.4); + // Full brightness for most of the burst, then a calm fade: the droop under gravity stays visible. + const life = (tau - RISE) / BURST; + const fade = life < 0.6 ? 1 : Math.max(0, 1 - (life - 0.6) / 0.4); + for (let j = 0; j < particles; j++) { + const angle = j / particles * TAU + noise(seed, k * 97 + j, 506) * 0.25; + // Every geometry gets a little per-particle variation so bursts never look stamped. + const speed = (geometry === 1 ? 0.45 + 0.55 * Math.sqrt(noise(seed, k * 97 + j, 507)) : 1) * (0.86 + 0.28 * noise(seed, k * 97 + j, 508)); + const travel = (1 - Math.exp(-burstTime * 2.6)) * reach * speed; + for (const [lag, dim] of (geometry === 2 ? [[0, 1], [0.12, 0.65], [0.26, 0.4], [0.4, 0.22]] : [[0, 1], [0.1, 0.6], [0.2, 0.3]]) as ReadonlyArray) { + const tb = Math.max(0, burstTime - lag); + const along = (1 - Math.exp(-tb * 2.6)) * reach * speed; + const x = x0 + Math.cos(angle) * along * 2; + const y = burstY + Math.sin(angle) * along + 0.5 * gravity * tb * tb; + // Bright and saturated: a fresh burst starts near white and settles into its color. + const tint = mixPacked(j % 3 === 0 && geometry === 1 ? second : color, palette.star, Math.max(0.25, 0.75 - burstTime * 0.8)); + const glyph = lag ? (nerd ? '·' : '.') : life < 0.25 ? (nerd ? '✦' : '*') : life < 0.7 ? (nerd ? '•' : '+') : (nerd ? '·' : '.'); + grid.plot(x, y, glyph, level === 'none' ? NO_COLOR_VALUE : mixPacked(sky(x, y), tint, fade * dim)); + } + void travel; + } + } +} + +// ---- Bouncing Vespyr -------------------------------------------------------------- + +/** The approved Vespyr pixels live in catSprite.ts (shared with raiseCatError). */ +const CAT = CAT_PIXELS; +const CAT_WIDTH = 14; +const CAT_HEIGHT = 4; +const BODY = pack({red: 172, green: 150, blue: 230}); +const EYE = pack({red: 22, green: 18, blue: 32}); +const ASCII_CAT = [' /\\_/\\ ', '( o.o )', ' > ^ < ']; + +/** Position on a bounce path: a triangle wave over [0, span]. */ +function bounce(distance: number, span: number): {value: number; forward: boolean} { + if (span <= 0) return {value: 0, forward: true}; + const period = span * 2; + const p = ((distance % period) + period) % period; + return p <= span ? {value: p, forward: true} : {value: period - p, forward: false}; +} + +export function vespyrPosition(time: number, seed: number, width: number, height: number, spriteWidth = CAT_WIDTH, spriteHeight = CAT_HEIGHT): +{x: number; y: number; facingRight: boolean; corner: boolean} { + const t = time / 1000; + const horizontal = bounce(t * 7 + noise(seed, 1, 601) * width, Math.max(0, width - spriteWidth)); + const vertical = bounce(t * 2.4 + noise(seed, 2, 601) * height, Math.max(0, height - spriteHeight)); + const nearX = horizontal.value < 0.5 || horizontal.value > width - spriteWidth - 0.5; + const nearY = vertical.value < 0.25 || vertical.value > height - spriteHeight - 0.25; + return {x: Math.round(horizontal.value), y: Math.round(vertical.value), facingRight: horizontal.forward, corner: nearX && nearY}; +} + +function vespyr(grid: CellGrid, options: SceneOptions): void { + const {width, height} = grid; + fillSky(grid, options.palette, options.level); + drawStars(grid, options, 0.01, 90, 701, 0.1); + const {time, seed, level, nerd, palette} = options; + if (level === 'none') { + const position = vespyrPosition(time, seed, width, height, 7, 3); + ASCII_CAT.forEach((line, row) => { [...line].forEach((glyph, column) => { if (glyph !== ' ') grid.set(position.x + column, position.y + row, glyph, NO_COLOR_VALUE); }); }); + return; + } + const position = vespyrPosition(time, seed, width, height); + // An occasional calm blink: closed for 160 ms every ~6.4 s. + const blinking = (time % 6400) < 160; + for (let row = 0; row < CAT_HEIGHT; row++) { + for (let column = 0; column < CAT_WIDTH; column++) { + // Mirror horizontally to face the direction of travel; never flip vertically. + const source = position.facingRight ? CAT_WIDTH - 1 - column : column; + const top = CAT[row * 2]![source]!, bottom = CAT[row * 2 + 1]![source]!; + const color = (pixel: string, upper: boolean) => pixel === 'L' ? BODY : pixel === 'E' ? (blinking && upper ? BODY : EYE) : -2; + const fg = color(top, true), bg = color(bottom, false); + if (fg === -2 && bg === -2) continue; + const x = position.x + column, y = position.y + row; + if (x < 0 || y < 0 || x >= width || y >= height) continue; + const index = y * width + x; + const sky = grid.bg[index]!; + grid.glyphs[index] = fg === -2 ? '▄' : '▀'; + grid.fg[index] = fg === -2 ? bg : fg; + grid.bg[index] = fg === -2 || bg === -2 ? sky : bg; + } + } + // Whiskers either side of the face (as in the Welcome cat), mirrored with the sprite. + for (const source of [0, 6]) { + const column = position.facingRight ? CAT_WIDTH - 1 - source : source; + grid.plot(position.x + column, position.y + 1, '=', pack({red: 150, green: 142, blue: 172})); + } + // A tiny, rare sparkle when it reaches a corner exactly. + if (position.corner) { + for (let j = 0; j < 4; j++) { + grid.plot(position.x + CAT_WIDTH / 2 + Math.cos(j * Math.PI / 2) * 9, position.y + 2 + Math.sin(j * Math.PI / 2) * 3, nerd ? '✦' : '*', palette.stops[j % palette.stops.length]!); + } + } +} + +function screenScene(effect: ScreenEffectId) { + return (grid: CellGrid, options: SceneOptions): void => { + // Without a capture there is nothing of the user's to animate: show the calm star field instead. + if (!options.capture) { deepSpace(grid, options); return; } + const {palette} = options; + renderScreenEffect(effect, grid, options.capture, {time: options.time, nerd: options.nerd, color: options.level !== 'none', + palette: {stops: palette.stops, text: 0xc8c8d4, error: pack(UI_COLORS.failure), warn: 0xe5c07b, accent: palette.stops[0] ?? 0xa67cf3}}); + }; +} + +const SCENES: Record void> = {aurora, deepSpace, warp, rain, sparkles, fireworks, vespyr, + random: screenScene('blackHole'), blackHole: screenScene('blackHole'), screenFireworks: screenScene('fireworks'), circletastic: screenScene('circletastic'), raiseCatError: screenScene('raiseCatError')}; + +/** Paint one frame of `mode` into `grid`. */ +export function renderScene(mode: IdleMode, grid: CellGrid, options: SceneOptions): void { + (SAVER_REGISTRY.find(entry => entry.id === mode)?.render ?? SCENES[mode] ?? deepSpace)(grid, options); +} + +/** The scene shown for a mode under Reduced Motion: calm modes hold still; high-motion modes become a still star field. */ +export function reducedMotionScene(mode: IdleMode): IdleMode { + return HIGH_MOTION.has(mode) ? 'deepSpace' : mode; +} + + +// ---- registry ---------------------------------------------------------------- +/** + * The canonical list of screen savers. Random derives its candidates from this + * registry (everything except itself and entries marked `randomEligible: + * false`), so registering a saver here is all a new one needs; Random has no + * list of its own. `loop` says when one natural loop has completed: + * stateful screen effects report their own loop counter, scenes that are + * pure functions of time declare their cycle length. + */ +export interface SaverDescriptor { + id: string; + /** Default true. False only for manual-only, debugging or test entries. */ + randomEligible?: boolean; + /** Stateless scene cycle in ms for a given size; ambient scenes with no natural boundary declare a fixed dwell. */ + cycleMs?: (size: {width: number; height: number}) => number; + /** Stateful screen effect whose own loop counter marks the boundary. */ + effect?: ScreenEffectId; + /** Custom renderer for a saver registered outside this file. */ + render?: (grid: CellGrid, options: SceneOptions) => void; +} + +const SPARKLE_CYCLE = 5200; +export const SAVER_REGISTRY: SaverDescriptor[] = [ + {id: 'aurora', cycleMs: () => 40_000}, {id: 'deepSpace', cycleMs: () => 30_000}, {id: 'warp', cycleMs: () => 20_000}, {id: 'rain', cycleMs: () => 20_000}, + {id: 'sparkles', cycleMs: () => SPARKLE_CYCLE * 4}, + {id: 'fireworks', cycleMs: () => LAUNCH_PERIOD * 6}, + // One full vertical round trip of the bouncing cat. + {id: 'vespyr', cycleMs: ({height}) => Math.max(1, (2 * Math.max(1, height - CAT_HEIGHT)) / 2.4) * 1000}, + {id: 'random', randomEligible: false}, + {id: 'blackHole', effect: 'blackHole'}, {id: 'screenFireworks', effect: 'fireworks'}, {id: 'circletastic', effect: 'circletastic'}, {id: 'raiseCatError', effect: 'raiseCatError'}, +]; + +export function randomCandidates(registry: readonly SaverDescriptor[] = SAVER_REGISTRY): string[] { + return registry.filter(entry => entry.id !== 'random' && entry.randomEligible !== false).map(entry => entry.id); +} + +/** Next Random choice: from the registry, never the previous one when there is a choice. */ +export function pickRandomSaver(rng: () => number, previous?: string, registry: readonly SaverDescriptor[] = SAVER_REGISTRY): string | undefined { + const all = randomCandidates(registry); + const options = all.length > 1 ? all.filter(id => id !== previous) : all; + return options.length ? options[Math.floor(rng() * options.length)] : undefined; +} + +/** True once the saver has completed one natural loop since it started (`elapsed` ms of its own scene time). */ +export function saverLoopComplete(id: string, state: {elapsed: number; width: number; height: number; capture?: ScreenCapture}, + registry: readonly SaverDescriptor[] = SAVER_REGISTRY): boolean { + const entry = registry.find(item => item.id === id); + if (!entry) return true; + if (entry.effect && state.capture) return effectLoops(state.capture, entry.effect) >= 1; + return entry.cycleMs ? state.elapsed >= entry.cycleMs(state) : false; +} diff --git a/src/idle/screenCapture.ts b/src/idle/screenCapture.ts new file mode 100644 index 00000000..0c0479c8 --- /dev/null +++ b/src/idle/screenCapture.ts @@ -0,0 +1,102 @@ +import {NO_COLOR_VALUE, pack} from './CellGrid.js'; +import {stripAnsi} from '../util/text.js'; +import {displayWidth} from '../util/text.js'; + +/** + * One captured visual frame: what the user is looking at when a screen saver + * starts, as cells (glyph + packed foreground). Presentation input only: it is + * parsed from the rows the renderer last wrote and never feeds back into the + * transcript, PTY, history or journals. Backgrounds are deliberately dropped, + * so effects sit on the host's own (possibly transparent) background. + */ +export interface ScreenCapture { + width: number; + height: number; + glyphs: string[]; + fg: Int32Array; + /** Authored background per cell (badges, selection, panels); NO_COLOR_VALUE where the host background shows through. */ + bg: Int32Array; + /** Per-effect state, created lazily by the effect and discarded with the capture. */ + instances: Record; + seed: number; +} + +const BASIC = [0x000000, 0xcd3131, 0x0dbc79, 0xe5e510, 0x2472c8, 0xbc3fbc, 0x11a8cd, 0xe5e5e5, + 0x666666, 0xf14c4c, 0x23d18b, 0xf5f543, 0x3b8eea, 0xd670d6, 0x29b8db, 0xffffff]; + +function xterm256(n: number): number { + if (n < 16) return BASIC[n]!; + if (n >= 232) { const v = 8 + (n - 232) * 10; return pack({red: v, green: v, blue: v}); } + const k = n - 16; const level = (i: number) => (i === 0 ? 0 : 55 + i * 40); + return pack({red: level(Math.floor(k / 36)), green: level(Math.floor(k / 6) % 6), blue: level(k % 6)}); +} + +/** Parse SGR foreground colors from rendered rows; every other escape is dropped. */ +export function captureFromRows(rows: readonly string[], width: number, height: number, seed: number): ScreenCapture { + const glyphs = new Array(width * height).fill(' '); + const fg = new Int32Array(width * height).fill(NO_COLOR_VALUE); + const bg = new Int32Array(width * height).fill(NO_COLOR_VALUE); + for (let y = 0; y < Math.min(height, rows.length); y += 1) { + let color = NO_COLOR_VALUE, back = NO_COLOR_VALUE, inverse = false; + let x = 0; + const line = rows[y]!.replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/gu, ''); + for (let i = 0; i < line.length && x < width;) { + if (line[i] === '\u001b' && line[i + 1] === '[') { + const end = line.slice(i + 2).search(/[@-~]/u); + if (end < 0) break; + const final = line[i + 2 + end]!; + if (final === 'm') { + const p = line.slice(i + 2, i + 2 + end).split(';').map(part => Number(part) || 0); + for (let k = 0; k < p.length; k += 1) { + const code = p[k]!; + if (code === 0) { color = NO_COLOR_VALUE; back = NO_COLOR_VALUE; inverse = false; } + else if (code === 39) color = NO_COLOR_VALUE; + else if (code === 49) back = NO_COLOR_VALUE; + else if (code === 7) inverse = true; + else if (code === 27) inverse = false; + else if (code >= 40 && code <= 47) back = BASIC[code - 40]!; + else if (code >= 100 && code <= 107) back = BASIC[code - 100 + 8]!; + else if (code === 48 && p[k + 1] === 2) { back = pack({red: p[k + 2] ?? 0, green: p[k + 3] ?? 0, blue: p[k + 4] ?? 0}); k += 4; } + else if (code === 48 && p[k + 1] === 5) { back = xterm256(p[k + 2] ?? 0); k += 2; } + else if (code >= 30 && code <= 37) color = BASIC[code - 30]!; + else if (code >= 90 && code <= 97) color = BASIC[code - 90 + 8]!; + else if (code === 38 && p[k + 1] === 2) { color = pack({red: p[k + 2] ?? 0, green: p[k + 3] ?? 0, blue: p[k + 4] ?? 0}); k += 4; } + else if (code === 38 && p[k + 1] === 5) { color = xterm256(p[k + 2] ?? 0); k += 2; } + } + } + i += end + 3; + continue; + } + const point = String.fromCodePoint(line.codePointAt(i)!); + i += point.length; + if (/[\u0000-\u001f\u007f]/u.test(point)) continue; + const cells = Math.max(1, displayWidth(point)); + const index = y * width + x; + // Reverse video swaps the pair; an unset side keeps the host's default (-1). + const cellFg = inverse ? back : color, cellBg = inverse ? color : back; + if (point !== ' ') { glyphs[index] = point; fg[index] = cellFg; } + bg[index] = cellBg; + if (cells === 2 && x + 1 < width) bg[index + 1] = cellBg; + // A wide glyph owns its second cell; leave it blank so nothing else is drawn through it. + x += cells; + } + } + return {width, height, glyphs, fg, bg, instances: {}, seed}; +} + +/** A smaller view for the gallery preview: the bottom-left of the screen, where the prompt and latest output are. */ +export function cropCapture(capture: ScreenCapture, width: number, height: number): ScreenCapture { + const w = Math.min(width, capture.width), h = Math.min(height, capture.height); + const glyphs = new Array(w * h).fill(' '); + const fg = new Int32Array(w * h).fill(NO_COLOR_VALUE); + const bg = new Int32Array(w * h).fill(NO_COLOR_VALUE); + const top = capture.height - h; + for (let y = 0; y < h; y += 1) for (let x = 0; x < w; x += 1) { + glyphs[y * w + x] = capture.glyphs[(top + y) * capture.width + x]!; + fg[y * w + x] = capture.fg[(top + y) * capture.width + x]!; + bg[y * w + x] = capture.bg[(top + y) * capture.width + x]!; + } + return {width: w, height: h, glyphs, fg, bg, instances: {}, seed: capture.seed}; +} + +export const plainText = (rows: readonly string[]) => rows.map(stripAnsi); diff --git a/src/idle/screenEffects.ts b/src/idle/screenEffects.ts new file mode 100644 index 00000000..865288a0 --- /dev/null +++ b/src/idle/screenEffects.ts @@ -0,0 +1,1101 @@ +import {CellGrid, luminance, mixPacked, NO_COLOR_VALUE} from './CellGrid.js'; +import type {ScreenCapture} from './screenCapture.js'; +import {CAT_ASCII, CAT_ASCII_BLINK, CAT_CELL_HEIGHT, CAT_CELL_WIDTH, catCells, type CatPose} from './catSprite.js'; + +/** + * The shared screen-saver engine: a seeded RNG, a fixed-timestep driver and a + * small particle record, plus four effects that animate the captured screen + * (Black Hole, Fireworks, Circletastic, raiseCatError). Effects are pure + * presentation: they read the capture, own their particles, and draw into a + * CellGrid whose cells keep the host background (no opaque fills). Cost is + * bounded by capping animated glyphs and particles regardless of screen size. + */ +export const SCREEN_EFFECTS = ['blackHole', 'fireworks', 'circletastic', 'raiseCatError'] as const; +export type ScreenEffectId = typeof SCREEN_EFFECTS[number]; + +export interface EffectPalette { stops: readonly number[]; text: number; error: number; warn: number; accent: number } +export interface EffectContext { time: number; palette: EffectPalette; nerd: boolean; color: boolean } + +export const STEP_MS = 50; +const MAX_STEPS_PER_DRAW = 400; +const MAX_GLYPHS = 900; +/** A terminal cell is about twice as tall as wide: x distances count half. */ +export const ASPECT = 2; + +export function makeRng(seed: number): () => number { + let s = seed >>> 0; + return () => { s = (s + 0x6d2b79f5) >>> 0; let t = s; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; +} + +export interface Glyph { + ch: string; fg: number; + ox: number; oy: number; x: number; y: number; vx: number; vy: number; + state: number; t: number; a: number; r: number; w: number; + /** Seconds after the effect's gravity starts before this glyph is disturbed (distance-ordered). */ + wake?: number; + /** Beyond the simulation cap: moves along a closed-form spiral instead of being integrated. */ + lite?: boolean; +} + +/** Non-blank captured cells as animatable glyphs; huge screens are sampled down to a cap. */ +export function glyphsOf(capture: ScreenCapture, textColor: number, max = MAX_GLYPHS): Glyph[] { + const cells: number[] = []; + for (let i = 0; i < capture.glyphs.length; i += 1) if (capture.glyphs[i] !== ' ') cells.push(i); + const stride = Math.max(1, Math.ceil(cells.length / max)); + const out: Glyph[] = []; + for (let k = 0; k < cells.length; k += stride) { + const i = cells[k]!; + const x = i % capture.width, y = Math.floor(i / capture.width); + const fg = capture.fg[i]!; + out.push({ch: capture.glyphs[i]!, fg: visibleFg(fg, capture.bg[i]!, textColor), ox: x, oy: y, x, y, vx: 0, vy: 0, state: 0, t: 0, a: 0, r: 0, w: 0}); + } + return out; +} + +/** + * A glyph that leaves its cell is drawn without its background, so its color + * must stand on its own: dark-on-light chrome (a badge, a selection) would + * otherwise become dark text on the host's dark background. + */ +export function visibleFg(fg: number, bg: number, textColor: number): number { + if (fg === NO_COLOR_VALUE) return bg !== NO_COLOR_VALUE && luminance(bg) > 0.35 ? bg : textColor; + if (luminance(fg) >= 0.3) return fg; + return bg !== NO_COLOR_VALUE && luminance(bg) >= 0.3 ? bg : textColor; +} + +/** + * Cells that are not in the animated set are drawn exactly as captured: + * glyph, foreground and authored background (host-default colors stay + * host-default, so transparency and the prompt chrome survive). A cell whose + * glyph is animated keeps its authored background and loses only the glyph. + */ +function drawStatic(grid: CellGrid, capture: ScreenCapture, skip: ReadonlySet, _textColor: number): void { + for (let i = 0; i < capture.glyphs.length; i += 1) { + const g = capture.glyphs[i]!, bg = capture.bg[i]!; + if (g === ' ' && bg === NO_COLOR_VALUE) continue; + const x = i % capture.width, y = Math.floor(i / capture.width); + if (skip.has(i)) grid.set(x, y, ' ', NO_COLOR_VALUE, bg); + else grid.set(x, y, g, capture.fg[i]!, bg); + } +} + +abstract class Sim { + t = 0; + /** Completed major loops; Random mode switches effect only on a boundary. */ + loops = 0; + rng: () => number; + constructor(readonly capture: ScreenCapture, protected readonly salt: number) { this.rng = makeRng(capture.seed ^ salt); } + abstract update(dt: number): void; + abstract paint(grid: CellGrid, ctx: EffectContext): void; + protected extraLoops(): number { return 0; } + advance(toMs: number): void { + let steps = 0; + while (this.t + STEP_MS <= toMs && steps < MAX_STEPS_PER_DRAW) { this.update(STEP_MS / 1000); this.t += STEP_MS; steps += 1; } + if (this.t + STEP_MS <= toMs) this.t = toMs - (toMs % STEP_MS); // skip a long stall instead of replaying it + this.loops = Math.max(this.loops, this.extraLoops()); + } + protected rand(a: number, b: number) { return a + (b - a) * this.rng(); } + protected pick(list: readonly T[]): T { return list[Math.floor(this.rng() * list.length)]!; } +} + +const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v)); +const ease = (u: number) => { const c = clamp(u, 0, 1); return c * c * (3 - 2 * c); }; + +// ---------------------------------------------------------------- Black Hole + +export type BlackHolePhase = 'seed' | 'impact' | 'gravity' | 'accretion' | 'hold' | 'release'; +const BH = {seed: 1.6, impact: 0.6, hold: 3, release: 2.8, rest: 1.5, consumeCap: 11, simCap: 1800, wakeSpan: 4.2}; + +export class BlackHole extends Sim { + readonly glyphs: Glyph[]; + phase: BlackHolePhase = 'seed'; + phaseT = 0; + cx: number; cy: number; + /** 0 home, 1 falling, 2 orbiting, 3 consumed, 4 returning */ + private readonly diskCap = 150; + private sparks: Array<{x: number; y: number; vx: number; vy: number; life: number}> = []; + private coreRadius = 1.4; + private pulse = 0; + /** Seconds since gravity began (continuous across gravity and accretion). */ + gt = 0; + constructor(capture: ScreenCapture, textColor: number) { + super(capture, 0xb1ac); + // Every visible glyph takes part. Above the simulation cap the excess follows a cheap closed-form spiral, + // so a dense screen is still swallowed whole rather than half left untouched. + this.glyphs = glyphsOf(capture, textColor, Number.MAX_SAFE_INTEGER); + const stride = Math.max(1, Math.ceil(this.glyphs.length / BH.simCap)); + this.glyphs.forEach((g, i) => { if (stride > 1 && i % stride !== 0) g.lite = true; }); + this.cx = capture.width / 2 + this.rand(-2, 2); + this.cy = capture.height / 2 + this.rand(-1, 1); + // One global field: nearby text reacts first, the far edges and corners last, all within the same span. + const dist = this.glyphs.map(g => Math.hypot((g.ox - this.cx) / ASPECT, g.oy - this.cy)); + const dmax = Math.max(1, ...dist); + this.glyphs.forEach((g, i) => { g.wake = BH.wakeSpan * Math.pow(dist[i]! / dmax, 0.9) + this.rand(0, 0.5); }); + } + private reset() { + for (const g of this.glyphs) { g.x = g.ox; g.y = g.oy; g.vx = g.vy = 0; g.state = 0; g.t = 0; } + this.sparks = []; this.phase = 'seed'; this.phaseT = 0; this.gt = 0; this.coreRadius = 1.4; this.loops += 1; + } + private go(phase: BlackHolePhase) { this.phase = phase; this.phaseT = 0; } + update(dt: number): void { + this.phaseT += dt; this.pulse += dt; + if (this.phase === 'gravity' || this.phase === 'accretion') this.gt += dt; + const alive = this.glyphs.filter(g => g.state !== 3).length; + switch (this.phase) { + case 'seed': if (this.phaseT >= BH.seed) this.go('impact'); break; + case 'impact': + // A few strong frames: nearby glyphs twitch toward the point. + for (const g of this.glyphs) { + const d = Math.hypot((g.ox - this.cx) / ASPECT, g.oy - this.cy); + if (d < 7 && g.state === 0) { const k = 0.35 * (1 - d / 7); g.x = g.ox + (this.cx - g.ox) * k; g.y = g.oy + (this.cy - g.oy) * k; } + } + if (this.phaseT >= BH.impact) this.go('gravity'); + break; + case 'gravity': case 'accretion': { + const ramp = clamp(this.phaseT / 3.5, 0, 1); + for (const g of this.glyphs) { + if (g.state === 3) continue; + if (g.lite) { + const dur = 2.4 + 1.6 * ((g.wake ?? 0) / BH.wakeSpan); + if (g.state === 0 && this.gt >= (g.wake ?? 0)) { g.state = 5; g.a = Math.atan2(g.oy - this.cy, (g.ox - this.cx) / ASPECT); g.r = Math.hypot((g.ox - this.cx) / ASPECT, g.oy - this.cy); g.w = dur; } + if (g.state === 5 && this.gt >= (g.wake ?? 0) + g.w) g.state = 3; // swallowed: retired from the simulation + continue; + } + const dx = (this.cx - g.x) / ASPECT, dy = this.cy - g.y; + const r = Math.max(0.3, Math.hypot(dx, dy)); + if (g.state === 0) { + // Distant glyphs wake later and move subtly at first: closer ones go first. + if (this.gt < (g.wake ?? 0)) continue; + g.state = 1; g.vx = g.vy = 0; g.w = this.rand(0.55, 1.1); + } + if (g.state === 1) { + g.t += dt; + // Anything lingering is drawn in harder, so the screen is always eventually consumed. + const pull = (9 + 70 / (r + 1.5)) * ramp * (1 + g.t * 0.6); + const ux = dx / r, uy = dy / r; + // Tangential velocity makes the path a spiral rather than a spoke. + g.vx += (ux * pull + -uy * pull * 0.55 * g.w) * dt * ASPECT; + g.vy += (uy * pull + ux * pull * 0.55 * g.w) * dt; + g.vx *= 0.985; g.vy *= 0.985; + g.x += g.vx * dt; g.y += g.vy * dt; + const nr = Math.hypot((this.cx - g.x) / ASPECT, this.cy - g.y); + if (nr < 4.6) { + if (this.glyphs.filter(h => h.state === 2).length >= this.diskCap) { g.state = 3; continue; } + g.state = 2; g.a = Math.atan2(g.y - this.cy, (g.x - this.cx) / ASPECT); g.r = clamp(nr, 2.4, 4.4); g.t = this.rand(0.8, 2.6); + g.w = (2.6 + this.rand(0, 1.2)) / Math.pow(g.r, 0.5); + } + } else if (g.state === 2) { + g.a += g.w * dt * 2.2; g.r = Math.max(this.coreRadius + 0.4, g.r - 0.35 * dt); g.t -= dt; + g.x = this.cx + Math.cos(g.a) * g.r * ASPECT; g.y = this.cy + Math.sin(g.a) * g.r; + if (g.t <= 0) g.state = 3; + } + } + if (this.phase === 'gravity' && this.phaseT > 2) this.go('accretion'); + if (this.phase === 'accretion' && (alive === 0 || this.glyphs.every(g => g.state >= 2) && this.phaseT > 5 || this.phaseT > BH.consumeCap)) { + for (const g of this.glyphs) if (g.state !== 3) g.state = 3; + this.go('hold'); + } + break; + } + case 'hold': + if (this.rng() < 0.12) { const a = this.rand(0, Math.PI * 2); this.sparks.push({x: this.cx, y: this.cy, vx: Math.cos(a) * 14, vy: Math.sin(a) * 6, life: 1.1}); } + this.sparks = this.sparks.filter(s => { s.x += s.vx * dt; s.y += s.vy * dt; s.life -= dt; return s.life > 0; }).slice(-24); + if (this.phaseT >= BH.hold) { this.go('release'); for (const g of this.glyphs) { g.state = 4; g.x = this.cx; g.y = this.cy; g.t = this.rand(0, 0.5); g.a = this.rand(-1, 1); } } + break; + case 'release': { + // Reconstruct: glyphs leave the center on an arc and settle on their original cells. + let done = true; + for (const g of this.glyphs) { + g.t += dt; + const u = ease((g.t - 0.2) / (BH.release - 0.8)); + if (u < 1) done = false; + const swirl = Math.sin(u * Math.PI) * g.a * 6; + const px = this.cx + (g.ox - this.cx) * u, py = this.cy + (g.oy - this.cy) * u; + const dx = g.ox - this.cx, dy = g.oy - this.cy, n = Math.hypot(dx, dy) || 1; + g.x = px + (-dy / n) * swirl; g.y = py + (dx / n) * swirl * 0.5; + } + if (done && this.phaseT > BH.release + BH.rest) this.reset(); + break; + } + } + } + paint(grid: CellGrid, ctx: EffectContext): void { + const {palette} = ctx; + const skip = new Set(); + const glyphIndex = (g: Glyph) => Math.floor(g.oy) * this.capture.width + Math.floor(g.ox); + for (const g of this.glyphs) if (g.state !== 0 || this.phase === 'release') skip.add(glyphIndex(g)); + drawStatic(grid, this.capture, skip, palette.text); + const rim = palette.stops[0] ?? palette.accent, rim2 = palette.stops[1] ?? rim; + const cx = this.cx, cy = this.cy; + if (this.phase === 'seed') { + const flicker = 0.5 + 0.5 * Math.sin(this.t / 90); + grid.set(cx, cy, this.phaseT > BH.seed * 0.6 ? '∘' : '·', mixPacked(palette.text, rim, flicker)); + } else if (this.phase === 'impact') { + const radius = Math.max(0, 3 - this.phaseT * 4.5); + this.ring(grid, radius, mixPacked(rim, 0xffffff, 0.6), 18); + grid.set(cx, cy, '•', 0xffffff); + } + // Moving glyphs: dim a little as they near the center so they read as stretched into the glow. + for (const g of this.glyphs) { + if (g.state === 0 && this.phase !== 'impact') continue; + if (g.state === 3) continue; + if (g.state === 5) { + // Closed-form inward spiral: radius shrinks, angle sweeps faster near the core. + const u = clamp((this.gt - (g.wake ?? 0)) / g.w, 0, 1); + const radius = g.r * Math.pow(1 - u, 1.5) + this.coreRadius * 0.6 * u, angle = g.a + 3.4 * u + 2.2 * u * u * u; + if (radius < this.coreRadius) continue; + grid.plot(this.cx + Math.cos(angle) * radius * ASPECT, this.cy + Math.sin(angle) * radius, g.ch, mixPacked(g.fg, rim, 0.25 + 0.5 * u)); + continue; + } + const r = Math.hypot((cx - g.x) / ASPECT, cy - g.y); + if (r < this.coreRadius) continue; + const near = g.state === 2 ? 1 : clamp(1 - r / 9, 0, 0.8); + grid.plot(g.x, g.y, g.ch, g.state === 2 ? mixPacked(g.fg, rim2, 0.55 + 0.3 * Math.sin(g.a * 3)) : mixPacked(g.fg, rim, near * 0.5)); + } + if (this.phase === 'accretion' || this.phase === 'hold' || (this.phase === 'gravity' && this.phaseT > 2)) { + const wobble = this.phase === 'hold' ? Math.sin(this.t / 260) * 0.25 : 0; + const intensity = this.phase === 'hold' ? 0.7 + 0.3 * Math.sin(this.t / 180) : 0.6; + this.disk(grid, 3.2 + wobble, ctx, intensity); + // The core stays empty: nothing is drawn within coreRadius. + } + for (const s of this.sparks) grid.plot(s.x, s.y, '·', mixPacked(rim2, 0xffffff, s.life / 1.5)); + } + private ring(grid: CellGrid, radius: number, color: number, points: number) { + if (radius <= 0.2) return; + for (let i = 0; i < points; i += 1) { const a = (i / points) * Math.PI * 2; grid.plot(this.cx + Math.cos(a) * radius * ASPECT, this.cy + Math.sin(a) * radius, '•', color); } + } + private disk(grid: CellGrid, radius: number, ctx: EffectContext, intensity: number) { + const rim = ctx.palette.stops[0] ?? ctx.palette.accent, rim2 = ctx.palette.stops[1] ?? rim; + const marks = ctx.nerd ? ['·', '∘', '•', '◦'] : ['.', 'o', '*', '+']; + const n = 44; + for (let i = 0; i < n; i += 1) { + const a = (i / n) * Math.PI * 2 + this.t / 700; + // Brightness varies around the ring: a hot side and a dim side. + const heat = 0.5 + 0.5 * Math.cos(a - this.t / 1300); + const color = mixPacked(rim, rim2, heat); + grid.plot(this.cx + Math.cos(a) * radius * ASPECT, this.cy + Math.sin(a) * radius, marks[Math.floor(heat * (marks.length - 0.01))]!, mixPacked(0x202030, color, 0.35 + heat * 0.65 * intensity)); + if (i % 11 === 0) grid.plot(this.cx + Math.cos(a + 0.12) * (radius + 1) * ASPECT, this.cy + Math.sin(a + 0.12) * (radius + 1), '·', mixPacked(color, 0xffffff, 0.3)); + } + } +} + +// ----------------------------------------------------------------- Fireworks + +type Burst = 'sphere' | 'ring' | 'spray' | 'cross' | 'split'; +interface Shell { x0: number; y0: number; cx: number; cy: number; x1: number; y1: number; t: number; dur: number; style: Burst; color: number; } +interface Spark { x: number; y: number; vx: number; vy: number; life: number; max: number; ch: string; fg: number; glyph?: Glyph } + +export const FIREWORK_CAPS = {shells: 3, sparks: 220}; + +export class Fireworks extends Sim { + readonly glyphs: Glyph[]; + shells: Shell[] = []; + sparks: Spark[] = []; + private next = 0.6; + protected override extraLoops() { return Math.floor(this.t / 40_000); } + /** Origin names recorded so tests can verify launches are not bottom-center only. */ + readonly launches: Array<{origin: string; x0: number; y0: number; x1: number; y1: number; style: Burst}> = []; + private readonly held = new Set(); + constructor(capture: ScreenCapture, private readonly palette: EffectPalette) { + super(capture, 0xf1e3); + this.glyphs = glyphsOf(capture, palette.text); + } + private launch() { + const w = this.capture.width, h = this.capture.height; + const origins: Array<[string, number, number, number]> = [ + ['bottom-center', w * 0.5, h - 1, 2], ['lower-left', w * 0.2, h - 1, 2], ['lower-right', w * 0.8, h - 1, 2], + ['left-edge', 1, h * 0.85, 1.5], ['right-edge', w - 2, h * 0.85, 1.5], + ]; + const total = origins.reduce((s, o) => s + o[3], 0); + let roll = this.rng() * total, origin = origins[0]!; + for (const o of origins) { roll -= o[3]; if (roll <= 0) { origin = o; break; } } + const zones: Array<[number, number, number, number]> = [[0.5, 0.35, 0.2, 0.12], [0.5, 0.2, 0.2, 0.1], [0.25, 0.28, 0.12, 0.1], [0.75, 0.28, 0.12, 0.1], [0.5, 0.55, 0.25, 0.1]]; + const zone = this.pick(zones); + const tx = clamp(w * (zone[0] + this.rand(-zone[2], zone[2])), 4, w - 5), ty = clamp(h * (zone[1] + this.rand(-zone[3], zone[3])), 2, h - 4); + const style = this.pick(['sphere', 'ring', 'spray', 'cross', 'split']); + // Each shell bends sideways by its own amount, so paths differ. + const sway = this.rand(-0.25, 0.25) * w; + const color = this.palette.stops.length ? this.pick(this.palette.stops) : this.palette.accent; + this.shells.push({x0: origin[1], y0: origin[2], cx: (origin[1] + tx) / 2 + sway, cy: Math.min(origin[2], ty) - this.rand(0, h * 0.2), x1: tx, y1: ty, t: 0, dur: this.rand(0.9, 1.7), style, color}); + this.launches.push({origin: origin[0], x0: origin[1], y0: origin[2], x1: tx, y1: ty, style}); + } + private burst(x: number, y: number, style: Burst, color: number) { + const room = FIREWORK_CAPS.sparks - this.sparks.length; + const n = Math.min(room, style === 'sphere' ? 34 : style === 'ring' ? 28 : style === 'spray' ? 26 : style === 'cross' ? 24 : 20); + const bias = this.rand(0, Math.PI * 2); + // Recruit nearby captured glyphs: they burst out and later settle back home. + const near = this.glyphs.filter(g => !this.held.has(g) && Math.hypot((g.ox - x) / ASPECT, g.oy - y) < 7).slice(0, 10); + near.forEach((g, i) => { + if (this.sparks.length >= FIREWORK_CAPS.sparks) return; + this.held.add(g); + const a = (i / near.length) * Math.PI * 2 + bias, sp = this.rand(5, 11); + this.sparks.push({x: g.ox, y: g.oy, vx: Math.cos(a) * sp * ASPECT, vy: Math.sin(a) * sp * 0.6, life: 1.6, max: 1.6, ch: g.ch, fg: g.fg, glyph: g}); + }); + for (let i = 0; i < n && this.sparks.length < FIREWORK_CAPS.sparks; i += 1) { + let a: number, sp = this.rand(6, 13); + if (style === 'ring') { a = (i / n) * Math.PI * 2; sp = 11; } + else if (style === 'spray') { a = bias + this.rand(-0.7, 0.7); sp = this.rand(7, 16); } + else if (style === 'cross') { a = Math.floor(i / (n / 4)) * (Math.PI / 2) + bias * 0 + this.rand(-0.08, 0.08); sp = 5 + (i % 6) * 1.4; } + else a = this.rand(0, Math.PI * 2); + this.sparks.push({x, y, vx: Math.cos(a) * sp * ASPECT, vy: Math.sin(a) * sp * 0.6, life: this.rand(0.9, 1.6), max: 1.6, ch: this.pick(['*', '·', '+', '•']), fg: color}); + if (style === 'split' && i % 5 === 0 && this.shells.length < FIREWORK_CAPS.shells) { + this.shells.push({x0: x, y0: y, cx: x + this.rand(-6, 6), cy: y - 3, x1: x + Math.cos(a) * 14, y1: y + Math.sin(a) * 5, t: 0, dur: 0.5, style: 'sphere', color}); + } + } + } + update(dt: number): void { + this.next -= dt; + const busy = this.sparks.length > 150; + if (this.next <= 0 && this.shells.length < FIREWORK_CAPS.shells && !busy) { this.launch(); this.next = this.rand(0.7, 1.7); } + for (const s of [...this.shells]) { + s.t += dt; + if (s.t >= s.dur) { this.shells.splice(this.shells.indexOf(s), 1); this.burst(s.x1, s.y1, s.style, s.color); } + } + for (const p of this.sparks) { + p.vy += 7 * dt; p.vx *= 1 - 0.7 * dt; p.vy *= 1 - 0.4 * dt; // light gravity and drag + p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt; + } + for (const p of this.sparks) if (p.life <= 0 && p.glyph) this.held.delete(p.glyph); + this.sparks = this.sparks.filter(p => p.life > 0); + } + shellPosition(s: Shell, u = s.t / s.dur): {x: number; y: number} { + const k = clamp(u, 0, 1), m = 1 - k; + return {x: m * m * s.x0 + 2 * m * k * s.cx + k * k * s.x1, y: m * m * s.y0 + 2 * m * k * s.cy + k * k * s.y1}; + } + paint(grid: CellGrid, ctx: EffectContext): void { + const skip = new Set(); + for (const g of this.held) skip.add(Math.floor(g.oy) * this.capture.width + Math.floor(g.ox)); + drawStatic(grid, this.capture, skip, ctx.palette.text); + for (const s of this.shells) { + for (let k = 0; k < 4; k += 1) { + const p = this.shellPosition(s, s.t / s.dur - k * 0.04); + grid.plot(p.x, p.y, k === 0 ? '*' : '·', mixPacked(s.color, 0xffffff, k === 0 ? 0.7 : 0.1)); + } + } + for (const p of this.sparks) { + const fade = clamp(p.life / 0.8, 0, 1); + grid.plot(p.x, p.y, p.glyph ? p.ch : fade > 0.35 ? p.ch : '·', p.glyph ? mixPacked(p.fg, 0x808090, 1 - fade) : mixPacked(0x30303c, p.fg, fade)); + } + } +} + +// -------------------------------------------------------------- Circletastic + +export type CircPhase = 'hold' | 'gather' | 'spin' | 'collapse' | 'explode' | 'scatter' | 'settle'; +export type ClusterStage = 'gather' | 'spin' | 'collapse' | 'critical' | 'exploded'; +export interface RingSpec { cx: number; cy: number; r: number; dir: 1 | -1; slots: number; cluster: number } +export interface Cluster { cx: number; cy: number; R: number; dir: 1 | -1; rings: RingSpec[] } +export const CIRC = { + scatter: 3.2, settle: 1.6, hold: 0.7, spacing: 0.8, ringGap: 1.5, minClusters: 2, maxClusters: 5, omegaStart: 0.5, omegaCollapse: 7, omegaMax: 15, + collapseSeconds: 1.7, collapseScale: 0.2, criticalSeconds: 0.3, flashSeconds: 0.4, glyphCap: 1200, debrisCap: 90, shockPoints: 40, + /** Share of the source screen harvested on run 1, 2, 3, 4; the fifth run starts from a restored screen. */ + harvest: [0.4, 0.62, 0.85, 0.97], runsBeforeReset: 4, +}; + +/** Capacity of one ring at physical radius r (rows units): glyphs are spaced by arc length. */ +export const ringCapacity = (r: number) => Math.max(0, Math.floor((2 * Math.PI * r) / CIRC.spacing)); +const innerLimit = (R: number) => Math.max(1.6, R * 0.32); +const ringRadii = (R: number) => { const out: number[] = []; for (let r = R; r >= innerLimit(R); r -= CIRC.ringGap) out.push(r); return out; }; +export const clusterCapacity = (R: number) => ringRadii(R).reduce((sum, r) => sum + ringCapacity(r), 0); + +/** + * A small bounded set (2–5) of compact text circles, each with a clear empty + * center and negative space around it. Physical units are rows (x cells count + * half) so rings look circular. Placement is seeded and collision-aware; + * when a viewport is too small for several, fewer (down to one) are used. + * Glyphs beyond the circles' total capacity simply do not participate. + */ +export function layoutCircles(count: number, width: number, height: number, rng: () => number): Cluster[] { + const W = width / ASPECT, H = height, margin = 1.5; + const Rmax = Math.max(2.6, Math.min(W, H) * 0.3); + const place = (k: number, scale: number): Cluster[] | undefined => { + const need = Math.ceil(count / k); + let base = 2.6; while (base < Rmax && clusterCapacity(base) < need) base += 0.4; + const placed: Cluster[] = []; + for (let n = 0; n < k; n += 1) { + const R = clamp(base * scale * (0.85 + rng() * 0.3), 2.6, Rmax); + if (R * 2 + margin * 2 > Math.min(W, H) + 0.01 && k > 1) return undefined; + let spot: {cx: number; cy: number} | undefined; + for (let attempt = 0; attempt < 200 && !spot; attempt += 1) { + const cx = margin + R + rng() * Math.max(0, W - 2 * (margin + R)), cy = margin + R + rng() * Math.max(0, H - 2 * (margin + R)); + if (placed.every(other => Math.hypot(cx - other.cx, cy - other.cy) >= R + other.R + 2.5)) spot = {cx, cy}; + } + if (!spot) return undefined; + placed.push({...spot, R, dir: rng() < 0.5 ? 1 : -1, rings: []}); + } + return placed; + }; + let clusters: Cluster[] | undefined; + for (let k = clamp(Math.ceil(count / 70), CIRC.minClusters, CIRC.maxClusters); k >= 2 && !clusters; k -= 1) { + // Prefer the full radius; shrink the circles a little before giving up on having several. + for (const scale of [1, 0.85, 0.72]) { clusters = place(k, scale); if (clusters) break; } + } + clusters ??= [{cx: W / 2, cy: H / 2, R: Math.max(2.4, Math.min(Rmax, Math.min(W, H) / 2 - margin)), dir: 1, rings: []}]; + // Share the glyphs between clusters by capacity; inside a cluster, between rings by capacity. + const totalCapacity = clusters.reduce((sum, c) => sum + clusterCapacity(c.R), 0); + let remaining = Math.min(count, totalCapacity); + clusters.forEach((cluster, index) => { + const cap = clusterCapacity(cluster.R); + const quota = index === clusters!.length - 1 ? Math.min(cap, remaining) : Math.min(cap, Math.round(Math.min(count, totalCapacity) * cap / totalCapacity)); + let left = quota; const radii = ringRadii(cluster.R); + radii.forEach((r, k) => { + const share = k === radii.length - 1 ? left : Math.min(left, Math.round(quota * ringCapacity(r) / cap)); + const slots = Math.min(share, ringCapacity(r)); + if (slots >= 3) { cluster.rings.push({cx: cluster.cx, cy: cluster.cy, r, dir: (k % 2 === 0 ? cluster.dir : -cluster.dir) as 1 | -1, slots, cluster: index}); left -= slots; } + }); + remaining -= quota - left; + }); + return clusters.filter(c => c.rings.length); +} + +interface ClusterRuntime { + stage: ClusterStage; t: number; start: number; order: number; alpha: number; scale: number; formed: boolean; + rings: number[]; readyAt: number; flash: number; blastAt?: number; +} +interface Debris { x: number; y: number; vx: number; vy: number; life: number; ch: string; fg: number } + +export class Circletastic extends Sim { + readonly glyphs: Glyph[]; + phase: CircPhase = 'hold'; + phaseT = 0; + clusters: Cluster[] = []; + runtime: ClusterRuntime[] = []; + rings: RingSpec[] = []; + angle: number[] = []; + omega: number[] = []; + assigned: Array<{ring: number; slot: number} | undefined> = []; + private delay: number[] = []; + private duration: number[] = []; + /** Seeded order in which source glyphs are harvested (stable for the life of the capture). */ + readonly harvestOrder: number[]; + /** Glyphs removed from their source position this reset cycle (presentation only). */ + readonly consumed = new Set(); + debris: Debris[] = []; + shock: Array<{cx: number; cy: number; t: number}> = []; + private lastBlast = -1e9; + private nextGap = 0.4; + cycles = 0; + /** Runs since the source screen was last restored. */ + run = 0; + /** Phase names in the order they were entered (for tests and diagnostics). */ + readonly history: CircPhase[] = ['hold']; + readonly explosions: Array<{cluster: number; at: number}> = []; + constructor(capture: ScreenCapture, private readonly textColor: number) { + super(capture, 0xc12c); + this.glyphs = glyphsOf(capture, textColor, CIRC.glyphCap); + const order = this.glyphs.map((_, i) => i); + for (let i = order.length - 1; i > 0; i -= 1) { const j = Math.floor(this.rng() * (i + 1)); [order[i], order[j]] = [order[j]!, order[i]!]; } + this.harvestOrder = order; + this.plan(); + } + /** Share of the source harvested on the current run. */ + harvestShare(): number { return CIRC.harvest[Math.min(this.run, CIRC.harvest.length - 1)]!; } + /** ASSIGN: pick this run's harvest, lay out the circles, and give every participant a ring and slot before anything moves. */ + private plan() { + const target = Math.ceil(this.harvestShare() * this.glyphs.length); + const harvest = this.harvestOrder.slice(0, target); + this.consumed.clear(); + this.clusters = layoutCircles(harvest.length, this.capture.width, this.capture.height, this.rng); + this.rings = this.clusters.flatMap(c => c.rings); + this.angle = this.rings.map(() => this.rand(0, Math.PI * 2)); + this.omega = this.rings.map(() => CIRC.omegaStart); + this.assigned = []; this.delay = []; this.duration = []; + const capacity = this.rings.reduce((sum, ring) => sum + ring.slots, 0); + // Harvested glyphs fill the rings; harvested glyphs beyond the capacity are consumed outright (hidden). + const participants = harvest.slice(0, capacity); + let g = 0; + this.rings.forEach((ring, ri) => { for (let s = 0; s < ring.slots && g < participants.length; s += 1, g += 1) this.assigned[participants[g]!] = {ring: ri, slot: s}; }); + for (const index of harvest.slice(capacity)) this.consumed.add(index); + this.glyphs.forEach((glyph, i) => { + glyph.a = glyph.x; glyph.r = glyph.y; glyph.t = 0; glyph.vx = glyph.vy = 0; + glyph.state = this.assigned[i] ? 0 : this.consumed.has(i) ? 4 : 3; + this.delay[i] = this.rand(0, 0.9); this.duration[i] = this.rand(1.3, 2.4); + // How far around the circle this glyph sweeps while gathering (signed by the ring's direction): far glyphs take broader arcs. + const ringDir = this.assigned[i] ? this.rings[this.assigned[i]!.ring]!.dir : 1; + glyph.w = ringDir * this.rand(0.35, 1.1); glyph.vy = 0; + }); + const base = this.rand(1.5, 2.1); + this.runtime = this.clusters.map((_, ci) => ({ + stage: 'gather', t: 0, start: ci === 0 ? 0 : this.rand(0.2, 0.7) * ci, order: ci, + // Later circles spin up harder, so they catch up with the first. + alpha: base * (1 + 0.65 * ci), scale: 1, formed: false, rings: this.rings.map((r, i) => (r.cluster === ci ? i : -1)).filter(i => i >= 0), readyAt: Infinity, flash: 0, + })); + this.lastBlast = -1e9; this.nextGap = this.rand(0.3, 0.6); + this.debris = []; this.shock = []; + } + slotPosition(i: number): {x: number; y: number} | undefined { + const a = this.assigned[i]; + if (!a) return undefined; + const ring = this.rings[a.ring]!; + const scale = this.runtime[ring.cluster]?.scale ?? 1; + const th = this.angle[a.ring]! + (a.slot / ring.slots) * Math.PI * 2; + return {x: (ring.cx + Math.cos(th) * ring.r * scale) * ASPECT, y: ring.cy + Math.sin(th) * ring.r * scale}; + } + clusterOf(i: number): number | undefined { const a = this.assigned[i]; return a ? this.rings[a.ring]!.cluster : undefined; } + private setPhase(phase: CircPhase) { if (phase === this.phase) return; this.phase = phase; this.phaseT = 0; this.history.push(phase); } + update(dt: number): void { + this.phaseT += dt; + const w = this.capture.width, h = this.capture.height; + if (this.phase === 'hold') { if (this.phaseT >= CIRC.hold) this.setPhase('gather'); else return; } + if (this.phase === 'scatter' || this.phase === 'settle') { + this.integrate(dt, w, h); + this.tickBlasts(dt); + if (this.phase === 'scatter' && this.phaseT >= CIRC.scatter) this.setPhase('settle'); + else if (this.phase === 'settle') { + for (const g of this.glyphs) if (g.state === 2) { g.vx *= 0.8; g.vy *= 0.8; } + if (this.phaseT >= CIRC.settle) this.nextRun(); + } + return; + } + this.runtime.forEach((rt, ci) => this.stepCluster(rt, ci, dt)); + this.integrate(dt, w, h); + this.tickBlasts(dt); + // Order of readiness decides who blows next, one after another. + const ready = this.runtime.map((rt, ci) => ({rt, ci})).filter(({rt}) => rt.stage === 'critical' && rt.readyAt <= this.t).sort((a, b) => a.rt.readyAt - b.rt.readyAt); + if (ready.length && this.t - this.lastBlast >= this.nextGap * 1000) { this.detonate(ready[0]!.ci); this.lastBlast = this.t; this.nextGap = this.rand(0.35, 0.8); } + const stages = this.runtime.map(rt => rt.stage); + if (stages.every(stage => stage === 'exploded')) this.setPhase('scatter'); + else if (stages.includes('exploded')) this.setPhase('explode'); + else if (stages.some(stage => stage === 'collapse' || stage === 'critical')) this.setPhase('collapse'); + else if (stages.includes('spin')) this.setPhase('spin'); + else this.setPhase('gather'); + } + private stepCluster(rt: ClusterRuntime, ci: number, dt: number) { + if (rt.stage === 'exploded') return; + rt.t += dt; + if (rt.stage === 'gather') { + if (rt.t < rt.start) return; + const local = rt.t - rt.start; + // The vortex: the ring is already turning while it forms, and every glyph spirals into it, + // sweeping around the ring's center as its radius closes. Angular speed converges on the ring's + // own (the easing has zero slope at both ends), so there is no snap from gathering to rotating. + for (const ri of rt.rings) { + this.omega[ri] = Math.min(CIRC.omegaMax, this.omega[ri]! + rt.alpha * 0.4 * dt); + this.angle[ri]! += this.omega[ri]! * this.rings[ri]!.dir * dt; + } + let allHome = true; + this.glyphs.forEach((g, i) => { + if (this.clusterOf(i) !== ci || g.state === 1) return; + const a = this.assigned[i]!, ring = this.rings[a.ring]!; + const theta = this.angle[a.ring]! + (a.slot / ring.slots) * Math.PI * 2; + if (local < this.delay[i]!) { allHome = false; return; } + if (g.vy === 0) { // first moment: remember where on the circle the glyph is coming from + const rho0 = Math.hypot(g.a / ASPECT - ring.cx, g.r - ring.cy), phi0 = Math.atan2(g.r - ring.cy, g.a / ASPECT - ring.cx); + g.vx = Math.atan2(Math.sin(phi0 - theta), Math.cos(phi0 - theta)); g.vy = 1; g.t = rho0; + } + const u = ease((local - this.delay[i]!) / this.duration[i]!); + const rho0 = g.t, rho = rho0 + (ring.r - rho0) * u; + const phi = theta + (1 - u) * (g.vx + g.w * Math.PI * 2); + g.x = (ring.cx + Math.cos(phi) * rho) * ASPECT; g.y = ring.cy + Math.sin(phi) * rho; + if (local >= this.delay[i]! + this.duration[i]!) { const p = this.slotPosition(i)!; g.x = p.x; g.y = p.y; g.state = 1; g.vx = g.vy = 0; } else allHome = false; + }); + if (allHome) { rt.stage = 'spin'; rt.formed = true; rt.t = 0; } // this circle starts turning right away + return; + } + // Spin (and keep accelerating through the collapse): angular acceleration, never a speed jump. + const lead = this.omega[rt.rings[0]!]!; + for (const ri of rt.rings) { + const ring = this.rings[ri]!; + this.omega[ri] = Math.min(CIRC.omegaMax * (1 + 0.06 * rt.rings.indexOf(ri)), this.omega[ri]! + rt.alpha * (1 + 0.3 * rt.rings.indexOf(ri)) * dt * (rt.stage === 'collapse' ? 1.6 : 1)); + this.angle[ri]! += this.omega[ri]! * ring.dir * dt; + } + if (rt.stage === 'spin' && lead >= CIRC.omegaCollapse) { rt.stage = 'collapse'; rt.t = 0; } + if (rt.stage === 'collapse') { + const u = clamp(rt.t / CIRC.collapseSeconds, 0, 1); + rt.scale = 1 - (1 - CIRC.collapseScale) * u * u; // tightens faster and faster + if (u >= 1) { rt.stage = 'critical'; rt.t = 0; rt.readyAt = this.t + CIRC.criticalSeconds * 1000; } + } + if (rt.stage === 'critical') rt.scale = CIRC.collapseScale * (1 + 0.05 * Math.sin(this.t / 30)); + // Strain: radial wobble and an occasional slipping glyph while the ring is stressed. + const strain = rt.stage === 'collapse' || rt.stage === 'critical' ? 1 : clamp((lead - CIRC.omegaCollapse * 0.6) / (CIRC.omegaCollapse * 0.4), 0, 1); + this.glyphs.forEach((g, i) => { + if (this.clusterOf(i) !== ci || g.state !== 1) return; + const p = this.slotPosition(i)!; + const slip = strain > 0 && (i * 2654435761 >>> 0) % 97 < 3 ? strain * 1.2 : 0; + const wob = strain * 0.4 * Math.sin(this.t / 55 + i); + const a = this.assigned[i]!, ring = this.rings[a.ring]!, th = this.angle[a.ring]! + (a.slot / ring.slots) * Math.PI * 2; + g.x = p.x + Math.cos(th) * (wob + slip) * ASPECT; g.y = p.y + Math.sin(th) * (wob + slip); + }); + } + /** The supernova: the compressed core releases its spin; every glyph keeps tangential momentum plus a hard radial kick. */ + private detonate(ci: number) { + const rt = this.runtime[ci]!; rt.stage = 'exploded'; rt.flash = CIRC.flashSeconds; rt.blastAt = this.t; + const cluster = this.clusters[ci]!; + this.explosions.push({cluster: ci, at: this.t}); + this.shock.push({cx: cluster.cx * ASPECT, cy: cluster.cy, t: 0}); + this.glyphs.forEach((g, i) => { + const a = this.assigned[i]; + if (!a || this.rings[a.ring]!.cluster !== ci) return; + const ring = this.rings[a.ring]!; + const th = this.angle[a.ring]! + (a.slot / ring.slots) * Math.PI * 2; + const speed = this.omega[a.ring]! * ring.r * rt.scale * ring.dir; // rows/s along the tangent at the moment of release + const out = this.rand(9, 22); + g.vx = (-Math.sin(th) * speed * 0.9 + Math.cos(th) * out) * ASPECT + this.rand(-2, 2); + g.vy = Math.cos(th) * speed * 0.9 + Math.sin(th) * out + this.rand(-1.2, 1.2); + g.state = 2; + }); + // Debris: consumed source characters and sparks fly out too (bounded). + const pool = [...this.consumed]; + const n = Math.min(CIRC.debrisCap - this.debris.length, 36); + for (let k = 0; k < n; k += 1) { + const a = this.rand(0, Math.PI * 2), sp = this.rand(8, 26); + const source = pool.length ? this.glyphs[pool[Math.floor(this.rng() * pool.length)]!] : undefined; + this.debris.push({x: cluster.cx * ASPECT, y: cluster.cy, vx: Math.cos(a) * sp * ASPECT, vy: Math.sin(a) * sp, life: this.rand(0.6, 1.4), ch: source?.ch ?? this.pick(['*', '·', '+', '•']), fg: source?.fg ?? 0xffffff}); + } + } + private tickBlasts(dt: number) { + for (const rt of this.runtime) if (rt.flash > 0) rt.flash = Math.max(0, rt.flash - dt); + for (const d of this.debris) { d.x += d.vx * dt; d.y += d.vy * dt; d.vx *= 1 - 0.9 * dt; d.vy *= 1 - 0.9 * dt; d.life -= dt; } + this.debris = this.debris.filter(d => d.life > 0 && d.x >= 0 && d.x < this.capture.width && d.y >= 0 && d.y < this.capture.height); + for (const s of this.shock) s.t += dt; + this.shock = this.shock.filter(s => s.t < 0.55); + } + private integrate(dt: number, w: number, h: number) { + for (const g of this.glyphs) { + if (g.state !== 2) continue; + g.vx *= 1 - 0.5 * dt; g.vy *= 1 - 0.5 * dt; + g.x += g.vx * dt; g.y += g.vy * dt; + if (g.x < 1) { g.x = 1; g.vx = Math.abs(g.vx) * 0.5; } else if (g.x > w - 2) { g.x = w - 2; g.vx = -Math.abs(g.vx) * 0.5; } + if (g.y < 1) { g.y = 1; g.vy = Math.abs(g.vy) * 0.5; } else if (g.y > h - 2) { g.y = h - 2; g.vy = -Math.abs(g.vy) * 0.5; } + } + } + /** Next run: more of the source is consumed each time; after the configured number of runs the screen is restored cleanly. */ + private nextRun() { + this.cycles += 1; this.loops = this.cycles; this.run += 1; + if (this.run >= CIRC.runsBeforeReset) { + this.run = 0; + for (const g of this.glyphs) { g.x = g.ox; g.y = g.oy; g.vx = g.vy = 0; g.state = 0; } + } + this.plan(); + this.setPhase('hold'); this.phaseT = 0; + } + paint(grid: CellGrid, ctx: EffectContext): void { + // Source glyphs that are harvested (this or an earlier run) are gone from their original cells. + const skip = new Set(); + this.glyphs.forEach(g => { if (g.state !== 3) skip.add(Math.floor(g.oy) * this.capture.width + Math.floor(g.ox)); }); + drawStatic(grid, this.capture, skip, ctx.palette.text); + const accent = ctx.palette.stops[0] ?? ctx.palette.accent; + this.glyphs.forEach((g, i) => { + if (g.state === 4 || g.state === 3) return; + const ci = this.clusterOf(i); const rt = ci === undefined ? undefined : this.runtime[ci]; + const heat = rt ? (rt.stage === 'collapse' ? 0.5 + 0.4 * (1 - rt.scale) : rt.stage === 'critical' ? 1 : rt.stage === 'spin' ? clamp((this.omega[this.assigned[i]!.ring]! - CIRC.omegaStart) / CIRC.omegaCollapse, 0, 1) * 0.5 : 0) : 0; + const flash = rt && rt.flash > 0 ? rt.flash / CIRC.flashSeconds : 0; + grid.set(g.x, g.y, g.ch, mixPacked(mixPacked(g.fg, accent, heat * 0.7), 0xffffff, Math.max(flash * 0.8, rt?.stage === 'critical' ? 0.7 : 0))); + }); + for (const s of this.shock) { + const radius = s.t / 0.55 * 12; + for (let k = 0; k < CIRC.shockPoints; k += 1) { const a = (k / CIRC.shockPoints) * Math.PI * 2; grid.plot(s.cx + Math.cos(a) * radius * ASPECT, s.cy + Math.sin(a) * radius, '·', mixPacked(0x303040, 0xffffff, 1 - s.t / 0.55)); } + } + for (const d of this.debris) grid.plot(d.x, d.y, d.ch, mixPacked(0x303040, d.fg, clamp(d.life / 0.6, 0, 1))); + } +} + +// ------------------------------------------------------------- raiseCatError + +export interface Platform { y: number; x0: number; x1: number; floor?: boolean } +const CAT_W = CAT_CELL_WIDTH, CAT_H = CAT_CELL_HEIGHT; +const HOP_RISE = 1.4; + +/** Short, clearly fictional jokes; none claims a real fault, breach, loss or corruption. */ +export const JOKES: readonly string[] = [ + 'Too many words, not enough naps', 'Expected cat, found project', 'Purrmission denied', 'Catastrophic purrsing failure', 'Unexpected meow in expression', + 'Lint found 3 loose whiskers', 'Variable refuses to be pet', 'Function has not been fed recently', 'This line is not sufficiently loaf-shaped', + 'Build blocked by nap dependency', 'Keyboard currently occupied by cat', 'Cat hair found in type system', 'Project requires more treats', + 'Syntax hiss-lighting conflict', 'Tail recursion has become literal', 'Command may knock items off desk', 'File is sitting in the box but not the repository', + 'Import contains unauthorized zoomies', 'Compiler requested belly rubs', 'Object is climbing the curtains', 'Semicolon escaped the scratching post', + 'Too much seriousness detected', 'Error raised successfully', 'Pointer chased laser into invalid memory', 'This token is acting suspiciously dog-like', + 'Thread tangled in yarn scheduler', 'Path is suspiciously walkable', 'Command contains traces of responsibility', 'Variable has exceeded daily zoomie allowance', + 'Missing dependency: treats', 'Runtime entered loaf mode', 'Whisker alignment check failed', 'Paw-sized race condition detected', 'Catastrophe successfully reproduced', + 'Process interrupted by mandatory nap', 'Excessive keyboard warmth detected', 'Stack contains forbidden tuna', 'Cache invalidated by tail movement', + 'Box exists and therefore must be occupied', 'This module has not been sniffed', 'Warning: suspiciously empty cardboard box', + '`{w}` looks suspiciously productive', 'Expected cat, found `{w}`', '`{w}` appears emotionally unavailable', '`{w}` has been judged', 'Unknown identifier: `{w}`', +]; +export const KEYBOARD_JOKES: readonly string[] = ['Input method: paws', 'Unexpected paw event', 'Keyboard privileges revoked', 'Typing accuracy degraded by loaf', + 'Eight paws detected. Expected zero.', 'Composer under temporary cat management', 'Keyboard appears sufficiently warm']; +export const MEOWS: readonly string[] = ['meow', 'meow!', 'mrow', 'mrow?', 'mrrp', 'mrrrp!', 'prrr', 'purr...', 'purrrrr', 'nya', 'mrrow!', 'mrowe!', 'mreow', 'brrrp', 'chirp!', 'ekekekek']; +export const CHARACTER_JOKE = 'Unexpected character'; +export const DIVIDER_JOKE = 'Structural integrity questionable'; +export const KEYSMASH = 'asdfjkl;qwertyuiopzxcvbnm,./[]0123456789'; +export interface Diagnostic { row: number; x0: number; x1: number; text: string; level: 'error' | 'warn'; born: number; labelRow?: number; labelX?: number } +export const DIAGNOSTIC_CAP = 14; +export const WOBBLE_CAP = 64; +export const KEYBOARD_MAX = 14; + +/** Horizontal stretches of occupied cells (single gaps tolerated) the cat can stand on, from geometry only. The screen's bottom edge is always one more floor. */ +export function extractPlatforms(capture: ScreenCapture, minWidth = 6): Platform[] { + const out: Platform[] = []; + for (let y = CAT_H; y < capture.height; y += 1) { + let start = -1, gap = 0; + for (let x = 0; x <= capture.width; x += 1) { + const occupied = x < capture.width && capture.glyphs[y * capture.width + x] !== ' '; + if (occupied) { if (start < 0) start = x; gap = 0; } + else if (start >= 0) { + gap += 1; + if (gap > 1 || x === capture.width) { + const end = x - gap; + if (end - start + 1 >= minWidth) out.push({y, x0: start, x1: end}); + start = -1; gap = 0; + } + } + } + } + if (capture.height > CAT_H) out.push({y: capture.height, x0: 0, x1: capture.width - 1, floor: true}); + return out; +} + +/** Hops between platforms: up to 9 rows up, 16 down, and a bounded horizontal gap. Pure geometry. */ +export function platformGraph(platforms: readonly Platform[]): number[][] { + return platforms.map((a, i) => platforms.flatMap((b, j) => { + if (i === j) return []; + const up = a.y - b.y, gap = Math.max(0, Math.max(a.x0, b.x0) - Math.min(a.x1, b.x1)); + // A rising hop needs room above the target for the whole sprite; drops are always possible. + const fits = up < 0 || (b.y - 1) - HOP_RISE - (CAT_H - 1) >= 0; + return (up <= 9 && up >= -16 && gap <= 18 && !(b.floor && a.floor) && fits) ? [j] : []; + })); +} + +/** Fewest-hop route (excluding the start) over the graph, or undefined when unreachable or longer than `limit`. */ +export function routeBetween(graph: readonly number[][], from: number, to: number, limit = 8): number[] | undefined { + if (from === to) return []; + const previous = new Map([[from, -1]]); const queue = [from]; + while (queue.length) { + const node = queue.shift()!; + for (const next of graph[node] ?? []) { + if (previous.has(next)) continue; + previous.set(next, node); queue.push(next); + if (next === to) { + const route: number[] = []; for (let n: number = to; n !== from; n = previous.get(n)!) route.unshift(n); + return route.length <= limit ? route : undefined; + } + } + } + return undefined; +} + +export type CatState = 'walk' | 'idle' | 'sit' | 'jump' | 'land' | 'paw'; + +export class RaiseCatError extends Sim { + platforms: Platform[]; + graph: number[][]; + cat = {x: 2, feet: 1, vx: 0, vy: 0, state: 'idle' as CatState, t: 0, dir: 1 as 1 | -1, platform: -1, walkTo: undefined as number | undefined, pose: 'idle' as CatPose}; + diagnostics: Diagnostic[] = []; + wobble = new Map(); + /** A short-lived cat sound near the cat; never an error. */ + speech?: {text: string; until: number}; + /** Fake keyboard garbage over the composer row: pure overlay text, owned entirely by this effect. */ + typing?: {row: number; x: number; text: string; until: number; next: number}; + route: number[] = []; + goal = -1; + readonly visits = new Map(); + readonly sounds: string[] = []; + readonly keyboard: string[] = []; + readonly composerRow: number; + private think = 0.6; + private cycleStart = 0; + private resetting = 0; + private lastSpeech = -1e9; + private lastTyping = -1e9; + private dwell = 0; + /** The platform just left; it cannot catch the cat again until the cat is clearly away from it. */ + private origin = -1; + private originFeet = 0; + readonly log: string[] = []; + constructor(capture: ScreenCapture, private readonly textColor: number) { + super(capture, 0xca7e); + this.platforms = extractPlatforms(capture); + this.graph = platformGraph(this.platforms); + let last = -1; + for (let i = 0; i < capture.glyphs.length; i += 1) if (capture.glyphs[i] !== ' ') last = Math.floor(i / capture.width); + this.composerRow = Math.max(0, last); + // Start somewhere it can also leave again. + const mobile = this.platforms.map((_, i) => i).filter(i => this.graph[i]!.length > 0 && !this.platforms[i]!.floor); + this.place(mobile.length ? this.pick(mobile) : Math.max(0, this.platforms.length - 1)); + } + private place(index: number) { + const p = this.platforms[index]; + if (!p) { this.cat.platform = -1; this.cat.feet = this.capture.height - 1; this.cat.x = 0; return; } + this.cat.platform = index; this.cat.feet = p.y - 1; + this.cat.x = clamp(p.x0 + this.rand(0, Math.max(0, p.x1 - p.x0 - CAT_W)), 0, Math.max(0, this.capture.width - CAT_W)); + this.visit(p); + } + private region(p: Platform): string { return `${Math.min(2, Math.floor((p.y / (this.capture.height + 1)) * 3))}:${Math.min(2, Math.floor((((p.x0 + p.x1) / 2) / this.capture.width) * 3))}`; } + private visit(p: Platform) { const key = this.region(p); this.visits.set(key, (this.visits.get(key) ?? 0) + 1); } + /** Regions (thirds of the screen) the cat has been to. */ + regionsVisited(): number { return this.visits.size; } + private wordAt(row: number, x: number): {x0: number; x1: number; text: string} | undefined { + const w = this.capture.width; let i = clamp(Math.round(x), 0, w - 1); + const at = (c: number) => this.capture.glyphs[row * w + c] ?? ' '; + for (let d = 0; d < 8 && at(i) === ' '; d += 1) i = clamp(i + (d % 2 === 0 ? d + 1 : -(d + 1)), 0, w - 1); + if (at(i) === ' ') return undefined; + let a = i, b = i; while (a > 0 && at(a - 1) !== ' ') a -= 1; while (b < w - 1 && at(b + 1) !== ' ') b += 1; + let text = ''; for (let c = a; c <= b; c += 1) text += at(c); + return {x0: a, x1: b, text: text.replace(/[^\p{L}\p{N}_./~-]/gu, '').slice(0, 14) || 'text'}; + } + private raise(row: number, x: number, joke?: string, pool: readonly string[] = JOKES) { + const word = this.wordAt(row, x); + if (!word || this.diagnostics.some(d => d.row === row && d.x0 === word.x0)) return; + const text = joke ?? this.pick(pool).replace('{w}', word.text); + this.diagnostics.push({row, x0: word.x0, x1: word.x1, text, level: this.rng() < 0.7 ? 'error' : 'warn', born: this.t}); + if (this.diagnostics.length > DIAGNOSTIC_CAP) this.diagnostics.shift(); + this.log.push(text); + this.placeLabel(this.diagnostics[this.diagnostics.length - 1]!); + } + /** A label goes on a blank stretch near its range (never over text), or is skipped when the screen is full there. */ + private placeLabel(d: Diagnostic) { + const need = Math.min(d.text.length + 4, this.capture.width - 2); + for (const dy of [-1, 1, -2, 2, -3, 3]) { + const row = d.row + dy; if (row < 0 || row >= this.capture.height) continue; + const x = clamp(d.x0, 0, this.capture.width - need); + let free = true; + for (let c = x; c < x + need && free; c += 1) if (this.capture.glyphs[row * this.capture.width + c] !== ' ' || this.diagnostics.some(o => o !== d && o.labelRow === row && o.labelX !== undefined && c >= o.labelX && c < o.labelX + o.text.length + 4)) free = false; + if (free) { d.labelRow = row; d.labelX = x; return; } + } + } + private wobbleCell(i: number, dx: number, dy: number, ms: number) { if (this.wobble.size < WOBBLE_CAP) this.wobble.set(i, {dx, dy, until: this.t + ms}); } + /** The cat bumps a word: its glyphs shift 1–3 cells and spring back. */ + private bump(row: number, x: number) { + const word = this.wordAt(row, x); if (!word) return; + const shift = (this.rng() < 0.5 ? -1 : 1) * (1 + Math.floor(this.rng() * 3)); + for (let c = word.x0; c <= word.x1; c += 1) { const i = row * this.capture.width + c; if (this.capture.glyphs[i] !== ' ') this.wobbleCell(i, Math.sign(shift) * Math.min(Math.abs(shift), 1 + (c - word.x0) % 3), c % 2 === 0 ? -1 : 0, 700); } + } + private isDivider(row: number): boolean { + let n = 0, lines = 0; const w = this.capture.width; + for (let c = 0; c < w; c += 1) { const g = this.capture.glyphs[row * w + c]!; if (g !== ' ') { n += 1; if ('─━═-_—│┄┈'.includes(g)) lines += 1; } } + return n >= 10 && lines / n >= 0.6; + } + /** Landing on a line makes it dip a row and rebound; a divider bends along its length. */ + private landOn(platform: Platform, x: number) { + if (platform.floor) return; + const w = this.capture.width; + const divider = this.isDivider(platform.y); + const from = divider ? platform.x0 : Math.max(platform.x0, Math.round(x) - 4), to = divider ? Math.min(platform.x1, platform.x0 + 47) : Math.min(platform.x1, Math.round(x) + CAT_W + 4); + for (let c = from; c <= to; c += 1) { const i = platform.y * w + c; if (this.capture.glyphs[i] !== ' ') this.wobbleCell(i, 0, divider ? (c % 4 < 2 ? 1 : -1) : 1, divider ? 900 : 450); } + if (divider && this.rng() < 0.6) this.raise(platform.y, x + CAT_W / 2, DIVIDER_JOKE); + } + private maybeMeow(probability: number) { + if (this.t - this.lastSpeech < 4000 || this.rng() >= probability) return; + const text = this.pick(MEOWS); this.lastSpeech = this.t; this.speech = {text, until: this.t + 1800}; this.sounds.push(text); + } + private floorIndex() { return this.platforms.findIndex(p => p.floor); } + /** Weighted target: usually somewhere plausible nearby, sometimes a far part of the screen, favouring regions not visited yet. */ + pickGoal(): number { + const here = this.platforms[this.cat.platform]; + const candidates: Array<{i: number; w: number}> = []; + this.platforms.forEach((p, i) => { + if (i === this.cat.platform) return; + if (!routeBetween(this.graph, this.cat.platform, i)) return; + const near = here ? Math.abs(p.y - here.y) <= 8 && Math.abs((p.x0 + p.x1) / 2 - this.cat.x) <= 25 : true; + // The composer row is a favourite spot: that is where a cat sits on a keyboard. + candidates.push({i, w: (near ? 3 : 1) * (p.floor || p.y >= this.composerRow ? 2.5 : 1) / (1 + (this.visits.get(this.region(p)) ?? 0))}); + }); + if (!candidates.length) return -1; + let roll = this.rng() * candidates.reduce((s, c) => s + c.w, 0); + for (const c of candidates) { roll -= c.w; if (roll <= 0) return c.i; } + return candidates[0]!.i; + } + update(dt: number): void { + const cat = this.cat; cat.t += dt; this.think -= dt; + for (const [i, w] of this.wobble) if (this.t > w.until) this.wobble.delete(i); + if (this.speech && this.t > this.speech.until) this.speech = undefined; + if (this.typing) { + if (this.t >= this.typing.until) { this.typing = undefined; this.maybeMeow(1); this.lastSpeech = this.t; } + else if (this.t >= this.typing.next) { this.typing.text = this.keysmash(); this.typing.next = this.t + 250; } + } + if (this.resetting > 0) { + this.resetting -= dt; + if (this.resetting <= 0) { this.diagnostics = []; this.wobble.clear(); this.typing = undefined; this.speech = undefined; this.cycleStart = this.t; this.loops += 1; this.visits.clear(); } + return; + } + // Cycle: errors accumulate, hold briefly when overwhelmed, then one tasteful reset. + if (this.diagnostics.length >= DIAGNOSTIC_CAP - 2 || this.t - this.cycleStart > 90_000) { this.resetting = 3; return; } + const p = this.platforms[cat.platform]; + if (cat.state === 'jump') { this.flying(dt); return; } + if (cat.platform < 0 && this.platforms.length) { const near = this.nearestPlatform(); if (near >= 0) this.place(near); } + if (cat.state === 'walk' && p) { + const [left, right] = this.bounds(p); + cat.x += cat.dir * 6 * dt; + if (cat.walkTo !== undefined) { + const target = clamp(cat.walkTo, left, right); + if ((cat.dir === 1 && cat.x >= target) || (cat.dir === -1 && cat.x <= target)) { cat.x = target; cat.walkTo = undefined; cat.state = 'idle'; cat.t = 0; this.think = 0; } + } else if (cat.x < left || cat.x > right) { cat.x = clamp(cat.x, left, right); cat.dir = (cat.dir * -1) as 1 | -1; cat.state = 'idle'; cat.t = 0; } + } + if ((cat.state === 'land' || cat.state === 'paw' || cat.state === 'sit') && cat.t > (cat.state === 'sit' ? 1.4 : 0.55)) { cat.state = 'idle'; cat.t = 0; } + if (this.think > 0 || cat.state === 'walk' || !p) return; + this.think = this.rand(0.6, 1.8); + this.decide(p); + } + /** Where the cat's left edge may be while it stands on a platform: it can overhang the ends a little, never leave the screen. */ + private bounds(p: Platform): [number, number] { + const left = Math.max(0, p.x0 - 3); + return [left, Math.max(left, Math.min(this.capture.width - CAT_W, p.x1 - CAT_W + 4))]; + } + private keysmash(): string { + const length = 6 + Math.floor(this.rng() * (KEYBOARD_MAX - 5)); + let text = ''; for (let i = 0; i < length; i += 1) text += KEYSMASH[Math.floor(this.rng() * KEYSMASH.length)]!; + return text; + } + private decide(p: Platform) { + const cat = this.cat; const centre = cat.x + CAT_W / 2; + if (this.goal === cat.platform) { this.goal = -1; this.route = []; } + if (!this.route.length && this.goal >= 0) this.route = routeBetween(this.graph, cat.platform, this.goal) ?? []; + if (!this.route.length) this.goal = -1; + // Following a route: walk to the takeoff point, then hop. + if (this.route.length) { + const next = this.platforms[this.route[0]!]!; + const [lo, hi] = this.bounds(p); + const takeoff = clamp((next.x0 + next.x1) / 2 - CAT_W / 2, lo, hi); + if (Math.abs(cat.x - takeoff) > 1.2) { cat.walkTo = takeoff; cat.dir = takeoff > cat.x ? 1 : -1; cat.state = 'walk'; cat.t = 0; return; } + this.hop(this.route[0]!); + return; + } + // Arrived (or no goal): small local business, then sometimes pick somewhere far. + const roll = this.rng(); + const onComposer = p.floor || p.y >= this.composerRow; + if (onComposer && this.t - this.lastTyping > 12_000 && !this.typing && roll < 0.5) { this.startTyping(); return; } + this.dwell += 1; + if (this.dwell >= 3 && this.rng() < 0.55) { + const goal = this.pickGoal(); + const route = goal >= 0 ? routeBetween(this.graph, cat.platform, goal) : undefined; + if (route?.length) { this.goal = goal; this.route = route; this.dwell = 0; this.maybeMeow(0.15); return; } + } + if (roll < 0.28) { cat.state = 'walk'; cat.dir = this.rng() < 0.5 ? 1 : -1; cat.t = 0; cat.walkTo = undefined; } + else if (roll < 0.48) { cat.state = 'sit'; cat.t = 0; if (!p.floor && this.rng() < 0.8) this.raise(p.y, centre); this.maybeMeow(0.3); } + else if (roll < 0.66) { cat.state = 'paw'; cat.t = 0; if (!p.floor) { this.bump(p.y, cat.dir === 1 ? cat.x + CAT_W + 1 : cat.x - 2); this.raise(p.y, cat.dir === 1 ? cat.x + CAT_W + 1 : cat.x - 2, CHARACTER_JOKE); } this.maybeMeow(0.3); } + else if (roll < 0.74) this.maybeMeow(1); + else { const goal = this.pickGoal(); const route = goal >= 0 ? routeBetween(this.graph, cat.platform, goal) : undefined; if (route?.length) { this.goal = goal; this.route = route; } else { cat.state = 'walk'; cat.dir = (cat.dir * -1) as 1 | -1; cat.walkTo = undefined; } } + } + private startTyping() { + const cat = this.cat; cat.state = 'sit'; cat.t = -0.8; // sits a little longer on the keyboard + this.lastTyping = this.t; + const w = this.capture.width; let last = -1; + for (let c = 0; c < w; c += 1) if (this.capture.glyphs[this.composerRow * w + c] !== ' ') last = c; + const text = this.keysmash(); + const x = clamp(last + 2, 0, Math.max(0, w - text.length - 1)); + this.typing = {row: this.composerRow, x, text, until: this.t + 2300, next: this.t + 250}; + this.keyboard.push(text); + if (this.rng() < 0.35) this.raise(this.composerRow, Math.max(0, last - 2), this.pick(KEYBOARD_JOKES)); + } + private nearestPlatform(): number { + let best = -1, bestD = 1e9; + this.platforms.forEach((q, i) => { const d = Math.abs(q.y - 1 - this.cat.feet) + Math.abs(q.x0 - this.cat.x) * 0.1; if (d < bestD) { bestD = d; best = i; } }); + return best; + } + /** Platforms one hop away from where the cat stands. */ + jumpTargets(): number[] { return this.graph[this.cat.platform] ?? []; } + private hop(index: number) { + const cat = this.cat; const q = this.platforms[index]!; + const targetFeet = q.y - 1, dy = cat.feet - targetFeet; // dy > 0: the target is higher + const g = 38; + const landX = clamp(q.x0 + (q.x1 - q.x0 - CAT_W) / 2 + this.rand(-2, 2), Math.max(0, q.x0 - 4), Math.max(0, Math.min(this.capture.width - CAT_W, q.x1 - CAT_W + 4))); + if (dy < 0) { + // A drop: step off the edge and fall; no upward arc. + const tDown = Math.sqrt(Math.max(0.0001, 2 * -dy / g)); + cat.vy = -2; cat.vx = (landX - cat.x) / (tDown + 0.1); + } else { + const rise = dy + HOP_RISE; + // The arc must keep the whole sprite inside the screen; otherwise this hop is not taken. + if (targetFeet - HOP_RISE - (CAT_H - 1) < 0 || cat.feet - rise - (CAT_H - 1) < 0) { this.route = []; this.goal = -1; cat.state = 'walk'; cat.dir = (cat.dir * -1) as 1 | -1; cat.walkTo = undefined; return; } + const vy0 = -Math.sqrt(2 * g * rise); + const tUp = -vy0 / g, tDown = Math.sqrt(Math.max(0.0001, 2 * Math.max(0.2, targetFeet - (cat.feet - rise)) / g)); + cat.vy = vy0; cat.vx = (landX - cat.x) / (tUp + tDown); + } + this.origin = cat.platform; this.originFeet = cat.feet; + cat.state = 'jump'; cat.t = 0; cat.platform = -1; cat.walkTo = undefined; + } + private flying(dt: number) { + const cat = this.cat; + cat.vy += 38 * dt; cat.x += cat.vx * dt; cat.feet += cat.vy * dt; + cat.x = clamp(cat.x, 0, Math.max(0, this.capture.width - CAT_W)); + if (cat.feet < CAT_H - 1) { cat.feet = CAT_H - 1; cat.vy = Math.max(cat.vy, 0); } // never above the top row + if (cat.vy <= 0) return; + const centre = cat.x + CAT_W / 2; + const hit = this.platforms.findIndex((q, i) => !(i === this.origin && Math.abs(cat.feet - this.originFeet) < 1.5) && Math.abs(cat.feet - (q.y - 1)) < 0.9 && centre >= q.x0 - 3 && centre <= q.x1 + 3 && cat.feet <= q.y - 1 + 0.9); + if (hit < 0) return; + const q = this.platforms[hit]!; + cat.platform = hit; cat.feet = q.y - 1; cat.vx = cat.vy = 0; cat.state = 'land'; cat.t = 0; + this.visit(q); + if (this.route[0] === hit) this.route.shift(); + // Landed on a line along the way (intermediate text catches the fall): keep the goal, plan again from here. + else if (this.route.length || this.goal >= 0) this.route = this.goal >= 0 && this.goal !== hit ? routeBetween(this.graph, hit, this.goal) ?? [] : []; + if (this.goal === hit) { this.goal = -1; this.route = []; } + this.landOn(q, cat.x); + if (!q.floor) { this.bump(q.y, centre); if (this.rng() < 0.7) this.raise(q.y, centre); } + this.maybeMeow(0.35); + this.think = 0.4; + } + frame(): CatPose { + const c = this.cat; + if (c.state === 'jump') return 'jump'; + if (c.state === 'land') return 'crouch'; + if (c.state === 'walk') return Math.floor(c.t * 5) % 2 === 0 ? 'walkA' : 'walkB'; + if (c.state === 'paw') return 'paw'; + if (c.state === 'sit') return 'sit'; + const phase = this.t % 4800; + if (phase < 160) return 'blink'; + if (phase > 2200 && phase < 2700) return 'tail'; + return 'idle'; + } + paint(grid: CellGrid, ctx: EffectContext): void { + const w = this.capture.width; + // Captured text, with wobbling cells nudged (presentation only; the capture itself is never written). + const moved = new Map(); + for (const [i, wb] of this.wobble) { + const remaining = clamp((wb.until - this.t) / 700, 0, 1); + const scale = remaining > 0.5 ? 1 : remaining * 2; + moved.set(i, {x: (i % w) + Math.round(wb.dx * scale), y: Math.floor(i / w) + Math.round(wb.dy * scale)}); + } + drawStatic(grid, this.capture, new Set(moved.keys()), ctx.palette.text); + for (const [i, pos] of moved) { + const fg = this.capture.fg[i]!, bg = this.capture.bg[i]!; + grid.set(i % w, Math.floor(i / w), ' ', NO_COLOR_VALUE, bg); + grid.set(pos.x, pos.y, this.capture.glyphs[i]!, fg); + } + const newest = this.diagnostics.length - 1; + this.diagnostics.forEach((d, i) => { + const age = clamp((this.diagnostics.length - 1 - i) / 8, 0, 0.7); + const base = d.level === 'error' ? ctx.palette.error : ctx.palette.warn; + const colour = mixPacked(base, 0x707078, age); + // Highlight the range: tint in color, underline-style marks always (readable without color). + for (let x = d.x0; x <= d.x1; x += 1) { + const index = d.row * w + x; const glyph = this.capture.glyphs[index]!; + if (ctx.color) grid.set(x, d.row, glyph, visibleFg(this.capture.fg[index]!, NO_COLOR_VALUE, ctx.palette.text), mixPacked(0x101014, base, 0.45 - age * 0.25)); + if (d.row + 1 < this.capture.height && this.capture.glyphs[(d.row + 1) * w + x] === ' ' && d.labelRow !== d.row + 1) grid.set(x, d.row + 1, ctx.nerd ? '~' : '^', colour); + } + const icon = d.level === 'error' ? (ctx.nerd ? '✕' : 'x') : (ctx.nerd ? '⚠' : '!'); + if (d.labelRow !== undefined && d.labelX !== undefined) { + const label = ` ${icon} ${d.text} `.slice(0, this.capture.width - d.labelX); + for (let k = 0; k < label.length; k += 1) grid.set(d.labelX + k, d.labelRow, label[k]!, i === newest ? mixPacked(colour, 0xffffff, 0.35) : colour); + } else if (d.x0 > 0) grid.set(d.x0 - 1, d.row, icon, colour); + }); + if (this.typing) for (let k = 0; k < this.typing.text.length; k += 1) grid.set(this.typing.x + k, this.typing.row, this.typing.text[k]!, ctx.palette.warn); + const cat = this.cat; const x = Math.round(cat.x), top = Math.round(cat.feet) - (CAT_H - 1); + if (this.speech) { const label = this.speech.text; for (let k = 0; k < label.length; k += 1) grid.set(x + 3 + k, top - 1, label[k]!, ctx.palette.accent); } + const facingRight = cat.dir === 1, pose = this.frame(); + if (ctx.color) for (const cell of catCells(pose, facingRight)) grid.set(x + cell.dx, top + cell.dy, cell.glyph, cell.fg, cell.bg); + else (pose === 'blink' ? CAT_ASCII_BLINK : CAT_ASCII).forEach((line, dy) => { for (let k = 0; k < line.length; k += 1) grid.set(x + 3 + k, top + 1 + dy, line[k]!, NO_COLOR_VALUE); }); + } +} + +// --------------------------------------------------------------- registry + +export function createEffect(id: ScreenEffectId, capture: ScreenCapture, palette: EffectPalette): Sim & {glyphs?: Glyph[]} { + switch (id) { + case 'blackHole': return new BlackHole(capture, palette.text); + case 'fireworks': return new Fireworks(capture, palette); + case 'circletastic': return new Circletastic(capture, palette.text); + case 'raiseCatError': return new RaiseCatError(capture, palette.text); + } +} + +/** Draw one frame of a screen effect for scene time `time` into `grid` (cleared to the host background). */ +export function renderScreenEffect(id: ScreenEffectId, grid: CellGrid, capture: ScreenCapture, ctx: EffectContext): void { + const instance = (capture.instances[id] ??= createEffect(id, capture, ctx.palette)) as Sim; + instance.advance(ctx.time); + grid.clear(NO_COLOR_VALUE); + instance.paint(grid, ctx); +} + +/** How many full loops the capture's running effect has completed (0 when none). */ +export function effectLoops(capture: ScreenCapture, id: ScreenEffectId): number { + return ((capture.instances[id] as Sim | undefined)?.loops) ?? 0; +} diff --git a/src/index.ts b/src/index.ts index 15818c8a..8b865b28 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,34 +1,65 @@ +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 {NESTED_NMSH_MESSAGE, createOrdinaryShellEnvironment, decideHandoffReturn, handoffReturnSession, isManagedNmshEnvironment, returnsToWaitingShell, type HandoffReturn} from './shell/ShellHandoff.js'; import {spawn} from 'node:child_process'; import {PRODUCT_ABBREVIATION, PRODUCT_NAME} from './config.js'; -function startOrdinaryZsh(cwd?: string): Promise { +/** Hand the terminal to an ordinary interactive shell, spawned from this (parent) process with NMSh markers removed. */ +function startOrdinaryShell(target: {executable: string; label: string}, cwd?: string, handoff?: HandoffReturn): Promise { return new Promise(resolve => { try { - const shell = spawn('/bin/zsh', ['-i'], { + const shell = spawn(target.executable, ['-i'], { ...(cwd ? {cwd} : {}), - env: createOrdinaryZshEnvironment(), + env: createOrdinaryShellEnvironment(process.env, handoff), stdio: 'inherit', }); shell.once('error', error => { - process.stderr.write(`NMSh could not start ordinary zsh: ${error.message}\n`); + process.stderr.write(`NMSh could not start ordinary ${target.label}: ${error.message}\n`); resolve(1); }); shell.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0))); } catch (error) { const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`NMSh could not start ordinary zsh: ${message}\n`); + process.stderr.write(`NMSh could not start ordinary ${target.label}: ${message}\n`); resolve(1); } }); } +async function runMaintenanceCommand(argv: string[]): Promise { + const out = (text: string) => process.stdout.write(text); + const err = (text: string) => process.stderr.write(text); + const version = readBuildIdentity().version; + if (argv[0] === 'doctor') { + const {doctorReport} = await import('./cli/doctor.js'); + out(doctorReport()); + return 0; + } + const {ttyConfirm} = await import('./cli/configCommand.js'); + const confirm = process.stdin.isTTY && process.stderr.isTTY ? ttyConfirm : undefined; + if (argv[0] === 'config') { + const {runConfigCommand} = await import('./cli/configCommand.js'); + return runConfigCommand(argv.slice(1), {out, err, confirm, version}); + } + const {runUninstallCommand} = await import('./cli/uninstallCommand.js'); + return runUninstallCommand(argv.slice(1), {out, err, confirm}); +} + const args = process.argv.slice(2); const attachIndex = args.indexOf('--attach'); +const presetIndex = args.indexOf('--preset'); -if (isVersionInvocation(args)) { +if (args[0] === 'config' || args[0] === 'uninstall' || args[0] === 'doctor') { + // Non-interactive maintenance commands: allowed from inside an NMSh-managed shell too. + process.exitCode = await runMaintenanceCommand(args); +} else 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'); @@ -50,9 +81,46 @@ if (isVersionInvocation(args)) { } else { const {TerminalApp} = await import('./app/TerminalApp.js'); const {attachSession, connectSession, listLiveSessions, SESSION_SERVICE_ENV} = await import('./session/connectSession.js'); - const size = () => ({cwd: process.cwd(), columns: process.stdout.columns || 80, rows: Math.max(2, (process.stdout.rows || 24) - 4)}); + // New sessions start the default backend from Settings; a missing one falls back to zsh, said plainly. + const {loadPromptConfiguration: loadBackendConfiguration} = await import('./prompt/configuration.js'); + const {shellAdapter} = await import('./shell/adapters/registry.js'); + let backend = loadBackendConfiguration().shellBackend; + let backendNotice: string | undefined; + const missing = shellAdapter(backend).unavailableReason(process.env); + if (backend !== 'zsh' && missing) { + backendNotice = `${missing} Started zsh instead; your default stays ${shellAdapter(backend).label}.`; + backend = 'zsh'; + } + const size = () => ({cwd: process.cwd(), columns: process.stdout.columns || 80, rows: Math.max(2, (process.stdout.rows || 24) - 4), shell: backend}); 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 +141,19 @@ if (isVersionInvocation(args)) { } } catch { /* recovery is best effort and never blocks launch */ } } - if (!explicit && !args.includes('--new') && process.env[SESSION_SERVICE_ENV] !== '0') { + // Launched from the ordinary shell a deliberate /zsh, /fish, /bash or /exit + // started: return to exactly that session, without the startup picker. + // --new, --attach and --preset win; a gone session is never substituted. + const returnSession = pendingPreset || explicit ? undefined : handoffReturnSession(process.env, args); + let returnUnavailable = false; + if (returnSession && process.env[SESSION_SERVICE_ENV] !== '0') { + let live: Awaited> = []; + try { live = await listLiveSessions(); } catch { /* no usable service */ } + const decision = decideHandoffReturn(returnSession, live); + if (decision.kind === 'attach') target = decision.sessionId; + else { notice = [notice, decision.notice].filter(Boolean).join(' '); returnUnavailable = true; } + } + if (!target && !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'); @@ -81,16 +161,27 @@ if (isVersionInvocation(args)) { const {detectTerminalHost} = await import('./host/terminalHost.js'); const {loadPromptConfiguration, savePromptConfiguration} = await import('./prompt/configuration.js'); const config = loadPromptConfiguration(); + // Same confirmed termination as /resume. Service truth is re-read first: a session that ended + // meanwhile is simply gone, and one attached elsewhere is never touched. + const killDetached = async (session: (typeof live)[number]): Promise<'killed' | 'gone' | 'attached'> => { + const [{killAndArchive}, {TranscriptStore}] = await Promise.all([import('./session/liveSessions.js'), import('./sessions/TranscriptStore.js')]); + const current = (await listLiveSessions().catch(() => [])).find(item => item.id === session.id); + if (!current) return 'gone'; + if (current.state !== 'detached') return 'attached'; + try { await killAndArchive(current, {store: new TranscriptStore(), origin: 'the startup picker'}); return 'killed'; } + catch (error) { + if (!(await listLiveSessions().catch(() => [current])).some(item => item.id === session.id)) return 'gone'; + throw error; + } + }; const restored = await restoreAtStartup(live, { - policy: {startup: config.liveSessionStartup, multiple: config.liveSessionMultiple}, + // After a failed return, never silently attach a different session: ask instead of Always. + policy: {startup: returnUnavailable && config.liveSessionStartup === 'always' ? 'ask' : config.liveSessionStartup, multiple: config.liveSessionMultiple}, saveStartup: startup => { - try { savePromptConfiguration({...loadPromptConfiguration(), liveSessionStartup: startup}); } catch { /* keep going; applies this launch */ } - }, - askOne: session => picker.runStartupScreen(columns => picker.renderSinglePrompt(session, columns, Date.now()), picker.singlePromptKey), - pick: sessions => { - const state = picker.createMultiPicker(sessions); - return picker.runStartupScreen(columns => picker.renderMultiPicker(state, columns, Date.now()), key => picker.multiPickerKey(state, key)); + try { const base = loadPromptConfiguration(); savePromptConfiguration({...base, liveSessionStartup: startup}, undefined, base); } catch { /* keep going; applies this launch */ } }, + askOne: session => picker.askWithKill(session, {run: picker.runStartupScreen, kill: killDetached}), + pick: sessions => picker.pickWithKill(sessions, {run: picker.runStartupScreen, kill: killDetached}), host: detectTerminalHost(), selfCommand: [process.execPath, ...process.execArgv.filter(arg => !arg.startsWith('--inspect')), process.argv[1]!], }); @@ -113,21 +204,49 @@ if (isVersionInvocation(args)) { } } connection ??= await connectSession(size()); + if (connection.mode === 'service' && !connection.client.features.has('shell-switch')) { + notice = [notice, 'Connected to an older NMSh session service (still running its live sessions): shell switching is unavailable until those sessions end; the next launch after that starts the current service.'].filter(Boolean).join(' '); + } + if (backendNotice && !connection.attached) { notice = [notice, backendNotice].filter(Boolean).join(' ') || undefined; backendNotice = undefined; } + if (connection.shell && connection.shell !== backend && !connection.attached) { + notice = [notice, `The running session service started ${connection.shell} (it predates shell backends); end its sessions to use ${backend}.`].filter(Boolean).join(' '); + } 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; continue; } - process.exitCode = app.isOrdinaryZshHandoffRequested - ? await startOrdinaryZsh(app.ordinaryZshHandoffCwd) - : exitCode; + const handoff = app.shellHandoff; + if (handoff && returnsToWaitingShell(process.env, handoff.shell, handoff.returnSession)) { + // This NMSh was started from the very shell that is waiting for this session: go back to it, don't nest another. + process.exitCode = 0; + break; + } + process.exitCode = handoff ? await startOrdinaryShell(handoff, handoff.cwd, handoff.returnSession ? {sessionId: handoff.returnSession, shell: handoff.shell} : undefined) : exitCode; break; } } diff --git a/src/input/CommandEditor.ts b/src/input/CommandEditor.ts index f359a528..35fefcd2 100644 --- a/src/input/CommandEditor.ts +++ b/src/input/CommandEditor.ts @@ -251,9 +251,10 @@ export class CommandEditor { if (this.selectionAnchor === this.cursor) this.selectionAnchor = undefined; } - moveUp(columns: number, firstLinePrefix?: string): void { + /** False when the caret is already on the first visual row (nothing above to move to). */ + moveUp(columns: number, firstLinePrefix?: string): boolean { this.clearSelection(); - this.moveVertical(columns, -1, firstLinePrefix); + return this.moveVertical(columns, -1, firstLinePrefix); } selectUp(columns: number, firstLinePrefix?: string): void { @@ -262,9 +263,10 @@ export class CommandEditor { if (this.selectionAnchor === this.cursor) this.selectionAnchor = undefined; } - moveDown(columns: number, firstLinePrefix?: string): void { + /** False when the caret is already on the last visual row. */ + moveDown(columns: number, firstLinePrefix?: string): boolean { this.clearSelection(); - this.moveVertical(columns, 1, firstLinePrefix); + return this.moveVertical(columns, 1, firstLinePrefix); } selectDown(columns: number, firstLinePrefix?: string): void { @@ -374,6 +376,19 @@ export class CommandEditor { if (this.cursor < this.characters.length) this.characters.splice(this.cursor, 1); } + /** Replace the whole buffer with plain text, caret at the end (history recall). */ + replaceText(value: string): void { + this.characters = graphemes(normalizedText(value)); + this.cursor = this.characters.length; + this.clearSelection(); + } + + /** Place the caret at a grapheme index (clamped); clears any selection. */ + setCursor(index: number): void { + this.cursor = Math.max(0, Math.min(this.characters.length, index)); + this.clearSelection(); + } + clear(): void { this.characters = []; this.cursor = 0; @@ -404,10 +419,10 @@ export class CommandEditor { return total; } - private moveVertical(columns: number, direction: -1 | 1, firstLinePrefix?: string): void { + private moveVertical(columns: number, direction: -1 | 1, firstLinePrefix?: string): boolean { const current = layoutInput(this.displayText, this.displayCursorIndex, columns, Number.POSITIVE_INFINITY, firstLinePrefix); const targetRow = current.caretRow + direction; - if (targetRow < 0 || targetRow >= current.allRows.length) return; + if (targetRow < 0 || targetRow >= current.allRows.length) return false; let bestIndex = this.cursor; let bestDistance = Number.POSITIVE_INFINITY; for (let index = 0; index <= this.characters.length; index += 1) { @@ -420,5 +435,6 @@ export class CommandEditor { } } this.cursor = bestIndex; + return true; } } diff --git a/src/input/ComposerHistory.ts b/src/input/ComposerHistory.ts new file mode 100644 index 00000000..17b8616d --- /dev/null +++ b/src/input/ComposerHistory.ts @@ -0,0 +1,109 @@ +/** Most recent distinct commands one navigation can reach; keeps a keypress bounded. */ +export const COMPOSER_HISTORY_LIMIT = 1000; + +/** + * Shell-style Up/Down recall over a snapshot of the authoritative history + * (newest first). The snapshot is taken once when navigation starts, so a + * keypress never touches disk or a provider. Stored entries are never + * mutated: the composer only receives copies of their text. + * + * Editing a recalled command ends navigation; the edited text becomes the new + * draft, so a later Up starts again from the newest entry and Down past it + * restores exactly what was being edited. + */ +export class ComposerHistory { + private entries: readonly string[] = []; + private index = -1; + private draft = ''; + /** The text this navigator last placed in the composer. */ + private shown?: string; + + /** Whether the composer currently shows text recalled by this navigator. */ + get active(): boolean { + return this.index >= 0; + } + + /** True while `text` is still exactly what navigation put in the composer. */ + showing(text: string): boolean { + return this.active && this.shown === text; + } + + /** + * One step older. `source` is read only when navigation starts. Returns the + * text to show, or undefined at the oldest entry (no wrap). + */ + previous(current: string, source: () => Iterable): string | undefined { + if (!this.showing(current)) this.begin(current, source); + if (this.index + 1 >= this.entries.length) return undefined; + this.index += 1; + return this.show(this.entries[this.index]!); + } + + /** One step newer; past the newest entry the original draft returns. Undefined when not navigating. */ + next(current: string): string | undefined { + if (!this.showing(current)) { this.reset(); return undefined; } + this.index -= 1; + if (this.index < 0) { + const draft = this.draft; + this.reset(); + return draft; + } + return this.show(this.entries[this.index]!); + } + + reset(): void { + this.entries = []; + this.index = -1; + this.draft = ''; + this.shown = undefined; + } + + private begin(current: string, source: () => Iterable): void { + this.reset(); + this.draft = current; + const seen = new Set(); + const entries: string[] = []; + for (const command of source()) { + // Distinct commands only; the unsent draft itself is not a step. + if (!command.trim() || seen.has(command) || command === current) continue; + seen.add(command); + entries.push(command); + if (entries.length >= COMPOSER_HISTORY_LIMIT) break; + } + this.entries = entries; + } + + private show(text: string): string { + this.shown = text; + return text; + } +} + +/** One submission in this NMSh session, newest last. */ +export interface SessionSubmission { + text: string; + /** An NMSh slash command: composer recall only, never shell or external history. */ + slash: boolean; +} + +/** Bounded so a long session cannot grow recall without limit. */ +export const SESSION_SUBMISSION_LIMIT = 200; + +/** How far into shell history a session's shell command is looked for. */ +const RECORDED_WINDOW = 2000; + +/** + * Recall order: this session's submissions newest first (NMSh slash commands + * in their real place between shell commands), then shell history. A session + * shell command is offered only when the history provider recorded it, so the + * shell's own history policy (ignored commands, for example) still decides. + * Duplicates collapse in ComposerHistory, keeping the newest position. + */ +export function* recallSource(session: readonly SessionSubmission[], history: readonly string[]): Iterable { + const recorded = new Set(history.slice(0, RECORDED_WINDOW)); + for (let index = session.length - 1; index >= 0; index -= 1) { + const entry = session[index]!; + if (entry.slash || recorded.has(entry.text)) yield entry.text; + } + yield* history; +} 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 && /(?; + /** First visible source line. */ + top: number; +} + +export type PasteReviewResult = 'insert' | 'back' | 'cancel' | undefined; + +export function createPasteReview(text: string, analysis: PasteAnalysis): PasteReviewState { + const lines = text.replace(/\r\n?/gu, '\n').split('\n'); + const starts: Array = new Array(lines.length).fill(undefined); + // Locate each command in order from where the previous one ended; display-only, linear in the paste size. + const offsets: number[] = []; + let at = 0; + for (const line of lines) { offsets.push(at); at += line.length + 1; } + const normalized = lines.join('\n'); + let cursor = 0; + let lineIndex = 0; + for (const command of analysis.commands) { + const found = normalized.indexOf(command.text, cursor); + if (found < 0) continue; + cursor = found + command.text.length; + while (lineIndex + 1 < lines.length && offsets[lineIndex + 1]! <= found) lineIndex += 1; + starts[lineIndex] ??= primaryKind(command.kinds); + } + return {text, analysis, lines, starts, top: 0}; +} + +/** The body rows available for source lines: header, a blank, a note row and the footer are fixed. */ +function bodyRows(height: number): number { + return Math.max(1, height - 4); +} + +export function pasteReviewKey(state: PasteReviewState, key: Key, height: number): PasteReviewResult { + const page = Math.max(1, bodyRows(height) - 1); + const max = Math.max(0, state.lines.length - bodyRows(height)); + const move = (to: number) => { state.top = Math.max(0, Math.min(max, to)); }; + if (key.kind === 'enter') return 'insert'; + if (key.kind === 'escape') return 'back'; + if (key.kind === 'interrupt') return 'cancel'; + if (key.kind === 'text' && key.value.toLowerCase() === 'r') return 'back'; + if (key.kind === 'up' || key.kind === 'wheelUp') move(state.top - (key.kind === 'wheelUp' ? 3 : 1)); + else if (key.kind === 'down' || key.kind === 'wheelDown') move(state.top + (key.kind === 'wheelDown' ? 3 : 1)); + else if (key.kind === 'pageUp') move(state.top - page); + else if (key.kind === 'pageDown') move(state.top + page); + else if (key.kind === 'lineHome' || key.kind === 'bufferHome') move(0); + else if (key.kind === 'lineEnd' || key.kind === 'bufferEnd') move(max); + return undefined; +} + +/** Make a pasted line safe and legible to draw: control characters become visible, tabs become spaces. */ +export function displaySafe(line: string): string { + return line.replace(/\t/gu, ' ').replace(/[\u0000-\u001f\u007f-\u009f]/gu, char => `␛`.length && char === '\u001B' ? '␛' : '·'); +} + +const RISKY: ReadonlySet = new Set(['destructive', 'privilege', 'pipeline']); + +/** Exactly `height` rows (never taller, never empty), so the panel does not jump while scrolling. */ +export function renderPasteReview(state: PasteReviewState, columns: number, height: number): string[] { + const subtle = foreground(UI_COLORS.subtle); + const secondary = foreground(UI_COLORS.secondary); + const primary = foreground(UI_COLORS.primary); + const accent = foreground(UI_COLORS.accent); + const failure = foreground(UI_COLORS.failure); + const reset = '\u001B[0m'; + const total = state.lines.length; + const rows = bodyRows(height); + const top = Math.max(0, Math.min(state.top, Math.max(0, total - rows))); + const last = Math.min(total, top + rows); + const numberWidth = String(total).length; + const labelWidth = columns >= 76 ? 26 : columns >= 56 ? 16 : 0; + const out: string[] = [` ${primary}Review paste${reset} ${subtle}${pasteHeader(state.analysis).replace(/^pasted · /u, '')} · lines ${top + 1}-${last} of ${total}${reset}`, '']; + for (let index = top; index < top + rows; index += 1) { + if (index >= total) { out.push(''); continue; } + const kind = state.starts[index]; + const color = kind ? (RISKY.has(kind) ? failure : kind === 'install' || kind === 'modifies' ? accent : subtle) : subtle; + const label = labelWidth ? `${color}${padCells(kind ? truncateAnsi(KIND_LABELS[kind], labelWidth - 1) : '', labelWidth)}${reset}` : kind && RISKY.has(kind) ? `${failure}! ${reset}` : ''; + out.push(truncateAnsi(` ${subtle}${String(index + 1).padStart(numberWidth)}${reset} ${label}${kind === 'text' ? subtle : secondary}${displaySafe(state.lines[index]!)}${reset}`, columns)); + } + const exact = state.analysis.commands.some(command => primaryKind(command.kinds) === 'text' || primaryKind(command.kinds) === 'unknown'); + out.push(truncateAnsi(` ${subtle}${exact ? PASTE_EXACT_NOTE : 'Nothing runs until you press Enter again.'}${reset}`, columns)); + out.push(renderControls([['↑↓ PgUp PgDn', 'scroll'], ['Enter', 'insert'], ['Esc', 'back']])); + // A very short terminal drops the note before the controls; the panel never exceeds its height. + const fixed = Math.max(3, height); + return out.length > fixed ? [...out.slice(0, fixed - 1), out[out.length - 1]!] : out; +} diff --git a/src/input/SyntaxPanel.ts b/src/input/SyntaxPanel.ts index 39a7879b..c012d60b 100644 --- a/src/input/SyntaxPanel.ts +++ b/src/input/SyntaxPanel.ts @@ -1,16 +1,17 @@ +import {THEME_FAMILIES} from '../appearance/themeFamilies.js'; +import {familyOf, defaultVariant, variantOptions} from '../appearance/themeSelection.js'; import { - NATIVE_PALETTE_IDS, SYNTAX_COLOR_MODES, type NativePaletteId, type SyntaxAppearance, type SyntaxColorMode, } from '../prompt/configuration.js'; -import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js'; +import {NATIVE_PROMPT_THEMES, themeContext} from '../prompt/prompt.js'; import type {CommandType} from '../shell/SemanticService.js'; import type {Key} from '../terminal/keys.js'; import {DRAFT_PANEL_ACTIONS, renderActionHelp} from '../ui/actions.js'; import {GLYPHS} from '../ui/glyphs.js'; -import {foreground, UI_COLORS} from '../ui/palette.js'; +import {focusForeground, foreground, UI_COLORS, lazyForeground} from '../ui/palette.js'; import {truncateAnsi} from '../util/text.js'; import {graphemes} from './inputLayout.js'; import {Highlighter} from './Highlighter.js'; @@ -24,12 +25,12 @@ export interface SyntaxPanelState { message?: string; } -type Row = 'highlighting' | 'colors' | 'theme'; +type Row = 'highlighting' | 'colors' | 'family' | 'theme'; -const PRIMARY = foreground(UI_COLORS.primary); -const SECONDARY = foreground(UI_COLORS.secondary); -const ACCENT = foreground(UI_COLORS.accent); -const SUBTLE = foreground(UI_COLORS.subtle); +const PRIMARY = lazyForeground(UI_COLORS.primary); +const SECONDARY = lazyForeground(UI_COLORS.secondary); +const ACCENT = lazyForeground(UI_COLORS.accent); +const SUBTLE = lazyForeground(UI_COLORS.subtle); const RESET = '\u001B[0m'; /** Short rows that together exercise every token type. */ @@ -56,7 +57,7 @@ export function colorModeLabel(mode: SyntaxColorMode): string { /** Colors only matter while highlighting is on; Theme only in Choose theme. */ function rows(draft: SyntaxAppearance): Row[] { if (!draft.highlighting) return ['highlighting']; - return ['highlighting', 'colors', ...(draft.colors === 'theme' ? ['theme' as const] : [])]; + return ['highlighting', 'colors', ...(draft.colors === 'theme' ? ['family' as const, ...(variantOptions(familyOf(draft.theme)).length > 1 ? ['theme' as const] : [])] : [])]; } function cycle(values: readonly T[], current: T, delta: number): T { @@ -78,7 +79,12 @@ export function handleSyntaxPanelKey(key: Key, state: SyntaxPanelState): boolean switch (available[state.selectedIndex]) { case 'highlighting': draft.highlighting = !draft.highlighting; break; case 'colors': draft.colors = cycle(SYNTAX_COLOR_MODES, draft.colors, delta); break; - case 'theme': draft.theme = cycle(NATIVE_PALETTE_IDS, draft.theme, delta); break; + case 'family': { + const families = THEME_FAMILIES.filter(family => family.id !== 'custom' || themeContext().custom).map(family => family.id); + draft.theme = defaultVariant(cycle(families, familyOf(draft.theme), delta)); + break; + } + case 'theme': draft.theme = cycle(variantOptions(familyOf(draft.theme)).map(option => option.id), draft.theme, delta); break; default: return false; } state.selectedIndex = Math.min(state.selectedIndex, rows(draft).length - 1); @@ -103,31 +109,32 @@ export function renderSyntaxPanel(state: SyntaxPanelState, columns: number, prom const labels: Record = { highlighting: `Highlighting ${value(onOff(draft.highlighting), onOff(saved.highlighting))}`, colors: `Colors ${value(colorModeLabel(draft.colors), colorModeLabel(saved.colors))}`, - theme: `Theme ${value(NATIVE_PROMPT_THEMES[draft.theme].label, NATIVE_PROMPT_THEMES[saved.theme].label)}`, + family: `Family ${value(THEME_FAMILIES.find(family => family.id === familyOf(draft.theme))!.label, THEME_FAMILIES.find(family => family.id === familyOf(saved.theme))!.label)}`, + theme: `Variant ${value(NATIVE_PROMPT_THEMES[draft.theme].label, NATIVE_PROMPT_THEMES[saved.theme].label)}`, }; rows(draft).forEach((row, index) => { const selected = index === state.selectedIndex; - out.push(`${selected ? `${ACCENT}›` : ' '} ${selected ? ACCENT : SECONDARY}${labels[row]}${RESET}`); + out.push(`${selected ? `${ACCENT}›` : ' '} ${focusForeground(selected)}${labels[row]}${RESET}`); }); if (draft.highlighting && draft.colors === 'followPrompt') { - out.push(` ${SUBTLE}Following ${NATIVE_PROMPT_THEMES[promptPalette].label} (the Native palette, even with Starship or Powerlevel10k)${RESET}`); + out.push(` ${SUBTLE}Following ${NATIVE_PROMPT_THEMES[promptPalette].label} (the saved NMSh theme, including with external prompts)${RESET}`); } const gallery: string[] = []; if (draft.highlighting && draft.colors === 'theme') { gallery.push('', `${PRIMARY}Syntax themes${RESET} ${SUBTLE}● selected ✓ saved${RESET}`); - for (const id of NATIVE_PALETTE_IDS) { + for (const {id} of variantOptions(familyOf(draft.theme))) { const marker = draft.theme === id ? `${ACCENT}●` : `${SUBTLE}○`; const savedMark = saved.highlighting && saved.colors === 'theme' && saved.theme === id ? '✓' : ' '; const sample = renderSyntaxPreviewLine(SYNTAX_PREVIEW_LINES[1], {...draft, theme: id}, promptPalette); - gallery.push(`${marker} ${SECONDARY}${NATIVE_PROMPT_THEMES[id].label.padEnd(17)}${ACCENT}${savedMark}${RESET} ${sample}`); + gallery.push(`${marker} ${SECONDARY}${NATIVE_PROMPT_THEMES[id].label.padEnd(23)}${ACCENT}${savedMark}${RESET} ${sample}`); } } const preview = ['', `${PRIMARY}Preview${RESET} ${syntaxDraftChanged(state) ? `${ACCENT}unsaved preview` : `${SUBTLE}matches current`}${RESET}`, ...SYNTAX_PREVIEW_LINES.map(line => ` ${ACCENT}${GLYPHS.prompt}${RESET} ${renderSyntaxPreviewLine(line, draft, promptPalette)}`)]; if (state.message) preview.push(`${SECONDARY}${state.message}${RESET}`); - const controls = ['', renderActionHelp(DRAFT_PANEL_ACTIONS)]; + const controls = ['', ` ${SUBTLE}Theme families are shared with /theme. Syntax colors affect NMSh input and submitted commands only.${RESET}`, renderActionHelp(DRAFT_PANEL_ACTIONS)]; const includeGallery = out.length + gallery.length + preview.length + controls.length <= rowsAvailable; return [...out, ...(includeGallery ? gallery : []), ...preview, ...controls].map(row => truncateAnsi(row, columns)); diff --git a/src/input/editingKeys.ts b/src/input/editingKeys.ts new file mode 100644 index 00000000..87cdfb4b --- /dev/null +++ b/src/input/editingKeys.ts @@ -0,0 +1,55 @@ +import type {Key} from '../terminal/keys.js'; +import type {CommandEditor} from './CommandEditor.js'; + +/** + * The editing keys every NMSh-owned text input shares: caret movement, word + * and line movement, selection and deletion over a CommandEditor. The shell + * composer, Ask and other NMSh inputs call this so they edit identically; + * keys with surface-specific meaning (Enter, Tab, ↑↓ history, ← on an idle + * composer) stay with the surface. + * + * Returns true when the key was an editing key and the editor handled it. + */ +export function applyEditingKey(editor: CommandEditor, key: Key, columns = 80, firstLinePrefix?: string): boolean { + switch (key.kind) { + case 'text': editor.insert(key.value.replace(/[\u0000-\u001f\u007f]/gu, '')); return true; + case 'paste': editor.insert(key.value.replace(/\r\n?/gu, '\n').replace(/[\u0000-\u0009\u000b-\u001f\u007f]+/gu, ' ')); return true; + case 'newline': editor.insert('\n'); return true; + case 'left': editor.moveLeft(); return true; + case 'right': editor.moveRight(); return true; + case 'selectLeft': editor.selectLeft(); return true; + case 'selectRight': editor.selectRight(); return true; + case 'wordLeft': editor.wordLeft(); return true; + case 'wordRight': editor.wordRight(); return true; + case 'selectWordLeft': editor.selectWordLeft(); return true; + case 'selectWordRight': editor.selectWordRight(); return true; + case 'selectUp': editor.selectUp(columns, firstLinePrefix); return true; + case 'selectDown': editor.selectDown(columns, firstLinePrefix); return true; + case 'lineHome': editor.lineHome(); return true; + case 'lineEnd': editor.lineEnd(); return true; + case 'selectLineHome': editor.selectLineHome(); return true; + case 'selectLineEnd': editor.selectLineEnd(); return true; + case 'bufferHome': editor.moveBufferHome(); return true; + case 'bufferEnd': editor.moveBufferEnd(); return true; + case 'selectBufferHome': editor.selectBufferHome(); return true; + case 'selectBufferEnd': editor.selectBufferEnd(); return true; + case 'selectAll': editor.selectAll(); return true; + case 'backspace': editor.backspace(); return true; + case 'delete': editor.delete(); return true; + case 'deleteWord': editor.deleteWord(); return true; + case 'deleteLineBefore': editor.deleteLineBefore(); return true; + case 'deleteLineAfter': editor.deleteLineAfter(); return true; + default: return false; + } +} + +/** Set the caret from a click column in a single-line input (0 = first grapheme). */ +export function caretFromColumn(editor: CommandEditor, column: number): void { + editor.moveBufferHome(); + let width = 0; + for (const grapheme of [...new Intl.Segmenter(undefined, {granularity: 'grapheme'}).segment(editor.text)].map(part => part.segment)) { + if (grapheme === '\n' || width >= column) break; + width += 1; + editor.moveRight(); + } +} diff --git a/src/input/inputLayout.ts b/src/input/inputLayout.ts index 90c99b92..0f1a735b 100644 --- a/src/input/inputLayout.ts +++ b/src/input/inputLayout.ts @@ -36,7 +36,9 @@ export function layoutInput( const safeCursor = Math.max(0, Math.min(glyphs.length, cursorIndex)); const width = Math.max(1, columns); const INPUT_PREFIX = firstLinePrefix ?? `${GLYPHS.prompt} `; - const rows: InputRow[] = [{prefix: width >= 2 ? INPUT_PREFIX : GLYPHS.prompt, text: '', charStart: 0, charEnd: 0}]; + // An explicitly empty prefix (Prompt None) means no marker and no continuation indent. + const bare = firstLinePrefix === ''; + const rows: InputRow[] = [{prefix: bare ? '' : width >= 2 ? INPUT_PREFIX : GLYPHS.prompt, text: '', charStart: 0, charEnd: 0}]; let rowIndex = 0; let contentWidth = 0; let caretRow = 0; @@ -51,7 +53,7 @@ export function layoutInput( const addRow = (nextCharStart: number): void => { rows[rowIndex].charEnd = nextCharStart; - rows.push({prefix: width >= 4 ? CONTINUATION_PREFIX : '', text: '', charStart: nextCharStart, charEnd: nextCharStart}); + rows.push({prefix: !bare && width >= 4 ? CONTINUATION_PREFIX : '', text: '', charStart: nextCharStart, charEnd: nextCharStart}); rowIndex += 1; contentWidth = 0; }; diff --git a/src/input/pasteGuard.ts b/src/input/pasteGuard.ts new file mode 100644 index 00000000..57881c97 --- /dev/null +++ b/src/input/pasteGuard.ts @@ -0,0 +1,238 @@ +/** + * Paste Preview / Paste Guard. Ordinary single-line pastes insert at once; + * a paste that is multiline, chains several commands, mutates, destroys, + * escalates privilege, pipes downloaded text into an interpreter, or is + * unusually large is shown first (muted, above the composer) with Insert / + * Review / Cancel. Classification is for display only: what is inserted is + * exactly what was pasted, and nothing runs until the person presses Enter. + */ +export type PasteKind = 'text' | 'unknown' | 'navigation' | 'read' | 'project' | 'network' | 'install' | 'modifies' | 'destructive' | 'privilege' | 'pipeline'; + +export interface PasteCommand { + /** The command's text as written (display only). */ + text: string; + kinds: PasteKind[]; +} + +export interface PasteAnalysis { + /** Commands as the shell would separate them (newlines, ; && || |, respecting quotes). */ + commands: PasteCommand[]; + lines: number; + characters: number; + operators: number; +} + +export type PastePreviewMode = 'smart' | 'always' | 'off'; +export const PASTE_PREVIEW_MODES: readonly PastePreviewMode[] = ['smart', 'always', 'off']; + +/** Split shell text into commands at newlines and ; && || | outside quotes, comments and escapes. */ +export function splitCommands(text: string): Array<{text: string; pipedFrom: boolean; pipesTo: boolean}> { + const out: Array<{text: string; pipedFrom: boolean; pipesTo: boolean}> = []; + let current = ''; + let quote: '"' | '\'' | '`' | undefined; + let pipedFrom = false; + const push = (pipesTo: boolean) => { + const trimmed = current.trim(); + if (trimmed) out.push({text: trimmed, pipedFrom, pipesTo}); + pipedFrom = pipesTo; + current = ''; + }; + for (let index = 0; index < text.length; index += 1) { + const char = text[index]!; + const next = text[index + 1]; + if (quote) { + current += char; + if (char === '\\' && quote !== '\'' && next !== undefined) { current += next; index += 1; continue; } + if (char === quote) quote = undefined; + continue; + } + if (char === '\\' && next !== undefined) { current += char + next; index += 1; continue; } + if (char === '"' || char === '\'' || char === '`') { quote = char; current += char; continue; } + if (char === '#' && (current === '' || /\s$/u.test(current))) { while (index < text.length && text[index] !== '\n') index += 1; push(false); continue; } + if (char === '\n' || char === ';') { push(false); continue; } + if (char === '&' && next === '&') { push(false); index += 1; continue; } + if (char === '|' && next === '|') { push(false); index += 1; continue; } + if (char === '|') { push(true); continue; } + current += char; + } + push(false); + return out; +} + +const INTERPRETERS = /^(?:sh|bash|zsh|fish|dash|ksh|python3?|node|perl|ruby|eval|source|\.)$/u; + +/** Words of one command (quotes removed for classification only). */ +function words(text: string): string[] { + return text.match(/"(?:\\.|[^"])*"|'[^']*'|\S+/gu)?.map(word => word.replace(/^["']|["']$/gu, '')) ?? []; +} + +const READ_COMMANDS = new Set(['ls', 'll', 'la', 'cat', 'less', 'more', 'head', 'tail', 'pwd', 'echo', 'printf', 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'fd', 'which', 'whereis', 'type', + 'whoami', 'id', 'groups', 'hostname', 'uname', 'date', 'cal', 'uptime', 'df', 'du', 'ps', 'top', 'htop', 'wc', 'tree', 'stat', 'file', 'env', 'printenv', 'history', 'man', 'tldr', + 'sort', 'uniq', 'diff', 'cmp', 'cut', 'tr', 'basename', 'dirname', 'realpath', 'readlink', 'jq', 'yq', 'bat', 'eza', 'lsof', 'sw_vers', 'arch', 'nproc', 'locale', 'true', 'false', 'clear']); +const NETWORK_COMMANDS = new Set(['curl', 'wget', 'http', 'https', 'nc', 'ssh', 'scp', 'sftp', 'rsync', 'ftp', 'telnet', 'ping', 'dig', 'nslookup', 'traceroute', 'mtr']); +/** Everything above plus tools whose names ordinary prose would not start with; used to tell prose from shell input. */ +const KNOWN_COMMANDS = new Set([...READ_COMMANDS, ...NETWORK_COMMANDS, 'cd', 'pushd', 'popd', 'z', 'zi', 'sudo', 'doas', 'su', 'pkexec', 'git', 'gh', 'nmsh', 'npm', 'pnpm', 'yarn', 'bun', + 'npx', 'pnpx', 'bunx', 'node', 'deno', 'tsx', 'tsc', 'python', 'python3', 'pip', 'pip3', 'pipx', 'poetry', 'uv', 'ruby', 'gem', 'bundle', 'rake', 'cargo', 'rustc', 'rustup', 'go', + 'make', 'cmake', 'ninja', 'java', 'javac', 'mvn', 'gradle', 'php', 'composer', 'swift', 'xcodebuild', 'docker', 'podman', 'kubectl', 'helm', 'terraform', 'ansible', 'aws', 'gcloud', 'az', + 'brew', 'apt', 'apt-get', 'dnf', 'yum', 'pacman', 'zypper', 'apk', 'port', 'snap', 'flatpak', 'mv', 'cp', 'mkdir', 'rmdir', 'rm', 'touch', 'ln', 'tee', 'sed', 'awk', 'perl', 'install', + 'chmod', 'chown', 'patch', 'unzip', 'zip', 'tar', 'gzip', 'gunzip', 'find', 'xargs', 'kill', 'killall', 'pkill', 'dd', 'truncate', 'shred', 'mkfs', 'sh', 'bash', 'zsh', 'fish', 'dash', + 'ksh', 'eval', 'source', 'export', 'alias', 'unalias', 'unset', 'set', 'exit', 'sleep', 'time', 'test', 'open', 'code', 'vim', 'nvim', 'vi', 'nano', 'emacs', 'tmux', 'screen', + 'pbcopy', 'pbpaste', 'defaults', 'launchctl', 'systemctl', 'journalctl', 'service', 'jest', 'vitest', 'mocha', 'pytest', 'eslint', 'prettier', 'ssh-keygen', 'ssh-add', 'fzf', 'zoxide']); + +const VERSION_QUERY = new Set(['--version', '-v', '-V', 'version', '--help', '-h', 'help']); +const NMSH_READ = new Set(['--version', '-v', '-V', 'version', '--help', '-h', 'help', 'doctor']); +/** Subcommands of the JS package managers that are not project scripts (pnpm/yarn/bun run a script for anything else). */ +const PACKAGE_BUILTINS = new Set(['install', 'i', 'add', 'ci', 'update', 'upgrade', 'up', 'remove', 'uninstall', 'rm', 'ls', 'list', 'll', 'la', 'why', 'explain', 'outdated', 'audit', + 'view', 'info', 'show', 'search', 'publish', 'login', 'logout', 'whoami', 'config', 'root', 'prefix', 'bin', 'cache', 'link', 'unlink', 'pack', 'init', 'create', 'dlx', 'exec', 'x', + 'help', 'version', 'prune', 'dedupe', 'rebuild', 'doctor', 'completion', 'fund', 'help-search', 'pkg', 'set', 'get', 'unplug', 'patch', 'workspaces', 'workspace']); +const PACKAGE_READ = new Set(['ls', 'list', 'll', 'la', 'why', 'explain', 'root', 'prefix', 'bin', 'help', 'version', 'whoami']); +const PACKAGE_NETWORK = new Set(['view', 'info', 'show', 'search', 'outdated', 'audit', 'publish', 'login', 'logout', 'dlx', 'x', 'fund']); +const PACKAGE_RUN = new Set(['run', 'run-script', 'test', 't', 'tst', 'start', 'stop', 'restart']); +const STOPWORDS = new Set(['the', 'a', 'an', 'is', 'are', 'was', 'were', 'to', 'of', 'and', 'or', 'in', 'for', 'with', 'this', 'that', 'it', 'you', 'we', 'i', 'my', 'your', 'on', 'be', + 'can', 'will', 'please', 'not', 'have', 'has', 'do', 'does', 'how', 'what', 'why', 'when', 'but', 'if', 'so', 'as', 'at', 'by', 'from', 'me', 'our', 'they', 'there', 'would', 'should']); + +/** + * Deterministic and cautious: ordinary sentences are labelled text, never + * "command". Anything shell-shaped (operators, paths, flags, assignments, a + * known executable first) is left to the command classifier. Unsure → false. + */ +export function looksLikeProse(line: string): boolean { + const text = line.trim(); + if (!text || /^[./~$!#-]/u.test(text)) return false; + if (/[|<>`$]|&&|\|\|/u.test(text)) return false; + const parts = text.split(/\s+/u); + const first = parts[0]!; + if (KNOWN_COMMANDS.has(first) || /^\w+=/u.test(first) || first.includes('/')) return false; + if (parts.length < 3) return false; + const prose = /^[A-Z]/u.test(first) || parts.slice(0, -1).some(word => /[,:;!?]$/u.test(word)) || /[.!?]$/u.test(text) && parts.length >= 4; + if (prose) return !parts.some(word => /^-{1,2}[A-Za-z]/u.test(word) || /^\w+=/u.test(word)); + const stop = parts.filter(word => STOPWORDS.has(word.toLowerCase().replace(/[^a-z]/gu, ''))).length; + return parts.length >= 6 && stop >= 2 && !parts.some(word => /^-{1,2}[A-Za-z]/u.test(word) || word.includes('/') || /^\w+=/u.test(word)); +} + +const GIT_READ = /^(?:status|log|diff|show|blame|rev-parse|rev-list|describe|ls-files|ls-tree|cat-file|shortlog|grep|name-rev|merge-base|diff-tree|show-ref|for-each-ref|count-objects|check-ignore|var|whatchanged|help|version|reflog|ls-remote)$/u; + +function gitKinds(sub: string, rest: string[], flags: string): PasteKind[] { + const args = rest.filter(word => !word.startsWith('-')); + if (sub === 'ls-remote') return ['network']; + if (GIT_READ.test(sub)) return ['read']; + if (/^(?:--version|-v|--help)$/u.test(sub)) return ['read']; + if (sub === 'fetch') return ['network']; + if (sub === 'branch') { + if (/(?:^|\s)(?:-D|-d|--delete)\b/u.test(flags)) return ['destructive']; + if (!args.slice(1).length && !/(?:^|\s)(?:-m|-M|-c|-C|--move|--copy|-u|--set-upstream-to|--unset-upstream|--edit-description)\b/u.test(flags)) return ['read']; + return ['modifies']; + } + if (sub === 'remote') return !args.slice(1).length || /^(?:show|get-url)$/u.test(args[1] ?? '') ? ['read'] : ['modifies']; + if (sub === 'tag') return !args.slice(1).length || /(?:^|\s)(?:-l|--list|-n)\b/u.test(flags) ? ['read'] : /(?:^|\s)(?:-d|--delete)\b/u.test(flags) ? ['destructive'] : ['modifies']; + if (sub === 'stash') return /^(?:list|show)$/u.test(args[1] ?? '') ? ['read'] : /^(?:drop|clear)$/u.test(args[1] ?? '') ? ['destructive'] : ['modifies']; + if (sub === 'config') return /(?:^|\s)(?:--get|--get-all|--list|-l|--show-origin)\b/u.test(flags) || args[1] === 'get' || args[1] === 'list' ? ['read'] : ['modifies']; + if (sub === 'worktree') return args[1] === 'list' ? ['read'] : ['modifies']; + if ((sub === 'reset' && /--hard/u.test(flags)) || (sub === 'clean' && /-\w*f/u.test(flags)) || (sub === 'push' && /(?:-f\b|--force)/u.test(flags)) + || (sub === 'checkout' && rest.includes('--')) || sub === 'filter-branch' || (sub === 'restore' && /--worktree|\.$/u.test(rest.join(' ')))) return ['destructive']; + return ['modifies']; +} + +function packageKinds(command: string, sub: string, rest: string[]): PasteKind[] { + if (/^(?:install|i|add|ci|update|upgrade|up|remove|uninstall|rm)$/u.test(sub)) return ['install']; + if (PACKAGE_READ.has(sub)) return ['read']; + if (PACKAGE_NETWORK.has(sub)) return ['network']; + if (sub === 'config') return /^(?:get|list|ls)$/u.test(rest.filter(word => !word.startsWith('-'))[1] ?? '') ? ['read'] : ['modifies']; + if (PACKAGE_RUN.has(sub)) return ['project']; + if (sub === '' ) return command === 'yarn' ? ['install'] : ['unknown']; + // pnpm / yarn / bun run a package script for any other word (`yarn build`); npm needs `run`. + if (command !== 'npm' && !PACKAGE_BUILTINS.has(sub)) return ['project']; + if (sub === 'exec' || sub === 'pkg' || sub === 'init' || sub === 'create') return ['modifies']; + return ['unknown']; +} + +export function classifyCommand(text: string, pipedFrom: boolean): PasteKind[] { + if (looksLikeProse(text)) return ['text']; + const all = words(text); + // Leading env assignments and sudo-like prefixes. + const kinds = new Set(); + let index = 0; + while (/^\w+=/u.test(all[index] ?? '')) index += 1; + while (/^(?:sudo|doas|su|pkexec)$/u.test(all[index] ?? '')) { kinds.add('privilege'); index += 1; while ((all[index] ?? '').startsWith('-')) index += 1; } + const [command = '', ...rest] = all.slice(index); + // git's global `-C ` / `-c k=v` options come before the subcommand. + if (command === 'git') for (let at = 0; at < rest.length;) { if (rest[at] === '-C' || rest[at] === '-c') rest.splice(at, 2); else at += 1; } + const sub = rest.find(word => !word.startsWith('-')) ?? ''; + const flags = rest.filter(word => word.startsWith('-')).join(' '); + if (pipedFrom && INTERPRETERS.test(command)) kinds.add('pipeline'); + if (/<\(\s*(?:curl|wget)\b/u.test(text) || /\$\(\s*(?:curl|wget)\b/u.test(text)) kinds.add('pipeline'); + if (NETWORK_COMMANDS.has(command)) kinds.add('network'); + if (/^(?:cd|pushd|popd|z|zi)$/u.test(command)) kinds.add('navigation'); + else if (command === 'nmsh') { + const first = rest[0] ?? ''; + if (NMSH_READ.has(first) && rest.length === 1) kinds.add('read'); + else if (first === 'uninstall') kinds.add('destructive'); + else if (first === 'config') kinds.add(rest[1] === 'import' || rest[1] === 'export' ? 'modifies' : 'unknown'); + } + else if (rest.length === 1 && VERSION_QUERY.has(rest[0]!) && KNOWN_COMMANDS.has(command) && !/^(?:sudo|rm|mv|cp|dd|kill|killall|pkill|shred|su|doas)$/u.test(command)) kinds.add('read'); + else if (READ_COMMANDS.has(command)) kinds.add('read'); + if (command === 'find' && /-(?:delete|exec|execdir|ok)\b/u.test(text)) kinds.add('destructive'); + else if (command === 'find') kinds.add('read'); + if (command === 'git' && !kinds.size) for (const kind of gitKinds(sub, rest, flags)) kinds.add(kind); + if (/^(?:npm|pnpm|yarn|bun)$/u.test(command) && !kinds.size) for (const kind of packageKinds(command, sub, rest)) kinds.add(kind); + if (/^(?:npx|pnpx|bunx)$/u.test(command)) { kinds.add('network'); kinds.add('project'); } + if (/^(?:make|cmake|ninja|gradle|mvn|rake|xcodebuild|pytest|jest|vitest|mocha)$/u.test(command) && !kinds.size) kinds.add('project'); + if (/^(?:cargo|go|deno|swift|dotnet|composer|poetry|uv)$/u.test(command) && /^(?:build|test|run|check|clippy|vet|bench|lint|fmt|doc|compile)$/u.test(sub) && !kinds.size) kinds.add('project'); + if (/^(?:\.\/|\.\.\/)/u.test(command) || (/^(?:node|tsx|python3?|ruby|bash|sh|zsh|perl|php)$/u.test(command) && sub && !kinds.has('pipeline'))) kinds.add('project'); + if (/^(?:brew|apt|apt-get|dnf|yum|pacman|zypper|apk|port|snap|flatpak)$/u.test(command) && /^(?:install|reinstall|upgrade|remove|uninstall|purge|erase|-S|-R)$/u.test(sub)) kinds.add('install'); + if (/^(?:pip|pip3|pipx|gem|cargo|go)$/u.test(command) && /^(?:install|uninstall)$/u.test(sub)) kinds.add('install'); + if (command === 'rm' || command === 'rmdir' || command === 'shred' || command === 'mkfs' || /^mkfs\./u.test(command) || command === 'dd' || command === 'truncate' + || ((command === 'chmod' || command === 'chown') && /-\w*R/u.test(flags)) || /\bkill(?:all)?\b/u.test(command) || /:\(\)\s*\{/u.test(text)) kinds.add('destructive'); + if (/^(?:mv|cp|mkdir|touch|ln|tee|sed|perl|install|chmod|chown|patch|unzip|tar)$/u.test(command) && !kinds.has('destructive')) { + if (command !== 'sed' || /-i\b|--in-place/u.test(flags)) { kinds.delete('read'); kinds.add('modifies'); } + } + if (/(?:^|[^>&])>>?\s*[^\s&|]/u.test(text.replace(/"(?:\\.|[^"])*"|'[^']*'/gu, '""')) && !/>\s*\/dev\/null/u.test(text)) kinds.add('modifies'); + if (!kinds.size) kinds.add('unknown'); + return [...kinds]; +} + +export function analyzePaste(text: string): PasteAnalysis { + const normalized = text.replace(/\r\n?/gu, '\n'); + const rows = normalized.split('\n').filter(line => line.trim()); + // Ordinary sentences are text as a whole, before any shell splitting (apostrophes and semicolons are prose, not syntax). + const prose = rows.length > 0 && rows.every(line => looksLikeProse(line)); + const split = prose ? rows.map(line => ({text: line.trim(), pipedFrom: false, pipesTo: false})) : splitCommands(text); + const commands = split.length ? split.map(item => ({text: item.text, kinds: classifyCommand(item.text, item.pipedFrom)})) + : rows.map(line => ({text: line.trim(), kinds: ['text'] as PasteKind[]})); + const outside = text.replace(/"(?:\\.|[^"])*"|'[^']*'/gu, '""'); + return {commands, lines: rows.length, characters: text.length, operators: prose ? 0 : (outside.match(/&&|\|\||;|\||\$\(|`/gu) ?? []).length}; +} + + +const ATTENTION: ReadonlySet = new Set(['destructive', 'privilege', 'pipeline', 'install']); + +/** Smart: preview only what is worth a second look. Ordinary single commands insert at once. */ +export function needsPreview(analysis: PasteAnalysis, mode: PastePreviewMode): boolean { + if (mode === 'off') return false; + if (mode === 'always') return analysis.characters > 0; + return analysis.lines > 1 || analysis.commands.length > 1 || analysis.characters > 2000 || analysis.operators >= 3 + || analysis.commands.some(command => command.kinds.some(kind => ATTENTION.has(kind) || kind === 'modifies' && command.kinds.includes('network'))); +} + +export const KIND_LABELS: Record = {text: 'plain text', unknown: 'unrecognized shell input', navigation: 'navigation', read: 'read-only', project: 'runs project script', + network: 'network', install: 'installs packages', modifies: 'modifies files', destructive: 'destructive', privilege: 'runs as root', pipeline: 'runs downloaded code'}; + +/** The most important classification of a command, for its one-word label. */ +export function primaryKind(kinds: readonly PasteKind[]): PasteKind { + const order: PasteKind[] = ['pipeline', 'destructive', 'privilege', 'install', 'modifies', 'network', 'project', 'navigation', 'read', 'unknown', 'text']; + return order.find(kind => kinds.includes(kind)) ?? 'unknown'; +} + +/** The compact preview's header: what the paste is, without claiming more than was recognised. */ +export function pasteHeader(analysis: PasteAnalysis): string { + const count = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`; + const text = analysis.commands.filter(command => primaryKind(command.kinds) === 'text').length; + const shell = analysis.commands.length - text; + const size = analysis.characters > 2000 ? ` · ${analysis.characters} characters` : ''; + if (!shell) return `pasted · text · ${count(analysis.lines, 'line')}${size}`; + const lead = analysis.commands.length === analysis.lines ? count(shell, 'shell line') : count(shell, 'command') + (analysis.lines > 1 ? ` · ${count(analysis.lines, 'line')}` : ''); + return `pasted · ${lead}${text ? ` · ${count(text, 'text line')}` : ''}${size}`; +} + +/** Shown when the paste is not clearly an ordinary command: what the shell will receive does not change. */ +export const PASTE_EXACT_NOTE = 'If inserted and submitted, your shell will still receive this text exactly.'; diff --git a/src/input/syntaxTheme.ts b/src/input/syntaxTheme.ts index ba340205..59bad7a3 100644 --- a/src/input/syntaxTheme.ts +++ b/src/input/syntaxTheme.ts @@ -4,7 +4,7 @@ import { type PromptConfiguration, type SyntaxAppearance, } from '../prompt/configuration.js'; -import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js'; +import {NATIVE_PROMPT_THEMES, themeContext} from '../prompt/prompt.js'; import {foreground, UI_COLORS, type RgbColor} from '../ui/palette.js'; import type {Token, TokenType} from './Highlighter.js'; @@ -147,7 +147,9 @@ const sgrCache = new Map(); /** Cached per setting combination, so rendering never recomputes colors per keystroke. */ export function syntaxSgr(syntax: SyntaxAppearance, promptPalette: NativePaletteId): SyntaxSgr { - const key = `${syntax.highlighting}:${syntax.colors}:${syntax.theme}:${promptPalette}`; + // Styles read the live chrome (text tiers, accent, status), so a chrome change must not reuse stale escapes. + const context = themeContext(); + const key = `${syntax.highlighting}:${syntax.colors}:${syntax.theme}:${promptPalette}:${context.accent}:${JSON.stringify(context.custom?.prompt)}:${JSON.stringify(UI_COLORS)}`; let cached = sgrCache.get(key); if (!cached) { const styles = resolveSyntaxStyles(syntax, promptPalette); diff --git a/src/keepAwake/KeepAwakePanel.ts b/src/keepAwake/KeepAwakePanel.ts new file mode 100644 index 00000000..1e2d678b --- /dev/null +++ b/src/keepAwake/KeepAwakePanel.ts @@ -0,0 +1,171 @@ +import type {Key} from '../terminal/keys.js'; +import {createConfirm, handleConfirmKey, renderConfirm, type ConfirmState} from '../ui/formControls.js'; +import {framePanel} from '../ui/PanelShell.js'; +import {renderControls} from '../ui/controls.js'; +import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; +import {truncateAnsi} from '../util/text.js'; +import {colorLevel} from '../presentation/capabilities.js'; +import {AWAKE_DISPLAY_LABELS, AWAKE_DISPLAYS, AWAKE_IDLE_AFTER, AWAKE_PLACEMENT_LABELS, AWAKE_PLACEMENTS, AWAKE_SAVER_POSITION_LABELS, AWAKE_SAVER_POSITIONS, type KeepAwakePresentation} from './presentation.js'; +import {formatDuration, KEEP_AWAKE_MODES, MODE_DESCRIPTIONS, MODE_LABELS, unsupportedReason, type KeepAwakeController, type KeepAwakeMode, type StartResult} from './keepAwake.js'; + +/** The one Keep Awake surface behind /caffeinate, /awake and /zoomies. */ +export interface KeepAwakePanel { + /** Row focus: the four modes, then Duration and the presentation rows (PANEL_SETTINGS). */ + selected: number; + /** Index into PANEL_DURATIONS used when a mode starts from the panel. */ + duration?: number; + /** The slash command that opened the panel (/caffeinate, /awake or /zoomies); results are recorded under it. */ + command?: string; + message?: string; + /** A different mode is running: replacing it waits for this confirmation (default No). */ + confirm?: {state: ConfirmState; mode: KeepAwakeMode; timeoutSeconds?: number; from: KeepAwakeMode}; +} + +export function createKeepAwakePanel(controller: KeepAwakeController, message?: string): KeepAwakePanel { + const status = controller.status(); + const index = status.state === 'running' ? KEEP_AWAKE_MODES.indexOf(status.record.mode) : 0; + return {selected: Math.max(0, index), ...(message ?? (status.state === 'off' ? status.note : undefined) ? {message: message ?? (status.state === 'off' ? status.note : undefined)} : {})}; +} + +/** Panel durations; slash commands accept any strict duration (45s, 30m, 2h). */ +export const PANEL_DURATIONS: ReadonlyArray<{label: string; seconds?: number}> = [ + {label: 'Until stopped'}, {label: '30 min', seconds: 1800}, {label: '1 hour', seconds: 3600}, {label: '2 hours', seconds: 7200}, {label: '4 hours', seconds: 14_400}, +]; + +/** Rows after the modes, in panel order. Each cycles its value with ←→ or Enter. */ +export const PANEL_SETTINGS = ['duration', 'placement', 'display', 'idleReminder', 'idleAfterSeconds', 'screensaver', 'screensaverPosition'] as const; +type PanelSetting = typeof PANEL_SETTINGS[number]; +const ROW_COUNT = KEEP_AWAKE_MODES.length + PANEL_SETTINGS.length; + +const cycle = (list: readonly T[], value: T, step: number): T => list[(list.indexOf(value) + step + list.length) % list.length]!; + +/** The next presentation settings for one row change; undefined for Duration (panel-local). */ +function changeSetting(row: PanelSetting, settings: KeepAwakePresentation, step: number): KeepAwakePresentation | undefined { + switch (row) { + case 'duration': return undefined; + case 'placement': return {...settings, placement: cycle(AWAKE_PLACEMENTS, settings.placement, step)}; + case 'display': return {...settings, display: cycle(AWAKE_DISPLAYS, settings.display, step)}; + case 'idleReminder': return {...settings, idleReminder: !settings.idleReminder}; + case 'idleAfterSeconds': return {...settings, idleAfterSeconds: cycle(AWAKE_IDLE_AFTER as readonly number[], settings.idleAfterSeconds, step)}; + case 'screensaver': return {...settings, screensaver: !settings.screensaver}; + case 'screensaverPosition': return {...settings, screensaverPosition: cycle(AWAKE_SAVER_POSITIONS, settings.screensaverPosition, step)}; + } +} + +const seconds = (value: number) => value < 60 ? `${value} sec` : `${value / 60} min`; + +/** One factual sentence for a start attempt. */ +export function describeStart(result: StartResult): string { + switch (result.kind) { + case 'started': return `Keep Awake on · ${MODE_LABELS[result.record.mode]}${result.record.timeoutSeconds ? ` for ${formatDuration(result.record.timeoutSeconds)}` : ''}${result.replaced ? ` (replaced ${MODE_LABELS[result.replaced]})` : ''}. It keeps running after this window closes; /caffeinate stop ends it.`; + case 'already': return `Already running · ${MODE_LABELS[result.record.mode]}.`; + case 'needsConfirm': return `${MODE_LABELS[result.from]} is running. Change Keep Awake mode to ${MODE_LABELS[result.to]}?`; + case 'unsupported': return result.reason; + case 'failed': return result.reason; + } +} + +/** Start from the panel or a slash command; a different running mode asks first. */ +export function requestStart(panel: KeepAwakePanel, controller: KeepAwakeController, mode: KeepAwakeMode, timeoutSeconds?: number): StartResult { + const result = controller.start(mode, timeoutSeconds); + if (result.kind === 'needsConfirm') panel.confirm = {state: createConfirm(), mode, from: result.from, ...(timeoutSeconds ? {timeoutSeconds} : {})}; + panel.message = describeStart(result); + return result; +} + +/** + * What a key did. A finished action (started, already running, stopped) hands + * the composer straight back with its one-line result: the assertion runs in + * its own NMSh-owned process, so nothing here waits on it like a shell command. + * Failures and the change confirmation stay in the panel. + */ +export type KeepAwakeKeyResult = 'close' | {done: string} | {settings: KeepAwakePresentation} | undefined; + +const finished = (result: StartResult) => result.kind === 'started' || result.kind === 'already'; + +export function keepAwakeKey(panel: KeepAwakePanel, controller: KeepAwakeController, key: Key, settings?: KeepAwakePresentation): KeepAwakeKeyResult { + if (panel.confirm) { + const decision = handleConfirmKey(key, panel.confirm.state); + if (decision === 'confirm') { + const {mode, timeoutSeconds} = panel.confirm; + panel.confirm = undefined; + const result = controller.start(mode, timeoutSeconds, true); + panel.message = describeStart(result); + if (finished(result)) return {done: panel.message}; + } else if (decision === 'cancel') { panel.message = `Kept ${MODE_LABELS[panel.confirm.from]}. Nothing was changed.`; panel.confirm = undefined; } + return undefined; + } + if (key.kind === 'escape' || key.kind === 'interrupt') return 'close'; + if (key.kind === 'up' || key.kind === 'down') { panel.selected = (panel.selected + (key.kind === 'up' ? ROW_COUNT - 1 : 1)) % ROW_COUNT; return undefined; } + const row = PANEL_SETTINGS[panel.selected - KEEP_AWAKE_MODES.length]; + if (row && (key.kind === 'left' || key.kind === 'right' || key.kind === 'enter')) { + const step = key.kind === 'left' ? -1 : 1; + if (row === 'duration') { panel.duration = ((panel.duration ?? 0) + step + PANEL_DURATIONS.length) % PANEL_DURATIONS.length; return undefined; } + const next = settings && changeSetting(row, settings, step); + return next ? {settings: next} : undefined; + } + if (key.kind === 'enter' && !row) { + const result = requestStart(panel, controller, KEEP_AWAKE_MODES[panel.selected]!, PANEL_DURATIONS[panel.duration ?? 0]!.seconds); + if (finished(result)) return {done: panel.message!}; + } + else if (key.kind === 'text' && key.value.toLowerCase() === 's') return {done: controller.stop()}; + return undefined; +} + +export function statusLines(controller: KeepAwakeController, now = Date.now()): string[] { + const status = controller.status(); + const backend = controller.backend?.label ?? 'none'; + if (status.state === 'off') return ['Keep Awake', 'Off', `Backend ${backend}`, ...(status.note ? [status.note] : [])]; + const record = status.record; + const since = new Date(record.startedAt); + const elapsed = Math.max(0, Math.round((now - record.startedAt) / 1000)); + return ['Keep Awake', 'Running', `Mode ${MODE_LABELS[record.mode]}`, `Backend ${backend}`, + `Since ${String(since.getHours()).padStart(2, '0')}:${String(since.getMinutes()).padStart(2, '0')}`, `Duration ${formatDuration(Math.max(1, elapsed))}`, + ...(record.timeoutSeconds ? [`Ends after ${formatDuration(record.timeoutSeconds)}`] : []), `PID ${record.pid}`]; +} + +export function renderKeepAwakePanel(panel: KeepAwakePanel, controller: KeepAwakeController, columns: number, height: number, settings?: KeepAwakePresentation): string[] { + const primary = foreground(UI_COLORS.primary), subtle = foreground(UI_COLORS.subtle), accent = foreground(UI_COLORS.accent), reset = '\u001b[0m'; + const status = controller.status(); + const rows = [` ${primary}Keep Awake${reset} ${subtle}/caffeinate · /awake · /zoomies${reset}`, '']; + if (!controller.backend) rows.push(` ${subtle}${unsupportedReason()}${reset}`); + for (const [index, mode] of KEEP_AWAKE_MODES.entries()) { + const supported = controller.supports(mode); + const pointer = index === panel.selected ? `${accent}›${reset}` : ' '; + const running = status.state === 'running' && status.record.mode === mode ? ` ${accent}● running${reset}` : ''; + rows.push(` ${pointer} ${focusForeground(index === panel.selected)}${MODE_LABELS[mode].padEnd(8)}${reset} ${subtle}${supported ? MODE_DESCRIPTIONS[mode] : `Unavailable on ${controller.backend ? `the ${controller.backend.label}` : 'this system'}`}${reset}${running}`); + } + if (settings) { + const value = (row: PanelSetting): string => { + switch (row) { + case 'duration': return PANEL_DURATIONS[panel.duration ?? 0]!.label; + case 'placement': return AWAKE_PLACEMENT_LABELS[settings.placement]; + case 'display': return AWAKE_DISPLAY_LABELS[settings.display]; + case 'idleReminder': return settings.idleReminder ? 'On' : 'Off'; + case 'idleAfterSeconds': return seconds(settings.idleAfterSeconds); + case 'screensaver': return settings.screensaver ? 'On' : 'Off'; + case 'screensaverPosition': return AWAKE_SAVER_POSITION_LABELS[settings.screensaverPosition]; + } + }; + const LABELS: Record = {duration: 'Duration', placement: 'Placement', display: 'Display', idleReminder: 'Idle reminder', + idleAfterSeconds: 'Idle after', screensaver: 'Show status', screensaverPosition: 'Position'}; + const line = (row: PanelSetting) => { + const focused = panel.selected === KEEP_AWAKE_MODES.length + PANEL_SETTINGS.indexOf(row); + return ` ${focused ? `${accent}›${reset}` : ' '} ${focusForeground(focused)}${LABELS[row].padEnd(15)}${reset} ${focused ? accent : subtle}${focused ? `‹ ${value(row)} ›` : value(row)}${reset}`; + }; + rows.push(line('duration'), '', ` ${subtle}Presentation${reset}`, line('placement'), line('display'), line('idleReminder'), line('idleAfterSeconds'), + '', ` ${subtle}Screensaver${reset}`, line('screensaver'), line('screensaverPosition'), + '', ` ${subtle}Status Strip${reset}`, ` ${subtle}Shown automatically while active (when the Status Strip is on)${reset}`); + } + rows.push('', ...statusLines(controller).slice(1).map(line => ` ${subtle}${line === 'Off' || line === 'Running' ? `Current ${line}` : line}${reset}`)); + for (const note of controller.backend?.notes ?? []) rows.push(` ${subtle}${note}${reset}`); + rows.push(` ${subtle}Nothing in your power settings changes; Stop (or the timeout) ends it.${reset}`); + if (panel.confirm) { + rows.push('', ` ${primary}Change Keep Awake mode? ${MODE_LABELS[panel.confirm.from]} → ${MODE_LABELS[panel.confirm.mode]}${reset}`, + ` ${renderConfirm(panel.confirm.state, {focused: true, color: colorLevel() !== 'none'})}`); + } else if (panel.message) rows.push('', ` ${panel.message}`); + rows.push('', renderControls(panel.confirm ? [['←→', 'choose'], ['Enter', 'confirm'], ['Esc', 'cancel']] + : panel.selected >= KEEP_AWAKE_MODES.length ? [['↑↓', 'row'], ['←→', 'change'], ['S', 'stop'], ['Esc', 'close']] + : [['↑↓', 'row'], ['Enter', 'keep awake'], ['S', 'stop'], ['Esc', 'close']])); + return framePanel(rows.map(row => truncateAnsi(row, columns)), columns).slice(0, Math.max(1, height)); +} diff --git a/src/keepAwake/keepAwake.ts b/src/keepAwake/keepAwake.ts new file mode 100644 index 00000000..f68a70f4 --- /dev/null +++ b/src/keepAwake/keepAwake.ts @@ -0,0 +1,324 @@ +import {spawn, spawnSync} from 'node:child_process'; +import {randomBytes} from 'node:crypto'; +import {existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync} from 'node:fs'; +import {dirname, join} from 'node:path'; +import {nmshConfigDirectory} from '../configuration/paths.js'; + +/** + * Keep Awake (/caffeinate, /awake, /zoomies): one controller over the + * operating system's own inhibition mechanism. + * + * macOS Apple /usr/bin/caffeinate (fixed path, fixed flags) + * Linux systemd-inhibit wrapping an NMSh-owned wait helper (idle/sleep only) + * Windows Kernel32 SetThreadExecutionState from a fixed PowerShell helper + * + * Modes are user intent, not flags. Every value reaching a child is an enum + * or a validated integer; nothing from input becomes program text. The + * assertion lives in a detached process that outlives the NMSh window and + * ends on Stop or its timeout. NMSh changes no power settings, desktop + * preferences or rc files, and never kills a process it cannot prove it owns. + */ + +export const KEEP_AWAKE_MODES = ['idle', 'display', 'system', 'all'] as const; +export type KeepAwakeMode = typeof KEEP_AWAKE_MODES[number]; +export const MODE_LABELS: Record = {idle: 'Idle', display: 'Display', system: 'System', all: 'All'}; +export const MODE_DESCRIPTIONS: Record = { + idle: 'Prevent automatic idle sleep', display: 'Keep the display and the machine awake', + system: 'Prevent automatic system sleep', all: 'All normal wake assertions available here', +}; + +export interface KeepAwakeCapabilities {idle: boolean; display: boolean; system: boolean} + +export interface LaunchPlan {command: string; args: string[]; env?: NodeJS.ProcessEnv} + +export interface KeepAwakeBackend { + id: 'macos-caffeinate' | 'linux-systemd-inhibit' | 'windows-execution-state' | 'inert'; + label: string; + capabilities: KeepAwakeCapabilities; + /** Factual notes shown in the panel (e.g. Apple's -s needs AC power). */ + notes: string[]; + /** undefined when this backend cannot honour the mode. */ + plan(mode: KeepAwakeMode, token: string, timeoutSeconds?: number): LaunchPlan | undefined; +} + +export const MAX_TIMEOUT_SECONDS = 7 * 24 * 3600; + +/** Strict durations only: 45s, 30m, 2h (1 second to 7 days). */ +export function parseDuration(text: string | undefined): number | undefined | 'invalid' { + if (text === undefined || text === '') return undefined; + const match = /^([1-9]\d{0,5})([smh])$/u.exec(text); + if (!match) return 'invalid'; + const seconds = Number(match[1]) * (match[2] === 'h' ? 3600 : match[2] === 'm' ? 60 : 1); + return seconds <= MAX_TIMEOUT_SECONDS ? seconds : 'invalid'; +} + +export function formatDuration(seconds: number): string { + const h = Math.floor(seconds / 3600), m = Math.floor((seconds % 3600) / 60), s = seconds % 60; + return h ? `${h}h${m ? ` ${m}m` : ''}` : m ? `${m}m` : `${s}s`; +} + +// ---- macOS ---------------------------------------------------------------------------- + +export const CAFFEINATE = '/usr/bin/caffeinate'; +const CAFFEINATE_FLAGS: Record = {idle: ['-i'], display: ['-d', '-i'], system: ['-s'], all: ['-d', '-i', '-s']}; + +export function macBackend(): KeepAwakeBackend { + return { + id: 'macos-caffeinate', label: 'Apple caffeinate', capabilities: {idle: true, display: true, system: true}, + notes: ['System (-s) holds only while the Mac is on AC power.'], + plan: (mode, _token, timeout) => ({command: CAFFEINATE, args: [...CAFFEINATE_FLAGS[mode], ...(timeout ? ['-t', String(timeout)] : [])]}), + }; +} + +// ---- Linux ---------------------------------------------------------------------------- + +export const SYSTEMD_INHIBIT_PATHS = ['/usr/bin/systemd-inhibit', '/bin/systemd-inhibit'] as const; +/** Normal inhibitors only: never shutdown, lid switch, power or suspend keys. */ +const INHIBIT_WHAT: Partial> = {idle: 'idle', system: 'sleep', all: 'idle:sleep'}; + +/** The NMSh-owned process the inhibitor wraps: it only waits (bounded), so the lock ends with it. Fixed code; token and seconds are argv. */ +export const WAIT_HELPER = 'const s=Number(process.argv[2]);const t=setTimeout(()=>process.exit(0),s>0?s*1000:2147483647);process.on("SIGTERM",()=>{clearTimeout(t);process.exit(0)});setInterval(()=>{},1<<30)'; + +export function linuxBackend(inhibit: string, node: string = process.execPath): KeepAwakeBackend { + return { + id: 'linux-systemd-inhibit', label: 'systemd inhibitor', capabilities: {idle: true, display: false, system: true}, + notes: ['Display: not supported by the systemd inhibitor (it is not a display API); use Idle or System.', 'Lid close and power keys keep their normal behavior.'], + plan: (mode, token, timeout) => { + const what = INHIBIT_WHAT[mode]; + if (!what) return undefined; + return {command: inhibit, args: [`--what=${what}`, '--mode=block', '--who=notMyShell', '--why=Keep-awake requested by NMSh', + node, '-e', WAIT_HELPER, '--', String(timeout ?? 0), `nmsh-keep-awake=${token}`]}; + }, + }; +} + +// ---- Windows -------------------------------------------------------------------------- + +export const ES_CONTINUOUS = 0x80000000; +export const ES_SYSTEM_REQUIRED = 0x00000001; +export const ES_DISPLAY_REQUIRED = 0x00000002; +/** Windows has no separate idle assertion: Idle and System both mean SYSTEM_REQUIRED. Away mode is never used. */ +export function executionStateFlags(mode: KeepAwakeMode): number { + return (ES_CONTINUOUS | ES_SYSTEM_REQUIRED | (mode === 'display' || mode === 'all' ? ES_DISPLAY_REQUIRED : 0)) >>> 0; +} + +/** Fixed first-party script; only numeric constants and the hex token are substituted. */ +export function windowsHelperScript(flags: number, token: string, timeout?: number): string { + if (!Number.isSafeInteger(flags) || !/^[0-9a-f]{32}$/u.test(token) || (timeout !== undefined && (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > MAX_TIMEOUT_SECONDS))) { + throw new Error('Refusing keep-awake helper values.'); + } + return [`# nmsh-keep-awake=${token}`, + 'Add-Type -Namespace NMSh -Name Power -MemberDefinition \'[DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint esFlags);\'', + `if ([NMSh.Power]::SetThreadExecutionState([uint32]${flags}) -eq 0) { exit 2 }`, + `$deadline = ${timeout ? `(Get-Date).AddSeconds(${timeout})` : '[DateTime]::MaxValue'}`, + 'try { while ((Get-Date) -lt $deadline) { Start-Sleep -Seconds 5 } } finally { [void][NMSh.Power]::SetThreadExecutionState([uint32]2147483648) }'].join('\n'); +} + +export function windowsBackend(powershell: string): KeepAwakeBackend { + return { + id: 'windows-execution-state', label: 'Windows execution-state API', capabilities: {idle: true, display: true, system: true}, + notes: ['Idle and System are the same Windows assertion (system required).', 'No power plan setting is changed.'], + plan: (mode, token, timeout) => ({command: powershell, args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-EncodedCommand', + Buffer.from(windowsHelperScript(executionStateFlags(mode), token, timeout), 'utf16le').toString('base64')]}), + }; +} + +// ---- Inert (tests and recorded demos only) ---------------------------------------------- + +/** + * A backend that asserts nothing: the same NMSh-owned, detached, bounded wait + * helper the Linux backend wraps, without the inhibitor. It exists so the real + * slash, controller, ownership and presentation paths can run in tests and + * VHS recordings without keeping a machine awake. Honored only together with + * NMSH_DETERMINISTIC=1; it never appears in ordinary use. + */ +export function inertBackend(node: string = process.execPath): KeepAwakeBackend { + return { + id: 'inert', label: 'inert demo backend (no assertion)', capabilities: {idle: true, display: true, system: true}, + notes: ['Deterministic demo/test mode: nothing is kept awake.'], + plan: (_mode, token, timeout) => ({command: node, args: ['-e', WAIT_HELPER, '--', String(timeout ?? 0), `nmsh-keep-awake=${token}`]}), + }; +} + +/** The backend detection decides; the platform alone does not. */ +export function detectBackend(platform: NodeJS.Platform = process.platform, exists: (path: string) => boolean = existsSync, + env: NodeJS.ProcessEnv = process.env): KeepAwakeBackend | undefined { + if (env.NMSH_DETERMINISTIC === '1' && env.NMSH_KEEP_AWAKE_BACKEND === 'inert') return inertBackend(); + if (platform === 'darwin') return exists(CAFFEINATE) ? macBackend() : undefined; + if (platform === 'linux') { + const inhibit = SYSTEMD_INHIBIT_PATHS.find(exists); + return inhibit ? linuxBackend(inhibit) : undefined; + } + if (platform === 'win32') { + const root = env.SystemRoot ?? env.windir ?? 'C:\\Windows'; + const powershell = `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; + return exists(powershell) ? windowsBackend(powershell) : undefined; + } + return undefined; +} + +export function unsupportedReason(platform: NodeJS.Platform = process.platform): string { + return platform === 'linux' ? 'No supported Linux inhibitor was detected (systemd-inhibit). NMSh does not install one or change power settings.' + : platform === 'darwin' ? '/usr/bin/caffeinate was not found.' : platform === 'win32' ? 'Windows PowerShell was not found for the execution-state helper.' + : 'Keep Awake has no backend on this platform.'; +} + +// ---- Processes and ownership ---------------------------------------------------------- + +export interface ProcessProbe { + start(plan: LaunchPlan): number | undefined; + alive(pid: number): boolean; + /** The process's full command line, or undefined when it cannot be read. */ + commandLine(pid: number): string | undefined; + signal(pid: number, signal: NodeJS.Signals): void; + now(): number; +} + +export const systemProbe: ProcessProbe = { + start(plan) { + const child = spawn(plan.command, plan.args, {detached: true, stdio: 'ignore', env: plan.env ?? process.env, windowsHide: true}); + child.on('error', () => { /* reported by the startup check */ }); + child.unref(); + return child.pid; + }, + alive(pid) { try { process.kill(pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code === 'EPERM'; } }, + commandLine(pid) { + if (process.platform === 'linux') { + try { return readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\u0000').filter(Boolean).join(' '); } catch { return undefined; } + } + if (process.platform === 'win32') { + // Absolute System32 path: never a PATH lookup. + const root = process.env.SystemRoot ?? process.env.windir ?? 'C:\\Windows'; + const result = spawnSync(`${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, ['-NoProfile', '-NonInteractive', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId=${Math.trunc(pid)}").CommandLine`], {encoding: 'utf8', timeout: 5000, windowsHide: true}); + return result.status === 0 ? result.stdout.trim() || undefined : undefined; + } + const result = spawnSync('/bin/ps', ['-ww', '-o', 'command=', '-p', String(Math.trunc(pid))], {encoding: 'utf8', timeout: 3000}); + return result.status === 0 ? result.stdout.trim() || undefined : undefined; + }, + signal(pid, signal) { try { process.kill(pid, signal); } catch { /* already gone */ } }, + now: () => Date.now(), +}; + +export interface KeepAwakeRecord { + version: 1; + token: string; + backend: KeepAwakeBackend['id']; + mode: KeepAwakeMode; + pid: number; + startedAt: number; + timeoutSeconds?: number; + /** The exact launch, for ownership checks. */ + command: string; + args: string[]; +} + +export type KeepAwakeStatus = + | {state: 'off'; note?: string} + | {state: 'running'; record: KeepAwakeRecord}; + +export function keepAwakePath(env: NodeJS.ProcessEnv = process.env): string { + return join(nmshConfigDirectory(env), 'keep-awake.json'); +} + +/** A record's process is ours only if it is alive and its command line is the exact launch (including the token where the backend carries one). */ +function owned(record: KeepAwakeRecord, probe: ProcessProbe): boolean { + if (!Number.isSafeInteger(record.pid) || record.pid <= 1 || !probe.alive(record.pid)) return false; + const line = probe.commandLine(record.pid); + if (!line) return false; + if (record.backend === 'macos-caffeinate') return line === [record.command, ...record.args].join(' '); + // Linux carries the token in argv; Windows carries it inside the encoded script (the last argument). + return record.backend === 'linux-systemd-inhibit' || record.backend === 'inert' ? line.includes(`nmsh-keep-awake=${record.token}`) : line.includes(record.args.at(-1)!); +} + +export type StartResult = + | {kind: 'started'; record: KeepAwakeRecord; replaced?: KeepAwakeMode} + | {kind: 'already'; record: KeepAwakeRecord} + | {kind: 'needsConfirm'; from: KeepAwakeMode; to: KeepAwakeMode} + | {kind: 'unsupported'; reason: string} + | {kind: 'failed'; reason: string; previousStopped?: boolean}; + +export class KeepAwakeController { + constructor(readonly backend: KeepAwakeBackend | undefined, private readonly probe: ProcessProbe = systemProbe, + readonly path: string = keepAwakePath(), private readonly platform: NodeJS.Platform = process.platform) {} + + private load(): KeepAwakeRecord | undefined { + try { + const data = JSON.parse(readFileSync(this.path, 'utf8')) as KeepAwakeRecord; + if (data.version !== 1 || typeof data.token !== 'string' || !KEEP_AWAKE_MODES.includes(data.mode) || !Number.isSafeInteger(data.pid) + || typeof data.command !== 'string' || !Array.isArray(data.args)) return undefined; + return data; + } catch { return undefined; } + } + + private save(record: KeepAwakeRecord): void { + mkdirSync(dirname(this.path), {recursive: true, mode: 0o700}); + const staged = `${this.path}.${process.pid}.tmp`; + writeFileSync(staged, `${JSON.stringify(record, null, 2)}\n`, {mode: 0o600}); + renameSync(staged, this.path); + } + + private clear(): void { rmSync(this.path, {force: true}); } + + /** The stored record without an ownership check: cheap enough to poll for presentation, never used to stop anything. */ + peek(): KeepAwakeRecord | undefined { return this.load(); } + + /** Verified state. Unprovable records are cleared (never killed). */ + status(): KeepAwakeStatus { + const record = this.load(); + if (!record) return {state: 'off'}; + if (owned(record, this.probe)) return {state: 'running', record}; + this.clear(); + const expired = record.timeoutSeconds && this.probe.now() >= record.startedAt + record.timeoutSeconds * 1000; + return {state: 'off', note: expired ? `The ${MODE_LABELS[record.mode]} keep-awake ended after its ${formatDuration(record.timeoutSeconds!)} timeout.` + : 'The earlier keep-awake is no longer running (or could not be verified as NMSh\'s); its record was cleared and nothing was stopped.'}; + } + + supports(mode: KeepAwakeMode): boolean { + if (!this.backend) return false; + const caps = this.backend.capabilities; + return mode === 'idle' ? caps.idle : mode === 'display' ? caps.display : mode === 'system' ? caps.system : caps.idle || caps.system; + } + + /** Starts, or reports already-running / needs-confirm. `confirmed` replaces a different running mode. */ + start(mode: KeepAwakeMode, timeoutSeconds?: number, confirmed = false): StartResult { + if (!this.backend) return {kind: 'unsupported', reason: unsupportedReason(this.platform)}; + if (!this.supports(mode)) return {kind: 'unsupported', reason: `${MODE_LABELS[mode]} is not supported by the ${this.backend.label}. ${this.backend.notes[0] ?? ''}`.trim()}; + const current = this.status(); + if (current.state === 'running') { + if (current.record.mode === mode && current.record.timeoutSeconds === timeoutSeconds) return {kind: 'already', record: current.record}; + if (!confirmed) return {kind: 'needsConfirm', from: current.record.mode, to: mode}; + } + const token = randomBytes(16).toString('hex'); + const plan = this.backend.plan(mode, token, timeoutSeconds); + if (!plan) return {kind: 'unsupported', reason: `${MODE_LABELS[mode]} is not supported by the ${this.backend.label}.`}; + // Start the replacement first, then release the old assertion: no unprotected gap. + const pid = this.probe.start(plan); + const record: KeepAwakeRecord = {version: 1, token, backend: this.backend.id, mode, pid: pid ?? 0, startedAt: this.probe.now(), + ...(timeoutSeconds ? {timeoutSeconds} : {}), command: plan.command, args: plan.args}; + if (!pid || !this.probe.alive(pid)) { + return {kind: 'failed', reason: `${this.backend.label} did not start.${current.state === 'running' ? ` The ${MODE_LABELS[current.record.mode]} keep-awake is still running.` : ''}`}; + } + if (current.state === 'running') this.terminate(current.record); + this.save(record); + return {kind: 'started', record, ...(current.state === 'running' ? {replaced: current.record.mode} : {})}; + } + + private terminate(record: KeepAwakeRecord): void { + this.probe.signal(record.pid, 'SIGTERM'); + const deadline = this.probe.now() + 2000; + const pause = new Int32Array(new SharedArrayBuffer(4)); + // Poll ownership, not bare liveness: an exited child awaiting reaping no longer has our command line. + while (owned(record, this.probe) && this.probe.now() < deadline) Atomics.wait(pause, 0, 0, 50); + if (owned(record, this.probe)) this.probe.signal(record.pid, 'SIGKILL'); + } + + /** Idempotent. Stops only a verified NMSh-owned assertion. */ + stop(): string { + const record = this.load(); + if (!record) return 'Keep Awake is off.'; + if (!owned(record, this.probe)) { this.clear(); return 'Keep Awake is off (the earlier record could not be verified as NMSh\'s, so nothing was stopped).'; } + this.terminate(record); + this.clear(); + return `Keep Awake stopped (${MODE_LABELS[record.mode]}). Normal sleep behavior is back.`; + } +} diff --git a/src/keepAwake/presentation.ts b/src/keepAwake/presentation.ts new file mode 100644 index 00000000..a84d988d --- /dev/null +++ b/src/keepAwake/presentation.ts @@ -0,0 +1,218 @@ +import {semanticIcon} from '../prompt/glyphChoices.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {displayWidth} from '../util/text.js'; +import {formatDuration, MODE_LABELS, type KeepAwakeMode} from './keepAwake.js'; + +/** + * Keep Awake presentation: how an active assertion shows up in NMSh chrome. + * + * Keep Awake is NMSh-owned composer chrome, never prompt or provider output. + * The prompt owns its structural space and this accessory moves around it: + * it takes a free composer edge, else a row adjacent to the composer, and + * never truncates or rewrites prompt, right prompt or editor content. When + * Keep Awake is off nothing here renders anything. + */ + +export const AWAKE_PLACEMENTS = ['edge', 'above', 'input'] as const; +export type AwakePlacement = typeof AWAKE_PLACEMENTS[number]; +export const AWAKE_PLACEMENT_LABELS: Record = {edge: 'Composer edge', above: 'Above composer', input: 'Input row'}; + +export const AWAKE_DISPLAYS = ['text', 'icon', 'iconText'] as const; +export type AwakeDisplay = typeof AWAKE_DISPLAYS[number]; +export const AWAKE_DISPLAY_LABELS: Record = {text: 'Text', icon: 'Icon', iconText: 'Icon + text'}; + +export const AWAKE_SAVER_POSITIONS = ['topLeft', 'topCenter', 'topRight', 'bottomLeft', 'bottomCenter', 'bottomRight'] as const; +export type AwakeSaverPosition = typeof AWAKE_SAVER_POSITIONS[number]; +export const AWAKE_SAVER_POSITION_LABELS: Record = { + topLeft: 'Top left', topCenter: 'Top center', topRight: 'Top right', bottomLeft: 'Bottom left', bottomCenter: 'Bottom center', bottomRight: 'Bottom right', +}; + +export const AWAKE_IDLE_AFTER = [15, 30, 60, 120, 300] as const; + +export interface KeepAwakePresentation { + placement: AwakePlacement; + display: AwakeDisplay; + /** Expand to duration + the muted stop hint after no NMSh input for `idleAfterSeconds`. */ + idleReminder: boolean; + idleAfterSeconds: number; + screensaver: boolean; + screensaverPosition: AwakeSaverPosition; +} + +export const DEFAULT_KEEP_AWAKE_PRESENTATION: Readonly = { + placement: 'edge', display: 'text', idleReminder: true, idleAfterSeconds: 30, screensaver: true, screensaverPosition: 'bottomLeft', +}; + +export function normalizeKeepAwakePresentation(value: unknown): KeepAwakePresentation { + const v = value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; + const d = DEFAULT_KEEP_AWAKE_PRESENTATION; + const pick = (list: readonly T[], key: string, fallback: T): T => list.includes(v[key] as T) ? v[key] as T : fallback; + return { + placement: pick(AWAKE_PLACEMENTS, 'placement', d.placement), + display: pick(AWAKE_DISPLAYS, 'display', d.display), + idleReminder: typeof v.idleReminder === 'boolean' ? v.idleReminder : d.idleReminder, + idleAfterSeconds: AWAKE_IDLE_AFTER.includes(v.idleAfterSeconds as typeof AWAKE_IDLE_AFTER[number]) ? v.idleAfterSeconds as number : d.idleAfterSeconds, + screensaver: typeof v.screensaver === 'boolean' ? v.screensaver : d.screensaver, + screensaverPosition: pick(AWAKE_SAVER_POSITIONS, 'screensaverPosition', d.screensaverPosition), + }; +} + +/** The facts presentation needs: the same record the controller verified. */ +export interface AwakeFacts {mode: KeepAwakeMode; startedAt: number; timeoutSeconds?: number} + +/** How much of the label fits: full (Awake · Display), short (Awake) or glyph (the icon alone, when the glyph mode has one). */ +export type AwakeForm = 'full' | 'short' | 'glyph'; + +/** Plain label text. Icon mode falls back to text when the glyph mode has no icon (Safe/ASCII). */ +export function awakeLabel(facts: AwakeFacts, display: AwakeDisplay, form: AwakeForm = 'full', icon = semanticIcon('awake')): string { + const text = form === 'full' ? `Awake · ${MODE_LABELS[facts.mode]}` : 'Awake'; + if (form === 'glyph') return icon || 'Awake'; + if (display === 'icon' && icon) return form === 'full' ? `${icon} ${MODE_LABELS[facts.mode]}` : icon; + if (display === 'iconText' && icon) return `${icon} ${text}`; + return text; +} + +/** Elapsed time, or what is left when a timeout was set: factual and coarse (no seconds after the first minute). */ +export function awakeDuration(facts: AwakeFacts, now: number): string { + const elapsed = Math.max(0, Math.floor((now - facts.startedAt) / 1000)); + if (facts.timeoutSeconds) { + const left = Math.max(0, facts.timeoutSeconds - elapsed); + return `${formatDuration(left >= 60 ? left - left % 60 : left)} left`; + } + return formatDuration(elapsed >= 60 ? elapsed - elapsed % 60 : Math.max(1, elapsed)); +} + +export const AWAKE_STOP_HINT = '/zoomies stop'; + +/** Semantic roles: informational while active, the same identity muted for the idle reminder. Theme-aware; NO_COLOR and 256-color follow colorEscape. */ +export const awakeStyle = { + active: () => foreground(UI_COLORS.accent), + muted: () => foreground(UI_COLORS.subtle), + failure: () => foreground(UI_COLORS.failure), + reset: '\u001b[0m', +}; + +// ---- Composer accessory slots ------------------------------------------------------------------ + +export type SlotState = 'available' | 'occupied' | 'unavailable'; +export type AccessorySlot = 'topEdge' | 'bottomEdge' | 'adjacentRow' | 'inputTrailing'; + +export interface ComposerSlots {topEdge: SlotState; bottomEdge: SlotState; inputTrailing: SlotState} + +/** + * Resolved every frame from real composer geometry. The saved preference is + * never changed by a fallback: Composer edge means "a free edge if there is + * one", Input row means "only when it is completely safe". + */ +export function resolveAccessorySlot(placement: AwakePlacement, slots: ComposerSlots): AccessorySlot { + if (placement === 'above') return 'adjacentRow'; + if (placement === 'input') return slots.inputTrailing === 'available' ? 'inputTrailing' : 'adjacentRow'; + if (slots.topEdge === 'available') return 'topEdge'; + if (slots.bottomEdge === 'available') return 'bottomEdge'; + return 'adjacentRow'; +} + +/** Rule cells kept on each side of an accessory, so the edge still reads as a divider. */ +export const EDGE_LEAD_MIN = 6; +export const EDGE_TRAIL = 2; + +/** Whether an accessory of this width fits on an edge of `columns` cells. */ +export function edgeFits(columns: number, accessoryWidth: number): boolean { + return accessoryWidth > 0 && columns >= accessoryWidth + 2 + EDGE_LEAD_MIN + EDGE_TRAIL; +} + +/** + * Cells [start, end) of an ANSI string, carrying the SGR state in effect at + * `start`. Divider rules are single-width glyphs, which is all this needs. + */ +export function sliceAnsiCells(value: string, start: number, end: number): string { + let out = ''; + let lastSgr = ''; + let cell = 0; + const pattern = /(\u001b\[[0-?]*[ -/]*[@-~])|([\s\S])/gu; + for (const match of value.matchAll(pattern)) { + if (match[1]) { + if (cell < start) { if (match[1].endsWith('m')) lastSgr = match[1]; } + else if (cell < end) out += match[1]; + continue; + } + const width = displayWidth(match[2]!); + if (cell >= start && cell + width <= end) out += match[2]; + cell += width; + if (cell >= end) break; + } + return `${lastSgr}${out}`; +} + +/** + * One composer edge: the painted rule with the accessory set into its + * trailing portion. The result is exactly `width` cells; the rule keeps its + * own colors (and Chroma) and the accessory keeps its own, so animating the + * rule never touches the accessory text. + */ +export function renderComposerEdge(options: {rule: string; width: number; accessory?: {ansi: string; width: number}}): string { + const {rule, width, accessory} = options; + if (!accessory || !edgeFits(width, accessory.width)) return rule; + const lead = width - accessory.width - 2 - EDGE_TRAIL; + return `${sliceAnsiCells(rule, 0, lead)}\u001b[0m ${accessory.ansi} \u001b[0m${sliceAnsiCells(rule, width - EDGE_TRAIL, width)}\u001b[0m`; +} + +/** Columns the accessory occupies on a composed edge row (for keeping transition tints off it). */ +export function edgeAccessoryColumns(width: number, accessoryWidth: number): {start: number; end: number} | undefined { + if (!edgeFits(width, accessoryWidth)) return undefined; + const start = width - accessoryWidth - 1 - EDGE_TRAIL; + return {start, end: start + accessoryWidth}; +} + +// ---- What to show this frame ----------------------------------------------------------------- + +export interface AwakeView { + /** Compact label for an edge or the input row, styled. */ + compact: {ansi: string; width: number}; + /** The idle form for the same slot when it fits; otherwise undefined and `reminder` carries the extra facts. */ + expanded?: {ansi: string; width: number}; + /** Muted duration + stop hint for one adjacent row (never repeating the compact label). */ + reminder: {ansi: string; width: number}; + /** One self-contained row for the adjacent slot: compact, plus the idle facts while idle. */ + row: (columns: number) => string; +} + +const styled = (text: string, style: string) => ({ansi: `${style}${text}${awakeStyle.reset}`, width: displayWidth(text)}); + +export function awakeView(facts: AwakeFacts, settings: KeepAwakePresentation, idle: boolean, now: number, icon = semanticIcon('awake')): AwakeView { + const label = awakeLabel(facts, settings.display, 'full', icon); + const duration = awakeDuration(facts, now); + const compact = styled(label, awakeStyle.active()); + const expandedText = `${label} · ${duration}`; + const expanded = {ansi: `${awakeStyle.active()}${label}${awakeStyle.reset}${awakeStyle.muted()} · ${duration} ${AWAKE_STOP_HINT}${awakeStyle.reset}`, + width: displayWidth(`${expandedText} ${AWAKE_STOP_HINT}`)}; + const reminder = styled(`${duration} ${AWAKE_STOP_HINT}`, awakeStyle.muted()); + return { + compact, + ...(idle ? {expanded} : {}), + reminder, + row: (columns: number) => { + if (!idle) return fitRow(compact, columns); + const gap = columns - displayWidth(expandedText) - displayWidth(AWAKE_STOP_HINT) - 1; + if (gap >= 2) return `${awakeStyle.active()}${label}${awakeStyle.reset}${awakeStyle.muted()} · ${duration}${' '.repeat(gap)}${AWAKE_STOP_HINT}${awakeStyle.reset}`; + return fitRow(compact, columns); + }, + }; +} + +function fitRow(part: {ansi: string; width: number}, columns: number): string { + return part.width <= columns ? part.ansi : `${awakeStyle.active()}Awake${awakeStyle.reset}`; +} + +// ---- Screensaver -------------------------------------------------------------------------- + +/** The positioned screensaver status (one row, inset one cell from the frame edge). */ +export function placeOnSaver(rows: readonly string[], columns: number, text: {ansi: string; width: number}, position: AwakeSaverPosition, + overlay: (row: string, column: number, ansi: string, width: number) => string): string[] { + if (!rows.length || text.width + 2 > columns) return [...rows]; + const row = position.startsWith('top') ? 0 : rows.length - 1; + const column = position.endsWith('Left') ? 1 : position.endsWith('Right') ? columns - text.width - 1 : Math.floor((columns - text.width) / 2); + const out = [...rows]; + out[row] = overlay(out[row] ?? '', column, text.ansi, text.width); + return out; +} diff --git a/src/keyboard/KeyboardPanel.ts b/src/keyboard/KeyboardPanel.ts index e1a53e3c..5973e79b 100644 --- a/src/keyboard/KeyboardPanel.ts +++ b/src/keyboard/KeyboardPanel.ts @@ -1,11 +1,11 @@ import {Key} from '../terminal/keys.js'; -import {foreground, UI_COLORS} from '../ui/palette.js'; +import {focusForeground, foreground, UI_COLORS, lazyForeground} from '../ui/palette.js'; import {truncateAnsi} from '../util/text.js'; -const PRIMARY = foreground(UI_COLORS.primary); -const SECONDARY = foreground(UI_COLORS.secondary); -const INTERACTIVE = foreground(UI_COLORS.accent); -const WARNING = foreground(UI_COLORS.failure); +const PRIMARY = lazyForeground(UI_COLORS.primary); +const SECONDARY = lazyForeground(UI_COLORS.secondary); +const INTERACTIVE = lazyForeground(UI_COLORS.accent); +const WARNING = lazyForeground(UI_COLORS.failure); const RESET = '\u001B[0m'; export interface KeyboardState { @@ -20,20 +20,20 @@ export function handleKeyboardKey(key: Key, state: KeyboardState): boolean { return false; } -export function renderKeyboardPanel(state: KeyboardState, columns: number): string[] { +export function renderKeyboardPanel(state: KeyboardState, columns: number, hostName = 'the host'): string[] { const rows: string[] = []; rows.push(`${PRIMARY} Keyboard Integration${RESET}`); rows.push(''); - rows.push(` ${WARNING}Note: Installing these bindings affects all Ghostty tabs globally.${RESET}`); + rows.push(` ${WARNING}Note: Installing these bindings affects all ${hostName} tabs globally.${RESET}`); rows.push(''); const sel = (index: number) => index === state.selectedIndex ? `${INTERACTIVE}>${RESET}` : ' '; - const labelColor = (index: number) => index === state.selectedIndex ? PRIMARY : SECONDARY; + const labelColor = (index: number) => focusForeground(index === state.selectedIndex); - 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/modelService.ts b/src/modelService.ts new file mode 100644 index 00000000..a235443d --- /dev/null +++ b/src/modelService.ts @@ -0,0 +1,19 @@ +// Entry point for the user-global local model service. Started on demand by +// an NMSh window only when local understanding is enabled for a feature; +// exits by itself when no window needs it. +import {defaultRuntimeDir, ensurePrivateRuntimeDir} from './session/runtimeDir.js'; +import {resolveCommand} from './providers/providers.js'; +import {ModelService, modelSocketPath, serveModelService} from './understanding/ModelService.js'; +import {runtimeFor} from './understanding/runtimes.js'; +import {readBuildIdentity} from './buildInfo.js'; + +const runtimeDir = defaultRuntimeDir(); +ensurePrivateRuntimeDir(runtimeDir); +let server: Awaited>; +const build = (() => { try { const id = readBuildIdentity(); return `${id.version} ${id.commit}`; } catch { return undefined; } })(); +const service = new ModelService({runtimeFor: model => runtimeFor(model, name => resolveCommand(name)), ...(build ? {build} : {}), + onExit: () => { server?.close(); process.exit(0); }}); +server = await serveModelService(modelSocketPath(runtimeDir), service); +// Another window's service already owns the socket: this one is not needed. +if (!server) process.exit(0); +for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) process.on(signal, () => { void service.unload().finally(() => process.exit(0)); }); diff --git a/src/motion/MotionPreview.ts b/src/motion/MotionPreview.ts new file mode 100644 index 00000000..50da52df --- /dev/null +++ b/src/motion/MotionPreview.ts @@ -0,0 +1,139 @@ +import type {MotionSettings} from '../prompt/configuration.js'; +import {overlayRow, type CellPaint} from '../presentation/cellOverlay.js'; +import {getCurrentGlyphMode} from '../ui/glyphs.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {displayWidth, truncateAnsi} from '../util/text.js'; +import {diffModules, progress, transitionPaint, Transitions, type MotionGate} from './transitions.js'; + +/** + * /appearance → Motion preview: deterministic synthetic fixtures driven by the + * real Transitions class (its gating and durations) and painted with the real + * transitionPaint functions, so the preview is the effect itself on fake + * content. It never touches the composer, transcript, shell or session. + * One run per trigger (row selected, value changed, R); never a loop. + * Like the live effects it only shifts foreground colors: no backgrounds. + */ +export type MotionPreviewMode = 'animating' | 'settled' | 'off' | 'effectsOff' | 'reduced' | 'noColor'; + +export interface MotionPreview { + /** Always PREVIEW_HEIGHT rows, so the panel does not jump. */ + rows: string[]; + caption: string; + mode: MotionPreviewMode; + /** A frame clock is needed only while this is true. */ + busy: boolean; +} + +/** Fixture rows plus the caption row. */ +export const PREVIEW_HEIGHT = 3; + +const DESCRIPTIONS: Record = { + 'contextTransitions:subtle': 'Only the changed modules (cwd, branch) wipe in; Node 22 stays still.', + 'contextTransitions:expressive': 'Changed prompt modules wipe in with a brighter front.', + 'commandLaunch:sweep': 'A brief light band tints the submitted command and rule.', + 'commandLaunch:pulse': 'The command brightens once and fades as it is handed off.', + 'completionHighlight:subtle': 'Only the inserted completion is underlined and tinted, briefly.', + 'completionHighlight:vivid': 'Only the inserted completion is underlined and tinted brightly, a little longer.', + 'cursorTravel:on': 'A soft trail follows the caret across a multi-cell jump; the caret itself is already at its destination.', + 'completionEffect:seal': 'A finished block\'s header takes one sweep of its outcome color.', + 'eventFeedback:subtle': 'Meaningful events tint the composer rule once.', + 'eventFeedback:expressive': 'Meaningful events tint the rule and sweep the input once.', +}; + +interface Fixture { lines: string[]; paint: (transitions: Transitions, now: number, width: number) => Array> } + +function fixture(row: keyof MotionSettings, width: number): Fixture & {trigger: (transitions: Transitions, start: number) => void} { + const safe = getCurrentGlyphMode() === 'safe'; + const prompt = safe ? '>' : '❯'; + const check = safe ? '+' : '✓'; + const rule = (safe ? '-' : '─').repeat(Math.max(4, Math.min(width, 28))); + const at = (transitions: Transitions, now: number) => transitions.live(now); + if (row === 'contextTransitions') { + // Node 22 is the same before and after: it must receive no effect at all. + const before = [{id: 'cwd', text: '~/project'}, {id: 'branch', text: 'main'}, {id: 'node', text: 'Node 22'}]; + const after = [{id: 'cwd', text: '~/src'}, {id: 'branch', text: 'feature/theme'}, {id: 'node', text: 'Node 22'}]; + const lines = [`before ${before.map(module => module.text).join(' ')}`, `after ${after.map(module => module.text).join(' ')}`]; + return {lines, trigger: (transitions, start) => transitions.morph(diffModules(before, after), start), + paint: (transitions, now) => { + const cells = new Map(); + for (const transition of at(transitions, now)) if (transition.kind === 'morph') for (const change of transition.changes) { + const index = lines[1]!.indexOf(change.text); + if (change.change === 'disappeared' || index < 0) continue; + const column = displayWidth(lines[1]!.slice(0, index)); + for (const [key, value] of transitionPaint.morph(column, column + displayWidth(change.text), progress(transition, now), transition.expressive, change.change, transition.look)) cells.set(key, value); + } + return [new Map(), cells]; + }}; + } + if (row === 'commandLaunch') { + return {lines: [`${prompt} npm test`, rule], trigger: (transitions, start) => transitions.launch(start), + paint: (transitions, now, columns) => { + const transition = at(transitions, now).find(item => item.kind === 'launch'); + if (transition?.kind !== 'launch') return []; + const t = progress(transition, now); + return [transitionPaint.launch(transition.style, columns, t, false, transition.look), transitionPaint.launch(transition.style, columns, t, true, transition.look)]; + }}; + } + if (row === 'completionHighlight') { + const typed = `${prompt} git sta`; + const line = `${typed}tus`; + return {lines: [line, ` ${safe ? '' : '⇥ '}Tab completed "tus"`], trigger: (transitions, start) => transitions.materialize(displayWidth(typed), displayWidth(line), line, start), + paint: (transitions, now) => { + const transition = at(transitions, now).find(item => item.kind === 'materialize'); + return transition?.kind === 'materialize' ? [transitionPaint.materialize(transition.from, transition.to, progress(transition, now), transition.vivid, transition.look)] : []; + }}; + } + if (row === 'cursorTravel') { + const line = `${prompt} git commit -m wip`; + return {lines: [line, ` ${safe ? '' : '⌥ '}Option+Left jumps back a word`], trigger: (transitions, start) => transitions.travel(displayWidth(line), displayWidth(`${prompt} git commit -m `), 0, start), + paint: (transitions, now) => { + const transition = at(transitions, now).find(item => item.kind === 'travel'); + return transition?.kind === 'travel' ? [transitionPaint.travel(transition.from, transition.to, progress(transition, now), transition.look)] : []; + }}; + } + if (row === 'completionEffect') { + return {lines: [`${check} npm test · 1.2s`, ' 12 passing'], trigger: (transitions, start) => transitions.seal(0, 'success', start), + paint: (transitions, now, columns) => { + const transition = at(transitions, now).find(item => item.kind === 'seal'); + return transition?.kind === 'seal' ? [transitionPaint.seal(transition.tone, columns, progress(transition, now), transition.look)] : []; + }}; + } + return {lines: [rule, `${check} task finished`], trigger: (transitions, start) => transitions.echo('taskDone', start), + paint: (transitions, now, columns) => { + const transition = at(transitions, now).find(item => item.kind === 'echo'); + if (transition?.kind !== 'echo') return []; + const t = progress(transition, now); + return [transitionPaint.echoRule(transition.event, columns, t, transition.expressive, transition.look), transition.expressive ? transitionPaint.echoInput(transition.event, columns, t, transition.look) : new Map()]; + }}; +} + +/** + * The preview for one Motion row at its current value, `now - start` after it + * was triggered. Pure: the same inputs give the same frame. + */ +export function renderMotionPreview(row: keyof MotionSettings, motion: MotionSettings, gate: MotionGate, columns: number, start: number, now: number): MotionPreview { + const subtle = foreground(UI_COLORS.subtle); + const secondary = foreground(UI_COLORS.secondary); + const reset = '\u001b[0m'; + const width = Math.max(0, columns - 4); + const value = motion[row] as string; + const mode: MotionPreviewMode = value === 'off' ? 'off' : gate.effectsOff ? 'effectsOff' : gate.reducedMotion ? 'reduced' : !gate.color ? 'noColor' : 'animating'; + const transitions = new Transitions(() => motion, () => gate); + const item = fixture(row, width); + item.trigger(transitions, start); + const paints = mode === 'animating' ? item.paint(transitions, now, Math.min(width, Math.max(...item.lines.map(line => displayWidth(line))) + 2)) : []; + const finalMode: MotionPreviewMode = mode === 'animating' && !transitions.busy ? 'settled' : mode; + const caption = finalMode === 'off' ? 'Off: nothing is shown.' + : finalMode === 'effectsOff' ? 'Decorative effects are Off, so this effect is suppressed.' + : finalMode === 'reduced' ? 'Reduced Motion: the change appears at once, without animation.' + : finalMode === 'noColor' ? 'NO_COLOR: no tint is painted.' + : `${DESCRIPTIONS[`${row}:${value}`] ?? ''}${finalMode === 'settled' ? ' R replays.' : ''}`; + // Too narrow for the fixture: the caption alone, still the same height. + const fixtureRows = width < 16 ? ['', ''] : item.lines.map((line, index) => { + const muted = row === 'contextTransitions' && index === 0; + const text = `${muted ? subtle : secondary}${line}${reset}`; + const cells = paints[index]; + return ` ${cells?.size ? overlayRow(text, cells, width) : text}`; + }); + return {rows: [...fixtureRows, ` ${subtle}${caption}${reset}`].map(line => truncateAnsi(line, columns)), caption, mode: finalMode, busy: finalMode === 'animating'}; +} diff --git a/src/motion/PresentationClock.ts b/src/motion/PresentationClock.ts new file mode 100644 index 00000000..89914f07 --- /dev/null +++ b/src/motion/PresentationClock.ts @@ -0,0 +1,50 @@ +/** 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; } + + /** + * `floor` lowers the minimum interval for short-lived motion only (cursor movement asks for 16 ms while a + * move is in flight and unsubscribes when it settles); everything else keeps the 40 ms floor. + */ + subscribe(callback: (now: number) => void, interval = 100, floor = 40): () => void { + const key = Symbol(); + // Short one-shot sweeps may ask for up to 25 frames per second; everything else keeps 10 or fewer. + const bounded = Number.isFinite(interval) ? Math.max(Math.max(16, floor), 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..6092d37b --- /dev/null +++ b/src/motion/effects.ts @@ -0,0 +1,82 @@ +import type {Region, ScreenPlan} from '../app/screenPlan.js'; +import {colorEscape, type Rgb} 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' | 'confetti'; +export const EFFECT_KINDS: readonly EffectKind[] = ['sparkles', 'rain', 'confetti']; +/** Confetti: a few theme-colored pieces drifting down; used for real milestones only. */ +const CONFETTI: readonly Rgb[] = [{red: 166, green: 124, blue: 243}, {red: 62, green: 232, blue: 181}, {red: 242, green: 158, blue: 76}, + {red: 79, green: 184, blue: 247}, {red: 228, green: 108, blue: 200}]; +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; hue?: 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 + : effect.kind === 'confetti' ? (Math.floor(phase * height) + Math.floor(frame / 3)) % height + : Math.floor(noise(effect.seed, index * 3 + 2) * height)), + glyph: effect.kind === 'rain' ? '|' : effect.kind === 'confetti' ? (safe ? '*' : ['▪', '•', '◆', '▴'][index % 4]!) : safe ? '+' : '·', + intensity: (1 + Math.sin((phase + frame / 12) * 2 * Math.PI)) / 2, + ...(effect.kind === 'confetti' ? {hue: index % CONFETTI.length} : {}), + }; + }); +} + +/** 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)) { + const color = cell.hue === undefined ? mixRgb(BRAND_LAVENDER, {red: 235, green: 220, blue: 255}, cell.intensity) + : mixRgb(CONFETTI[cell.hue]!, {red: 255, green: 255, blue: 255}, cell.intensity * 0.3); + grid[cell.row - region.top]![cell.column] = `${colorEscape(38, color, level)}${cell.glyph}`; + } + grid.forEach((row, index) => { next[region.top + index] = row.join('') + (level === 'none' ? '' : '\u001B[0m'); }); + return next; +} diff --git a/src/motion/lightSweep.ts b/src/motion/lightSweep.ts new file mode 100644 index 00000000..560c1170 --- /dev/null +++ b/src/motion/lightSweep.ts @@ -0,0 +1,241 @@ +import {colorEscape, type Rgb} from '../chroma/escape.js'; +import {fromOklch, mixOklch, toOklch} from '../chroma/color.js'; +import type {ColorLevel} from '../presentation/capabilities.js'; +import {displayWidth} from '../util/text.js'; + +/** + * Light sweep: a narrow, soft band of light that travels across otherwise + * stationary cells. Glyphs, widths and positions never change; only each + * cell's own foreground color is lifted, and only while the band is over it. + * + * Per cell: base color → optional tint (Chroma gradient at the cell, or a + * subtle accent) → luminance lift in OKLCH (hue kept) → blend from the exact + * base by a Gaussian envelope → capability degradation. Outside the band the + * envelope is exactly zero and the base color is returned unchanged. + */ + +export const SWEEP_LEVELS = ['off', 'subtle', 'vivid'] as const; +export type SweepLevel = typeof SWEEP_LEVELS[number]; +export const SWEEP_LEVEL_LABELS: Record = {off: 'Off', subtle: 'Subtle', vivid: 'Vivid / Chroma'}; +export const SWEEP_SPEEDS = ['slow', 'normal', 'fast'] as const; +export type SweepSpeed = typeof SWEEP_SPEEDS[number]; +export const SWEEP_SPEED_LABELS: Record = {slow: 'Slow', normal: 'Normal', fast: 'Fast'}; +export const SWEEP_COLORS = ['appearance', 'accent'] as const; +export type SweepColors = typeof SWEEP_COLORS[number]; +export const SWEEP_COLOR_LABELS: Record = {appearance: 'Follow appearance', accent: 'Theme accent'}; + +/** Cells per second the crest travels. */ +const SPEED_CELLS: Record = {slow: 9, normal: 14, fast: 22}; +/** Soft band half-width (Gaussian sigma) in cells, and the cutoff where it is exactly zero. */ +const SIGMA = 2.4; +const CUTOFF = 3.2 * SIGMA; +/** Quiet cells after the band leaves before it enters again. */ +const QUIET_CELLS = 26; + +export interface SweepCell { + glyph: string; + /** The cell's base foreground; undefined cells (spaces, uncolored) never change. */ + color?: Rgb; + /** A semantic cell (success, warning, failure, info): Preserve keeps its hue. */ + semantic?: boolean; +} + +export interface SweepStyle { + level: SweepLevel; + speed: SweepSpeed; + /** + * Tint at a position 0..1 along the text (the Chroma gradient, or the theme + * accent); undefined means luminance only. + */ + tint?: (position: number) => Rgb; + /** Chroma participation: 'override' lets semantic cells take the tint too. */ + semantic: 'preserve' | 'override'; + /** Grayscale chrome: luminance only, never a colored fringe. */ + grayscale: boolean; +} + +const LIFT: Record, number> = {subtle: 0.42, vivid: 0.66}; +const TINT: Record, number> = {subtle: 0.18, vivid: 0.5}; + +/** Total cells one cycle travels: in from the left edge, across, out, then a quiet stretch. */ +export function sweepTravel(width: number): number { + return Math.max(1, width) + 2 * CUTOFF + QUIET_CELLS; +} + +export function sweepCycleMs(width: number, speed: SweepSpeed): number { + return sweepTravel(width) / SPEED_CELLS[speed] * 1000; +} + +/** Crest position (in cells, may be off either edge) at `time`. */ +export function sweepCrest(width: number, time: number, speed: SweepSpeed): number { + const travel = sweepTravel(width); + const cells = (Math.max(0, time) / 1000) * SPEED_CELLS[speed]; + return ((cells % travel) + travel) % travel - CUTOFF; +} + +/** Smooth bell envelope 0..1 at a cell; exactly 0 beyond the cutoff. */ +export function sweepEnvelope(column: number, crest: number): number { + const distance = Math.abs(column - crest); + if (distance >= CUTOFF) return 0; + return Math.exp(-((distance / SIGMA) ** 2)); +} + +/** The swept color of one cell at intensity `envelope` (0 returns the base object itself). */ +export function sweepColor(base: Rgb, envelope: number, position: number, style: SweepStyle, semantic = false): Rgb { + if (style.level === 'off' || envelope <= 0) return base; + let target = base; + if (!style.grayscale && style.tint) { + // Chroma or accent tint: full participation, or a faint compatible touch for preserved semantic cells. + const amount = TINT[style.level] * (semantic && style.semantic === 'preserve' ? 0.15 : 1); + target = mixOklch(base, style.tint(position), amount); + } + const lch = toOklch(target); + const baseChroma = toOklch(base).c; + const boost = style.grayscale ? 0 : style.level === 'vivid' ? 0.03 : 0.015; + // Light, not white: lift toward a ceiling below white and keep at least the cell's own colorfulness. + const lifted = fromOklch({l: lch.l + LIFT[style.level] * (0.9 - lch.l), + c: style.grayscale ? lch.c : Math.min(0.32, Math.max(lch.c, baseChroma) + boost), h: lch.h}); + return mixOklch(base, lifted, Math.min(1, envelope)); +} + +/** + * Paints cells with the sweep at `time`. `still` (Reduced Motion) and + * `level: 'off'` paint the base colors; NO_COLOR returns the plain glyphs. + */ +export function sweepCells(cells: readonly SweepCell[], time: number, style: SweepStyle, colorLevel: ColorLevel, still = false): string { + if (colorLevel === 'none') return cells.map(cell => cell.glyph).join(''); + const width = cells.reduce((sum, cell) => sum + displayWidth(cell.glyph), 0); + const crest = still || style.level === 'off' ? Number.NEGATIVE_INFINITY : sweepCrest(width, time, style.speed); + let column = 0; + let out = ''; + for (const cell of cells) { + const center = column + (displayWidth(cell.glyph) - 1) / 2; + if (cell.color) { + const color = sweepColor(cell.color, sweepEnvelope(center, crest), width <= 1 ? 0 : center / (width - 1), style, cell.semantic); + out += `${colorEscape(38, color, colorLevel)}${cell.glyph}`; + } else out += `\u001B[39m${cell.glyph}`; + column += displayWidth(cell.glyph); + } + return `${out}\u001B[39m`; +} + +/** Whether a sweep at these settings needs presentation frames at all. */ +export function sweepAnimates(level: SweepLevel, still: boolean, colorLevel: ColorLevel): boolean { + return level !== 'off' && !still && colorLevel !== 'none'; +} + +// ---- One-shot sweeps (event-driven) --------------------------------------------- + +/** + * A one-shot sweep crosses the text once: it enters just before the first + * cell and fully leaves past the last within `durationMs`, then everything is + * exactly the base color again. Selection, value changes, submit and + * confirmations each start one; a new one replaces the old (no queue). + */ +export function sweepDurationMs(width: number): number { + return Math.round(Math.max(600, Math.min(1200, 520 + 11 * Math.max(1, width)))); +} + +/** Crest position for a one-shot sweep at `elapsed` ms; past the end it is beyond the cutoff. */ +export function oneShotCrest(width: number, elapsed: number, durationMs = sweepDurationMs(width)): number { + const start = -CUTOFF, end = Math.max(1, width) - 1 + CUTOFF; + if (elapsed <= 0) return start - 1; + if (elapsed >= durationMs) return end + 1; + // Gentle ease so the band glides in and out rather than starting abruptly. + const t = elapsed / durationMs; + const eased = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2; + return start + (end - start) * (0.15 * t + 0.85 * eased); +} + +/** Paint cells at one point of a one-shot sweep (no motion when `still`). */ +export function sweepOnce(cells: readonly SweepCell[], elapsed: number, style: SweepStyle, colorLevel: ColorLevel, still = false, durationMs?: number): string { + const width = cells.reduce((sum, cell) => sum + displayWidth(cell.glyph), 0); + return paintAtCrest(cells, still || style.level === 'off' ? Number.NEGATIVE_INFINITY : oneShotCrest(width, elapsed, durationMs ?? sweepDurationMs(width)), style, colorLevel); +} + +function paintAtCrest(cells: readonly SweepCell[], crest: number, style: SweepStyle, colorLevel: ColorLevel): string { + if (colorLevel === 'none') return cells.map(cell => cell.glyph).join(''); + const width = cells.reduce((sum, cell) => sum + displayWidth(cell.glyph), 0); + let column = 0; + let out = ''; + for (const cell of cells) { + const center = column + (displayWidth(cell.glyph) - 1) / 2; + if (cell.color) out += `${colorEscape(38, sweepColor(cell.color, sweepEnvelope(center, crest), width <= 1 ? 0 : center / (width - 1), style, cell.semantic), colorLevel)}${cell.glyph}`; + else out += `\u001B[39m${cell.glyph}`; + column += displayWidth(cell.glyph); + } + return `${out}\u001B[39m`; +} + +// ---- Sweeping an already-rendered NMSh row --------------------------------------- + +const SGR = /^\u001B\[([0-9;]*)m/u; +const OTHER_ESCAPE = /^\u001B(?:\][^\u0007\u001B]*(?:\u0007|\u001B\\)|\[[0-?]*[ -/]*[@-~])/u; + +function xterm256Rgb(index: number): Rgb | undefined { + if (index < 16) return undefined; + if (index >= 232) { const level = 8 + (index - 232) * 10; return {red: level, green: level, blue: level}; } + const levels = [0, 95, 135, 175, 215, 255]; + const cube = index - 16; + return {red: levels[Math.floor(cube / 36)]!, green: levels[Math.floor(cube / 6) % 6]!, blue: levels[cube % 6]!}; +} + +/** Foreground after applying an SGR parameter list; undefined is the terminal default. */ +function applySgr(params: number[], current: Rgb | undefined): Rgb | undefined { + let fg = current; + for (let index = 0; index < params.length; index++) { + const value = params[index]!; + if (value === 0 || value === 39) fg = undefined; + else if (value === 38 && params[index + 1] === 2) { fg = {red: params[index + 2] ?? 0, green: params[index + 3] ?? 0, blue: params[index + 4] ?? 0}; index += 4; } + else if (value === 38 && params[index + 1] === 5) { fg = xterm256Rgb(params[index + 2] ?? 0); index += 2; } + else if (value === 48) index += params[index + 1] === 2 ? 4 : 2; + } + return fg; +} + +/** + * The one-shot sweep over a row NMSh already rendered (a selected Settings or + * Setup Cat row, a palette item, the prompt row). Every escape and glyph is + * kept; cells under the band get a new foreground followed by their original + * one, so backgrounds, bold and spacing are untouched. Cells with the + * terminal-default foreground are left as they are. `from`..`to` limit the + * swept columns (e.g. label and value, not the whole width). + */ +export function sweepAnsiRow(row: string, elapsed: number, style: SweepStyle, colorLevel: ColorLevel, range?: {from: number; to: number}, + durationMs?: number): string { + if (colorLevel === 'none' || colorLevel === 'ansi16' || style.level === 'off') return row; + const from = range?.from ?? 0; + const to = range?.to ?? displayWidth(row); + const width = Math.max(1, to - from); + const crest = oneShotCrest(width, elapsed, durationMs ?? sweepDurationMs(width)); + if (crest < -CUTOFF || crest > width - 1 + CUTOFF) return row; + let out = ''; + let fg: Rgb | undefined; + let fgEscape = ''; + let column = 0; + let index = 0; + while (index < row.length) { + if (row[index] === '\u001B') { + const sgr = SGR.exec(row.slice(index)); + if (sgr) { + fg = applySgr(sgr[1] ? sgr[1].split(';').map(Number) : [0], fg); + if (fg === undefined) fgEscape = '\u001B[39m'; + else if (/(^|;)38;/u.test(sgr[1] ?? '')) fgEscape = colorEscape(38, fg, colorLevel); + out += sgr[0]; index += sgr[0].length; continue; + } + const other = OTHER_ESCAPE.exec(row.slice(index)); + if (other) { out += other[0]; index += other[0].length; continue; } + } + const codePoint = row.codePointAt(index)!; + const glyph = String.fromCodePoint(codePoint); + index += glyph.length; + const cellWidth = displayWidth(glyph); + const local = column - from + (cellWidth - 1) / 2; + const envelope = column >= from && column < to ? sweepEnvelope(local, crest) : 0; + if (fg && envelope > 0 && glyph.trim()) { + out += `${colorEscape(38, sweepColor(fg, envelope, local / Math.max(1, width - 1), style), colorLevel)}${glyph}${fgEscape || colorEscape(38, fg, colorLevel)}`; + } else out += glyph; + column += cellWidth; + } + return out; +} diff --git a/src/motion/motionRows.ts b/src/motion/motionRows.ts new file mode 100644 index 00000000..9913918a --- /dev/null +++ b/src/motion/motionRows.ts @@ -0,0 +1,65 @@ +import {COMMAND_LAUNCHES, COMPLETION_EFFECTS, CURSOR_TRAVELS, COMPLETION_HIGHLIGHTS, CONTEXT_TRANSITIONS, DEFAULT_MOTION_TUNING, EVENT_FEEDBACK, MOTION_INTENSITIES, MOTION_RENDERINGS, MOTION_SPEEDS, + type MotionRendering, type MotionSettings, type MotionTuning} from '../prompt/configuration.js'; + +/** + * The general Motion settings: one table for /appearance → Motion, Settings + * and Setup, so a value, its label and its help are written once. + */ +export interface MotionRow {key: keyof MotionSettings; label: string; values: readonly string[]; note: string} + +export const MOTION_ROWS: readonly MotionRow[] = [ + {key: 'contextTransitions', label: 'Context transitions', values: CONTEXT_TRANSITIONS, note: 'Prompt modules transform in place when cwd, branch, Git state or tools change'}, + {key: 'commandLaunch', label: 'Command launch', values: COMMAND_LAUNCHES, note: 'Enter hands the command to the shell at once; this only shows the handoff'}, + {key: 'completionHighlight', label: 'Completion highlight', values: COMPLETION_HIGHLIGHTS, note: 'What completion just inserted, briefly'}, + {key: 'completionEffect', label: 'Command completion', values: COMPLETION_EFFECTS, note: 'Block Seal: a finished block settles with one semantic sweep'}, + {key: 'cursorTravel', label: 'Cursor travel trail', values: CURSOR_TRAVELS, note: 'A soft trail between where the caret was and where it is, on jumps of several cells; the caret itself moves at once'}, + {key: 'eventFeedback', label: 'Event feedback', values: EVENT_FEEDBACK, note: 'Semantic Echo: failures, long successes, conflicts, attention, tasks finishing'}, +]; + +export const MOTION_LABELS: Record = {off: 'Off', subtle: 'Subtle', expressive: 'Expressive', sweep: 'Sweep', pulse: 'Pulse', vivid: 'Vivid', seal: 'Seal', on: 'On'}; + +/** One editable Motion value, as /appearance → Motion, Settings and Setup all show it. */ +export interface MotionItem { + id: string; + label: string; + note: string; + values: readonly string[]; + labelOf: (value: string) => string; + get: (motion: MotionSettings) => string; + set: (motion: MotionSettings, value: string) => MotionSettings; + /** The effect whose preview demonstrates this value. */ + preview: MotionRow['key']; +} + +const RENDERING_LABELS: Record = {clean: 'Clean', rich: 'Rich'}; +const INTENSITY_LABELS: Record = {low: 'Low', medium: 'Medium', high: 'High'}; +const SPEED_LABELS: Record = {slow: 'Slow', normal: 'Normal', fast: 'Fast'}; + +/** Rendering: Clean keeps the host's background; Rich is the stronger filled look. */ +export const MOTION_RENDERING_ITEM: MotionItem = {id: 'rendering', label: 'Rendering', + note: 'Clean tints and underlines text and keeps your terminal\'s background (best on transparent windows); Rich draws the stronger filled bands', + values: MOTION_RENDERINGS, labelOf: value => RENDERING_LABELS[value] ?? value, get: motion => motion.rendering ?? 'clean', + set: (motion, value) => ({...motion, rendering: value as MotionRendering}), preview: 'commandLaunch'}; + +/** Per-rendering tuning: it edits the selected rendering's own values and leaves the other's alone. */ +export const MOTION_TUNING_ITEMS: readonly MotionItem[] = [ + {id: 'intensity', label: 'Intensity', note: 'How strong the paint is for the selected rendering; each rendering keeps its own', + values: MOTION_INTENSITIES, labelOf: value => INTENSITY_LABELS[value] ?? value, preview: 'commandLaunch', + get: motion => motion.tuning?.[motion.rendering ?? 'clean']?.intensity ?? 'medium', + set: (motion, value) => withTuning(motion, {intensity: value as MotionTuning['intensity']})}, + {id: 'speed', label: 'Speed', note: 'How long each effect lasts for the selected rendering: Slow lingers, Fast is brief', + values: MOTION_SPEEDS, labelOf: value => SPEED_LABELS[value] ?? value, preview: 'commandLaunch', + get: motion => motion.tuning?.[motion.rendering ?? 'clean']?.speed ?? 'normal', + set: (motion, value) => withTuning(motion, {speed: value as MotionTuning['speed']})}, +]; + +function withTuning(motion: MotionSettings, patch: Partial): MotionSettings { + const rendering = motion.rendering ?? 'clean'; + const base = DEFAULT_MOTION_TUNING(); + const tuning = {...base, ...(motion.tuning ?? {})}; + return {...motion, tuning: {...tuning, [rendering]: {...tuning[rendering], ...patch}}}; +} + +/** The five effects, then the rendering choice first: the order the Motion screen lists them in. */ +export const MOTION_ITEMS: readonly MotionItem[] = [MOTION_RENDERING_ITEM, ...MOTION_ROWS.map((row): MotionItem => ({id: row.key, label: row.label, note: row.note, values: row.values, + labelOf: value => MOTION_LABELS[value] ?? value, get: motion => motion[row.key] as string, set: (motion, value) => ({...motion, [row.key]: value}), preview: row.key}))]; diff --git a/src/motion/sweepStyle.ts b/src/motion/sweepStyle.ts new file mode 100644 index 00000000..5cf22de3 --- /dev/null +++ b/src/motion/sweepStyle.ts @@ -0,0 +1,29 @@ +import type {PromptConfiguration} from '../prompt/configuration.js'; +import {sampleGradient} from '../chroma/chroma.js'; +import {treatmentFor} from '../chroma/treatment.js'; +import {UI_COLORS} from '../ui/palette.js'; +import type {SweepStyle} from './lightSweep.js'; + +/** + * The light sweep for a configuration. Follow appearance tints with the + * Chroma gradient at each cell while Chroma is on, otherwise with a little of + * the theme accent; Grayscale chrome makes it luminance-only. Decorative + * effects Off turns it off. + */ +export function sweepStyleFor(configuration: PromptConfiguration, strength: 'subtle' | 'vivid' = 'subtle'): SweepStyle { + const presentation = configuration.presentation; + const level = presentation.effectsOff || presentation.shimmer === 'off' ? 'off' : strength; + const grayscale = configuration.uiChrome.source === 'custom' && configuration.uiChrome.preset === 'grayscale'; + const chroma = treatmentFor(presentation); + const gradient = chroma && {stops: chroma.stops.map((color, index) => ({color, at: chroma.stops.length <= 1 ? 0 : index / (chroma.stops.length - 1)}))}; + return { + level, speed: 'normal', grayscale, + semantic: presentation.semantic ?? 'preserve', + tint: grayscale ? undefined : gradient ? position => sampleGradient(gradient, position) : () => ({...UI_COLORS.accent}), + }; +} + +/** Reduced Motion (setting or NMSH_REDUCED_MOTION): no traveling wave. */ +export function sweepStill(configuration: PromptConfiguration, env: NodeJS.ProcessEnv = process.env): boolean { + return configuration.presentation.reducedMotion || env.NMSH_REDUCED_MOTION === '1'; +} diff --git a/src/motion/transitions.ts b/src/motion/transitions.ts new file mode 100644 index 00000000..afd15987 --- /dev/null +++ b/src/motion/transitions.ts @@ -0,0 +1,299 @@ +import {mixRgb} from '../chroma/chroma.js'; +import type {MotionRendering, MotionSettings} from '../prompt/configuration.js'; +import type {CellPaint} from '../presentation/cellOverlay.js'; +import {UI_COLORS, type RgbColor} from '../ui/palette.js'; + +/** + * NMSh's short, finite presentation transitions in one place, so they share + * one clock, one visual language and one composition order: + * + * launch Command Transfer: Enter handed the command to the shell + * materialize what completion just inserted + * seal Block Seal: a finished block settles + * echo Semantic Echo: a meaningful event (long success, failure, attention…) + * morph prompt modules transform when their facts change + * + * Each is state (start, duration, target) plus a pure paint function; the + * logical state they present has already changed. Paints only tint cell + * the foreground of cells that already hold text (tint, brightness, underline; + * see cellOverlay), never a background: SGR has no per-cell alpha, so a + * background would be an opaque box on a transparent terminal, and the host's + * own background is always preserved. Text and widths never move or flash. + * Nothing is stored in transcripts or snapshots. With nothing active there is + * no clock at all. + */ +export type SemanticEvent = 'longSuccess' | 'failure' | 'interrupted' | 'conflict' | 'attention' | 'taskDone' | 'taskFailed' | 'setupDone' | 'setupFailed' | 'installDone' | 'installFailed'; +export type Tone = 'success' | 'failure' | 'muted' | 'attention' | 'warning' | 'accent'; + +export const EVENT_TONES: Record = {longSuccess: 'success', failure: 'failure', interrupted: 'muted', conflict: 'warning', attention: 'attention', + taskDone: 'success', taskFailed: 'failure', setupDone: 'success', setupFailed: 'failure', installDone: 'success', installFailed: 'failure'}; + +/** How a transition is drawn: the renderer and how strongly. Chosen once per event from the Motion settings. */ +export interface MotionLook {rendering: MotionRendering; intensity: number} +export const CLEAN_LOOK: MotionLook = {rendering: 'clean', intensity: 1}; +const INTENSITY = {low: 0.6, medium: 1, high: 1.4} as const; +/** Duration multipliers: Slow lasts longer, Fast is shorter. */ +const SPEED = {slow: 1.5, normal: 1, fast: 0.65} as const; + +/** The look and timing for the selected rendering (each rendering keeps its own tuning). Tolerates settings saved before rendering existed. */ +export function lookFor(motion: MotionSettings): MotionLook & {speed: number} { + const rendering = motion.rendering === 'rich' ? 'rich' : 'clean'; + const tuning = motion.tuning?.[rendering]; + return {rendering, intensity: INTENSITY[tuning?.intensity ?? 'medium'], speed: SPEED[tuning?.speed ?? 'normal']}; +} + +export type Transition = + | {kind: 'launch'; style: 'sweep' | 'pulse'; start: number; duration: number; look: MotionLook} + | {kind: 'materialize'; from: number; to: number; text: string; vivid: boolean; start: number; duration: number; look: MotionLook} + | {kind: 'travel'; from: number; to: number; row: number; start: number; duration: number; look: MotionLook} + | {kind: 'seal'; blockStartId: number; tone: Tone; start: number; duration: number; look: MotionLook} + | {kind: 'echo'; event: SemanticEvent; expressive: boolean; start: number; duration: number; look: MotionLook} + | {kind: 'morph'; changes: ModuleChange[]; expressive: boolean; start: number; duration: number; look: MotionLook}; + +export interface ModuleChange {id: string; text: string; change: 'changed' | 'appeared' | 'disappeared'; role?: string} + +export const DURATIONS = {launch: 170, materializeSubtle: 150, materializeVivid: 260, seal: 240, echoSubtle: 380, echoExpressive: 620, morphSubtle: 150, morphExpressive: 260, travelBase: 170, travelPerCell: 4, travelMax: 230, travelMinCells: 3} as const; + +/** Whether decorative motion may run at all right now (Reduced Motion, Effects Off, NO_COLOR win). */ +export interface MotionGate {reducedMotion: boolean; effectsOff: boolean; color: boolean} +export const motionAllowed = (gate: MotionGate) => !gate.reducedMotion && !gate.effectsOff && gate.color; + +export class Transitions { + private active: Transition[] = []; + + constructor(private readonly settings: () => MotionSettings, private readonly gate: () => MotionGate) {} + + private allowed(): boolean { return motionAllowed(this.gate()); } + + /** Look and speed for an event starting now; the speed scales its duration. */ + private tuned(duration: number): {look: MotionLook; duration: number} { + const {speed, ...look} = lookFor(this.settings()); + return {look, duration: Math.max(40, Math.round(duration * speed))}; + } + + /** Enter submitted a shell command (never Ask or an agent composer). */ + launch(now: number): void { + const style = this.settings().commandLaunch; + if (style === 'off' || !this.allowed()) return; + this.replace('launch', {kind: 'launch', style, start: now, ...this.tuned(DURATIONS.launch)}); + } + + /** Completion inserted [from, to) (editor grapheme indices). A newer completion replaces the older one. */ + materialize(from: number, to: number, text: string, now: number): void { + const level = this.settings().completionHighlight; + if (level === 'off' || to <= from || !this.allowed()) return; + this.replace('materialize', {kind: 'materialize', from, to, text, vivid: level === 'vivid', start: now, ...this.tuned(level === 'vivid' ? DURATIONS.materializeVivid : DURATIONS.materializeSubtle)}); + } + + /** + * The caret jumped from column `from` to `to` on `row` (presentation only; the caret already moved). + * One trail at a time: a newer jump retargets, never queues. One-cell moves are not animated. + */ + travel(from: number, to: number, row: number, now: number): void { + if (this.settings().cursorTravel !== 'on' || Math.abs(to - from) < DURATIONS.travelMinCells || !this.allowed()) return; + const duration = Math.min(DURATIONS.travelMax, DURATIONS.travelBase + Math.abs(to - from) * DURATIONS.travelPerCell); + this.replace('travel', {kind: 'travel', from, to, row, start: now, ...this.tuned(duration)}); + } + + /** The editor changed some other way: a stale materialization range must not linger. */ + editorChanged(text: string): void { + this.active = this.active.filter(item => item.kind !== 'materialize' || item.text === text); + } + + seal(blockStartId: number, outcome: 'success' | 'failure' | 'interrupted', now: number): void { + if (this.settings().completionEffect === 'off' || !this.allowed()) return; + this.active = this.active.filter(item => item.kind !== 'seal'); + this.active.push({kind: 'seal', blockStartId, tone: outcome === 'success' ? 'success' : outcome === 'failure' ? 'failure' : 'muted', start: now, ...this.tuned(DURATIONS.seal)}); + } + + echo(event: SemanticEvent, now: number): void { + const level = this.settings().eventFeedback; + if (level === 'off' || !this.allowed()) return; + // One echo at a time: a newer meaningful event replaces the older one (no stacked flashes). + this.replace('echo', {kind: 'echo', event, expressive: level === 'expressive', start: now, ...this.tuned(level === 'expressive' ? DURATIONS.echoExpressive : DURATIONS.echoSubtle)}); + } + + /** Prompt facts changed: one epoch for every module that changed together; a newer epoch retargets. */ + morph(changes: ModuleChange[], now: number): void { + const level = this.settings().contextTransitions; + if (level === 'off' || !changes.length || !this.allowed()) return; + this.replace('morph', {kind: 'morph', changes, expressive: level === 'expressive', start: now, ...this.tuned(level === 'expressive' ? DURATIONS.morphExpressive : DURATIONS.morphSubtle)}); + } + + private replace(kind: Transition['kind'], transition: Transition): void { + this.active = [...this.active.filter(item => item.kind !== kind), transition]; + } + + cancel(kind?: Transition['kind']): void { this.active = kind ? this.active.filter(item => item.kind !== kind) : []; } + + /** Live transitions at `now` (finished ones are dropped); Reduced Motion and friends end everything at once. */ + live(now: number): Transition[] { + if (!this.allowed()) { this.active = []; return []; } + this.active = this.active.filter(item => now - item.start < item.duration); + return this.active; + } + + get busy(): boolean { return this.active.length > 0; } +} + +export function toneColor(tone: Tone): RgbColor { + return tone === 'success' ? UI_COLORS.success : tone === 'failure' ? UI_COLORS.failure : tone === 'warning' ? WARNING + : tone === 'muted' ? UI_COLORS.subtle : UI_COLORS.accent; +} + +const WARNING: RgbColor = {red: 224, green: 176, blue: 72}; +const clamp01 = (value: number) => Math.min(1, Math.max(0, value)); + +/** A luminance band travelling left → right across [0, width) at progress t: a foreground tint, strongest at the band's center. */ +export function sweepCells(width: number, t: number, color: RgbColor, strength: number, band = 10): Map { + const cells = new Map(); + const center = -band + t * (width + band * 2); + for (let column = Math.max(0, Math.floor(center - band)); column < Math.min(width, Math.ceil(center + band)); column += 1) { + const falloff = 1 - Math.abs(column - center) / band; + if (falloff > 0) cells.set(column, {tint: {color, amount: clamp01(falloff * strength)}}); + } + return cells; +} + +/** + * A soft trail between two caret columns: strongest next to the destination, fading with distance and time. + * The destination cell itself is left to the real caret. Gentler and slower than the completion highlight. + */ +export function travelCells(from: number, to: number, t: number, color: RgbColor, strength: number, rich = false): Map { + const cells = new Map(); + const span = Math.abs(to - from); + const lo = Math.min(from, to), hi = Math.max(from, to); + const fade = (1 - clamp01(t)) ** 1.3; + for (let column = lo; column <= hi; column += 1) { + if (column === to) continue; + const near = 1 - Math.abs(column - to) / Math.max(1, span); + const level = strength * fade * near ** 1.4; + if (level <= 0.02) continue; + cells.set(column, rich ? {fill: mixRgb(RICH_BASE, color, clamp01(level))} : {tint: {color, amount: clamp01(level)}}); + } + return cells; +} + +/** A foreground tint over [from, to) that decays to nothing; `underline` marks the range while it is still strong. */ +export function decayCells(from: number, to: number, t: number, color: RgbColor, strength: number, underline = false): Map { + const cells = new Map(); + const level = strength * (1 - clamp01(t)) ** 1.6; + if (level <= 0.01) return cells; + for (let column = from; column < to; column += 1) cells.set(column, {tint: {color, amount: level}, ...(underline && level > 0.18 ? {underline: true} : {})}); + return cells; +} + +/** + * Prompt morph over one module's final columns: a wipe whose front reveals the + * settled module; cells ahead of the front are briefly dimmed, a tinted band + * marks the front. The final geometry is used from the first frame. Only the + * module's own cells are ever painted. + */ +export function morphCells(from: number, to: number, t: number, color: RgbColor, expressive: boolean, _change: ModuleChange['change'], gain = 1): Map { + const cells = new Map(); + const width = Math.max(1, to - from); + const front = from + clamp01(t) * (width + 2); + for (let column = from; column < to; column += 1) { + const ahead = column > front; + const distance = Math.abs(column - front); + if (ahead) cells.set(column, {dim: true}); + else if (distance < 2.5) cells.set(column, {tint: {color, amount: Math.min(1, (1 - distance / 2.5) * (expressive ? 0.75 : 0.45) * gain)}, ...(expressive ? {bold: true} : {})}); + } + return cells; +} + +export function progress(transition: Transition, now: number): number { + return clamp01((now - transition.start) / Math.max(1, transition.duration)); +} + +/** + * Semantic diff of two prompt module lists (ids, roles, text): only modules + * whose facts changed, appeared or disappeared. Unchanged modules never move. + */ +export function diffModules(previous: ReadonlyArray<{id: string; text: string; role?: string}>, next: ReadonlyArray<{id: string; text: string; role?: string}>): ModuleChange[] { + const key = (item: {id: string; role?: string}) => `${item.id}:${item.role ?? ''}`; + const before = new Map(previous.map(item => [key(item), item])); + const after = new Map(next.map(item => [key(item), item])); + const changes: ModuleChange[] = []; + for (const [id, item] of after) { + const old = before.get(id); + if (!old) changes.push({id: item.id, text: item.text, change: 'appeared', ...(item.role ? {role: item.role} : {})}); + else if (old.text !== item.text) changes.push({id: item.id, text: item.text, change: 'changed', ...(item.role ? {role: item.role} : {})}); + } + for (const [id, item] of before) if (!after.has(id)) changes.push({id: item.id, text: item.text, change: 'disappeared', ...(item.role ? {role: item.role} : {})}); + return changes; +} + +/** + * Rich rendering: the original filled-band look, recovered from 4b04351. Bands are + * blended against a dark base and set as a cell background (`fill`), so they read + * strongly on an opaque terminal; on a transparent one they show as a box, which is + * why Clean is the default. + */ +const RICH_BASE: RgbColor = {red: 18, green: 18, blue: 22}; + +export function richSweepCells(width: number, t: number, color: RgbColor, strength: number, band = 10): Map { + const cells = new Map(); + const center = -band + t * (width + band * 2); + for (let column = Math.max(0, Math.floor(center - band)); column < Math.min(width, Math.ceil(center + band)); column += 1) { + const falloff = 1 - Math.abs(column - center) / band; + if (falloff > 0) cells.set(column, {fill: mixRgb(RICH_BASE, color, clamp01(falloff * strength))}); + } + return cells; +} + +export function richDecayCells(from: number, to: number, t: number, color: RgbColor, strength: number): Map { + const cells = new Map(); + const level = strength * (1 - clamp01(t)) ** 1.6; + if (level <= 0.01) return cells; + for (let column = from; column < to; column += 1) cells.set(column, {fill: mixRgb(RICH_BASE, color, Math.min(1, level))}); + return cells; +} + +export function richMorphCells(from: number, to: number, t: number, color: RgbColor, expressive: boolean, change: ModuleChange['change'], gain = 1): Map { + const cells = new Map(); + const width = Math.max(1, to - from); + const front = from + clamp01(t) * (width + 2); + for (let column = from; column < to; column += 1) { + const ahead = column > front; + const distance = Math.abs(column - front); + if (ahead) cells.set(column, {fill: mixRgb(RICH_BASE, {red: 0, green: 0, blue: 0}, change === 'disappeared' ? 0.2 : expressive ? 0.5 : 0.35)}); + else if (distance < 2.5) cells.set(column, {fill: mixRgb(RICH_BASE, color, clamp01((1 - distance / 2.5) * (expressive ? 0.75 : 0.5) * gain))}); + } + return cells; +} + +const rich = (look: MotionLook) => look.rendering === 'rich'; +const gain = (look: MotionLook, value: number) => Math.min(1, value * look.intensity); + +/** + * The real per-effect paints, shared by the live frame and the /appearance → + * Motion preview so the two can never drift. Each returns the cells for one + * logical target row at progress t, drawn by the transition's own look + * (Clean or Rich) at its own intensity. + */ +export const transitionPaint = { + /** Command launch over a composer row; `rule` rows (separator, border) take a stronger band. */ + launch: (style: 'sweep' | 'pulse', columns: number, t: number, rule: boolean, look: MotionLook = CLEAN_LOOK) => { + if (rich(look)) return style === 'sweep' ? richSweepCells(columns, t, UI_COLORS.accent, gain(look, rule ? 0.8 : 0.5)) : richDecayCells(0, columns, t, UI_COLORS.accent, gain(look, 0.35)); + return style === 'sweep' ? sweepCells(columns, t, UI_COLORS.accent, gain(look, rule ? 0.7 : 0.45)) : decayCells(0, columns, t, UI_COLORS.accent, gain(look, 0.3)); + }, + /** Completion highlight over the inserted columns [from, to). */ + materialize: (from: number, to: number, t: number, vivid: boolean, look: MotionLook = CLEAN_LOOK) => + rich(look) ? richDecayCells(from, to, t, UI_COLORS.accent, gain(look, vivid ? 0.7 : 0.45)) : decayCells(from, to, t, UI_COLORS.accent, gain(look, vivid ? 0.75 : 0.5), true), + /** The caret-travel trail on the input row between the old and new caret columns. */ + travel: (from: number, to: number, t: number, look: MotionLook = CLEAN_LOOK) => travelCells(from, to, t, UI_COLORS.accent, gain(look, 0.42), rich(look)), + /** Block Seal over a finished block's header row. */ + seal: (tone: Tone, columns: number, t: number, look: MotionLook = CLEAN_LOOK) => rich(look) + ? richSweepCells(columns, t, toneColor(tone), gain(look, tone === 'failure' ? 0.75 : 0.55), tone === 'failure' ? 6 : 12) + : sweepCells(columns, t, toneColor(tone), gain(look, tone === 'failure' ? 0.75 : 0.55), tone === 'failure' ? 6 : 12), + /** Semantic Echo on a rule row; expressive echoes also sweep the input row. */ + echoRule: (event: SemanticEvent, columns: number, t: number, expressive: boolean, look: MotionLook = CLEAN_LOOK) => rich(look) + ? richDecayCells(0, columns, t, toneColor(EVENT_TONES[event]), gain(look, expressive ? 0.6 : 0.4)) + : decayCells(0, columns, t, toneColor(EVENT_TONES[event]), gain(look, expressive ? 0.7 : 0.45)), + echoInput: (event: SemanticEvent, columns: number, t: number, look: MotionLook = CLEAN_LOOK) => rich(look) + ? richSweepCells(columns, t, toneColor(EVENT_TONES[event]), gain(look, 0.35)) : sweepCells(columns, t, toneColor(EVENT_TONES[event]), gain(look, 0.35)), + /** Prompt morph over one module's final columns. */ + morph: (from: number, to: number, t: number, expressive: boolean, change: ModuleChange['change'], look: MotionLook = CLEAN_LOOK) => + rich(look) ? richMorphCells(from, to, t, UI_COLORS.accent, expressive, change, look.intensity) : morphCells(from, to, t, UI_COLORS.accent, expressive, change, look.intensity), +}; diff --git a/src/notifications/commandNotifications.ts b/src/notifications/commandNotifications.ts new file mode 100644 index 00000000..7ce3b245 --- /dev/null +++ b/src/notifications/commandNotifications.ts @@ -0,0 +1,160 @@ +import {spawn, type ChildProcess, type SpawnOptions} from 'node:child_process'; +import {accessSync, constants} from 'node:fs'; +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(protected readonly spawnProcess: SpawnFunction = spawn) {} + + protected command(notification: CommandNotification): [string, string[]] { + return [OSASCRIPT_PATH, osascriptArguments(notification)]; + } + + notify(notification: CommandNotification): Promise { + return new Promise(resolve => { + let child: ChildProcess; + try { + const [command, args] = this.command(notification); + child = this.spawnProcess(command, args, + {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}); + }); + }); + } +} + +/** Freedesktop notifications through notify-send (libnotify); argv only, `--` ends option parsing. */ +export function notifySendArguments(notification: CommandNotification): string[] { + return ['--app-name=NMSh', '--', `${notification.title}: ${notification.subtitle}`, notification.body]; +} + +export class LinuxNotificationService extends MacNotificationService { + constructor(private readonly notifySend: string, spawnProcess: SpawnFunction = spawn) { super(spawnProcess); } + protected override command(notification: CommandNotification): [string, string[]] { + return [this.notifySend, notifySendArguments(notification)]; + } +} + +export class UnsupportedNotificationService implements NotificationService { + readonly supported = false; + notify(): Promise { + return Promise.resolve({ok: false, reason: 'unsupported'}); + } +} + +/** + * macOS: osascript. Linux (including WSL 2 with WSLg): notify-send when it is + * installed and a desktop session is reachable; otherwise unsupported. Nothing + * is installed. + */ +export function createNotificationService(platform: NodeJS.Platform = process.platform, spawnProcess?: SpawnFunction, + env: NodeJS.ProcessEnv = process.env): NotificationService { + if (platform === 'darwin') return new MacNotificationService(spawnProcess); + if (platform === 'linux' && (env.DBUS_SESSION_BUS_ADDRESS || env.WAYLAND_DISPLAY || env.DISPLAY)) { + const notifySend = (env.PATH ?? '').split(':').filter(dir => dir.startsWith('/')).map(dir => `${dir}/notify-send`).find(path => { + try { accessSync(path, constants.X_OK); return true; } catch { return false; } + }); + if (notifySend) return new LinuxNotificationService(notifySend, spawnProcess); + } + return new UnsupportedNotificationService(); +} diff --git a/src/output/AnsiOutputParser.ts b/src/output/AnsiOutputParser.ts index b0edafeb..1c17ba42 100644 --- a/src/output/AnsiOutputParser.ts +++ b/src/output/AnsiOutputParser.ts @@ -4,6 +4,32 @@ export interface StyledCell { text: string; width: number; style: string; + /** Original program-emitted OSC 8, never generated presentation. */ + hyperlink?: string; + /** + * The link was authored by NMSh itself (help, docs, task URLs, NMSh-written + * files) through addAuthoredLine and passed the authored-target check. + * Raw PTY links never carry this mark. + */ + authored?: true; +} + +/** Targets NMSh may author: http(s) without credentials, and local file URLs. */ +export function authoredTargetAllowed(target: string): boolean { + if (!target || target.length > 4096 || /[\u0000-\u0020\u007f-\u009f]/u.test(target)) return false; + try { + const url = new URL(target); + if (url.protocol === 'file:') return !url.hostname; + return (url.protocol === 'http:' || url.protocol === 'https:') && Boolean(url.hostname) && !url.username && !url.password; + } catch { return false; } +} + +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 +42,10 @@ export class AnsiOutputParser { private column = 0; private style = ''; private pending = ''; + private hyperlink?: string; + /** While writing an NMSh-authored line: OSC 8 becomes an authored link only for allowed targets. */ + private authoring = false; + private discardingOsc = false; constructor(private readonly onClear?: () => void) {} @@ -23,6 +53,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 +68,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,21 +109,29 @@ 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); } } } + /** An NMSh-authored line: its OSC 8 links are marked authored when the target is allowed, dropped otherwise. */ + addAuthoredLine(text: string, style = ''): void { + this.authoring = true; + try { this.addLine(text, style); } finally { this.authoring = false; } + } + 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 +148,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 +169,8 @@ export class AnsiOutputParser { this.column = oldColumn; this.style = oldStyle; this.pending = oldPending; + this.hyperlink = oldHyperlink; + this.discardingOsc = oldDiscardingOsc; } completedCount(): number { @@ -160,6 +217,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 +248,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) && (!this.authoring || authoredTargetAllowed(target)) + ? 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 +292,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.authoring ? {authored: true as const} : {})} : {})}; + 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/FoldPolicy.ts b/src/output/FoldPolicy.ts index e9aeda1f..e6ffe2d8 100644 --- a/src/output/FoldPolicy.ts +++ b/src/output/FoldPolicy.ts @@ -147,3 +147,53 @@ export function shouldAutoFold(mode: OutputFoldingMode, input: FoldInput): boole export function foldWindow(lineCount: number): {head: number; tail: number} { return lineCount >= FOLD_HEAD_LINES + FOLD_TAIL_LINES + 5 ? {head: FOLD_HEAD_LINES, tail: FOLD_TAIL_LINES} : {head: 0, tail: 0}; } + +/** Advisory semantic hint from optional local understanding: never content, only a classification. */ +export interface SemanticFoldHint {kind: 'noise' | 'progress' | 'test-detail' | 'summary' | 'warning' | 'error' | 'mixed'; confidence: number} + +/** Deterministic decisions close to the threshold are the only ones a hint may tip. */ +export const HINT_BAND = {low: -1, high: FOLD_THRESHOLD} as const; + +export function hintEligible(mode: OutputFoldingMode, input: FoldInput): boolean { + if (mode !== 'smart' || input.exitCode !== 0 || !isFoldable(input.lineCount)) return false; + const decision = evaluateFold(input); + return decision.score >= HINT_BAND.low && decision.score < HINT_BAND.high + 2 && !decision.reasons.some(reason => /error-like|stack trace|compiler|diff-like/u.test(reason)); +} + +/** + * Applying a hint to the deterministic decision. Biased toward keeping output + * visible: an error or warning hint always expands; a noise hint folds only a + * borderline block with no failure signals and only when confident. A hint + * never changes content, only whether the block starts collapsed. + */ +export function applyFoldHint(input: FoldInput, hint: SemanticFoldHint | undefined): boolean { + const decision = evaluateFold(input); + if (!hint) return decision.fold; + if (decision.reasons.some(reason => /error-like|stack trace|compiler|diff-like/u.test(reason))) return false; + if ((hint.kind === 'error' || hint.kind === 'warning' || hint.kind === 'summary') && hint.confidence >= 0.5) return false; + if ((hint.kind === 'noise' || hint.kind === 'progress' || hint.kind === 'test-detail') && hint.confidence >= 0.75 && decision.score >= HINT_BAND.low) return true; + return decision.fold; +} + +/** One visible Ask turn as recorded: plain text only, never model data. */ +export interface RecordedAskTurn {role: 'you' | 'ask'; text: string} + +/** Ask answers at or below this many lines stay open in Smart. */ +export const ASK_SMART_LINES = 4; + +/** + * Folding for a recorded Ask conversation, from its structure rather than + * the shell-output heuristic: Smart keeps one short exchange open and folds a + * multi-turn or long conversation; Always folds any conversation with an + * answer; Off never folds. + */ +export function shouldFoldAsk(mode: OutputFoldingMode, turns: readonly RecordedAskTurn[]): boolean { + if (mode === 'never') return false; + const answers = turns.filter(turn => turn.role === 'ask'); + if (!answers.length) return false; + if (mode === 'always') return true; + const asked = turns.filter(turn => turn.role === 'you').length; + const lines = turns.reduce((sum, turn) => sum + turn.text.split('\n').length, 0); + // Smart: a short exchange (one or two questions) stays open; longer chatter folds by turns or length, not shell line thresholds. + return asked > 2 || lines > ASK_SMART_LINES; +} diff --git a/src/output/Hyperlinks.ts b/src/output/Hyperlinks.ts new file mode 100644 index 00000000..35dfd114 --- /dev/null +++ b/src/output/Hyperlinks.ts @@ -0,0 +1,137 @@ +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; + } +} + +const OSC8_CLOSE = '\u001B]8;;\u001B\\'; + +/** + * An NMSh-authored link for live UI rows (task URLs, docs): the text wrapped + * in OSC 8 only when the host supports links and the target is safe; plain + * text otherwise. Callers that truncate append `closeAuthoredLinks` so a cut + * never leaves a link open. + */ +export function authoredLink(text: string, target: string, enabled: boolean): string { + const safe = enabled ? safeHyperlinkTarget(target) : undefined; + return safe ? `\u001B]8;;${safe}\u001B\\${text}${OSC8_CLOSE}` : text; +} + +export function closeAuthoredLinks(row: string): string { + return row.includes('\u001B]8;;') ? `${row}${OSC8_CLOSE}` : row; +} diff --git a/src/output/OutputBuffer.ts b/src/output/OutputBuffer.ts index 78fb9b47..ca45fd08 100644 --- a/src/output/OutputBuffer.ts +++ b/src/output/OutputBuffer.ts @@ -1,3 +1,4 @@ +import {applyOutputFilter, type OutputFilter} from './TranscriptSearch.js'; import {AnsiOutputParser, type SerializedLine} from './AnsiOutputParser.js'; import {type WrappedRow} from './viewport.js'; import {foreground, UI_COLORS} from '../ui/palette.js'; @@ -5,7 +6,7 @@ import {GLYPHS} from '../ui/glyphs.js'; import {PresentationMode} from './PresentationMode.js'; import {CommandClassifier} from './Classifier.js'; import {type WelcomeCatFrame, type WelcomeSnapshot} from './Welcome.js'; -import {shouldAutoFold, type OutputFoldingMode} from './FoldPolicy.js'; +import {shouldAutoFold, shouldFoldAsk, type OutputFoldingMode, type RecordedAskTurn} from './FoldPolicy.js'; import {type PromptSnapshot} from '../prompt/snapshot.js'; import {type TranscriptAppearance} from '../prompt/configuration.js'; import {TranscriptPresenter, type TranscriptView} from './TranscriptPresenter.js'; @@ -17,6 +18,8 @@ export interface HistoricalContextSnapshot { project?: string; branch?: string; prompt?: PromptSnapshot; + /** Submitted under Prompt None: there was no prompt, so history renders none (never a substituted Native one). */ + promptless?: true; } export interface SecondaryActivity { @@ -32,6 +35,10 @@ export interface SecondaryActivity { } export interface CompletedCommand { + /** Only explicitly eligible commands enter command history; transcript retention is separate. */ + historyEligible?: boolean; + startedAt?: number; + durationMs?: number; command: string; output: string; lifecycleText: string; @@ -44,6 +51,10 @@ export interface CompletedCommand { mode?: PresentationMode; historicalContext?: HistoricalContextSnapshot; activities?: SecondaryActivity[]; + /** An NMSh-owned block rather than a shell command: never history, /copy or shell-output folding input. */ + frontend?: 'ask'; + /** A recorded Ask conversation: its visible turns as plain text (version 1). Older transcripts lack it. */ + ask?: {version: 1; turns: RecordedAskTurn[]}; } export interface OutputTranscript { @@ -98,6 +109,7 @@ export class OutputBuffer { ...record, historicalContext: record.historicalContext ? structuredClone(record.historicalContext) : undefined, activities: record.activities?.map(activity => ({...activity})), + ...(record.ask ? {ask: {version: 1 as const, turns: record.ask.turns.map(turn => ({role: turn.role, text: turn.text}))}} : {}), })), lines: this.parser.snapshot(), visualGaps: [...this.visualGaps], @@ -280,7 +292,11 @@ export class OutputBuffer { this.parser.addLine(text, style); } - addFrontendInteraction(command: string, result: string, resultStyle = ''): void { + /** + * `authoredLinks`: the result was rendered by NMSh (for example /help) and + * may carry NMSh-authored OSC 8 links; they are kept as authored cells. + */ + addFrontendInteraction(command: string, result: string, resultStyle = '', authoredLinks = false): void { this.parser.ensureLineBoundary(); if (this.parser.completedCount() > 0) { this.visualGaps.add(this.parser.completedCount()); @@ -288,7 +304,61 @@ export class OutputBuffer { this.lineTypes.set(this.parser.completedCount(), 'metadata'); this.parser.addLine(`${GLYPHS.prompt} ${command}`, foreground(UI_COLORS.command)); this.lineTypes.set(this.parser.completedCount(), 'metadata'); - this.parser.addLine(` ${GLYPHS.info} ${result}`, resultStyle); + if (authoredLinks) this.parser.addAuthoredLine(` ${GLYPHS.info} ${result}`, resultStyle); + else this.parser.addLine(` ${GLYPHS.info} ${result}`, resultStyle); + } + + /** + * A recorded Ask conversation as one foldable block: the request is its + * header line and each visible turn follows as plain rows. Folding comes + * from the conversation's structure (shouldFoldAsk), and Ctrl+O and the + * user's own fold choices apply as for any block. While a shell command is + * running, the conversation is added as plain rows instead. + */ + addAskInteraction(request: string, turns: readonly RecordedAskTurn[], command: '/btw' | '/ask' = '/btw'): void { + const body = turns.length ? turns : [{role: 'ask' as const, text: 'Closed without an answer.'}]; + if (this.active) { + this.addFrontendInteraction(`${command} ${request}`, body.map(turn => turn.text.split('\n').map((line, index) => `${index === 0 ? (turn.role === 'you' ? 'You ' : 'Ask ') : ' '}${line}`).join('\n')).join('\n'), ''); + return; + } + this.parser.ensureLineBoundary(); + if (this.parser.completedCount() > 0) this.visualGaps.add(this.parser.completedCount()); + const startId = this.parser.completedCount(); + this.lineTypes.set(startId, 'metadata'); + this.parser.addLine(`${GLYPHS.prompt} ${command} ${request}`, foreground(UI_COLORS.command)); + const outputStartId = this.parser.completedCount(); + const plain: string[] = []; + // Compact exchanges: a role column, then the turn's own lines; a blank row separates exchanges. + body.forEach((turn, turnIndex) => { + if (turn.role === 'you' && turnIndex > 0) { plain.push(''); this.lineTypes.set(this.parser.completedCount(), 'metadata'); this.parser.addLine('', ''); } + turn.text.split('\n').forEach((line, index) => { + const row = ` ${index === 0 ? (turn.role === 'you' ? 'You ' : 'Ask ') : ' '}${line}`; + plain.push(row); + this.lineTypes.set(this.parser.completedCount(), 'metadata'); + this.parser.addLine(row, turn.role === 'you' ? foreground(UI_COLORS.primary) : foreground(UI_COLORS.secondary)); + }); + }); + const endId = this.parser.completedCount(); + this.completed.unshift({command: `${command} ${request}`, output: plain.join('\n'), lifecycleText: '', exitCode: 0, startId, outputStartId, endId, + expanded: !shouldFoldAsk(this.outputFolding, body), frontend: 'ask', ask: {version: 1, turns: body.map(turn => ({role: turn.role, text: turn.text}))}}); + } + + /** The index-th newest shell command record (NMSh-owned blocks such as recorded Ask are skipped). */ + recentShell(index: number): CompletedCommand | undefined { + return this.completed.filter(record => !record.frontend)[index - 1]; + } + + /** A multi-row NMSh-owned result (for example /agents); presentation rows, never shell output. */ + addFrontendBlock(command: string, rows: readonly string[], authoredLinks = false): void { + this.parser.ensureLineBoundary(); + if (this.parser.completedCount() > 0) this.visualGaps.add(this.parser.completedCount()); + this.lineTypes.set(this.parser.completedCount(), 'metadata'); + this.parser.addLine(`${GLYPHS.prompt} ${command}`, foreground(UI_COLORS.command)); + for (const row of rows) { + this.lineTypes.set(this.parser.completedCount(), 'metadata'); + if (authoredLinks) this.parser.addAuthoredLine(` ${row}`, ''); + else this.parser.addLine(` ${row}`, ''); + } } recent(index: number): CompletedCommand | undefined { @@ -297,9 +367,30 @@ export class OutputBuffer { /** Rows for this transcript as the owned presenter draws them. */ wrapped(width: number): WrappedRow[] { - return this.presenter.rows(this.view(), width); + const rows = this.presenter.rows(this.view(), width); + if (!this.outputFilter) return rows; + // Cached per presented rows + filter, so frames and hit-tests never re-filter. + if (this.filterCache?.rows === rows && this.filterCache.filter === this.outputFilter) return this.filterCache.result; + // Presentation only: stored lines, records and /copy payloads are untouched. + const result = applyOutputFilter(rows, this.outputFilter, line => !this.lineTypes.has(line)); + this.filterCache = {rows, filter: this.outputFilter, result: result.rows}; + this.filterStatus = {kept: result.kept, total: result.total, ...(result.error ? {error: result.error} : {})}; + return result.rows; + } + + private outputFilter?: OutputFilter; + private filterCache?: {rows: WrappedRow[]; filter: OutputFilter; result: WrappedRow[]}; + /** Last applied filter's counts, for status text. */ + filterStatus?: {kept: number; total: number; error?: string}; + + /** Show only matching output lines of one block (presentation only); undefined clears it. */ + setOutputFilter(filter: OutputFilter | undefined): void { + this.outputFilter = filter; + if (!filter) this.filterStatus = undefined; } + get activeFilter(): OutputFilter | undefined { return this.outputFilter; } + /** Read-only view of the transcript data for presentation. */ view(): TranscriptView { return { @@ -350,9 +441,24 @@ export class OutputBuffer { const cmd = this.completed[commandIndex]; if (cmd) { cmd.expanded = !cmd.expanded; + this.userToggled.add(cmd.startId); } } + /** Blocks the user expanded or collapsed themselves; advisory hints never override them. */ + private readonly userToggled = new Set(); + + /** + * Advisory folding from a late semantic hint: only for a block the user has + * not touched. Presentation only; the output itself is never changed. + */ + applyAdvisoryFold(startId: number, folded: boolean): boolean { + const record = this.completed.find(item => item.startId === startId); + if (!record || this.userToggled.has(startId) || record.activities?.length || record.expanded === !folded) return false; + record.expanded = !folded; + return true; + } + toggleActivityExpanded(activityId: string): void { const activities = [ ...this.completed.flatMap(command => command.activities ?? []), diff --git a/src/output/SearchChrome.ts b/src/output/SearchChrome.ts new file mode 100644 index 00000000..24125a33 --- /dev/null +++ b/src/output/SearchChrome.ts @@ -0,0 +1,101 @@ +import {displayWidth, truncateText} from '../util/text.js'; +import type {FilterClause, FindClause} from './TranscriptSearch.js'; + +/** + * The find/filter status above the composer: Find on the left, Filter on the + * right, at most TWO rows. At most two clauses per side are visible; the + * second visible row carries `+N more` for the hidden ones. Narrow terminals + * degrade to truncated terms, then compact summaries. Plain text in, styled + * by the caller's palette. + */ + +export interface ChromeStyle { + accent: string; + primary: string; + secondary: string; + subtle: string; + error: string; + reset: string; +} + +export interface FindChromeState { + clauses: readonly FindClause[]; + /** The clause being typed (Ctrl+F / /find), shown with a caret. */ + editing?: FindClause; + count: string; + error?: boolean; +} + +export interface FilterChromeState { + clauses: readonly FilterClause[]; +} + +/** Concise metadata tags: [case] [regex] [invert] [±N]. */ +export function clauseTags(clause: {options: {regex: boolean; caseSensitive: boolean}; invert?: boolean; context?: number}): string { + return [clause.options.caseSensitive ? '[case]' : '', clause.options.regex ? '[regex]' : '', clause.invert ? '[invert]' : '', + clause.context ? `[±${clause.context}]` : ''].filter(Boolean).join(' '); +} + +interface Cell { text: string; style: 'term' | 'editing' | 'meta' } + +/** Up to two visible lines for one side; the second gets `+N more` when clauses are hidden. */ +function sideLines(items: Array<{text: string; tags: string; editing?: boolean}>, termWidth: number, firstSuffix: string): Cell[][] { + if (!items.length) return []; + // Keep the clause being edited visible: it takes the second slot when there are more than two. + const editing = items.findIndex(item => item.editing); + const visible = items.length <= 2 ? items : editing >= 0 ? [items[0]!, items[editing]!] : [items[0]!, items[1]!]; + const hidden = items.length - visible.length; + return visible.map((item, index) => { + const cells: Cell[] = [{text: truncateText(item.text || ' ', Math.max(3, termWidth)) + (item.editing ? '_' : ''), style: item.editing ? 'editing' : 'term'}]; + if (item.tags) cells.push({text: ` ${item.tags}`, style: 'meta'}); + if (index === 1 && hidden > 0) cells.push({text: ` +${hidden} more`, style: 'meta'}); + if (index === 0 && firstSuffix) cells.push({text: ` ${firstSuffix}`, style: 'meta'}); + return cells; + }); +} + +const width = (cells: Cell[]) => cells.reduce((sum, cell) => sum + displayWidth(cell.text), 0); + +function paint(cells: Cell[], style: ChromeStyle, marker: string, error = false): string { + return `${style.accent}${marker}${style.reset} ${cells.map(cell => `${cell.style === 'editing' ? style.primary : cell.style === 'term' ? style.secondary : error ? style.error : style.subtle}${cell.text}${style.reset}`).join('')}`; +} + +/** + * Rows for the chrome region (0, 1 or 2). `columns` is the full width. + * Degradation: shorter terms → compact `N terms` / `N filters` summaries. + */ +export function searchChromeRows(find: FindChromeState | undefined, filter: FilterChromeState | undefined, columns: number, style: ChromeStyle, + glyphs: {find: string; filter: string} = {find: '⌕', filter: '⧩'}): string[] { + const findItems = find ? [...find.clauses.map(clause => ({text: clause.query, tags: clauseTags(clause)})), + ...(find.editing ? [{text: find.editing.query, tags: clauseTags(find.editing), editing: true}] : [])] : []; + const filterItems = filter ? filter.clauses.map(clause => ({text: clause.query, tags: clauseTags(clause)})) : []; + if (!findItems.length && !filterItems.length) return []; + const both = findItems.length > 0 && filterItems.length > 0; + const sideWidth = both ? Math.floor((columns - 3) / 2) : columns; + const findSuffix = find ? find.count : ''; + for (const termWidth of [32, 20, 12, 6]) { + const left = sideLines(findItems, termWidth, findSuffix); + const right = sideLines(filterItems, termWidth, ''); + const fits = (lines: Cell[][]) => lines.every(line => width(line) + 2 <= sideWidth); + if (!fits(left) || !fits(right)) continue; + const rows: string[] = []; + for (let index = 0; index < Math.max(left.length, right.length); index += 1) { + const l = left[index]; + const r = right[index]; + const leftText = l ? paint(l, style, glyphs.find, find?.error && index === 0) : ''; + const rightText = r ? paint(r, style, glyphs.filter) : ''; + const leftWidth = l ? width(l) + 2 : 0; + const rightWidth = r ? width(r) + 2 : 0; + const gap = Math.max(1, columns - leftWidth - rightWidth); + rows.push(r ? `${leftText}${' '.repeat(gap)}${rightText}` : leftText); + } + return rows; + } + // Compact summaries on one row. + const summary = (count: number, noun: string, suffix = '') => `${count} ${noun}${count === 1 ? '' : 's'}${suffix ? ` · ${suffix}` : ''}`; + const left = findItems.length ? paint([{text: summary(findItems.length, 'term', findSuffix), style: 'meta'}], style, glyphs.find) : ''; + const right = filterItems.length ? paint([{text: summary(filterItems.length, 'filter'), style: 'meta'}], style, glyphs.filter) : ''; + const leftWidth = findItems.length ? displayWidth(summary(findItems.length, 'term', findSuffix)) + 2 : 0; + const rightWidth = filterItems.length ? displayWidth(summary(filterItems.length, 'filter')) + 2 : 0; + return [right ? `${left}${' '.repeat(Math.max(1, columns - leftWidth - rightWidth))}${right}` : left]; +} diff --git a/src/output/TranscriptPanel.ts b/src/output/TranscriptPanel.ts index 92c4b7db..db9ff379 100644 --- a/src/output/TranscriptPanel.ts +++ b/src/output/TranscriptPanel.ts @@ -1,4 +1,11 @@ +import {DEFAULT_TREATMENT_SETTINGS, type TreatmentSettings} from '../chroma/treatment.js'; import { + TRANSCRIPT_PRESENTATION_LABELS, TRANSCRIPT_PRESENTATIONS, type TranscriptPresentation, + DIVIDER_COLOR_LABELS, + HISTORICAL_PROMPT_LEVEL_LABELS, + HISTORICAL_PROMPT_LEVELS, + type HistoricalPromptLevel, + DIVIDER_COLOR_MODES, NATIVE_PALETTE_IDS, type HistoryColorMode, type TranscriptAppearance, @@ -7,24 +14,32 @@ import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js'; import type {Key} from '../terminal/keys.js'; import {DRAFT_PANEL_ACTIONS, renderActionHelp} from '../ui/actions.js'; import {GLYPHS} from '../ui/glyphs.js'; -import {foreground, UI_COLORS} from '../ui/palette.js'; +import {focusForeground, foreground, UI_COLORS, lazyForeground} from '../ui/palette.js'; import {truncateAnsi} from '../util/text.js'; -import {renderHistoricalContext, type HistoricalContextSnapshot} from './OutputBuffer.js'; +import {OutputBuffer, renderHistoricalContext, type HistoricalContextSnapshot} from './OutputBuffer.js'; +import {FOLD_HEAD_LINES, FOLD_TAIL_LINES, OUTPUT_FOLDING_MODES, type OutputFoldingMode} from './FoldPolicy.js'; export interface TranscriptPanelState { selectedIndex: number; draft: TranscriptAppearance; /** The appearance currently in effect; the draft is only a preview until saved. */ saved: TranscriptAppearance; + /** + * Output folding: the same root `outputFolding` setting as Config and the + * command palette, edited here as a draft. Absent: the row is not shown. + */ + folding?: {draft: OutputFoldingMode; saved: OutputFoldingMode}; + /** Draft of the root transcriptPresentation setting, shared with Layout/Settings. */ + presentation?: {draft: TranscriptPresentation; saved: TranscriptPresentation}; message?: string; } -type Row = 'divider' | 'density' | 'prompt' | 'colors' | 'theme'; +type Row = 'presentation' | 'divider' | 'density' | 'dividerColors' | 'prompt' | 'colors' | 'theme' | 'folding'; -const PRIMARY = foreground(UI_COLORS.primary); -const SECONDARY = foreground(UI_COLORS.secondary); -const ACCENT = foreground(UI_COLORS.accent); -const SUBTLE = foreground(UI_COLORS.subtle); +const PRIMARY = lazyForeground(UI_COLORS.primary); +const SECONDARY = lazyForeground(UI_COLORS.secondary); +const ACCENT = lazyForeground(UI_COLORS.accent); +const SUBTLE = lazyForeground(UI_COLORS.subtle); const RESET = '\u001B[0m'; const COLOR_MODES: readonly HistoryColorMode[] = ['followPrompt', 'theme', 'grayscale']; @@ -36,9 +51,14 @@ function onOff(value: boolean): string { return value ? 'On' : 'Off'; } +export function foldingLabel(mode: OutputFoldingMode): string { + return mode === 'never' ? 'Off' : mode === 'smart' ? 'Smart' : 'Always'; +} + /** Editable rows; the theme row exists only while Choose theme is selected. */ function rows(state: TranscriptPanelState): Row[] { - return ['divider', 'density', 'prompt', 'colors', ...(state.draft.historyColors === 'theme' ? ['theme' as const] : [])]; + return [...(state.presentation ? ['presentation' as const] : []), 'divider', 'density', ...(state.draft.divider ? ['dividerColors' as const] : []), 'prompt', 'colors', ...(state.draft.historyColors === 'theme' ? ['theme' as const] : []), + ...(state.folding ? ['folding' as const] : [])]; } function cycle(values: readonly T[], current: T, delta: number): T { @@ -46,8 +66,16 @@ function cycle(values: readonly T[], current: T, delta: number): T { return values[(index + delta + values.length) % values.length]!; } +const PROMPT_LEVELS = [...HISTORICAL_PROMPT_LEVELS, 'off'] as const; + +/** Full / Compact / Minimal while on, Off otherwise: one presentation choice over the two stored fields. */ +export function historicalPromptLevel(appearance: TranscriptAppearance): HistoricalPromptLevel | 'off' { + return appearance.historicalPrompt ? appearance.historicalPromptLevel ?? 'full' : 'off'; +} + export function transcriptDraftChanged(state: TranscriptPanelState): boolean { - return JSON.stringify(state.draft) !== JSON.stringify(state.saved); + return JSON.stringify(state.draft) !== JSON.stringify(state.saved) || state.folding?.draft !== state.folding?.saved + || state.presentation?.draft !== state.presentation?.saved; } export function handleTranscriptPanelKey(key: Key, state: TranscriptPanelState): boolean { @@ -58,11 +86,19 @@ export function handleTranscriptPanelKey(key: Key, state: TranscriptPanelState): const delta = key.kind === 'left' ? -1 : 1; const draft = state.draft; switch (available[state.selectedIndex]) { + case 'presentation': state.presentation!.draft = cycle(TRANSCRIPT_PRESENTATIONS, state.presentation!.draft, delta); break; case 'divider': draft.divider = !draft.divider; break; case 'density': draft.dividerDensity = draft.dividerDensity === 'compact' ? 'normal' : 'compact'; break; - case 'prompt': draft.historicalPrompt = !draft.historicalPrompt; break; + case 'dividerColors': draft.dividerColors = cycle(DIVIDER_COLOR_MODES, draft.dividerColors, delta); break; + case 'prompt': { + const level = cycle(PROMPT_LEVELS, historicalPromptLevel(draft), delta); + draft.historicalPrompt = level !== 'off'; + if (level !== 'off') draft.historicalPromptLevel = level; + break; + } case 'colors': draft.historyColors = cycle(COLOR_MODES, draft.historyColors, delta); break; case 'theme': draft.historyTheme = cycle(NATIVE_PALETTE_IDS, draft.historyTheme, delta); break; + case 'folding': state.folding!.draft = cycle(OUTPUT_FOLDING_MODES, state.folding!.draft, delta); break; default: return false; } state.selectedIndex = Math.min(state.selectedIndex, rows(state).length - 1); @@ -75,28 +111,41 @@ export function handleTranscriptPanelKey(key: Key, state: TranscriptPanelState): * `sample` is a representative historical context (a semantic prompt * snapshot); previews render it through the real history-header renderer. */ -export function renderTranscriptPanel(state: TranscriptPanelState, columns: number, sample: HistoricalContextSnapshot, rowsAvailable = Infinity): string[] { +export function renderTranscriptPanel(state: TranscriptPanelState, columns: number, sample: HistoricalContextSnapshot, rowsAvailable = Infinity, + treatment: TreatmentSettings = DEFAULT_TREATMENT_SETTINGS): string[] { const {draft, saved} = state; const width = Math.max(1, columns - 2); - const out = [`${PRIMARY} Transcript appearance${RESET}`, '']; + const out = [`${PRIMARY} Transcript${RESET}`, '']; const available = rows(state); const value = (text: string, savedText: string) => text === savedText ? `‹ ${text} ›` : `‹ ${text} › ${SUBTLE}saved: ${savedText}`; const labels: Record = { + presentation: state.presentation ? `Presentation ${value(TRANSCRIPT_PRESENTATION_LABELS[state.presentation.draft], TRANSCRIPT_PRESENTATION_LABELS[state.presentation.saved])}` : '', divider: `Divider ${value(onOff(draft.divider), onOff(saved.divider))}`, density: `Divider density ${value(draft.dividerDensity === 'compact' ? 'Compact' : 'Normal', saved.dividerDensity === 'compact' ? 'Compact' : 'Normal')}`, - prompt: `Historical prompt ${value(onOff(draft.historicalPrompt), onOff(saved.historicalPrompt))}`, + dividerColors: `Divider colors ${value(DIVIDER_COLOR_LABELS[draft.dividerColors], DIVIDER_COLOR_LABELS[saved.dividerColors])}`, + prompt: `Historical prompt ${value(HISTORICAL_PROMPT_LEVEL_LABELS[historicalPromptLevel(draft)], HISTORICAL_PROMPT_LEVEL_LABELS[historicalPromptLevel(saved)])}`, colors: `History colors ${value(colorModeLabel(draft.historyColors), colorModeLabel(saved.historyColors))}`, theme: `History theme ${value(NATIVE_PROMPT_THEMES[draft.historyTheme].label, NATIVE_PROMPT_THEMES[saved.historyTheme].label)}`, + folding: state.folding ? `Output folding ${value(foldingLabel(state.folding.draft), foldingLabel(state.folding.saved))}` : '', }; available.forEach((row, index) => { + if (row === 'presentation') out.push(`${PRIMARY} Live presentation${RESET}`); + if (row === 'divider' && state.presentation) out.push('', `${PRIMARY} History${RESET}`); const selected = index === state.selectedIndex; - out.push(`${selected ? `${ACCENT}›` : ' '} ${selected ? ACCENT : SECONDARY}${labels[row]}${RESET}`); + out.push(`${selected ? `${ACCENT}›` : ' '} ${focusForeground(selected)}${labels[row]}${RESET}`); }); - const preview = (appearance: TranscriptAppearance) => renderHistoricalContext(sample, width - 2, appearance)?.ansi; const gallery: string[] = []; + if (available[state.selectedIndex] === 'dividerColors') { + // Every choice through the real history-header renderer; history dividers never move. + gallery.push('', `${PRIMARY}Divider colors${RESET} ${SUBTLE}● selected · history stays static${RESET}`); + for (const mode of DIVIDER_COLOR_MODES) { + const row = renderHistoricalContext(sample, Math.max(1, width - 21), {...draft, dividerColors: mode}, treatment); + gallery.push(`${draft.dividerColors === mode ? `${ACCENT}●` : `${SUBTLE}○`} ${SECONDARY}${DIVIDER_COLOR_LABELS[mode].padEnd(18)}${RESET} ${row?.ansi ?? ''}${RESET}`); + } + } if (draft.historyColors === 'theme') { gallery.push('', `${PRIMARY}History themes${RESET} ${SUBTLE}● selected ✓ saved${RESET}`); for (const id of NATIVE_PALETTE_IDS) { @@ -110,15 +159,40 @@ export function renderTranscriptPanel(state: TranscriptPanelState, columns: numb const sampleRows: string[] = ['']; sampleRows.push(`${PRIMARY}Preview${RESET} ${transcriptDraftChanged(state) ? `${ACCENT}unsaved preview` : `${SUBTLE}matches current`}${RESET}`); - for (const [command, output] of [['git status', 'On branch main'], ['npm test', '✔ 42 passing']] as const) { - const header = preview(draft); - if (header) sampleRows.push(` ${header}`); - sampleRows.push(` ${SECONDARY}${GLYPHS.prompt} ${command}${RESET}`, ` ${SUBTLE}${output}${RESET}`); + const sampleOutput = new OutputBuffer(); + sampleOutput.setTranscriptAppearance(draft); + sampleOutput.presenter.setTreatment(treatment); + sampleOutput.presenter.setLayout(state.presentation?.draft ?? 'normal'); + for (const [command, output] of [['git status', 'On branch main'], ['npm test', '✔ passing']] as const) { + sampleOutput.beginCommand(command, [`${SECONDARY}${GLYPHS.prompt} ${command}${RESET}`], undefined, sample); + sampleOutput.write(`${output}\r\n`); + sampleOutput.complete(0); } - if (state.message) sampleRows.push(`${SECONDARY}${state.message}${RESET}`); - const controls = ['', renderActionHelp(DRAFT_PANEL_ACTIONS)]; + const transcriptRows = sampleOutput.wrapped(Math.max(1, width - 2)).map(row => ` ${row.ansi}`); + const foldingRows = state.folding ? foldingPreview(state.folding.draft) : []; + // Keep the selected control's preview first when a short window cannot show both. + sampleRows.push(...(available[state.selectedIndex] === 'folding' ? [...foldingRows, ...transcriptRows] : [...transcriptRows, ...foldingRows])); + const controls = ['', ...(state.message ? [`${SECONDARY}${state.message}${RESET}`, ''] : []), renderActionHelp(DRAFT_PANEL_ACTIONS)]; // Short terminals keep the editable rows, preview, and controls; the gallery goes first. const includeGallery = out.length + gallery.length + sampleRows.length + controls.length <= rowsAvailable; - return [...out, ...(includeGallery ? gallery : []), ...sampleRows, ...controls].map(row => truncateAnsi(row, columns)); + const shownGallery = includeGallery ? gallery : []; + const previewBudget = Math.max(0, rowsAvailable - out.length - shownGallery.length - controls.length); + return [...out, ...shownGallery, ...sampleRows.slice(0, previewBudget), ...controls].map(row => truncateAnsi(row, columns)); +} + +const FOLD_NOTES: Record = { + never: 'long output always stays expanded', + smart: 'long, repetitive successful output starts folded; failures stay open', + always: 'every long block starts folded, failures included', +}; + +/** A long successful block as this mode would present it on completion (Space/Tab still expands it). */ +export function foldingPreview(mode: OutputFoldingMode): string[] { + const rows = [` ${SECONDARY}${GLYPHS.prompt} npm install${RESET} ${SUBTLE}${FOLD_NOTES[mode]}${RESET}`]; + const lines = Array.from({length: 120}, (_, index) => `added package-${index + 1}`); + if (mode === 'never') return [...rows, ...lines.slice(0, 3).map(line => ` ${SUBTLE}${line}${RESET}`), ` ${SUBTLE}… 117 more lines${RESET}`]; + const hidden = lines.length - FOLD_HEAD_LINES - FOLD_TAIL_LINES; + return [...rows, ...lines.slice(0, FOLD_HEAD_LINES).map(line => ` ${SUBTLE}${line}${RESET}`), + ` ${ACCENT}${hidden} lines hidden · Ctrl+O ›${RESET}`, ...lines.slice(-FOLD_TAIL_LINES).map(line => ` ${SUBTLE}${line}${RESET}`)]; } diff --git a/src/output/TranscriptPresenter.ts b/src/output/TranscriptPresenter.ts index 583d29ac..f20aba73 100644 --- a/src/output/TranscriptPresenter.ts +++ b/src/output/TranscriptPresenter.ts @@ -1,12 +1,17 @@ +import {askFoldLabel} from '../ask/transcriptSummary.js'; +import {paintDivider, 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'; +import {background, foreground, UI_COLORS, lazyForeground} from '../ui/palette.js'; +import {mixRgb} from '../chroma/chroma.js'; import {GLYPHS} from '../ui/glyphs.js'; import {displayWidth, repeatToWidth, stripAnsi, truncateAnsi, truncateText} from '../util/text.js'; import {formatDuration} from '../status/commandTiming.js'; import {shimmerTextWithColors} from '../status/shimmer.js'; import {homedir} from 'node:os'; import {fitPowerlineBlocks, fitRightPowerlineBlocks, renderPowerlineBlocks, normalizeConnectorFadeColors, normalizePromptStyle, normalizeConnectorStyle, normalizeEdgeStyle, resolveConnectorFade, type PowerlineBlock, type PowerlineShape} from '../prompt/powerline.js'; +import {normalizeStyleProfiles} from '../prompt/styles.js'; import {renderWelcome, type WelcomeCatFrame, type WelcomeSnapshot} from './Welcome.js'; import {foldWindow} from './FoldPolicy.js'; import {archiveColor, grayscaleArchiveColor} from '../prompt/snapshot.js'; @@ -14,11 +19,20 @@ import {isPromptRole, promptRoleColors} from '../prompt/prompt.js'; import {DEFAULT_TRANSCRIPT_APPEARANCE, normalizeConnectorFade, type GitColorMode, type TranscriptAppearance} from '../prompt/configuration.js'; import type {CompletedCommand, HistoricalContextSnapshot, SecondaryActivity} from './OutputBuffer.js'; -const ARCHIVE_DIVIDER = foreground({red: 162, green: 151, blue: 190}); +/** + * History rules are UI chrome, resolved at use so themes apply. The shipped + * Lavender chrome keeps its original rule colors exactly; any other chrome + * gets lighter and darker tones of its own separator. + */ +const SHIPPED_SEPARATOR = {red: 139, green: 132, blue: 178}; +const shippedChrome = () => UI_COLORS.separator.red === SHIPPED_SEPARATOR.red && UI_COLORS.separator.green === SHIPPED_SEPARATOR.green + && UI_COLORS.separator.blue === SHIPPED_SEPARATOR.blue; +/** History divider tones of the UI separator role (Follow UI theme); Chroma dividers use the shared divider source. */ +const archiveDividerRgb = () => shippedChrome() ? {red: 162, green: 151, blue: 190} : mixRgb(UI_COLORS.separator, UI_COLORS.primary, 0.22); const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/gu; -const PRIMARY = foreground(UI_COLORS.primary); -const SECONDARY = foreground(UI_COLORS.secondary); -const SUBTLE = foreground(UI_COLORS.subtle); +const PRIMARY = lazyForeground(UI_COLORS.primary); +const SECONDARY = lazyForeground(UI_COLORS.secondary); +const SUBTLE = lazyForeground(UI_COLORS.subtle); const RESET = '\u001B[0m'; /** Row surfaces: submitted command rows, hovered and focused disclosure rows. */ const COMMAND_SURFACE = background({red: 38, green: 38, blue: 48}); @@ -50,7 +64,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 +88,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 +135,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 +157,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); @@ -140,7 +170,22 @@ export class TranscriptPresenter { const hiddenLines = cmd.endId - cmd.outputStartId; // Activity-bearing parents use their lifecycle row as the disclosure control below. const hasActivities = Boolean(cmd.activities?.length); - if (hasActivities) { + if (cmd.frontend === 'ask') { + // A recorded Ask conversation folds whole: the /ask request line above stays as its identity. + const turns = cmd.ask?.turns.length ?? hiddenLines; + const label = cmd.ask ? askFoldLabel(cmd.ask.turns, cmd.command.replace(/^\/(?:btw|ask)\s*/u, '')) : `Ask conversation · ${turns} turn${turns === 1 ? '' : 's'} · Ctrl+O`; + const commandIndex = completed.indexOf(cmd); + if (!cmd.expanded) { + const plain = foldHint(label, '›', width); + result.push({ansi: `${foreground(UI_COLORS.secondary)}${plain}\u001B[0m`, plain, lineIndex: cmd.outputStartId, isFoldHint: true, commandIndex}); + skipUntil = cmd.endId; + continue; + } + if (turns > 2) { + const plain = foldHint(label, '⌄', width); + result.push({ansi: `${foreground(UI_COLORS.secondary)}${plain}\u001B[0m`, plain, lineIndex: cmd.outputStartId, isFoldHint: true, commandIndex}); + } + } else if (hasActivities) { if (!cmd.expanded) { skipUntil = cmd.endId; continue; @@ -155,7 +200,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 +231,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 +240,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 +263,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 +283,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 +371,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}`, @@ -350,8 +395,8 @@ const LEGACY_BACKGROUNDS: Record = { }; /** Compact density: a finer dashed rule in a quieter tone, same single row. */ const DIVIDER_STYLES = { - normal: {glyph: '─', color: ARCHIVE_DIVIDER}, - compact: {glyph: '┈', color: foreground({red: 118, green: 112, blue: 138})}, + normal: {glyph: '─', get color() { return archiveDividerRgb(); }}, + compact: {glyph: '┈', get color() { return shippedChrome() ? {red: 118, green: 112, blue: 138} : mixRgb(UI_COLORS.separator, {red: 0, green: 0, blue: 0}, 0.15); }}, } as const; interface HistoricalSegment { @@ -393,6 +438,23 @@ function legacySegments(context: HistoricalContextSnapshot): HistoricalSegment[] return segments.map(segment => ({...segment, foreground: LEGACY_FOREGROUND, background: LEGACY_BACKGROUNDS[segment.role!], preMuted: true})); } +/** + * Compact and Minimal historical prompts: a quiet view of the stored facts + * (project or short cwd, branch, marker). Presentation only; the snapshot and + * /copy are unchanged, and nothing is invented that was not recorded. + */ +function condensedPrompt(context: HistoricalContextSnapshot, level: 'compact' | 'minimal', width: number): string { + const marker = `${foreground(UI_COLORS.accent)}${GLYPHS.prompt}\u001B[0m`; + if (level === 'minimal') return truncateAnsi(marker, width); + const clean = (text: string) => text.replace(CONTROL_CHARACTERS, '�'); + const cwd = clean(context.cwd); + const home = homedir().replace(/\/$/u, ''); + const place = context.project ? clean(context.project) : cwd === home ? '~' : cwd.split('/').filter(Boolean).pop() ?? cwd; + const subtle = foreground(UI_COLORS.secondary); + const branch = context.branch ? ` ${foreground(UI_COLORS.subtle)}${GLYPHS.branch} ${clean(context.branch)}` : ''; + return truncateAnsi(`${subtle}${place}${branch}\u001B[0m ${marker}`, width); +} + /** The prompt part of a historical header, colored per the transcript appearance. */ /** Divider cells kept between a historical left prompt and its right context. */ const RIGHT_CONTEXT_MIN_DIVIDER = 2; @@ -426,13 +488,16 @@ function historicalPrompt(context: HistoricalContextSnapshot, width: number, app const fade = snapshot.connectorFade === undefined ? undefined : resolveConnectorFade(normalizeConnectorFade(snapshot.connectorFade), connector); const fadeColors = normalizeConnectorFadeColors(snapshot.connectorFadeColors); + // History replays the submitted style profile; Chroma is a live treatment and never replays. + const submitted = normalizePromptStyle(snapshot.style); + const extras = {profiles: normalizeStyleProfiles(submitted === 'powerline' ? undefined : {[submitted]: snapshot.styleProfile}, gapEnabled ? gap : 0, spacing)}; const left = fitPowerlineBlocks(blocks.filter((_, index) => segments[index]!.placement !== 'right'), gap, spacing, width, - endStyle, gapEnabled, startStyle, connector, fade, fadeColors); + endStyle, gapEnabled, startStyle, connector, fade, fadeColors, extras); const right = blocks.filter((_, index) => segments[index]!.placement === 'right'); if (right.length === 0) return left; return {left, right: fitRightPowerlineBlocks(right, width - displayWidth(left) - 1 - RIGHT_CONTEXT_MIN_DIVIDER, candidate => renderPowerlineBlocks(candidate, gap, spacing, endStyle, gapEnabled, startStyle, connector, fade, fadeColors, - snapshot.mirrorRight ? 'mirrored' : 'normal'))}; + snapshot.mirrorRight ? 'mirrored' : 'normal', extras))}; } const plainSpans = segments.map(segment => `${rgbStyle( historyColor(segment.foreground, ARCHIVE_DIVIDER_COLOR, 'foreground', segment, appearance), @@ -446,15 +511,47 @@ function historicalPrompt(context: HistoricalContextSnapshot, width: number, app * and the historical prompt are off. Presentation only: stored snapshots and * raw PTY output are never modified. */ +/** Deliberately quiet neutral divider: readable, clearly secondary. */ +const MUTED_DIVIDER: Rgb = {red: 98, green: 100, blue: 106}; + +/** + * Historical divider color, by Divider colors: Follow Chroma (the active + * palette, always static here; the UI-theme tone while Chroma is Off), + * Follow history (the History colors mode), Follow UI theme (the separator + * role's history tone) or Muted grayscale. Presentation only. + */ +function historicalDivider(text: string, context: HistoricalContextSnapshot, appearance: TranscriptAppearance, + treatment: TreatmentSettings, uiTone: Rgb): string { + const mode = appearance.dividerColors ?? 'chroma'; + if (mode === 'chroma') return paintDivider(text, {...treatment, rules: true}, 0, false, uiTone); + if (mode === 'muted') return paintDivider(text, DEFAULT_TREATMENT_SETTINGS, 0, false, appearance.dividerDensity === 'compact' ? mixRgb(MUTED_DIVIDER, {red: 0, green: 0, blue: 0}, 0.15) : MUTED_DIVIDER); + if (mode === 'history') { + const first = context.prompt?.segments[0]; + const segment: HistoricalSegment = first ? {text: '', role: first.role, background: first.background} : legacySegments(context)[0] ?? {text: ''}; + const color = appearance.historyColors === 'theme' && isPromptRole(segment.role) + ? archiveColor(promptRoleColors(segment.role, appearance.historyTheme, 'followTheme').background, 'foreground') + : segment.background + ? appearance.historyColors === 'grayscale' ? grayscaleArchiveColor(segment.background, 'foreground') : archiveColor(segment.background, 'foreground') + : uiTone; + return paintDivider(text, DEFAULT_TREATMENT_SETTINGS, 0, false, color); + } + return paintDivider(text, DEFAULT_TREATMENT_SETTINGS, 0, false, uiTone); +} + export function renderHistoricalContext(context: HistoricalContextSnapshot, width: number, - appearance: TranscriptAppearance = DEFAULT_TRANSCRIPT_APPEARANCE): WrappedRow | undefined { - if (!appearance.divider && !appearance.historicalPrompt) return undefined; + appearance: TranscriptAppearance = DEFAULT_TRANSCRIPT_APPEARANCE, + treatment: TreatmentSettings = DEFAULT_TREATMENT_SETTINGS): WrappedRow | undefined { + // Prompt None submissions had no prompt: they render like the Off presentation, never a substituted one. + const promptShown = appearance.historicalPrompt && !context.promptless; + if (!appearance.divider && !promptShown) return undefined; const divider = DIVIDER_STYLES[appearance.dividerDensity]; - if (!appearance.historicalPrompt) { + if (!promptShown) { const line = repeatToWidth(divider.glyph, width); - return {ansi: `${divider.color}${line}\u001B[0m`, plain: line, isHistoricalHeader: true}; + return {ansi: `${historicalDivider(line, context, appearance, treatment, divider.color)}\u001B[0m`, plain: line, isHistoricalHeader: true}; } - const parts = historicalPrompt(context, Math.max(0, width - (appearance.divider ? 1 : 0)), appearance); + const level = appearance.historicalPromptLevel ?? 'full'; + const parts = level === 'full' ? historicalPrompt(context, Math.max(0, width - (appearance.divider ? 1 : 0)), appearance) + : condensedPrompt(context, level, Math.max(0, width - (appearance.divider ? 1 : 0))); const prompt = typeof parts === 'string' ? parts : parts.left; const right = typeof parts === 'string' || !parts.right ? '' : parts.right; const rightWidth = right ? displayWidth(right) + 1 : 0; @@ -466,7 +563,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 ${historicalDivider(fill, context, appearance, treatment, 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/TranscriptSearch.ts b/src/output/TranscriptSearch.ts new file mode 100644 index 00000000..58356794 --- /dev/null +++ b/src/output/TranscriptSearch.ts @@ -0,0 +1,340 @@ +import type {WrappedRow} from './viewport.js'; + +/** + * Transcript FIND and FILTER over NMSh-owned transcript data. + * + * Both are presentation-only: FIND highlights and scrolls, FILTER hides + * non-matching output lines of one command block while active. Neither ever + * rewrites stored lines, completed records, journals or /copy payloads. + * + * Both work on LOGICAL lines (every wrapped row of a line together), so the + * terminal width never changes whether something matches. Several clauses + * combine with AND: a line matches only when every clause matches it. + * + * Scopes are explicit so search can grow (current block, current transcript, + * NMSh history today; archived sessions are a documented future scope). This + * is not a filesystem or project search: that belongs to the editor/host. + */ + +export interface MatchOptions { + regex: boolean; + caseSensitive: boolean; +} + +export interface Span { start: number; end: number } + +export type CompiledQuery = {ok: true; test: (text: string) => Span[]} | {ok: false; error: string}; + +const MAX_MATCHES_PER_LINE = 64; + +/** Compile once per query change; a bad regex is reported, never thrown at render time. */ +export function compileQuery(query: string, options: MatchOptions): CompiledQuery { + if (!query) return {ok: true, test: () => []}; + if (options.regex) { + let pattern: RegExp; + try { pattern = new RegExp(query, options.caseSensitive ? 'gu' : 'giu'); } catch (error) { + return {ok: false, error: error instanceof Error ? error.message.replace(/^Invalid regular expression: /u, '') : 'invalid regular expression'}; + } + return {ok: true, test: text => { + const spans: Span[] = []; + pattern.lastIndex = 0; + for (let match = pattern.exec(text); match && spans.length < MAX_MATCHES_PER_LINE; match = pattern.exec(text)) { + if (match[0].length === 0) { pattern.lastIndex += 1; continue; } // empty matches never count + spans.push({start: match.index, end: match.index + match[0].length}); + } + return spans; + }}; + } + const needle = options.caseSensitive ? query : query.toLocaleLowerCase(); + return {ok: true, test: text => { + const haystack = options.caseSensitive ? text : text.toLocaleLowerCase(); + // Case folding may change length (rare); then only exact-length matches are safe to map. + if (haystack.length !== text.length) return haystack.includes(needle) ? [{start: 0, end: text.length}] : []; + const spans: Span[] = []; + for (let found = haystack.indexOf(needle); found !== -1 && spans.length < MAX_MATCHES_PER_LINE; found = haystack.indexOf(needle, found + needle.length)) { + spans.push({start: found, end: found + needle.length}); + } + return spans; + }}; +} + +// ------------------------------------------------------------------ logical lines + +export interface LogicalLine { + /** Presented row indexes, top to bottom. */ + rows: number[]; + /** Concatenated plain text of those rows. */ + text: string; + /** Start offset of each row inside `text`. */ + offsets: number[]; +} + +/** + * Group presented rows into logical lines: rows sharing a lineIndex are one + * line; rows without one (synthetic chrome) stand alone. Filter hints are skipped. + */ +export function logicalLines(rows: readonly WrappedRow[], include: (row: WrappedRow) => boolean = () => true): LogicalLine[] { + const lines: LogicalLine[] = []; + let current: LogicalLine | undefined; + let currentIndex: number | undefined; + rows.forEach((row, index) => { + if (row.isFilterHint || !include(row)) { current = undefined; currentIndex = undefined; return; } + if (current && row.lineIndex !== undefined && row.lineIndex === currentIndex) { + current.offsets.push(current.text.length); + current.rows.push(index); + current.text += row.plain; + return; + } + current = {rows: [index], text: row.plain, offsets: [0]}; + currentIndex = row.lineIndex; + lines.push(current); + }); + return lines; +} + +/** Map spans in a logical line back onto its rows. */ +export function spansByRow(line: LogicalLine, spans: readonly Span[]): Map { + const result = new Map(); + line.rows.forEach((row, position) => { + const start = line.offsets[position]!; + const end = position + 1 < line.offsets.length ? line.offsets[position + 1]! : line.text.length; + for (const span of spans) { + const from = Math.max(span.start, start); + const to = Math.min(span.end, end); + if (from < to) result.set(row, [...(result.get(row) ?? []), {start: from - start, end: to - start}]); + } + }); + return result; +} + +// ------------------------------------------------------------------ find + +export type SearchScopeId = 'block' | 'transcript'; + +export interface FindClause { + query: string; + options: MatchOptions; +} + +/** One matching logical line: every clause matched it. */ +export interface FindResult { + /** First presented row of the line (navigation target). */ + row: number; + /** Every clause's spans on that line, per presented row. */ + spans: Map; +} + +export const MAX_RESULTS = 10_000; + +/** Lines where every clause has at least one match, top to bottom; bounded. */ +export function findResults(rows: readonly WrappedRow[], clauses: readonly FindClause[], scope: SearchScopeId = 'transcript', blockStartId?: number): + {results: FindResult[]; error?: string} { + const active = clauses.filter(clause => clause.query); + if (!active.length) return {results: []}; + const compiled = active.map(clause => compileQuery(clause.query, clause.options)); + const failed = compiled.find((item): item is {ok: false; error: string} => !item.ok); + if (failed) return {results: [], error: failed.error}; + const tests = compiled as Array<{ok: true; test: (text: string) => Span[]}>; + const results: FindResult[] = []; + for (const line of logicalLines(rows, row => scope !== 'block' || row.blockStartId === blockStartId)) { + const spans: Span[] = []; + let all = true; + for (const compiledClause of tests) { + const found = compiledClause.test(line.text); + if (!found.length) { all = false; break; } + spans.push(...found); + } + if (!all) continue; + results.push({row: line.rows[0]!, spans: spansByRow(line, spans)}); + if (results.length >= MAX_RESULTS) break; + } + return {results}; +} + +export interface FindState { + /** Applied clauses (AND). */ + clauses: FindClause[]; + /** The clause being typed, while the find editor is open. */ + editing?: FindClause; + scope: SearchScopeId; + blockStartId?: number; + /** Index into `results` of the active result. */ + active: number; + results: FindResult[]; + error?: string; + computedFor?: string; +} + +export function createFind(scope: SearchScopeId = 'transcript', blockStartId?: number): FindState { + return {clauses: [], scope, ...(blockStartId === undefined ? {} : {blockStartId}), active: -1, results: []}; +} + +/** Applied clauses plus the one being typed. */ +export function effectiveClauses(state: FindState): FindClause[] { + return state.editing?.query ? [...state.clauses, state.editing] : [...state.clauses]; +} + +const clauseKey = (clauses: readonly FindClause[]) => clauses.map(clause => `${clause.options.regex ? 'r' : ''}${clause.options.caseSensitive ? 'c' : ''}:${clause.query}`).join('\u0000'); + +/** + * Recompute only when the rows' generation key or the clauses changed (never + * once per frame); the newest result becomes active when the query changes. + */ +export function refreshFind(state: FindState, rows: readonly WrappedRow[], generation: string): void { + const clauses = effectiveClauses(state); + const key = `${generation}\u0001${state.scope}:${state.blockStartId ?? ''}\u0001${clauseKey(clauses)}`; + if (state.computedFor === key) return; + const queryChanged = !state.computedFor || state.computedFor.split('\u0001')[2] !== clauseKey(clauses) || state.computedFor.split('\u0001')[1] !== `${state.scope}:${state.blockStartId ?? ''}`; + const previous = state.results[state.active]; + const {results, error} = findResults(rows, clauses, state.scope, state.blockStartId); + state.results = results; + state.error = error; + if (!results.length) state.active = -1; + else if (queryChanged || !previous) state.active = results.length - 1; + else { + const same = results.findIndex(result => result.row === previous.row); + state.active = same === -1 ? Math.min(Math.max(0, state.active), results.length - 1) : same; + } + state.computedFor = key; +} + +/** Next = older (up the transcript, like a terminal's search back); wraps around. */ +export function stepFind(state: FindState, direction: 'next' | 'previous'): FindResult | undefined { + if (!state.results.length) return undefined; + const count = state.results.length; + state.active = direction === 'next' ? (state.active - 1 + count) % count : (state.active + 1) % count; + return state.results[state.active]; +} + +export function findCount(state: FindState): string { + if (state.error) return `invalid regex: ${state.error}`; + if (!effectiveClauses(state).length) return 'type to search'; + if (!state.results.length) return 'no matches'; + return `${state.active + 1}/${state.results.length}${state.results.length >= MAX_RESULTS ? '+' : ''}`; +} + +/** Viewport start that shows `row` about a third from the top. */ +export function revealStart(row: number, totalRows: number, height: number): number { + const maximum = Math.max(0, totalRows - height); + return Math.max(0, Math.min(maximum, row - Math.floor(height / 3))); +} + +// ------------------------------------------------------------------ filter + +export interface FilterClause { + query: string; + options: MatchOptions; + invert: boolean; + /** Lines of context kept around kept lines (0..20); the set uses the largest. */ + context: number; +} + +export interface OutputFilter { + /** The command block being filtered; never moved implicitly. */ + startId: number; + clauses: FilterClause[]; +} + +export interface FilterResult { + rows: WrappedRow[]; + /** Output lines of the block kept / total. */ + kept: number; + total: number; + error?: string; +} + +/** + * Presentation filter for one block. A line is kept when every clause is + * satisfied (a clause is satisfied when it matches, or when it does not match + * for an inverted clause). Header rows, non-output rows and other blocks stay + * exactly as they are; a hint row says a filter is active. + */ +export function applyOutputFilter(rows: readonly WrappedRow[], filter: OutputFilter, isOutputLine: (lineIndex: number) => boolean): FilterResult { + const compiled = filter.clauses.map(clause => ({clause, compiled: compileQuery(clause.query, clause.options)})); + const failed = compiled.find(item => !item.compiled.ok); + if (failed && !failed.compiled.ok) return {rows: [...rows], kept: 0, total: 0, error: failed.compiled.error}; + const lineText = new Map(); + for (const row of rows) { + if (row.blockStartId !== filter.startId || row.lineIndex === undefined || row.lineIndex === filter.startId || !isOutputLine(row.lineIndex)) continue; + lineText.set(row.lineIndex, (lineText.get(row.lineIndex) ?? '') + row.plain); + } + const lines = [...lineText.keys()]; + const context = Math.max(0, Math.min(20, ...filter.clauses.map(clause => clause.context))); + const keep = new Set(); + lines.forEach((line, position) => { + const text = lineText.get(line)!; + const satisfied = compiled.every(({clause, compiled: query}) => query.ok && (query.test(text).length > 0) !== clause.invert); + if (!satisfied) return; + for (let offset = -context; offset <= context; offset += 1) { + const neighbour = lines[position + offset]; + if (neighbour !== undefined) keep.add(neighbour); + } + }); + const result: WrappedRow[] = []; + let hintPlaced = false; + for (const row of rows) { + const filtered = row.blockStartId === filter.startId && row.lineIndex !== undefined && lineText.has(row.lineIndex); + if (filtered && !keep.has(row.lineIndex!)) continue; + if (filtered && !hintPlaced) { + result.push(filterHintRow(filter, keep.size, lines.length)); + hintPlaced = true; + } + result.push(row); + } + if (!hintPlaced && lines.length > 0) { + // Nothing kept: the hint goes where the output was, after the block's header. + const header = result.findIndex(row => row.blockStartId === filter.startId && row.lineIndex === filter.startId); + result.splice(header === -1 ? result.length : header + 1, 0, filterHintRow(filter, 0, lines.length)); + } + return {rows: result, kept: keep.size, total: lines.length}; +} + +/** A compact label for one clause: `FAIL`, `!PASS`, `/disk|vol/`, with c for case-sensitive. */ +export function clauseLabel(clause: {query: string; options: MatchOptions; invert?: boolean}): string { + const body = clause.options.regex ? `/${clause.query}/` : clause.query; + return `${clause.invert ? '!' : ''}${body}${clause.options.caseSensitive ? ' (c)' : ''}`; +} + +function filterHintRow(filter: OutputFilter, kept: number, total: number): WrappedRow { + const context = Math.max(0, ...filter.clauses.map(clause => clause.context)); + const terms = filter.clauses.map(clause => `“${clauseLabel(clause)}”`).join(' ∧ '); + const plain = ` ⧩ filter ${terms}${context ? ` (±${context})` : ''} · ${kept} of ${total} lines · /filter clear`; + return {ansi: `\u001b[2m${plain}\u001b[0m`, plain, blockStartId: filter.startId, isFilterHint: true}; +} + +/** Parse `/filter` and `/find` flags: -r regex, -c case-sensitive, -v invert, -C N context, -b block. */ +export function parseSearchArguments(text: string): {query: string; options: MatchOptions; invert: boolean; context: number; block: boolean} { + const options: MatchOptions = {regex: false, caseSensitive: false}; + let invert = false; + let context = 0; + let block = false; + const words = text.trim().length ? text.trim().split(/(\s+)/u) : []; + let index = 0; + for (; index < words.length; index += 1) { + const word = words[index]!; + if (/^\s+$/u.test(word)) continue; + if (word === '-r') options.regex = true; + else if (word === '-c') options.caseSensitive = true; + else if (word === '-v') invert = true; + else if (word === '-b') block = true; + else if (word === '-C' && /^\d{1,2}$/u.test(words[index + 2] ?? '')) { context = Number(words[index + 2]); index += 2; } + else if (word === '--') { index += 2; break; } + else break; + } + const raw = words.slice(index).join('').trim(); + // One pair of surrounding quotes is the user's quoting, not part of the term. + const query = /^(["']).*\1$/u.test(raw) && raw.length > 1 ? raw.slice(1, -1) : raw; + return {query, options, invert, context: Math.min(20, context), block}; +} + +/** `/find clear`, `/find remove N`, or a clause to add. */ +export type SearchCommand = {kind: 'clear'} | {kind: 'remove'; index: number} | {kind: 'open'} | {kind: 'add'; parsed: ReturnType}; + +export function parseSearchCommand(text: string): SearchCommand { + const trimmed = text.trim(); + if (!trimmed) return {kind: 'open'}; + if (trimmed === 'clear') return {kind: 'clear'}; + const remove = /^remove\s+(\d{1,3})$/u.exec(trimmed); + if (remove) return {kind: 'remove', index: Number(remove[1])}; + return {kind: 'add', parsed: parseSearchArguments(trimmed)}; +} diff --git a/src/output/TranscriptSelection.ts b/src/output/TranscriptSelection.ts new file mode 100644 index 00000000..c6fc39fe --- /dev/null +++ b/src/output/TranscriptSelection.ts @@ -0,0 +1,63 @@ +import type {WrappedRow} from './viewport.js'; + +/** + * NMSh-owned transcript selection. Terminal-native (Shift+drag) selection + * lives in the host's screen grid, which NMSh repaints when its virtual + * transcript scrolls, so it cannot extend past the visible rows. A plain drag + * over the transcript selects transcript content instead: rows are indices in + * the wrapped transcript, so the wheel can scroll while selecting and the + * selection keeps growing. Presentation only; the transcript never changes. + */ +export interface TranscriptSelection { + anchor: number; + head: number; + /** Button still held; a release ends the drag. */ + dragging: boolean; + /** At least one drag step beyond the anchor: a plain click selects nothing. */ + moved: boolean; + /** Last pointer row on screen (terminal row), so a wheel step can re-aim the head. */ + pointerY?: number; +} + +export function beginSelection(row: number, pointerY: number): TranscriptSelection { + return {anchor: row, head: row, dragging: true, moved: false, pointerY}; +} + +export function extendSelection(selection: TranscriptSelection, row: number, pointerY?: number): void { + if (row !== selection.anchor) selection.moved = true; + selection.head = row; + if (pointerY !== undefined) selection.pointerY = pointerY; +} + +export function selectionRange(selection: TranscriptSelection): {start: number; end: number} { + return {start: Math.min(selection.anchor, selection.head), end: Math.max(selection.anchor, selection.head)}; +} + +export function isRowSelected(selection: TranscriptSelection | undefined, row: number): boolean { + if (!selection?.moved) return false; + const {start, end} = selectionRange(selection); + return row >= start && row <= end; +} + +/** + * The selected transcript content: wrapped rows of one logical line join + * without a break (wrapping is presentation, not content), different lines + * join with newlines, and trailing padding is trimmed. Synthetic rows + * (filter notices) are not transcript content and are skipped. + */ +export function selectedText(rows: readonly WrappedRow[], selection: TranscriptSelection): string { + if (!selection.moved) return ''; + const {start, end} = selectionRange(selection); + const lines: string[] = []; + let previousLine: number | undefined; + for (let index = start; index <= end && index < rows.length; index += 1) { + const row = rows[index]!; + if (row.isFilterHint) continue; + const text = row.plain.slice(row.indent ?? 0); + // Continuation rows of the same logical line are joined exactly; padding is trimmed once the line ends. + if (row.lineIndex !== undefined && row.lineIndex === previousLine && lines.length) lines[lines.length - 1] += text; + else lines.push(text); + previousLine = row.lineIndex; + } + return lines.map(line => line.replace(/\s+$/u, '')).join('\n'); +} diff --git a/src/output/Welcome.ts b/src/output/Welcome.ts index 0a21db71..0b3a9ce1 100644 --- a/src/output/Welcome.ts +++ b/src/output/Welcome.ts @@ -1,8 +1,12 @@ 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'; +import type {ShellId} from '../shell/adapters/ShellAdapter.js'; const RESET = '\u001B[0m'; const BODY = {red: 172, green: 150, blue: 230}; @@ -16,16 +20,24 @@ const CONTROLS = /[\u0000-\u001f\u007f-\u009f]/gu; export interface WelcomeSnapshot { identity: BuildIdentity; + /** Recording identity is presentation only; version reporting stays factual. */ + demo?: boolean; cwd: string; - shell: 'zsh'; + /** The backend NMSh manages for this presentation (never $SHELL). */ + shell: ShellId; + /** + * Local understanding as it was when this presentation began (e.g. "Off", + * "Auto · model idle · Ask"). Snapshotted: later model loads never rewrite it. + */ + understanding?: string; /** External welcome captured once at session start; absent means Vespyr. */ - provider?: 'fastfetch' | 'neofetch'; + provider?: Exclude; /** SGR-only rows the external provider printed. */ captured?: string[]; } -export function createWelcomeSnapshot(identity: BuildIdentity, cwd: string): WelcomeSnapshot { - return {identity: {...identity}, cwd, shell: 'zsh'}; +export function createWelcomeSnapshot(identity: BuildIdentity, cwd: string, shell: ShellId = 'zsh', understanding?: string): WelcomeSnapshot { + return {identity: {...identity}, demo: process.env.NMSH_DEMO === '1', cwd, shell, ...(understanding ? {understanding} : {})}; } function safe(value: string): string { @@ -98,6 +110,11 @@ function catRow(row: number, frame: WelcomeCatFrame = 'open'): {ansi: string; pl return {ansi, plain}; } +/** The approved Vespyr sprite alone (four rows), for places that show the mascot without the Welcome card. */ +export function vespyrSprite(frame: WelcomeCatFrame = 'open'): string[] { + return Array.from({length: CAT_ROWS}, (_, row) => catRow(row, frame).ansi); +} + interface Span { text: string; color: typeof BODY; @@ -133,15 +150,16 @@ export function renderWelcome(snapshot: WelcomeSnapshot, width: number, frame: W {text: 'not', color: UI_COLORS.primary, bold: true}, {text: 'My', color: BRAND_ACCENT, bold: true}, {text: 'Shell', color: UI_COLORS.primary, bold: true}, - {text: ` ${safe(versionLabel(identity.version))}`, color: UI_COLORS.subtle}, + {text: ` ${snapshot.demo ? 'demo' : safe(versionLabel(identity.version))}`, color: UI_COLORS.subtle}, ], [ - {text: `build ${safe(identity.commit)}`, color: UI_COLORS.subtle}, - ...(identity.branch ? [{text: ` · ${safe(identity.branch)}`, color: UI_COLORS.secondary}] : []), - ...(identity.dirty ? [{text: ' · dirty', color: UI_COLORS.subtle}] : []), + {text: snapshot.demo ? 'A real shell. A livelier terminal.' : `build ${safe(identity.commit)}`, color: UI_COLORS.subtle}, + ...(!snapshot.demo && identity.branch ? [{text: ` · ${safe(identity.branch)}`, color: UI_COLORS.secondary}] : []), + ...(!snapshot.demo && identity.dirty ? [{text: ' · dirty', color: UI_COLORS.subtle}] : []), ], [{text: shortCwd(snapshot.cwd), color: UI_COLORS.secondary}], - [{text: snapshot.shell, color: {red: 104, green: 110, blue: 120}}], + [{text: snapshot.shell, color: {red: 104, green: 110, blue: 120}}, + ...(snapshot.understanding ? [{text: ` · Local understanding ${safe(snapshot.understanding)}`, color: UI_COLORS.subtle}] : [])], ]; const gutter = 2; const cat = width >= 42; @@ -157,7 +175,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 +188,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..3c97e9d7 100644 --- a/src/output/WelcomeProviders.ts +++ b/src/output/WelcomeProviders.ts @@ -6,10 +6,13 @@ import {detectProvider, findExecutable, runExternal, type ProviderDescriptor} fr export const WELCOME_PROVIDERS: readonly ProviderDescriptor[] = [ {id: 'vespyr', family: 'welcome', label: 'Vespyr', kind: 'native', description: 'the NMSh cat with build and directory'}, {id: 'fastfetch', family: 'welcome', label: 'Fastfetch', kind: 'external', executable: 'fastfetch', versionArgs: ['--version'], - description: 'your installed fastfetch and its configuration', - ...(process.platform === 'darwin' ? {install: {label: 'brew install fastfetch', command: 'brew', args: ['install', 'fastfetch']}} : {})}, + description: 'your installed fastfetch and its configuration', recipe: {brew: 'fastfetch'}, source: 'https://github.com/fastfetch-cli/fastfetch'}, {id: 'neofetch', family: 'welcome', label: 'Neofetch', kind: 'external', executable: 'neofetch', versionArgs: ['--version'], legacy: true, - description: 'archived upstream; used only if already installed'}, + successor: 'Fastfetch', description: 'archived upstream; used only if already installed'}, + {id: 'macchina', family: 'welcome', label: 'Macchina', kind: 'external', executable: 'macchina', versionArgs: ['--version'], lifecycle: 'maintenance', + description: 'system information fetcher in maintenance mode', recipe: {brew: 'macchina'}, source: 'https://github.com/Macchina-CLI/macchina'}, + {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 +21,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 +33,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..0bbec302 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'; @@ -16,6 +16,8 @@ export interface WrappedRow { blockStartId?: number; /** Leading alignment columns (Chat); surfaces and hit bounds start after them. */ indent?: number; + /** The synthetic "filter active" row; presentation only, never transcript data. */ + isFilterHint?: boolean; } export interface StickyHeader { @@ -42,16 +44,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 +67,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/packages/homebrew.ts b/src/packages/homebrew.ts new file mode 100644 index 00000000..0401833e --- /dev/null +++ b/src/packages/homebrew.ts @@ -0,0 +1,121 @@ +import {environmentFor, resolveCommand, runExternal} from '../providers/providers.js'; +import {parseBrewOutdated} from '../tools/ToolUpdates.js'; + +/** + * Homebrew as NMSh's first package manager, behind a small adapter that the + * rest of NMSh (tools, providers, Ask) can share. Facts come from Homebrew's + * machine-readable output (`--json=v2`, one-name-per-line lists), validated + * and bounded; malformed output is treated as "no facts", never guessed. + * Mutations are typed (install/upgrade/uninstall of a validated name) and + * never assembled from free text. + */ + +export const PACKAGE_NAME = /^[A-Za-z0-9][A-Za-z0-9@._+/-]{0,127}$/u; +const VERSION = /^[A-Za-z0-9][A-Za-z0-9._+:,-]{0,63}$/u; + +export interface PackageInfo { + name: string; + kind: 'formula' | 'cask'; + description?: string; + homepage?: string; + /** The version Homebrew would install now. */ + current?: string; + /** Installed versions (empty when not installed). */ + installed: string[]; + dependencies: string[]; + outdated: boolean; +} + +export interface PackageManagerAdapter { + id: 'homebrew'; + label: string; + /** The brew executable, when installed. */ + executable(): string | undefined; + info(name: string): Promise; + installed(): Promise<{formulae: string[]; casks: string[]}>; + leaves(): Promise; + search(query: string): Promise<{formulae: string[]; casks: string[]}>; + outdated(): Promise>; + uses(name: string): Promise; + prefix(name: string): Promise; +} + +const list = (text: string, limit = 4096) => text.split('\n').map(line => line.trim()).filter(line => PACKAGE_NAME.test(line)).slice(0, limit); + +/** `brew info --json=v2 `: formulae and casks, validated. */ +export function parseBrewInfo(json: string): PackageInfo[] { + let value: {formulae?: unknown; casks?: unknown}; + try { value = JSON.parse(json) as typeof value; } catch { return []; } + const out: PackageInfo[] = []; + for (const formula of Array.isArray(value?.formulae) ? value.formulae.slice(0, 32) : []) { + const record = formula as {name?: unknown; desc?: unknown; homepage?: unknown; versions?: {stable?: unknown}; installed?: unknown; dependencies?: unknown; outdated?: unknown}; + if (typeof record.name !== 'string' || !PACKAGE_NAME.test(record.name)) continue; + const installed = Array.isArray(record.installed) ? record.installed.map(item => (item as {version?: unknown}).version).filter((version): version is string => typeof version === 'string' && VERSION.test(version)) : []; + out.push({name: record.name, kind: 'formula', ...(typeof record.desc === 'string' ? {description: record.desc.slice(0, 200)} : {}), + ...(typeof record.homepage === 'string' && /^https:\/\//u.test(record.homepage) ? {homepage: record.homepage} : {}), + ...(typeof record.versions?.stable === 'string' && VERSION.test(record.versions.stable) ? {current: record.versions.stable} : {}), + installed, dependencies: Array.isArray(record.dependencies) ? record.dependencies.filter((name): name is string => typeof name === 'string' && PACKAGE_NAME.test(name)).slice(0, 64) : [], + outdated: record.outdated === true}); + } + for (const cask of Array.isArray(value?.casks) ? value.casks.slice(0, 32) : []) { + const record = cask as {token?: unknown; desc?: unknown; homepage?: unknown; version?: unknown; installed?: unknown; outdated?: unknown}; + if (typeof record.token !== 'string' || !PACKAGE_NAME.test(record.token)) continue; + out.push({name: record.token, kind: 'cask', ...(typeof record.desc === 'string' ? {description: record.desc.slice(0, 200)} : {}), + ...(typeof record.homepage === 'string' && /^https:\/\//u.test(record.homepage) ? {homepage: record.homepage} : {}), + ...(typeof record.version === 'string' && VERSION.test(record.version) ? {current: record.version} : {}), + installed: typeof record.installed === 'string' && VERSION.test(record.installed) ? [record.installed] : [], dependencies: [], outdated: record.outdated === true}); + } + return out; +} + +/** `brew outdated --json=v2`: formulae (shared parser) and casks. */ +export function parseOutdatedAll(json: string): Array<{name: string; installed: string; current: string; kind: 'formula' | 'cask'}> { + const formulae = Object.entries(parseBrewOutdated(json)).map(([name, entry]) => ({name, ...entry, kind: 'formula' as const})); + let casks: unknown; + try { casks = (JSON.parse(json) as {casks?: unknown}).casks; } catch { return formulae; } + const caskList = (Array.isArray(casks) ? casks : []).slice(0, 2048).flatMap(cask => { + const record = cask as {name?: unknown; installed_versions?: unknown; current_version?: unknown}; + const installed = Array.isArray(record.installed_versions) ? record.installed_versions.at(-1) : undefined; + return typeof record.name === 'string' && PACKAGE_NAME.test(record.name) && typeof installed === 'string' && VERSION.test(installed) + && typeof record.current_version === 'string' && VERSION.test(record.current_version) ? [{name: record.name, installed, current: record.current_version, kind: 'cask' as const}] : []; + }); + return [...formulae, ...caskList]; +} + +/** Read-only Homebrew queries: never auto-update taps, no analytics, bounded time and output. */ +export function homebrewAdapter(brew: string | undefined = resolveCommand('brew')): PackageManagerAdapter { + const query = async (args: string[], timeoutMs = 15_000) => { + if (!brew) return ''; + const result = await runExternal(brew, args, {timeoutMs, maxBytes: 4 * 1024 * 1024, + env: {...environmentFor(brew), HOMEBREW_NO_AUTO_UPDATE: '1', HOMEBREW_NO_ANALYTICS: '1', HOMEBREW_NO_ENV_HINTS: '1', HOMEBREW_NO_INSTALL_CLEANUP: '1'}}); + return result.stdout; + }; + const named = (name: string) => PACKAGE_NAME.test(name) ? name : undefined; + return { + id: 'homebrew', label: 'Homebrew', executable: () => brew, + info: async name => named(name) ? parseBrewInfo(await query(['info', '--json=v2', name])) : [], + installed: async () => ({formulae: list(await query(['list', '--formula', '-1'])), casks: list(await query(['list', '--cask', '-1']))}), + leaves: async () => list(await query(['leaves'])), + search: async term => { + if (!/^[\w@.+-]{2,64}$/u.test(term)) return {formulae: [], casks: []}; + const [formulae, casks] = await Promise.all([query(['search', '--formula', term], 20_000), query(['search', '--cask', term], 20_000)]); + return {formulae: list(formulae, 20), casks: list(casks, 20)}; + }, + outdated: async () => parseOutdatedAll(await query(['outdated', '--json=v2'], 30_000)), + uses: async name => named(name) ? list(await query(['uses', '--installed', name]), 64) : [], + prefix: async name => { + if (!named(name)) return undefined; + const path = (await query(['--prefix', name])).trim(); + return path.startsWith('/') && !path.includes('\n') ? path : undefined; + }, + }; +} + +/** The mutations Ask may run, as exact argv shapes; anything else is never run. */ +export function brewMutationAllowed(argv: readonly string[]): boolean { + if (argv[0] !== 'brew' || !['install', 'upgrade', 'uninstall'].includes(argv[1] ?? '')) return false; + const rest = argv.slice(2); + const name = rest.at(-1); + if (!name || !PACKAGE_NAME.test(name) || name.startsWith('-')) return false; + return rest.length === 1 || (rest.length === 2 && rest[0] === '--cask'); +} diff --git a/src/packages/managers.ts b/src/packages/managers.ts new file mode 100644 index 00000000..893a58ce --- /dev/null +++ b/src/packages/managers.ts @@ -0,0 +1,137 @@ +import {readFileSync} from 'node:fs'; +import {resolveCommand, type ProviderInstall} from '../providers/providers.js'; + +/** + * Typed package-manager install plans for the curated tool catalog. + * + * A plan is an executable plus an argv array, never a shell string. Package + * names come from a curated per-manager table of names that differ from the + * Homebrew formula or exist only on some distributions; a tool with no entry + * for the detected manager has no plan, so NMSh shows it as manual instead of + * guessing. Native Windows is not supported; WSL is Linux and uses the + * distribution's own manager. + * + * Privilege: Homebrew never needs elevation. The other managers do. NMSh + * never embeds passwords or elevates quietly: as root it runs the manager + * directly; otherwise the reviewed argv is explicitly `sudo -n ...`, which + * only succeeds if sudo needs no password prompt, and a failed run points at + * the exact command to run yourself. + */ +export type ManagerId = 'homebrew' | 'apt' | 'dnf' | 'pacman' | 'zypper'; + +export interface ManagerInfo {id: ManagerId; label: string; executable: string; elevation: 'none' | 'administrator'} +export interface PackagePlan extends ProviderInstall { + manager: ManagerId; + managerLabel: string; + package: string; + elevation: 'none' | 'administrator'; + /** Exact command to run yourself, shown when an elevated run cannot proceed. */ + manual: string; +} + +const MANAGERS: Record = { + homebrew: {label: 'Homebrew', executable: 'brew', install: ['install'], elevation: 'none'}, + apt: {label: 'APT', executable: 'apt-get', install: ['install', '-y'], elevation: 'administrator'}, + dnf: {label: 'DNF', executable: 'dnf', install: ['install', '-y'], elevation: 'administrator'}, + pacman: {label: 'pacman', executable: 'pacman', install: ['-S', '--needed', '--noconfirm'], elevation: 'administrator'}, + zypper: {label: 'zypper', executable: 'zypper', install: ['--non-interactive', 'install'], elevation: 'administrator'}, +}; + +/** + * Distribution package names, only where they provide the same executable the + * catalog expects. Left out on purpose: fd/bat on Debian-family (installed as + * fdfind/batcat), kubectl, yq (several implementations), anything uncertain. + */ +const ALL = (name: string): Partial> => ({apt: name, dnf: name, pacman: name, zypper: name}); +const NATIVE_PACKAGES: Record>> = { + rg: ALL('ripgrep'), fzf: ALL('fzf'), jq: ALL('jq'), zoxide: ALL('zoxide'), btop: ALL('btop'), + fd: {dnf: 'fd-find', pacman: 'fd', zypper: 'fd'}, + bat: {dnf: 'bat', pacman: 'bat', zypper: 'bat'}, + gh: {apt: 'gh', dnf: 'gh', pacman: 'github-cli', zypper: 'gh'}, + lazygit: {pacman: 'lazygit'}, + delta: {apt: 'git-delta', dnf: 'git-delta', pacman: 'git-delta'}, + shellcheck: {apt: 'shellcheck', dnf: 'ShellCheck', pacman: 'shellcheck', zypper: 'ShellCheck'}, + shfmt: {apt: 'shfmt', dnf: 'shfmt', pacman: 'shfmt'}, + hyperfine: {apt: 'hyperfine', dnf: 'hyperfine', pacman: 'hyperfine'}, + just: {apt: 'just', dnf: 'just', pacman: 'just'}, +}; + +export interface PackageEnvironment { + platform: NodeJS.Platform; + /** Contents of /etc/os-release, when readable. */ + osRelease?: string; + has(command: string): boolean; + isRoot: boolean; +} + +export function systemPackageEnvironment(): PackageEnvironment { + let osRelease: string | undefined; + try { osRelease = readFileSync('/etc/os-release', 'utf8').slice(0, 4096); } catch { /* not Linux or unreadable */ } + return {platform: process.platform, ...(osRelease === undefined ? {} : {osRelease}), has: command => resolveCommand(command) !== undefined, + isRoot: typeof process.getuid === 'function' && process.getuid() === 0}; +} + +/** Distribution family from os-release ID / ID_LIKE; unknown distributions get no native manager. */ +export function distroManager(osRelease: string | undefined): Exclude | undefined { + if (!osRelease) return undefined; + const field = (key: string) => new RegExp(`^${key}=("?)([^"\\n]*)\\1$`, 'mu').exec(osRelease)?.[2]?.toLowerCase() ?? ''; + const ids = `${field('ID')} ${field('ID_LIKE')}`.split(/\s+/u); + if (ids.some(id => ['debian', 'ubuntu'].includes(id))) return 'apt'; + if (ids.some(id => ['fedora', 'rhel', 'centos'].includes(id))) return 'dnf'; + if (ids.some(id => ['arch', 'archlinux'].includes(id))) return 'pacman'; + if (ids.some(id => ['suse', 'opensuse', 'opensuse-leap', 'opensuse-tumbleweed'].includes(id))) return 'zypper'; + return undefined; +} + +/** Managers usable here, in preference order: the distribution's own, then Homebrew. */ +export function availableManagers(env: PackageEnvironment): ManagerInfo[] { + const order: ManagerId[] = []; + if (env.platform === 'linux') { const native = distroManager(env.osRelease); if (native) order.push(native); } + if (env.platform === 'darwin' || env.platform === 'linux') order.push('homebrew'); + return order.filter(id => env.has(MANAGERS[id].executable)) + .map(id => ({id, label: MANAGERS[id].label, executable: MANAGERS[id].executable, elevation: MANAGERS[id].elevation})); +} + +/** Package name for a catalog tool under one manager; Homebrew uses the catalog formula. */ +function packageFor(tool: {id: string; package?: string}, manager: ManagerId): string | undefined { + return manager === 'homebrew' ? tool.package : NATIVE_PACKAGES[tool.id]?.[manager]; +} + +/** + * The first available manager with a verified mapping for this tool, as an + * exact argv. Undefined means "install manually" (see `planUnavailableReason`). + */ +export function planPackageInstall(tool: {id: string; package?: string; legacy?: boolean}, env: PackageEnvironment = systemPackageEnvironment()): PackagePlan | undefined { + if (tool.legacy) return undefined; + for (const manager of availableManagers(env)) { + const name = packageFor(tool, manager.id); + if (!name) continue; + const spec = MANAGERS[manager.id]; + const base = [spec.executable, ...spec.install, name]; + const elevated = spec.elevation === 'administrator' && !env.isRoot; + const argv = elevated ? ['sudo', '-n', ...base] : base; + return {label: argv.join(' '), command: argv[0]!, args: argv.slice(1), manager: manager.id, managerLabel: manager.label, package: name, + elevation: spec.elevation, manual: elevated ? ['sudo', ...base].join(' ') : base.join(' ')}; + } + return undefined; +} + +export function planUnavailableReason(tool: {id: string; label: string; package?: string}, env: PackageEnvironment = systemPackageEnvironment()): string { + if (env.platform === 'win32') return 'Native Windows is not supported. Run NMSh inside WSL and use that distribution\'s package manager.'; + const managers = availableManagers(env); + if (!managers.length) { + const native = env.platform === 'linux' ? distroManager(env.osRelease) : undefined; + return native ? `${MANAGERS[native].label} was not found. Install ${tool.label} yourself.` + : env.platform === 'linux' ? `NMSh did not recognise a supported package manager (APT, DNF, pacman, zypper or Homebrew). Install ${tool.label} yourself.` + : `Homebrew is not installed. Install ${tool.label} yourself.`; + } + return `NMSh has no verified ${managers.map(manager => manager.label).join(' / ')} package for ${tool.label}; it does not guess package names. Install it yourself.`; +} + +/** Reviewed-plan sentence about privilege, shown before the confirmation. */ +export function elevationNote(plan: Pick): string | undefined { + if (plan.elevation === 'none') return undefined; + return plan.command === 'sudo' + ? `Needs administrator rights. This runs with sudo -n (no password prompt); if sudo asks for a password it fails safely and you can run: ${plan.manual}` + : 'Needs administrator rights; NMSh is running as root.'; +} diff --git a/src/pickers/Picker.ts b/src/pickers/Picker.ts new file mode 100644 index 00000000..ac433bd5 --- /dev/null +++ b/src/pickers/Picker.ts @@ -0,0 +1,110 @@ +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'; +import {withFzfTheme} from '../themeBridge/targets.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', + recipe: {brew: 'fzf'}, source: 'https://github.com/junegunn/fzf'}, + {id: 'television', family: 'picker', label: 'Television', kind: 'external', executable: 'tv', versionArgs: ['--version'], description: 'optional terminal fuzzy picker', + recipe: {brew: 'television'}, source: 'https://github.com/alexpasmantier/television'}, +]; +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}; +} + +/** NMSh-owned fzf arguments; `layout` follows the composer side. */ +export function fzfPickerArgs(layout: 'default' | 'reverse'): string[] { + return ['--no-multi', '--no-sort', '--delimiter=\t', '--with-nth=2..', `--layout=${layout}`, '--no-mouse', '--pointer=>', '--marker=*']; +} + +/** 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, themeArgs: readonly string[] = [], layout: 'default' | 'reverse' = 'default'): 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, 300_000, themeArgs, layout)); + 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, themeArgs: readonly string[] = [], layout: 'default' | 'reverse' = 'default'): 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[]; + // The query follows NMSh's composer: bottom (fzf's own default) or top (reverse). Never the user's standalone fzf config. + if (provider === 'fzf') args = fzfPickerArgs(layout); + 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'); + // Theme Bridge colors (fzf only, invocation-scoped) come first, so this surface's explicit options win. + if (provider === 'fzf') args = withFzfTheme(args, themeArgs); + 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/ImageSurface.ts b/src/presentation/ImageSurface.ts new file mode 100644 index 00000000..d95ecc83 --- /dev/null +++ b/src/presentation/ImageSurface.ts @@ -0,0 +1,122 @@ +import type {TerminalCapabilities} from '../host/capabilities.js'; + +/** + * NMSh's raster image surface. + * + * The terminal host owns image support; NMSh can only use a protocol the host + * implements. Selection order: + * 1. Kitty graphics protocol, when a probe reply proved it (or the host is kitty itself) + * 2. iTerm2 inline images (OSC 1337), from the known-host record (iTerm2, WezTerm) + * 3. none: the caller's terminal-native fallback rows + * Core UI never depends on images, and NMSh never suggests switching hosts. + * + * Every image escape is built here, so nothing else ever writes raw image bytes, + * and nothing is written at all when the protocol is `none`. + */ + +export type ImageProtocol = 'kitty' | 'iterm2' | 'none'; + +export interface ImageEnvironment { + capabilities: Readonly; + env: NodeJS.ProcessEnv; +} + +/** Which protocol to use right now. Multiplexers hide the outer host, so they get none. */ +export function selectImageProtocol({capabilities, env}: ImageEnvironment): ImageProtocol { + if (env.NMSH_IMAGES === '0' || env.TERM === 'dumb') return 'none'; + if (env.TMUX || env.STY || env.ZELLIJ || /^(?:tmux|screen)/u.test(env.TERM ?? '')) return 'none'; + if (capabilities.graphicsProtocol === 'kitty') return 'kitty'; + if (capabilities.graphicsProtocol === 'iterm2') return 'iterm2'; + return 'none'; +} + +/** Max bytes of base64 per Kitty APC chunk (the protocol's limit). */ +export const KITTY_CHUNK = 4096; +const ESC = '\u001b'; +const ST = `${ESC}\\`; + +export interface ImageSize { columns: number; rows: number } + +/** + * Transmit a PNG once under `id` without displaying it (a=t), quietly (q=2: + * no replies reach stdin), chunked as the protocol requires. + */ +export function kittyTransmit(png: Uint8Array, id: number): string { + const data = Buffer.from(png).toString('base64'); + if (!Number.isInteger(id) || id < 1 || id > 0xffffffff) throw new RangeError('kitty image id out of range'); + let output = ''; + for (let offset = 0; offset < data.length || offset === 0; offset += KITTY_CHUNK) { + const chunk = data.slice(offset, offset + KITTY_CHUNK); + const more = offset + KITTY_CHUNK < data.length ? 1 : 0; + output += offset === 0 ? `${ESC}_Ga=t,f=100,t=d,i=${id},q=2,m=${more};${chunk}${ST}` : `${ESC}_Gm=${more};${chunk}${ST}`; + if (!data.length) break; + } + return output; +} + +/** Display a transmitted image at the cursor, scaled into a cell box, without moving the cursor (C=1). */ +export function kittyPlace(id: number, size: ImageSize, placement = 1): string { + return `${ESC}_Ga=p,i=${id},p=${placement},c=${Math.max(1, size.columns)},r=${Math.max(1, size.rows)},C=1,q=2${ST}`; +} + +/** Delete an image's placements and its data (uppercase I frees the stored image). */ +export function kittyDelete(id: number): string { + return `${ESC}_Ga=d,d=I,i=${id},q=2${ST}`; +} + +/** iTerm2 inline image (OSC 1337 File=) in a cell box, aspect preserved. */ +export function iterm2Image(png: Uint8Array, size: ImageSize, name = 'nmsh.png'): string { + const bytes = Buffer.from(png); + return `${ESC}]1337;File=name=${Buffer.from(name).toString('base64')};size=${bytes.length};width=${Math.max(1, size.columns)};height=${Math.max(1, size.rows)};preserveAspectRatio=1;inline=1:${bytes.toString('base64')}\u0007`; +} + +/** Cell box for an image of pixel size w×h fitting in maxColumns×maxRows (cells are ~1:2). */ +export function fitCells(width: number, height: number, maxColumns: number, maxRows: number): ImageSize { + const aspect = width / Math.max(1, height); + let rows = Math.max(1, maxRows); + let columns = Math.round(rows * aspect * 2); + if (columns > maxColumns) { columns = Math.max(1, maxColumns); rows = Math.max(1, Math.round(columns / (aspect * 2))); } + return {columns, rows}; +} + +/** PNG pixel size from its IHDR chunk; undefined if this is not a PNG. */ +export function pngSize(png: Uint8Array): {width: number; height: number} | undefined { + const bytes = Buffer.from(png); + if (bytes.length < 24 || bytes.readUInt32BE(0) !== 0x89504e47 || bytes.toString('ascii', 12, 16) !== 'IHDR') return undefined; + return {width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20)}; +} + +/** + * One overlay the renderer owns: transmitted once, (re)placed whenever its + * rows are repainted, and removed on close, passthrough, and exit. + */ +export interface ImageOverlay { + /** Identity: a different key replaces the overlay. */ + key: string; + /** Zero-based screen row and column of the box's top-left cell. */ + row: number; + column: number; + size: ImageSize; + /** Sent once before the first placement (Kitty data upload); empty for iTerm2. */ + transmit: string; + /** Sent at the box position each time the box's rows are repainted. */ + place: string; + /** Sent when the overlay goes away. Empty for iTerm2: repainting the rows removes it. */ + cleanup: string; +} + +let nextKittyId = 0x4e4d0000; // "NM" prefix keeps NMSh ids distinct from programs' own. + +export function createImageOverlay(protocol: ImageProtocol, png: Uint8Array, key: string, row: number, column: number, size: ImageSize): ImageOverlay | undefined { + if (protocol === 'none' || !pngSize(png)) return undefined; + if (protocol === 'kitty') { + const id = ++nextKittyId; + return {key, row, column, size, transmit: kittyTransmit(png, id), place: kittyPlace(id, size), cleanup: kittyDelete(id)}; + } + return {key, row, column, size, transmit: '', place: iterm2Image(png, size), cleanup: ''}; +} + +/** True when the string contains any image protocol bytes; used to prove fallbacks emit none. */ +export function containsImageBytes(text: string): boolean { + return text.includes(`${ESC}_G`) || text.includes(`${ESC}]1337;File=`); +} 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/presentation/cellOverlay.ts b/src/presentation/cellOverlay.ts new file mode 100644 index 00000000..80f93fc2 --- /dev/null +++ b/src/presentation/cellOverlay.ts @@ -0,0 +1,154 @@ +import {graphemes} from '../input/inputLayout.js'; +import {displayWidth} from '../util/text.js'; +import type {RgbColor} from '../ui/palette.js'; +import {colorEscape} from '../chroma/escape.js'; +import {mixRgb} from '../chroma/chroma.js'; +import {readableForeground} from '../chroma/color.js'; + +/** + * Presentation overlays on already-rendered ANSI rows, cell by cell: cursor + * trails, particles, sweeps. An overlay never changes a row's width or its + * text, and (except Rich motion's explicit `fill`) never paints a background + * behind text: SGR has no per-cell alpha, so a background is an opaque box on a + * transparent or frosted terminal. On a text cell an overlay shifts the glyph's own + * foreground (`tint`) or adds weight (`dim`, `bold`, `underline`); on a blank + * cell it may draw a glyph. Only the visual caret may fill a cell + * (`background` with `caret`). Rows themselves are never stored with overlays. + */ +export interface CellPaint { + /** The caret's own fill, honored only together with `caret`. Tints must not use it: it would hide a transparent terminal. */ + background?: RgbColor; + /** Shift the cell's own foreground toward a color; the glyph never changes. A cell with no explicit foreground takes the color itself. */ + tint?: {color: RgbColor; amount: number}; + /** + * Rich motion only: a filled band behind the cell's own glyph (the stronger look). Clean motion and the + * portable cursor engine never set it, so a transparent terminal stays transparent for them. + */ + fill?: RgbColor; + dim?: boolean; + bold?: boolean; + underline?: boolean; + /** A glyph for a blank cell only; ignored on a cell that holds text. */ + glyph?: string; + /** The glyph's color (blank cells only). */ + foreground?: RgbColor; + /** Force-replace even a text cell (only the visual caret uses this). */ + caret?: boolean; + /** + * The visual caret drawn in a real terminal cursor's shape: Block fills the + * cell, Bar and Underline draw a thin glyph on a blank cell (and mark a text + * cell with weight or an underline instead of replacing its glyph). `color` + * is the caret color. + */ + caretShape?: 'block' | 'bar' | 'underline'; + color?: RgbColor; +} + +const CARET_GLYPH = {bar: '▏', underline: '▁'} as const; + +/** The SGR for a shaped caret over one cell (`blank`: a space, which a thin shape may draw into). */ +function shapedCaret(paint: CellPaint, glyph: string, blank: boolean): string { + const color = paint.color ?? paint.background ?? {red: 220, green: 220, blue: 220}; + const shape = paint.caretShape!; + if (shape === 'block') return `${rgb(48, color)}${rgb(38, readableForeground(color))}${glyph}`; + if (blank) return `${rgb(38, color)}${CARET_GLYPH[shape]}`; + return `${rgb(38, color)}${shape === 'underline' ? '\u001B[4m' : '\u001B[1m'}${glyph}`; +} + +const ESCAPE = /\u001B\[[0-?]*[ -/]*[@-~]|\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)/gu; +const rounded = (color: RgbColor): RgbColor => ({red: Math.round(color.red), green: Math.round(color.green), blue: Math.round(color.blue)}); +const rgb = (code: 38 | 48, color: RgbColor) => colorEscape(code, rounded(color)); + +const ANSI16_RGB: ReadonlyArray = [[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]]; + +function indexedRgb(index: number): RgbColor { + if (index < 16) { const [red, green, blue] = ANSI16_RGB[index]!; return {red, green, blue}; } + if (index >= 232) { const gray = 8 + (index - 232) * 10; return {red: gray, green: gray, blue: gray}; } + const cube = [0, 95, 135, 175, 215, 255]; + const n = index - 16; + return {red: cube[Math.floor(n / 36)]!, green: cube[Math.floor(n / 6) % 6]!, blue: cube[n % 6]!}; +} + +/** The foreground in effect after replaying SGR sequences since the last reset; undefined when it is the terminal's default. */ +export function currentForeground(style: string): RgbColor | undefined { + let color: RgbColor | undefined; + for (const match of style.matchAll(/\u001B\[([\d;]*)m/gu)) { + const params = match[1]!.split(';').map(value => (value === '' ? 0 : Number(value))); + for (let index = 0; index < params.length; index += 1) { + const code = params[index]!; + if (code === 0 || code === 39) color = undefined; + else if (code === 38 && params[index + 1] === 2) { color = {red: params[index + 2] ?? 0, green: params[index + 3] ?? 0, blue: params[index + 4] ?? 0}; index += 4; } + else if (code === 38 && params[index + 1] === 5) { color = indexedRgb(params[index + 2] ?? 0); index += 2; } + else if (code >= 30 && code <= 37) color = indexedRgb(code - 30); + else if (code >= 90 && code <= 97) color = indexedRgb(code - 90 + 8); + } + } + return color; +} + +/** A foreground-only treatment for a text cell: tint toward a color, plus weight. Never a background. */ +function textTreatment(paint: CellPaint, style: string): string { + let out = ''; + if (paint.bold) out += '\u001B[1m'; + if (paint.dim) out += '\u001B[2m'; + if (paint.underline) out += '\u001B[4m'; + if (paint.tint && paint.tint.amount > 0.02) { + const base = currentForeground(style); + // Without an explicit foreground there is nothing to blend with, so a visible tint takes the color; a faint one does nothing. + const color = base ? mixRgb(base, paint.tint.color, Math.min(1, paint.tint.amount)) : paint.tint.amount >= 0.2 ? paint.tint.color : undefined; + if (color) out += rgb(38, color); + } + return out; +} + +/** Apply paints (column → paint) to one rendered row. Columns past the row's end are padded with plain spaces first. */ +export function overlayRow(row: string, paints: ReadonlyMap, columns?: number): string { + if (!paints.size) return row; + const last = Math.max(...paints.keys()); + let width = displayWidth(row); + let source = row; + if (last >= width && (columns === undefined || last < columns)) { source = `${row}\u001B[0m${' '.repeat(last + 1 - width)}`; width = last + 1; } + let out = ''; + let column = 0; + // The SGR state since the last reset, replayed after a painted cell so the rest of the row is unchanged. + let style = ''; + let index = 0; + ESCAPE.lastIndex = 0; + while (index < source.length) { + ESCAPE.lastIndex = index; + const escape = ESCAPE.exec(source); + if (escape && escape.index === index) { + out += escape[0]; + if (/\u001B\[[\d;]*m/u.test(escape[0])) style = /^\u001B\[0?m$/u.test(escape[0]) ? '' : style + escape[0]; + index += escape[0].length; + continue; + } + const next = escape ? escape.index : source.length; + for (const glyph of graphemes(source.slice(index, next))) { + const cells = Math.max(1, displayWidth(glyph)); + const paint = paints.get(column); + if (paint?.caret && paint.caretShape) { + out += `${shapedCaret(paint, glyph, glyph === ' ')}\u001B[0m${style}`; + } else if (paint) { + const blank = glyph === ' '; + const drawn = paint.caret ? (paint.glyph ?? glyph) : blank && paint.glyph ? paint.glyph : glyph; + const drawsGlyph = (paint.caret || blank) && paint.foreground; + const fg = drawsGlyph ? rgb(38, paint.foreground!) : ''; + // Only the caret may fill a cell: everything else keeps the host's own background. + const bg = paint.caret && paint.background ? rgb(48, paint.background) : paint.fill ? rgb(48, paint.fill) : ''; + const text = !drawsGlyph && !blank ? textTreatment(paint, style) : drawsGlyph && paint.dim ? '\u001B[2m' : ''; + out += `${bg}${fg}${text}${drawn}\u001B[0m${style}`; + } else out += glyph; + column += cells; + } + index = next; + } + return out; +} + +/** Apply paints keyed by row then column to a frame's rows (only the rows with paints are touched). */ +export function overlayFrame(rows: string[], paints: ReadonlyMap>, columns?: number): string[] { + if (!paints.size) return rows; + return rows.map((row, index) => { const cells = paints.get(index); return cells ? overlayRow(row, cells, columns) : row; }); +} diff --git a/src/prompt/Powerlevel10kConfigurator.ts b/src/prompt/Powerlevel10kConfigurator.ts index e6c946fd..ece76282 100644 --- a/src/prompt/Powerlevel10kConfigurator.ts +++ b/src/prompt/Powerlevel10kConfigurator.ts @@ -21,11 +21,12 @@ export function powerlevel10kZshrcPath(env: NodeJS.ProcessEnv = process.env): st return resolve(join(env.ZDOTDIR || env.HOME || homedir(), '.zshrc')); } -function fingerprint(content: Buffer): string { +export function fingerprint(content: Buffer): string { return createHash('sha256').update(content).digest('hex'); } -async function snapshot(path: string): Promise { +/** Fingerprint and back up one config file before a handoff that may change it (absent files are recorded as absent). */ +export async function snapshot(path: string): Promise { try { const info = await lstat(path); if (!info.isFile() || info.isSymbolicLink()) throw new Error(`${path} is not a regular file; open the wizard manually from /zsh.`); diff --git a/src/prompt/PromptPanel.ts b/src/prompt/PromptPanel.ts index d9aacdfd..8b2eae0e 100644 --- a/src/prompt/PromptPanel.ts +++ b/src/prompt/PromptPanel.ts @@ -1,3 +1,13 @@ +import {routeModule} from '../context/surfaceRouter.js'; +import {railNeedsPromptConversion} from './railLayout.js'; +import {chromaPreviewNote} from '../appearance/chromaNotes.js'; +import {gradientEditorControls, gradientEditorKey, renderGradientEditorRows, type GradientEditorState} from '../ui/GradientEditor.js'; +export {parseStopInput, type GradientEditorState} from '../ui/GradientEditor.js'; +import {DIVIDER_LINES_HELP, dividerLinesLabel, SEMANTIC_MODES, SEMANTIC_MODE_LABELS, TREATMENT_DIRECTION_LABELS} from '../chroma/treatment.js'; +import {getCurrentGlyphMode} from '../ui/glyphs.js'; +import {PROMPT_SYMBOL_IDS, promptSymbolGlyph, promptSymbolLabel, separatorLabel, validateGlyph} from './glyphChoices.js'; +import {CATPPUCCIN_ACCENTS, CATPPUCCIN_ACCENT_LABELS, THEME_FAMILIES} from '../appearance/themeFamilies.js'; +import {FAMILY_IDS, familyOf, selectFamily, variantLabel, variantOptions} from '../appearance/themeSelection.js'; import { applyNativeGapChoice, CONNECTOR_FADE_STYLES, @@ -11,13 +21,24 @@ import { type GitColorMode, nativeGapChoice, type NativeGapChoice, - type PromptConfiguration, + type PromptConfiguration, type ContextRailSettings, DEFAULT_CONTEXT_RAIL, + type NativePaletteId, type PromptProviderId, - modulePlacement, + modulePlacement, applyModulePlacement, + MODULE_SURFACES, MODULE_SURFACE_LABELS, THEME_PALETTE_IDS, ON_COMMAND_MODULES, } from './configuration.js'; import {NATIVE_PROMPT_THEMES, RICH_GIT_SHOWCASE} from './prompt.js'; import {fadeColorChoices, POWERLINE_EDGE_STYLES, POWERLINE_SHAPES, PROMPT_STYLES, PROMPT_STYLE_LABELS, type ConnectorFadeColors, type PowerlineEdgeStyle, type PowerlineShape} from './powerline.js'; +import {PROMPT_STYLE_NOTES, STYLE_OPTION_LABELS, STYLE_PROFILE_OPTIONS, type ProfiledStyle} from './styles.js'; +import {VIBRANCE_LABELS, VIBRANCE_LEVELS, parseHexColor} from '../chroma/color.js'; +import { + MAX_CUSTOM_STOPS, MIN_CUSTOM_STOPS, motionHasDirection, PRESET_STOPS, TREATMENT_CURVE_LABELS, TREATMENT_CURVES, TREATMENT_DIRECTIONS, + TREATMENT_GEOMETRIES, TREATMENT_GEOMETRY_LABELS, TREATMENT_INFLUENCES, TREATMENT_MOTION_LABELS, TREATMENT_MOTIONS, TREATMENT_PRESET_LABELS, + TREATMENT_PRESETS, TREATMENT_SCOPE_LABELS, TREATMENT_SCOPES, TREATMENT_SPEED_LABELS, TREATMENT_SPEEDS, treatmentInfluence, treatmentSwatch, +} from '../chroma/treatment.js'; +import {isReducedMotion} from '../presentation/environment.js'; +import {colorEscape} from '../chroma/escape.js'; import {renderControls} from '../ui/controls.js'; import {renderTabStrip} from '../ui/PanelShell.js'; import type {StarshipStatus} from './starship.js'; @@ -25,12 +46,13 @@ import {STARSHIP_MODULES, type StarshipConfigProposal} from './StarshipConfigAda import type {Powerlevel10kStatus} from './powerlevel10k.js'; import {powerlevel10kZshrcPath, type ConfiguratorPreparation} from './Powerlevel10kConfigurator.js'; import type {Key} from '../terminal/keys.js'; -import {foreground, UI_COLORS} from '../ui/palette.js'; -import {stripAnsi, truncateAnsi} from '../util/text.js'; +import {focusForeground, foreground, UI_COLORS, type RgbColor, lazyForeground} from '../ui/palette.js'; +import {displayWidth, labelColumnWidth, padCells, stripAnsi, truncateAnsi} from '../util/text.js'; import {renderTaskProgress, type TaskProgress} from '../status/TaskProgress.js'; import {providerRowText, type ProviderDescriptor} from '../providers/providers.js'; -export type PromptPanelStep = 'provider' | 'starship' | 'starshipModules' | 'starshipConfirm' | 'powerlevel10k' | 'p10kConfirm' | 'p10kReady' | 'p10kResult' | 'layout' | 'appearance' | 'modules' | 'installConfirm' | 'installProgress' | 'installResult' | 'installDetails'; +export type PromptPanelStep = 'railInsideConfirm' | 'provider' | 'starship' | 'starshipModules' | 'starshipConfirm' | 'powerlevel10k' | 'p10kConfirm' | 'p10kReady' | 'p10kResult' | 'layout' | 'appearance' | 'modules' | 'gradient' | 'installConfirm' | 'installProgress' | 'installResult' | 'installDetails'; + export interface PromptPanelState { onboarding: boolean; step: PromptPanelStep; @@ -43,23 +65,32 @@ export interface PromptPanelState { p10kPreparation?: ConfiguratorPreparation; p10kResult?: string[]; message?: string; + railConfirmReturn?: {step: PromptPanelStep; selectedIndex: number}; task?: TaskProgress; starshipModules?: boolean[]; starshipProposal?: StarshipConfigProposal; + /** The palette the Chroma quick control turned Off, restored when it turns Chroma back On (panel-local, never saved). */ + chromaRestore?: PromptConfiguration['presentation']['preset']; /** NMSh appearance top view: Main Prompt (default) or Rich Git. */ view?: PromptView; /** `tabs`: ←/→ switch views; `rows` (default): ←/→ edit the selected row. */ focus?: 'tabs' | 'rows'; + gradient?: GradientEditorState; + /** Typing a custom glyph: the row being edited, the typed text, and a factual validation note. */ + glyphEdit?: {rowId: string; buffer: string; note?: string}; } -export type PromptView = 'main' | 'git'; -export const PROMPT_VIEWS = ['Main Prompt', 'Rich Git'] as const; -const PROMPT_VIEW_IDS: readonly PromptView[] = ['main', 'git']; +export type PromptView = 'main' | 'git' | 'chroma' | 'rail'; +export const PROMPT_VIEWS = ['Main Prompt', 'Rich Git', 'Chroma', 'Context Rail'] as const; +const PROMPT_VIEW_IDS: readonly PromptView[] = ['main', 'git', 'chroma', 'rail']; -const PRIMARY = foreground(UI_COLORS.primary); -const SECONDARY = foreground(UI_COLORS.secondary); -const ACCENT = foreground(UI_COLORS.accent); -const SUBTLE = foreground(UI_COLORS.subtle); +const PRIMARY = lazyForeground(UI_COLORS.primary); +const SECONDARY = lazyForeground(UI_COLORS.secondary); +const ACCENT = lazyForeground(UI_COLORS.accent); +const BOLD = '\u001B[1m'; +const SUBTLE = lazyForeground(UI_COLORS.subtle); +const ERROR = lazyForeground(UI_COLORS.failure); +const INVERSE = '\u001B[7m'; const RESET = '\u001B[0m'; const GAP_CHOICES: readonly NativeGapChoice[] = ['off', 'compact', 'normal', 'wide']; /** Prompt providers on the shared provider descriptor (#134). */ @@ -67,6 +98,8 @@ export const PROMPT_PROVIDERS: readonly ProviderDescriptor[] = {id: 'nmsh', family: 'prompt', label: 'NMSh Native', kind: 'native', description: 'built-in themes, geometry, and modules'}, {id: 'starship', family: 'prompt', label: 'Starship', kind: 'external', executable: 'starship', description: 'use its themes/configuration'}, {id: 'powerlevel10k', family: 'prompt', label: 'Powerlevel10k', kind: 'external', description: 'use your ~/.p10k.zsh left prompt'}, + {id: 'ohMyPosh', family: 'prompt', label: 'Oh My Posh', kind: 'external', executable: 'oh-my-posh', description: 'render with oh-my-posh; no shell rc change'}, + {id: 'none', family: 'prompt', label: 'None', kind: 'none', description: 'composer only: no prompt row or modules; the input marker stays'}, ]; export const PROVIDER_ORDER: readonly PromptProviderId[] = PROMPT_PROVIDERS.map(provider => provider.id); @@ -74,16 +107,290 @@ export function providerLabel(provider: PromptProviderId): string { return PROMPT_PROVIDERS.find(descriptor => descriptor.id === provider)?.label ?? provider; } -// Style is appended so existing row positions stay stable. -const APPEARANCE_ROWS = ['theme', 'start', 'connector', 'connectorFade', 'fadeColors', 'gap', 'end', 'icons', 'modules', 'style'] as const; -export const APPEARANCE_MODULES_ROW = APPEARANCE_ROWS.indexOf('modules'); +/** + * One editable appearance row. Rows are derived from the draft, so a style + * shows only controls that change it and a hidden row can never be edited. + */ +export interface AppearanceRow { + id: string; + label: string; + value(configuration: PromptConfiguration): string; + /** ←/→; undefined for rows opened with Enter. */ + change?(configuration: PromptConfiguration, delta: number): void; + note?(configuration: PromptConfiguration): string | undefined; + /** Enter opens a sub-editor instead of saving. */ + opens?: 'modules' | 'gradient'; + /** Enter starts typing a one-glyph value (custom separator or prompt symbol). */ + edit?: {get(configuration: PromptConfiguration): string | undefined; set(configuration: PromptConfiguration, glyph: string): void}; +} + +const cycleKey = (values: readonly T[], current: T, delta: number): T => cycle(values, current, delta); +const optionLabel = (value: string | number) => typeof value === 'number' ? String(value) : STYLE_OPTION_LABELS[value] ?? value; + +/** Rows for one style profile field, cycling only that style's valid options. */ +function profileRow(style: S, key: K, label: string, + format: (value: string | number) => string = optionLabel): AppearanceRow { + return { + id: `${style}.${key}`, label, + value: configuration => format((configuration.nmsh.styleProfiles[style] as unknown as Record)[key]!), + change: (configuration, delta) => { + const profile = configuration.nmsh.styleProfiles[style] as unknown as Record; + const options = (STYLE_PROFILE_OPTIONS[style] as unknown as Record)[key]!; + profile[key] = cycleKey(options, profile[key]!, delta); + }, + }; +} + +const cells = (value: string | number) => `${value} ${value === 1 ? 'cell' : 'cells'}`; + +const POWERLINE_ROWS: readonly AppearanceRow[] = [ + {id: 'start', label: 'Start', value: c => edgeStyleLabel(c.nmsh.startStyle), change: (c, d) => { c.nmsh.startStyle = cycle(POWERLINE_EDGE_STYLES, c.nmsh.startStyle, d); }}, + {id: 'connector', label: 'Connector', value: c => SHAPE_LABELS[c.nmsh.connector], change: (c, d) => { c.nmsh.connector = cycle(POWERLINE_SHAPES, c.nmsh.connector, d); }}, + {id: 'connectorFade', label: 'Connector fade', value: c => connectorFadeLabel(c.nmsh.connectorFade), + change: (c, d) => { c.nmsh.connectorFade = cycle(CONNECTOR_FADE_STYLES, c.nmsh.connectorFade, d); }}, + {id: 'fadeColors', label: 'Fade colors', value: c => fadeColorsLabel(c.nmsh.connectorFadeColors), + change: (c, d) => { c.nmsh.connectorFadeColors = cycle(fadeColorChoices(c.nmsh.gapEnabled, c.gap), c.nmsh.connectorFadeColors, d); }}, + {id: 'gap', label: 'Gap', value: c => gapLabel(nativeGapChoice(c)), change: (c, d) => applyNativeGapChoice(c, cycle(GAP_CHOICES, nativeGapChoice(c), d))}, + {id: 'end', label: 'End', value: c => edgeStyleLabel(c.nmsh.endStyle), change: (c, d) => { c.nmsh.endStyle = cycle(POWERLINE_EDGE_STYLES, c.nmsh.endStyle, d); }}, + {id: 'padding', label: 'Padding', value: c => cells(c.spacing), change: (c, d) => { c.spacing = cycle([0, 1, 2, 3], c.spacing, d); }}, +]; + +/** The style-scoped custom separator glyph; switching styles keeps each style's own glyph. */ +function customSeparatorRow(style: 'minimal' | 'breadcrumb' | 'outline'): AppearanceRow { + return {id: `${style}.customSeparator`, label: ' Glyph', value: c => c.nmsh.styleProfiles[style].customSeparator ?? 'Enter to type one', + note: () => 'one character, 1–2 cells', + edit: {get: c => c.nmsh.styleProfiles[style].customSeparator, set: (c, glyph) => { c.nmsh.styleProfiles[style].customSeparator = glyph; }}}; +} + +const PROMPT_SYMBOL_ROW: AppearanceRow = {id: 'promptSymbol', label: 'Prompt symbol', value: c => promptSymbolLabel(c.promptSymbol, c.promptSymbolCustom), + change: (c, d) => { c.promptSymbol = cycle(PROMPT_SYMBOL_IDS, c.promptSymbol, d); }, + note: c => getCurrentGlyphMode() === 'safe' && promptSymbolGlyph(c.promptSymbol, c.promptSymbolCustom, true) !== promptSymbolGlyph(c.promptSymbol, c.promptSymbolCustom, false) + ? `Safe glyphs show ${promptSymbolGlyph(c.promptSymbol, c.promptSymbolCustom, false)}` : undefined}; +const PROMPT_SYMBOL_GLYPH_ROW: AppearanceRow = {id: 'promptSymbolCustom', label: ' Glyph', value: c => c.promptSymbolCustom ?? 'Enter to type one', + note: () => 'one character, 1–2 cells', edit: {get: c => c.promptSymbolCustom, set: (c, glyph) => { c.promptSymbolCustom = glyph; }}}; + +/** Theme family, then its variant and (Catppuccin) accent, indented under it. */ +function themeRows(configuration: PromptConfiguration): AppearanceRow[] { + const family = familyOf(configuration.nmsh.palette); + const variants = variantOptions(family); + return [ + {id: 'themeFamily', label: 'Theme family', value: c => THEME_FAMILIES.find(item => item.id === familyOf(c.nmsh.palette))!.label, + change: (c, d) => { + const next = selectFamily(c, cycle(FAMILY_IDS, familyOf(c.nmsh.palette), d)); + c.nmsh = next.nmsh; + if (next.customTheme) c.customTheme = next.customTheme; + }}, + ...(variants.length > 1 ? [{id: 'themeVariant', label: ` ${variantLabel(configuration)}`, + value: (c: PromptConfiguration) => variantOptions(familyOf(c.nmsh.palette)).find(option => option.id === c.nmsh.palette)?.label ?? c.nmsh.palette, + change: (c: PromptConfiguration, d: number) => { c.nmsh.palette = cycle(variantOptions(familyOf(c.nmsh.palette)).map(option => option.id), c.nmsh.palette, d); }}] : []), + ...(family === 'catppuccin' ? [{id: 'themeAccent', label: ' Accent', value: (c: PromptConfiguration) => CATPPUCCIN_ACCENT_LABELS[c.nmsh.accent], + change: (c: PromptConfiguration, d: number) => { c.nmsh.accent = cycle(CATPPUCCIN_ACCENTS, c.nmsh.accent, d); }}] : []), + ...(family === 'custom' ? [{id: 'themeStudio', label: ' Edit colors', value: (c: PromptConfiguration) => `${c.customTheme?.name ?? 'Custom'} · /theme ›`, + note: () => 'clone, edit, import and export in /theme'}] : []), + ]; +} + +/** Gallery entries for the current family: NMSh themes, or the selected family's variants. */ +export function galleryPalettes(configuration: PromptConfiguration): NativePaletteId[] { + return variantOptions(familyOf(configuration.nmsh.palette)).map(option => option.id); +} + +/** Each style's own controls, shown only when they can change that style. */ +export function styleRows(configuration: PromptConfiguration): AppearanceRow[] { + const profiles = configuration.nmsh.styleProfiles; + switch (configuration.nmsh.style) { + case 'powerline': return POWERLINE_ROWS.filter(row => + // A fade lives in a gap: with the gap Off neither the fade nor its colors can show. + (row.id !== 'connectorFade' || configuration.nmsh.gapEnabled) + && (row.id !== 'fadeColors' || (configuration.nmsh.gapEnabled && configuration.nmsh.connectorFade !== 'off'))); + case 'soft': return [profileRow('soft', 'cap', 'Caps'), profileRow('soft', 'layout', 'Layout'), + ...(profiles.soft.layout === 'separated' ? [profileRow('soft', 'gap', 'Gap', cells)] : []), + profileRow('soft', 'padding', 'Padding', cells), profileRow('soft', 'fill', 'Fill')]; + case 'minimal': return [profileRow('minimal', 'separator', 'Separator', value => separatorLabel(String(value), profiles.minimal.customSeparator)), + ...(profiles.minimal.separator === 'custom' ? [customSeparatorRow('minimal')] : []), + profileRow('minimal', 'spacing', 'Spacing', cells), profileRow('minimal', 'emphasis', 'Bold')]; + case 'outline': return [profileRow('outline', 'cap', 'Outline'), profileRow('outline', 'layout', 'Layout'), + ...(profiles.outline.layout === 'separated' ? [profileRow('outline', 'gap', 'Gap', cells)] : [ + profileRow('outline', 'divider', ' Divider', value => separatorLabel(String(value), profiles.outline.customSeparator)), + ...(profiles.outline.divider === 'custom' ? [customSeparatorRow('outline')] : [])]), + profileRow('outline', 'padding', 'Padding', cells)]; + case 'breadcrumb': return [profileRow('breadcrumb', 'separator', 'Separator', value => separatorLabel(String(value), profiles.breadcrumb.customSeparator)), + ...(profiles.breadcrumb.separator === 'custom' ? [customSeparatorRow('breadcrumb')] : []), + profileRow('breadcrumb', 'anchor', 'Anchor'), profileRow('breadcrumb', 'spacing', 'Spacing', cells)]; + case 'compact': return [profileRow('compact', 'ends', 'Ends'), profileRow('compact', 'padding', 'Padding', cells), + profileRow('compact', 'seams', 'Seams')]; + case 'ribbon': return [profileRow('ribbon', 'slant', 'Slant'), profileRow('ribbon', 'ends', 'Ends'), + profileRow('ribbon', 'padding', 'Padding', cells), profileRow('ribbon', 'band', 'Band')]; + } +} + +/** Main Prompt rows: theme, style and vibrance, the style's own controls, then icons and modules. */ +export function appearanceRows(configuration: PromptConfiguration): AppearanceRow[] { + // Prompt None keeps only the input marker (plus the theme, which still styles NMSh UI). + if (configuration.provider === 'none') return [...themeRows(configuration), PROMPT_SYMBOL_ROW, ...(configuration.promptSymbol === 'custom' ? [PROMPT_SYMBOL_GLYPH_ROW] : [])]; + return [ + ...themeRows(configuration), + {id: 'textColors', label: 'Text colors', value: c => c.nmsh.textColors === 'neutral' ? 'Neutral' : 'Theme', + change: c => { c.nmsh.textColors = c.nmsh.textColors === 'neutral' ? 'theme' : 'neutral'; }, + note: c => c.nmsh.textColors === 'neutral' ? 'Neutral: the theme\'s fills, connectors and accent stay; text is a steady neutral' : 'Theme: the theme\'s own text colors'}, + {id: 'style', label: 'Style', value: c => PROMPT_STYLE_LABELS[c.nmsh.style], change: (c, d) => { c.nmsh.style = cycle(PROMPT_STYLES, c.nmsh.style, d); }, + note: c => PROMPT_STYLE_NOTES[c.nmsh.style]}, + {id: 'vibrance', label: 'Vibrance', value: c => VIBRANCE_LABELS[c.nmsh.vibrance], change: (c, d) => { c.nmsh.vibrance = cycle(VIBRANCE_LEVELS, c.nmsh.vibrance, d); }}, + ...styleRows(configuration), + {id: 'icons', label: 'Icons', value: c => c.nmsh.icons === 'off' ? 'Off' : 'On', change: c => { c.nmsh.icons = c.nmsh.icons === 'off' ? 'nerd' : 'off'; }}, + PROMPT_SYMBOL_ROW, + ...(configuration.promptSymbol === 'custom' ? [PROMPT_SYMBOL_GLYPH_ROW] : []), + {id: 'modules', label: 'Modules', value: c => `${c.modules.filter(module => module.visible).length} of ${c.modules.length} shown ›`, opens: 'modules'}, + ]; +} + +export function appearanceModulesRow(configuration: PromptConfiguration): number { + return appearanceRows(configuration).findIndex(row => row.id === 'modules'); +} + +/** Chroma rows; everything after Palette hides while Chroma is Off, and timing rows hide while static. */ +export function chromaRows(configuration: PromptConfiguration): AppearanceRow[] { + const p = configuration.presentation; + const rows: AppearanceRow[] = [{id: 'preset', label: 'Palette', value: c => TREATMENT_PRESET_LABELS[c.presentation.preset], + change: (c, d) => { + c.presentation.preset = cycle(TREATMENT_PRESETS, c.presentation.preset, d); + // Custom always has stops to edit: seed from the Lavender family. + if (c.presentation.preset === 'custom' && c.presentation.customStops.length < MIN_CUSTOM_STOPS) c.presentation.customStops = [...PRESET_STOPS.lavender]; + }, + note: c => c.provider !== 'nmsh' ? 'external prompts keep their own colors' : undefined}]; + if (p.preset === 'off') return rows; + if (p.preset === 'custom') rows.push({id: 'gradient', label: 'Custom gradient', value: c => `${c.presentation.customStops.length} stops ›`, opens: 'gradient'}); + rows.push( + {id: 'influence', label: 'Influence', value: c => TREATMENT_INFLUENCES.find(entry => entry.id === treatmentInfluence(c.presentation))!.label, + change: (c, d) => { + const ids = TREATMENT_INFLUENCES.map(entry => entry.id); + const next = cycle(ids, treatmentInfluence(c.presentation), d); + c.presentation.intensity = TREATMENT_INFLUENCES.find(entry => entry.id === next)!.intensity; + if (next === 'full') c.presentation.semantic = 'override'; + }}, + {id: 'semantic', label: ' Semantic colors', value: c => SEMANTIC_MODE_LABELS[c.presentation.semantic ?? 'preserve'], + change: (c, d) => { c.presentation.semantic = cycle(SEMANTIC_MODES, c.presentation.semantic ?? 'preserve', d); }, + note: c => (c.presentation.semantic ?? 'preserve') === 'override' ? 'symbols and readable text keep success, failure and Git meaning' : 'success, failure and Git state keep their colors'}, + {id: 'scope', label: 'Applies to', value: c => TREATMENT_SCOPE_LABELS[c.presentation.scope ?? 'prompt'], + change: (c, d) => { c.presentation.scope = cycle(TREATMENT_SCOPES, c.presentation.scope ?? 'prompt', d); }}, + {id: 'rules', label: 'Divider lines', value: c => dividerLinesLabel(c.presentation.rules !== false), + change: c => { c.presentation.rules = c.presentation.rules === false; }, note: () => DIVIDER_LINES_HELP}, + {id: 'geometry', label: 'Gradient layout', value: c => TREATMENT_GEOMETRY_LABELS[c.presentation.geometry], + change: (c, d) => { c.presentation.geometry = cycle(TREATMENT_GEOMETRIES, c.presentation.geometry, d); }}, + {id: 'motion', label: 'Motion', value: c => TREATMENT_MOTION_LABELS[c.presentation.motion], + change: (c, d) => { c.presentation.motion = cycle(TREATMENT_MOTIONS, c.presentation.motion, d); }, + note: c => c.presentation.motion !== 'static' && (c.presentation.reducedMotion || c.presentation.effectsOff || isReducedMotion()) + ? 'held still: Reduced Motion / Decorative effects Off' : undefined}, + ); + if (p.motion !== 'static') { + rows.push( + {id: 'speed', label: 'Speed', value: c => TREATMENT_SPEED_LABELS[c.presentation.speed ?? 'normal'], + change: (c, d) => { c.presentation.speed = cycle(TREATMENT_SPEEDS, c.presentation.speed ?? 'normal', d); }}, + {id: 'curve', label: 'Ramp', value: c => TREATMENT_CURVE_LABELS[c.presentation.curve ?? 'linear'], + change: (c, d) => { c.presentation.curve = cycle(TREATMENT_CURVES, c.presentation.curve ?? 'linear', d); }}, + ); + if (motionHasDirection(p.motion)) rows.push({id: 'direction', label: 'Motion direction', value: c => TREATMENT_DIRECTION_LABELS[c.presentation.direction ?? 'forward'], + change: (c, d) => { c.presentation.direction = cycle(TREATMENT_DIRECTIONS, c.presentation.direction ?? 'forward', d); }}); + } + // Only meaningful when some module actually has explicit colors. + if (configuration.modules.some(module => module.foreground || module.background)) { + rows.push({id: 'customColors', label: 'Custom module colors', value: c => c.presentation.customColors ? 'Chroma too' : 'Kept as set', + change: c => { c.presentation.customColors = !c.presentation.customColors; }}); + } + return rows; +} + +/** Deliberately bounded Rail controls over the existing theme library and style painter. */ +type RailChoice = 'relation' | 'direction' | 'integration' | 'spacing' | 'dividerAnchor'; +function railChoice(id: string, label: string, field: K, + choices: readonly (readonly [NonNullable, string])[]): AppearanceRow { + return {id, label, + value: c => choices.find(([value]) => value === (c.contextRail[field] ?? DEFAULT_CONTEXT_RAIL[field]))?.[1] ?? choices[0]![1], + change: (c, d) => { + const values = choices.map(([value]) => value); + c.contextRail[field] = cycle(values, c.contextRail[field] ?? DEFAULT_CONTEXT_RAIL[field]!, d); + }}; +} + +export function contextRailRows(configuration: PromptConfiguration): AppearanceRow[] { + const rows: AppearanceRow[] = [ + {id: 'railMode', label: 'Mode', value: c => ({auto: 'Auto', always: 'Always', off: 'Off'})[c.contextRail.mode], + change: (c, d) => { c.contextRail.mode = cycle(['auto', 'always', 'off'], c.contextRail.mode, d); }}, + {id: 'railRows', label: 'Rows', value: c => String(c.contextRail.rows), change: c => { c.contextRail.rows = c.contextRail.rows === 1 ? 2 : 1; }}, + railChoice('railRelation', 'Relation', 'relation', [['vertical','Vertical'],['right','Right of Prompt']]), + railChoice('railDirection', 'Direction', 'direction', [['followMain','Follow Main'],['forward','Forward'],['mirrored','Mirrored']]), + {...railChoice('railIntegration', 'Integration', 'integration', [['auto','Auto'],['outside','Outside'],['inside','Inside']]), + note: c => railNeedsPromptConversion(c) ? 'Requires Main Prompt: Inside; confirm on save' : 'horizontal dividers only'}, + railChoice('railSpacing', 'Spacing', 'spacing', [['attached','Attached'],['gap','Gap'],['spacious','Spacious']]), + {...railChoice('railAnchor', 'Divider Anchor', 'dividerAnchor', [['prompt','Prompt Level'],['rail','Rail Level'],['above','Above Group']]), + note: c => !c.composerDividers ? 'no dividers: effective Prompt Level' + : c.contextRail.integration === 'outside' ? 'used with Inside integration' + : c.composerPosition === 'top' && c.contextRail.relation !== 'right' ? 'horizontal outer boundary faces below the Top group' : 'independent of spacing'}, + {id: 'railTheme', label: 'Theme', value: c => c.contextRail.theme === 'followMain' ? 'Follow Main' : 'Choose theme', + change: c => { c.contextRail.theme = c.contextRail.theme === 'followMain' ? 'choose' : 'followMain'; c.contextRail.palette ??= c.nmsh.palette; c.contextRail.themeId ??= c.nmsh.themeId; }}, + ]; + if (configuration.contextRail.theme === 'choose') { + const choices = [...THEME_PALETTE_IDS.filter(id => id !== 'custom').map(palette => ({palette, themeId: undefined as string | undefined, label: NATIVE_PROMPT_THEMES[palette].label})), + ...configuration.themes.map(asset => ({palette: 'custom' as const, themeId: asset.id, label: asset.theme.name}))]; + rows.push({id: 'railPalette', label: ' Chosen theme', + value: c => choices.find(choice => choice.palette === c.contextRail.palette && (choice.palette !== 'custom' || choice.themeId === c.contextRail.themeId))?.label ?? 'Lavender Native', + change: (c, d) => { + const current = choices.findIndex(choice => choice.palette === c.contextRail.palette && (choice.palette !== 'custom' || choice.themeId === c.contextRail.themeId)); + const next = choices[(Math.max(0, current) + d + choices.length) % choices.length]!; + c.contextRail.palette = next.palette; + if (next.themeId) c.contextRail.themeId = next.themeId; else delete c.contextRail.themeId; + }}); + } + rows.push({id: 'railStyle', label: 'Style', value: c => c.contextRail.style === 'followMain' ? 'Follow Main' : PROMPT_STYLE_LABELS[c.contextRail.style], + change: (c, d) => { c.contextRail.style = cycle(['followMain', 'soft', 'minimal', 'compact'], c.contextRail.style, d); }}, + {id: 'railOverflow', label: 'Overflow', value: () => 'Priority', note: () => 'compact, then drop lower-priority context'}); + if (configuration.provider !== 'nmsh') rows[0]!.note = () => 'available with NMSh Native; settings are retained'; + return rows; +} + +function viewRows(state: PromptPanelState): AppearanceRow[] { + return state.view === 'rail' ? contextRailRows(state.draft) : state.view === 'chroma' ? chromaRows(state.draft) : appearanceRows(state.draft); +} + /** Rich Git's own settings; each edits inline with ←/→ (Space also toggles Enabled). */ const RICH_GIT_ROWS = ['gitEnabled', 'gitColors', 'gitGeometry', 'gitConnectorFade'] as const; /** Enter on the Main Prompt Modules row opens the module manager. */ export function onModulesRow(state: PromptPanelState): boolean { return state.step === 'appearance' && (state.view ?? 'main') === 'main' && state.focus !== 'tabs' - && state.selectedIndex === APPEARANCE_MODULES_ROW; + && appearanceRows(state.draft)[state.selectedIndex]?.opens === 'modules'; +} + +/** Enter on the Chroma Custom gradient row opens the stop editor. */ +export function onGradientRow(state: PromptPanelState): boolean { + return state.step === 'appearance' && state.view === 'chroma' && state.focus !== 'tabs' + && chromaRows(state.draft)[state.selectedIndex]?.opens === 'gradient'; +} + +export function openGradientEditor(state: PromptPanelState): void { + state.gradient = {stops: [...state.draft.presentation.customStops], index: 0}; + state.step = 'gradient'; + state.selectedIndex = 0; +} + +/** Leave the stop editor, keeping valid stops in the draft (still unsaved until Enter in the panel). */ +export function closeGradientEditor(state: PromptPanelState): void { + const gradient = state.gradient; + if (gradient && gradient.stops.length >= MIN_CUSTOM_STOPS) { + state.draft.presentation.customStops = [...gradient.stops]; + state.draft.presentation.preset = 'custom'; + } + state.gradient = undefined; + state.step = 'appearance'; + state.view = 'chroma'; + state.selectedIndex = Math.max(0, chromaRows(state.draft).findIndex(row => row.opens === 'gradient')); +} + +/** The shared stop editor; R resets to the saved Custom stops (or Lavender). */ +export function handleGradientKey(key: Key, state: PromptPanelState): boolean { + const gradient = state.gradient; + if (!gradient) return false; + return gradientEditorKey(gradient, key, () => state.saved?.presentation.customStops.length ? state.saved.presentation.customStops : PRESET_STOPS.lavender); } export function connectorFadeLabel(value: ConnectorFadeStyle): string { @@ -123,7 +430,7 @@ export function edgeStyleLabel(value: PowerlineEdgeStyle): string { const MODULE_LABELS: Record = { project: 'Project', cwd: 'Path', gitBranch: 'Git branch', gitStatus: 'Git status', toolchain: 'Toolchains', exitStatus: 'Exit status', - kubeContext: 'Kubernetes', dockerContext: 'Docker context', + kubeContext: 'Kubernetes', dockerContext: 'Docker context', shell: 'Current shell', discoveredTools: 'Local tools', }; function cycle(values: readonly T[], current: T, delta: number): T { @@ -164,17 +471,23 @@ export function layoutLabel(configuration: PromptConfiguration): string { /** One-line summary of an effective configuration. */ export function describePromptConfiguration(configuration: PromptConfiguration): string { + if (configuration.provider === 'none') return 'None · composer only'; if (configuration.provider !== 'nmsh') return `${providerLabel(configuration.provider)} · ${layoutLabel(configuration)}`; const nmsh = configuration.nmsh; - return [ - NATIVE_PROMPT_THEMES[nmsh.palette].label, - layoutLabel(configuration), + // Powerline geometry describes only Powerline; other styles name their own look. + const geometry = nmsh.style === 'powerline' ? [ `${edgeStyleLabel(nmsh.startStyle).toLowerCase()} start`, `${SHAPE_LABELS[nmsh.connector].toLowerCase()} joins`, `gap ${nativeGapChoice(configuration)}`, `${edgeStyleLabel(nmsh.endStyle).toLowerCase()} end`, + ] : [`${PROMPT_STYLE_LABELS[nmsh.style].toLowerCase()} style`]; + return [ + NATIVE_PROMPT_THEMES[nmsh.palette].label, + layoutLabel(configuration), + ...geometry, `icons ${nmsh.icons === 'off' ? 'off' : 'on'}`, - ...(nmsh.style !== 'powerline' ? [`${PROMPT_STYLE_LABELS[nmsh.style].toLowerCase()} style`] : []), + ...(nmsh.vibrance !== 'standard' ? [`${VIBRANCE_LABELS[nmsh.vibrance].toLowerCase()} vibrance`] : []), + ...(configuration.presentation.preset !== 'off' ? [`${TREATMENT_PRESET_LABELS[configuration.presentation.preset]} Chroma`] : []), ].join(' · '); } @@ -190,6 +503,8 @@ function moduleOption(module: PromptConfiguration['modules'][number]): string { case 'toolchain': return module.condition === 'onCommand' ? 'on command' : 'when detected'; case 'kubeContext': case 'dockerContext': return module.condition === 'onCommand' ? 'on command' : 'always'; case 'exitStatus': return module.condition === 'always' ? 'always' : 'on failure'; + case 'shell': return module.condition === 'always' ? 'always' : 'when not default'; + case 'discoveredTools': return 'when detected'; default: return 'always'; } } @@ -203,11 +518,16 @@ function handleModulesKey(key: Key, state: PromptPanelState): boolean { if (key.kind === 'text' && key.value === ' ') module.visible = !module.visible; else if (key.kind === 'text' && (key.value === 'm' || key.value === 'M')) state.draft.nmsh.mirrorRight = !state.draft.nmsh.mirrorRight; else if (key.kind === 'text' && (key.value === 'p' || key.value === 'P')) { - if (modulePlacement(module) === 'right') delete module.placement; - else module.placement = 'right'; + const side = routeModule({...module, visible: true, ...(module.surface === 'hidden' ? {surface: undefined} : {})}); + applyModulePlacement(module, side === 'rightContext' ? 'left' : 'right'); + } + else if (key.kind === 'text' && (key.value === 's' || key.value === 'S')) { + module.surface = cycle(MODULE_SURFACES, module.surface ?? (modulePlacement(module) === 'right' ? 'rightContext' : 'mainPrompt'), 1); } else if ((key.kind === 'left' || key.kind === 'right') && module.id === 'exitStatus') { module.condition = module.condition === 'always' ? 'nonzeroExit' : 'always'; + } else if ((key.kind === 'left' || key.kind === 'right') && module.id === 'shell') { + module.condition = module.condition === 'always' ? 'shellDiffers' : 'always'; } else if ((key.kind === 'left' || key.kind === 'right') && ON_COMMAND_MODULES.has(module.id)) { module.condition = module.condition === 'onCommand' ? 'always' : 'onCommand'; } else if (key.kind === 'selectUp' || key.kind === 'selectDown') { @@ -220,6 +540,7 @@ function handleModulesKey(key: Key, state: PromptPanelState): boolean { } export function promptPanelControls(state: PromptPanelState): Array<[string, string]> { + if (state.step === 'railInsideConfirm') return [['↑↓', 'move'], ['Enter', 'choose'], ['Esc', 'keep current']]; if (state.step === 'installProgress') return [['Please wait', 'installation in progress']]; if (state.step === 'installResult') return [['Enter', state.task?.state.status === 'failed' ? 'details' : 'continue'], ['D', 'details'], ['Esc', 'back']]; if (state.step === 'installDetails') return [['Enter/Esc', 'back']]; @@ -229,12 +550,15 @@ export function promptPanelControls(state: PromptPanelState): Array<[string, str if (state.step === 'p10kConfirm' || state.step === 'p10kReady') return [['↑↓', 'move'], ['Enter', 'choose'], ['Esc', 'cancel']]; const escape: [string, string] = ['Esc', state.onboarding ? 'skip' : 'cancel']; if (state.step === 'modules') { - return [['↑↓', 'move'], ['Space', 'show/hide'], ['Shift+↑↓', 'reorder'], ['←→', 'option'], ['P', 'left/right'], - ['M', `mirror right: ${state.draft.nmsh.mirrorRight ? 'On' : 'Off'}`], ['Enter/Esc', 'done']]; + return [['↑↓', 'move'], ['Space', 'show/hide'], ['Shift+↑↓', 'reorder'], ['←→', 'option'], ['P', 'left/right'], ['S', 'surface'], + ['M', `mirror: ${state.draft.nmsh.mirrorRight ? 'On' : 'Off'}`], ['Enter/Esc', 'done']]; + } + if (state.step === 'gradient') { + return state.gradient ? gradientEditorControls(state.gradient) : [['Esc', 'done']]; } if (state.step === 'appearance') { if (state.focus === 'tabs') return [['←→', 'switch view'], ['↓', 'select'], ['Enter', 'save'], escape]; - return [['↑↓', 'move'], ['←→', 'change'], ['Enter', onModulesRow(state) ? 'edit modules' : 'save'], escape]; + return [['↑↓', 'move'], ['←→', 'change'], ['Enter', onModulesRow(state) ? 'edit modules' : onGradientRow(state) ? 'edit gradient' : 'save'], escape]; } return [['↑↓', 'move'], ['Enter', 'choose'], escape]; } @@ -247,22 +571,101 @@ export function promptPanelItemCount(state: PromptPanelState): number { case 'p10kResult': return 1; case 'starship': return state.starshipStatus?.installed ? 5 : 3; case 'starshipModules': return STARSHIP_MODULES.length; - case 'starshipConfirm': return 2; + case 'railInsideConfirm': case 'starshipConfirm': return 2; case 'layout': return LAYOUT_CHOICES.length; - case 'appearance': return (state.view ?? 'main') === 'git' ? RICH_GIT_ROWS.length : APPEARANCE_ROWS.length; + case 'appearance': return (state.view ?? 'main') === 'git' ? RICH_GIT_ROWS.length : viewRows(state).length; + case 'gradient': return state.gradient?.stops.length ?? 1; case 'modules': return state.draft.modules.length; case 'installConfirm': return 2; case 'installProgress': case 'installResult': case 'installDetails': return 1; } + return 1; +} + +/** Whether the panel itself handles this key (glyph typing owns Enter and Esc). */ +export function promptPanelOwnsKey(state: PromptPanelState, key: Key): boolean { + if (state.glyphEdit) return true; + return key.kind === 'enter' && state.step === 'appearance' && state.focus !== 'tabs' && Boolean(viewRows(state)[state.selectedIndex]?.edit); +} + +function handleGlyphEdit(key: Key, state: PromptPanelState): boolean { + const edit = state.glyphEdit; + const row = viewRows(state).find(item => item.id === edit?.rowId); + if (!edit || !row?.edit) { state.glyphEdit = undefined; return true; } + if (key.kind === 'escape' || key.kind === 'interrupt') { state.glyphEdit = undefined; return true; } + if (key.kind === 'enter') { + const result = validateGlyph(edit.buffer); + if (!result.ok) { edit.note = result.reason; return true; } + row.edit.set(state.draft, result.glyph); + state.glyphEdit = undefined; + state.message = result.warning; + return true; + } + if (key.kind === 'backspace') { + const graphemes = [...new Intl.Segmenter(undefined, {granularity: 'grapheme'}).segment(edit.buffer)].map(part => part.segment); + edit.buffer = graphemes.slice(0, -1).join(''); + } else if (key.kind === 'text' || key.kind === 'paste') { + // Controls never enter the buffer; validation reports anything else that does not fit. + edit.buffer = (edit.buffer + key.value).replace(/[\u0000-\u001f\u007f-\u009f]/gu, '').slice(0, 32); + } else return true; + const result = validateGlyph(edit.buffer); + edit.note = edit.buffer ? (result.ok ? result.warning ?? `Preview: ${result.glyph} (${result.width} cell${result.width === 1 ? '' : 's'})` : result.reason) : undefined; + return true; +} + +/** + * The theme gallery's Chroma quick control: flips the draft's one Chroma + * setting (the palette, Off or not). Turning back On restores the palette it + * turned Off, else the saved one, else Lavender. + */ +export function toggleChromaPreview(state: PromptPanelState): void { + const presentation = state.draft.presentation; + if (presentation.preset !== 'off') { + state.chromaRestore = presentation.preset; + presentation.preset = 'off'; + } else { + const saved = state.saved?.presentation.preset; + presentation.preset = state.chromaRestore ?? (saved && saved !== 'off' ? saved : 'lavender'); + if (presentation.preset === 'custom' && presentation.customStops.length < MIN_CUSTOM_STOPS) presentation.customStops = [...PRESET_STOPS.lavender]; + } + state.message = undefined; +} + +/** One row: state and the toggle first, the explanation only when it fits. Readable without color. */ +export function chromaQuickControl(on: boolean, columns: number): string { + const toggle = `${ACCENT}[C]${RESET} ${PRIMARY}${on ? 'Turn Off' : 'Turn On'}${RESET}`; + const state = on ? `${BOLD}${PRIMARY}✦ Chroma ON${RESET}` : `${SECONDARY}Chroma OFF${RESET}`; + const detail = `${SUBTLE} · ${on ? 'previews are colorized' : 'showing base theme colors'}${RESET}`; + const hint = `${SUBTLE} Chroma tab for details${RESET}`; + for (const candidate of [`${state}${detail} ${toggle}${hint}`, `${state}${detail} ${toggle}`, `${state} ${toggle}`]) { + if (displayWidth(candidate) <= columns) return candidate; + } + return `${state} ${toggle}`; } export function handlePromptPanelKey(key: Key, state: PromptPanelState): boolean { + if (state.glyphEdit) return handleGlyphEdit(key, state); + if (state.step === 'appearance' && (state.view ?? 'main') === 'main' && state.draft.provider === 'nmsh' + && key.kind === 'text' && (key.value === 'c' || key.value === 'C')) { + toggleChromaPreview(state); + return true; + } + if (key.kind === 'enter' && promptPanelOwnsKey(state, key)) { + const row = viewRows(state)[state.selectedIndex]!; + state.glyphEdit = {rowId: row.id, buffer: row.edit!.get(state.draft) ?? ''}; + return true; + } if (state.step === 'appearance' && state.view === 'git' && state.focus !== 'tabs' && RICH_GIT_ROWS[state.selectedIndex] === 'gitEnabled' && key.kind === 'text' && key.value === ' ') { state.draft.nmsh.gitEnabled = !state.draft.nmsh.gitEnabled; state.message = undefined; return true; } + if (state.step === 'gradient') { + const handled = handleGradientKey(key, state); + if (handled) state.message = undefined; + return handled; + } if (state.step === 'modules' && handleModulesKey(key, state)) { state.message = undefined; return true; @@ -295,19 +698,12 @@ export function handlePromptPanelKey(key: Key, state: PromptPanelState): boolean default: return false; } } else if (state.step === 'appearance') { - const nmsh = state.draft.nmsh; - switch (APPEARANCE_ROWS[state.selectedIndex]) { - case 'theme': nmsh.palette = cycle(NATIVE_PALETTE_IDS, nmsh.palette, delta); break; - case 'start': nmsh.startStyle = cycle(POWERLINE_EDGE_STYLES, nmsh.startStyle, delta); break; - case 'connector': nmsh.connector = cycle(POWERLINE_SHAPES, nmsh.connector, delta); break; - case 'connectorFade': nmsh.connectorFade = cycle(CONNECTOR_FADE_STYLES, nmsh.connectorFade, delta); break; - case 'fadeColors': nmsh.connectorFadeColors = cycle(fadeColorChoices(nmsh.gapEnabled, state.draft.gap), nmsh.connectorFadeColors, delta); break; - case 'gap': applyNativeGapChoice(state.draft, cycle(GAP_CHOICES, nativeGapChoice(state.draft), delta)); break; - case 'end': nmsh.endStyle = cycle(POWERLINE_EDGE_STYLES, nmsh.endStyle, delta); break; - case 'icons': nmsh.icons = nmsh.icons === 'off' ? 'nerd' : 'off'; break; - case 'style': nmsh.style = cycle(PROMPT_STYLES, nmsh.style, delta); break; - default: return false; - } + const row = viewRows(state)[state.selectedIndex]; + if (!row?.change) return false; + row.change(state.draft, delta); + // Rows can appear or vanish (e.g. Speed once Motion moves); keep the cursor on this row. + const moved = viewRows(state).findIndex(candidate => candidate.id === row.id); + if (moved !== -1) state.selectedIndex = moved; } } else return false; state.message = undefined; @@ -319,13 +715,19 @@ export function handlePromptPanelKey(key: Key, state: PromptPanelState): boolean * NATIVE_PALETTE_IDS order; it is shown only while editing appearance. */ export function renderPromptPanel(state: PromptPanelState, columns: number, preview: string[], themePreviews: string[] = [], rowsAvailable = Infinity, - gitShowcase: string[] = []): string[] { + gitShowcase: string[] = [], chromaThemeStops?: readonly RgbColor[]): string[] { const title = state.onboarding ? 'Prompt setup' : 'Prompt settings'; const rows = [`${PRIMARY} ${title}${RESET}`]; if (state.saved) rows.push(`${SUBTLE} Current ${SECONDARY}${describePromptConfiguration(state.saved)}${RESET}`); rows.push(''); - const item = (index: number, text: string) => `${index === state.selectedIndex ? ACCENT : SECONDARY}${index === state.selectedIndex ? '›' : ' '} ${text}${RESET}`; - if (state.step === 'provider') { + const item = (index: number, text: string) => `${focusForeground(index === state.selectedIndex)}${index === state.selectedIndex ? '›' : ' '} ${text}${RESET}`; + if (railNeedsPromptConversion(state.draft)) rows.push(`${SECONDARY} Requires Main Prompt: Inside · preview only${RESET}`, ''); + if (state.step === 'railInsideConfirm') { + rows.push(`${PRIMARY}Context Rail Inside requires Main Prompt Inside geometry.${RESET}`); + rows.push(`${SECONDARY}Change Main Prompt to two-line Inside with horizontal dividers?${RESET}`); + rows.push(item(0, 'Change & Save')); + rows.push(item(1, 'Cancel / Keep Current')); + } else if (state.step === 'provider') { rows.push(`${PRIMARY}Choose your prompt${RESET}`); // Prompt providers report detection in their own steps, so the list carries no badge. PROMPT_PROVIDERS.forEach((provider, index) => rows.push(item(index, @@ -350,7 +752,7 @@ export function renderPromptPanel(state: PromptPanelState, columns: number, prev rows.push(`${PRIMARY}Starship modules${RESET}`); rows.push(`${SUBTLE}Edit supported modules using Starship's config command.${RESET}`); STARSHIP_MODULES.forEach((module, index) => rows.push(item(index, - `${module.padEnd(16)} ${state.starshipModules?.[index] ? 'Disabled' : 'Enabled'}`))); + `${padCells(module, labelColumnWidth(STARSHIP_MODULES, columns, 2))}${state.starshipModules?.[index] ? 'Disabled' : 'Enabled'}`))); } else if (state.step === 'starshipConfirm') { const proposal = state.starshipProposal; rows.push(`${PRIMARY}Review Starship config change${RESET}`); @@ -416,13 +818,18 @@ export function renderPromptPanel(state: PromptPanelState, columns: number, prev const savedChoice = state.saved ? layoutChoiceIndex(state.saved) : -1; LAYOUT_CHOICES.forEach((choice, index) => rows.push(item(index, `${choice.label}${index === draftChoice ? ' ●' : ''}${index === savedChoice ? ' ✓ saved' : ''}`))); + } else if (state.step === 'gradient' && state.gradient) { + const gradient = state.gradient; + rows.push(...renderGradientEditorRows(gradient, 'Custom gradient')); + const stops = gradient.stops.length >= MIN_CUSTOM_STOPS ? gradient.stops : state.draft.presentation.customStops; + rows.push(` ${SUBTLE}Gradient ${RESET}${treatmentSwatch({...state.draft.presentation, preset: 'custom', customStops: stops}, Math.max(8, Math.min(40, columns - 14)))}${RESET}`); } else if (state.step === 'modules') { rows.push(`${PRIMARY}Prompt modules${RESET} ${SUBTLE}in prompt order · Mirror right side: ${RESET}${state.draft.nmsh.mirrorRight ? `${ACCENT}On` : `${SECONDARY}Off`}${RESET}`); state.draft.modules.forEach((module, index) => { const shown = module.visible ? `${ACCENT}●` : `${SUBTLE}○`; const option = module.id === 'exitStatus' || ON_COMMAND_MODULES.has(module.id) ? `‹ ${moduleOption(module)} ›` : moduleOption(module); - const side = modulePlacement(module).padEnd(7); - rows.push(`${index === state.selectedIndex ? `${ACCENT}›` : ' '} ${shown} ${index === state.selectedIndex ? PRIMARY : SECONDARY}${MODULE_LABELS[module.id].padEnd(15)}${SUBTLE}${side}${module.visible ? option : 'hidden'}${RESET}`); + const side = module.surface ? MODULE_SURFACE_LABELS[module.surface] : modulePlacement(module); + rows.push(`${index === state.selectedIndex ? `${ACCENT}›` : ' '} ${shown} ${focusForeground(index === state.selectedIndex)}${padCells(MODULE_LABELS[module.id], labelColumnWidth(Object.values(MODULE_LABELS), columns, 4))}${SUBTLE}${padCells(side, 15)}${module.visible ? option : 'hidden'}${RESET}`); }); } else { const saved = state.saved?.nmsh; @@ -439,42 +846,55 @@ export function renderPromptPanel(state: PromptPanelState, columns: number, prev rows.push(renderTabStrip(PROMPT_VIEWS, PROMPT_VIEW_IDS.indexOf(view), columns, state.focus === 'tabs'), ''); if (view === 'git') { const onOff = (enabled: boolean) => enabled ? 'On' : 'Off'; - rows.push(row(0, `Enabled ${value(onOff(draft.gitEnabled), saved && onOff(saved.gitEnabled))}`)); - rows.push(row(1, `Colors ${value(gitColorsLabel(draft.gitColors), saved && gitColorsLabel(saved.gitColors))}`)); - rows.push(row(2, `Geometry ${value(gitGeometryLabel(draft.gitGeometry), saved && gitGeometryLabel(saved.gitGeometry))}`)); + const gitWidth = labelColumnWidth(['Enabled', 'Colors', 'Geometry', 'Connector fade'], columns, 2); + rows.push(row(0, `${padCells('Enabled', gitWidth)}${value(onOff(draft.gitEnabled), saved && onOff(saved.gitEnabled))}`)); + rows.push(row(1, `${padCells('Colors', gitWidth)}${value(gitColorsLabel(draft.gitColors), saved && gitColorsLabel(saved.gitColors))}`)); + rows.push(row(2, `${padCells('Geometry', gitWidth)}${value(gitGeometryLabel(draft.gitGeometry), saved && gitGeometryLabel(saved.gitGeometry))}`)); const gitFadeNote = !state.draft.nmsh.gapEnabled && draft.gitConnectorFade !== 'off' ? ` ${SUBTLE}applies with a gap` : ''; - rows.push(row(3, `Connector fade ${value(gitConnectorFadeLabel(draft.gitConnectorFade), saved && gitConnectorFadeLabel(saved.gitConnectorFade))}${gitFadeNote}`)); + rows.push(row(3, `${padCells('Connector fade', gitWidth)}${value(gitConnectorFadeLabel(draft.gitConnectorFade), saved && gitConnectorFadeLabel(saved.gitConnectorFade))}${gitFadeNote}`)); if (gitShowcase.length) { rows.push('', draft.gitEnabled ? `${PRIMARY}Rich Git states${RESET} ${SUBTLE}preview only${RESET}` : `${PRIMARY}Rich Git states${RESET} ${SUBTLE}Rich Git is off · the prompt shows the branch only · dimmed sample${RESET}`); RICH_GIT_SHOWCASE.forEach((entry, index) => { const line = gitShowcase[index] ?? ''; - rows.push(` ${SECONDARY}${entry.label.padEnd(10)}${RESET} ${draft.gitEnabled ? line : `${SUBTLE}${stripAnsi(line)}`}${RESET}`); + rows.push(` ${SECONDARY}${padCells(entry.label, labelColumnWidth(RICH_GIT_SHOWCASE.map(item => item.label), columns, 2))}${RESET}${draft.gitEnabled ? line : `${SUBTLE}${stripAnsi(line)}`}${RESET}`); }); } } else { - rows.push(row(0, `Theme ${value(NATIVE_PROMPT_THEMES[draft.palette].label, saved && NATIVE_PROMPT_THEMES[saved.palette].label)}`)); - rows.push(row(1, `Start ${value(edgeStyleLabel(draft.startStyle), saved && edgeStyleLabel(saved.startStyle))}`)); - rows.push(row(2, `Connector ${value(SHAPE_LABELS[draft.connector], saved && SHAPE_LABELS[saved.connector])}`)); - const fadeNote = !state.draft.nmsh.gapEnabled && draft.connectorFade !== 'off' ? ` ${SUBTLE}applies with a gap` : ''; - rows.push(row(3, `Connector fade ${value(connectorFadeLabel(draft.connectorFade), saved && connectorFadeLabel(saved.connectorFade))}${fadeNote}`)); - rows.push(row(4, `Fade colors ${value(fadeColorsLabel(draft.connectorFadeColors), saved && fadeColorsLabel(saved.connectorFadeColors))}`)); - rows.push(row(5, `Gap ${value(gapLabel(nativeGapChoice(state.draft)), savedGap)}`)); - rows.push(row(6, `End ${value(edgeStyleLabel(draft.endStyle), saved && edgeStyleLabel(saved.endStyle))}`)); - rows.push(row(7, `Icons ${value(iconLabel(draft.icons), saved && iconLabel(saved.icons))}`)); - rows.push(row(8, `Modules ${visibleModules} of ${state.draft.modules.length} shown ›`)); - const styleNote = draft.style === 'powerline' ? '' : ` ${SUBTLE}${draft.style === 'soft' ? 'rounded, separated' : 'no filled blocks'}`; - rows.push(row(9, `Style ${value(PROMPT_STYLE_LABELS[draft.style], saved && PROMPT_STYLE_LABELS[saved.style])}${styleNote}`)); + // Main Prompt and Chroma: rows derived from the draft, so hidden controls cannot be edited. + const savedConfiguration = state.saved; + const labelWidth = labelColumnWidth(viewRows(state).map(entry => entry.label), columns, 2); + viewRows(state).forEach((entry, index) => { + const editing = state.glyphEdit?.rowId === entry.id ? state.glyphEdit : undefined; + const text = editing ? `${editing.buffer}${INVERSE} ${RESET}` : entry.value(state.draft); + const savedText = editing ? undefined : savedConfiguration ? entry.value(savedConfiguration) : undefined; + const note = editing ? editing.note ?? 'type one character · Enter set · Esc cancel' : entry.note?.(state.draft); + const body = entry.opens ? text : value(text, savedText); + rows.push(row(index, `${padCells(entry.label, labelWidth)}${body}${note ? ` ${SUBTLE}${note}` : ''}`)); + }); + if (view === 'chroma' && state.draft.presentation.preset !== 'off') { + rows.push(` ${SUBTLE}Gradient ${RESET}${treatmentSwatch(state.draft.presentation, Math.max(8, Math.min(40, columns - 14)), chromaThemeStops)}${RESET}`); + } + } + if (themePreviews.length && view === 'chroma') { + rows.push(''); + rows.push(`${PRIMARY}Palettes${RESET} ${SUBTLE}● selected ✓ saved · the showcase prompt in each${RESET}`); + TREATMENT_PRESETS.forEach((id, index) => { + const marker = state.draft.presentation.preset === id ? `${ACCENT}●` : `${SUBTLE}○`; + const savedMark = state.saved?.presentation.preset === id ? '✓' : ' '; + rows.push(`${marker} ${SECONDARY}${padCells(TREATMENT_PRESET_LABELS[id], labelColumnWidth(Object.values(TREATMENT_PRESET_LABELS), columns, 2, 20), 1)}${ACCENT}${savedMark}${RESET} ${themePreviews[index] ?? ''}${RESET}`); + }); } if (themePreviews.length && view === 'main') { rows.push(''); rows.push(`${PRIMARY}Themes${RESET} ${SUBTLE}● selected ✓ saved${RESET}`); - NATIVE_PALETTE_IDS.forEach((id, index) => { + if (state.draft.provider === 'nmsh') rows.push(chromaQuickControl(state.draft.presentation.preset !== 'off', columns), `${SUBTLE}${chromaPreviewNote(state.draft.presentation.preset !== 'off')}${RESET}`); + galleryPalettes(state.draft).forEach((id, index) => { const theme = NATIVE_PROMPT_THEMES[id]; const marker = state.draft.nmsh.palette === id ? `${ACCENT}●` : `${SUBTLE}○`; const savedMark = saved?.palette === id ? '✓' : ' '; - const label = theme.label.padEnd(17); + const label = padCells(theme.label, labelColumnWidth(galleryPalettes(state.draft).map(palette => NATIVE_PROMPT_THEMES[palette].label), columns, 2, 20), 1); rows.push(`${marker} ${SECONDARY}${label}${ACCENT}${savedMark}${RESET} ${themePreviews[index] ?? ''}${RESET}`); }); } @@ -486,7 +906,8 @@ export function renderPromptPanel(state: PromptPanelState, columns: number, prev ? LAYOUT_CHOICES[state.selectedIndex]?.composerLayout ?? state.draft.composerLayout : state.draft.composerLayout; const status = promptDraftChanged(state) ? `${ACCENT}unsaved preview` : state.saved ? `${SUBTLE}matches current` : ''; - rows.push(`${PRIMARY}${selectedLayout === 'oneLine' ? 'One-line preview' : 'Two-line preview'}${RESET}${status ? ` ${status}${RESET}` : ''}`); + const chromaPreview = state.step === 'gradient' || (state.step === 'appearance' && state.view === 'chroma'); + rows.push(`${PRIMARY}${state.view === 'rail' ? 'Context Rail preview' : chromaPreview ? 'Chroma preview' : selectedLayout === 'oneLine' ? 'One-line preview' : 'Two-line preview'}${RESET}${status ? ` ${status}${RESET}` : ''}`); rows.push(...preview); } rows.push(''); 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/commandContext.ts b/src/prompt/commandContext.ts index 6e28068a..3091c505 100644 --- a/src/prompt/commandContext.ts +++ b/src/prompt/commandContext.ts @@ -1,4 +1,5 @@ -import {readFile} from 'node:fs/promises'; +import {readContextMetadata} from '../context/trustedServices.js'; +import {delimiter, isAbsolute} from 'node:path'; import {homedir} from 'node:os'; import {join} from 'node:path'; import type {ToolchainId} from '../shell/ShellContext.js'; @@ -56,10 +57,10 @@ export function matchesCommand(triggers: readonly string[], words: readonly stri /** Current kubectl context from the first kubeconfig that names one. Reads files only. */ export async function readKubeContext(env: NodeJS.ProcessEnv = process.env, home = homedir()): Promise { - const files = env.KUBECONFIG ? env.KUBECONFIG.split(':').filter(Boolean) : [join(home, '.kube', 'config')]; + const files = env.KUBECONFIG ? env.KUBECONFIG.split(delimiter).filter(isAbsolute).slice(0, 16) : [join(home, '.kube', 'config')]; for (const file of files) { try { - const match = /^current-context:[ \t]*(['"]?)(.*?)\1[ \t]*$/mu.exec(await readFile(file, 'utf8')); + const match = /^current-context:[ \t]*(['"]?)(.*?)\1[ \t]*$/mu.exec((await readContextMetadata(file) ?? '')); if (match?.[2]) return match[2]; } catch { // Missing or unreadable kubeconfig files contribute nothing. @@ -73,7 +74,7 @@ export async function readDockerContext(env: NodeJS.ProcessEnv = process.env, ho if (env.DOCKER_CONTEXT) return env.DOCKER_CONTEXT; if (env.DOCKER_HOST) return env.DOCKER_HOST; try { - const config = JSON.parse(await readFile(join(env.DOCKER_CONFIG ?? join(home, '.docker'), 'config.json'), 'utf8')) as unknown; + const config = JSON.parse((await readContextMetadata(join(env.DOCKER_CONFIG ?? join(home, '.docker'), 'config.json')) ?? '')) as unknown; const current = typeof config === 'object' && config !== null ? (config as {currentContext?: unknown}).currentContext : undefined; if (typeof current === 'string' && current) return current; } catch { @@ -100,6 +101,12 @@ export class CommandContextCache { private readonly now: () => number = Date.now, ) {} + /** Render-safe access: never refreshes, even after TTL expiry. */ + peek(id: CommandContextId): string | undefined { return this.values.get(id)?.value; } + + /** Core lifecycle/editor demand, outside the renderer. */ + request(id: CommandContextId): void { this.get(id); } + get(id: CommandContextId): string | undefined { const cached = this.values.get(id); if ((!cached || this.now() - cached.at > this.maxAgeMs) && !this.inFlight.has(id)) void this.refresh(id); diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index 4dadbb5e..c1a6b143 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -1,13 +1,19 @@ +import type {FactId, ContextCapabilityId} from '../context/facts.js'; +import {FACT_CAPABILITIES} from '../context/facts.js'; +import {normalizeTreatmentSettings, DEFAULT_TREATMENT_SETTINGS, validCustomStops, 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'; -import {UPDATE_CHECK_FREQUENCIES, type UpdateCheckFrequency} from '../update/update.js'; +import {UPDATE_CHECK_FREQUENCIES, migrateUpdateSettings, type UpdateCheckFrequency, type UpdateFrequency, type UpdateMode} from '../update/update.js'; export const LIVE_SESSION_STARTUP = ['ask', 'always', 'never'] as const; 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 { normalizeConnectorFadeColors, @@ -21,13 +27,28 @@ import { normalizePromptStyle, type PromptStyle, } from './powerline.js'; +import {normalizeStyleProfiles, type StyleProfiles} from './styles.js'; +import {normalizeCustomGlyph, normalizePromptSymbol, type PromptSymbolId} from './glyphChoices.js'; +import {normalizeCatppuccinAccent, type CatppuccinAccent} from '../appearance/themeFamilies.js'; +import {type CustomTheme} from '../appearance/customTheme.js'; +import {findTheme, normalizeThemeLibrary, type ThemeAsset} from '../appearance/themeLibrary.js'; +import {DEFAULT_THEME_BRIDGE, normalizeThemeBridge, type ThemeBridgeSettings} from '../themeBridge/model.js'; +import {IDLE_MODES, type IdleMode} from '../idle/scenes.js'; +import {DEFAULT_UI_CHROME, normalizeUiChrome, type UiChromeSettings} from '../appearance/uiChrome.js'; +import {normalizeVibrance, type Vibrance} from '../chroma/color.js'; +import {isShellId, type ShellId} from '../shell/adapters/ShellAdapter.js'; +import {normalizeProfiles, type AgentProfile} from '../agents/sessions/manager.js'; +import {OPEN_WITH_IDS, type OpenWith} from '../host/HostActions.js'; +import {DEFAULT_KEEP_AWAKE_PRESENTATION, normalizeKeepAwakePresentation, type KeepAwakePresentation} from '../keepAwake/presentation.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'; /** Bottom and Top dock the composer; Flow places it right after the newest output, inside the document. */ +export type PanelPosition = 'bottom' | 'top'; +export const PANEL_POSITIONS: readonly PanelPosition[] = ['bottom', 'top']; export type ComposerPosition = 'bottom' | 'top' | 'flow'; export type TranscriptPresentation = 'normal' | 'chat'; /** Implemented layout choices, shared by Config rows and the /layout showcase. */ @@ -37,24 +58,121 @@ export const TRANSCRIPT_PRESENTATIONS: readonly TranscriptPresentation[] = ['nor export const TRANSCRIPT_PRESENTATION_LABELS: Record = {normal: 'Normal', chat: 'Chat'}; export type GlyphStyle = 'nerd' | 'safe'; export type SessionRetention = 100 | 500 | 1000 | 5000 | null; -export type ContextModuleId = 'project' | 'cwd' | 'gitBranch' | 'gitStatus' | 'toolchain' | 'exitStatus' | 'kubeContext' | 'dockerContext'; +export type ContextModuleId = 'project' | 'cwd' | 'gitBranch' | 'gitStatus' | 'toolchain' | 'exitStatus' | 'kubeContext' | 'dockerContext' | 'shell' | 'discoveredTools'; /** Where a module's segments render: appended to the left prompt, or the right-aligned context area. */ export type ModulePlacement = 'left' | 'right'; +/** Status Strip remains a future destination, not a shipped choice. */ +export type ContextSurface = 'mainPrompt' | 'rightContext' | 'contextRail' | 'statusStrip'; +export type ModuleSurface = Exclude | 'auto' | 'hidden'; +export const MODULE_SURFACES: readonly ModuleSurface[] = ['auto', 'mainPrompt', 'rightContext', 'contextRail', 'hidden']; +export const MODULE_SURFACE_LABELS: Record = {auto: 'Auto', mainPrompt: 'Main Prompt', rightContext: 'Right Context', contextRail: 'Context Rail', hidden: 'Hidden'}; +export interface ContextRailSettings { + mode: 'auto' | 'always' | 'off'; + rows: 1 | 2; + relation?: 'vertical' | 'right'; + direction?: 'followMain' | 'forward' | 'mirrored'; + integration?: 'auto' | 'outside' | 'inside'; + spacing?: 'attached' | 'gap' | 'spacious'; + dividerAnchor?: 'prompt' | 'rail' | 'above'; + theme: 'followMain' | 'choose'; + palette?: NativePaletteId; + themeId?: string; + style: 'followMain' | 'soft' | 'minimal' | 'compact'; + overflow: 'priority'; +} +export const DEFAULT_CONTEXT_RAIL: ContextRailSettings = {mode: 'auto', rows: 1, relation: 'vertical', direction: 'followMain', integration: 'outside', spacing: 'gap', dividerAnchor: 'prompt', theme: 'followMain', style: 'followMain', overflow: 'priority'}; +function normalizeContextRail(value: unknown, themes: readonly ThemeAsset[]): ContextRailSettings { + const v = isRecord(value) ? value : {}; + const theme = v.theme === 'choose' ? 'choose' : 'followMain'; + const themeId = typeof v.themeId === 'string' && findTheme(themes, v.themeId) ? v.themeId : undefined; + const palette = normalizePaletteId(v.palette); + return {mode: v.mode === 'always' || v.mode === 'off' ? v.mode : 'auto', rows: v.rows === 2 ? 2 : 1, + relation: v.relation === 'right' ? 'right' : 'vertical', + direction: v.direction === 'forward' || v.direction === 'mirrored' ? v.direction : 'followMain', + integration: v.integration === 'inside' || v.integration === 'auto' ? v.integration : 'outside', + spacing: v.spacing === 'attached' || v.spacing === 'gap' || v.spacing === 'spacious' ? v.spacing : isRecord(value) ? 'attached' : 'gap', + dividerAnchor: v.dividerAnchor === 'rail' || v.dividerAnchor === 'above' ? v.dividerAnchor : 'prompt', + theme, ...(theme === 'choose' || v.palette !== undefined ? {palette: palette === 'custom' && !themeId ? 'lavender' : palette, ...(themeId ? {themeId} : {})} : {}), + style: v.style === 'soft' || v.style === 'minimal' || v.style === 'compact' ? v.style : 'followMain', overflow: 'priority'}; +} /** Every module can sit in either area; narrow widths drop the right area first. */ export function modulePlacement(module: {placement?: ModulePlacement}): ModulePlacement { return module.placement === 'right' ? 'right' : 'left'; } /** `onCommand`: shown only while the typed command is one the module is about (show-on-command). */ -export type ContextCondition = 'always' | 'inRepository' | 'nonzeroExit' | 'onCommand'; +/** `shellDiffers`: shown only while this session's backend is not the default for new sessions (the `shell` module). */ +export type ContextCondition = 'always' | 'inRepository' | 'nonzeroExit' | 'onCommand' | 'shellDiffers'; +/** The current-shell module's visibility, stored as the module's visible flag and condition. */ +export type ShellModuleVisibility = 'whenDifferent' | 'always' | 'never'; +export const SHELL_MODULE_VISIBILITY: readonly ShellModuleVisibility[] = ['whenDifferent', 'always', 'never']; +export const SHELL_MODULE_VISIBILITY_LABELS: Record = {whenDifferent: 'When not default', always: 'Always', never: 'Hidden'}; +export function shellModuleVisibility(configuration: Pick): ShellModuleVisibility { + const module = configuration.modules.find(item => item.id === 'shell'); + if (!module || !module.visible || module.surface === 'hidden') return module ? 'never' : 'whenDifferent'; + return module.condition === 'always' ? 'always' : 'whenDifferent'; +} +export function applyShellModuleVisibility(configuration: Pick, visibility: ShellModuleVisibility): void { + let module = configuration.modules.find(item => item.id === 'shell'); + if (!module) { module = {id: 'shell', visible: true, condition: 'shellDiffers'}; configuration.modules.push(module); } + if (visibility !== 'never' && module.surface === 'hidden') delete module.surface; + module.visible = visibility !== 'never'; + module.condition = visibility === 'always' ? 'always' : 'shellDiffers'; +} +/** Set the prompt side without changing visibility; Hidden retains its surface override. */ +export function applyModulePlacement(module: ContextModuleConfig, side: ModulePlacement): void { + if (module.surface !== 'hidden') delete module.surface; + if (side === 'right') module.placement = 'right'; + else delete module.placement; +} /** Modules whose condition can be switched to show-on-command. */ export const ON_COMMAND_MODULES: ReadonlySet = new Set(['toolchain', 'kubeContext', 'dockerContext']); -export type PromptProviderId = 'nmsh' | 'starship' | 'powerlevel10k'; +/** Stable module identity, factual inputs, and current field-demand policy. */ +export interface ContextModuleDefinition { + id: ContextModuleId; + category: string; + fields: readonly FactId[]; + capabilities: readonly ContextCapabilityId[]; + demand: string; + priority: number; + supportedSurfaces: readonly Exclude[]; + preferredSurface: Exclude; + icons: 'existing-semantic-glyphs'; + width: 'compact-then-drop'; +} +function defineModule(id: ContextModuleId, category: string, fields: readonly FactId[], demand: string, + priority: number, preferredSurface: Exclude = 'mainPrompt'): ContextModuleDefinition { + return {id, category, fields, capabilities: fields.map(field => FACT_CAPABILITIES[field]), demand, priority, + supportedSurfaces: ['mainPrompt', 'rightContext', 'contextRail'], preferredSurface, + icons: 'existing-semantic-glyphs', width: 'compact-then-drop'}; +} +export const CONTEXT_MODULE_REGISTRY: Record = { + project: defineModule('project', 'identity', ['project', 'root'], 'always', 60), + cwd: defineModule('cwd', 'identity', ['cwd', 'root', 'pathAbbreviations'], 'always', 70), + gitBranch: defineModule('gitBranch', 'vcs', ['branch', 'git'], 'repository', 80), + gitStatus: defineModule('gitStatus', 'vcs', ['git'], 'repository', 90), + toolchain: defineModule('toolchain', 'tooling', ['toolchains'], 'project-markers', 40), + exitStatus: defineModule('exitStatus', 'session', ['exitStatus'], 'always', 100), + kubeContext: defineModule('kubeContext', 'context', ['kubeContext'], 'command', 95, 'contextRail'), + dockerContext: defineModule('dockerContext', 'context', ['dockerContext'], 'command', 85, 'contextRail'), + shell: defineModule('shell', 'session', ['shell'], 'always', 50, 'rightContext'), + discoveredTools: defineModule('discoveredTools', 'tooling', ['discovery'], 'cached-inventory', 10, 'rightContext'), +}; +/** `none` is composer only: no prompt row, modules or right prompt (the input marker stays); everything else in NMSh stays on. */ +export type PromptProviderId = 'nmsh' | 'starship' | 'powerlevel10k' | 'ohMyPosh' | 'none'; export type NativeEndStyle = PowerlineEdgeStyle; export type NativeStartStyle = PowerlineEdgeStyle; export type NativeConnectorStyle = PowerlineConnectorStyle; /** `nerd` shows Nerd Font module icons; a future `text` mode can join without migration. */ export type NativeIconMode = 'nerd' | 'off'; -export type NativePaletteId = 'lavender' | 'brand' | 'cool' | 'warm' | 'grayscale'; +export type NativePaletteId = 'lavender' | 'brand' | 'cool' | 'warm' | 'grayscale' | 'aurora' | 'ocean' | 'sunset' | 'forest' | 'rose' | 'nebula' | 'highContrast' + | ThirdPartyPaletteId | 'custom'; +/** Bundled third-party variants (see appearance/themeFamilies). */ +export type ThirdPartyPaletteId = 'catppuccinLatte' | 'catppuccinFrappe' | 'catppuccinMacchiato' | 'catppuccinMocha' | 'dracula' + | 'tokyonightNight' | 'tokyonightStorm' | 'tokyonightMoon' | 'tokyonightDay' | 'gruvboxDark' | 'gruvboxLight' + | 'rosePine' | 'rosePineMoon' | 'rosePineDawn' | 'nord' | 'solarizedDark' | 'solarizedLight' | 'oneDark' | 'oneLight'; +export const THIRD_PARTY_PALETTE_IDS: readonly ThirdPartyPaletteId[] = ['catppuccinLatte', 'catppuccinFrappe', 'catppuccinMacchiato', 'catppuccinMocha', + 'dracula', 'tokyonightNight', 'tokyonightStorm', 'tokyonightMoon', 'tokyonightDay', 'gruvboxDark', 'gruvboxLight', + 'rosePine', 'rosePineMoon', 'rosePineDawn', 'nord', 'solarizedDark', 'solarizedLight', 'oneDark', 'oneLight']; export type NativeGapChoice = 'off' | 'compact' | 'normal' | 'wide'; /** * Rich Git state colors: `semantic` keeps meaningful Git colors under any @@ -93,12 +211,16 @@ export function normalizeConnectorFade(value: unknown): ConnectorFadeStyle { return CONNECTOR_FADE_STYLES.includes(value as ConnectorFadeStyle) ? value as ConnectorFadeStyle : 'off'; } -export const NATIVE_PALETTE_IDS: readonly NativePaletteId[] = ['lavender', 'brand', 'cool', 'warm', 'grayscale']; +/** The NMSh theme family (Native themes); other families are listed by THEME_PALETTE_IDS. */ +export const NATIVE_PALETTE_IDS: readonly NativePaletteId[] = ['lavender', 'brand', 'cool', 'warm', 'grayscale', + 'aurora', 'ocean', 'sunset', 'forest', 'rose', 'nebula', 'highContrast']; +/** Every selectable theme id: NMSh themes, bundled families, and the user's custom theme. */ +export const THEME_PALETTE_IDS: readonly NativePaletteId[] = [...NATIVE_PALETTE_IDS, ...THIRD_PARTY_PALETTE_IDS, 'custom']; /** Retired theme ids keep working: Soft Semantic overlapped Brand / Semantic. */ export function normalizePaletteId(value: unknown, fallback: NativePaletteId = 'lavender'): NativePaletteId { if (value === 'semantic') return 'brand'; - return NATIVE_PALETTE_IDS.includes(value as NativePaletteId) ? value as NativePaletteId : fallback; + return THEME_PALETTE_IDS.includes(value as NativePaletteId) ? value as NativePaletteId : fallback; } export interface ContextModuleConfig { @@ -107,29 +229,48 @@ export interface ContextModuleConfig { condition: ContextCondition; /** Missing means left; only right-eligible modules honor `right`. */ placement?: ModulePlacement; + /** Missing preserves legacy left/right; explicit Auto opts into definition preference. */ + surface?: ModuleSurface; foreground?: string; background?: string; } export type HistoryColorMode = 'followPrompt' | 'theme' | 'grayscale'; export type DividerDensity = 'normal' | 'compact'; +/** + * Historical divider colors (live composer dividers follow the Chroma + * Divider lines setting instead). Follow Chroma is static in history. + */ +export const DIVIDER_COLOR_MODES = ['chroma', 'history', 'ui', 'muted'] as const; +export type DividerColorMode = typeof DIVIDER_COLOR_MODES[number]; +export const DIVIDER_COLOR_LABELS: Record = {chroma: 'Follow Chroma', history: 'Follow history', ui: 'Follow UI theme', muted: 'Muted grayscale'}; /** How historical command headers are presented; stored snapshots are never changed. */ +/** How much of a stored prompt snapshot past commands show; Off is `historicalPrompt: false`. */ +export const HISTORICAL_PROMPT_LEVELS = ['full', 'compact', 'minimal'] as const; +export type HistoricalPromptLevel = typeof HISTORICAL_PROMPT_LEVELS[number]; +export const HISTORICAL_PROMPT_LEVEL_LABELS: Record = {full: 'Full', compact: 'Compact', minimal: 'Minimal', off: 'Off'}; + export interface TranscriptAppearance { divider: boolean; historicalPrompt: boolean; + /** Presentation only, used while `historicalPrompt` is on; the stored snapshot stays complete. Missing means Full. */ + historicalPromptLevel: HistoricalPromptLevel; historyColors: HistoryColorMode; /** Used when `historyColors` is `theme`. */ historyTheme: NativePaletteId; dividerDensity: DividerDensity; + dividerColors: DividerColorMode; } export const DEFAULT_TRANSCRIPT_APPEARANCE: TranscriptAppearance = { divider: true, historicalPrompt: true, + historicalPromptLevel: 'full', historyColors: 'followPrompt', historyTheme: 'lavender', dividerDensity: 'normal', + dividerColors: 'chroma', }; export function normalizeTranscriptAppearance(value: unknown): TranscriptAppearance { @@ -137,9 +278,11 @@ export function normalizeTranscriptAppearance(value: unknown): TranscriptAppeara return { divider: typeof value.divider === 'boolean' ? value.divider : true, historicalPrompt: typeof value.historicalPrompt === 'boolean' ? value.historicalPrompt : true, + historicalPromptLevel: HISTORICAL_PROMPT_LEVELS.includes(value.historicalPromptLevel as HistoricalPromptLevel) ? value.historicalPromptLevel as HistoricalPromptLevel : 'full', historyColors: value.historyColors === 'theme' || value.historyColors === 'grayscale' ? value.historyColors : 'followPrompt', historyTheme: normalizePaletteId(value.historyTheme), dividerDensity: value.dividerDensity === 'compact' ? 'compact' : 'normal', + dividerColors: DIVIDER_COLOR_MODES.includes(value.dividerColors as DividerColorMode) ? value.dividerColors as DividerColorMode : 'chroma', }; } @@ -165,16 +308,279 @@ 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', + }; +} + +/** Text-caret style while NMSh owns the composer; Host default sends nothing. */ +export const CURSOR_SHAPES = ['host', 'block', 'bar', 'underline'] as const; +export type CursorShape = typeof CURSOR_SHAPES[number]; +export const CURSOR_BLINKS = ['host', 'on', 'off'] as const; +export type CursorBlink = typeof CURSOR_BLINKS[number]; +/** + * One registry for NMSh's UI motion, each a short, finite presentation of a + * real event; none delays input or execution, none runs when Off, under + * Reduced Motion, with Decorative Effects Off, or without color. + */ +export const CONTEXT_TRANSITIONS = ['off', 'subtle', 'expressive'] as const; +export const COMMAND_LAUNCHES = ['off', 'sweep', 'pulse'] as const; +export const COMPLETION_HIGHLIGHTS = ['off', 'subtle', 'vivid'] as const; +export const COMPLETION_EFFECTS = ['off', 'seal'] as const; +export const EVENT_FEEDBACK = ['off', 'subtle', 'expressive'] as const; +export const CURSOR_TRAVELS = ['off', 'on'] as const; +export interface MotionSettings { + /** Prompt modules transform in place when their facts change (cwd, branch, Git state, tools). */ + contextTransitions: typeof CONTEXT_TRANSITIONS[number]; + /** The handoff when Enter submits a shell command. */ + commandLaunch: typeof COMMAND_LAUNCHES[number]; + /** What completion just inserted. */ + completionHighlight: typeof COMPLETION_HIGHLIGHTS[number]; + /** Block Seal: a finished block settles. */ + completionEffect: typeof COMPLETION_EFFECTS[number]; + /** Semantic Echo: a short response to meaningful events (long success, failure, attention, task done). */ + eventFeedback: typeof EVENT_FEEDBACK[number]; + /** A soft trail between the old and new caret position on multi-cell jumps. Presentation only; the caret moves at once. */ + cursorTravel: typeof CURSOR_TRAVELS[number]; + /** + * How the same events are drawn. Clean keeps the host's background (foreground tint, dim, underline); + * Rich draws the stronger filled bands. One event system feeds either renderer. + */ + rendering: MotionRendering; + /** Each rendering keeps its own tuning, so switching never loses the other's. */ + tuning: Record; +} +export const MOTION_RENDERINGS = ['clean', 'rich'] as const; +export type MotionRendering = typeof MOTION_RENDERINGS[number]; +export const MOTION_INTENSITIES = ['low', 'medium', 'high'] as const; +export const MOTION_SPEEDS = ['slow', 'normal', 'fast'] as const; +/** Intensity scales how strong the paint is; speed scales how long it lasts. Medium/Normal is each renderer's own baseline. */ +export interface MotionTuning {intensity: typeof MOTION_INTENSITIES[number]; speed: typeof MOTION_SPEEDS[number]} +export const DEFAULT_MOTION_TUNING = (): Record => ({clean: {intensity: 'medium', speed: 'normal'}, rich: {intensity: 'medium', speed: 'normal'}}); +/** Fresh installs: restrained motion, Clean rendering. */ +export const DEFAULT_MOTION: MotionSettings = {contextTransitions: 'subtle', commandLaunch: 'sweep', completionHighlight: 'subtle', completionEffect: 'seal', eventFeedback: 'subtle', cursorTravel: 'on', + rendering: 'clean', tuning: DEFAULT_MOTION_TUNING()}; +/** Existing configs without a motion group: nothing new moves until the person turns it on. */ +export const MIGRATED_MOTION: MotionSettings = {contextTransitions: 'off', commandLaunch: 'off', completionHighlight: 'off', completionEffect: 'off', eventFeedback: 'off', cursorTravel: 'off', + rendering: 'clean', tuning: DEFAULT_MOTION_TUNING()}; + +export function normalizeMotion(value: unknown): MotionSettings { + if (!isRecord(value)) return {...MIGRATED_MOTION}; + const pickOne = (list: readonly T[], item: unknown, fallback: T): T => list.includes(item as T) ? item as T : fallback; + return {contextTransitions: pickOne(CONTEXT_TRANSITIONS, value.contextTransitions, 'off'), commandLaunch: pickOne(COMMAND_LAUNCHES, value.commandLaunch, 'off'), + completionHighlight: pickOne(COMPLETION_HIGHLIGHTS, value.completionHighlight, 'off'), completionEffect: pickOne(COMPLETION_EFFECTS, value.completionEffect, 'off'), + eventFeedback: pickOne(EVENT_FEEDBACK, value.eventFeedback, 'off'), cursorTravel: pickOne(CURSOR_TRAVELS, value.cursorTravel, 'off'), + // Saved configs without a rendering are on today's behavior, which is Clean. + rendering: pickOne(MOTION_RENDERINGS, value.rendering, 'clean'), tuning: normalizeMotionTuning(value.tuning)}; +} + +function normalizeMotionTuning(value: unknown): Record { + const record = isRecord(value) ? value : {}; + const one = (item: unknown): MotionTuning => { + const tuning = isRecord(item) ? item : {}; + return {intensity: MOTION_INTENSITIES.includes(tuning.intensity as never) ? tuning.intensity as MotionTuning['intensity'] : 'medium', + speed: MOTION_SPEEDS.includes(tuning.speed as never) ? tuning.speed as MotionTuning['speed'] : 'normal'}; + }; + return {clean: one(record.clean), rich: one(record.rich)}; +} + +export const PROMPT_TEXT_COLORS = ['neutral', 'theme'] as const; +export type PromptTextColors = typeof PROMPT_TEXT_COLORS[number]; + +export const CURSOR_RENDERERS = ['auto', 'portable', 'native'] as const; +export type CursorRenderer = typeof CURSOR_RENDERERS[number]; +/** How the visual caret travels; the logical caret always moves at once. */ +export const CURSOR_MOTIONS = ['off', 'smooth', 'smear', 'tail'] as const; +export type CursorMotion = typeof CURSOR_MOTIONS[number]; +/** What the movement emits (separate from Motion: Fire is not a movement algorithm). */ +export const CURSOR_EFFECTS = ['none', 'fire', 'sparks', 'lightning', 'railgun', 'ripple', 'wireframe'] as const; +export type CursorEffect = typeof CURSOR_EFFECTS[number]; +/** A low-cadence effect while the caret rests (opt-in; Off schedules nothing). */ +export const CURSOR_IDLE_EFFECTS = ['off', 'glow', 'embers', 'flame', 'sparks'] as const; +export type CursorIdleEffect = typeof CURSOR_IDLE_EFFECTS[number]; +/** + * Where the caret/effect color comes from. `theme` is Follow current theme (the + * stored name predates Choose theme and is kept so saved configs keep working); + * `chosen` is Choose theme: any bundled theme, independent of the prompt. + */ +export const CURSOR_COLOR_SOURCES = ['host', 'accent', 'theme', 'custom', 'chosen'] as const; +export type CursorColorSource = typeof CURSOR_COLOR_SOURCES[number]; +export const CURSOR_TRAIL_COLORS = ['cursor', 'custom', 'gradient'] as const; +export const CURSOR_PARTICLE_COLORS = ['trail', 'custom', 'gradient'] as const; +export const CURSOR_LEVELS = ['low', 'medium', 'high'] as const; +export type CursorLevel = typeof CURSOR_LEVELS[number]; +export const CURSOR_EASINGS = ['out-cubic', 'out-expo', 'linear', 'spring'] as const; + +/** Physics and pacing for people who want to tune (the default view never shows these). */ +export interface CursorAdvanced { + shortMoveMs: number; longMoveMs: number; easing: typeof CURSOR_EASINGS[number]; + stiffness: number; tailStiffness: number; damping: number; trailExponent: number; maxTrail: number; + /** Cells: moves at or under this are "short" (adjacent typing). */ + moveThreshold: number; + /** ms of rest before the idle effect starts. */ + dwellMs: number; + particleDensity: number; particleLifetimeMs: number; spread: number; particleSpeed: number; drag: number; gravity: number; + /** Frames per second while moving; idle effects use at most 15. */ + fps: number; +} + +export interface CursorSettings { + shape: CursorShape; blink: CursorBlink; + renderer: CursorRenderer; motion: CursorMotion; effect: CursorEffect; idleEffect: CursorIdleEffect; + color: {source: CursorColorSource; custom?: string; /** Choose theme: the theme and (Catppuccin) accent the cursor uses. */ theme?: NativePaletteId; themeAccent?: CatppuccinAccent}; + trail: {source: typeof CURSOR_TRAIL_COLORS[number]; colors: string[]}; + particles: {source: typeof CURSOR_PARTICLE_COLORS[number]; colors: string[]}; + speed: CursorLevel; intensity: CursorLevel; trailLength: CursorLevel; particleAmount: CursorLevel; + advanced: CursorAdvanced; +} +export const DEFAULT_CURSOR_ADVANCED: CursorAdvanced = {shortMoveMs: 40, longMoveMs: 150, easing: 'out-cubic', stiffness: 0.6, tailStiffness: 0.35, damping: 0.85, + trailExponent: 1.6, maxTrail: 24, moveThreshold: 1, dwellMs: 600, particleDensity: 1, particleLifetimeMs: 520, spread: 0.6, particleSpeed: 1, drag: 0.9, gravity: 1, fps: 60}; +/** Factory defaults: no motion, no effect, no idle effect. Existing users see no surprise animation. */ +export const DEFAULT_CURSOR: CursorSettings = {shape: 'host', blink: 'host', renderer: 'auto', motion: 'off', effect: 'none', idleEffect: 'off', + color: {source: 'host'}, trail: {source: 'cursor', colors: []}, particles: {source: 'trail', colors: []}, + speed: 'medium', intensity: 'medium', trailLength: 'medium', particleAmount: 'medium', advanced: DEFAULT_CURSOR_ADVANCED}; + +const HEX = /^#[0-9a-f]{6}$/iu; +const pick = (list: readonly T[], value: unknown, fallback: T): T => list.includes(value as T) ? value as T : fallback; +const clampNumber = (value: unknown, min: number, max: number, fallback: number) => typeof value === 'number' && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback; + +export function normalizeCursor(value: unknown): CursorSettings { + const v = isRecord(value) ? value : {}; + const color = isRecord(v.color) ? v.color : {}; + const trail = isRecord(v.trail) ? v.trail : {}; + const particles = isRecord(v.particles) ? v.particles : {}; + const advanced = isRecord(v.advanced) ? v.advanced : {}; + const colors = (list: unknown) => Array.isArray(list) ? list.filter((item): item is string => typeof item === 'string' && HEX.test(item)).slice(0, 6) : []; + const a = DEFAULT_CURSOR_ADVANCED; + return {shape: pick(CURSOR_SHAPES, v.shape, 'host'), blink: pick(CURSOR_BLINKS, v.blink, 'host'), + renderer: pick(CURSOR_RENDERERS, v.renderer, 'auto'), motion: pick(CURSOR_MOTIONS, v.motion, 'off'), effect: pick(CURSOR_EFFECTS, v.effect, 'none'), + idleEffect: pick(CURSOR_IDLE_EFFECTS, v.idleEffect, 'off'), + color: {source: pick(CURSOR_COLOR_SOURCES, color.source, 'host'), ...(typeof color.custom === 'string' && HEX.test(color.custom) ? {custom: color.custom} : {}), + ...(color.theme !== undefined || color.source === 'chosen' ? {theme: normalizePaletteId(color.theme)} : {}), + ...(color.themeAccent !== undefined ? {themeAccent: normalizeCatppuccinAccent(color.themeAccent)} : {})}, + trail: {source: pick(CURSOR_TRAIL_COLORS, trail.source, 'cursor'), colors: colors(trail.colors)}, + particles: {source: pick(CURSOR_PARTICLE_COLORS, particles.source, 'trail'), colors: colors(particles.colors)}, + speed: pick(CURSOR_LEVELS, v.speed, 'medium'), intensity: pick(CURSOR_LEVELS, v.intensity, 'medium'), trailLength: pick(CURSOR_LEVELS, v.trailLength, 'medium'), + particleAmount: pick(CURSOR_LEVELS, v.particleAmount, 'medium'), + advanced: {shortMoveMs: clampNumber(advanced.shortMoveMs, 0, 200, a.shortMoveMs), longMoveMs: clampNumber(advanced.longMoveMs, 40, 600, a.longMoveMs), + easing: pick(CURSOR_EASINGS, advanced.easing, a.easing), stiffness: clampNumber(advanced.stiffness, 0.05, 1, a.stiffness), + tailStiffness: clampNumber(advanced.tailStiffness, 0.05, 1, a.tailStiffness), damping: clampNumber(advanced.damping, 0.1, 1, a.damping), + trailExponent: clampNumber(advanced.trailExponent, 0.5, 4, a.trailExponent), maxTrail: clampNumber(advanced.maxTrail, 2, 80, a.maxTrail), + moveThreshold: clampNumber(advanced.moveThreshold, 0, 8, a.moveThreshold), dwellMs: clampNumber(advanced.dwellMs, 0, 5000, a.dwellMs), + particleDensity: clampNumber(advanced.particleDensity, 0, 4, a.particleDensity), particleLifetimeMs: clampNumber(advanced.particleLifetimeMs, 100, 2000, a.particleLifetimeMs), + spread: clampNumber(advanced.spread, 0, 2, a.spread), particleSpeed: clampNumber(advanced.particleSpeed, 0.1, 4, a.particleSpeed), + drag: clampNumber(advanced.drag, 0.5, 1, a.drag), gravity: clampNumber(advanced.gravity, -2, 2, a.gravity), fps: clampNumber(advanced.fps, 12, 120, a.fps)}}; +} + +/** Optional NMSh-owned status strip; Off by default, Minimal (clock + real battery) when enabled. */ +export const RAM_DISPLAYS = ['percent', 'absolute', 'both'] as const; +export type RamDisplay = typeof RAM_DISPLAYS[number]; +export interface StatusStripSettings { + enabled: boolean; clock: boolean; battery: boolean; cpu: boolean; ram: boolean; uptime: boolean; ramDisplay: RamDisplay; +} +export const DEFAULT_STATUS_STRIP: StatusStripSettings = {enabled: false, clock: true, battery: true, cpu: false, ram: false, uptime: false, ramDisplay: 'percent'}; + +export function normalizeStatusStrip(value: unknown): StatusStripSettings { + const v = isRecord(value) ? value : {}; + const flag = (key: keyof StatusStripSettings) => typeof v[key] === 'boolean' ? v[key] as boolean : DEFAULT_STATUS_STRIP[key] as boolean; + return {enabled: flag('enabled'), clock: flag('clock'), battery: flag('battery'), cpu: flag('cpu'), ram: flag('ram'), uptime: flag('uptime'), + ramDisplay: RAM_DISPLAYS.includes(v.ramDisplay as RamDisplay) ? v.ramDisplay as RamDisplay : 'percent'}; +} + +/** Idle visuals: minutes of inactivity before the NMSh screensaver starts; 0 is Never (the default). */ +export const IDLE_TIMEOUTS = [0, 1, 5, 15, 30, 60] as const; +export type IdleTimeout = typeof IDLE_TIMEOUTS[number]; +/** + * Idle colors. Follow Chroma / Theme (stored `appearance`): Chroma when it is + * on, otherwise the active theme. Theme only: always the theme, ignoring + * Chroma. Custom: the idle visuals' own gradient stops. + */ +export const IDLE_COLOR_SOURCES = ['appearance', 'theme', 'custom'] as const; +export type IdleColorSource = typeof IDLE_COLOR_SOURCES[number]; +export const IDLE_COLOR_LABELS: Record = {appearance: 'Follow Chroma / Theme', theme: 'Theme only', custom: 'Custom'}; +/** runWhileBusy lets the saver start during a foreground command; it never overrides passthrough or a fullscreen program. */ +export interface IdleVisualSettings {timeout: IdleTimeout; mode: IdleMode; colorSource: IdleColorSource; customStops: string[]; runWhileBusy: boolean} +export const DEFAULT_IDLE_VISUALS: IdleVisualSettings = {timeout: 0, mode: 'aurora', colorSource: 'appearance', customStops: [], runWhileBusy: false}; + +export function normalizeIdleVisuals(value: unknown): IdleVisualSettings { + const v = isRecord(value) ? value : {}; + const customStops = validCustomStops(v.customStops) ? v.customStops.map(stop => stop.toLowerCase()) : []; + const colorSource = IDLE_COLOR_SOURCES.includes(v.colorSource as IdleColorSource) ? v.colorSource as IdleColorSource : 'appearance'; + return {timeout: IDLE_TIMEOUTS.includes(v.timeout as IdleTimeout) ? v.timeout as IdleTimeout : 0, + mode: IDLE_MODES.includes(v.mode as IdleMode) ? v.mode as IdleMode : 'aurora', + colorSource: colorSource === 'custom' && !customStops.length ? 'appearance' : colorSource, customStops, runWhileBusy: v.runWhileBusy === true}; +} + +/** + * Live activity colors: the running-command line ("• Running sleep 5 · 3.4s"). + * Follow appearance uses Chroma when it is on (Semantic Preserve keeps the + * working color), otherwise the theme. Only the live line moves; a finished + * command is the ordinary, static semantic result. + */ +export const LIVE_ACTIVITY_COLORS = ['appearance', 'lavender', 'grayscale', 'custom'] as const; +export type LiveActivityColors = typeof LIVE_ACTIVITY_COLORS[number]; +export const LIVE_ACTIVITY_COLOR_LABELS: Record = {appearance: 'Follow appearance', lavender: 'Native Lavender', grayscale: 'Grayscale', custom: 'Custom'}; +export interface LiveActivitySettings {colors: LiveActivityColors; customStops: string[]} +export const DEFAULT_LIVE_ACTIVITY: LiveActivitySettings = {colors: 'appearance', customStops: []}; + +export function normalizeLiveActivity(value: unknown): LiveActivitySettings { + const v = isRecord(value) ? value : {}; + const customStops = validCustomStops(v.customStops) ? v.customStops.map(stop => stop.toLowerCase()) : []; + const colors = LIVE_ACTIVITY_COLORS.includes(v.colors as LiveActivityColors) ? v.colors as LiveActivityColors : 'appearance'; + return {colors: colors === 'custom' && !customStops.length ? 'appearance' : colors, customStops}; +} + export interface PromptConfiguration { + presentation: TreatmentSettings; + /** General NMSh UI motion (cursor motion lives in `cursor`, Chroma in `presentation`). */ + motion: MotionSettings; + /** Paste Preview: Smart shows multiline, chained, mutating or risky pastes before they enter the composer. */ + pastePreview: 'smart' | 'always' | 'off'; 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; /** Maximum unpinned presentation sessions; null disables rotation. */ sessionRetention: SessionRetention; - /** Background release checks are opt-in; `/update` always checks on request. */ - updateChecks: UpdateCheckFrequency; + /** Automatic updates: Automatic prepares verified releases, Notify only announces them, Off never checks. `/update` always works on request. */ + updateMode: UpdateMode; + updateFrequency: UpdateFrequency; /** Whether launch restores a detached live session: ask, always, or never (never only skips; it ends nothing). */ liveSessionStartup: LiveSessionStartup; /** With several detached live sessions at launch: ask which, or open them all. */ @@ -185,14 +591,68 @@ export interface PromptConfiguration { welcome: WelcomeProviderId; /** Ghost-text suggestion provider; external providers fall back to Native. */ 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; + /** Batched outdated checks for optional external tools; Off by default, never on render. */ + toolUpdateChecks: UpdateCheckFrequency; + /** Offer an install when a submitted command is a missing curated tool (exact name only). */ + installSuggestions: boolean; + /** Curated tool ids the user asked not to be offered again. */ + ignoredInstallSuggestions: string[]; + /** The composer's prompt marker; provider-owned prompts (Starship, Powerlevel10k) are never changed. */ + promptSymbol: PromptSymbolId; + /** Used when `promptSymbol` is `custom`; kept when another symbol is chosen. */ + promptSymbolCustom?: string; + /** + * The Native theme library: canonical user-owned themes (Custom and + * Imported) with stable ids. `nmsh.themeId` names the one the `custom` + * palette uses. + */ + themes: ThemeAsset[]; + /** + * Mirror of the library asset named by `nmsh.themeId`, rewritten on every + * normalization (never edited directly). Renderers and older NMSh versions + * read it; only a configuration without `themes` migrates from it. + */ + customTheme?: CustomTheme; + /** Opt-in Theme Bridge: one independent mode per external tool target; all Independent by default. */ + themeBridge: ThemeBridgeSettings; + cursor: CursorSettings; + statusStrip: StatusStripSettings; + /** How an active Keep Awake shows in NMSh chrome (placement, display, idle reminder, screensaver). Off shows nothing. */ + keepAwake: KeepAwakePresentation; + idleVisuals: IdleVisualSettings; + liveActivity: LiveActivitySettings; + /** Where NMSh chrome (frames, rules, tabs, selection, accents) takes its colors from. */ + uiChrome: UiChromeSettings; + /** Compact cross-session notices above the composer (other sessions finished, failed, ended...). */ + sessionNotices: boolean; + /** Named agent launch profiles (provider-specific, never credentials); see src/agents/sessions/manager.ts. */ + agentProfiles: AgentProfile[]; + /** Keep Ask questions and replies with the session transcript. Approved actions follow their own history rules either way. */ + askRecord: boolean; + /** How the Ask panel lays out its conversation; independent of the transcript's presentation. */ + askPresentation: 'chat' | 'normal'; + /** Optional local language understanding; Auto by default (deterministic first, nothing downloads without consent). Folding stays opt-in. */ + localUnderstanding: LocalUnderstandingSettings; + /** Local-only agent CLI activity stats (durations and counts; never content). */ + agentActivity: boolean; + /** Shell backend for new sessions; /shell switches only the current session unless saved as default. */ + shellBackend: ShellId; + /** Where /open and /open-diff delegate: the surrounding editor (auto), Zed, VS Code, or $VISUAL/$EDITOR. */ + openWith: OpenWith; nmsh: { gapEnabled: boolean; startStyle: NativeStartStyle; connector: NativeConnectorStyle; endStyle: NativeEndStyle; palette: NativePaletteId; + /** The library asset (`themes[].id`) a `custom` palette uses; kept while a built-in is active. */ + themeId?: string; icons: NativeIconMode; /** Visual style over the same semantic segments; missing in older configs means Powerline. */ style: PromptStyle; @@ -206,19 +666,43 @@ export interface PromptConfiguration { gitConnectorFade: GitConnectorFade; /** Right-aligned context faces left (reflected geometry); missing in older configs means On. */ mirrorRight: boolean; + /** Theme color strength; missing in older configs means Standard (unchanged colors). */ + vibrance: Vibrance; + /** + * Prompt text colors: Theme uses the theme's own text treatment; Neutral keeps every + * fill, connector and accent but draws ordinary text in stable neutral tones. Applies + * to every Native theme. Missing in older configs means Theme (saved looks unchanged). + */ + textColors: PromptTextColors; + /** Catppuccin accent; ignored by other families. */ + accent: CatppuccinAccent; + /** + * Per-style settings for every style except Powerline, whose settings are + * the fields above plus the root gap/spacing. Missing profiles are seeded + * from the legacy shared gap/spacing so upgrades look the same. + */ + styleProfiles: StyleProfiles; }; starship: {configPath: string | null}; /** Optional overrides; null uses detection and the default ~/.p10k.zsh. Never written to. */ powerlevel10k: {themePath: string | null; configPath: string | null}; + /** Optional local config path; null uses POSH_CONFIG, else Oh My Posh's built-in default. Never written to. */ + ohMyPosh: {configPath: string | null}; + notifications: NotificationSettings; transcript: TranscriptAppearance; syntax: SyntaxAppearance; placement: ContextPlacement; composerLayout: ComposerLayout; /** Dock Bottom (default) or Dock Top; independent of transcript presentation. */ composerPosition: ComposerPosition; + /** Where full-width NMSh panels (Setup, Settings, Tools, ...) sit; independent of the composer position. */ + panelPosition: PanelPosition; /** Normal or Chat rows; presentation only and independent of composer position. */ transcriptPresentation: TranscriptPresentation; + /** The decorative horizontal rules around the live composer; Off reclaims their rows. Transcript dividers are separate. */ + composerDividers: boolean; modules: ContextModuleConfig[]; + contextRail: ContextRailSettings; separator: string; /** Spaces between colored context blocks; use spacing for padding inside each block. */ gap: number; @@ -226,30 +710,64 @@ export interface PromptConfiguration { } export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { + presentation: {...DEFAULT_TREATMENT_SETTINGS, customStops: []}, + motion: {...DEFAULT_MOTION}, + pastePreview: 'smart', provider: 'nmsh', onboardingComplete: false, + toolsSetupComplete: false, glyphStyle: 'nerd', glyphChoiceComplete: false, sessionRetention: 1000, - updateChecks: 'off', + updateMode: 'automatic', + updateFrequency: 'daily', liveSessionStartup: 'ask', liveSessionMultiple: 'ask', + notifications: {...DEFAULT_NOTIFICATION_SETTINGS}, outputFolding: 'smart', welcome: 'vespyr', suggestions: 'nmsh', + history: 'native', + picker: 'native', + navigation: 'native', suggestionsOnEmpty: false, + toolUpdateChecks: 'off', + installSuggestions: true, + ignoredInstallSuggestions: [], + promptSymbol: 'chevron', + cursor: {...DEFAULT_CURSOR}, + statusStrip: {...DEFAULT_STATUS_STRIP}, + keepAwake: {...DEFAULT_KEEP_AWAKE_PRESENTATION}, + agentProfiles: [], + sessionNotices: true, + askRecord: true, + askPresentation: 'chat' as const, + localUnderstanding: {mode: 'auto', ask: true, folding: false}, + agentActivity: true, + shellBackend: 'zsh', + openWith: 'auto', + idleVisuals: {...DEFAULT_IDLE_VISUALS, customStops: []}, + liveActivity: {...DEFAULT_LIVE_ACTIVITY, customStops: []}, + uiChrome: {...DEFAULT_UI_CHROME}, + themes: [], + themeBridge: DEFAULT_THEME_BRIDGE(), 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', - mirrorRight: true}, + mirrorRight: true, vibrance: 'standard', textColors: 'theme', accent: 'mauve', styleProfiles: normalizeStyleProfiles(undefined)}, starship: {configPath: null}, powerlevel10k: {themePath: null, configPath: null}, + ohMyPosh: {configPath: null}, transcript: {...DEFAULT_TRANSCRIPT_APPEARANCE}, syntax: {...DEFAULT_SYNTAX_APPEARANCE}, placement: 'header', composerLayout: 'twoLine', composerPosition: 'bottom', + panelPosition: 'bottom', + composerDividers: true, transcriptPresentation: 'normal', + contextRail: {...DEFAULT_CONTEXT_RAIL}, modules: [ + {id: 'shell', visible: true, condition: 'shellDiffers'}, {id: 'project', visible: true, condition: 'always'}, {id: 'cwd', visible: true, condition: 'always'}, {id: 'gitBranch', visible: true, condition: 'inRepository'}, @@ -258,14 +776,15 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { {id: 'exitStatus', visible: true, condition: 'nonzeroExit'}, {id: 'kubeContext', visible: true, condition: 'onCommand'}, {id: 'dockerContext', visible: true, condition: 'onCommand'}, + {id: 'discoveredTools', visible: false, condition: 'always'}, ], separator: '', gap: 1, spacing: 1, }; -const MODULE_IDS = new Set(['project', 'cwd', 'gitBranch', 'gitStatus', 'toolchain', 'exitStatus', 'kubeContext', 'dockerContext']); -const CONDITIONS = new Set(['always', 'inRepository', 'nonzeroExit', 'onCommand']); +const MODULE_IDS = new Set(Object.keys(CONTEXT_MODULE_REGISTRY) as ContextModuleId[]); +const CONDITIONS = new Set(['always', 'inRepository', 'nonzeroExit', 'onCommand', 'shellDiffers']); function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); @@ -282,15 +801,16 @@ 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; - const updateChecks: UpdateCheckFrequency = UPDATE_CHECK_FREQUENCIES.includes(value.updateChecks as UpdateCheckFrequency) - ? value.updateChecks as UpdateCheckFrequency : 'off'; + const {updateMode, updateFrequency} = migrateUpdateSettings(value); const liveSessionStartup: LiveSessionStartup = LIVE_SESSION_STARTUP.includes(value.liveSessionStartup as LiveSessionStartup) ? value.liveSessionStartup as LiveSessionStartup : 'ask'; const liveSessionMultiple: LiveSessionMultiple = LIVE_SESSION_MULTIPLE.includes(value.liveSessionMultiple as LiveSessionMultiple) @@ -301,13 +821,31 @@ 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' + const toolUpdateChecks: UpdateCheckFrequency = UPDATE_CHECK_FREQUENCIES.includes(value.toolUpdateChecks as UpdateCheckFrequency) + ? value.toolUpdateChecks as UpdateCheckFrequency : 'off'; + const installSuggestions = value.installSuggestions !== false; + const ignoredInstallSuggestions = Array.isArray(value.ignoredInstallSuggestions) + ? [...new Set(value.ignoredInstallSuggestions.filter((id): id is string => typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.+-]{0,63}$/u.test(id)))].slice(0, 256) + : []; + const promptSymbolCustom = normalizeCustomGlyph(value.promptSymbolCustom); + const tooling = {motion: normalizeMotion(value.motion), pastePreview: (value.pastePreview === 'always' || value.pastePreview === 'off' ? value.pastePreview : 'smart') as 'smart' | 'always' | 'off', cursor: normalizeCursor(value.cursor), statusStrip: normalizeStatusStrip(value.statusStrip), keepAwake: normalizeKeepAwakePresentation(value.keepAwake), idleVisuals: normalizeIdleVisuals(value.idleVisuals), liveActivity: normalizeLiveActivity(value.liveActivity), uiChrome: normalizeUiChrome(value.uiChrome), + sessionNotices: value.sessionNotices !== false, agentProfiles: normalizeProfiles(value.agentProfiles), agentActivity: value.agentActivity !== false, askRecord: value.askRecord !== false, askPresentation: value.askPresentation === 'normal' ? 'normal' as const : 'chat' as const, + localUnderstanding: normalizeLocalUnderstanding(value.localUnderstanding), + shellBackend: isShellId(value.shellBackend) ? value.shellBackend : 'zsh', + openWith: OPEN_WITH_IDS.includes(value.openWith as OpenWith) ? value.openWith as OpenWith : 'auto', toolUpdateChecks, installSuggestions, ignoredInstallSuggestions, promptSymbol: normalizePromptSymbol(value.promptSymbol), + ...(promptSymbolCustom ? {promptSymbolCustom} : {})}; + const provider: PromptProviderId = promptValue.provider === 'starship' || promptValue.provider === 'powerlevel10k' || promptValue.provider === 'ohMyPosh' || promptValue.provider === 'none' ? promptValue.provider : 'nmsh'; const p10kValue = isRecord(promptValue.powerlevel10k) ? promptValue.powerlevel10k : {}; const optionalPath = (value: unknown) => typeof value === 'string' && value.trim() ? value : null; const powerlevel10k = {themePath: optionalPath(p10kValue.themePath), configPath: optionalPath(p10kValue.configPath)}; + const ompValue = isRecord(promptValue.ohMyPosh) ? promptValue.ohMyPosh : {}; + const ohMyPosh = {configPath: optionalPath(ompValue.configPath)}; const nativeValue = isRecord(promptValue.nmsh) ? promptValue.nmsh : promptValue; const starshipValue = isRecord(promptValue.starship) ? promptValue.starship : {}; const endStyle = normalizeEdgeStyle(nativeValue.endStyle, 'fadeWedge'); @@ -315,24 +853,36 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio const connector = normalizeConnectorStyle(nativeValue.connector); const icons: NativeIconMode = nativeValue.icons === 'off' || nativeValue.icons === false ? 'off' : 'nerd'; const style = normalizePromptStyle(nativeValue.style); - const palette = normalizePaletteId(nativeValue.palette); + // The library is canonical; a pre-library customTheme migrates into it once. + const library = normalizeThemeLibrary(value.themes, value.customTheme, nativeValue.themeId); + const customTheme = findTheme(library.themes, library.themeId)?.theme; + // A custom palette without a valid library theme falls back instead of rendering nothing. + const storedPalette = normalizePaletteId(nativeValue.palette); + const palette = storedPalette === 'custom' && !customTheme ? 'lavender' : storedPalette; + const themed = {...tooling, contextRail: normalizeContextRail(value.contextRail, library.themes), themes: library.themes, themeBridge: normalizeThemeBridge(value.themeBridge), ...(customTheme ? {customTheme: structuredClone(customTheme)} : {})}; 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, + startStyle, connector, endStyle, palette, ...(library.themeId ? {themeId: library.themeId} : {}), icons, style, connectorFade: normalizeConnectorFade(nativeValue.connectorFade), connectorFadeColors: normalizeConnectorFadeColors(nativeValue.connectorFadeColors), gitEnabled: typeof nativeValue.gitEnabled === 'boolean' ? nativeValue.gitEnabled : true, gitColors: normalizeGitColorMode(nativeValue.gitColors), gitGeometry: normalizeGitGeometry(nativeValue.gitGeometry), gitConnectorFade: normalizeGitConnectorFade(nativeValue.gitConnectorFade), - mirrorRight: typeof nativeValue.mirrorRight === 'boolean' ? nativeValue.mirrorRight : true}; + mirrorRight: typeof nativeValue.mirrorRight === 'boolean' ? nativeValue.mirrorRight : true, + vibrance: normalizeVibrance(nativeValue.vibrance), + textColors: (nativeValue.textColors === 'neutral' ? 'neutral' : 'theme') as PromptTextColors, + accent: normalizeCatppuccinAccent(nativeValue.accent), + styleProfiles: normalizeStyleProfiles(undefined)}; const starshipConfigPath = typeof starshipValue.configPath === 'string' && starshipValue.configPath.trim() ? starshipValue.configPath : null; const placement: ContextPlacement = value.placement === 'composer' ? 'composer' : 'header'; const composerLayout: ComposerLayout = value.composerLayout === 'oneLine' ? 'oneLine' : 'twoLine'; + const panelPosition: PanelPosition = value.panelPosition === 'top' ? 'top' : 'bottom'; const composerPosition: ComposerPosition = value.composerPosition === 'top' || value.composerPosition === 'flow' ? value.composerPosition : 'bottom'; const transcriptPresentation: TranscriptPresentation = value.transcriptPresentation === 'chat' ? 'chat' : 'normal'; const spacing = typeof value.spacing === 'number' && Number.isFinite(value.spacing) @@ -341,14 +891,15 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio const gap = typeof value.gap === 'number' && Number.isFinite(value.gap) ? Math.max(0, Math.min(3, Math.round(value.gap))) : DEFAULT_PROMPT_CONFIGURATION.gap; + nmsh.styleProfiles = normalizeStyleProfiles(nativeValue.styleProfiles, nmsh.gapEnabled ? gap : 0, spacing); // Mixed needs a Normal or Wide gap; an unreleased Compact/Off + Mixed reads as Previous. nmsh.connectorFadeColors = resolveFadeColors(nmsh.connectorFadeColors, nmsh.gapEnabled, gap); const separator = validSeparator(value.separator) ? value.separator : DEFAULT_PROMPT_CONFIGURATION.separator; if (!Array.isArray(value.modules)) { return {...structuredClone(DEFAULT_PROMPT_CONFIGURATION), provider, onboardingComplete: value.onboardingComplete === true, - glyphStyle, glyphChoiceComplete, sessionRetention, updateChecks, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, suggestionsOnEmpty, - nmsh, starship: {configPath: starshipConfigPath}, powerlevel10k, transcript, syntax, placement, composerLayout, composerPosition, transcriptPresentation, spacing, gap, separator}; + toolsSetupComplete, glyphStyle, glyphChoiceComplete, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, + presentation, nmsh, starship: {configPath: starshipConfigPath}, powerlevel10k, ohMyPosh, transcript, syntax, notifications, placement, composerLayout, composerPosition, panelPosition, transcriptPresentation, composerDividers: value.composerDividers !== false, spacing, gap, separator, ...themed}; } const modules: ContextModuleConfig[] = []; @@ -364,18 +915,25 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio visible: typeof item.visible === 'boolean' ? item.visible : fallback.visible, condition: typeof item.condition === 'string' && CONDITIONS.has(item.condition as ContextCondition) && (item.condition !== 'onCommand' || ON_COMMAND_MODULES.has(id)) + && ((item.condition === 'shellDiffers') === (id === 'shell') || (id === 'shell' && item.condition === 'always')) ? item.condition as ContextCondition : fallback.condition, }; + if (MODULE_SURFACES.includes(item.surface as ModuleSurface)) module.surface = item.surface as ModuleSurface; if (item.placement === 'right') module.placement = 'right'; if (validColor(item.foreground)) module.foreground = item.foreground; if (validColor(item.background)) module.background = item.background; modules.push(module); } + // Upgrade only the former untouched default. Saved custom order, placement, + // visibility and module colors remain authoritative. + const formerDefault = [...DEFAULT_PROMPT_CONFIGURATION.modules.slice(1), DEFAULT_PROMPT_CONFIGURATION.modules[0]!]; + if (JSON.stringify(modules) === JSON.stringify(formerDefault)) modules.unshift(modules.pop()!); // Modules added in later releases join saved configurations at their // default position instead of silently staying absent. DEFAULT_PROMPT_CONFIGURATION.modules.forEach((fallback, defaultIndex) => { if (seen.has(fallback.id)) return; + if (fallback.id === 'discoveredTools') { modules.push({...fallback}); seen.add(fallback.id); return; } // Git status split from the branch module: it joins right after the // branch wherever the user placed it, so v0.3 prompts look the same. const branch = fallback.id === 'gitStatus' ? modules.findIndex(module => module.id === 'gitBranch') : -1; @@ -388,8 +946,10 @@ 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, suggestionsOnEmpty, nmsh, transcript, syntax, powerlevel10k, - starship: {configPath: starshipConfigPath}, placement, composerLayout, composerPosition, transcriptPresentation, modules, separator, spacing, gap}; + return {provider, onboardingComplete: value.onboardingComplete === true, + toolsSetupComplete, + glyphStyle, glyphChoiceComplete, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, presentation, nmsh, transcript, syntax, notifications, powerlevel10k, ohMyPosh, + starship: {configPath: starshipConfigPath}, placement, composerLayout, composerPosition, panelPosition, transcriptPresentation, composerDividers: value.composerDividers !== false, modules, separator, spacing, gap, ...themed}; } export function loadPromptConfiguration(path = promptConfigurationPath()): PromptConfiguration { @@ -400,24 +960,85 @@ 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); } export function hasVisibleContextModule( configuration: PromptConfiguration, - context?: {branch?: string; exitStatus?: number; commandWords?: readonly string[]}, + context?: {branch?: string; exitStatus?: number; commandWords?: readonly string[]; shell?: {differs: boolean}}, /** Whether an on-command module is relevant to the typed command. */ onCommand: (id: ContextModuleId, words: readonly string[]) => boolean = () => false, ): boolean { return configuration.modules.some(module => module.visible + && module.surface !== 'hidden' && module.surface !== 'contextRail' + && (module.surface !== 'auto' || CONTEXT_MODULE_REGISTRY[module.id].preferredSurface !== 'contextRail') && (module.condition !== 'inRepository' || Boolean(context?.branch)) && (module.condition !== 'nonzeroExit' || (context?.exitStatus ?? 0) !== 0) - && (module.condition !== 'onCommand' || onCommand(module.id, context?.commandWords ?? []))); + && (module.condition !== 'onCommand' || onCommand(module.id, context?.commandWords ?? [])) + && (module.id !== 'shell' || Boolean(context?.shell)) + && (module.condition !== 'shellDiffers' || Boolean(context?.shell?.differs))); } /** @@ -437,3 +1058,44 @@ export function applyNativeGapChoice(configuration: PromptConfiguration, choice: configuration.nmsh.connectorFadeColors = resolveFadeColors(configuration.nmsh.connectorFadeColors, configuration.nmsh.gapEnabled, configuration.gap); } + +/** Off never loads a model. Auto may, lazily, for enabled scopes. Always prefers it for enabled scopes. */ +export type LocalUnderstandingMode = 'off' | 'auto' | 'always'; +export const LOCAL_UNDERSTANDING_MODES: readonly LocalUnderstandingMode[] = ['off', 'auto', 'always']; +export const LOCAL_UNDERSTANDING_LABELS: Record = {off: 'Off', auto: 'Auto', always: 'Always'}; +export type LocalRuntimeKind = 'llama.cpp' | 'ollama' | 'lmstudio'; + +/** The chosen model: NMSh's own download, or a compatible model found locally. Runtime state is never stored here. */ +export interface LocalModelChoice { + label: string; + runtime: LocalRuntimeKind; + /** GGUF file for llama.cpp. */ + path?: string; + /** Model name for Ollama or LM Studio. */ + name?: string; + /** NMSh downloaded it (and may remove it); otherwise it was found and is never deleted by NMSh. */ + owned: boolean; +} + +export interface LocalUnderstandingSettings { + mode: LocalUnderstandingMode; + /** Feature scopes; each is opt-in and kept when the mode is Off. */ + ask: boolean; + folding: boolean; + model?: LocalModelChoice; +} + +function normalizeLocalUnderstanding(value: unknown): LocalUnderstandingSettings { + const record = isRecord(value) ? value : {}; + // Only an absent or unrecognised mode gets the Auto default; a saved Off stays Off. + const mode = LOCAL_UNDERSTANDING_MODES.includes(record.mode as LocalUnderstandingMode) ? record.mode as LocalUnderstandingMode : 'auto'; + const settings: LocalUnderstandingSettings = {mode, ask: typeof record.ask === 'boolean' ? record.ask : true, folding: record.folding === true}; + const model = isRecord(record.model) ? record.model : undefined; + const runtime = model && (['llama.cpp', 'ollama', 'lmstudio'] as const).includes(model.runtime as LocalRuntimeKind) ? model.runtime as LocalRuntimeKind : undefined; + if (model && runtime && typeof model.label === 'string' && model.label.length <= 120 && !/[\u0000-\u001f]/u.test(model.label) + && (typeof model.path === 'string' || typeof model.name === 'string')) { + settings.model = {label: model.label, runtime, owned: model.owned === true, + ...(typeof model.path === 'string' ? {path: model.path} : {}), ...(typeof model.name === 'string' ? {name: model.name} : {})}; + } + return settings; +} diff --git a/src/prompt/glyphChoices.ts b/src/prompt/glyphChoices.ts new file mode 100644 index 00000000..a9eed7ce --- /dev/null +++ b/src/prompt/glyphChoices.ts @@ -0,0 +1,182 @@ +import stringWidth from 'string-width'; +import {getCurrentGlyphMode} from '../ui/glyphs.js'; + +/** + * Curated prompt glyphs. Each style offers only separators that read as that + * style's structure; geometry styles (Powerline, Soft, Outline, Compact, + * Ribbon) keep their shape choices and get no free-form glyph. Every choice + * has an ASCII fallback for Safe glyph mode, and a custom glyph is one + * validated grapheme of one or two cells, never an escape sequence. + */ + +export interface GlyphChoice { + /** Nerd / Unicode presentation. */ + glyph: string; + /** Safe / ASCII presentation. */ + ascii: string; + label: string; +} + +export const SEPARATOR_CHOICES = { + space: {glyph: '', ascii: '', label: 'Space'}, + dot: {glyph: '·', ascii: '.', label: 'Dot ·'}, + bullet: {glyph: '•', ascii: '*', label: 'Bullet •'}, + pipe: {glyph: '│', ascii: '|', label: 'Pipe │'}, + slash: {glyph: '/', ascii: '/', label: 'Slash /'}, + chevron: {glyph: '', ascii: '>', label: 'Chevron ›'}, + arrow: {glyph: '→', ascii: '>', label: 'Arrow →'}, + doubleChevron: {glyph: '»', ascii: '>>', label: 'Double chevron »'}, + diamond: {glyph: '◆', ascii: '*', label: 'Diamond ◆'}, + triangle: {glyph: '▸', ascii: '>', label: 'Triangle ▸'}, + dash: {glyph: '-', ascii: '-', label: 'ASCII -'}, + dashed: {glyph: '┆', ascii: ':', label: 'Dashed ┆'}, +} as const satisfies Record; +export type SeparatorId = keyof typeof SEPARATOR_CHOICES; + +/** Style-appropriate separators; `custom` is offered only where a text separator makes sense. */ +export const MINIMAL_SEPARATORS = ['space', 'dot', 'bullet', 'pipe', 'slash', 'chevron', 'arrow', 'doubleChevron', 'diamond', 'dash', 'custom'] as const; +export const BREADCRUMB_SEPARATORS = ['chevron', 'arrow', 'slash', 'dot', 'doubleChevron', 'triangle', 'custom'] as const; +/** Outline's connected layout divides outlined segments with a thin vertical divider only. */ +export const OUTLINE_DIVIDERS = ['pipe', 'dashed', 'dot', 'slash', 'custom'] as const; +export type OutlineDivider = typeof OUTLINE_DIVIDERS[number]; +export type MinimalSeparator = typeof MINIMAL_SEPARATORS[number]; +export type BreadcrumbSeparator = typeof BREADCRUMB_SEPARATORS[number]; + +/** Breadcrumb's slash is the Powerline thin slash in Nerd mode; everything else is shared. */ +const BREADCRUMB_OVERRIDES: Partial> = {slash: {glyph: '', ascii: '/', label: 'Slash /'}}; + +export type GlyphValidation = {ok: true; glyph: string; width: 1 | 2; warning?: string} | {ok: false; reason: string}; + +/** Unicode ranges whose width differs between fonts/terminals (ambiguous or emoji presentation). */ +const UNCERTAIN_WIDTH = /[←-⇿⌀-⏿①-⓿─-◿☀-➿⬀-⯿️\u{1f000}-\u{1faff}]/u; + +/** + * One grapheme cluster, one or two terminal cells, no controls, no escapes, + * no newlines. A glyph whose width depends on the font is accepted with a + * factual warning instead of silently misaligning the prompt. + */ +export function validateGlyph(input: string): GlyphValidation { + if (!input) return {ok: false, reason: 'Enter one character.'}; + if (/[\u0000-\u001f\u007f-\u009f]/u.test(input)) return {ok: false, reason: 'Control characters, escapes and newlines are not allowed.'}; + const graphemes = [...new Intl.Segmenter(undefined, {granularity: 'grapheme'}).segment(input)]; + if (graphemes.length !== 1) return {ok: false, reason: 'Use exactly one character (one grapheme).'}; + const width = stringWidth(input); + if (width < 1 || width > 2) return {ok: false, reason: `That glyph is ${width} cells wide; use one that is 1 or 2 cells.`}; + const warning = width === 2 ? 'Wide glyph (2 cells); some fonts draw it narrower.' + : UNCERTAIN_WIDTH.test(input) ? 'Width can vary by font; check the preview.' : undefined; + return {ok: true, glyph: input, width: width as 1 | 2, ...(warning ? {warning} : {})}; +} + +/** Stored custom glyphs are revalidated on load; anything invalid is dropped. */ +export function normalizeCustomGlyph(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const result = validateGlyph(value); + return result.ok ? result.glyph : undefined; +} + +const isAscii = (text: string) => /^[\x20-\x7e]*$/u.test(text); + +/** The separator glyph actually drawn for a style under the current glyph mode. */ +export function separatorGlyph(style: 'minimal' | 'breadcrumb' | 'outline', id: string, custom: string | undefined, nerd = getCurrentGlyphMode() === 'nerd'): string { + if (id === 'custom') { + const glyph = custom ?? ''; + if (!glyph) return style === 'minimal' ? '' : style === 'outline' ? (nerd ? '│' : '|') : (nerd ? '' : '>'); + // Safe mode keeps a custom glyph only when it is plain ASCII. + return nerd || isAscii(glyph) ? glyph : style === 'breadcrumb' ? '>' : '|'; + } + const choice = (style === 'breadcrumb' ? BREADCRUMB_OVERRIDES[id as SeparatorId] : undefined) ?? SEPARATOR_CHOICES[id as SeparatorId]; + if (!choice) return ''; + if (nerd) return choice.glyph; + return choice.ascii; +} + +export function separatorLabel(id: string, custom?: string): string { + if (id === 'custom') return custom ? `Custom ${custom}` : 'Custom'; + return SEPARATOR_CHOICES[id as SeparatorId]?.label ?? id; +} + +// ---- Prompt symbol ----------------------------------------------------------- + +export const PROMPT_SYMBOLS = { + chevron: {glyph: '❯', ascii: '>', label: '❯'}, + gt: {glyph: '>', ascii: '>', label: '>'}, + dollar: {glyph: '$', ascii: '$', label: '$'}, + lambda: {glyph: 'λ', ascii: '>', label: 'λ'}, + arrow: {glyph: '→', ascii: '>', label: '→'}, + heavyArrow: {glyph: '➜', ascii: '>', label: '➜'}, +} as const satisfies Record; +export type PromptSymbolId = keyof typeof PROMPT_SYMBOLS | 'custom'; +export const PROMPT_SYMBOL_IDS: readonly PromptSymbolId[] = ['chevron', 'gt', 'dollar', 'lambda', 'arrow', 'heavyArrow', 'custom']; + +export function normalizePromptSymbol(value: unknown): PromptSymbolId { + return PROMPT_SYMBOL_IDS.includes(value as PromptSymbolId) ? value as PromptSymbolId : 'chevron'; +} + +/** The composer's prompt marker for a choice; Safe mode never shows a non-ASCII glyph. */ +export function promptSymbolGlyph(id: PromptSymbolId, custom: string | undefined, nerd = getCurrentGlyphMode() === 'nerd'): string { + if (id === 'custom') { + if (!custom) return nerd ? '❯' : '>'; + return nerd || isAscii(custom) ? custom : '>'; + } + const choice = PROMPT_SYMBOLS[id]; + return nerd ? choice.glyph : choice.ascii; +} + +export function promptSymbolLabel(id: PromptSymbolId, custom?: string): string { + return id === 'custom' ? (custom ? `Custom ${custom}` : 'Custom') : PROMPT_SYMBOLS[id].label; +} + +// ---- Semantic icons ----------------------------------------------------------- + +/** + * Semantic icons with fallbacks. An icon is used only where it says something + * faster than its text, and text always stays alongside it: icons never carry + * meaning alone. `unicode` is portable (no private-use Nerd Font code points); + * `ascii` is strict ASCII and may be empty when the text already says it. + */ +export interface SemanticIcon {nerd: string; unicode: string; ascii: string} + +export const SEMANTIC_ICONS = { + folder: {nerd: '', unicode: '', ascii: ''}, + home: {nerd: '', unicode: '~', ascii: '~'}, + repository: {nerd: '', unicode: '', ascii: ''}, + branch: {nerd: '', unicode: '', ascii: 'git:'}, + commit: {nerd: '', unicode: '●', ascii: '@'}, + modified: {nerd: '', unicode: '~', ascii: '~'}, + node: {nerd: '', unicode: '', ascii: ''}, + python: {nerd: '', unicode: '', ascii: ''}, + go: {nerd: '', unicode: '', ascii: ''}, + rust: {nerd: '', unicode: '', ascii: ''}, + docker: {nerd: '', unicode: '', ascii: ''}, + kubernetes: {nerd: '\u{f10fe}', unicode: '', ascii: ''}, + clock: {nerd: '', unicode: '', ascii: ''}, + battery: {nerd: '', unicode: '', ascii: 'bat'}, + batteryCharging: {nerd: '', unicode: '⚡', ascii: '+'}, + cpu: {nerd: '', unicode: '', ascii: 'cpu'}, + memory: {nerd: '', unicode: '', ascii: 'ram'}, + uptime: {nerd: '', unicode: '', ascii: 'up'}, + search: {nerd: '', unicode: '?', ascii: '/'}, + settings: {nerd: '', unicode: '', ascii: ''}, + success: {nerd: '✓', unicode: '✓', ascii: '+'}, + failure: {nerd: '✕', unicode: '✕', ascii: 'x'}, + warning: {nerd: '', unicode: '!', ascii: '!'}, + info: {nerd: '', unicode: 'i', ascii: 'i'}, + update: {nerd: '', unicode: '↻', ascii: '^'}, + sparkle: {nerd: '✦', unicode: '✦', ascii: '*'}, + moon: {nerd: '', unicode: '☾', ascii: ''}, + /** Keep Awake: an open eye (no mascot, no cup); Safe/ASCII has none, so the text carries it. */ + awake: {nerd: '\u{f06e}', unicode: '\u25c9', ascii: ''}, + palette: {nerd: '', unicode: '', ascii: ''}, +} as const satisfies Record; +export type SemanticIconId = keyof typeof SEMANTIC_ICONS; + +/** The icon for the current glyph mode; Safe mode uses the ASCII form (possibly empty). */ +export function semanticIcon(id: SemanticIconId, mode: 'nerd' | 'unicode' | 'ascii' = getCurrentGlyphMode() === 'nerd' ? 'nerd' : 'ascii'): string { + return SEMANTIC_ICONS[id][mode]; +} + +/** Icon then text, or text alone when the mode has no icon for it. */ +export function withSemanticIcon(id: SemanticIconId, text: string, mode?: 'nerd' | 'unicode' | 'ascii'): string { + const icon = semanticIcon(id, mode); + return icon ? `${icon} ${text}` : text; +} diff --git a/src/prompt/ohMyPosh.ts b/src/prompt/ohMyPosh.ts new file mode 100644 index 00000000..8c314e93 --- /dev/null +++ b/src/prompt/ohMyPosh.ts @@ -0,0 +1,112 @@ +import {execFile, spawn} from 'node:child_process'; +import {accessSync, constants} from 'node:fs'; +import {homedir} from 'node:os'; +import {isAbsolute, resolve} from 'node:path'; +import {promisify} from 'node:util'; +import {resolveCommand} from '../providers/providers.js'; +import type {PromptContext} from '../shell/ShellContext.js'; +import {parseStarshipPrompt, type StarshipPromptResult} from './starship.js'; + +const execFileAsync = promisify(execFile); + +/** + * Oh My Posh as an external prompt provider. NMSh runs the executable + * directly (`oh-my-posh print primary`), never `oh-my-posh init` and never + * through a shell or the user's rc files: argv only, the cwd and last exit + * status passed as documented flags, no controlling TTY, bounded output, a + * timeout that kills the whole process group, and cancellation. Its ANSI + * output crosses the same safe span parser as Starship and Powerlevel10k. + * + * Config: the path chosen in NMSh, else POSH_CONFIG (documented by Oh My + * Posh), else Oh My Posh's own built-in default. No rc file is parsed. + */ + +export interface OhMyPoshStatus { + installed: boolean; + binary?: string; + version?: string; + /** undefined: Oh My Posh's built-in default configuration. */ + configPath?: string; + configSource: 'nmsh' | 'POSH_CONFIG' | 'default'; + configExists: boolean; +} + +export const OH_MY_POSH_OUTPUT_LIMIT = 256 * 1024; + +function readable(path: string): boolean { + try { accessSync(path, constants.R_OK); return true; } catch { return false; } +} + +/** Absolute, readable-or-not local path; remote (URL) configs are never fetched by NMSh's choice. */ +export function normalizeOhMyPoshConfigPath(value: string | undefined, home = homedir()): string | undefined { + const trimmed = value?.trim(); + if (!trimmed || /^[a-z][a-z0-9+.-]*:\/\//iu.test(trimmed) || /[\u0000-\u001f\u007f]/u.test(trimmed)) return undefined; + const expanded = trimmed.replace(/^~(?=\/|$)/u, home); + return isAbsolute(expanded) ? resolve(expanded) : undefined; +} + +export function ohMyPoshConfig(configured: string | undefined, env: NodeJS.ProcessEnv = process.env, home = homedir()): Pick { + const chosen = normalizeOhMyPoshConfigPath(configured, home); + if (chosen) return {configPath: chosen, configSource: 'nmsh', configExists: readable(chosen)}; + const fromEnv = normalizeOhMyPoshConfigPath(env.POSH_CONFIG, home); + if (fromEnv) return {configPath: fromEnv, configSource: 'POSH_CONFIG', configExists: readable(fromEnv)}; + return {configSource: 'default', configExists: false}; +} + +export async function detectOhMyPosh(configured?: string, env: NodeJS.ProcessEnv = process.env, + binary = resolveCommand('oh-my-posh', env.PATH ?? '')): Promise { + const config = ohMyPoshConfig(configured, env, env.HOME || homedir()); + if (!binary) return {installed: false, ...config}; + let version: string | undefined; + try { + const result = await execFileAsync(binary, ['version'], {timeout: 3000, maxBuffer: 4096, env: ohMyPoshEnvironment(env)}); + version = result.stdout.trim().split('\n')[0] || undefined; + } catch { /* Found; a failing version command does not hide that. */ } + return {installed: true, binary, ...(version ? {version} : {}), ...config}; +} + +/** The documented `print primary` argv. Every value is its own argv element; nothing is shell text. */ +export function ohMyPoshArgs(context: Pick, status: Pick, width = 200): string[] { + const exit = context.exitStatus; + return ['print', 'primary', `--pwd=${context.cwd}`, ...(exit === undefined ? ['--no-status'] : [`--status=${exit}`]), + `--terminal-width=${width}`, '--escape=false', ...(status.configPath ? [`--config=${status.configPath}`] : [])]; +} + +/** The environment is inherited minus POSH_CONFIG (the config is explicit argv) and anything that could hand it a TTY. */ +function ohMyPoshEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const {POSH_CONFIG: _config, ...rest} = env; + return {...rest, TERM: env.TERM || 'xterm-256color', NO_COLOR: undefined}; +} + +export function renderOhMyPoshPrompt(context: PromptContext, status: OhMyPoshStatus, env: NodeJS.ProcessEnv = process.env, + options: {timeoutMs?: number; signal?: AbortSignal; width?: number} = {}): Promise { + if (!status.installed || !status.binary) return Promise.reject(new Error('Oh My Posh is not installed or not available on PATH.')); + if (status.configPath && !status.configExists) return Promise.reject(new Error(`Oh My Posh config not found: ${status.configPath}`)); + const cwd = isAbsolute(context.cwd) ? context.cwd : process.cwd(); + return new Promise((resolvePrompt, reject) => { + const child = spawn(status.binary!, ohMyPoshArgs({...context, cwd}, status, options.width), { + cwd, env: ohMyPoshEnvironment(env), stdio: ['ignore', 'pipe', 'ignore'], detached: true, + }); + let stdout = ''; + let settled = false; + const kill = () => { try { process.kill(-child.pid!, 'SIGKILL'); } catch { child.kill('SIGKILL'); } }; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + options.signal?.removeEventListener('abort', abort); + if (error) reject(error); else resolvePrompt(parseStarshipPrompt(stdout)); + }; + const abort = () => { kill(); finish(new Error('Oh My Posh prompt was cancelled.')); }; + const timer = setTimeout(() => { kill(); finish(new Error('Oh My Posh prompt timed out.')); }, options.timeoutMs ?? 3000); + if (options.signal?.aborted) { abort(); return; } + options.signal?.addEventListener('abort', abort, {once: true}); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', chunk => { + stdout += chunk; + if (stdout.length > OH_MY_POSH_OUTPUT_LIMIT) { kill(); finish(new Error('Oh My Posh prompt output exceeded its limit.')); } + }); + child.on('error', error => finish(error)); + child.on('close', code => finish(code === 0 ? undefined : new Error(`Oh My Posh exited with ${code}.`))); + }); +} diff --git a/src/prompt/powerline.ts b/src/prompt/powerline.ts index ad299e66..f9319c17 100644 --- a/src/prompt/powerline.ts +++ b/src/prompt/powerline.ts @@ -1,4 +1,9 @@ -import {background, foreground, type RgbColor} from '../ui/palette.js'; +import {separatorGlyph} from './glyphChoices.js'; +import {readableTextTone, samplePromptTreatment, treatmentFor, type Treatment, type TreatmentSettings} from '../chroma/treatment.js'; +import {contrastOn, fromOklch, mixOklch, readableForeground, surfaceFor, toOklch} from '../chroma/color.js'; +import {graphemes} from '../input/inputLayout.js'; +import {background, foreground, UI_COLORS, type RgbColor} from '../ui/palette.js'; +import {defaultStyleProfiles, type PromptStyle, type StyleProfiles} from './styles.js'; import {getCurrentGlyphMode, GLYPHS, powerlineShapeGlyphs, type PowerlineShape} from '../ui/glyphs.js'; import {fadePromptColor} from './snapshot.js'; import {displayWidth, truncateText} from '../util/text.js'; @@ -25,21 +30,11 @@ export const POWERLINE_EDGE_STYLES: readonly PowerlineEdgeStyle[] = [ ]; export const POWERLINE_SHAPES: readonly PowerlineShape[] = ['wedge', 'flat', 'rounded', 'slash', 'backslash']; -/** - * Native prompt visual styles. All render from the same semantic blocks and - * colors: Powerline uses the configured geometry; Soft is Powerline with - * rounded caps and separated segments; Minimal and Outline draw text in - * each segment's color without filled backgrounds. - */ -export type PromptStyle = 'powerline' | 'soft' | 'minimal' | 'outline'; -export const PROMPT_STYLES: readonly PromptStyle[] = ['powerline', 'soft', 'minimal', 'outline']; -export const PROMPT_STYLE_LABELS: Record = {powerline: 'Powerline', soft: 'Soft', minimal: 'Minimal', outline: 'Outline'}; - -export function normalizePromptStyle(value: unknown): PromptStyle { - return PROMPT_STYLES.includes(value as PromptStyle) ? value as PromptStyle : 'powerline'; -} +export {PROMPT_STYLES, PROMPT_STYLE_LABELS, normalizePromptStyle, type PromptStyle} from './styles.js'; export interface PowerlineBlock { + /** Present when this block may receive Chroma; protected status/Git-state blocks never carry it. */ + treatment?: TreatmentSettings; /** Visual style; every block of one prompt carries the same one. Missing means Powerline. */ style?: PromptStyle; text: string; @@ -51,6 +46,20 @@ export interface PowerlineBlock { geometry?: PowerlineShape; /** Gap fade for boundaries touching this block: a shape, `off`, or the prompt's when unset. */ fade?: PowerlineShape | 'off'; + /** Text colors Neutral: text-only styles draw the label in the neutral text tone; caps and fills keep the theme. */ + neutralText?: boolean; +} + +/** Prompt Text colors Neutral: stable light neutral text, or a dark neutral where light text would fail contrast. Chosen once per fill. */ +export const NEUTRAL_PROMPT_LIGHT: RgbColor = {red: 236, green: 236, blue: 240}; +export const NEUTRAL_PROMPT_DARK: RgbColor = {red: 24, green: 24, blue: 28}; +export function neutralPromptText(background: RgbColor): RgbColor { + const luminance = (color: RgbColor) => { + const channel = (value: number) => { const v = value / 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; + return 0.2126 * channel(color.red) + 0.7152 * channel(color.green) + 0.0722 * channel(color.blue); + }; + const contrast = (a: RgbColor, b: RgbColor) => { const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p); return (x! + 0.05) / (y! + 0.05); }; + return contrast(NEUTRAL_PROMPT_LIGHT, background) >= 4.5 || contrast(NEUTRAL_PROMPT_LIGHT, background) >= contrast(NEUTRAL_PROMPT_DARK, background) ? NEUTRAL_PROMPT_LIGHT : NEUTRAL_PROMPT_DARK; } /** The shape a connector fade actually uses, or undefined for solid connectors. */ @@ -123,11 +132,19 @@ class Painter { /** Horizontal reflection of every geometry glyph, in both glyph modes. */ const REFLECTED_GLYPHS: Readonly> = { '\ue0b0': '\ue0b2', '\ue0b2': '\ue0b0', '\ue0d7': '\ue0d6', '\ue0d6': '\ue0d7', + '\ue0b1': '\ue0b3', '\ue0b3': '\ue0b1', '\ue0bb': '\ue0b9', '\ue0b9': '\ue0bb', '\ue0b5': '\ue0b7', '\ue0b7': '\ue0b5', + '[': ']', ']': '[', '\ue0b4': '\ue0b6', '\ue0b6': '\ue0b4', '\ue0b8': '\ue0ba', '\ue0ba': '\ue0b8', '\ue0bc': '\ue0be', '\ue0be': '\ue0bc', '<': '>', '>': '<', '(': ')', ')': '(', '/': '\\', '\\': '/', }; +/** Reflect every geometry glyph in a glyph cell, leaving embedded SGR sequences intact. */ +function reflectGlyphs(text: string): string { + return text.split(/(\u001B\[[0-9;]*m)/u).map(part => part.startsWith('\u001B[') ? part + : [...part].map(glyph => REFLECTED_GLYPHS[glyph] ?? glyph).join('')).join(''); +} + /** Normal orientation: the exact escape sequence this renderer has always produced. */ function serialize(cells: readonly Cell[]): string { return cells.map(cell => `${cell.sgr}${cell.text}`).join(''); @@ -140,7 +157,7 @@ function serialize(cells: readonly Cell[]): string { */ function serializeReflected(cells: readonly Cell[]): string { return cells.filter(cell => cell.text).reverse().map(cell => { - const text = cell.kind === 'glyph' ? REFLECTED_GLYPHS[cell.text] ?? cell.text : cell.text; + const text = cell.kind === 'glyph' ? reflectGlyphs(cell.text) : cell.text; return `${RESET}${zoneBackground(cell.background)}${cell.foreground ? foreground(cell.foreground) : ''}${text}`; }).join(''); } @@ -343,24 +360,151 @@ export function renderPowerlineBlocks( connectorFade: ResolvedConnectorFade = undefined, fadeColors: ConnectorFadeColors = 'previous', orientation: PowerlineOrientation = 'normal', + extras: RenderExtras = {}, ): string { if (modules.length === 0) return `${RESET}${NEUTRAL_BACKGROUND}`; const style = modules[0]!.style ?? 'powerline'; - if (style === 'minimal' || style === 'outline') return renderTextStyle(modules, style, gapEnabled ? gap : 0, spacing); - if (style === 'soft') { - // Soft: the same painter with rounded caps, always separated, no fades. - startStyle = 'rounded'; - endStyle = 'rounded'; - connector = 'rounded'; - connectorFade = undefined; - gap = Math.max(1, gap); - gapEnabled = true; - } - if (orientation === 'mirrored') { - const reflected = paintPowerlineBlocks([...modules].reverse(), gap, spacing, endStyle, gapEnabled, startStyle, connector, connectorFade, fadeColors); - return `${serializeReflected(reflected)}${RESET}${NEUTRAL_BACKGROUND}`; + const profiles = extras.profiles ?? defaultStyleProfiles(gapEnabled ? gap : 0, spacing); + const chroma = resolveChroma(modules, extras); + const paint = (blocks: readonly PowerlineBlock[]) => paintStyle(style, blocks, profiles, chroma, + {gap, spacing, endStyle, gapEnabled, startStyle, connector, connectorFade, fadeColors}); + if (orientation === 'mirrored') return `${serializeReflected(paint([...modules].reverse()))}${RESET}${NEUTRAL_BACKGROUND}`; + return serialize(paint(modules)); +} + +/** Per-prompt render options beyond the Powerline geometry. */ +export interface RenderExtras { + /** Style profiles; missing means the legacy shared gap/spacing (historical snapshots). */ + profiles?: StyleProfiles; + chroma?: PromptChroma; +} + +/** A resolved Chroma treatment for one prompt render: what, when, and whether it may move. */ +export interface PromptChroma { + treatment: Treatment; + time: number; + still: boolean; +} + +/** Explicit Chroma wins; otherwise blocks carrying treatment settings render it statically. */ +function resolveChroma(modules: readonly PowerlineBlock[], extras: RenderExtras): PromptChroma | undefined { + if (extras.chroma) return extras.chroma; + const settings = modules.find(block => block.treatment)?.treatment; + const treatment = settings && treatmentFor(settings); + return treatment ? {treatment, time: 0, still: true} : undefined; +} + +interface Geometry { + gap: number; + spacing: number; + endStyle: PowerlineEndStyle | boolean; + gapEnabled: boolean; + startStyle: PowerlineStartStyle; + connector: PowerlineConnectorStyle; + connectorFade: ResolvedConnectorFade; + fadeColors: ConnectorFadeColors; +} + +/** Block centers along the prompt, 0..1. */ +function blockPosition(index: number, count: number): number { + return count <= 1 ? 0.5 : index / (count - 1); +} + +/** + * Filled-surface Chroma: each eligible block's background moves toward the + * gradient at its place in the prompt, and its text is re-chosen for + * contrast once per presentation state. Boundaries stay because neighbors + * sample different positions. + */ +function treatFilled(blocks: readonly PowerlineBlock[], chroma: PromptChroma | undefined): PowerlineBlock[] { + if (!chroma) return [...blocks]; + return blocks.map((block, index) => { + if (!block.treatment) return block; + const position = blockPosition(index, blocks.length); + // Text is chosen once, from the motion-free treated fill; each frame's fill is then + // held within the range where that same text reads, so motion never flips it light/dark. + const text = readableForeground(samplePromptTreatment(chroma.treatment, block.background, position, 0, true), block.foreground); + const fill = surfaceFor(samplePromptTreatment(chroma.treatment, block.background, position, chroma.time, chroma.still), text); + return {...block, background: fill, foreground: text}; + }); +} + +function paintStyle(style: PromptStyle, modules: readonly PowerlineBlock[], profiles: StyleProfiles, chroma: PromptChroma | undefined, + geometry: Geometry): Cell[] { + switch (style) { + case 'minimal': case 'outline': case 'breadcrumb': return paintTextStyle(modules, style, profiles, chroma); + case 'soft': { + const profile = profiles.soft; + const blocks = treatFilled(profile.fill === 'subtle' ? modules.map(subtleBlock) : modules, chroma); + const cap: PowerlineShape = profile.cap === 'slant' ? 'slash' : profile.cap === 'square' ? 'flat' : 'rounded'; + return profile.layout === 'connected' + ? paintPowerlineBlocks(blocks, 0, profile.padding, cap, false, cap, 'flat', undefined, 'previous') + : paintPowerlineBlocks(blocks, profile.gap, profile.padding, cap, true, cap, cap, undefined, 'previous'); + } + case 'compact': return paintCompact(treatFilled(modules, chroma), profiles.compact); + case 'ribbon': return paintRibbon(modules, profiles.ribbon, chroma); + case 'powerline': return paintPowerlineBlocks(treatFilled(modules, chroma), geometry.gap, geometry.spacing, geometry.endStyle, + geometry.gapEnabled, geometry.startStyle, geometry.connector, geometry.connectorFade, geometry.fadeColors); } - return serialize(paintPowerlineBlocks(modules, gap, spacing, endStyle, gapEnabled, startStyle, connector, connectorFade, fadeColors)); +} + +const TERMINAL_DARK: RgbColor = {red: 30, green: 30, blue: 36}; + +/** Soft's subtle fill: a quiet tinted surface with the module color carried by its text. */ +function subtleBlock(block: PowerlineBlock): PowerlineBlock { + const fill = mixOklch(block.background, TERMINAL_DARK, 0.6); + return {...block, background: fill, foreground: readableForeground(fill, readableTextTone(mixOklch(block.background, {red: 255, green: 255, blue: 255}, 0.25)))}; +} + +function compactEdge(ends: StyleProfiles['compact']['ends']): PowerlineShape { + return ends === 'rounded' ? 'rounded' : ends === 'wedge' ? 'wedge' : 'flat'; +} + +/** Compact: dense filled cells, no gaps; optional thin same-hue seams. */ +function paintCompact(modules: readonly PowerlineBlock[], profile: StyleProfiles['compact']): Cell[] { + const paint = new Painter(); + const edge = compactEdge(profile.ends); + renderStart(paint, modules[0]!.background, edge); + modules.forEach((block, index) => { + blockContent(paint, block, profile.padding); + const next = modules[index + 1]; + if (next && profile.seams === 'thin') { + const seam = connectorFadeColor(next.background); + paint.add(`${RESET}${background(next.background)}${foreground(seam)}`, getCurrentGlyphMode() === 'nerd' ? '\u2595' : '|', next.background, seam); + } + }); + renderEnd(paint, modules[modules.length - 1]!.background, edge); + paint.add(`${RESET}${NEUTRAL_BACKGROUND}`, '', undefined); + return paint.cells; +} + +/** The ribbon's single band color. */ +function ribbonBand(first: RgbColor, band: StyleProfiles['ribbon']['band']): RgbColor { + if (band === 'neutral') return {red: 40, green: 40, blue: 48}; + const lch = toOklch(first); + return fromOklch({l: 0.3, c: Math.min(0.06, lch.c), h: lch.h}); +} + +/** Ribbon: one band across the prompt; each module is colored text on it, divided by thin slants. */ +function paintRibbon(modules: readonly PowerlineBlock[], profile: StyleProfiles['ribbon'], chroma: PromptChroma | undefined): Cell[] { + const paint = new Painter(); + const band = ribbonBand(modules[0]!.background, profile.band); + const nerd = getCurrentGlyphMode() === 'nerd'; + const edge: PowerlineShape = profile.ends === 'pointed' ? 'wedge' : profile.ends === 'flat' ? 'flat' : profile.slant === 'forward' ? 'slash' : 'backslash'; + const divider = profile.slant === 'forward' ? (nerd ? '\ue0bb' : '/') : (nerd ? '\ue0b9' : '\\'); + const dividerColor = readableForeground(band, mixOklch(band, {red: 255, green: 255, blue: 255}, 0.3), 2); + const pad = ' '.repeat(profile.padding); + const tones = modules.map(block => readableForeground(band, readableTextTone(textTone(block.background)), 4.5)); + const spans: Span[] = []; + modules.forEach((block, index) => { + if (index > 0) spans.push({text: ` ${divider} `, color: dividerColor, kind: 'glyph', eligible: false}); + spans.push({text: `${pad}${block.compact ? (nerd ? '●' : '*') : block.text}${pad}`, color: tones[index]!, kind: 'text', eligible: Boolean(block.treatment)}); + }); + renderStart(paint, band, edge); + paintSpans(paint, spans, chroma, band); + renderEnd(paint, band, edge); + paint.add(`${RESET}${NEUTRAL_BACKGROUND}`, '', undefined); + return paint.cells; } function paintPowerlineBlocks( @@ -433,24 +577,143 @@ function textTone(color: RgbColor): RgbColor { return {red: lift(color.red), green: lift(color.green), blue: lift(color.blue)}; } +/** A run of text one color (or one Chroma sweep) wide. */ +interface Span { + text: string; + color: RgbColor; + kind: Cell['kind']; + /** Whether Chroma may color this span. */ + eligible: boolean; + bold?: boolean; + /** A filled span (breadcrumb anchor); text spans are unfilled. */ + fill?: RgbColor; +} + /** - * Minimal and Outline: no filled backgrounds. Segment colors become text - * tones; Outline adds thin caps (Nerd half-circle outlines, Safe parentheses). - * Symmetric, so mirrored right context renders the same way. + * Paint spans as cells. With Chroma, eligible spans take a per-column color + * from one sweep across the whole prompt; each span stays one cell so a + * mirrored render keeps its reading order. */ -function renderTextStyle(modules: readonly PowerlineBlock[], style: 'minimal' | 'outline', gap: number, spacing: number): string { - const safe = getCurrentGlyphMode() === 'safe'; - const [open, close] = safe ? ['(', ')'] : ['\uE0B7', '\uE0B5']; - const pad = ' '.repeat(Math.max(0, Math.min(3, Math.trunc(spacing)))); - 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; - return style === 'minimal' - ? `${tone}${text}` - : `${tone}${open}${pad}${text}${pad}${close}`; - }); - return `${RESET}${NEUTRAL_BACKGROUND}${parts.join(`${RESET}${NEUTRAL_BACKGROUND}${separator}`)}${RESET}${NEUTRAL_BACKGROUND}`; +function paintSpans(paint: Painter, spans: readonly Span[], chroma: PromptChroma | undefined, zone?: RgbColor): void { + const total = spans.reduce((sum, span) => sum + displayWidth(span.text), 0); + let column = 0; + let previous = ''; + for (const span of spans) { + const fill = span.fill ?? zone; + const backgroundSgr = fill ? background(fill) : NEUTRAL_BACKGROUND; + const bold = span.bold ? BOLD : ''; + let body = span.text; + if (chroma && span.eligible && span.text.trim()) { + let offset = column; + body = graphemes(span.text).map(glyph => { + const position = total <= 1 ? 0 : offset / (total - 1); + offset += displayWidth(glyph); + const treated = samplePromptTreatment(chroma.treatment, span.color, position, chroma.time, chroma.still); + // On a fixed fill the hue may move but lightness only moves away from the fill, never across it. + const color = fill ? contrastOn(treated, fill, 3) : readableTextTone(treated); + return `${foreground(color)}${glyph}`; + }).join(''); + } + const sgr = `${RESET}${backgroundSgr}${bold}${foreground(span.color)}`; + // Adjacent spans in the same plain style share one escape. + paint.add(sgr === previous ? '' : sgr, body, fill, span.color, span.kind); + previous = body === span.text ? sgr : ''; + column += displayWidth(span.text); + } +} + +const BOLD = '\u001B[1m'; +const SUBTLE_SEPARATOR = UI_COLORS.subtle; + +function minimalSeparator(profile: StyleProfiles['minimal']): string { + const glyph = separatorGlyph('minimal', profile.separator, profile.customSeparator); + if (!glyph) return ' '.repeat(profile.spacing); + const side = ' '.repeat(Math.max(1, Math.ceil(profile.spacing / 2))); + return `${side}${glyph}${side}`; +} + +function outlineCaps(cap: StyleProfiles['outline']['cap']): [string, string] { + const nerd = getCurrentGlyphMode() === 'nerd'; + if (cap === 'square') return ['[', ']']; + if (cap === 'angle') return nerd ? ['\ue0b3', '\ue0b1'] : ['<', '>']; + return nerd ? ['\uE0B7', '\uE0B5'] : ['(', ')']; +} + +function breadcrumbSeparator(profile: StyleProfiles['breadcrumb']): string { + const glyph = separatorGlyph('breadcrumb', profile.separator, profile.customSeparator); + const side = ' '.repeat(profile.spacing); + return `${side}${glyph}${side}`; +} + +/** + * Minimal, Outline and Breadcrumb: no filled module backgrounds (the + * Breadcrumb anchor is the one filled pill). Module colors become text + * tones; separators and outlines stay quiet unless Chroma treats them. + */ +function paintTextStyle(modules: readonly PowerlineBlock[], style: 'minimal' | 'outline' | 'breadcrumb', profiles: StyleProfiles, + chroma: PromptChroma | undefined): Cell[] { + const nerd = getCurrentGlyphMode() === 'nerd'; + const label = (block: PowerlineBlock) => block.compact ? (nerd ? '●' : '*') : block.text; + const tone = (block: PowerlineBlock) => textTone(block.background); + // Neutral text: the label is neutral, the caps and separators keep the theme color. + const labelTone = (block: PowerlineBlock) => block.neutralText ? NEUTRAL_PROMPT_LIGHT : tone(block); + const spans: Span[] = []; + if (style === 'minimal') { + const profile = profiles.minimal; + const separator = minimalSeparator(profile); + modules.forEach((block, index) => { + if (index > 0) spans.push({text: separator, color: SUBTLE_SEPARATOR, kind: separator.trim() ? 'glyph' : 'space', + eligible: Boolean(modules[index - 1]!.treatment && block.treatment)}); + spans.push({text: label(block), color: labelTone(block), kind: 'text', eligible: Boolean(block.treatment) && !block.neutralText, + bold: profile.emphasis === 'all' || (profile.emphasis === 'first' && index === 0)}); + }); + } else if (style === 'outline') { + const profile = profiles.outline; + const [open, close] = outlineCaps(profile.cap); + const pad = ' '.repeat(profile.padding); + if (profile.layout === 'connected') { + const divider = separatorGlyph('outline', profile.divider, profile.customSeparator); + modules.forEach((block, index) => { + const color = tone(block); + const eligible = Boolean(block.treatment); + if (index === 0) spans.push({text: open, color, kind: 'glyph', eligible}); + else spans.push({text: divider, color: SUBTLE_SEPARATOR, kind: 'glyph', eligible: eligible && Boolean(modules[index - 1]!.treatment)}); + spans.push({text: `${pad}${label(block)}${pad}`, color: labelTone(block), kind: 'text', eligible: eligible && !block.neutralText}); + if (index === modules.length - 1) spans.push({text: close, color, kind: 'glyph', eligible}); + }); + } else { + modules.forEach((block, index) => { + const color = tone(block); + const eligible = Boolean(block.treatment); + if (index > 0 && profile.gap > 0) spans.push({text: ' '.repeat(profile.gap), color, kind: 'space', eligible: false}); + spans.push({text: open, color, kind: 'glyph', eligible}); + spans.push({text: `${pad}${label(block)}${pad}`, color: labelTone(block), kind: 'text', eligible: eligible && !block.neutralText}); + spans.push({text: close, color, kind: 'glyph', eligible}); + }); + } + } else { + const profile = profiles.breadcrumb; + const separator = breadcrumbSeparator(profile); + const anchor = profile.anchor === 'none' ? -1 : profile.anchor === 'first' ? 0 : modules.length - 1; + const filled = treatFilled(modules, chroma); + modules.forEach((block, index) => { + if (index > 0) spans.push({text: separator, color: SUBTLE_SEPARATOR, kind: 'glyph', eligible: false}); + if (index !== anchor) { + spans.push({text: label(block), color: labelTone(block), kind: 'text', eligible: Boolean(block.treatment) && !block.neutralText}); + return; + } + const pill = filled[index]!; + const caps = powerlineShapeGlyphs(nerd ? 'rounded' : 'flat'); + if (caps.open) spans.push({text: caps.open, color: pill.background, kind: 'glyph', eligible: false}); + spans.push({text: ` ${label(pill)} `, color: pill.foreground, kind: 'text', eligible: false, fill: pill.background}); + if (caps.close) spans.push({text: caps.close, color: pill.background, kind: 'glyph', eligible: false}); + }); + } + const paint = new Painter(); + paint.add(`${RESET}${NEUTRAL_BACKGROUND}`, '', undefined); + paintSpans(paint, spans, chroma); + paint.add(`${RESET}${NEUTRAL_BACKGROUND}`, '', undefined); + return paint.cells; } /** Fit complete segment transitions to the cell budget, omitting decorations first. */ @@ -465,6 +728,7 @@ export function fitPowerlineBlocks( connector: PowerlineConnectorStyle = 'wedge', connectorFade: ResolvedConnectorFade = undefined, fadeColors: ConnectorFadeColors = 'previous', + extras: RenderExtras = {}, ): string { if (width <= 0 || modules.length === 0) return ''; const style = normalizeEndStyle(endStyle); @@ -473,7 +737,7 @@ export function fitPowerlineBlocks( return `${foreground(first.foreground)}${truncateText(first.text, width)}${RESET}${NEUTRAL_BACKGROUND}`; } const render = (blocks: readonly PowerlineBlock[], blockGap: number, blockSpacing: number, enabled: boolean) => - renderPowerlineBlocks(blocks, blockGap, blockSpacing, style, enabled, startStyle, connector, connectorFade, fadeColors); + renderPowerlineBlocks(blocks, blockGap, blockSpacing, style, enabled, startStyle, connector, connectorFade, fadeColors, 'normal', extras); for (let count = modules.length; count >= 1; count -= 1) { const visible = modules.slice(0, count); diff --git a/src/prompt/prompt.ts b/src/prompt/prompt.ts index a8d2165c..4943a8d8 100644 --- a/src/prompt/prompt.ts +++ b/src/prompt/prompt.ts @@ -1,11 +1,22 @@ +import {colorLevel} from '../presentation/capabilities.js'; +import {promptFacts, moduleFactContext, safeContextText, factAllowed} from '../context/facts.js'; +import {routeModule} from '../context/surfaceRouter.js'; +import {findTheme} from '../appearance/themeLibrary.js'; +import {accentedVariant, THEME_VARIANTS, type CatppuccinAccent, type ThemeVariant} from '../appearance/themeFamilies.js'; +import type {CustomTheme} from '../appearance/customTheme.js'; +import {paintDivider, treatmentAnimated, treatmentFor, type TreatmentSettings} from '../chroma/treatment.js'; +import {applyVibrance, fromOklch, readableForeground, toOklch, type Vibrance} from '../chroma/color.js'; +import {isReducedMotion} from '../presentation/environment.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'; +import {foreground, UI_COLORS, type RgbColor, lazyForeground} from '../ui/palette.js'; +import {neutralPromptText} from './powerline.js'; import {GLYPHS, moduleIcon, type ModuleIconId} from '../ui/glyphs.js'; import { DEFAULT_PROMPT_CONFIGURATION, - modulePlacement, type ContextModuleConfig, + CONTEXT_MODULE_REGISTRY, + type ContextSurface, type GitColorMode, type NativeIconMode, type NativePaletteId, @@ -14,22 +25,21 @@ import { import {homedir} from 'node:os'; import {COMMAND_CONTEXT_TRIGGERS, matchesCommand, TOOLCHAIN_TRIGGERS} from './commandContext.js'; import {displayPath, PATH_DISPLAY_LEVELS} from './pathDisplay.js'; -import {fitPowerlineBlocks, fitRightPowerlineBlocks, renderPowerlineBlocks, resolveConnectorFade, resolveFadeColors, type PowerlineShape, type PromptStyle} from './powerline.js'; +import {fitPowerlineBlocks, fitRightPowerlineBlocks, renderPowerlineBlocks, resolveConnectorFade, resolveFadeColors, type PowerlineShape, type PromptChroma, type PromptStyle, type RenderExtras, type PowerlineBlock} from './powerline.js'; import {desaturatePromptColor, type PromptSnapshot, type PromptSegmentSnapshot} from './snapshot.js'; const RESET = '\u001B[0m'; -const LINE = foreground(UI_COLORS.separator); -const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/gu; /** NMSh brand/project lavender. */ export const NMSH_BRAND_LAVENDER: RgbColor = {red: 166, green: 124, blue: 243}; export const FADE_TAIL_GLYPHS = GLYPHS.powerlineFade; function safePromptText(value: string): string { - return value.replace(CONTROL_CHARACTERS, '�'); + return safeContextText(value); } interface RenderedModule { + treatment?: TreatmentSettings; id: ContextModuleConfig['id']; role: PromptRole; style?: PromptStyle; @@ -62,6 +72,9 @@ export function isPromptRole(value: unknown): value is PromptRole { } const hex = (value: string): RgbColor => colorFromHex(value, {red: 0, green: 0, blue: 0}); +/** Newer themes give only backgrounds; text is chosen for >= 4.5:1 contrast on each. */ +const auto = (backgrounds: Record): ThemeRoles => Object.fromEntries(Object.entries(backgrounds) + .map(([role, value]) => [role, {background: hex(value), foreground: readableForeground(hex(value))}])) as ThemeRoles; const pair = (backgroundHex: string, foregroundHex: string): SegmentColors => ({background: hex(backgroundHex), foreground: hex(foregroundHex)}); const STATUS_COLORS = { success: {background: UI_COLORS.success, foreground: hex('#10231b')}, @@ -129,7 +142,7 @@ export function promptRoleColors(role: PromptRole, palette: NativePaletteId, git * any module order. Toolchain segments keep recognizable identities in the * semantic themes; Lavender Native and Grayscale stay within their family. */ -export const NATIVE_PROMPT_THEMES: Record = { +export const NATIVE_PROMPT_THEMES = { lavender: theme('lavender', 'Lavender Native', 'lavender, violet and iris family', { project: pair('#a67cf3', '#faf6ff'), cwd: pair('#7a68b8', '#f3eeff'), @@ -187,7 +200,79 @@ export const NATIVE_PROMPT_THEMES: Record = success: pair('#8e9196', '#111214'), failure: pair('#e4e5e7', '#111214'), }), -}; + aurora: theme('aurora', 'Aurora', 'polar green, teal and violet', auto({ + project: '#3fbf9a', cwd: '#2f6f8f', gitBranch: '#6a4fc4', node: '#5fae5a', go: '#2aa7c9', python: '#c9b94a', + docker: '#3d7fd6', kubernetes: '#4b63c9', success: '#4fae84', failure: '#c45a7a', + })), + ocean: theme('ocean', 'Ocean', 'deep blue, sea teal and spray', auto({ + project: '#2f8fd8', cwd: '#25506e', gitBranch: '#1f7a8c', node: '#3b9e8f', go: '#3ab0d0', python: '#5fa8c8', + docker: '#2c6fb5', kubernetes: '#3b5fb0', success: '#3aa58c', failure: '#d0605e', + })), + sunset: theme('sunset', 'Sunset', 'coral, plum and gold', auto({ + project: '#f08a5d', cwd: '#7a4a6a', gitBranch: '#b8456b', node: '#d9a441', go: '#c75d8a', python: '#f2c14e', + docker: '#8a5fa8', kubernetes: '#6b4f9e', success: '#7fae5a', failure: '#d64550', + })), + // A forest, not "all green": moss and pine, warm bark for the path, amber sap, a muted teal stream and lichen blue. + forest: theme('forest', 'Forest', 'moss, pine, bark, amber and stream teal', auto({ + project: '#6b9a52', cwd: '#5c4a36', gitBranch: '#2e6656', node: '#8fae5a', go: '#3f8a86', python: '#c9a24a', + docker: '#5b7f86', kubernetes: '#4e6a8a', success: '#6fb072', failure: '#c0603e', + })), + rose: theme('rose', 'Rose', 'rose, mauve and apricot', auto({ + project: '#e07a9a', cwd: '#6e4a5a', gitBranch: '#a8507a', node: '#c98a9a', go: '#9a6aa8', python: '#e0a87a', + docker: '#8a5a8a', kubernetes: '#7a5a9a', success: '#7aa88a', failure: '#c94a5a', + })), + nebula: theme('nebula', 'Nebula', 'violet, magenta and starlight blue', auto({ + project: '#8a4fd8', cwd: '#3a2f6e', gitBranch: '#c04fa8', node: '#4f6fd8', go: '#3fa8d8', python: '#d86fa8', + docker: '#5a4fc8', kubernetes: '#6a3fb0', success: '#4fa89a', failure: '#e0507a', + })), + highContrast: theme('highContrast', 'High Contrast Neon', 'saturated neon on maximum contrast', auto({ + project: '#ff3df2', cwd: '#24243a', gitBranch: '#00e5ff', node: '#39ff14', go: '#00b3ff', python: '#ffe600', + docker: '#2f6bff', kubernetes: '#8a5cff', success: '#00ff9c', failure: '#ff2e63', + })), +} as Record; + +/** Theme context that is not part of a palette id: Catppuccin accent and the user's custom theme. */ +let activeAccent: CatppuccinAccent = 'mauve'; +let activeCustomTheme: CustomTheme | undefined; + +/** Set from the live configuration before rendering; previews of the same config see the same context. */ +export function setThemeContext(accent: CatppuccinAccent, custom: CustomTheme | undefined): void { + activeAccent = accent; + activeCustomTheme = custom; +} + +export function themeContext(): {accent: CatppuccinAccent; custom: CustomTheme | undefined} { + return {accent: activeAccent, custom: activeCustomTheme}; +} + +function familyTheme(variant: ThemeVariant): NativePromptTheme { + const rolesFor = () => auto(accentedVariant(variant, activeAccent).roles); + let cacheKey = ''; + let cached = rolesFor(); + return {id: variant.id as NativePaletteId, label: variant.label, description: variant.description, colors: role => { + if (cacheKey !== activeAccent) { cacheKey = activeAccent; cached = rolesFor(); } + return isGitStateRole(role) ? themeGitColors(cached, role) : cached[role]; + }}; +} + +const FALLBACK_CUSTOM = {project: '#a67cf3', cwd: '#7a68b8', gitBranch: '#5e45a6', node: '#9a6fd6', go: '#5d56c2', python: '#b08bcb', + docker: '#544ca8', kubernetes: '#6c5fc7', success: '#7c84cf', failure: '#b85c8f'}; + +let customRolesSource: CustomTheme['prompt'] | undefined; +let customRoles = auto(FALLBACK_CUSTOM); +const CUSTOM_THEME: NativePromptTheme = {id: 'custom', label: 'Custom', description: 'your own NMSh theme', colors: role => { + const source = activeCustomTheme?.prompt; + if (source !== customRolesSource) { customRolesSource = source; customRoles = auto(source ?? FALLBACK_CUSTOM); } + return isGitStateRole(role) ? themeGitColors(customRoles, role) : customRoles[role]; +}}; + +for (const variant of THEME_VARIANTS) (NATIVE_PROMPT_THEMES as Record)[variant.id] = familyTheme(variant); +(NATIVE_PROMPT_THEMES as Record).custom = CUSTOM_THEME; + +/** The live label for the custom theme includes its name. */ +export function themeLabel(palette: NativePaletteId): string { + return palette === 'custom' && activeCustomTheme ? `Custom · ${activeCustomTheme.name}` : (NATIVE_PROMPT_THEMES[palette] ?? NATIVE_PROMPT_THEMES.lavender).label; +} function colorFromHex(color: string | undefined, fallback: RgbColor): RgbColor { if (!color || !/^#[0-9a-f]{6}$/iu.test(color)) return fallback; @@ -200,7 +285,7 @@ function colorFromHex(color: string | undefined, fallback: RgbColor): RgbColor { /** The cwd module's text at one shortening level of the central path policy. */ function cwdText(context: PromptContext, level: number): string { - return safePromptText(displayPath({cwd: context.cwd, home: homedir(), root: context.root, abbreviations: context.pathAbbreviations}, level)); + return safePromptText(displayPath({cwd: context.cwd, home: context.home ?? homedir(), root: context.root, abbreviations: context.pathAbbreviations}, level)); } const TOOLCHAIN_LABELS: Record = {node: 'node', go: 'go', python: 'python', docker: 'docker'}; @@ -222,6 +307,7 @@ function moduleSegments(config: ContextModuleConfig, context: PromptContext, ico if (config.condition === 'nonzeroExit' && status === 0) return []; // Toolchains filter per toolchain below; other on-command modules need a matching command. if (config.condition === 'onCommand' && config.id !== 'toolchain' && !isOnCommandRelevant(config.id, context.commandWords ?? [])) return []; + if (config.condition === 'shellDiffers' && !context.shell?.differs) return []; switch (config.id) { case 'project': return [{text: safePromptText(context.project), role: 'project'}]; @@ -256,6 +342,12 @@ function moduleSegments(config: ContextModuleConfig, context: PromptContext, ico .filter(id => config.condition !== 'onCommand' || matchesCommand(TOOLCHAIN_TRIGGERS[id], context.commandWords)) .map(id => ({text: withIcon(id, TOOLCHAIN_LABELS[id], icons), role: id})); case 'kubeContext': return context.kubeContext ? [{text: withIcon('kubernetes', safePromptText(context.kubeContext), icons), role: 'kubernetes'}] : []; + // The managed backend uses the environment-context color (as the Kubernetes context does), so every theme colors it. + case 'shell': return context.shell ? [{text: withIcon('shell', safePromptText(context.shell.current), icons), role: 'kubernetes'}] : []; + case 'discoveredTools': { + const count = context.discovery?.executables.length ?? 0; + return count ? [{text: `${count} local tools`, role: 'project'}] : []; + } case 'dockerContext': return context.dockerContext ? [{text: withIcon('docker', safePromptText(context.dockerContext), icons), role: 'docker'}] : []; case 'exitStatus': return [{ text: `${status === 0 ? GLYPHS.success : GLYPHS.failure} ${status}`, @@ -287,9 +379,77 @@ export function richGitGeometry(nmsh: PromptConfiguration['nmsh']): {geometry?: return {...(geometry ? {geometry} : {}), fade}; } +const IDENTITY_ROLES: ReadonlySet = new Set(['project', 'cwd', 'node', 'go', 'python', 'docker']); + +/** + * Semantic colors: Preserve keeps success, failure and Rich Git state fills; + * Override lets Chroma recolor them, because their text and symbols (✔ 0, + * +2, ~1, ↑3) still say what they mean and text contrast is corrected after + * Chroma. The textless clean-tree marker is meaning by color alone, so it is + * always kept. Identity scope treats project, path and toolchains; Whole + * prompt also treats the branch and context modules. + */ +export function chromaEligibleRole(role: PromptRole, scope: 'identity' | 'prompt', semantic: 'preserve' | 'override' = 'preserve'): boolean { + if (role === 'gitClean') return false; + if (role === 'success' || role === 'failure' || isGitStateRole(role)) return semantic === 'override'; + return scope === 'prompt' || IDENTITY_ROLES.has(role); +} + +/** Theme colors after vibrance, with text re-chosen when the fill moved. Semantic Git colors are not theme colors. */ +export function vibrantRoleColors(role: PromptRole, palette: NativePaletteId, gitColors: GitColorMode, vibrance: Vibrance): SegmentColors { + const colors = promptRoleColors(role, palette, gitColors); + if (vibrance === 'standard' || (isGitStateRole(role) && gitColors !== 'followTheme')) return colors; + const fill = applyVibrance(colors.background, vibrance); + return {background: fill, foreground: readableForeground(fill, colors.foreground)}; +} + +const THEME_STOP_ROLES: readonly PromptRole[] = ['project', 'cwd', 'gitBranch', 'node', 'go', 'python', 'docker', 'kubernetes']; + +/** + * Current Theme Chroma stops from the actual Native theme (after vibrance): + * the theme's own module fills in prompt order, lightness held in a band + * and chroma kept up so the sweep stays recognizably that theme instead of + * washing toward white. Near-duplicate hues collapse. + */ +export function themeChromaStops(palette: NativePaletteId, vibrance: Vibrance = 'standard'): RgbColor[] { + const stops: RgbColor[] = []; + const hues: number[] = []; + for (const role of THEME_STOP_ROLES) { + const lch = toOklch(vibrantRoleColors(role, palette, 'semantic', vibrance).background); + const neutral = lch.c < 0.03; + if (!neutral && hues.some(hue => Math.min(Math.abs(hue - lch.h), 360 - Math.abs(hue - lch.h)) < 18)) continue; + if (!neutral) hues.push(lch.h); + stops.push(fromOklch({l: Math.max(0.5, Math.min(0.74, lch.l)), c: neutral ? lch.c : Math.max(0.09, lch.c), h: lch.h})); + if (stops.length >= 5) break; + } + return stops; +} + +/** The Chroma a live render uses at `time`; undefined when Off. */ +export function promptChroma(configuration: PromptConfiguration, time = 0): PromptChroma | undefined { + const presentation = configuration.presentation; + const found = treatmentFor(presentation, themeChromaStops(configuration.nmsh.palette, configuration.nmsh.vibrance)); + if (!found) return undefined; + const treatment = presentation.preset === 'theme' ? {...found, own: true} : found; + const still = !treatmentAnimated(presentation) || isReducedMotion(); + return {treatment, time: still ? 0 : time, still}; +} + +/** Render options for a live Native prompt: the selected style's profiles and its Chroma. */ +export function promptRenderExtras(configuration: PromptConfiguration, time = 0): RenderExtras { + return {profiles: configuration.nmsh.styleProfiles, chroma: promptChroma(configuration, time)}; +} + /** `pathLevel` shortens the cwd module (see PATH_DISPLAY_LEVELS); 0 is the full, width-independent form. */ -export function renderedModules(context: PromptContext, configuration: PromptConfiguration, pathLevel = 0): RenderedModule[] { - const eligible = configuration.modules.flatMap(module => moduleSegments(module, context, configuration.nmsh.icons, configuration.nmsh.gitEnabled, pathLevel) +export function renderedModules(context: PromptContext, configuration: PromptConfiguration, pathLevel = 0, + surface: 'prompt' | 'contextRail' = 'prompt', purpose: 'display' | 'snapshot' = 'display'): RenderedModule[] { + const facts = promptFacts(context); + const eligible = configuration.modules.filter(module => { + const primary = facts[CONTEXT_MODULE_REGISTRY[module.id].fields[0]!]; + if (!primary || !factAllowed(primary, purpose)) return false; + const target = routeModule(module); + return surface === 'contextRail' ? target === 'contextRail' : target === 'mainPrompt' || target === 'rightContext'; + }).flatMap(module => moduleSegments(module, moduleFactContext(context, facts, CONTEXT_MODULE_REGISTRY[module.id].fields, purpose), configuration.nmsh.icons, configuration.nmsh.gitEnabled, pathLevel) .map(segment => ({...segment, module}))); // The project block owns the brighter live identity when both location @@ -297,26 +457,39 @@ export function renderedModules(context: PromptContext, configuration: PromptCon const project = eligible.find(segment => segment.role === 'project'); const visible = eligible.filter(segment => !(segment.role === 'cwd' && project?.text === segment.text)); const richGit = richGitGeometry(configuration.nmsh); + const presentation = configuration.presentation; return visible.map(segment => { - const colors = promptRoleColors(segment.role, configuration.nmsh.palette, configuration.nmsh.gitColors); + // Order of operations: semantic theme → vibrance → explicit module colors. + // Chroma, contrast correction and capability degradation happen at render. + const colors = vibrantRoleColors(segment.role, configuration.nmsh.palette, configuration.nmsh.gitColors, configuration.nmsh.vibrance); // Per-module custom colors are for the module's identity, not its Git states. const custom = !isGitStateRole(segment.role); + const explicit = custom && Boolean(segment.module.foreground || segment.module.background); + const chroma = configuration.provider === 'nmsh' && presentation.preset !== 'off' && chromaEligibleRole(segment.role, presentation.scope ?? 'prompt', presentation.semantic ?? 'preserve') + && (!explicit || presentation.customColors === true); + const fill = custom ? colorFromHex(segment.module.background, colors.background) : colors.background; + // Text colors Neutral: the fill, connectors and accent stay the theme's; ordinary text is a stable neutral chosen from the fill. + // An explicit per-module text color is the person's own choice and is kept. + const neutral = configuration.nmsh.textColors === 'neutral' && !(custom && segment.module.foreground); return { ...(configuration.nmsh.style !== 'powerline' ? {style: configuration.nmsh.style} : {}), + ...(chroma ? {treatment: presentation} : {}), id: segment.module.id, role: segment.role, - text: segment.text, - foreground: custom ? colorFromHex(segment.module.foreground, colors.foreground) : colors.foreground, - background: custom ? colorFromHex(segment.module.background, colors.background) : colors.background, + text: safeContextText(segment.text), + foreground: neutral ? neutralPromptText(fill) : custom ? colorFromHex(segment.module.foreground, colors.foreground) : colors.foreground, + background: fill, + ...(neutral ? {neutralText: true} : {}), ...(segment.compact ? {compact: true} : {}), ...(isGitStateRole(segment.role) ? richGit : {}), - ...(modulePlacement(segment.module) === 'right' ? {placement: 'right' as const} : {}), + ...(routeModule(segment.module) === 'rightContext' ? {placement: 'right' as const} : {}), }; }); } export function nativePromptSnapshot(context: PromptContext, configuration: PromptConfiguration): PromptSnapshot { - const modules = renderedModules(context, configuration); + const modules = renderedModules(context, configuration, 0, 'prompt', 'snapshot'); + const snapshotContext = moduleFactContext(context, promptFacts(context), ['cwd', 'branch'], 'snapshot'); const segments: PromptSegmentSnapshot[] = modules.map(module => ({ text: module.text, role: module.role, @@ -332,7 +505,8 @@ export function nativePromptSnapshot(context: PromptContext, configuration: Prom provider: 'nmsh', layout: configuration.composerLayout, segments, - ...(configuration.nmsh.style !== 'powerline' ? {style: configuration.nmsh.style} : {}), + ...(configuration.nmsh.style !== 'powerline' ? {style: configuration.nmsh.style, + styleProfile: structuredClone(configuration.nmsh.styleProfiles[configuration.nmsh.style])} : {}), endStyle: configuration.nmsh.endStyle, startStyle: configuration.nmsh.startStyle, connector: configuration.nmsh.connector, @@ -347,8 +521,8 @@ export function nativePromptSnapshot(context: PromptContext, configuration: Prom gap: configuration.nmsh.gapEnabled ? configuration.gap : 0, gapEnabled: configuration.nmsh.gapEnabled, spacing: configuration.spacing, - cwd: context.cwd, - ...(context.branch ? {branch: context.branch} : {}), + cwd: snapshotContext.cwd, + ...(snapshotContext.branch ? {branch: safeContextText(snapshotContext.branch)} : {}), }; } @@ -362,26 +536,32 @@ export interface ContextRowParts { * Fit one prompt row: the left prompt takes what it needs first, then the * right context gets what remains after a one-cell minimum gap, or drops. */ -export function fitContextRow(modules: readonly RenderedModule[], width: number, configuration: PromptConfiguration): ContextRowParts { +export function fitContextRow(modules: readonly RenderedModule[], width: number, configuration: PromptConfiguration, time = 0): ContextRowParts { const nmsh = configuration.nmsh; + const extras = promptRenderExtras(configuration, time); const gap = nmsh.gapEnabled ? configuration.gap : 0; const fade = resolveConnectorFade(nmsh.connectorFade, nmsh.connector); const leftBlocks = modules.filter(module => module.placement !== 'right'); const rightBlocks = modules.filter(module => module.placement === 'right'); const left = fitPowerlineBlocks(leftBlocks, gap, configuration.spacing, width, nmsh.endStyle, nmsh.gapEnabled, - nmsh.startStyle, nmsh.connector, fade, nmsh.connectorFadeColors); + nmsh.startStyle, nmsh.connector, fade, nmsh.connectorFadeColors, extras); const remaining = width - displayWidth(left) - (left ? 1 : 0); const right = rightBlocks.length === 0 || remaining < 3 ? '' : fitRightPowerlineBlocks(rightBlocks, remaining, blocks => renderPowerlineBlocks(blocks, gap, configuration.spacing, nmsh.endStyle, nmsh.gapEnabled, nmsh.startStyle, nmsh.connector, - fade, nmsh.connectorFadeColors, nmsh.mirrorRight ? 'mirrored' : 'normal')); + fade, nmsh.connectorFadeColors, nmsh.mirrorRight ? 'mirrored' : 'normal', extras)); return {left, right}; } +/** Resolved Native surface parts for composition; no facts are collected here. */ +export function buildContextParts(context: PromptContext, width: number, configuration: PromptConfiguration, time = 0): ContextRowParts { + return fitContextRow(fittedModules(context, configuration, width), width, configuration, time); +} + /** Right-aligned context alone, for rows whose left side is the editor (one-line composer). */ -export function buildRightContext(context: PromptContext, width: number, configuration: PromptConfiguration): string { +export function buildRightContext(context: PromptContext, width: number, configuration: PromptConfiguration, time = 0): string { if (width <= 0) return ''; const modules = renderedModules(context, configuration).filter(module => module.placement === 'right'); - return modules.length === 0 ? '' : fitContextRow(modules, width, configuration).right; + return modules.length === 0 ? '' : fitContextRow(modules, width, configuration, time).right; } /** @@ -393,9 +573,11 @@ function fittedModules(context: PromptContext, configuration: PromptConfiguratio let modules = renderedModules(context, configuration); const cwd = modules.find(module => module.role === 'cwd'); if (!cwd) return modules; + // Widths never depend on Chroma, so fitting measures the plain style. + const extras: RenderExtras = {profiles: nmsh.styleProfiles}; const render = (blocks: readonly RenderedModule[], mirrored = false) => blocks.length === 0 ? 0 : displayWidth(renderPowerlineBlocks(blocks, nmsh.gapEnabled ? configuration.gap : 0, configuration.spacing, nmsh.endStyle, nmsh.gapEnabled, nmsh.startStyle, nmsh.connector, - resolveConnectorFade(nmsh.connectorFade, nmsh.connector), nmsh.connectorFadeColors, mirrored ? 'mirrored' : 'normal')); + resolveConnectorFade(nmsh.connectorFade, nmsh.connector), nmsh.connectorFadeColors, mirrored ? 'mirrored' : 'normal', extras)); for (let level = 0; level < PATH_DISPLAY_LEVELS; level += 1) { if (level > 0) modules = renderedModules(context, configuration, level); const left = render(modules.filter(module => module.placement !== 'right')); @@ -412,20 +594,77 @@ export function buildContextLine( width: number, configuration: PromptConfiguration, placement: 'header' | 'composer' = configuration.placement, + /** Presentation time for animated Chroma; the static treatment ignores it. */ + time = 0, ): string { if (width <= 0) return ''; - if (width < 8) return `${LINE}${repeatToWidth(GLYPHS.separator, width)}${RESET}`; + // The divider fill is a composer divider line: the same source as the composer rules. + const divider = (cells: number) => `${paintDivider(repeatToWidth(GLYPHS.separator, cells), configuration.presentation, time)}${RESET}`; + if (width < 8) return divider(width); const modules = renderedModules(context, configuration); if (modules.length === 0) { - return placement === 'header' ? `${LINE}${repeatToWidth(GLYPHS.separator, width)}${RESET}` : ''; + return placement === 'header' ? divider(width) : ''; } - const {left, right} = fitContextRow(fittedModules(context, configuration, width), width, configuration); + const {left, right} = fitContextRow(fittedModules(context, configuration, width), width, configuration, time); const rightPart = right ? ` ${right}${RESET}` : ''; const fillWidth = Math.max(0, width - displayWidth(left) - displayWidth(rightPart)); if (placement === 'composer') return rightPart ? `${left}${RESET}${' '.repeat(fillWidth)}${rightPart}` : `${left}${RESET}`; - return `${left}${LINE}${repeatToWidth(GLYPHS.separator, fillWidth)}${RESET}${rightPart}`; + return `${left}${RESET}${divider(fillWidth)}${rightPart}`; +} + +/** Resolve appearance through the existing semantic theme context, restoring it after a chosen-theme render. */ +export function buildContextRail(context: PromptContext, width: number, configuration: PromptConfiguration, time = 0): string[] { + const rail = configuration.contextRail; + if (configuration.provider !== 'nmsh' || rail.mode === 'off') return []; + const saved = themeContext(); + const chosen = rail.theme === 'choose' ? findTheme(configuration.themes, rail.themeId)?.theme : undefined; + const draft: PromptConfiguration = {...configuration, nmsh: {...configuration.nmsh, + ...(rail.theme === 'choose' ? {palette: rail.palette ?? 'lavender'} : {}), + ...(rail.style !== 'followMain' ? {style: rail.style} : {})}}; + if (rail.theme === 'choose') setThemeContext(draft.nmsh.accent, chosen ?? configuration.customTheme); + try { + const modules = renderedModules(context, draft, 0, 'contextRail'); + if (!modules.length) return rail.mode === 'always' ? Array(rail.rows).fill('') : []; + const groups = configuration.modules.map(module => modules.filter(segment => segment.id === module.id)) + .filter(group => group.length).sort((a, b) => CONTEXT_MODULE_REGISTRY[b[0]!.id].priority - CONTEXT_MODULE_REGISTRY[a[0]!.id].priority); + const rows: RenderedModule[][] = Array.from({length: rail.rows}, () => []); + const compact = Array(rail.rows).fill(false); + const nmsh = draft.nmsh; + const mirrored = rail.direction === 'mirrored'; + const paintPhysical = (blocks: readonly PowerlineBlock[], tight: boolean) => renderPowerlineBlocks(blocks, tight ? 0 : nmsh.gapEnabled ? draft.gap : 0, + tight ? 0 : draft.spacing, nmsh.endStyle, tight ? false : nmsh.gapEnabled, nmsh.startStyle, nmsh.connector, + resolveConnectorFade(nmsh.connectorFade, nmsh.connector), nmsh.connectorFadeColors, mirrored ? 'mirrored' : 'normal', promptRenderExtras(draft, time)); + const paint = (blocks: RenderedModule[], tight: boolean) => paintPhysical(mirrored ? [...blocks].reverse() : blocks, tight); + for (const group of groups) { + let placed = false; + for (let index = 0; index < rows.length; index += 1) { + const candidate = [...rows[index]!, ...group]; + if (displayWidth(paint(candidate, compact[index]!)) <= width) { rows[index] = candidate; placed = true; break; } + } + if (!placed) for (let index = 0; index < rows.length; index += 1) { + const candidate = [...rows[index]!, ...group]; + if (displayWidth(paint(candidate, true)) <= width) { rows[index] = candidate; compact[index] = true; placed = true; break; } + } + // Preserve the highest priority group even when its full text cannot fit. + if (!placed && rows.every(row => !row.length)) { + rows[0] = group; + compact[0] = true; + } + } + const paintedRows = rows.map((blocks, index) => { + if (!blocks.length || width <= 0) return ''; + const painted = paint(blocks, compact[index]!); + if (displayWidth(painted) <= width) return painted; + if (mirrored) return fitRightPowerlineBlocks([...blocks].reverse(), width, fitted => paintPhysical(fitted, true)); + return fitPowerlineBlocks(blocks, 0, 0, width, nmsh.endStyle, false, + nmsh.startStyle, nmsh.connector, resolveConnectorFade('off', nmsh.connector), nmsh.connectorFadeColors, promptRenderExtras(draft, time)); + }); + return colorLevel() === 'none' ? paintedRows.map(stripAnsi) : paintedRows; + } finally { + if (rail.theme === 'choose') setThemeContext(saved.accent, saved.custom); + } } /** @@ -463,6 +702,7 @@ export function moduleShowcaseContext(home = homedir()): PromptContext { commandWords: ['kubectl', 'docker', 'npm'], kubeContext: 'dev-cluster', dockerContext: 'colima', + shell: {current: 'fish', differs: true}, }; } @@ -488,11 +728,11 @@ export function buildRichGitShowcaseLine(configuration: PromptConfiguration, git } /** One theme row for /prompt: real geometry from the draft, synthetic modules, all visible. */ -export function buildThemePreviewLine(configuration: PromptConfiguration, palette: NativePaletteId, width: number): string { +export function buildThemePreviewLine(configuration: PromptConfiguration, palette: NativePaletteId, width: number, time = 0): string { const preview = structuredClone(configuration); preview.nmsh.palette = palette; preview.modules = DEFAULT_PROMPT_CONFIGURATION.modules.map(module => ({...module, visible: true})); - return buildContextLine(themePreviewContext(), width, preview, 'composer'); + return buildContextLine(themePreviewContext(), width, preview, 'composer', time); } /** Context plus the editable input prompt, sized to leave at least one input cell. */ @@ -500,14 +740,15 @@ export function buildInlineContextPrefix( context: PromptContext, width: number, configuration: PromptConfiguration, + time = 0, ): string { if (width <= 0) return ''; if (width <= displayWidth(GLYPHS.prompt) + 2) return `${foreground(UI_COLORS.accent)}${GLYPHS.prompt}${RESET}`; const moduleWidth = Math.max(0, width - displayWidth(`${GLYPHS.prompt} `) - 1); // One-line: the prefix is the left prompt; right context sits at the end of the input row. - const leftOnly = {...configuration, modules: configuration.modules.filter(module => modulePlacement(module) === 'left')}; + const leftOnly = {...configuration, modules: configuration.modules.filter(module => routeModule(module) === 'mainPrompt')}; const modules = moduleWidth >= 8 - ? buildContextLine(context, moduleWidth, leftOnly, 'composer') + ? buildContextLine(context, moduleWidth, leftOnly, 'composer', time) : ''; return `${modules}${modules ? ' ' : ''}${foreground(UI_COLORS.accent)}${GLYPHS.prompt}${RESET} `; } diff --git a/src/prompt/railComposition.ts b/src/prompt/railComposition.ts new file mode 100644 index 00000000..0225491e --- /dev/null +++ b/src/prompt/railComposition.ts @@ -0,0 +1,109 @@ +import {colorLevel} from '../presentation/capabilities.js'; +import type {PromptContext} from '../shell/ShellContext.js'; +import type {PromptConfiguration} from './configuration.js'; +import {buildContextParts, buildContextRail, buildInlineContextPrefix, buildRightContext} from './prompt.js'; +import {railPresentation, type RailPresentation} from './railLayout.js'; +import {planScreen, regionOf, type ScreenPlan} from '../app/screenPlan.js'; +import {displayWidth, repeatToWidth, stripAnsi, truncateAnsi} from '../util/text.js'; +import {paintDivider} from '../chroma/treatment.js'; +import {GLYPHS} from '../ui/glyphs.js'; + +const RESET = '\u001b[0m'; +export interface PreparedRail {presentation: RailPresentation; content: string[]; time: number} + +/** Facts arrive resolved. This projection never requests or collects a value. */ +export function prepareRail(context: PromptContext, columns: number, configuration: PromptConfiguration, time = 0): PreparedRail { + const enabled = configuration.provider === 'nmsh' && configuration.contextRail.mode !== 'off'; + const parts = buildContextParts(context, columns, configuration, time); + const budget = railPresentation(configuration, enabled ? configuration.contextRail.rows : 0, columns, parts); + const content = enabled && !(budget.width === 0 && configuration.contextRail.mode === 'auto') ? buildContextRail(context, budget.width, configuration, time) : []; + return {content, time, presentation: railPresentation(configuration, content.length, columns, parts)}; +} + +/** Pure final composition over ScreenPlan-owned rows/cells, shared by preview and live. */ +export function paintRailComposition(rows: string[], plan: ScreenPlan, prepared: PreparedRail, + context: PromptContext, columns: number, configuration: PromptConfiguration, time = 0): void { + const geometry = plan.rail; + if (!geometry || plan.panelActive) return; + const p = geometry.presentation; + const contentRows = time === prepared.time ? prepared.content : buildContextRail(context, p.width, configuration, time); + const width = Math.max(0, columns); + const divider = (cells: number) => `${paintDivider(repeatToWidth(GLYPHS.separator, Math.max(0, cells)), configuration.presentation, time)}${RESET}`; + const fill = (content: string, rule = false, mirrored = false) => { + const text = truncateAnsi(content, width); + const padding = Math.max(0, width - displayWidth(text)); + const space = rule ? divider(padding) : ' '.repeat(padding); + return mirrored ? `${space}${text}${RESET}` : `${text}${RESET}${space}`; + }; + const input = regionOf(plan, 'input'); + const prompt = regionOf(plan, 'prompt'); + const native = buildContextParts(context, width, configuration, time); + const nativeRow = (left: string, rail: string | undefined, rule: boolean, includeRight = true) => { + let right = includeRight ? native.right : ''; + if (p.relation === 'right' && !p.width && !prompt && displayWidth(left) + displayWidth(right) + 2 > width) right = ''; + // At zero Rail width, inline input retains its full measured editor budget. + const limit = p.relation === 'right' && p.width ? Math.max(0, p.column - p.gap) : width - (right ? displayWidth(right) + 1 : 0); + const main = truncateAnsi(left, limit); + let result = main; + if (rail !== undefined && p.width) { + const space = Math.max(0, p.column - displayWidth(result)); + const ruleCells = rule && !p.inside ? Math.max(0, space - p.gap) : 0; + result += RESET + divider(ruleCells) + ' '.repeat(space - ruleCells); + const fitted = truncateAnsi(rail, p.width); + result += p.mirrored ? ' '.repeat(Math.max(0, p.width - displayWidth(fitted))) + fitted : fitted; + } + const remaining = Math.max(0, width - displayWidth(result) - displayWidth(right)); + result += RESET + (rule && !(p.relation === 'right' && !p.inside) ? divider(remaining) : ' '.repeat(remaining)) + right + RESET; + return truncateAnsi(result, width); + }; + if (prompt) rows[prompt.top] = nativeRow(native.left, undefined, + p.inside ? p.anchor === 'prompt' : configuration.placement === 'header'); + for (const region of plan.regions) if (region.kind === 'railGap' || region.kind === 'railEdge' || region.kind === 'contextRail') rows[region.top] = ''; + for (const slot of geometry.slots) { + const content = contentRows[slot.index] ?? ''; + const rule = p.inside && p.anchor === 'rail' && slot.top === geometry.edgeRow; + if (p.relation === 'vertical') rows[slot.top] = !content && !rule ? '' : fill(content, rule, p.mirrored); + else { + const main = slot.top === prompt?.top ? native.left : slot.top >= (input?.top ?? Infinity) && slot.top < (input?.top ?? 0) + (input?.height ?? 0) ? rows[slot.top] ?? '' : ''; + rows[slot.top] = nativeRow(main, content, rule || p.inside && p.anchor === 'prompt' && slot.top === prompt?.top, slot.top === (prompt?.top ?? input?.top)); + } + } + if (p.relation === 'right' && !p.inside && p.width) { + for (const region of plan.regions) if (region.kind === 'composerBorder' || region.kind === 'separator') + rows[region.top] = divider(Math.max(0, p.column - p.gap)); + } + if (p.inside && (p.anchor === 'above' || plan.regions.some(region => region.kind === 'railEdge' && region.top === geometry.edgeRow))) + rows[geometry.edgeRow] = divider(width); + const finish = () => { if (colorLevel() === 'none') for (let row = geometry.start; row < geometry.end; row++) rows[row] = stripAnsi(rows[row] ?? ''); }; + finish(); +} + +/** Preview compiles the same regions and painter, with an explicit synthetic input. */ +export function railCompositionPreview(context: PromptContext, columns: number, configuration: PromptConfiguration, time = 0): string[] { + const prepared = prepareRail(context, columns, configuration, time); + const p = prepared.presentation; + const parts = buildContextParts(context, columns, configuration, time); + const plan = planScreen({rows: 16, inputRows: 1, suggestions: 0, running: false, detached: false, hasOutput: false, + contextPlacement: configuration.placement, composerLayout: configuration.composerLayout, composerPosition: configuration.composerPosition, + composerDividers: configuration.composerDividers, hasVisibleContext: Boolean(parts.left || parts.right), transcriptRows: 0, + railPresentation: prepared.presentation}); + const rows = Array(plan.rows).fill(''); + const input = regionOf(plan, 'input'); + if (input) { + const prefix = configuration.composerLayout === 'oneLine' ? buildInlineContextPrefix(context, p.editorColumns, configuration) : `${GLYPHS.prompt} `; + rows[input.top] = `${prefix}echo hello`; + if (configuration.composerLayout === 'oneLine' && p.relation !== 'right') { + const line = rows[input.top]!; + const right = buildRightContext(context, columns - displayWidth(line) - 2, configuration, time); + if (right) rows[input.top] += RESET + ' '.repeat(Math.max(0, columns - displayWidth(line) - displayWidth(right))) + right + RESET; + } + } + const rule = `${paintDivider(repeatToWidth(GLYPHS.separator, columns), configuration.presentation, time)}${RESET}`; + for (const region of plan.regions) { + if (region.kind === 'composerBorder' || region.kind === 'separator') rows[region.top] = rule; + if (region.kind === 'prompt') rows[region.top] = `${parts.left}${RESET}${' '.repeat(Math.max(0, columns-displayWidth(parts.left)-displayWidth(parts.right)))}${parts.right}`; + } + paintRailComposition(rows, plan, prepared, context, columns, configuration, time); + const visible = plan.regions.filter(region => ['prompt','input','separator','composerBorder','contextRail','railGap','railEdge'].includes(region.kind)); + return visible.length ? rows.slice(Math.min(...visible.map(r=>r.top)), Math.max(...visible.map(r=>r.top+r.height))) : []; +} diff --git a/src/prompt/railLayout.ts b/src/prompt/railLayout.ts new file mode 100644 index 00000000..a4afdc1d --- /dev/null +++ b/src/prompt/railLayout.ts @@ -0,0 +1,71 @@ +import type {PromptConfiguration} from './configuration.js'; +import {displayWidth} from '../util/text.js'; + +export type RailRow = {kind: 'content'; index: number} | {kind: 'gap'} | {kind: 'edge'}; +export interface RailPresentation { + relation: 'vertical' | 'right'; + inside: boolean; + anchor: 'prompt' | 'rail' | 'above'; + mirrored: boolean; + rows: number; + gap: number; + between: number; + width: number; + column: number; + editorColumns: number; +} + +/** Pure visual policy. Missing fields retain the original proof's geometry. */ +export function railPresentation(configuration: PromptConfiguration, rows: number, columns: number, + parts: {left: string; right: string} = {left: '', right: ''}): RailPresentation { + const rail = configuration.contextRail; + const relation = rail.relation ?? 'vertical'; + const inside = rows > 0 && mainSupportsRailInside(configuration) + && (rail.integration === 'inside' || rail.integration === 'auto'); + const anchor = inside ? rail.dividerAnchor ?? 'prompt' : 'prompt'; + const gap = rail.spacing === 'spacious' ? (relation === 'right' ? 4 : 1) : rail.spacing === 'gap' ? 1 : 0; + const between = relation === 'vertical' && rail.spacing === 'spacious' ? 1 : 0; + const usable = Math.max(0, columns); + const rightReserve = displayWidth(parts.right) + (parts.right ? 1 : 0); + const protectedWidth = Math.max(24, displayWidth(parts.left) + (configuration.composerLayout === 'oneLine' ? 16 : 0)); + const width = relation === 'right' ? Math.max(0, Math.min(Math.floor(usable / 3), usable - protectedWidth - rightReserve - gap)) : usable; + const column = relation === 'right' ? Math.max(0, usable - rightReserve - width) : 0; + return {relation, inside, anchor, mirrored: rail.direction === 'mirrored', rows: Math.min(2, rows), gap, between, + width, column, editorColumns: relation === 'right' && width && rows && (configuration.composerLayout === 'oneLine' || rows === 2) + ? Math.max(1, column - gap) : usable}; +} + +/** Vertical spacing is explicitly represented; it is never a context module. */ +export function verticalRailRows(p: RailPresentation, top: boolean): RailRow[] { + const content: RailRow[] = []; + for (let index = 0; index < p.rows; index++) { + if (index && p.between) content.push({kind: 'gap'}); + content.push({kind: 'content', index}); + } + const gap: RailRow[] = p.gap ? [{kind: 'gap'}] : []; + const edge: RailRow[] = p.inside && p.anchor !== 'rail' ? [{kind: 'edge'}] : []; + return top ? [...gap, ...content, ...edge] : [...edge, ...content, ...gap]; +} + + +/** Existing Main Prompt Inside geometry: two lines between horizontal rules. */ +export function mainSupportsRailInside(configuration: PromptConfiguration): boolean { + return configuration.provider === 'nmsh' && configuration.composerLayout === 'twoLine' + && configuration.placement === 'composer' && configuration.composerDividers; +} + +export function railNeedsPromptConversion(configuration: PromptConfiguration): boolean { + return configuration.provider === 'nmsh' && configuration.contextRail.integration === 'inside' + && !mainSupportsRailInside(configuration); +} + +/** Proposed geometry only. Neither the live configuration nor the draft is mutated. */ +export function railPreviewConfiguration(configuration: PromptConfiguration): PromptConfiguration { + const preview = structuredClone(configuration); + if (railNeedsPromptConversion(preview)) { + preview.composerLayout = 'twoLine'; + preview.placement = 'composer'; + preview.composerDividers = true; + } + return preview; +} diff --git a/src/prompt/snapshot.ts b/src/prompt/snapshot.ts index 70619b36..105f0c4a 100644 --- a/src/prompt/snapshot.ts +++ b/src/prompt/snapshot.ts @@ -1,5 +1,6 @@ import type {RgbColor} from '../ui/palette.js'; import type {ConnectorFadeColors, PowerlineShape, PromptStyle} from './powerline.js'; +import type {ProfiledStyle, StyleProfiles} from './styles.js'; import type {ComposerLayout, ConnectorFadeStyle, GitColorMode, GitConnectorFade, GitGeometry, NativeConnectorStyle, NativeEndStyle, NativePaletteId, NativeStartStyle, PromptProviderId} from './configuration.js'; export interface PromptSegmentSnapshot { @@ -26,6 +27,8 @@ export interface PromptSnapshot { segments: PromptSegmentSnapshot[]; /** Native visual style at submission; missing means Powerline. */ style?: PromptStyle; + /** That style's profile at submission (non-Powerline styles); missing replays the legacy gap/spacing look. */ + styleProfile?: StyleProfiles[ProfiledStyle]; endStyle?: NativeEndStyle; /** Older transcripts may hold the legacy `pointed`; renderers normalize it. */ startStyle?: NativeStartStyle; diff --git a/src/prompt/styles.ts b/src/prompt/styles.ts new file mode 100644 index 00000000..bb6101f3 --- /dev/null +++ b/src/prompt/styles.ts @@ -0,0 +1,133 @@ +/** + * Native prompt styles and their independent, persisted profiles. Powerline + * keeps its long-standing storage (the Main Prompt geometry fields and the + * root gap/spacing), so existing configurations render exactly as before; + * every other style owns a small profile under `nmsh.styleProfiles`, so + * switching styles never discards another style's customization. + */ + +import {BREADCRUMB_SEPARATORS, MINIMAL_SEPARATORS, normalizeCustomGlyph, OUTLINE_DIVIDERS, type BreadcrumbSeparator, type MinimalSeparator, + type OutlineDivider} from './glyphChoices.js'; + +export type PromptStyle = 'powerline' | 'soft' | 'minimal' | 'outline' | 'breadcrumb' | 'compact' | 'ribbon'; +export const PROMPT_STYLES: readonly PromptStyle[] = ['powerline', 'soft', 'minimal', 'outline', 'breadcrumb', 'compact', 'ribbon']; +export const PROMPT_STYLE_LABELS: Record = { + powerline: 'Powerline', soft: 'Soft', minimal: 'Minimal', outline: 'Outline', breadcrumb: 'Breadcrumb', compact: 'Compact', ribbon: 'Ribbon', +}; +export const PROMPT_STYLE_NOTES: Record = { + powerline: 'filled segments with shaped joins', + soft: 'filled pills or one capsule', + minimal: 'colored text, no fills', + outline: 'outlined segments, no fills', + breadcrumb: 'a trail with one filled anchor', + compact: 'dense filled cells, no gaps', + ribbon: 'one band, colored text', +}; + +export function normalizePromptStyle(value: unknown): PromptStyle { + return PROMPT_STYLES.includes(value as PromptStyle) ? value as PromptStyle : 'powerline'; +} + +/** Styles drawn without filled module backgrounds (text treatments). */ +export function isTextStyle(style: PromptStyle): boolean { + return style === 'minimal' || style === 'outline' || style === 'breadcrumb'; +} + +export interface SoftProfile {cap: 'rounded' | 'slant' | 'square'; layout: 'separated' | 'connected'; gap: number; padding: number; fill: 'filled' | 'subtle'} +export interface MinimalProfile {separator: MinimalSeparator; spacing: number; emphasis: 'none' | 'first' | 'all'; + /** Used when `separator` is `custom`; kept when another separator is chosen. */ + customSeparator?: string} +export interface OutlineProfile {cap: 'rounded' | 'square' | 'angle'; layout: 'separated' | 'connected'; gap: number; padding: number; + /** Connected layout only: the divider between outlined segments. Missing in older configs means Pipe. */ + divider: OutlineDivider; customSeparator?: string} +export interface BreadcrumbProfile {separator: BreadcrumbSeparator; anchor: 'first' | 'last' | 'none'; spacing: number; + /** Used when `separator` is `custom`; kept when another separator is chosen. */ + customSeparator?: string} +export interface CompactProfile {ends: 'flat' | 'rounded' | 'wedge'; padding: number; seams: 'none' | 'thin'} +export interface RibbonProfile {slant: 'forward' | 'backward'; ends: 'slanted' | 'pointed' | 'flat'; padding: number; band: 'deep' | 'neutral'} + +export interface StyleProfiles { + soft: SoftProfile; + minimal: MinimalProfile; + outline: OutlineProfile; + breadcrumb: BreadcrumbProfile; + compact: CompactProfile; + ribbon: RibbonProfile; +} +export type ProfiledStyle = keyof StyleProfiles; + +/** + * Defaults seeded from the legacy shared gap/spacing, matching how Soft, + * Minimal and Outline rendered before they had their own settings. + */ +export function defaultStyleProfiles(legacyGap = 1, legacySpacing = 1): StyleProfiles { + const gap = Math.max(0, Math.min(3, Math.trunc(legacyGap))); + const spacing = Math.max(0, Math.min(3, Math.trunc(legacySpacing))); + return { + soft: {cap: 'rounded', layout: 'separated', gap: Math.max(1, gap), padding: spacing, fill: 'filled'}, + minimal: {separator: 'space', spacing: Math.max(2, gap + 1), emphasis: 'none'}, + outline: {cap: 'rounded', layout: 'separated', gap: Math.max(1, gap), padding: spacing, divider: 'pipe'}, + breadcrumb: {separator: 'chevron', anchor: 'first', spacing: 1}, + compact: {ends: 'flat', padding: 1, seams: 'none'}, + ribbon: {slant: 'forward', ends: 'slanted', padding: 1, band: 'deep'}, + }; +} + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); +const choose = (values: readonly T[], value: unknown, fallback: T): T => values.includes(value as T) ? value as T : fallback; +const bounded = (value: unknown, min: number, max: number, fallback: number) => + typeof value === 'number' && Number.isFinite(value) ? Math.max(min, Math.min(max, Math.round(value))) : fallback; + +export const STYLE_PROFILE_OPTIONS = { + soft: {cap: ['rounded', 'slant', 'square'], layout: ['separated', 'connected'], gap: [1, 2, 3], padding: [0, 1, 2, 3], fill: ['filled', 'subtle']}, + minimal: {separator: MINIMAL_SEPARATORS, spacing: [1, 2, 3, 4], emphasis: ['none', 'first', 'all']}, + outline: {cap: ['rounded', 'square', 'angle'], layout: ['separated', 'connected'], gap: [0, 1, 2, 3], padding: [0, 1, 2, 3], divider: OUTLINE_DIVIDERS}, + breadcrumb: {separator: BREADCRUMB_SEPARATORS, anchor: ['first', 'last', 'none'], spacing: [1, 2]}, + compact: {ends: ['flat', 'rounded', 'wedge'], padding: [0, 1], seams: ['none', 'thin']}, + ribbon: {slant: ['forward', 'backward'], ends: ['slanted', 'pointed', 'flat'], padding: [0, 1, 2], band: ['deep', 'neutral']}, +} as const; + +/** A stored custom separator survives only if it still validates. */ +function withCustom(value: unknown): {customSeparator?: string} { + const glyph = normalizeCustomGlyph(value); + return glyph ? {customSeparator: glyph} : {}; +} + +/** Missing profiles (older configs) are seeded from the legacy shared gap/spacing. */ +export function normalizeStyleProfiles(value: unknown, legacyGap = 1, legacySpacing = 1): StyleProfiles { + const defaults = defaultStyleProfiles(legacyGap, legacySpacing); + const v = isRecord(value) ? value : {}; + const o = STYLE_PROFILE_OPTIONS; + const soft = isRecord(v.soft) ? v.soft : {}; + const minimal = isRecord(v.minimal) ? v.minimal : {}; + const outline = isRecord(v.outline) ? v.outline : {}; + const breadcrumb = isRecord(v.breadcrumb) ? v.breadcrumb : {}; + const compact = isRecord(v.compact) ? v.compact : {}; + const ribbon = isRecord(v.ribbon) ? v.ribbon : {}; + return { + soft: {cap: choose(o.soft.cap, soft.cap, defaults.soft.cap), layout: choose(o.soft.layout, soft.layout, defaults.soft.layout), + gap: bounded(soft.gap, 1, 3, defaults.soft.gap), padding: bounded(soft.padding, 0, 3, defaults.soft.padding), + fill: choose(o.soft.fill, soft.fill, defaults.soft.fill)}, + minimal: {separator: choose(o.minimal.separator, minimal.separator, defaults.minimal.separator), + spacing: bounded(minimal.spacing, 1, 4, defaults.minimal.spacing), emphasis: choose(o.minimal.emphasis, minimal.emphasis, defaults.minimal.emphasis), + ...withCustom(minimal.customSeparator)}, + outline: {cap: choose(o.outline.cap, outline.cap, defaults.outline.cap), layout: choose(o.outline.layout, outline.layout, defaults.outline.layout), + gap: bounded(outline.gap, 0, 3, defaults.outline.gap), padding: bounded(outline.padding, 0, 3, defaults.outline.padding), + divider: choose(o.outline.divider, outline.divider, defaults.outline.divider), ...withCustom(outline.customSeparator)}, + breadcrumb: {separator: choose(o.breadcrumb.separator, breadcrumb.separator, defaults.breadcrumb.separator), + anchor: choose(o.breadcrumb.anchor, breadcrumb.anchor, defaults.breadcrumb.anchor), spacing: bounded(breadcrumb.spacing, 1, 2, defaults.breadcrumb.spacing), + ...withCustom(breadcrumb.customSeparator)}, + compact: {ends: choose(o.compact.ends, compact.ends, defaults.compact.ends), padding: bounded(compact.padding, 0, 1, defaults.compact.padding), + seams: choose(o.compact.seams, compact.seams, defaults.compact.seams)}, + ribbon: {slant: choose(o.ribbon.slant, ribbon.slant, defaults.ribbon.slant), ends: choose(o.ribbon.ends, ribbon.ends, defaults.ribbon.ends), + padding: bounded(ribbon.padding, 0, 2, defaults.ribbon.padding), band: choose(o.ribbon.band, ribbon.band, defaults.ribbon.band)}, + }; +} + +export const STYLE_OPTION_LABELS: Readonly> = { + rounded: 'Rounded', slant: 'Slant', square: 'Square', separated: 'Separated', connected: 'Connected', filled: 'Filled', subtle: 'Subtle', + space: 'Space', dot: 'Dot ·', pipe: 'Pipe │', slash: 'Slash /', chevron: 'Chevron ›', none: 'None', first: 'First module', all: 'All modules', + angle: 'Angle', last: 'Last module', flat: 'Flat', wedge: 'Wedge', thin: 'Thin seams', forward: 'Forward /', backward: 'Backward \\', + slanted: 'Slanted', pointed: 'Pointed', deep: 'Deep theme band', neutral: 'Neutral band', + dashed: 'Dashed ┆', bullet: 'Bullet •', arrow: 'Arrow →', doubleChevron: 'Double chevron »', diamond: 'Diamond ◆', triangle: 'Triangle ▸', dash: 'ASCII -', custom: 'Custom', +}; diff --git a/src/providers/ProviderPanel.ts b/src/providers/ProviderPanel.ts index 2b412dbf..010d148e 100644 --- a/src/providers/ProviderPanel.ts +++ b/src/providers/ProviderPanel.ts @@ -1,14 +1,15 @@ import type {Key} from '../terminal/keys.js'; import {renderControls} from '../ui/controls.js'; -import {foreground, UI_COLORS} from '../ui/palette.js'; +import {focusForeground, foreground, UI_COLORS, lazyForeground} from '../ui/palette.js'; import {truncateAnsi} from '../util/text.js'; import {renderTaskProgress, type TaskProgress} from '../status/TaskProgress.js'; -import {providerRowText, providerUsable, type ProviderDescriptor, type ProviderFamily, type ProviderStatus} from './providers.js'; +import {installUnavailableReason, providerInstall, providerRowText, providerUsable, type ProviderDescriptor, type ProviderFamily, type ProviderInstall, + type ProviderStatus, lifecycleNote} from './providers.js'; -const PRIMARY = foreground(UI_COLORS.primary); -const SECONDARY = foreground(UI_COLORS.secondary); -const ACCENT = foreground(UI_COLORS.accent); -const SUBTLE = foreground(UI_COLORS.subtle); +const PRIMARY = lazyForeground(UI_COLORS.primary); +const SECONDARY = lazyForeground(UI_COLORS.secondary); +const ACCENT = lazyForeground(UI_COLORS.accent); +const SUBTLE = lazyForeground(UI_COLORS.subtle); const RESET = '\u001B[0m'; /** @@ -39,10 +40,11 @@ export function providerPanelSelection(state: ProviderPanelSt } /** What Enter does for the highlighted provider. */ -export function providerPanelEnterAction(state: ProviderPanelState): 'save' | 'installConfirm' | 'unavailable' { +export function providerPanelEnterAction(state: ProviderPanelState, install: (descriptor: ProviderDescriptor) => ProviderInstall | undefined = providerInstall): +'save' | 'installConfirm' | 'unavailable' { const selected = providerPanelSelection(state); if (providerUsable(selected, state.statuses[selected.id])) return 'save'; - return selected.install && state.statuses[selected.id]?.state === 'missing' ? 'installConfirm' : 'unavailable'; + return install(selected) && state.statuses[selected.id]?.state === 'missing' ? 'installConfirm' : 'unavailable'; } export function handleProviderPanelKey(key: Key, state: ProviderPanelState): boolean { @@ -61,7 +63,8 @@ export function renderProviderPanel(state: ProviderPanelState, columns: number, const rows = [`${PRIMARY} ${state.title}${RESET}`, `${SUBTLE} Current ${SECONDARY}${savedLabel}${RESET}`, '']; const selected = providerPanelSelection(state); if (state.step === 'installConfirm') { - rows.push(`${PRIMARY}Install ${selected.label}?${RESET}`, `${SECONDARY}Runs: ${selected.install?.label ?? ''}${RESET}`, '', + rows.push(`${PRIMARY}Install ${selected.label}?${RESET}`, `${SECONDARY}Runs: ${providerInstall(selected)?.label ?? ''}${RESET}`, + `${SUBTLE}Changes installed software only; shell hooks and settings are not touched. After it succeeds, ${selected.label} is selected.${RESET}`, '', renderControls([['Enter', 'install'], ['Esc', 'back']])); return rows.map(row => truncateAnsi(row, columns)); } @@ -71,10 +74,16 @@ export function renderProviderPanel(state: ProviderPanelState, columns: number, } state.providers.forEach((provider, index) => { const active = index === state.selectedIndex; - rows.push(`${active ? ACCENT : SECONDARY}${active ? '›' : ' '} ${providerRowText(provider, + rows.push(`${focusForeground(active)}${active ? '›' : ' '} ${providerRowText(provider, {draft: selected.id, saved: state.saved, status: state.statuses[provider.id]})}${RESET}`); }); + const missing = selected.kind === 'external' && state.statuses[selected.id]?.state === 'missing'; + const recipe = missing ? providerInstall(selected) : undefined; const footer = [ + ...(missing ? ['', recipe ? `${SUBTLE}Not installed · Enter installs with ${recipe.label} after you confirm${RESET}` + : `${SUBTLE}${installUnavailableReason(selected)}${RESET}`] : []), + ...(lifecycleNote(selected) ? ['', `${SUBTLE}${lifecycleNote(selected)}${RESET}`] : []), + ...(selected.kind === 'native' ? ['', `${SUBTLE}Built in · no installation required. External providers are optional; switch anytime.${RESET}`] : []), ...(selected.setup ? ['', `${SUBTLE}${selected.setup}${RESET}`] : []), ...(state.message ? ['', `${SECONDARY}${state.message}${RESET}`] : []), '', renderControls([['↑↓', 'preview'], ['Enter', 'use'], ['Esc', 'cancel']]), diff --git a/src/providers/ProvidersOverview.ts b/src/providers/ProvidersOverview.ts new file mode 100644 index 00000000..036e7ae6 --- /dev/null +++ b/src/providers/ProvidersOverview.ts @@ -0,0 +1,203 @@ +import type {Key} from '../terminal/keys.js'; +import type {PromptConfiguration} from '../prompt/configuration.js'; +import {renderControls} from '../ui/controls.js'; +import {GLYPHS} from '../ui/glyphs.js'; +import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; +import {COLUMN_GUTTER, labelColumnWidth, padCells, truncateAnsi, truncateText} from '../util/text.js'; +import {familyFacts, providerFamily, PROVIDER_FAMILIES, type SwitchableFamily} from './families.js'; +import {providerInstall, type ProviderStatus} from './providers.js'; + +/** + * /providers: the one provider control surface. Every family is a row; + * Enter expands its providers inline in the same panel (never a second + * screen), Enter on a usable provider selects it at once, and Enter on a + * missing installable one opens an inline install confirmation (default No). + * It reads the one configuration and runtime detection; it owns no provider + * state of its own. + */ +export type OverviewRowId = SwitchableFamily | 'understanding' | 'shell'; + +export interface ProvidersOverviewState { + selected: number; + expanded?: OverviewRowId; + /** Inline install confirmation under a provider row. */ + confirm?: {family: SwitchableFamily; id: string; yes: boolean}; + /** An install running inline. */ + installing?: {family: SwitchableFamily; id: string; line: string}; + /** Detection still running. */ + detecting: boolean; + message?: string; +} + +export interface OverviewFacts { + configuration: PromptConfiguration; + statuses: ReadonlyMap; + /** Provider ids NMSh installed itself (install provenance); everything else was found. */ + installedByNmsh: ReadonlySet; + /** Local understanding summary rows, from the shared model service. */ + understanding: {active: string; detail: string[]}; + shell: {current: string; defaultShell: string}; +} + +export type OverviewAction = + | {kind: 'close'} | {kind: 'detect'} + | {kind: 'open'; row: OverviewRowId} + | {kind: 'select'; family: SwitchableFamily; id: string} + | {kind: 'install'; family: SwitchableFamily; id: string}; + +export const OVERVIEW_ROWS: readonly OverviewRowId[] = [...PROVIDER_FAMILIES.map(family => family.family), 'understanding', 'shell']; + +type Item = {kind: 'family'; row: OverviewRowId} | {kind: 'provider'; family: SwitchableFamily; id: string} | {kind: 'configure'; family: 'prompt'}; + +export function createProvidersOverview(focus?: SwitchableFamily): ProvidersOverviewState { + const state: ProvidersOverviewState = {selected: 0, detecting: true}; + if (focus) { state.expanded = focus; state.selected = OVERVIEW_ROWS.indexOf(focus); } + return state; +} + +export function overviewItems(state: ProvidersOverviewState): Item[] { + const items: Item[] = []; + for (const row of OVERVIEW_ROWS) { + items.push({kind: 'family', row}); + const definition = providerFamily(row); + if (state.expanded !== row || !definition) continue; + for (const provider of definition.providers) items.push({kind: 'provider', family: definition.family, id: provider.id}); + if (row === 'prompt') items.push({kind: 'configure', family: 'prompt'}); + } + return items; +} + +/** One provider's state in words and a glyph, never color alone. */ +export function providerStatusLabel(row: ReturnType['rows'][number], fallbackLabel: string): string { + const kind = row.descriptor.kind; + if (row.active) return '● Active'; + if (row.preferred) return `✓ Selected · fallback → ${fallbackLabel}`; + if (kind === 'native') return 'Built in'; + if (kind === 'none') return 'Off'; + if (!row.status) return 'Checking…'; + if (row.status.state === 'installed') return `Available${row.status.version ? ` · ${row.status.version}` : ''}`; + if (row.status.state === 'missing') return providerInstall(row.descriptor) ? 'Missing · Enter to install' : 'Missing'; + return `Unavailable${row.status.detail ? ` · ${row.status.detail}` : ''}`; +} + +/** Kept for callers that only need the install provenance wording. */ +export function providerStateText(kind: string, status: ProviderStatus | undefined, nmshInstalled: boolean): string { + if (kind === 'native') return 'Built in'; + if (kind === 'none') return 'Off'; + if (!status) return 'Checking…'; + if (status.state === 'installed') return `Installed${status.version ? ` ${status.version}` : ''} · ${nmshInstalled ? 'installed by NMSh' : 'found on this system'}`; + if (status.state === 'missing') return 'Not installed'; + return `Unavailable${status.detail ? ` · ${status.detail}` : ''}`; +} + +export function providersOverviewKey(state: ProvidersOverviewState, key: Key, facts?: Pick): OverviewAction | undefined { + if (state.installing) return undefined; + if (state.confirm) { + if (key.kind === 'left' || key.kind === 'right') { state.confirm.yes = !state.confirm.yes; return undefined; } + if (key.kind === 'enter') { + const {family, id, yes} = state.confirm; + state.confirm = undefined; + if (yes) return {kind: 'install', family, id}; + state.message = 'Nothing was installed.'; + return undefined; + } + if (key.kind === 'escape' || key.kind === 'interrupt') { state.confirm = undefined; state.message = 'Nothing was installed.'; } + return undefined; + } + const items = overviewItems(state); + state.selected = Math.max(0, Math.min(state.selected, items.length - 1)); + const item = items[state.selected]!; + if (key.kind === 'escape' || key.kind === 'interrupt') { + // Esc collapses the expanded family first; it closes only when nothing is expanded. + if (state.expanded) { + const row = state.expanded; + state.expanded = undefined; + state.selected = OVERVIEW_ROWS.indexOf(row); + return undefined; + } + return {kind: 'close'}; + } + if (key.kind === 'up' || key.kind === 'down') { + state.selected = (state.selected + (key.kind === 'up' ? -1 : 1) + items.length) % items.length; + state.message = undefined; + return undefined; + } + if (key.kind === 'text' && key.value.toLowerCase() === 'r') return {kind: 'detect'}; + if (key.kind !== 'enter' && !(key.kind === 'text' && key.value === ' ')) return undefined; + if (item.kind === 'family') { + if (item.row === 'understanding' || item.row === 'shell') return {kind: 'open', row: item.row}; + state.expanded = state.expanded === item.row ? undefined : item.row; + state.selected = overviewItems(state).findIndex(entry => entry.kind === 'family' && entry.row === item.row); + return undefined; + } + if (item.kind === 'configure') return {kind: 'open', row: 'prompt'}; + const definition = providerFamily(item.family)!; + const descriptor = definition.providers.find(provider => provider.id === item.id)!; + const status = facts?.statuses.get(descriptor.id); + if (descriptor.kind === 'external' && status?.state === 'missing') { + if (providerInstall(descriptor)) state.confirm = {family: item.family, id: item.id, yes: false}; + else state.message = `${descriptor.label} is not installed and NMSh has no verified install recipe for it here.`; + return undefined; + } + if (descriptor.kind === 'external' && status && status.state !== 'installed') { state.message = `${descriptor.label} is unavailable${status.detail ? `: ${status.detail}` : ''}.`; return undefined; } + return {kind: 'select', family: item.family, id: item.id}; +} + +export function renderProvidersOverview(state: ProvidersOverviewState, facts: OverviewFacts, columns: number, height = Infinity): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const reset = '\u001b[0m'; + const items = overviewItems(state); + const selected = Math.max(0, Math.min(state.selected, items.length - 1)); + const lines: Array<{text: string; item?: number}> = [{text: `${primary} Providers${reset} ${subtle}what NMSh uses for each job · Enter expands a family and selects a provider${reset}`}, {text: ''}]; + const nameWidth = labelColumnWidth([...PROVIDER_FAMILIES.map(family => family.title), 'Local understanding'], columns, 2, 30); + const activeWidth = Math.max(8, Math.min(28, columns - 2 - nameWidth - 2 * COLUMN_GUTTER - 8)); + const mark = (index: number) => index === selected ? `${accent}${GLYPHS.selection}${reset}` : ' '; + items.forEach((item, index) => { + const isSelected = index === selected; + if (item.kind === 'family') { + const expander = item.row === 'understanding' || item.row === 'shell' ? ' ' : state.expanded === item.row ? '▾' : '▸'; + const definition = providerFamily(item.row); + if (definition) { + const familyState = familyFacts(definition, facts.configuration, facts.statuses); + const active = definition.providers.find(provider => provider.id === familyState.active)!; + const preferred = definition.providers.find(provider => provider.id === familyState.preferred); + const tag = familyState.notice ? `Selected ${preferred?.label ?? familyState.preferred} · fallback → ${active.label}` : '● Active'; + lines.push({item: index, text: `${mark(index)} ${subtle}${expander}${reset} ${focusForeground(isSelected)}${padCells(definition.title, nameWidth)}${reset}${primary}${padCells(truncateText(active.label, activeWidth), activeWidth)}${reset}${subtle}${tag}${reset}`}); + } else if (item.row === 'understanding') { + lines.push({item: index, text: `${mark(index)} ${focusForeground(isSelected)}${padCells('Local understanding', nameWidth)}${reset}${primary}${padCells(truncateText(facts.understanding.active, activeWidth), activeWidth)}${reset}${subtle}Enter opens it${reset}`}); + if (isSelected) for (const detail of facts.understanding.detail) lines.push({text: ` ${subtle}${detail}${reset}`}); + } else { + lines.push({item: index, text: `${mark(index)} ${focusForeground(isSelected)}${padCells('Shell', nameWidth)}${reset}${primary}${padCells(`${facts.shell.current} (this session)`, activeWidth)}${reset}${subtle}default ${facts.shell.defaultShell} · Enter opens /shell${reset}`}); + } + return; + } + if (item.kind === 'configure') { + lines.push({item: index, text: `${mark(index)} ${isSelected ? accent : subtle}Configure the prompt (themes, styles, modules) in /prompt ›${reset}`}); + return; + } + const definition = providerFamily(item.family)!; + const familyState = familyFacts(definition, facts.configuration, facts.statuses); + const row = familyState.rows.find(entry => entry.descriptor.id === item.id)!; + const fallbackLabel = definition.providers.find(provider => provider.id === definition.fallback)?.label ?? definition.fallback; + const providerWidth = labelColumnWidth(familyState.rows.map(entry => entry.descriptor.label), columns, 6, 16); + const description = row.descriptor.kind === 'external' && row.status?.binary ? row.status.binary : row.descriptor.description; + lines.push({item: index, text: `${mark(index)} ${focusForeground(isSelected)}${padCells(row.descriptor.label, providerWidth)}${reset}${row.active ? accent : subtle}${padCells(providerStatusLabel(row, fallbackLabel), 38)}${reset}${subtle}${truncateText(description, 40)}${reset}`}); + if (state.confirm && state.confirm.family === item.family && state.confirm.id === item.id) { + const install = providerInstall(row.descriptor)!; + lines.push({text: ` ${primary}Install with: ${install.label}${reset}`}); + lines.push({text: ` ${primary}Install now?${reset} ${state.confirm.yes ? `${subtle}No${reset} ${accent}‹ Yes ›${reset}` : `${accent}‹ No ›${reset} ${subtle}Yes${reset}`} ${subtle}←→ choose · Enter confirm · Esc cancel${reset}`}); + } + if (state.installing && state.installing.family === item.family && state.installing.id === item.id) lines.push({text: ` ${subtle}${state.installing.line}${reset}`}); + }); + lines.push({text: ''}, {text: ` ${subtle}Selecting applies at once. Installs are shown first and start on No; uninstall of what NMSh installed is in /tools.${reset}`}); + if (state.detecting) lines.push({text: ` ${subtle}Detecting installed providers…${reset}`}); + if (state.message) lines.push({text: ''}, {text: ` ${secondary}${state.message}${reset}`}); + const controls = renderControls([['↑↓', 'select'], ['Enter', state.expanded ? 'select / collapse' : 'expand'], ['R', 'detect again'], ['Esc', state.expanded ? 'collapse' : 'close']]); + const budget = Number.isFinite(height) ? Math.max(3, height - 2) : lines.length; + const at = Math.max(0, lines.findIndex(line => line.item === selected)); + const start = lines.length <= budget ? 0 : Math.max(0, Math.min(at - Math.floor(budget / 2), lines.length - budget)); + return [...lines.slice(start, start + budget).map(line => line.text), '', controls].map(row => truncateAnsi(row, columns)); +} diff --git a/src/providers/families.ts b/src/providers/families.ts new file mode 100644 index 00000000..aded9270 --- /dev/null +++ b/src/providers/families.ts @@ -0,0 +1,86 @@ +import type {PromptConfiguration} from '../prompt/configuration.js'; +import {PROMPT_PROVIDERS} from '../prompt/PromptPanel.js'; +import {WELCOME_PROVIDERS} from '../output/WelcomeProviders.js'; +import {SUGGESTION_PROVIDERS} from '../suggestions/types.js'; +import {HISTORY_PROVIDERS} from '../shell/historyProviders.js'; +import {PICKER_PROVIDERS} from '../pickers/Picker.js'; +import {NAVIGATION_PROVIDERS} from '../shell/DirectoryService.js'; +import type {ProviderDescriptor, ProviderStatus} from './providers.js'; + +/** + * Every swappable provider family in one place, over the existing + * descriptors and the one persisted configuration. /providers, Ask, + * Settings and Setup Cat all read and write through these accessors, so + * there is no second provider state. Detection results stay runtime-only. + */ +export type SwitchableFamily = 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation'; + +export interface ProviderFamilyDefinition { + family: SwitchableFamily; + title: string; + providers: readonly ProviderDescriptor[]; + /** The provider used when the selected one is not usable. */ + fallback: string; + get: (config: PromptConfiguration) => string; + set: (config: PromptConfiguration, id: string) => PromptConfiguration; + /** The existing domain panel for deeper configuration, when there is one. */ + settingsDestination?: 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation'; +} + +export const PROVIDER_FAMILIES: readonly ProviderFamilyDefinition[] = [ + {family: 'prompt', title: 'Prompt', providers: PROMPT_PROVIDERS, fallback: 'nmsh', get: config => config.provider, + set: (config, id) => ({...config, provider: id as PromptConfiguration['provider']})}, + {family: 'welcome', title: 'Welcome', providers: WELCOME_PROVIDERS, fallback: 'vespyr', get: config => config.welcome, + set: (config, id) => ({...config, welcome: id as PromptConfiguration['welcome']}), settingsDestination: 'welcome'}, + {family: 'suggestions', title: 'Suggestions', providers: SUGGESTION_PROVIDERS, fallback: 'nmsh', get: config => config.suggestions, + set: (config, id) => ({...config, suggestions: id as PromptConfiguration['suggestions']}), settingsDestination: 'suggestions'}, + {family: 'history', title: 'History', providers: HISTORY_PROVIDERS, fallback: 'native', get: config => config.history, + set: (config, id) => ({...config, history: id as PromptConfiguration['history']}), settingsDestination: 'history'}, + {family: 'picker', title: 'Picker', providers: PICKER_PROVIDERS, fallback: 'native', get: config => config.picker, + set: (config, id) => ({...config, picker: id as PromptConfiguration['picker']}), settingsDestination: 'picker'}, + {family: 'navigation', title: 'Directory navigation', providers: NAVIGATION_PROVIDERS, fallback: 'native', get: config => config.navigation, + set: (config, id) => ({...config, navigation: id as PromptConfiguration['navigation']}), settingsDestination: 'navigation'}, +]; + +export function providerFamily(family: string): ProviderFamilyDefinition | undefined { + return PROVIDER_FAMILIES.find(item => item.family === family); +} + +/** Apply a provider choice through the family's own accessor; unknown ids change nothing. */ +export function selectProvider(config: PromptConfiguration, family: string, id: string): PromptConfiguration | undefined { + const definition = providerFamily(family); + if (!definition || !definition.providers.some(provider => provider.id === id)) return undefined; + return definition.set(config, id); +} + +export type ProviderRowState = 'active' | 'fallback-active' | 'selected-unavailable' | 'available' | 'installed' | 'missing' | 'unhealthy' | 'builtin'; + +/** + * One family's facts: what is configured (preferred), what is actually in + * use (active, after fallback), and each provider's detection state. + */ +export function familyFacts(definition: ProviderFamilyDefinition, config: PromptConfiguration, statuses: ReadonlyMap): { + preferred: string; active: string; notice?: string; + rows: Array<{descriptor: ProviderDescriptor; status?: ProviderStatus; preferred: boolean; active: boolean; usable: boolean}>; +} { + const preferred = definition.get(config); + const usable = (descriptor: ProviderDescriptor) => descriptor.kind !== 'external' || statuses.get(descriptor.id)?.state === 'installed'; + const selected = definition.providers.find(provider => provider.id === preferred); + const known = selected && (selected.kind !== 'external' || statuses.has(selected.id)); + // While detection is still running the preference stands; only a known-unusable provider falls back. + const active = !selected || (known && !usable(selected)) ? definition.fallback : preferred; + const fallbackLabel = definition.providers.find(provider => provider.id === definition.fallback)?.label ?? definition.fallback; + const notice = active !== preferred && selected ? `${selected.label} unavailable · using ${fallbackLabel}` : undefined; + return {preferred, active, ...(notice ? {notice} : {}), + rows: definition.providers.map(descriptor => ({descriptor, ...(statuses.get(descriptor.id) ? {status: statuses.get(descriptor.id)!} : {}), + preferred: descriptor.id === preferred, active: descriptor.id === active, usable: usable(descriptor)}))}; +} + +/** Compact facts for Ask: never more than family, id, label, active and availability. */ +export function askProviderFacts(config: PromptConfiguration, statuses: ReadonlyMap): Array<{family: string; id: string; label: string; active: boolean; available: boolean}> { + return PROVIDER_FAMILIES.flatMap(definition => { + const facts = familyFacts(definition, config, statuses); + return facts.rows.map(row => ({family: definition.family, id: row.descriptor.id, label: row.descriptor.label, active: row.active, + available: row.descriptor.kind !== 'external' || row.status?.state === 'installed'})); + }); +} diff --git a/src/providers/providers.ts b/src/providers/providers.ts index 567287ee..ba6096d1 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'; +export type ProviderFamily = 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation' | 'tool'; export type ProviderKind = 'native' | 'external' | 'none'; export interface ProviderInstall { @@ -28,12 +28,46 @@ export interface ProviderDescriptor { versionArgs?: readonly string[]; /** Upstream is archived; kept for compatibility, never recommended. */ legacy?: boolean; - /** Offered only with explicit confirmation. */ + /** + * Curated upstream lifecycle, updated deliberately during NMSh release work + * (never discovered over the network). `legacy` implies Legacy / archived. + */ + lifecycle?: ProviderLifecycle; + /** A maintained alternative named factually when this one is legacy. */ + successor?: string; + /** Offered only with explicit confirmation. Prefer `recipe`; a fixed `install` is used as given. */ install?: ProviderInstall; + /** + * Curated Homebrew formula (core, or a fixed `owner/tap/formula`). Used on + * macOS, and on Linux only where Homebrew is actually installed; NMSh never + * guesses distribution package names, runs install scripts or uses sudo. + */ + recipe?: {brew: string; platforms?: readonly NodeJS.Platform[]}; + /** Official upstream page, named when no recipe applies. */ + source?: string; /** One-line setup note shown while the provider is highlighted. */ setup?: string; } +export type ProviderLifecycle = 'active' | 'maintenance' | 'legacy'; +export const LIFECYCLE_LABELS: Record = {active: 'Active', maintenance: 'Maintenance mode', legacy: 'Legacy / archived'}; + +/** Curated lifecycle; Active when nothing says otherwise. */ +export function providerLifecycle(descriptor: Pick): ProviderLifecycle { + return descriptor.legacy ? 'legacy' : descriptor.lifecycle ?? 'active'; +} + +/** + * A muted, factual lifecycle note, or undefined for active providers. It + * names a successor only for legacy providers and never switches anything. + */ +export function lifecycleNote(descriptor: Pick): string | undefined { + const lifecycle = providerLifecycle(descriptor); + if (lifecycle === 'active') return undefined; + const successor = lifecycle === 'legacy' && descriptor.successor ? ` · ${descriptor.successor} is the recommended maintained alternative.` : ''; + return `${LIFECYCLE_LABELS[lifecycle]}${successor}`; +} + export type ProviderState = 'builtin' | 'installed' | 'missing' | 'unhealthy'; export interface ProviderStatus { @@ -99,7 +133,8 @@ 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} = {}): 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 => { let child: ReturnType; @@ -116,12 +151,33 @@ export function runExternal(binary: string, args: readonly string[], options: {t if (settled) return; settled = true; 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); }; + const abort = () => finish({ok: false, stdout: '', error: 'cancelled'}); const timer = setTimeout(() => finish({ok: false, stdout: '', error: 'timed out'}), options.timeoutMs ?? 2000); + options.signal?.addEventListener('abort', abort, {once: true}); + if (options.signal?.aborted) abort(); child.stdout!.on('data', (chunk: Buffer) => { size += chunk.length; if (size > maxBytes) finish({ok: false, stdout: '', error: 'output too large'}); @@ -197,3 +253,30 @@ export function providerRowText(descriptor: ProviderDescriptor, options: {draft? return `${descriptor.label} · ${descriptor.description}${badge}` + `${options.draft === descriptor.id ? ' ●' : ''}${options.saved === descriptor.id ? ' ✓ saved' : ''}`; } + +/** The install this platform supports for a provider, or undefined. Pure apart from the brew lookup. */ +export function providerInstall(descriptor: ProviderDescriptor, platform: NodeJS.Platform = process.platform, + hasBrew: boolean = resolveCommand('brew') !== undefined): ProviderInstall | undefined { + if (descriptor.install) return descriptor.install; + const recipe = descriptor.recipe; + if (!recipe || !hasBrew || (platform !== 'darwin' && platform !== 'linux')) return undefined; + if (recipe.platforms && !recipe.platforms.includes(platform)) return undefined; + return {label: `brew install ${recipe.brew}`, command: 'brew', args: ['install', recipe.brew]}; +} + +/** Why no install is offered, in plain words, instead of a dead-end "not available". */ +export function installUnavailableReason(descriptor: ProviderDescriptor, platform: NodeJS.Platform = process.platform, + hasBrew: boolean = resolveCommand('brew') !== undefined): string { + const source = descriptor.source ? ` See ${descriptor.source}.` : ''; + if (descriptor.legacy) return `${descriptor.label} is archived upstream; NMSh uses it only if it is already installed.`; + if (!descriptor.recipe && !descriptor.install) return `NMSh has no curated install for ${descriptor.label}. Install it yourself, then reopen this list.${source}`; + if (descriptor.recipe?.platforms && !descriptor.recipe.platforms.includes(platform)) { + return `The curated ${descriptor.label} install is for ${descriptor.recipe.platforms.map(name => name === 'darwin' ? 'macOS' : name).join(', ')} only.${source}`; + } + if (!hasBrew) { + return platform === 'linux' + ? `Homebrew is not installed, and NMSh does not guess distribution package names. Install ${descriptor.label} with your package manager.${source}` + : `Homebrew is not installed. Install ${descriptor.label} from its official source.${source}`; + } + return `NMSh has no install for ${descriptor.label} on this platform.${source}`; +} diff --git a/src/session/InProcessSessionClient.ts b/src/session/InProcessSessionClient.ts index b0324051..3b90eddb 100644 --- a/src/session/InProcessSessionClient.ts +++ b/src/session/InProcessSessionClient.ts @@ -1,21 +1,39 @@ import {EventEmitter} from 'node:events'; import {ShellSession} from '../shell/ShellSession.js'; +import {knowledgeJobCount, type ShellId} from '../shell/adapters/ShellAdapter.js'; +import {shellAdapter} from '../shell/adapters/registry.js'; +import {SERVICE_FEATURES, type ServiceFeature} from './SessionProtocol.js'; import {SESSION_MODE_ENV, type SessionClient, type SessionClientEvents, type SessionOptions} from './SessionClient.js'; -type ShellLike = Pick; +type ShellLike = Pick + & Partial>; const NO_STAMP = {}; /** SessionClient backed by a ShellSession living in this process. */ export class InProcessSessionClient extends EventEmitter implements SessionClient { - private readonly shell: ShellLike; + private shell: ShellLike; + shellId: ShellId; + /** In-process means this very build: every feature is available. */ + readonly features: ReadonlySet = new Set(SERVICE_FEATURES); + private size: {columns: number; rows: number}; + private running = false; + private knowledge?: string; - constructor(options: SessionOptions, factory: (options: SessionOptions) => ShellLike = defaultShell) { + constructor(options: SessionOptions, private readonly factory: (options: SessionOptions) => ShellLike = defaultShell) { super(); + this.shellId = options.shell ?? 'zsh'; + this.size = {columns: options.columns, rows: options.rows}; this.shell = factory(options); - 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 => this.emit('exec', command, NO_STAMP)); - this.shell.on('exit', event => this.emit('exit', event)); + this.wire(this.shell); + } + + private wire(shell: ShellLike): void { + shell.on('data', data => this.emit('data', data, NO_STAMP)); + shell.on('prompt', marker => { this.running = false; this.knowledge = marker.knowledge; this.emit('prompt', marker, NO_STAMP); }); + shell.on('exec', (command, historyAllowed) => { this.running = true; this.emit('exec', command, historyAllowed === undefined ? NO_STAMP : {historyAllowed}); }); + shell.on('startup', tail => this.emit('startup', tail)); + shell.on('inputRejected', (data, submission) => this.emit('inputRejected', data, submission)); + shell.on('exit', event => this.emit('exit', event)); } start(): void {} @@ -23,13 +41,33 @@ export class InProcessSessionClient extends EventEmitter im write(data: string): void { this.shell.write(data); } interrupt(): void { this.shell.interrupt(); } endInput(): void { this.shell.endInput(); } - resize(columns: number, rows: number): void { this.shell.resize(columns, rows); } + resize(columns: number, rows: number): void { this.size = {columns, rows}; this.shell.resize(columns, rows); } kill(): void { this.shell.kill(); } detach(): void { this.shell.kill(); } ack(): void {} + + /** Same guards as the session service: never end a running command, a starting shell or jobs. */ + async switchShell(shell: ShellId, cwd: string): Promise<{shell: ShellId; pid: number}> { + if (shell === this.shellId) throw new Error(`This session already runs ${shellAdapter(shell).label}.`); + const unavailable = shellAdapter(shell).unavailableReason(process.env); + if (unavailable) throw new Error(unavailable); + if (this.shell.isReady === false) throw new Error('The current shell is still starting; switch once it is ready.'); + if (this.running) throw new Error('A command is still running; switching would end it.'); + const jobs = knowledgeJobCount(this.knowledge); + if (jobs) throw new Error(`${jobs} background or stopped job${jobs === 1 ? '' : 's'} would end with the current shell. Finish them first.`); + const next = this.factory({cwd, ...this.size, shell}); + const previous = this.shell; + previous.removeAllListeners?.(); + previous.kill(); + this.shell = next; + this.shellId = shell; + this.knowledge = undefined; + this.wire(next); + return {shell, pid: next.pid ?? 0}; + } } function defaultShell(options: SessionOptions): ShellSession { return new ShellSession(options.cwd, options.columns, options.rows, process.env.HOME || '', - {...process.env, [SESSION_MODE_ENV]: 'in-process'}); + {...process.env, [SESSION_MODE_ENV]: 'in-process'}, options.shell ?? 'zsh'); } 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 9266db91..aed974b6 100644 --- a/src/session/SessionClient.ts +++ b/src/session/SessionClient.ts @@ -1,9 +1,13 @@ import type {EventEmitter} from 'node:events'; import type {ShellMarker} from '../shell/ShellProtocol.js'; import type {TranscriptSession} from '../sessions/TranscriptStore.js'; +import type {ShellId} from '../shell/adapters/ShellAdapter.js'; +import type {ServiceFeature} from './SessionProtocol.js'; /** Position of an event in a service session's stream, when it has one. */ export interface StreamStamp { + /** Explicit zsh history eligibility; absent from older services. */ + historyAllowed?: number; seq?: number; /** When the service observed the event (epoch ms). */ at?: number; @@ -16,6 +20,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. */ @@ -46,6 +54,16 @@ export interface SessionClient extends EventEmitter { detach(): void; /** Stream events up to seq are durable in journalId; the service may drop them. */ ack(seq: number, journalId: string): void; + /** + * Replace the shell backend of this same session, started in cwd. Resolves + * once the new shell is spawned (its readiness arrives as a normal prompt + * event); rejects with a factual reason when switching would lose anything. + */ + switchShell(shell: ShellId, cwd: string): Promise<{shell: ShellId; pid: number}>; + /** Optional capabilities of whatever owns the shell (the service's welcome, or this build in-process). */ + readonly features: ReadonlySet; + /** Build of the session service, when it reported one. */ + readonly serviceBuild?: string; } /** State of a live session this frontend attached to rather than created. */ @@ -62,12 +80,20 @@ 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; + /** Backend of the session; absent from older services (zsh). */ + shell?: string; } export interface SessionOptions { cwd: string; columns: number; rows: number; + /** Shell backend for a new session; zsh when absent. */ + shell?: ShellId; } /** Exported into the managed shell so users can see which mode owns it. */ @@ -83,4 +109,6 @@ export interface SessionConnection { journal?: TranscriptSession; /** Set when the service was unavailable and the shell runs in-process. */ notice?: string; + /** Backend actually running (an older service may only run zsh). */ + shell?: ShellId; } diff --git a/src/session/SessionNotices.ts b/src/session/SessionNotices.ts new file mode 100644 index 00000000..515ec38f --- /dev/null +++ b/src/session/SessionNotices.ts @@ -0,0 +1,240 @@ +import {detectAgentCommand, detectAgentProcess} from '../agents/agents.js'; +import {formatDuration} from '../status/commandTiming.js'; +import {commandWord} from './liveStatus.js'; +import {formatAge, tildePath} from './sessionList.js'; + +/** + * Cross-session notices: one compact, factual line per other session that + * changed state while you were elsewhere. They are frontend chrome above the + * composer: never transcript rows, never journaled, never copied. + * + * The session service owns the notice for each session, because it is the + * one process that sees every session's lifecycle. Focusing a session + * (attaching to it or typing into it) clears its notice in the service, so it + * disappears from every attached NMSh frontend on their next refresh. + * + * Every notice is built from facts the service already has: the program word + * of the command line, the exit code, timestamps, and whether the program + * requested attention (bell / OSC 9 / OSC 777 notify). Nothing reads what an + * agent or program said. + */ + +export const NOTICE_KINDS = ['completed', 'failed', 'attention', 'ended', 'long-running'] as const; +export type NoticeKind = typeof NOTICE_KINDS[number]; + +export interface SessionNotice { + sessionId: string; + kind: NoticeKind; + /** When the transition happened (epoch ms). */ + at: number; + /** Program word of the command involved (basename only, never arguments). */ + program?: string; + /** Known agent id when the program identity proves it. */ + agent?: string; + exitCode?: number; + durationMs?: number; + cwd?: string; +} + +export const MAX_VISIBLE_NOTICES = 3; +/** A command still running after this long is worth one notice. */ +export const LONG_RUNNING_MS = 15 * 60_000; +/** + * Retention is not visibility. The service keeps an ended session's notice + * this long (bounded in count) so a frontend that attaches later can still + * list it; /sessions and /resume own that history. What a frontend SHOWS + * above the composer is much shorter, see NOTICE_VISIBLE_MS. + */ +export const ENDED_NOTICE_TTL_MS = 60 * 60_000; +export const MAX_ENDED_NOTICES = 8; + +/** + * How long a notice stays above the composer after its transition. A notice + * is an event ("something just happened elsewhere"), not a status panel: + * routine outcomes fade in seconds, failures linger a little longer, and only + * a program asking for a human (attention) stays until it is focused, answered + * or replaced. Long-running is shown once, briefly; /sessions owns the state. + */ +export const NOTICE_VISIBLE_MS = { + completed: 12_000, + ended: 20_000, + endedAbnormal: 45_000, + failed: 45_000, + longRunning: 10_000, +} as const; + +/** Visibility window in ms, or undefined for a sticky notice (attention). */ +export function noticeVisibleMs(notice: SessionNotice): number | undefined { + switch (notice.kind) { + case 'completed': return NOTICE_VISIBLE_MS.completed; + case 'failed': return NOTICE_VISIBLE_MS.failed; + case 'long-running': return NOTICE_VISIBLE_MS.longRunning; + case 'ended': return notice.exitCode ? NOTICE_VISIBLE_MS.endedAbnormal : NOTICE_VISIBLE_MS.ended; + case 'attention': return undefined; + } +} + +/** When the notice stops being shown (epoch ms); undefined while it is sticky. */ +export function noticeExpiresAt(notice: SessionNotice): number | undefined { + const visible = noticeVisibleMs(notice); + return visible === undefined ? undefined : notice.at + visible; +} + +export function noticeVisible(notice: SessionNotice, now: number): boolean { + const expires = noticeExpiresAt(notice); + return expires === undefined || now < expires; +} + +/** Stable identity of one transition: the same transition is never shown twice. */ +export function noticeKey(notice: SessionNotice): string { + return `${notice.sessionId}:${notice.kind}:${notice.at}`; +} + +/** Program word and agent identity for a command line; arguments are dropped here. */ +export function programIdentity(command: string | undefined, process?: string): {program?: string; agent?: string} { + const agent = (command ? detectAgentCommand(command) : undefined) ?? detectAgentProcess(process); + const program = command ? commandWord(command) : undefined; + return {...(program ? {program: program.slice(0, 64)} : {}), ...(agent ? {agent: agent.id} : {})}; +} + +/** + * Service-side state machine for one session's notice. Only the latest + * transition is kept (a newer one replaces an older one: dedupe by design). + */ +export class SessionNoticeTracker { + private current?: SessionNotice; + private running?: {command: string; since: number; process?: string}; + /** The run a long-running notice was already raised for. */ + private longRaisedFor?: number; + /** Attention requested at or before this time was already seen by someone focusing the session. */ + private clearedAt = -1; + + constructor(private readonly sessionId: string) {} + + onExec(command: string, at: number): void { + this.running = {command, since: at}; + this.longRaisedFor = undefined; + } + + /** Foreground process name, when the platform reports it (agents launched through a wrapper). */ + observeProcess(process: string | undefined): void { + if (this.running && process) this.running.process = process; + } + + onPrompt(exitCode: number, at: number, cwd: string): void { + const run = this.running; + this.running = undefined; + if (!run) return; + // Ctrl+C (130) is the user acting in that session, not news to report elsewhere. + if (exitCode === 130) { this.current = undefined; return; } + this.current = {sessionId: this.sessionId, kind: exitCode === 0 ? 'completed' : 'failed', at, + ...programIdentity(run.command, run.process), exitCode, durationMs: Math.max(0, at - run.since), cwd}; + } + + /** The running program asked for attention (evidence from its own output stream). */ + onAttention(at: number): void { + if (!this.running || at <= this.clearedAt) return; + if (this.current?.kind === 'attention' && this.current.at >= this.running.since) return; + this.current = {sessionId: this.sessionId, kind: 'attention', at, ...programIdentity(this.running.command, this.running.process)}; + } + + /** Raise one long-running notice per run, lazily when someone lists sessions; no timers. */ + checkLongRunning(now: number): void { + const run = this.running; + if (!run || this.longRaisedFor === run.since || now - run.since < LONG_RUNNING_MS) return; + if (this.current && this.current.at >= run.since) return; + this.longRaisedFor = run.since; + this.current = {sessionId: this.sessionId, kind: 'long-running', at: now, ...programIdentity(run.command, run.process), + durationMs: now - run.since}; + } + + /** The session was focused somewhere: its notice is no longer news anywhere. */ + clear(now = Date.now()): void { + this.current = undefined; + this.clearedAt = now; + } + + ended(exitCode: number, at: number, cwd: string): SessionNotice { + return {sessionId: this.sessionId, kind: 'ended', at, exitCode, cwd}; + } + + get notice(): SessionNotice | undefined { return this.current; } +} + +/** Bounded, expiring list of notices for sessions that no longer exist. */ +export class EndedNotices { + private notices: SessionNotice[] = []; + + add(notice: SessionNotice): void { + this.notices = [notice, ...this.notices.filter(item => item.sessionId !== notice.sessionId)].slice(0, MAX_ENDED_NOTICES); + } + + dismiss(sessionId: string): boolean { + const before = this.notices.length; + this.notices = this.notices.filter(item => item.sessionId !== sessionId); + return before !== this.notices.length; + } + + list(now: number): SessionNotice[] { + this.notices = this.notices.filter(item => now - item.at < ENDED_NOTICE_TTL_MS); + return [...this.notices]; + } +} + +// ---------------------------------------------------------------- frontend + +export interface NoticeView { + /** Up to MAX_VISIBLE_NOTICES rows; the last one may summarize overflow. */ + notices: SessionNotice[]; + /** Notices not shown individually. */ + hidden: number; +} + +/** + * Choose what to show: newest first, never this frontend's own session, + * deduplicated by transition identity, capped at three rows. When there are + * more, the third row becomes a collapsed "+N more" summary. With `now`, a + * notice past its visibility window is not selected (see NOTICE_VISIBLE_MS). + */ +export function selectNotices(all: readonly SessionNotice[], ownSessionId: string | undefined, dismissed: ReadonlySet = new Set(), now?: number): NoticeView { + const seen = new Set(); + const ordered = [...all] + .filter(notice => notice.sessionId !== ownSessionId && !dismissed.has(noticeKey(notice)) && (now === undefined || noticeVisible(notice, now))) + .sort((a, b) => b.at - a.at || a.sessionId.localeCompare(b.sessionId)) + .filter(notice => { const key = noticeKey(notice); if (seen.has(key)) return false; seen.add(key); return true; }); + if (ordered.length <= MAX_VISIBLE_NOTICES) return {notices: ordered, hidden: 0}; + return {notices: ordered.slice(0, MAX_VISIBLE_NOTICES - 1), hidden: ordered.length - (MAX_VISIBLE_NOTICES - 1)}; +} + +/** Short stable label for a session: its numeric position if known, else an id prefix. */ +export function sessionLabel(sessionId: string, ordinal?: number): string { + return ordinal !== undefined ? `Session ${ordinal}` : `Session ${sessionId.slice(0, 4)}`; +} + +export interface NoticeLineParts { + kind: NoticeKind; + /** The glyph slot; the caller picks Nerd/Safe. */ + symbol: 'done' | 'attention' | 'failed' | 'ended' | 'long'; + text: string; +} + +const AGENT_NAMES: Record = {claude: 'Claude', codex: 'Codex'}; + +/** Factual wording for one notice. */ +export function describeNotice(notice: SessionNotice, label: string, now: number): NoticeLineParts { + const who = notice.agent ? AGENT_NAMES[notice.agent] ?? notice.program : notice.program; + const age = formatAge(Math.max(0, now - notice.at)); + const where = notice.cwd ? ` · ${tildePath(notice.cwd)}` : ''; + switch (notice.kind) { + case 'completed': + return {kind: notice.kind, symbol: 'done', text: `${label} · ${who ? `${who} finished` : 'command finished'}${notice.durationMs !== undefined ? ` after ${formatDuration(notice.durationMs)}` : ''} · ${age} ago`}; + case 'failed': + return {kind: notice.kind, symbol: 'failed', text: `${label} · ${who ?? 'command'} failed (exit ${notice.exitCode ?? '?'}) · ${age} ago`}; + case 'attention': + return {kind: notice.kind, symbol: 'attention', text: `${label} · ${who ?? 'program'} asked for attention · ${age} ago`}; + case 'long-running': + return {kind: notice.kind, symbol: 'long', text: `${label} · ${who ?? 'command'} still running · ${formatDuration(notice.durationMs ?? 0)}`}; + case 'ended': + return {kind: notice.kind, symbol: 'ended', text: `${label} · shell ended${notice.exitCode ? ` (exit ${notice.exitCode})` : ''}${where} · ${age} ago`}; + } +} 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 71a35568..eac4b178 100644 --- a/src/session/SessionProtocol.ts +++ b/src/session/SessionProtocol.ts @@ -9,40 +9,78 @@ * rejected instead of partially applied. */ +import {NOTICE_KINDS, type SessionNotice} from './SessionNotices.js'; + // v2: a frontend going away detaches its session instead of ending it. export const PROTOCOL_VERSION = 2; export const MAX_FRAME_BYTES = 8 * 1024 * 1024; +/** + * Optional capabilities negotiated in the welcome. A frontend sends a feature's + * messages only when the connected service advertised it, so a newer frontend + * talking to an older service (same protocol version, still running its live + * sessions) degrades factually instead of failing later. + */ +export const SERVICE_FEATURES = ['shell-switch', 'shell-backends', 'notices'] as const; +export type ServiceFeature = typeof SERVICE_FEATURES[number]; + +export function parseFeatures(text: string | undefined): Set { + return new Set((text ?? '').split(',').map(item => item.trim()).filter((item): item is ServiceFeature => (SERVICE_FEATURES as readonly string[]).includes(item))); +} + export type ClientMessage = | {type: 'hello'; version: number; client: string} - | {type: 'create'; cwd: string; env: Record; columns: number; rows: number} + /** shell: backend id (zsh, fish, bash); absent means zsh (older frontends). */ + | {type: 'create'; cwd: string; env: Record; columns: number; rows: number; shell?: string} | {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} /** End a detached session this connection does not control (Kill Session). */ | {type: 'kill'; sessionId: string} + /** Clear a session's cross-session notice everywhere (it was opened/focused). */ + | {type: 'dismiss'; sessionId: string} + /** Replace this session's shell backend in place, starting it in cwd. Refused while anything would be lost. */ + | {type: 'switch-shell'; shell: string; cwd: string} + /** Rename a live session (display only; its id never changes). An empty name returns to its signature. */ + | {type: 'rename'; sessionId: string; name: string} | {type: 'terminate'}; export type ServerMessage = - | {type: 'welcome'; version: number; service: string} + /** startupSafety=1 promises pre-ready input isolation, bounded rejection and startup state reporting. */ + /** + * features: comma-separated capabilities this service implements beyond the + * base protocol (see SERVICE_FEATURES); absent from older services, which + * therefore have none. build: the service's build identity, informational. + */ + | {type: 'welcome'; version: number; service: string; startupSafety?: number; features?: string; build?: string} | {type: 'error'; code: string; message: string} - | {type: 'created'; sessionId: string; pid: number} + /** shell: the backend actually started (absent from older services: zsh). */ + | {type: 'created'; sessionId: string; pid: number; shell?: string} + | {type: 'shell-switched'; shell: 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; shell?: 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[]} + /** ended: notices for sessions that ended recently (absent from older services). */ + | {type: 'sessions'; sessions: SessionInfo[]; ended?: SessionNotice[]} + | {type: 'dismissed'; sessionId: string} /** * Shell stream events carry a per-session sequence number and the time the * service observed them, so a reattaching frontend can replay what it * missed exactly once with the original timing. */ | {type: 'output'; data: string; seq?: number; at?: number} - | {type: 'exec'; command: string; seq: number; at: number} - | {type: 'prompt'; exitCode: number; cwd: string; seq?: number; at?: number} + | {type: 'exec'; command: string; seq: number; at: number; historyAllowed?: 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} @@ -78,6 +116,14 @@ export interface SessionInfo { attentionSince?: number; /** Exit code of the last finished command, while idle. */ lastExit?: number; + /** Shell backend id; absent from older services (zsh). */ + shell?: string; + /** Familiar signature name, assigned once by the service (absent from older services). */ + signature?: string; + /** The person's own name for the session, when renamed. */ + name?: string; + /** The session's current cross-session notice, until it is focused (#v0.16; absent from older services). */ + notice?: SessionNotice; } export type ProtocolMessage = ClientMessage | ServerMessage; @@ -90,30 +136,37 @@ export function encodeMessage(message: ProtocolMessage): string { return `${JSON.stringify({v: PROTOCOL_VERSION, ...message})}\n`; } -type Kind = 'string' | 'int' | 'env' | 'int?' | 'string?' | 'sessions'; +type Kind = 'string' | 'int' | 'env' | 'int?' | 'string?' | 'sessions' | 'notice?' | 'notices?'; type Shape = Record; const SHAPES: Record = { hello: {version: 'int', client: 'string'}, - create: {cwd: 'string', env: 'env', columns: 'int', rows: 'int'}, + create: {cwd: 'string', env: 'env', columns: 'int', rows: 'int', shell: 'string?'}, + 'switch-shell': {shell: 'string', cwd: 'string'}, + rename: {sessionId: 'string', name: 'string'}, + 'shell-switched': {shell: 'string', pid: 'int'}, 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'}, + dismiss: {sessionId: 'string'}, terminate: {}, - welcome: {version: 'int', service: 'string'}, + welcome: {version: 'int', service: 'string', startupSafety: 'int?', features: 'string?', build: 'string?'}, error: {code: 'string', message: 'string'}, - created: {sessionId: 'string', pid: 'int'}, + created: {sessionId: 'string', pid: 'int', shell: 'string?'}, 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?', shell: 'string?'}, + startup: {output: 'string'}, + 'input-rejected': {data: 'string', submission: 'int?'}, detached: {sessionId: 'string'}, - sessions: {sessions: 'sessions'}, + sessions: {sessions: 'sessions', ended: 'notices?'}, + dismissed: {sessionId: 'string'}, output: {data: 'string', seq: 'int?', at: 'int?'}, - exec: {command: 'string', seq: 'int', at: 'int'}, - prompt: {exitCode: 'int', cwd: 'string', seq: 'int?', at: 'int?'}, + exec: {command: 'string', seq: 'int', at: 'int', historyAllowed: 'int?'}, + prompt: {exitCode: 'int', cwd: 'string', knowledge: 'string?', seq: 'int?', at: 'int?'}, replayed: {truncatedBytes: 'int'}, killed: {sessionId: 'string'}, exit: {exitCode: 'int', signal: 'int?'}, @@ -126,7 +179,15 @@ function isEnv(value: unknown): value is Record { const INFO_SHAPE: Shape = {id: 'string', pid: 'int', state: 'string', cwd: 'string', createdAt: 'int', running: 'string?', runningSince: 'int?', idleSince: 'int?', journalId: 'string?', - process: 'string?', fullscreen: 'int?', lastOutputAt: 'int?', title: 'string?', attentionSince: 'int?', lastExit: 'int?'}; + process: 'string?', fullscreen: 'int?', lastOutputAt: 'int?', title: 'string?', attentionSince: 'int?', lastExit: 'int?', notice: 'notice?', shell: 'string?', signature: 'string?', name: 'string?'}; + +const NOTICE_SHAPE: Shape = {sessionId: 'string', kind: 'string', at: 'int', program: 'string?', agent: 'string?', exitCode: 'int?', + durationMs: 'int?', cwd: 'string?'}; + +function decodeNotice(raw: unknown): SessionNotice | undefined { + const decoded = decodeShape(NOTICE_SHAPE, raw) as SessionNotice | undefined; + return decoded && (NOTICE_KINDS as readonly string[]).includes(decoded.kind) ? decoded : undefined; +} function validField(kind: Kind, value: unknown): boolean { switch (kind) { @@ -134,6 +195,8 @@ function validField(kind: Kind, value: unknown): boolean { case 'env': return isEnv(value); case 'sessions': return Array.isArray(value) && value.every(entry => decodeShape(INFO_SHAPE, entry) !== undefined && ((entry as SessionInfo).state === 'attached' || (entry as SessionInfo).state === 'detached')); + case 'notice?': return decodeNotice(value) !== undefined; + case 'notices?': return Array.isArray(value) && value.length <= 64 && value.every(entry => decodeNotice(entry) !== undefined); default: return Number.isSafeInteger(value); } } @@ -144,10 +207,11 @@ function decodeShape(shape: Shape, raw: unknown): Record | unde const decoded: Record = {}; for (const [field, kind] of Object.entries(shape)) { const value = record[field]; - if ((kind === 'int?' || kind === 'string?') && value === undefined) continue; + if ((kind === 'int?' || kind === 'string?' || kind === 'notice?' || kind === 'notices?') && value === undefined) continue; if (!validField(kind === 'string?' ? 'string' : kind, value)) return undefined; decoded[field] = kind === 'env' ? {...(value as Record)} - : kind === 'sessions' ? (value as unknown[]).map(entry => decodeShape(INFO_SHAPE, entry)) : value; + : kind === 'sessions' ? (value as unknown[]).map(entry => decodeShape(INFO_SHAPE, entry)) + : kind === 'notice?' ? decodeNotice(value) : kind === 'notices?' ? (value as unknown[]).map(decodeNotice) : value; } return decoded; } diff --git a/src/session/SessionService.ts b/src/session/SessionService.ts index 568e8b6e..9a9cacc9 100644 --- a/src/session/SessionService.ts +++ b/src/session/SessionService.ts @@ -1,11 +1,15 @@ +import {assignSignature} from './signatures.js'; import {randomUUID} from 'node:crypto'; import {chmodSync, lstatSync, unlinkSync} from 'node:fs'; import {connect, createServer, type Server, type Socket} from 'node:net'; import {ShellSession} from '../shell/ShellSession.js'; +import {isShellId, knowledgeJobCount, type ShellId} from '../shell/adapters/ShellAdapter.js'; +import {shellAdapter} from '../shell/adapters/registry.js'; import {SessionEvidence} from './SessionEvidence.js'; -import {FrameDecoder, PROTOCOL_VERSION, encodeMessage, type ServerMessage, type SessionInfo, type SessionState} from './SessionProtocol.js'; +import {FrameDecoder, PROTOCOL_VERSION, SERVICE_FEATURES, encodeMessage, type ServerMessage, type SessionInfo, type SessionState} from './SessionProtocol.js'; import {SESSION_MODE_ENV} from './SessionClient.js'; import {ensurePrivateRuntimeDir, socketPathFor, spoolPathFor} from './runtimeDir.js'; +import {EndedNotices, SessionNoticeTracker} from './SessionNotices.js'; import {StreamBacklog, type BacklogEvent, type BacklogLimits} from './StreamBacklog.js'; export const SERVICE_NAME = 'nmshd'; @@ -17,6 +21,10 @@ export interface SessionRecord { createdAt: string; state: SessionState; protocolVersion: number; + /** Familiar signature ("Mango"), assigned once at creation and kept across reattaches. */ + signature?: string; + /** The person's own name for the session, when they renamed it. */ + name?: string; } type Send = (message: ServerMessage) => void; @@ -24,6 +32,11 @@ type Send = (message: ServerMessage) => void; interface ManagedSession { record: SessionRecord; shell: ShellSession; + /** Backend of the current shell; replaced in place by switch-shell. */ + backend: ShellId; + /** Kept in memory only, to start a replacement backend with the same environment; never stored or logged. */ + env: Record; + size: {columns: number; rows: number}; /** The one writable frontend; undefined while detached. */ controller?: Send; running?: {command: string; since: number}; @@ -36,6 +49,9 @@ interface ManagedSession { backlog: StreamBacklog; /** What the foreground program's own output says: recency, title, attention, last exit. */ evidence: SessionEvidence; + /** This session's cross-session notice; cleared when the session is focused. */ + notices: SessionNoticeTracker; + knowledge?: string; } export {AlternateScreenTracker} from './TerminalModes.js'; @@ -51,6 +67,8 @@ export interface SessionServiceOptions { backlogLimits?: BacklogLimits; /** Live sessions allowed at once; detached ones are never ended to make room. */ maxSessions?: number; + /** Build identity reported in the welcome (informational). */ + build?: string; } export const DEFAULT_MAX_SESSIONS = 16; @@ -58,8 +76,10 @@ export const DEFAULT_MAX_SESSIONS = 16; function toMessage(event: BacklogEvent): ServerMessage { switch (event.kind) { 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}; - case 'prompt': return {type: 'prompt', exitCode: event.exitCode, cwd: event.cwd, 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, + ...(event.knowledge === undefined ? {} : {knowledge: event.knowledge})}; } } @@ -85,6 +105,7 @@ export class SessionService { private server: Server | undefined; private readonly sessions = new Map(); private readonly connections = new Set(); + private readonly endedNotices = new EndedNotices(); private idleTimer: NodeJS.Timeout | undefined; private closed = false; readonly done: Promise; @@ -104,7 +125,12 @@ export class SessionService { const evidence = session.evidence.snapshot(); // Read only when someone lists sessions; nothing polls the process table. const process = running ? session.shell.foregroundProcess : undefined; - return {id: record.id, pid: record.pid, state: record.state, cwd: record.cwd, createdAt: Date.parse(record.createdAt), + session.notices.observeProcess(process); + if (evidence.attentionSince !== undefined) session.notices.onAttention(evidence.attentionSince); + session.notices.checkLongRunning(Date.now()); + const notice = session.notices.notice; + return {id: record.id, pid: record.pid, state: record.state, cwd: record.cwd, createdAt: Date.parse(record.createdAt), shell: session.backend, + ...(record.signature ? {signature: record.signature} : {}), ...(record.name ? {name: record.name} : {}), ...(running ? {running: running.command, runningSince: running.since} : {idleSince: session.idleSince}), ...(session.backlog.journalId ? {journalId: session.backlog.journalId} : {}), ...(process && process !== 'zsh' ? {process} : {}), @@ -112,7 +138,8 @@ export class SessionService { ...(evidence.lastOutputAt !== undefined ? {lastOutputAt: evidence.lastOutputAt} : {}), ...(evidence.title ? {title: evidence.title} : {}), ...(evidence.attentionSince !== undefined ? {attentionSince: evidence.attentionSince} : {}), - ...(evidence.lastExit !== undefined && !running ? {lastExit: evidence.lastExit} : {})}; + ...(evidence.lastExit !== undefined && !running ? {lastExit: evidence.lastExit} : {}), + ...(notice ? {notice} : {})}; } async start(): Promise { @@ -164,7 +191,8 @@ 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, features: SERVICE_FEATURES.join(','), + ...(this.options.build ? {build: this.options.build} : {})}); continue; } switch (message.type) { @@ -175,8 +203,10 @@ export class SessionService { break; } try { - owned = this.create(message.cwd, message.env, message.columns, message.rows, send); - send({type: 'created', sessionId: owned.record.id, pid: owned.record.pid}); + const backend = message.shell === undefined ? 'zsh' : message.shell; + if (!isShellId(backend)) throw new Error(`unknown shell backend ${backend}`); + owned = this.create(message.cwd, message.env, message.columns, message.rows, send, backend); + send({type: 'created', sessionId: owned.record.id, pid: owned.record.pid, shell: owned.backend}); } catch (error) { send({type: 'error', code: 'spawn', message: error instanceof Error ? error.message : String(error)}); } @@ -188,12 +218,17 @@ export class SessionService { if (session.controller) { send({type: 'error', code: 'attached', message: 'session is attached to another frontend'}); break; } owned = session; this.bind(session, send); + // Opening a session is focusing it: its notice is no longer news in any window. + session.notices.clear(); + this.endedNotices.dismiss(session.record.id); const info = this.info(session); const {backlog} = session; 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}), shell: session.backend, + ...(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)); @@ -210,11 +245,45 @@ export class SessionService { } break; case 'list': - send({type: 'sessions', sessions: [...this.sessions.values()].map(session => this.info(session))}); + send({type: 'sessions', sessions: [...this.sessions.values()].map(session => this.info(session)), ended: this.endedNotices.list(Date.now())}); break; - case 'input': owned?.evidence.onInput(); owned?.shell.write(message.data); break; + case 'dismiss': + this.sessions.get(message.sessionId)?.notices.clear(); + this.endedNotices.dismiss(message.sessionId); + send({type: 'dismissed', sessionId: message.sessionId}); + break; + case 'input': + owned?.evidence.onInput(); + owned?.notices.clear(); + 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 'switch-shell': { + if (!owned) { send({type: 'error', code: 'state', message: 'no session to switch'}); break; } + const refusal = this.switchRefusal(owned, message.shell); + if (refusal) { send({type: 'error', code: refusal.code, message: refusal.message}); break; } + try { + this.switchShell(owned, message.shell as ShellId, message.cwd); + send({type: 'shell-switched', shell: owned.backend, pid: owned.record.pid}); + } catch (error) { + send({type: 'error', code: 'switch-failed', message: error instanceof Error ? error.message : String(error)}); + } + break; + } + case 'rename': { + const target = this.sessions.get(message.sessionId); + if (!target) { send({type: 'error', code: 'unknown', message: 'no live session with that id'}); break; } + const name = message.name.replace(/[\u0000-\u001f\u007f]/gu, '').trim().slice(0, 40); + if (name) target.record.name = name; else delete target.record.name; + send({type: 'sessions', sessions: [...this.sessions.values()].map(session => this.info(session)), ended: this.endedNotices.list(Date.now())}); + break; + } case 'resize': - if (owned) { owned.resizes += 1; this.resize(owned, message.columns, message.rows, send); } + if (owned) { owned.size = {columns: message.columns, rows: message.rows}; owned.resizes += 1; this.resize(owned, message.columns, message.rows, send); } break; case 'ack': owned?.backlog.ack(message.seq, message.journalId); break; case 'kill': { @@ -278,18 +347,26 @@ export class SessionService { } } - private create(cwd: string, env: Record, columns: number, rows: number, send: Send): ManagedSession { + private create(cwd: string, env: Record, columns: number, rows: number, send: Send, backend: ShellId = 'zsh'): ManagedSession { // The shell gets the launching frontend's environment and cwd, never the // service's own startup state. The env is opaque: it is not stored or logged. - const shell = new ShellSession(cwd, columns, rows, env.HOME || '', {...env, [SESSION_MODE_ENV]: 'service'}); - const record: SessionRecord = {id: randomUUID(), pid: shell.pid, cwd, createdAt: new Date().toISOString(), - state: 'attached', protocolVersion: PROTOCOL_VERSION}; - const session: ManagedSession = {record, shell, controller: send, idleSince: Date.now(), screen: new AlternateScreenTracker(), resizes: 0, + const shell = new ShellSession(cwd, columns, rows, env.HOME || '', {...env, [SESSION_MODE_ENV]: 'service'}, backend); + const id = randomUUID(); + // A familiar signature unique among live sessions, assigned once; reattaching never changes it. + const signature = assignSignature(id, [...this.sessions.values()].flatMap(item => item.record.signature ? [item.record.signature] : [])); + const record: SessionRecord = {id, pid: shell.pid, cwd, createdAt: new Date().toISOString(), + state: 'attached', protocolVersion: PROTOCOL_VERSION, signature}; + const session: ManagedSession = {record, shell, backend, env, size: {columns, rows}, controller: send, idleSince: Date.now(), screen: new AlternateScreenTracker(), resizes: 0, seq: 0, backlog: new StreamBacklog(spoolPathFor(this.options.runtimeDir, record.id), this.options.backlogLimits), - evidence: new SessionEvidence()}; + evidence: new SessionEvidence(), notices: new SessionNoticeTracker(record.id)}; this.sessions.set(record.id, session); - // Every event is retained until a frontend journal acknowledges it, and - // sent live when a frontend is attached. + this.wire(session); + return session; + } + + /** Connect a session's current shell to its stream. Every event is retained until a frontend journal acknowledges it. */ + private wire(session: ManagedSession): void { + const {record, shell} = session; const emit = (event: BacklogEvent, live: ServerMessage = toMessage(event)) => { session.backlog.append(event); session.controller?.(live); @@ -302,22 +379,29 @@ 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('exec', command => { + shell.on('startup', output => session.controller?.({type: 'startup', output})); + shell.on('exec', (command, historyAllowed) => { const at = Date.now(); session.running = {command, since: at}; session.evidence.onExec(); - emit({kind: 'exec', seq: ++session.seq, at, command}); + session.notices.onExec(command, at); + 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.notices.onPrompt(marker.exitCode, Date.now(), marker.cwd); 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); + // Ending with no window attached is news; with one attached, that window saw it. + if (!session.controller) this.endedNotices.add(session.notices.ended(event.exitCode, Date.now(), record.cwd)); // Detached: keep what the journal lacks on disk for archiving. Attached: // the frontend journal is authoritative and the backlog goes. if (session.controller) session.backlog.dispose(); @@ -326,7 +410,45 @@ export class SessionService { session.controller = undefined; this.maybeShutdown(); }); - return session; + } + + /** + * Why a backend switch must not happen now, if anything would be lost: a + * running command or full-screen program, a shell still starting, or + * background/stopped jobs (ending the old shell would end them too). + */ + private switchRefusal(session: ManagedSession, target: string): {code: string; message: string} | undefined { + if (!isShellId(target)) return {code: 'unknown-shell', message: `${target} is not a supported shell backend (zsh, fish, bash).`}; + if (target === session.backend) return {code: 'same-shell', message: `This session already runs ${shellAdapter(target).label}.`}; + const available = shellAdapter(target).unavailableReason(session.env); + if (available) return {code: 'unavailable', message: available}; + if (!session.shell.isReady) return {code: 'busy', message: 'The current shell is still starting; switch once it is ready.'}; + if (session.running) return {code: 'busy', message: `"${session.running.command.slice(0, 60)}" is still running; switching would end it. Finish or interrupt it first.`}; + if (session.screen.ownsTerminal) return {code: 'busy', message: 'A full-screen program owns the terminal; switching would end it.'}; + const jobs = knowledgeJobCount(session.knowledge); + if (jobs) return {code: 'jobs', message: `${jobs} background or stopped job${jobs === 1 ? '' : 's'} would end with the current shell. Finish them first (jobs, fg, kill %N).`}; + return undefined; + } + + /** + * Replace the shell process under the same session identity. The old + * shell's listeners are removed before it is ended, so its exit never ends + * the session; the new one starts in cwd with the original environment. + */ + private switchShell(session: ManagedSession, target: ShellId, cwd: string): void { + const next = new ShellSession(cwd, session.size.columns, session.size.rows, session.env.HOME || '', {...session.env, [SESSION_MODE_ENV]: 'service'}, target); + const previous = session.shell; + previous.removeAllListeners(); + previous.kill(); + session.shell = next; + session.backend = target; + session.record.pid = next.pid; + session.record.cwd = cwd; + session.knowledge = undefined; + session.running = undefined; + session.idleSince = Date.now(); + session.screen.reset(); + this.wire(session); } private maybeShutdown(): void { diff --git a/src/session/SocketSessionClient.ts b/src/session/SocketSessionClient.ts index d93eedc9..fb51c9d9 100644 --- a/src/session/SocketSessionClient.ts +++ b/src/session/SocketSessionClient.ts @@ -1,7 +1,9 @@ import {EventEmitter} from 'node:events'; import {connect, type Socket} from 'node:net'; -import {FrameDecoder, PROTOCOL_VERSION, encodeMessage, type ClientMessage, type ServerMessage} from './SessionProtocol.js'; +import {FrameDecoder, PROTOCOL_VERSION, encodeMessage, parseFeatures, type ClientMessage, type ServiceFeature, type ServerMessage} from './SessionProtocol.js'; import type {SessionInfo} from './SessionProtocol.js'; +import type {SessionNotice} from './SessionNotices.js'; +import {isShellId, type ShellId} from '../shell/adapters/ShellAdapter.js'; import type {AttachedSession, SessionClient, SessionClientEvents, SessionOptions} from './SessionClient.js'; export interface SocketConnectOptions extends SessionOptions { @@ -20,6 +22,8 @@ type Decoded = ReturnType; interface Established { value: T; + /** The service's welcome: its advertised features and build. */ + welcome?: Extract; socket: Socket; decoder: FrameDecoder; /** Frames that arrived in the same read as the reply; they belong to the caller. */ @@ -33,6 +37,7 @@ function request(socketPath: string, timeoutMs: number, first: ClientMessage socket.setEncoding('utf8'); const decoder = new FrameDecoder(); let settled = false; + let welcome: Extract | undefined; const fail = (message: string, code: string) => { if (settled) return; settled = true; @@ -50,7 +55,12 @@ function request(socketPath: string, timeoutMs: number, first: ClientMessage const message = result.message as ServerMessage; if (message.type === 'error') { fail(`session service refused: ${message.message}`, message.code); return; } if (message.type === 'welcome') { + welcome = message; 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; } @@ -61,7 +71,7 @@ function request(socketPath: string, timeoutMs: number, first: ClientMessage socket.off('data', onData); socket.off('error', onError); socket.off('close', onClose); - resolve({value, socket, decoder, rest: results.slice(index + 1)}); + resolve({value, socket, decoder, rest: results.slice(index + 1), ...(welcome ? {welcome} : {})}); return; } } @@ -90,6 +100,32 @@ export async function listSessions(socketPath: string, timeoutMs = 3000): Promis return value; } +/** Live sessions plus notices for recently ended ones (older services report none). */ +export async function listSessionsWithNotices(socketPath: string, timeoutMs = 3000): Promise<{sessions: SessionInfo[]; ended: SessionNotice[]}> { + const {value, socket} = await request(socketPath, timeoutMs, {type: 'list'}, + message => (message.type === 'sessions' ? {sessions: message.sessions, ended: message.ended ?? []} : undefined)); + socket.end(); + return value; +} + +/** Rename a live session (display only); an empty name returns to its signature. */ +export async function renameSession(socketPath: string, sessionId: string, name: string, timeoutMs = 3000): Promise { + const {value, socket} = await request(socketPath, timeoutMs, {type: 'rename', sessionId, name}, + message => (message.type === 'sessions' ? message.sessions.find(session => session.id === sessionId) ?? null : undefined)); + socket.end(); + return value ?? undefined; +} + +/** Clear a session's notice in every attached frontend. */ +export async function dismissNotice(socketPath: string, sessionId: string, timeoutMs = 3000): Promise { + const {socket} = await request(socketPath, timeoutMs, {type: 'dismiss', sessionId}, + message => (message.type === 'dismissed' ? true : undefined)); + socket.end(); +} + +export const OLDER_SERVICE_SWITCH = 'The session service running this session is an older NMSh build without shell switching. ' + + 'Its live sessions keep running; once they end, the next nmsh launch starts the current service.'; + export class SessionConnectError extends Error { constructor(message: string, readonly code: string) { super(message); } } @@ -115,9 +151,10 @@ export class SocketSessionClient extends EventEmitter imple static async connect(options: SocketConnectOptions): Promise { const first: ClientMessage = options.attach ? {type: 'attach', sessionId: options.attach, columns: options.columns, rows: options.rows} - : {type: 'create', cwd: options.cwd, env: options.env, columns: options.columns, rows: options.rows}; - const {value, socket, decoder, rest} = await request(options.socketPath, options.timeoutMs ?? 5000, first, message => { - if (message.type === 'created') return {sessionId: message.sessionId, pid: message.pid, cwd: options.cwd, fullscreen: 0, ackedSeq: 0}; + : {type: 'create', cwd: options.cwd, env: options.env, columns: options.columns, rows: options.rows, ...(options.shell ? {shell: options.shell} : {})}; + const {value, socket, decoder, rest, welcome} = await request(options.socketPath, options.timeoutMs ?? 5000, first, message => { + // An older service ignores the shell request and reports none: that is zsh. + if (message.type === 'created') return {sessionId: message.sessionId, pid: message.pid, cwd: options.cwd, fullscreen: 0, ackedSeq: 0, shell: message.shell ?? 'zsh'}; if (message.type === 'attached') { const {type: _type, ...attached} = message; return attached; @@ -125,6 +162,10 @@ export class SocketSessionClient extends EventEmitter imple return undefined; }); const client = new SocketSessionClient(socket, value.sessionId, value.pid); + client.shell = isShellId(value.shell) ? value.shell : 'zsh'; + // Known at connect time: an older service advertises no features. + client.features = parseFeatures(welcome?.features); + client.serviceBuild = welcome?.build; if (options.attach) client.attachedSession = value; client.listen(decoder, rest); return client; @@ -152,10 +193,37 @@ export class SocketSessionClient extends EventEmitter imple this.socket.resume(); } + /** Backend currently running in this session. */ + shell: ShellId = 'zsh'; + /** Optional capabilities the connected service advertised. */ + features: ReadonlySet = new Set(); + serviceBuild?: string; + private pendingSwitch?: {resolve: (value: {shell: ShellId; pid: number}) => void; reject: (error: Error) => void}; + + switchShell(shell: ShellId, cwd: string): Promise<{shell: ShellId; pid: number}> { + // Never send a message the service did not advertise. + if (!this.features.has('shell-switch')) return Promise.reject(new SessionConnectError(OLDER_SERVICE_SWITCH, 'unsupported')); + if (this.pendingSwitch) return Promise.reject(new Error('A shell switch is already in progress.')); + return new Promise((resolve, reject) => { + this.pendingSwitch = {resolve, reject}; + this.send({type: 'switch-shell', shell, cwd}); + }); + } + private receive(message: ServerMessage): void { + if (message.type === 'shell-switched' || (message.type === 'error' && this.pendingSwitch)) { + const pending = this.pendingSwitch; + this.pendingSwitch = undefined; + if (message.type === 'shell-switched' && isShellId(message.shell)) { this.shell = message.shell; pending?.resolve({shell: message.shell, pid: message.pid}); } + else pending?.reject(new SessionConnectError(message.type === 'error' ? message.message : 'unexpected shell reply', message.type === 'error' ? message.code : 'protocol')); + return; + } 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 === 'exec') this.emit('exec', message.command, {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); @@ -166,6 +234,8 @@ export class SocketSessionClient extends EventEmitter imple private finish(exitCode: number, signal?: number, lost = false): void { if (this.exited) return; this.exited = true; + this.pendingSwitch?.reject(new Error('The session ended.')); + this.pendingSwitch = undefined; this.emit('exit', {exitCode, ...(signal === undefined ? {} : {signal}), ...(lost ? {lost} : {})}); } @@ -174,7 +244,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/StartupPicker.ts b/src/session/StartupPicker.ts index 24878727..9dc092b0 100644 --- a/src/session/StartupPicker.ts +++ b/src/session/StartupPicker.ts @@ -2,8 +2,10 @@ import {KeyDecoder, type Key} from '../terminal/keys.js'; import type {SessionInfo} from './SessionProtocol.js'; import {formatAge, tildePath} from './sessionList.js'; -// Launch-time restore screens, shown before any session is attached. Neither -// has a destructive key: killing a live session stays a confirmed /resume action. +// Launch-time restore screens, shown before any session is attached. Each has +// one destructive key, X, which only opens a confirmation; the kill itself is +// the same confirmed termination path /resume uses (killAndArchive), and the +// transcript is archived, never discarded. const clipTo = (columns: number) => (text: string) => (text.length > columns - 1 ? `${text.slice(0, Math.max(0, columns - 2))}…` : text); const activity = (session: SessionInfo) => session.running?.replace(/\s+/gu, ' ').slice(0, 60) ?? 'zsh'; @@ -12,7 +14,7 @@ const age = (session: SessionInfo, now: number) => formatAge(now - (session.runn // ── One detached session ───────────────────────────────────────────────────── /** resume: attach · not-now: fresh session, this launch only · always/never: also persist the startup setting. */ -export type SinglePromptChoice = 'resume' | 'not-now' | 'always' | 'never'; +export type SinglePromptChoice = 'resume' | 'not-now' | 'always' | 'never' | 'kill'; export function renderSinglePrompt(session: SessionInfo, columns: number, now: number): string[] { const clip = clipTo(columns); @@ -21,6 +23,7 @@ export function renderSinglePrompt(session: SessionInfo, columns: number, now: n clip(` ${session.running ? `running ${activity(session)}` : 'idle at the prompt'} · ${age(session, now)}`), clip(` started ${formatAge(now - session.createdAt)} ago`), '', ' R Resume', ' N Not now (start a new session; it keeps running)', + ' X Kill session (ends the shell; transcript stays in /resume)', ' A Always resume', ' D Don\'t resume at startup (it keeps running)', '', clip('Enter resume · Esc not now · sessions stay in /resume')]; } @@ -29,7 +32,27 @@ export function singlePromptKey(key: Key): SinglePromptChoice | undefined { if (key.kind === 'enter') return 'resume'; if (key.kind === 'escape' || key.kind === 'interrupt') return 'not-now'; if (key.kind !== 'text') return undefined; - return ({r: 'resume', n: 'not-now', a: 'always', d: 'never'} as const)[key.value.toLowerCase() as 'r' | 'n' | 'a' | 'd']; + return ({r: 'resume', n: 'not-now', a: 'always', d: 'never', x: 'kill'} as const)[key.value.toLowerCase() as 'r' | 'n' | 'a' | 'd' | 'x']; +} + +// ── Kill confirmation (shared by both screens) ────────────────────────────── + +export function renderKillConfirm(session: SessionInfo, columns: number, now: number): string[] { + const clip = clipTo(columns); + return ['Kill detached session?', '', + clip(` ${tildePath(session.cwd)}`), + clip(` ${session.running ? `running ${activity(session)}` : 'idle at the prompt'} · ${age(session, now)}`), '', + ' This ends the live shell.', ' Its transcript will remain available in /resume.', '', + clip('Enter/Y kill · Esc/N cancel')]; +} + +/** Enter/Y confirm, Esc/N/Ctrl+C cancel; anything else is ignored. */ +export function killConfirmKey(key: Key): 'confirm' | 'cancel' | undefined { + if (key.kind === 'enter') return 'confirm'; + if (key.kind === 'escape' || key.kind === 'interrupt') return 'cancel'; + if (key.kind !== 'text') return undefined; + const value = key.value.toLowerCase(); + return value === 'y' ? 'confirm' : value === 'n' ? 'cancel' : undefined; } // ── Several detached sessions ──────────────────────────────────────────────── @@ -38,8 +61,15 @@ export interface MultiPickerState { sessions: SessionInfo[]; cursor: number; selected: Set; + /** The session a kill confirmation is open for. */ + confirming?: string; + /** One factual line about the last kill, shown under the list. */ + message?: string; } +/** Returned by multiPickerKey once a kill is confirmed; the caller performs it and re-enters the picker. */ +export interface KillRequest {kill: string} + export function createMultiPicker(sessions: SessionInfo[]): MultiPickerState { return {sessions, cursor: 0, selected: new Set()}; } @@ -48,21 +78,31 @@ export function renderMultiPicker(state: MultiPickerState, columns: number, now: const clip = clipTo(columns); const width = Math.min(32, Math.max(...state.sessions.map(session => tildePath(session.cwd).length), 4)); const commandWidth = Math.min(20, Math.max(...state.sessions.map(session => activity(session).length), 3)); - const rows = [`NMSh · ${state.sessions.length} detached live sessions`, '']; + const confirming = state.sessions.find(session => session.id === state.confirming); + if (confirming) return renderKillConfirm(confirming, columns, now); + const rows = [`NMSh · ${state.sessions.length} detached live session${state.sessions.length === 1 ? '' : 's'}`, '']; state.sessions.forEach((session, index) => { const mark = state.selected.has(session.id) ? '[x]' : '[ ]'; rows.push(clip(`${index === state.cursor ? '›' : ' '} ${mark} ${tildePath(session.cwd).padEnd(width)} ${activity(session).padEnd(commandWidth)} ${age(session, now)}`)); }); const count = state.selected.size; - rows.push('', clip(`↑↓ move · Space select · A all · Enter resume ${count === 0 ? 'none' : count} · Esc none`), - clip('Unselected sessions keep running and stay in /resume.')); + rows.push('', clip(`↑↓ move · Space select · A all · X kill · Enter resume ${count === 0 ? 'none' : count} · Esc none`), + clip('Unselected sessions keep running and stay in /resume.'), ...(state.message ? [clip(state.message)] : [])); return rows; } /** Apply one key; returns the ids to resume (in list order) once the user is done, [] for none. */ -export function multiPickerKey(state: MultiPickerState, key: Key): string[] | undefined { +export function multiPickerKey(state: MultiPickerState, key: Key): string[] | KillRequest | undefined { const count = state.sessions.length; - if (key.kind === 'up') state.cursor = (state.cursor - 1 + count) % count; + if (state.confirming) { + const decision = killConfirmKey(key); + const id = state.confirming; + if (decision) state.confirming = undefined; + return decision === 'confirm' ? {kill: id} : undefined; + } + state.message = undefined; + if (key.kind === 'text' && key.value.toLowerCase() === 'x') state.confirming = state.sessions[state.cursor]?.id; + else if (key.kind === 'up') state.cursor = (state.cursor - 1 + count) % count; else if (key.kind === 'down') state.cursor = (state.cursor + 1) % count; else if (key.kind === 'escape' || key.kind === 'interrupt') return []; else if (key.kind === 'enter') return state.sessions.filter(session => state.selected.has(session.id)).map(session => session.id); @@ -77,6 +117,61 @@ export function multiPickerKey(state: MultiPickerState, key: Key): string[] | un return undefined; } +/** Drop a session (killed or already gone): selection and cursor stay consistent and in bounds. */ +export function removeFromMultiPicker(state: MultiPickerState, id: string): void { + state.sessions = state.sessions.filter(session => session.id !== id); + state.selected.delete(id); + if (state.confirming === id) state.confirming = undefined; + state.cursor = Math.max(0, Math.min(state.cursor, state.sessions.length - 1)); +} + +export type KillResult = 'killed' | 'gone' | 'attached'; + +/** + * The multi-session screen with in-place kill. `run` shows one screen until + * a result; `kill` re-checks service truth and performs the real termination. + * Resolves with the ids to resume, [] for none or when no session is left + * (the caller then starts a fresh session). + */ +export async function pickWithKill(sessions: SessionInfo[], io: { + run: (render: (columns: number) => string[], onKey: (key: Key) => T | undefined) => Promise; + kill: (session: SessionInfo) => Promise; + now?: () => number; +}): Promise { + const state = createMultiPicker(sessions); + const now = io.now ?? Date.now; + for (;;) { + const result = await io.run(columns => renderMultiPicker(state, columns, now()), key => multiPickerKey(state, key)); + if (Array.isArray(result)) return result; + const target = state.sessions.find(session => session.id === result.kill); + if (!target) continue; + let outcome: KillResult; + try { outcome = await io.kill(target); } + catch (error) { state.message = `Could not end that session: ${error instanceof Error ? error.message : String(error)}`; continue; } + removeFromMultiPicker(state, target.id); + state.message = outcome === 'killed' ? 'Session ended; its transcript is in /resume.' + : outcome === 'gone' ? 'That session had already ended; it is in /resume.' : 'That session is attached in another window and was not touched.'; + if (state.sessions.length === 0) return []; + } +} + +/** The one-session prompt with in-place kill; confirmation cancels back to the prompt. */ +export async function askWithKill(session: SessionInfo, io: { + run: (render: (columns: number) => string[], onKey: (key: Key) => T | undefined) => Promise; + kill: (session: SessionInfo) => Promise; + now?: () => number; +}): Promise { + const now = io.now ?? Date.now; + for (;;) { + const choice = await io.run(columns => renderSinglePrompt(session, columns, now()), singlePromptKey); + if (choice !== 'kill') return choice; + const decision = await io.run<'confirm' | 'cancel'>(columns => renderKillConfirm(session, columns, now()), killConfirmKey); + if (decision === 'cancel') continue; + try { await io.kill(session); } catch { continue; } + return 'kill'; + } +} + // ── Terminal runner ───────────────────────────────────────────────────────── /** Run a full-screen prompt on the real terminal until `onKey` returns a result. */ diff --git a/src/session/StreamBacklog.ts b/src/session/StreamBacklog.ts index f02e8a49..216065e3 100644 --- a/src/session/StreamBacklog.ts +++ b/src/session/StreamBacklog.ts @@ -4,8 +4,8 @@ import {dirname} from 'node:path'; /** One shell stream event as retained for replay. */ export type BacklogEvent = | {kind: 'output'; seq: number; at: number; data: string} - | {kind: 'exec'; seq: number; at: number; command: string} - | {kind: 'prompt'; seq: number; at: number; exitCode: number; cwd: string}; + | {kind: 'exec'; seq: number; at: number; command: string; historyAllowed?: number} + | {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 @@ -16,14 +16,19 @@ type SpoolRecord = BacklogEvent export interface BacklogLimits { /** Unacknowledged bytes kept in memory before spilling to the spool file. */ memoryBytes: number; - /** Spool size after which output payloads are dropped (with a counted marker). */ + /** + * Output bytes in the spool after which output payloads are dropped (with a + * counted marker). Command boundaries and prompt metadata are always kept + * and do not consume this budget, so a large alias/function list cannot make + * in-limit output look truncated. + */ spoolBytes: number; } 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 +38,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'); @@ -84,7 +90,7 @@ export class StreamBacklog { private memory: BacklogEvent[] = []; private memoryBytes = 0; private spooled = false; - private spoolSize = 0; + private spoolOutput = 0; private acked = 0; private journal?: string; private truncated = 0; @@ -145,18 +151,18 @@ export class StreamBacklog { // The runtime directory is private (0700); the spool directory is too. mkdirSync(dirname(this.spoolPath), {recursive: true, mode: 0o700}); this.spooled = true; - this.spoolSize = 0; + this.spoolOutput = 0; if (this.acked > 0) records.push({kind: 'ack', seq: this.acked, journalId: this.journal ?? ''}); } let dropped = 0; for (const event of this.memory) { - if (event.kind === 'output' && this.spoolSize + event.data.length > this.limits.spoolBytes) { + if (event.kind === 'output' && this.spoolOutput + event.data.length > this.limits.spoolBytes) { dropped += event.data.length; continue; } if (dropped > 0) { records.push({kind: 'truncated', bytes: dropped}); this.truncated += dropped; dropped = 0; } records.push(event); - this.spoolSize += eventBytes(event); + if (event.kind === 'output') this.spoolOutput += event.data.length; this.lastSpooledSeq = event.seq; } if (dropped > 0) { records.push({kind: 'truncated', bytes: dropped}); this.truncated += dropped; } @@ -174,7 +180,7 @@ export class StreamBacklog { private removeSpool(): void { try { unlinkSync(this.spoolPath); } catch { /* already gone */ } this.spooled = false; - this.spoolSize = 0; + this.spoolOutput = 0; } /** Bytes currently on disk; for tests and diagnostics. */ 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/connectSession.ts b/src/session/connectSession.ts index cbe97e83..c0010f0c 100644 --- a/src/session/connectSession.ts +++ b/src/session/connectSession.ts @@ -2,7 +2,8 @@ import {spawn} from 'node:child_process'; import {extname} from 'node:path'; import {fileURLToPath} from 'node:url'; import {InProcessSessionClient} from './InProcessSessionClient.js'; -import {SocketSessionClient, listSessions} from './SocketSessionClient.js'; +import {SocketSessionClient, dismissNotice, listSessions, listSessionsWithNotices} from './SocketSessionClient.js'; +import type {SessionNotice} from './SessionNotices.js'; import type {SessionInfo} from './SessionProtocol.js'; import {TranscriptStore, type TranscriptSession} from '../sessions/TranscriptStore.js'; import {type SessionConnection, type SessionOptions} from './SessionClient.js'; @@ -43,7 +44,7 @@ export async function attachSession(sessionId: string, options: ConnectSessionOp if (loaded.live?.sessionId === attached.sessionId) journal = loaded; } catch { /* fall through */ } } - return {client, mode: 'service', sessionId: client.sessionId, attached, ...(journal ? {journal} : {})}; + return {client, mode: 'service', sessionId: client.sessionId, attached, shell: client.shell, ...(journal ? {journal} : {})}; } catch (error) { const code = (error as {code?: string}).code ?? 'error'; const reason = code === 'ENOENT' || code === 'ECONNREFUSED' ? 'no session service is running' @@ -97,8 +98,8 @@ const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); */ export async function connectSession(options: ConnectSessionOptions): Promise { const env = options.env ?? process.env; - const sessionOptions = {cwd: options.cwd, columns: options.columns, rows: options.rows}; - if (env[SESSION_SERVICE_ENV] === '0') return {client: new InProcessSessionClient(sessionOptions), mode: 'in-process'}; + const sessionOptions = {cwd: options.cwd, columns: options.columns, rows: options.rows, ...(options.shell ? {shell: options.shell} : {})}; + if (env[SESSION_SERVICE_ENV] === '0') return {client: new InProcessSessionClient(sessionOptions), mode: 'in-process', shell: options.shell ?? 'zsh'}; try { const runtimeDir = options.runtimeDir ?? defaultRuntimeDir(env); ensurePrivateRuntimeDir(runtimeDir); @@ -110,7 +111,7 @@ export async function connectSession(options: ConnectSessionOptions): Promise { + const runtimeDir = options.runtimeDir ?? defaultRuntimeDir(options.env ?? process.env); + try { + return await listSessionsWithNotices(socketPathFor(runtimeDir), 1500); + } catch { + return {sessions: [], ended: []}; + } +} + +/** Best effort: clear a session's notice in every frontend. */ +export async function dismissSessionNotice(sessionId: string, options: {env?: NodeJS.ProcessEnv; runtimeDir?: string} = {}): Promise { + const runtimeDir = options.runtimeDir ?? defaultRuntimeDir(options.env ?? process.env); + try { await dismissNotice(socketPathFor(runtimeDir), sessionId, 1500); } catch { /* older service or none: nothing to clear */ } +} diff --git a/src/session/liveSessions.ts b/src/session/liveSessions.ts index a6911037..9b8b2507 100644 --- a/src/session/liveSessions.ts +++ b/src/session/liveSessions.ts @@ -34,6 +34,8 @@ export interface KillOptions { runtimeDir?: string; store?: TranscriptStore; env?: NodeJS.ProcessEnv; + /** Where the kill was requested, for the archive note. */ + origin?: string; } /** @@ -47,7 +49,7 @@ export async function killAndArchive(session: SessionInfo, options: KillOptions // session concurrently now that the service no longer lists it. const outcome = await finalizeLiveSession({store: options.store ?? new TranscriptStore(), runtimeDir, sessionId: session.id, ...(session.journalId ? {journalId: session.journalId} : {}), cwd: session.cwd, waitMs: 10_000, - note: () => 'Session killed from /resume; its shell has ended.'}); + note: () => `Session killed from ${options.origin ?? '/resume'}; its shell has ended.`}); if (outcome.kind === 'claimed-elsewhere') throw new Error('Another NMSh window is archiving this session.'); return outcome.session; } 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/session/sessionList.ts b/src/session/sessionList.ts index c86152c4..e06c79de 100644 --- a/src/session/sessionList.ts +++ b/src/session/sessionList.ts @@ -23,9 +23,12 @@ export function formatBytes(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } -/** Plain factual listing for `nmsh --sessions`: one live session per line. */ +/** + * Plain factual listing for `nmsh --sessions`: one live session per line. The + * same facts /sessions shows (backend, state); the CLI keeps full ids for --attach. + */ export function formatSessionList(sessions: readonly SessionInfo[], now: number): string { if (sessions.length === 0) return 'No live NMSh sessions.\n'; return sessions.map(session => [session.id, session.state, `pid ${session.pid}`, `age ${formatAge(now - session.createdAt)}`, - session.cwd, liveStatusParts(session, now).join(' · ')].join(' ')).join('\n') + '\n'; + session.cwd, liveStatusParts(session, now).join(' · '), `shell ${session.shell ?? 'zsh'}`].join(' ')).join('\n') + '\n'; } diff --git a/src/session/signatures.ts b/src/session/signatures.ts new file mode 100644 index 00000000..8b027fc5 --- /dev/null +++ b/src/session/signatures.ts @@ -0,0 +1,58 @@ +import type {RgbColor} from '../ui/palette.js'; + +/** + * Session signatures: a short familiar word and a subtle accent, so a + * person can tell sessions apart without reading ids. The vocabulary is small + * and plain (seasons, simple colors, fruits, vegetables). The name is the + * primary cue; the color is secondary and never the only one. Internally a + * session is still identified by its real id. + */ +export interface Signature {name: string; accent: RgbColor} + +const rgb = (hex: string): RgbColor => ({red: parseInt(hex.slice(1, 3), 16), green: parseInt(hex.slice(3, 5), 16), blue: parseInt(hex.slice(5, 7), 16)}); + +/** Accents avoid the pure success green / failure red / warning amber so they never read as status. */ +export const SIGNATURES: readonly Signature[] = [ + {name: 'Spring', accent: rgb('#9ad7a8')}, {name: 'Summer', accent: rgb('#f2c46d')}, {name: 'Autumn', accent: rgb('#d99a62')}, {name: 'Winter', accent: rgb('#9fb8d9')}, + {name: 'Blue', accent: rgb('#7aa2e8')}, {name: 'Green', accent: rgb('#7fc4a0')}, {name: 'Violet', accent: rgb('#b49ae8')}, {name: 'Teal', accent: rgb('#6cc5c0')}, + {name: 'Mango', accent: rgb('#f0b35a')}, {name: 'Peach', accent: rgb('#f2a88a')}, {name: 'Plum', accent: rgb('#b882c9')}, {name: 'Apple', accent: rgb('#e88f8f')}, + {name: 'Lemon', accent: rgb('#e6d873')}, {name: 'Cherry', accent: rgb('#d97a9a')}, {name: 'Kiwi', accent: rgb('#a8c96a')}, {name: 'Fig', accent: rgb('#a48bb5')}, + {name: 'Carrot', accent: rgb('#e89a5a')}, {name: 'Olive', accent: rgb('#a3a86a')}, {name: 'Pepper', accent: rgb('#d9866e')}, {name: 'Basil', accent: rgb('#86b98a')}, + {name: 'Radish', accent: rgb('#d98aa6')}, {name: 'Pea', accent: rgb('#9fcf8a')}, {name: 'Leek', accent: rgb('#b5cf9a')}, {name: 'Beet', accent: rgb('#b8718f')}, +]; + +function hash(text: string): number { + let value = 2166136261; + for (let index = 0; index < text.length; index += 1) { value ^= text.charCodeAt(index); value = Math.imul(value, 16777619); } + return value >>> 0; +} + +/** + * A name for a new session: deterministic from its id, the first one not in + * use by another live session. Only when every familiar name is taken does a + * small suffix appear ("Mango 2"), never a random tag. + */ +export function assignSignature(id: string, taken: Iterable): string { + const used = new Set(taken); + const start = hash(id) % SIGNATURES.length; + for (let offset = 0; offset < SIGNATURES.length; offset += 1) { + const name = SIGNATURES[(start + offset) % SIGNATURES.length]!.name; + if (!used.has(name)) return name; + } + for (let suffix = 2; ; suffix += 1) { + const name = `${SIGNATURES[start]!.name} ${suffix}`; + if (!used.has(name)) return name; + } +} + +/** The accent for a signature name ("Mango 2" uses Mango's), undefined for names outside the vocabulary. */ +export function signatureAccent(name: string | undefined): RgbColor | undefined { + if (!name) return undefined; + const base = name.replace(/ \d+$/u, ''); + return SIGNATURES.find(item => item.name === base)?.accent; +} + +/** What to show for a session: the person's own name if they renamed it, else its signature. */ +export function sessionDisplayName(session: {name?: string; signature?: string}, fallback: string): string { + return session.name || session.signature || fallback; +} diff --git a/src/session/startupRestore.ts b/src/session/startupRestore.ts index 99999ee1..a5a7e2f1 100644 --- a/src/session/startupRestore.ts +++ b/src/session/startupRestore.ts @@ -40,6 +40,7 @@ export async function restoreAtStartup(live: readonly SessionInfo[], deps: Start if (choice === 'always') deps.saveStartup('always'); if (choice === 'never') deps.saveStartup('never'); if (choice === 'resume' || choice === 'always') ids = [plan.session.id]; + // 'kill' was confirmed and performed by the prompt; the session is archived and this launch starts fresh. } const [target, ...others] = ids; if (others.length === 0) return target ? {target} : {}; diff --git a/src/sessionService.ts b/src/sessionService.ts index 01789f60..b2185892 100644 --- a/src/sessionService.ts +++ b/src/sessionService.ts @@ -2,6 +2,7 @@ // frontend; exits by itself once no sessions or frontends remain. import {DEFAULT_MAX_SESSIONS, runSessionService} from './session/SessionService.js'; import {defaultRuntimeDir} from './session/runtimeDir.js'; +import {readBuildIdentity} from './buildInfo.js'; import {DEFAULT_BACKLOG_LIMITS} from './session/StreamBacklog.js'; // Test/diagnostic overrides for the detached-output retention limits. @@ -14,7 +15,7 @@ try { await runSessionService({runtimeDir: defaultRuntimeDir(), backlogLimits: { memoryBytes: limit('NMSH_BACKLOG_MEMORY_BYTES', DEFAULT_BACKLOG_LIMITS.memoryBytes), spoolBytes: limit('NMSH_BACKLOG_SPOOL_BYTES', DEFAULT_BACKLOG_LIMITS.spoolBytes), - }, maxSessions: limit('NMSH_MAX_SESSIONS', DEFAULT_MAX_SESSIONS)}); + }, maxSessions: limit('NMSH_MAX_SESSIONS', DEFAULT_MAX_SESSIONS), build: (() => { try { const id = readBuildIdentity(); return `${id.version} ${id.commit}`; } catch { return undefined; } })()}); process.exit(0); } catch { process.exit(1); diff --git a/src/sessions/LiveSessionView.ts b/src/sessions/LiveSessionView.ts new file mode 100644 index 00000000..b68f4fb2 --- /dev/null +++ b/src/sessions/LiveSessionView.ts @@ -0,0 +1,39 @@ +import type {SessionInfo} from '../session/SessionProtocol.js'; +import {formatAge, tildePath} from '../session/sessionList.js'; +import {isShellId} from '../shell/adapters/ShellAdapter.js'; +import {liveRowAgent, liveRowState, LIVE_ROW_LABELS, type LiveRowState} from './ResumeBrowser.js'; +import type {AgentDescriptor} from '../agents/agents.js'; + +/** + * One factual model of a live session, shared by `/sessions` and + * `nmsh --sessions`: ordinal (by start time, as session notices number them), + * whether it is this window's session, backend, state word, proven agent, + * where, what and how long. + */ +export interface LiveSessionRow { + session: SessionInfo; + ordinal: number; + current: boolean; + shell: string; + state: LiveRowState; + stateLabel: string; + agent?: AgentDescriptor; + summary: string; +} + +const SHELL_LABELS: Record = {zsh: 'zsh', fish: 'Fish', bash: 'Bash'}; + +export function liveSessionRows(sessions: readonly SessionInfo[], currentId: string | undefined, now: number): LiveSessionRow[] { + return [...sessions].sort((a, b) => a.createdAt - b.createdAt).map((session, index) => { + const state = liveRowState(session, now); + const agent = liveRowAgent(session); + const what = session.running ? `running ${session.running.replace(/\s+/gu, ' ').slice(0, 48)} · ${formatAge(now - (session.runningSince ?? now))}` + : `idle${session.idleSince ? ` ${formatAge(now - session.idleSince)}` : ''}`; + const attachment = session.id === currentId ? 'this window' : session.state === 'attached' ? 'open in another window' : 'detached'; + const notice = session.notice ? ` · notice: ${session.notice.kind}` : ''; + return {session, ordinal: index + 1, current: session.id === currentId, + shell: isShellId(session.shell) ? SHELL_LABELS[session.shell]! : session.shell ? session.shell : 'zsh', + state, stateLabel: LIVE_ROW_LABELS[state], ...(agent ? {agent} : {}), + summary: `${tildePath(session.cwd)} · ${what} · ${attachment} · age ${formatAge(now - session.createdAt)}${notice}`}; + }); +} diff --git a/src/sessions/ResumeBrowser.ts b/src/sessions/ResumeBrowser.ts index f6242dea..beac63fc 100644 --- a/src/sessions/ResumeBrowser.ts +++ b/src/sessions/ResumeBrowser.ts @@ -1,7 +1,8 @@ import type {TranscriptSummary} from './TranscriptStore.js'; import type {SessionInfo} from '../session/SessionProtocol.js'; import {formatAge, tildePath} from '../session/sessionList.js'; -import {liveStatusParts} from '../session/liveStatus.js'; +import {ACTIVE_OUTPUT_MS, liveStatusParts} from '../session/liveStatus.js'; +import {detectAgentCommand, detectAgentProcess, type AgentDescriptor} from '../agents/agents.js'; export interface ResumeBrowserState { /** Live service sessions other than this frontend's own; listed first. */ @@ -14,6 +15,9 @@ export interface ResumeBrowserState { week: number; selectedIndex: number; indexing: boolean; + /** /sessions: live sessions only (archives are /resume's), including this window's own. */ + liveOnly?: boolean; + currentId?: string; } function weekStart(date: Date): number { @@ -38,8 +42,10 @@ export function createResumeBrowser(sessions: TranscriptSummary[], live: Session export function visibleLiveSessions(state: ResumeBrowserState): SessionInfo[] { const query = state.query.toLocaleLowerCase().trim(); - if (!query) return state.live; - return state.live.filter(session => `${session.cwd} ${session.running ?? ''}`.toLocaleLowerCase().includes(query)); + // /sessions lists in start order, so #N matches session notices. + const live = state.liveOnly ? [...state.live].sort((a, b) => a.createdAt - b.createdAt) : state.live; + if (!query) return live; + return live.filter(session => `${session.cwd} ${session.running ?? ''} ${session.shell ?? ''}`.toLocaleLowerCase().includes(query)); } export type ResumeSelection = @@ -68,6 +74,7 @@ export function describeLiveSession(session: SessionInfo, now: number): string { } export function visibleResumeSessions(state: ResumeBrowserState): TranscriptSummary[] { + if (state.liveOnly) return []; const query = state.query.toLocaleLowerCase().trim(); return state.sessions.filter(session => { if (!query) return weekStart(new Date(session.createdAt)) === state.week; @@ -106,3 +113,58 @@ export function resumeDayLabel(timestamp: string, now = new Date()): string { if (day === yesterday) return 'Yesterday'; return date.toLocaleDateString(); } + +/** + * The one-word state a live row leads with, strongest fact first. Only what + * the service reports: a pending attention request, recent output, a running + * command, or how the last command ended. "Awaiting input" is claimed only + * when the program itself asked for attention. + */ +export type LiveRowState = 'attention' | 'active' | 'running' | 'completed' | 'failed' | 'idle'; + +export function liveRowState(session: SessionInfo, now: number): LiveRowState { + if (session.running) { + if (session.attentionSince !== undefined) return 'attention'; + if (session.lastOutputAt !== undefined && now - session.lastOutputAt < ACTIVE_OUTPUT_MS) return 'active'; + return 'running'; + } + if (session.notice?.kind === 'completed') return 'completed'; + if (session.notice?.kind === 'failed' || (session.lastExit !== undefined && session.lastExit !== 0 && session.lastExit !== 130)) return 'failed'; + return 'idle'; +} + +export const LIVE_ROW_LABELS: Record = { + attention: 'Needs attention', active: 'Active', running: 'Running', completed: 'Completed', failed: 'Failed', idle: 'Idle', +}; + +/** A known agent, only when the command's program word or the foreground process proves it. */ +export function liveRowAgent(session: SessionInfo): AgentDescriptor | undefined { + return (session.running ? detectAgentCommand(session.running) : undefined) ?? detectAgentProcess(session.process); +} + +/** Session viewer row facts after the state badge: where, what, how long. */ +export function describeLiveRow(session: SessionInfo, now: number): string { + const where = tildePath(session.cwd); + const attached = session.state === 'attached' ? 'open in another window' : 'detached'; + const what = session.running + ? `${session.running.replace(/\s+/gu, ' ').slice(0, 48)} · ${formatAge(now - (session.runningSince ?? now))}` + : `idle${session.idleSince ? ` ${formatAge(now - session.idleSince)}` : ''}${session.lastExit !== undefined && session.lastExit !== 0 ? ` · last exit ${session.lastExit}` : ''}`; + const extra = session.title && session.running ? ` · “${session.title.slice(0, 32)}”` : ''; + const name = session.name || session.signature; + return `${name ? `${name} · ` : ''}${where} · ${what}${extra} · ${attached} · age ${formatAge(now - session.createdAt)}`; +} + +/** Archived row: duration when the journal recorded an end, otherwise its age. */ +export function describeArchivedRow(session: TranscriptSummary, now: number): string { + const ended = session.endedAt ? Date.parse(session.endedAt) : NaN; + const started = Date.parse(session.createdAt); + const span = Number.isFinite(ended) && Number.isFinite(started) ? `ran ${formatAge(Math.max(0, ended - started))}` : `${formatAge(Math.max(0, now - started))} ago`; + const interrupted = session.journaled && !session.endedAt ? ' · interrupted' : ''; + return `${session.project || 'notMyShell'} · ${tildePath(session.finalCwd)} · ${session.commandCount} command${session.commandCount === 1 ? '' : 's'} · ${span}${interrupted}`; +} + +/** /sessions: the existing browser in live-only mode, with this window's session included. */ +export function createSessionsView(live: SessionInfo[], currentId: string | undefined): ResumeBrowserState { + return {live, sessions: [], commandText: new Map(), query: '', week: 0, selectedIndex: 0, indexing: false, liveOnly: true, + ...(currentId ? {currentId} : {})}; +} diff --git a/src/sessions/TranscriptStore.ts b/src/sessions/TranscriptStore.ts index c08a0051..d915fb6a 100644 --- a/src/sessions/TranscriptStore.ts +++ b/src/sessions/TranscriptStore.ts @@ -42,7 +42,7 @@ export interface LiveLink { /** Last shell stream event reflected in this transcript. */ seq: number; /** The command in flight when this checkpoint was taken. */ - running?: {command: string; startedAt: number; cwd: string; startId: number; outputStartId: number}; + running?: {command: string; startedAt: number; cwd: string; startId: number; outputStartId: number; historyAllowed?: number}; } function parseLive(value: unknown): LiveLink | undefined { @@ -70,28 +70,33 @@ function isTranscript(value: unknown): value is OutputTranscript { if (!value || typeof value !== 'object') return false; const transcript = value as Partial; return (transcript.welcome === undefined || (typeof transcript.welcome.cwd === 'string' - && transcript.welcome.shell === 'zsh' + && (transcript.welcome.shell === 'zsh' || transcript.welcome.shell === 'fish' || transcript.welcome.shell === 'bash') && transcript.welcome.identity !== null && typeof transcript.welcome.identity === 'object' && typeof transcript.welcome.identity.version === 'string' && typeof transcript.welcome.identity.commit === 'string' && (transcript.welcome.identity.branch === undefined || typeof transcript.welcome.identity.branch === 'string') && (transcript.welcome.identity.dirty === undefined || typeof transcript.welcome.identity.dirty === 'boolean') + && (transcript.welcome.understanding === undefined || typeof transcript.welcome.understanding === 'string') && (transcript.welcome.provider === undefined || transcript.welcome.provider === 'fastfetch' || transcript.welcome.provider === 'neofetch') && (transcript.welcome.captured === undefined || (Array.isArray(transcript.welcome.captured) && transcript.welcome.captured.every(line => typeof line === 'string'))))) && Array.isArray(transcript.records) - && transcript.records.every(record => record && typeof record.command === 'string' + && transcript.records.every(record => record && (record.historyEligible === undefined || typeof record.historyEligible === 'boolean') + && (record.startedAt === undefined || Number.isFinite(record.startedAt)) + && (record.durationMs === undefined || (Number.isFinite(record.durationMs) && record.durationMs >= 0)) + && typeof record.command === 'string' && typeof record.output === 'string' && typeof record.lifecycleText === 'string' && typeof record.exitCode === 'number' && typeof record.startId === 'number' && typeof record.outputStartId === 'number' && (record.historicalContext === undefined || (typeof record.historicalContext.cwd === 'string' && (record.historicalContext.project === undefined || typeof record.historicalContext.project === 'string') - && (record.historicalContext.branch === undefined || typeof record.historicalContext.branch === 'string'))) + && (record.historicalContext.branch === undefined || typeof record.historicalContext.branch === 'string') + && (record.historicalContext.promptless === undefined || record.historicalContext.promptless === true))) && (record.historicalContext?.prompt === undefined || (typeof record.historicalContext.prompt === 'object' && record.historicalContext.prompt !== null - && ['nmsh', 'starship', 'powerlevel10k'].includes(record.historicalContext.prompt.provider) + && ['nmsh', 'starship', 'powerlevel10k', 'ohMyPosh'].includes(record.historicalContext.prompt.provider) && Array.isArray(record.historicalContext.prompt.segments) && (record.historicalContext.prompt.gapEnabled === undefined || typeof record.historicalContext.prompt.gapEnabled === 'boolean') && record.historicalContext.prompt.segments.every(segment => segment && typeof segment.text === 'string' diff --git a/src/sessions/archiveLive.ts b/src/sessions/archiveLive.ts index dd7d628f..d1caf824 100644 --- a/src/sessions/archiveLive.ts +++ b/src/sessions/archiveLive.ts @@ -35,19 +35,24 @@ export async function archiveLiveSession(options: ArchiveLiveOptions): Promise { if (!running) return; const record = output.complete(exitCode); + if (record) { + record.startedAt = running.startedAt; + record.durationMs = Math.max(0, at - running.startedAt); + record.historyEligible = running.historyAllowed === 1 && !/^\s/u.test(running.command); + } const parts = completedActivity(running.command, at - running.startedAt, new Date(at), interrupted ? 0 : exitCode, interrupted, extractFacts(running.command, record?.output ?? '')); output.setCompletionLifecycle(`${parts.main}${parts.detail}`); @@ -59,7 +64,7 @@ export async function archiveLiveSession(options: ArchiveLiveOptions): Promise = { + keep: 'Keep current setup', native: 'Native only', recommended: 'Recommended tools', + enhanced: 'Recommended + Enhanced', individual: 'Choose individually', +}; + +export interface SetupContext { + /** Detected provider/tool state by executable name; filled asynchronously, never on render. */ + statuses: Readonly>; + completion?: CompletionFacts; + /** + * The step's preview from the real renderers (prompt, theme and chrome, + * syntax, idle visuals, Vespyr...), supplied by the app for the current + * draft and shown below the pinned controls. + */ + preview?: readonly string[]; + /** The title as painted by the app (a one-pass light sweep); plain when absent. */ + title?: string; + /** Theme Bridge targets found on this system (local PATH facts), so Setup shows only relevant ones. */ + bridgeTargets?: readonly BridgeTargetId[]; +} + +export interface SetupRow { + row: SettingsRow; + /** For provider rows: the executable of the selected external provider, if any. */ + provider?: (draft: PromptConfiguration) => string | undefined; + /** A muted line under the row while it is selected. */ + note?: (draft: PromptConfiguration, context: SetupContext) => string | undefined; +} + +export interface SetupSection { + id: string; + title: string; + intro: readonly string[]; + rows: readonly SetupRow[]; + /** Rows that depend on the draft (the Native prompt style's own fields), appended after `rows`. */ + dynamicRows?: (draft: PromptConfiguration, context?: SetupContext) => readonly SetupRow[]; + /** Muted informational lines after the rows. */ + facts?: (draft: PromptConfiguration, context: SetupContext) => string[]; +} + +/** Direct entry names to the section they open. */ +export const SETUP_ENTRIES: Readonly> = { + prompt: 'prompt', appearance: 'appearance', chroma: 'appearance', tools: 'tools', editor: 'editor', syntax: 'editor', transcript: 'transcript', shell: 'shell', ask: 'ask', cursor: 'cursor', motion: 'motion', sessions: 'sessions', +}; + +const configRow = (id: string): SettingsRow => { + const row = SETTINGS_ROWS.find(item => item.id === id); + if (!row) throw new Error(`Setup Cat references unknown setting ${id}`); + return row; +}; + +/** Built in · no installation required, or the factual optional/install state of an external provider. */ +export function providerNote(descriptor: ProviderDescriptor | undefined, status: ProviderStatus | undefined): string | undefined { + if (!descriptor) return undefined; + if (descriptor.kind === 'native') return 'Built in · no installation required'; + if (descriptor.kind === 'none') return 'Off'; + const family = descriptor.family === 'tool' ? 'tool' : `${descriptor.family} provider`; + const lifecycle = lifecycleNote(descriptor); + const state = !status ? 'checking…' : status.state === 'installed' ? 'installed' + : 'not installed · NMSh keeps using Native until it is'; + return [`Optional external ${family}`, state, lifecycle].filter(Boolean).join(' · '); +} + +function providerRow(id: string, label: string, description: string, category: string, + providers: readonly ProviderDescriptor[], get: (config: PromptConfiguration) => Id, + set: (config: PromptConfiguration, value: Id) => PromptConfiguration): SetupRow { + return { + row: enumRow({id, label, description, category, values: providers.map(provider => provider.id), + labels: providers.map(provider => provider.label), get, set}), + note: (draft, context) => { + const descriptor = providers.find(provider => provider.id === get(draft)); + const note = providerNote(descriptor, descriptor?.executable ? context.statuses[descriptor.executable] : undefined); + const installable = descriptor?.executable && context.statuses[descriptor.executable]?.state === 'missing' + && TOOLS.some(tool => tool.executable === descriptor.executable); + return installable ? `${note} · I to install` : note; + }, + provider: draft => providers.find(provider => provider.id === get(draft) && provider.kind === 'external')?.executable, + }; +} + +/** Minimal and Breadcrumb draw a text separator; other styles keep their own geometry (custom glyphs live in /prompt). */ +const separatorStyle = (config: PromptConfiguration): 'minimal' | 'breadcrumb' | undefined => + config.nmsh.style === 'minimal' || config.nmsh.style === 'breadcrumb' ? config.nmsh.style : undefined; +const separatorIds = (config: PromptConfiguration): string[] => { + const style = separatorStyle(config); + return style ? (STYLE_PROFILE_OPTIONS[style].separator as readonly string[]).filter(id => id !== 'custom') : []; +}; +const SEPARATOR_ROW: SettingsRow = {id: 'setupSeparator', parent: 'promptStyle', when: config => separatorStyle(config) !== undefined, + label: 'Separator', description: 'Only styles that draw a text separator offer one; custom glyphs are typed in /prompt', category: 'Prompt', + control: 'enum', options: [], optionsFor: config => separatorIds(config).map(id => separatorLabel(id)), + index: config => { + const style = separatorStyle(config); + return style ? Math.max(0, separatorIds(config).indexOf(config.nmsh.styleProfiles[style].separator)) : 0; + }, + select: (config, index) => { + const style = separatorStyle(config); + if (!style) return config; + const id = separatorIds(config)[index] ?? separatorIds(config)[0]!; + return {...config, nmsh: {...config.nmsh, styleProfiles: {...config.nmsh.styleProfiles, + [style]: {...config.nmsh.styleProfiles[style], separator: id}}}}; + }}; + +/** Rows that only affect the Native prompt disappear while an external prompt provider is selected. */ +const nativeOnly = (row: SettingsRow): SettingsRow => ({...row, when: config => config.provider === 'nmsh' && (row.when?.(config) ?? true)}); + +const PROMPT_PROVIDER_ROW = providerRow('setupPromptProvider', 'Prompt provider', 'Native prompt, your existing Starship / Powerlevel10k, or None (composer only)', 'Prompt', + PROMPT_PROVIDERS, config => config.provider, (config, provider) => ({...config, provider})); + +/** + * An editor Setup does not embed: Enter applies this Setup (so nothing is + * lost), then opens the real editor. The row says so; it is a route, never a + * second configuration path. + */ +/** Setup's single Theme Bridge question; Yes only reveals the detected tools, each still Independent. */ +const THEME_BRIDGE_ROW: SettingsRow = {id: 'setupThemeBridge', label: 'Extend colors to tools?', description: 'Theme Bridge: opt-in colors for fzf, less/man, LS_COLORS, tmux, Neovim and Vim', + category: 'Appearance', control: 'enum', options: ['No', 'Yes'], index: c => c.themeBridge.enabled ? 1 : 0, + select: (c, index) => ({...c, themeBridge: {...c.themeBridge, enabled: index === 1}})}; + +const SETUP_BRIDGE_TARGETS: readonly BridgeTargetId[] = ['fzf', 'pager', 'lsColors', 'tmux', 'neovim', 'vim', 'helix']; + +function bridgeTargetRows(draft: PromptConfiguration, context: SetupContext | undefined): SetupRow[] { + if (!draft.themeBridge.enabled) return []; + const found = new Set(context?.bridgeTargets ?? []); + return SETUP_BRIDGE_TARGETS.filter(target => found.has(target)).map(target => ({ + row: {id: `setupBridge:${target}`, parent: 'setupThemeBridge', label: ` ${BRIDGE_TARGET_LABELS[target]}`, description: 'Independent, or follow the active NMSh theme', category: 'Appearance', + control: 'enum', options: ['Independent', 'Follow NMSh'], index: c => c.themeBridge.targets[target].mode === 'independent' ? 0 : c.themeBridge.targets[target].mode === 'follow' ? 1 : 1, + select: (c, index) => ({...c, themeBridge: {...c.themeBridge, targets: {...c.themeBridge.targets, [target]: {...c.themeBridge.targets[target], mode: index === 1 ? (c.themeBridge.targets[target].mode === 'choose' ? 'choose' : 'follow') : 'independent'}}}})}, + note: () => target === 'tmux' || target === 'neovim' || target === 'vim' || target === 'helix' + ? 'NMSh generates its own color file; adding it to your config is a separate, reviewed step in /theme-bridge' + : target === 'fzf' ? 'Only fzf launched by NMSh; FZF_DEFAULT_OPTS and your rc files are untouched' : 'Applied in NMSh shells at the next prompt; no rc file is edited', + } satisfies SetupRow)); +} + +function routeRow(id: string, label: string, description: string, destination: SettingsDestination, category: string): SetupRow { + return {row: {id, label, description, category, control: 'action', actionLabel: 'Open ›', destination}, + note: () => 'Enter applies this Setup first, then opens it; it keeps the choices you made here'}; +} + +const PROMPT_FIELDS_EXCLUDED = new Set(['themeFamily', 'themeVariant', 'themeAccent', 'themeStudio', 'style', 'vibrance', 'promptSymbol', 'promptSymbolCustom', 'modules']); + +/** + * A /prompt appearance row (it edits a configuration through `change`) as a + * Setup row, so the Native prompt style's own fields (edges, connector, gap, + * padding, fills, text colors, icons...) are edited by the very same logic, + * on the Setup draft. Options are found by cycling a copy, never by a second table. + */ +function adaptAppearanceRow(row: AppearanceRow): SettingsRow | undefined { + const change = row.change; + if (!change || row.edit || row.opens) return undefined; + const cycleOf = (config: PromptConfiguration): string[] => { + const probe = structuredClone(config); + const first = row.value(probe); + const seen = [first]; + for (let step = 0; step < 24; step += 1) { + change(probe, 1); + const value = row.value(probe); + if (value === first) break; + seen.push(value); + } + return seen; + }; + return {id: `prompt:${row.id}`, label: row.label.trim(), description: row.note ? 'The Native prompt style\'s own setting; the same field /prompt edits' : 'The Native prompt style\'s own setting; the same field /prompt edits', + category: 'Prompt', control: 'enum', options: [], optionsFor: cycleOf, index: () => 0, + select: (config, index) => { + const next = structuredClone(config); + for (let step = 0; step < index; step += 1) change(next, 1); + return next; + }}; +} + +const promptStyleRows = (draft: PromptConfiguration): SetupRow[] => draft.provider !== 'nmsh' ? [] : appearanceRows(draft) + .filter(row => !PROMPT_FIELDS_EXCLUDED.has(row.id) && !row.id.endsWith('.separator')) + .flatMap(row => { const adapted = adaptAppearanceRow(row); return adapted ? [{row: adapted, note: () => row.note?.(draft)}] : []; }); + +/** Every Setup Cat row id, so callers (and tests) can find any row in the one model. */ +export const SETUP_SECTIONS: readonly SetupSection[] = [ + {id: 'welcome', title: 'Start', intro: [ + 'Setup Cat walks through NMSh settings with you; Vespyr, the NMSh cat, says hello.', + NATIVE_FIRST_MESSAGE, + NATIVE_FIRST_DETAIL, + 'Your current choices are already selected. Nothing changes until you apply on the last step.', + ], rows: [ + {row: configRow('panelPosition'), note: draft => draft.panelPosition === 'top' ? 'Panels like this one sit at the top of the screen' : 'Panels like this one sit at the bottom of the screen'}, + ]}, + {id: 'terminal', title: 'Terminal', intro: ['Glyphs your terminal font can draw.'], rows: [ + {row: configRow('glyphStyle'), note: draft => draft.glyphStyle === 'nerd' ? 'Needs a Nerd Font in your terminal' : 'Works with any terminal font'}, + ]}, + // The same cursor rows as Settings; rich editing (the color picker, trail and particle colors, physics) is /cursor, opened over this draft. + {id: 'cursor', title: 'Cursor', intro: ['The text caret and its optional effects, Off by default. Portable effects are built in; Ghostty and Kitty can add a GPU version via /cursor.'], rows: [ + {row: configRow('cursorShape'), note: draft => draft.cursor.shape === 'host' ? 'Host default: your terminal keeps its own cursor' : 'Applied while NMSh owns the composer; full-screen programs get your normal cursor'}, + {row: configRow('cursorBlink')}, + {row: configRow('cursorRenderer'), note: draft => chooseBackend(draft.cursor, currentCursorHost()).reason}, + {row: configRow('cursorMotion')}, + {row: configRow('cursorEffect')}, + {row: configRow('cursorIdle')}, + {row: configRow('cursorColor'), note: draft => caretColorNote(currentCursorHost(), draft.cursor.color.source)}, + {row: configRow('cursorColorFamily')}, + {row: configRow('cursorColorVariant')}, + {row: configRow('cursorColorAccent')}, + {row: configRow('cursorColorCustom')}, + // Speed, intensity, trail length and particle amount, trail/particle colors and the physics live in /cursor, opened over this draft. + {row: configRow('cursorAdvanced'), note: () => 'Speed, intensity, trail and particles, trail/particle colors and physics; changes stay in this Setup draft until Apply'}, + ], facts: draft => [`Renderer in use: ${chooseBackend(draft.cursor, currentCursorHost()).reason}`]}, + {id: 'prompt', title: 'Prompt', intro: [NATIVE_PROMPT_RECOMMENDATION, 'Deep prompt customization lives in /prompt.'], rows: [ + {...PROMPT_PROVIDER_ROW, note: (draft, context) => draft.provider === 'nmsh' ? 'Built in · no installation required' + : draft.provider === 'none' ? 'None · composer only · themes still style NMSh UI, syntax and Theme Bridge' : `${PROMPT_PROVIDER_ROW.note!(draft, context)} · ${NATIVE_ONLY_NOTE}`}, + {row: nativeOnly(configRow('promptStyle'))}, + {row: nativeOnly(SEPARATOR_ROW)}, + {row: configRow('promptSymbol')}, + // The one canonical Chroma setting (also in Appearance, /prompt, /appearance and /chroma). + {row: configRow('treatmentPreset'), note: () => 'The same Chroma setting as Appearance and /chroma; P toggles the preview only'}, + routeRow('setupPromptModules', 'Prompt modules & custom glyphs', 'Which modules show and in what order, and your own separator or prompt glyph, in /prompt', 'prompt', 'Prompt'), + ], dynamicRows: draft => promptStyleRows(draft)}, + {id: 'appearance', title: 'Appearance', intro: [ + 'Theme: the base NMSh prompt/UI palette · Theme text: whether it colors NMSh text · UI chrome: frames, tabs, selection, separators, accents.', + 'Chroma: an optional treatment over the Native prompt/effects and opted-in surfaces; Full Chroma may override the prompt\'s theme colors. Your terminal and editor keep their own colors.', + ], rows: [ + {row: configRow('themeFamily'), note: () => 'Built-in, Imported and Custom themes; /theme creates, imports and edits them'}, + {row: configRow('themeVariant')}, + {row: configRow('themeAccent')}, + {row: configRow('themeText')}, + {row: configRow('lavenderText')}, + {row: configRow('lavenderSurface')}, + {row: configRow('promptVibrance')}, + {row: configRow('uiChrome')}, + {row: configRow('uiChromePreset')}, + {row: configRow('treatmentPreset'), note: () => `${CHROMA_SCOPE_NOTE} /chroma has every option`}, + {row: configRow('treatmentIntensity')}, + {row: configRow('treatmentSemantic')}, + {row: configRow('treatmentScope')}, + {row: configRow('chromaRules')}, + {row: configRow('treatmentGeometry')}, + {row: configRow('treatmentMotion')}, + {row: configRow('treatmentSpeed')}, + {row: configRow('treatmentCurve')}, + {row: configRow('reducedMotion')}, + {row: configRow('effectsOff')}, + {row: configRow('shimmer')}, + {row: configRow('autoEffects')}, + routeRow('setupChromeColors', 'Edit UI chrome colors', 'Accent, text, separator, selection and status roles with the color picker', 'chromeColors', 'Appearance'), + {...routeRow('setupThemeStudio', 'Theme Studio', 'Create, edit, import, export and manage Native themes; selection is above', 'themeStudio', 'Appearance'), + row: {id: 'setupThemeStudio', label: 'Theme Studio', description: 'Create, edit, import, export and manage Native themes; selection is above', category: 'Appearance', + control: 'action', actionLabel: 'Open ›', destination: 'themeStudio', value: draft => [librarySummary(draft.themes), 'Open ›'].filter(Boolean).join(' ')}}, + {row: THEME_BRIDGE_ROW, note: draft => draft.themeBridge.enabled + ? 'Only tools found on this system are listed; each starts Independent. Choose theme and includes for tmux/Neovim/Vim are in /theme-bridge' + : 'No: every tool keeps its own colors; NMSh injects and changes nothing'}, + routeRow('setupHostWindow', 'Terminal window (opacity, blur)', 'Host window opacity and blur where your terminal supports it', 'appearance', 'Appearance'), + ], dynamicRows: (draft, context) => bridgeTargetRows(draft, context)}, + // General NMSh motion: the same rows /appearance → Motion edits, with the same real previews. + {id: 'motion', title: 'Motion', intro: ['Short, finite presentations of real events. Each can be Off; Reduced Motion, Decorative Effects Off and NO_COLOR stop all of them.', + 'The preview below runs the selected one on sample content, once; it never touches your session.'], rows: [{row: configRow('motion_rendering')}, ...MOTION_ROWS.map(item => ({row: configRow(`motion_${item.key}`)})), {row: configRow('motion_intensity')}, {row: configRow('motion_speed')}]}, + {id: 'editor', title: 'Editor', intro: ['The composer, syntax colors and suggestions.'], rows: [ + {row: configRow('composerPosition')}, + {row: configRow('composerDividers')}, + {row: configRow('pastePreview')}, + {row: configRow('syntaxHighlighting')}, + {row: configRow('syntaxColors'), note: draft => draft.syntax.colors === 'followPrompt' ? 'Follows the Native prompt theme, even with Starship or Powerlevel10k' : draft.syntax.colors === 'grayscale' ? 'Brightness and weight only; no hue' : undefined}, + {row: configRow('syntaxThemeFamily')}, + {row: configRow('syntaxThemeVariant')}, + {row: configRow('syntaxThemeAccent')}, + providerRow('setupSuggestions', 'Suggestions', 'Ghost-text prediction', 'Suggestions', SUGGESTION_PROVIDERS, + config => config.suggestions, (config, suggestions) => ({...config, suggestions})), + {row: configRow('suggestionsOnEmpty')}, + routeRow('setupKeyboard', 'Keyboard bindings', 'Terminal key bindings, in /keyboard', 'keyboard', 'Keyboard'), + ], facts: (_draft, context) => completionFacts(context.completion)}, + // The same transcript rows as Settings and /transcript; one draft, one save path. + {id: 'transcript', title: 'Transcript', intro: ['How past commands look. Stored history is never changed; this is presentation only.'], rows: [ + {row: configRow('transcriptPresentation')}, + {row: configRow('historicalPrompt')}, + {row: configRow('historyColors')}, + {row: configRow('historyTheme')}, + {row: configRow('divider')}, + {row: configRow('dividerDensity')}, + {row: configRow('dividerColors'), note: () => 'Live composer dividers follow Appearance → Divider lines; past dividers never move'}, + {row: configRow('outputFolding')}, + ]}, + {id: 'history', title: 'History & navigation', intro: ['NMSh Native covers history, directory jumps and picking.', 'External providers are optional alternatives.'], rows: [ + providerRow('setupHistory', 'History', 'Command history source', 'History', HISTORY_PROVIDERS, + config => config.history, (config, history) => ({...config, history})), + {...providerRow('setupNavigation', 'Navigation', 'Directory navigation ranking', 'History', NAVIGATION_PROVIDERS, + config => config.navigation, (config, navigation) => ({...config, navigation})), + // zoxide provides its own `z` command; NMSh never defines one. + note: (draft, context) => draft.navigation === 'zoxide' + ? `${providerNote(NAVIGATION_PROVIDERS[1], context.statuses.zoxide)} · zoxide itself provides \`z\`` + : 'Built in · no installation required · use /dirs; NMSh does not define `z`'}, + providerRow('setupPicker', 'Picker', 'Interactive picker', 'History', PICKER_PROVIDERS, + config => config.picker, (config, picker) => ({...config, picker})), + ]}, + // The same shell rows as Settings: one configuration, one save path. + {id: 'shell', title: 'Shell', intro: ['NMSh runs over a real shell; its composer, transcript and settings stay the same on each.'], rows: [ + {row: configRow('shellBackend'), note: () => 'Default shell: the real shell NMSh starts underneath new sessions. /shell switches this one.'}, + {row: configRow('showShell'), note: () => 'Show current shell: the active backend always, only when it differs from the default, or never.'}, + ]}, + // Sessions, notices, notifications and update checks: the same rows as Settings. + {id: 'sessions', title: 'Sessions & alerts', intro: ['How live sessions restart, what other sessions tell you, and when NMSh checks for updates.', + 'Session notices are short events about another session (a finished command, a failure, a request for attention); /sessions keeps the state.'], rows: [ + {row: configRow('liveSessionStartup')}, + {row: configRow('liveSessionMultiple')}, + {row: configRow('sessionNotices'), note: draft => draft.sessionNotices ? 'Brief: a success fades in seconds, a failure lingers a little, a request for attention stays until you look' : 'Off: other sessions never add lines above the composer'}, + {row: configRow('agentActivity')}, + {row: configRow('openWith')}, + {row: configRow('notifications')}, + {row: configRow('notifyAfter')}, + {row: configRow('notifyOnSuccess')}, + {row: configRow('notifyOnFailure')}, + {row: configRow('notifyWhenFocused')}, + {row: configRow('updateMode')}, + {row: configRow('updateFrequency')}, + ]}, + // The same rows as Settings; nothing here implies NMSh needs a model. + {id: 'ask', title: 'Ask & local understanding', intro: ['/btw: ask NMSh what it can do in plain English. Ask works without a language model,', + 'and so does NMSh\'s normal Smart Folding. Local understanding defaults to Auto: built-in understanding answers first, and a local model is only consulted when it is unsure and one is set up.'], rows: [ + {row: configRow('askPresentation')}, + {row: configRow('askRecord'), note: draft => draft.askRecord ? 'Keep Ask conversations in transcripts' : 'Ask conversations are not saved; approved commands still are'}, + {row: configRow('localUnderstanding'), note: draft => localUnderstandingNote(draft)}, + {row: configRow('localUnderstandingAsk'), note: () => 'Improve Ask understanding'}, + {row: configRow('localUnderstandingFolding'), note: () => 'Improve Smart Folding'}, + ]}, + {id: 'welcomeScreen', title: 'Welcome', intro: ['What a new session shows first. Vespyr is the NMSh cat.'], rows: [ + providerRow('setupWelcome', 'Welcome', 'New-session welcome', 'Welcome', WELCOME_PROVIDERS.filter(provider => WELCOME_PROVIDER_IDS.includes(provider.id)), + config => config.welcome, (config, welcome) => ({...config, welcome})), + {row: configRow('statusStrip'), note: draft => draft.statusStrip.enabled ? 'Minimal: clock, plus battery only when this machine has one' : 'Off: no extra row'}, + {row: configRow('stripClock')}, + {row: configRow('stripBattery')}, + {row: configRow('stripCpu')}, + {row: configRow('stripRam')}, + {row: configRow('stripRamDisplay')}, + {row: configRow('stripUptime')}, + ]}, + {id: 'idle', title: 'Idle & activity', intro: ['An optional screensaver inside NMSh, only while it owns the terminal and nothing is running.', + 'Off by default (Never). Any key, mouse or new output ends it and leaves everything exactly as it was.'], rows: [ + {row: configRow('idleTimeout')}, + {row: configRow('idleMode')}, + {row: configRow('idleRunBusy')}, + {row: configRow('idleColor')}, + routeRow('setupIdleColors', 'Edit idle colors', 'The idle visuals\' own gradient stops, with a live preview', 'idleColors', 'Idle visuals'), + {row: configRow('activityColors'), note: () => 'The running-command line only; finished commands show their plain result'}, + routeRow('setupActivityColors', 'Edit live activity colors', 'Gradient stops for the live activity line', 'activityColors', 'Live activity'), + ]}, + {id: 'tools', title: 'Optional tools', intro: [NATIVE_FIRST_SHORT, INSTALL_DRAFT_NOTE], rows: [ + {row: configRow('toolUpdateChecks'), note: draft => draft.toolUpdateChecks === 'off' ? 'Off: NMSh never checks unless you ask in /tools' : 'Checks run in the background at startup, never while typing'}, + {row: configRow('installSuggestions')}, + {row: configRow('resetInstallSuggestions'), note: () => 'Resets in this draft; nothing changes until Apply'}, + routeRow('setupToolConfig', 'Tool configuration', 'Review supported Starship module changes', 'toolConfig', 'Tools'), + ], facts: (_draft, context) => completionFacts(context.completion)}, + {id: 'review', title: 'Review & Apply', intro: [], rows: []}, +]; + +/** Factual Local understanding copy per mode; nothing here implies a model is installed or downloads on its own. */ +function localUnderstandingNote(draft: PromptConfiguration): string { + const {mode, model} = draft.localUnderstanding; + if (mode === 'off') return 'Off: no local model is ever consulted, loaded or run'; + const setup = model ? `Model: ${model.label}` : 'No model is set up: built-in Ask works and Auto simply stays built-in; /llm can set one up, and nothing downloads without your Yes'; + return mode === 'auto' ? `Auto: built-in understanding first, a local model only when it is unsure. ${setup}` + : `Always: a local model is consulted first when one is set up. ${setup}`; +} + +/** + * Settings rows that Setup edits through a differently named row (its provider + * choices) or reaches through a labelled route. Anything else in SETTINGS_ROWS + * must appear in a Setup section under its own id: a test enforces it, so a new + * customization cannot silently become undiscoverable from Setup. + */ +export const SETUP_EQUIVALENTS: Readonly> = { + provider: 'setupPromptProvider', welcome: 'setupWelcome', suggestions: 'setupSuggestions', history: 'setupHistory', navigation: 'setupNavigation', picker: 'setupPicker', + cursorSpeed: 'cursorAdvanced', cursorIntensity: 'cursorAdvanced', cursorTrail: 'cursorAdvanced', cursorParticles: 'cursorAdvanced', + tools: 'setupToolChoice', themeStudio: 'setupThemeStudio', themeBridge: 'setupThemeBridge', uiChromeColors: 'setupChromeColors', idleCustomColors: 'setupIdleColors', activityCustomColors: 'setupActivityColors', +}; + +/** Where each Settings entry point (a full panel) is reached from Setup: a section, or the route row that opens it. */ +export const SETUP_ENTRY_COVERAGE: Readonly> = { + appearance: 'setupHostWindow', glyph: 'glyphStyle', prompt: 'setupPromptModules', transcript: 'transcriptPresentation', syntax: 'syntaxHighlighting', keyboard: 'setupKeyboard', + welcome: 'setupWelcome', suggestions: 'setupSuggestions', history: 'setupHistory', picker: 'setupPicker', navigation: 'setupNavigation', layout: 'composerPosition', toolConfig: 'setupToolConfig', tools: 'setupBrowseTools', screensaver: 'idleTimeout', setup: 'setupToolChoice', + cursor: 'cursorAdvanced', themeStudio: 'setupThemeStudio', themeBridge: 'setupThemeBridge', chromeColors: 'setupChromeColors', idleColors: 'setupIdleColors', activityColors: 'setupActivityColors', resetInstallSuggestions: 'resetInstallSuggestions', +}; + +function completionFacts(facts: CompletionFacts | undefined): string[] { + if (!facts) return ['Configured zsh completion Checking…']; + const line = (label: string, value: string) => `${padCells(label, 29)}${value}`; + return [ + line('Configured zsh completion', facts.completionSystem ? 'Detected' : 'Not detected · NMSh Native completion still works'), + line('zsh-completions', facts.zshCompletions ? 'Detected · used as completion knowledge' : 'Not detected'), + ...(facts.fzfTab ? [line('fzf-tab', 'Detected · NMSh keeps its own completion UI')] : []), + ]; +} + +export interface SetupState { + /** Initial discovery uses the same draft and marks completion only on Apply. */ + onboarding?: boolean; + section: number; + row: number; + draft: PromptConfiguration; + /** The configuration as saved when Setup Cat opened; Esc returns to exactly this. */ + saved: PromptConfiguration; + tools: ToolChoice; + /** Esc with unapplied edits asks first. */ + confirmDiscard?: boolean; + /** + * The optional-tool browser opened inside Setup Cat (the same /tools panel + * and installer). The draft and section are untouched while it is open. + */ + toolBrowser?: ToolsPanel; + /** + * The /cursor panel opened inside Setup Cat over the draft's cursor settings + * (advanced tuning, the custom color picker). It applies to the draft only; + * Esc returns here, and nothing is saved until Apply. + */ + cursorPanel?: CursorPanelState; + /** + * Local preview only (P on Prompt/Appearance): show the preview through the + * draft's Chroma. Off by default so the base theme colors are visible; it + * never changes the draft or the saved Chroma. + */ + previewChroma?: boolean; + /** The cursor preview restarts when the selected row or the draft's cursor settings change. */ + previewKey?: string; + previewStart?: number; + /** + * Enter on an option row lists every choice under it: ↑↓ preview each one + * live in the draft, Enter keeps it, Esc restores the value it had. + */ + chooser?: {rowId: string; index: number; before: PromptConfiguration}; + context: SetupContext; +} + +export function sectionIndex(id: string): number { + return Math.max(0, SETUP_SECTIONS.findIndex(section => section.id === id)); +} + +/** Opens Setup Cat over a copy of the current configuration; an entry name jumps to its section. */ +export function createSetup(configuration: PromptConfiguration, entry?: string): SetupState { + const target = entry ? SETUP_ENTRIES[entry] : undefined; + const section = target ? sectionIndex(target) : 0; + const rows = SETUP_SECTIONS[section]!.rows; + const row = entry === 'chroma' ? Math.max(0, rows.findIndex(item => item.row.id === 'treatmentPreset')) : 0; + return {section, row, draft: structuredClone(configuration), saved: structuredClone(configuration), tools: 'keep', context: {statuses: {}}}; +} + +export function parseSetupEntry(argument: string | undefined): string | undefined | false { + if (!argument) return undefined; + return SETUP_ENTRIES[argument] ? argument : false; +} + +function rowValue(row: SettingsRow, config: PromptConfiguration): string { + const value = settingsRowValue(row, config) ?? ''; + return row.control === 'boolean' ? (value === 'true' ? 'On' : 'Off') : value; +} + +/** Changes between the saved configuration and the draft, by Setup Cat row, for the review step. */ +export function setupChanges(state: SetupState): Array<{label: string; from: string; to: string}> { + const changes: Array<{label: string; from: string; to: string}> = []; + const seen = new Set(); + for (const section of SETUP_SECTIONS) { + for (const {row} of [...sectionRows(section, state.draft), ...sectionRows(section, state.saved)]) { + if (seen.has(row.id)) continue; + seen.add(row.id); + const from = rowValue(row, state.saved); + const to = rowValue(row, state.draft); + if (from !== to) changes.push({label: row.label, from, to}); + } + } + if (state.tools !== 'keep') changes.push({label: 'Optional tools', from: TOOL_CHOICE_LABELS.keep, to: TOOL_CHOICE_LABELS[state.tools]}); + // Edits made inside an embedded editor (advanced cursor tuning) that no row summarizes are still unapplied changes. + if (!changes.some(change => change.label.startsWith('Cursor')) && JSON.stringify(state.draft.cursor) !== JSON.stringify(state.saved.cursor)) { + changes.push({label: 'Advanced cursor tuning', from: 'saved', to: 'edited'}); + } + return changes; +} + +/** Native only puts every provider family back on Native in the draft (visible in review); it removes nothing. */ +function applyToolChoice(draft: PromptConfiguration, choice: ToolChoice): PromptConfiguration { + if (choice !== 'native') return draft; + return {...draft, provider: 'nmsh', suggestions: 'nmsh', history: 'native', navigation: 'native', picker: 'native', + welcome: draft.welcome === 'none' ? 'none' : 'vespyr'}; +} + +export type SetupResult = + | {kind: 'cancel'} + /** Open the shared tool browser inside Setup Cat, optionally on one tool. */ + | {kind: 'browseTools'; toolId?: string} + /** Open the shared /cursor panel inside Setup Cat, over the draft. */ + | {kind: 'cursorEditor'; advanced: boolean; row?: string} + /** `then`: an editor to open after Apply (a route row). */ + | {kind: 'apply'; configuration: PromptConfiguration; tools: ToolChoice; changed: boolean; then?: SettingsDestination}; + +/** Rows that apply to the draft (a child row disappears when its parent makes it meaningless). */ +function sectionRows(section: SetupSection, draft: PromptConfiguration, context?: SetupContext): readonly SetupRow[] { + return [...section.rows, ...(section.dynamicRows?.(draft, context) ?? [])]; +} + +function currentRows(state: SetupState): readonly SetupRow[] { + const rows = sectionRows(SETUP_SECTIONS[state.section]!, state.draft, state.context).filter(item => setupRowApplies(item.row, state.draft)); + return state.section === sectionIndex('tools') ? [...rows, TOOL_CHOICE_ROW, BROWSE_ROW] : rows; +} + +/** The selected row of the current step (undefined on steps without rows). */ +export function setupSelectedRow(state: SetupState): SetupRow | undefined { + return currentRows(state)[state.row]; +} + +function setupRowApplies(row: SettingsRow, config: PromptConfiguration): boolean { + if (row.when && !row.when(config)) return false; + return SETTINGS_ROWS.some(item => item.id === row.id) ? settingsRowApplies(row, config) : true; +} + +/** Rows that open an editor instead of changing a value (they are routes, or run on the draft). */ +export const isRouteRow = (row: SettingsRow): boolean => row.control === 'action' && row.destination !== 'tools' && row.destination !== 'cursor' && !row.run; + +/** Opens the shared tool browser in place; Enter on it never leaves the step. */ +const BROWSE_ROW: SetupRow = {row: {id: 'setupBrowseTools', label: 'Browse optional tools', + description: 'See what each tool does, whether it is installed, and install it with a previewed, confirmed command', + category: 'Tools', control: 'action', actionLabel: 'Open ›', destination: 'tools'}}; + +/** The tools tier choice is a Setup Cat action, not a stored setting; it rides on the same row model. */ +const TOOL_CHOICE_ROW: SetupRow = { + row: {id: 'setupToolChoice', label: 'Optional tools', description: 'What to do about optional tools after Apply', category: 'Tools', + control: 'enum', options: TOOL_CHOICES.map(choice => TOOL_CHOICE_LABELS[choice]), index: () => 0, select: config => config}, + note: () => undefined, +}; + +function toolChoiceNote(choice: ToolChoice): string { + switch (choice) { + case 'keep': return 'Installed tools and providers stay as they are'; + case 'native': return 'Every provider uses NMSh Native; nothing is uninstalled'; + case 'recommended': return 'Opens /tools on ripgrep, fd, fzf, zoxide and jq; each install asks first'; + case 'enhanced': return 'Also lists Enhanced CLI tools (bat, eza, delta, gh, …); not better for everyone'; + case 'individual': return 'Opens /tools on the full catalog; each install asks first'; + } +} + +/** + * ↑↓ rows · ←→ change · Tab / Shift+Tab sections · Enter next (Apply on + * the last step) · Esc cancel (asks when there are unapplied edits). + */ +export function setupKey(state: SetupState, key: Key): SetupResult | undefined { + const changes = () => setupChanges(state).length > 0; + if (state.chooser) { chooserKey(state, state.chooser, key); return undefined; } + if (state.confirmDiscard) { + if (key.kind === 'enter' || (key.kind === 'text' && key.value.toLowerCase() === 'y')) return {kind: 'cancel'}; + if (key.kind === 'escape' || key.kind === 'interrupt' || (key.kind === 'text' && key.value.toLowerCase() === 'n')) state.confirmDiscard = false; + return undefined; + } + if (key.kind === 'escape' || key.kind === 'interrupt') { + if (changes()) { state.confirmDiscard = true; return undefined; } + return {kind: 'cancel'}; + } + const sectionId = SETUP_SECTIONS[state.section]?.id; + if (key.kind === 'text' && key.value.toLowerCase() === 'p' && (sectionId === 'appearance' || sectionId === 'prompt')) { + state.previewChroma = !state.previewChroma; + return undefined; + } + const last = SETUP_SECTIONS.length - 1; + const rows = currentRows(state); + const go = (section: number) => { state.section = Math.max(0, Math.min(last, section)); state.row = 0; }; + if (key.kind === 'complete') go(state.section === last ? 0 : state.section + 1); + else if (key.kind === 'focusPrevious') go(state.section === 0 ? last : state.section - 1); + else if (key.kind === 'up') state.row = Math.max(0, state.row - 1); + else if (key.kind === 'down') state.row = Math.min(Math.max(0, rows.length - 1), state.row + 1); + else if ((key.kind === 'left' || key.kind === 'right' || (key.kind === 'text' && key.value === ' ')) && rows[state.row]) { + const delta = key.kind === 'left' ? -1 : 1; + const {row} = rows[state.row]!; + if (row.id === TOOL_CHOICE_ROW.row.id) { + state.tools = TOOL_CHOICES[(TOOL_CHOICES.indexOf(state.tools) + delta + TOOL_CHOICES.length) % TOOL_CHOICES.length]!; + state.draft = applyToolChoice(state.draft, state.tools); + } else { + const next = adjustSettingsRow(row, state.draft, delta); + if (next) state.draft = next; + } + } else if (key.kind === 'enter' && rows[state.row]?.row.control === 'action') { + const row = rows[state.row]!.row; + if (row.control === 'action' && row.destination === 'cursor') return {kind: 'cursorEditor', advanced: false, row: row.id === 'cursorAdvanced' ? 'speed' : 'colorCustom'}; + // An in-draft action (reset a list), or a route: apply this Setup, then open the real editor. + if (row.control === 'action' && row.run) { state.draft = row.run(state.draft); return undefined; } + if (row.control === 'action' && row.destination !== 'tools') return {kind: 'apply', configuration: normalizePromptConfiguration(state.draft), tools: state.tools, changed: changes(), then: row.destination}; + return {kind: 'browseTools'}; + } else if (key.kind === 'enter' && rows[state.row] && rows[state.row]!.row.id !== TOOL_CHOICE_ROW.row.id && !rows[state.row]!.row.unavailable?.(state.draft) && chooserOptions(rows[state.row]!.row, state.draft).length) { + const {row} = rows[state.row]!; + state.chooser = {rowId: row.id, index: row.control === 'enum' ? row.index(state.draft) : 0, before: state.draft}; + } else if (key.kind === 'text' && key.value.toLowerCase() === 'r' && ['cursor', 'motion'].includes(SETUP_SECTIONS[state.section]!.id)) { + // Replay: the preview restarts from its first frame. + state.previewKey = undefined; + } else if (key.kind === 'text' && key.value.toLowerCase() === 'i' && rows[state.row]) { + const tool = installableTool(rows[state.row]!, state); + if (tool) return {kind: 'browseTools', toolId: tool}; + } else if (key.kind === 'enter') { + if (state.section < last) go(state.section + 1); + else { + const configuration = normalizePromptConfiguration(state.draft); + return {kind: 'apply', configuration, tools: state.tools, changed: changes()}; + } + } + return undefined; +} + +/** The visible choices of an option row (enum rows only; booleans and actions have none to browse). */ +export function chooserOptions(row: SettingsRow, config: PromptConfiguration): readonly string[] { + return row.control === 'enum' ? row.optionsFor?.(config) ?? row.options : []; +} + +function chooserKey(state: SetupState, chooser: NonNullable, key: Key): void { + const row = currentRows(state).find(item => item.row.id === chooser.rowId)?.row; + const options = row ? chooserOptions(row, chooser.before) : []; + if (!row || row.control !== 'enum' || !options.length || key.kind === 'escape' || key.kind === 'interrupt') { + state.draft = chooser.before; + state.chooser = undefined; + return; + } + if (key.kind === 'enter' || key.kind === 'complete' || key.kind === 'focusPrevious') { state.chooser = undefined; return; } + if (key.kind === 'up' || key.kind === 'down' || key.kind === 'left' || key.kind === 'right') { + const delta = key.kind === 'up' || key.kind === 'left' ? -1 : 1; + chooser.index = (chooser.index + delta + options.length) % options.length; + // Preview the highlighted choice live; Esc puts the earlier value back. + state.draft = row.select(chooser.before, chooser.index); + } +} + +/** + * The catalog tool behind a selected external provider that is not installed + * yet; `I` opens it in the shared tool browser (install still needs its own + * preview and confirmation). + */ +export function installableTool(item: SetupRow, state: SetupState): string | undefined { + const executable = item.provider?.(state.draft); + if (!executable || state.context.statuses[executable]?.state !== 'missing') return undefined; + return TOOLS.find(tool => tool.executable === executable)?.id; +} + +/** The smallest size where Setup Cat shows its essential controls. */ +export const SETUP_MIN_SIZE = {columns: 44, rows: 12} as const; + +export function renderSetup(state: SetupState, columns: number, height: number): string[] { + const primary = foreground(UI_COLORS.primary); + const secondary = foreground(UI_COLORS.secondary); + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + const success = foreground(UI_COLORS.success); + const bold = '\u001B[1m'; + const reset = '\u001B[0m'; + const section = SETUP_SECTIONS[state.section]!; + const nerd = getCurrentGlyphMode() === 'nerd'; + const title = ` ${bold}${state.context.title ?? `${primary}Setup Cat`}${reset} ${subtle}${nerd ? '·' : '-'} ${state.section + 1}/${SETUP_SECTIONS.length} ${section.title}${reset}`; + const head = [title, renderTabStrip(SETUP_SECTIONS.map(item => item.title), state.section, columns)]; + if (state.toolBrowser) { + // The shared /tools browser, inside Setup Cat: the draft and step are kept underneath. + const note = wrapWords(`${INSTALL_DRAFT_NOTE} Esc returns to Setup Cat.`, Math.max(10, columns - 4)).map(line => ` ${subtle}${line}${reset}`); + const tools = renderTools(state.toolBrowser, columns, Math.max(4, height - head.length - note.length - 1)); + return [...framePanel([...head, ...note].map(row => truncateAnsi(row, columns)), columns), ...tools].slice(0, Math.max(1, height)); + } + + // Stable top area: intro, controls and the selected control's help. + const top: string[] = ['']; + for (const line of section.intro) { + for (const part of wrapWords(line, Math.max(10, columns - 4))) top.push(` ${line === NATIVE_FIRST_MESSAGE ? primary : subtle}${part}${reset}`); + } + if (section.intro.length) top.push(''); + if (section.id === 'review') { + const changes = setupChanges(state); + if (!changes.length) top.push(` ${secondary}${state.onboarding ? 'Your current choices are ready. Enter completes setup; you can return with /setup anytime.' : 'No changes. Enter closes Setup Cat and keeps everything as it is.'}${reset}`); + else { + top.push(` ${primary}Apply these changes?${reset}`, ''); + const labelWidth = Math.min(28, Math.max(...changes.map(change => displayWidth(change.label))) + 2); + for (const change of changes) { + top.push(` ${secondary}${padCells(change.label, labelWidth - 2)}${subtle}${change.from} ${nerd ? '→' : '->'} ${reset}${success}${change.to}${reset}`); + } + } + } else { + const rows = currentRows(state); + const indent = (row: SettingsRow) => row.parent ? ' ' : ''; + const labelWidth = Math.min(30, Math.max(0, ...rows.map(item => displayWidth(indent(item.row) + item.row.label))) + 3); + rows.forEach((item, index) => { + const selected = index === state.row; + const value = item.row.id === TOOL_CHOICE_ROW.row.id ? TOOL_CHOICE_LABELS[state.tools] : rowValue(item.row, state.draft); + const changed = item.row.id !== TOOL_CHOICE_ROW.row.id && item.row.control !== 'action' && rowValue(item.row, state.saved) !== value; + const pointer = selected ? `${accent}${GLYPHS.selection}${reset}` : ' '; + const unavailable = item.row.unavailable?.(state.draft); + // An unavailable row has nothing to cycle: no arrows, and the reason is shown instead of the description. + const control = item.row.control === 'action' ? `${selected ? accent : secondary}${value}${reset}` + : unavailable ? `${subtle}${value}${reset}` + : selected ? `${accent}${nerd ? '‹' : '<'} ${value} ${nerd ? '›' : '>'}${reset}` : `${secondary}${value}${reset}`; + top.push(` ${pointer} ${focusForeground(selected, UI_COLORS.primary)}${padCells(indent(item.row) + item.row.label, labelWidth - 2)}${reset}${control}${changed ? ` ${subtle}${nerd ? '•' : '*'}${reset}` : ''}`); + if (selected && state.chooser?.rowId === item.row.id) { + // Every choice, visible: the highlighted one is previewed live below. + const options = chooserOptions(item.row, state.chooser.before); + const savedValue = rowValue(item.row, state.saved); + options.forEach((option, optionIndex) => { + const current = optionIndex === state.chooser!.index; + top.push(` ${' '.repeat(labelWidth + 2)}${current ? `${accent}${nerd ? '●' : '*'} ${focusForeground(true)}` : `${subtle}${nerd ? '○' : 'o'} ${secondary}`}${option}${reset}${option === savedValue ? ` ${subtle}${nerd ? '✓' : '(saved)'}${reset}` : ''}`); + }); + } + }); + const selected = rows[state.row]; + if (selected) { + const note = selected.row.id === TOOL_CHOICE_ROW.row.id ? toolChoiceNote(state.tools) : selected.note?.(state.draft, state.context); + const reason = selected.row.unavailable?.(state.draft); + top.push('', ` ${subtle}${reason ?? selected.row.description}${reset}`); + if (note && !reason) top.push(` ${subtle}${note}${reset}`); + } + } + if (state.confirmDiscard) top.push('', ` ${primary}Discard unapplied Setup Cat changes? Your saved settings stay exactly as they are.${reset}`); + + // Bottom area: the live preview starts below the controls and gives way first on short terminals. + const facts = section.facts?.(state.draft, state.context) ?? []; + const preview = [...(state.context.preview ?? []), ...facts.map(line => ` ${subtle}${line}${reset}`)]; + const selectedRow = section.id === 'review' ? undefined : currentRows(state)[state.row]; + const browsable = Boolean(selectedRow && selectedRow.row.id !== TOOL_CHOICE_ROW.row.id && !selectedRow.row.unavailable?.(state.draft) && chooserOptions(selectedRow.row, state.draft).length); + const footer = state.confirmDiscard + ? renderControls([['Enter', 'discard changes'], ['Esc', 'keep editing']]) + : state.chooser + ? renderControls([['↑↓', 'preview choice'], ['Enter', 'keep'], ['Esc', 'restore']]) + : renderControls([ + ...(section.rows.length || section.id === 'tools' ? [['↑↓', 'select'] as [string, string], ['←→', 'change'] as [string, string]] : []), + ...(['cursor', 'motion'].includes(section.id) ? [['R', 'replay preview'] as [string, string]] : []), + ['Tab', 'next'], ['Shift+Tab', 'previous section'], + ['Enter', browsable ? 'choices' : selectedRow && isRouteRow(selectedRow.row) ? 'apply & open' : selectedRow?.row.control === 'action' ? 'open' + : state.section === SETUP_SECTIONS.length - 1 ? (setupChanges(state).length || state.onboarding ? 'apply' : 'close') : 'next'], + ['Esc', state.onboarding ? 'skip for now' : 'cancel']]); + // Frame line, head, footer and its blank line are fixed; controls come next; the preview gets what is left. + const budget = Math.max(1, height - 1 - head.length - 2); + const controls = top.slice(0, budget); + const room = budget - controls.length - 1; + const shown = room >= 2 && preview.length ? ['', ` ${subtle}${nerd ? '─' : '-'} Preview${reset}`, ...preview].slice(0, room + 1) : []; + return framePanel([...head, ...controls, ...shown, '', footer].map(row => truncateAnsi(row, columns)), columns).slice(0, Math.max(1, height)); +} + +/** Plain word wrap for prose rows; long words are left for truncation. */ +export function wrapWords(text: string, width: number): string[] { + const lines: string[] = []; + let line = ''; + for (const word of text.split(/\s+/u)) { + if (line && displayWidth(line) + 1 + displayWidth(word) > width) { lines.push(line); line = word; } + else line = line ? `${line} ${word}` : word; + } + if (line) lines.push(line); + return lines; +} + +/** True when no saved value would change: applying is then a no-op. */ +export function setupIsIdempotent(state: SetupState): boolean { + return JSON.stringify(normalizePromptConfiguration(state.draft)) === JSON.stringify(normalizePromptConfiguration(state.saved)); +} + +export {DEFAULT_PROMPT_CONFIGURATION}; diff --git a/src/setup/glyphDiagnostic.ts b/src/setup/glyphDiagnostic.ts new file mode 100644 index 00000000..2631e621 --- /dev/null +++ b/src/setup/glyphDiagnostic.ts @@ -0,0 +1,49 @@ +import {getIconStyle, GLYPHS, moduleIcon, powerlineShapeGlyphs, setIconStyle, type GlyphMode} from '../ui/glyphs.js'; +import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; +import {displayWidth} from '../util/text.js'; + +/** Glyphs from the production registry, drawn in one glyph mode. */ +function samples(mode: GlyphMode): Array<{label: string; text: string}> { + const previous = getIconStyle(); + setIconStyle(mode); + try { + const wedge = powerlineShapeGlyphs('wedge'); + const rounded = powerlineShapeGlyphs('rounded'); + const icon = (id: Parameters[0], name: string) => `${moduleIcon(id)}${moduleIcon(id) ? ' ' : ''}${name}`; + return [ + {label: 'Powerline', text: `${wedge.open}${wedge.join}${rounded.open}${rounded.close}`}, + {label: 'Branch', text: `${moduleIcon('gitBranch') || GLYPHS.branch}${moduleIcon('gitBranch') ? ' ' : ''}main`}, + {label: 'Node', text: icon('node', 'node')}, + {label: 'Python', text: icon('python', 'py')}, + {label: 'Go', text: icon('go', 'go')}, + {label: 'Docker', text: icon('docker', 'docker')}, + {label: 'Status', text: `${GLYPHS.success} ${GLYPHS.failure}`}, + {label: 'Arrows', text: `${GLYPHS.selection} ${GLYPHS.jumpDown}`}, + {label: 'Prompt', text: GLYPHS.prompt}, + ]; + } finally { setIconStyle(previous); } +} + +/** + * The Terminal step's font diagnostic: the same NMSh glyphs in Nerd Font and + * Safe/ASCII, column-aligned so the two can be compared at a glance. Boxes, + * question marks or ragged columns in the Nerd row mean the font is missing them. + */ +export function glyphDiagnosticRows(selected: GlyphMode): string[] { + const nerd = samples('nerd'); + const safe = samples('safe'); + const widths = nerd.map((item, index) => Math.max(displayWidth(item.label), displayWidth(item.text), displayWidth(safe[index]!.text)) + 2); + const subtle = foreground(UI_COLORS.subtle); + const primary = foreground(UI_COLORS.primary); + const accent = foreground(UI_COLORS.accent); + const reset = '\u001B[0m'; + const cell = (text: string, width: number) => `${text}${' '.repeat(Math.max(0, width - displayWidth(text)))}`; + const line = (mode: GlyphMode, name: string, items: Array<{text: string}>) => + `${mode === selected ? `${accent}›` : ' '} ${focusForeground(mode === selected)}${cell(name, 11)}${reset}${primary}${items.map((item, index) => cell(item.text, widths[index]!)).join('')}${reset}`; + return [ + ` ${subtle}${cell('', 11)}${nerd.map((item, index) => cell(item.label, widths[index]!)).join('')}${reset}`, + line('nerd', 'Nerd Font', nerd), + line('safe', 'Safe/ASCII', safe), + ` ${subtle}Boxes, question marks or uneven columns in the Nerd Font row mean your font lacks those glyphs: choose Safe.${reset}`, + ]; +} diff --git a/src/setup/providerExplanations.ts b/src/setup/providerExplanations.ts new file mode 100644 index 00000000..5a638789 --- /dev/null +++ b/src/setup/providerExplanations.ts @@ -0,0 +1,43 @@ +/** + * Short, factual explanations of what each provider choice actually does. + * Native choices say no installation is needed; external choices say why + * someone might pick them, never that they are better. + */ +export const PROVIDER_EXPLANATIONS: Readonly>>> = { + history: { + native: 'Built in · local command history and search (/history) · no installation required', + atuin: 'Optional external history · reads your existing local Atuin database, read-only, no sync', + }, + navigation: { + native: 'Built in · ranks directories you use and jumps with /dirs · no installation required', + zoxide: 'Optional external ranking · reads your zoxide database; zoxide itself provides `z`', + }, + picker: { + native: "Built in · NMSh's interactive list in the composer · no installation required", + fzf: 'Optional external picker engine · uses your installed fzf for full-screen fuzzy picking', + television: 'Optional external picker · uses your installed Television (tv)', + }, + suggestions: { + nmsh: 'Built in · ghost-text predictions from your history and directory · no installation required', + deja: 'Optional external engine · your installed Deja, which learns through its own zsh hooks', + none: 'No ghost text while typing', + }, + welcome: { + vespyr: 'Built in · Vespyr the NMSh cat with build and directory · no installation required', + fastfetch: 'Optional external · runs your installed fastfetch with its own configuration', + neofetch: 'Optional external · legacy/archived; used only if already installed', + macchina: 'Optional external · system information fetcher in maintenance mode', + zigfetch: 'Optional external · minimal fetcher using your installed configuration', + none: 'No welcome; sessions start at the first command', + }, + prompt: { + nmsh: 'Built in · NMSh Native prompt with themes, styles and Chroma · no installation required', + starship: 'Optional external · renders your installed Starship and its configuration', + powerlevel10k: 'Optional external · your existing ~/.p10k.zsh left prompt', + none: 'Composer only · no prompt row or modules (the input marker stays); themes still style NMSh UI, syntax and Theme Bridge', + }, +}; + +export function providerExplanation(family: string, id: string): string { + return PROVIDER_EXPLANATIONS[family]?.[id] ?? ''; +} diff --git a/src/shell/BundledCatalog.ts b/src/shell/BundledCatalog.ts new file mode 100644 index 00000000..1c3f9b5a --- /dev/null +++ b/src/shell/BundledCatalog.ts @@ -0,0 +1,167 @@ +import {closeSync, openSync, readFileSync, readSync} from 'node:fs'; +import {dirname, join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {inflateRawSync} from 'node:zlib'; +import {completionWord} from './ConfiguredCompletion.js'; +import type {CompletionCandidate, CompletionContext, CompletionKind, CompletionSource} from './completion.js'; + +/** + * The bundled completion catalog: static command knowledge imported at build + * time from withfig/autocomplete and carapace-bin (both MIT; see + * assets/completion/provenance.json). It is data only; nothing upstream is + * executed, and dynamic completions were dropped by the importer. + * + * Format: `catalog-index.json` maps root names to entry keys and entry keys to + * [offset, length] in `catalog.bin`, where each entry is deflateRaw'd JSON of + * one node. A large subcommand is its own entry, referenced as `{n, d, r}`. + * The index is read on first use; entries are inflated on demand and kept in a + * small LRU, so a lookup touches only the command path being completed. + */ + +export interface CatalogArg { + n?: string; + d?: string; + /** Static choices: [value, description?]. */ + c?: Array<[string, string?]>; + /** The argument is a path. */ + t?: 'files' | 'folders'; + o?: 1; + v?: 1; +} +export interface CatalogOption {n: string[]; d?: string; a?: CatalogArg[]; p?: 1} +export interface CatalogNode { + n: string[]; + d?: string; + s?: CatalogNode[]; + o?: CatalogOption[]; + a?: CatalogArg[]; + /** Entry key holding this node's body. */ + r?: string; +} +interface CatalogIndex {version: number; roots: Record; entries: Record} + +const MAX_CACHED_ENTRIES = 24; +const MAX_CHOICES = 500; +export const BUNDLED_CATALOG_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'assets', 'completion'); + +export class BundledCatalog { + private index?: CatalogIndex | null; + private readonly cache = new Map(); + /** Entries inflated so far (diagnostics and tests). */ + loads = 0; + + constructor(private readonly directory: string = BUNDLED_CATALOG_DIRECTORY) {} + + /** Undefined when the catalog is absent or unreadable; never throws. */ + private readIndex(): CatalogIndex | undefined { + if (this.index === undefined) { + try { + const parsed = JSON.parse(readFileSync(join(this.directory, 'catalog-index.json'), 'utf8')) as CatalogIndex; + this.index = parsed.version === 1 && parsed.roots && parsed.entries ? parsed : null; + } catch { this.index = null; } + } + return this.index ?? undefined; + } + + get available(): boolean { return !!this.readIndex(); } + get rootCount(): number { return Object.keys(this.readIndex()?.roots ?? {}).length; } + /** Every root command name the catalog knows (for typo recovery over known names only). */ + rootNames(): string[] { return Object.keys(this.readIndex()?.roots ?? {}); } + hasRoot(name: string): boolean { return Object.hasOwn(this.readIndex()?.roots ?? {}, name); } + + entry(key: string): CatalogNode | undefined { + const cached = this.cache.get(key); + if (cached) { this.cache.delete(key); this.cache.set(key, cached); return cached; } + const location = this.readIndex()?.entries[key]; + if (!location) return undefined; + let fd: number | undefined; + try { + const [offset, length] = location; + const buffer = Buffer.alloc(length); + fd = openSync(join(this.directory, 'catalog.bin'), 'r'); + if (readSync(fd, buffer, 0, length, offset) !== length) return undefined; + const node = JSON.parse(inflateRawSync(buffer).toString('utf8')) as CatalogNode; + this.loads += 1; + this.cache.set(key, node); + if (this.cache.size > MAX_CACHED_ENTRIES) this.cache.delete(this.cache.keys().next().value!); + return node; + } catch { return undefined; } finally { if (fd !== undefined) closeSync(fd); } + } + + root(name: string): CatalogNode | undefined { + return this.hasRoot(name) ? this.entry(this.readIndex()!.roots[name]!) : undefined; + } + + /** A referenced subcommand's body; inline nodes are returned as they are. */ + resolve(node: CatalogNode): CatalogNode { return node.r ? this.entry(node.r) ?? node : node; } +} + +/** Words before the cursor, split on unquoted whitespace. */ +function wordsBefore(buffer: string, start: number): string[] { + return buffer.slice(0, start).trim().split(/\s+/u).filter(Boolean); +} + +/** Candidates from the catalog for the word at the cursor. */ +export function catalogCandidates(catalog: BundledCatalog, context: CompletionContext, source = 'catalog'): CompletionCandidate[] { + const cursor = context.cursor ?? context.buffer.length; + const range = completionWord({...context, cursor}); + if (!range || range.start === 0) return []; + const words = wordsBefore(context.buffer, range.start); + let node = catalog.root(words[0] ?? ''); + if (!node) return []; + const persistent: CatalogOption[] = []; + const findOption = (word: string) => [...(node!.o ?? []), ...persistent].find(option => option.n.includes(word)); + let pendingOption: CatalogOption | undefined; + let positional = 0; + for (const word of words.slice(1)) { + if (pendingOption) { pendingOption = undefined; continue; } + if (word.startsWith('-')) { + const option = word.includes('=') ? undefined : findOption(word); + if (option?.a?.length) pendingOption = option; + continue; + } + const sub = node.s?.find(item => item.n.includes(word)); + if (sub) { + persistent.push(...(node.o ?? []).filter(option => option.p)); + node = catalog.resolve(sub); + positional = 0; + } else positional += 1; + } + const typed = context.buffer.slice(range.start, cursor); + const make = (value: string, description: string | undefined, kind: CompletionKind): CompletionCandidate => ({value, display: value, name: value, + description: description ?? '', kind, source, replacement: range, context: {...context}, insertion: context.buffer.slice(0, range.start) + value + context.buffer.slice(range.end), + insertionCursor: range.start + value.length}); + const result: CompletionCandidate[] = []; + const choices = (arg: CatalogArg | undefined) => { + for (const [value, description] of (arg?.c ?? []).slice(0, MAX_CHOICES)) if (value.startsWith(typed)) result.push(make(value, description ?? arg?.d, 'value')); + }; + // The value of an option that takes one: only its static choices (paths are the shell's job). + if (pendingOption) { choices(pendingOption.a?.[0]); return result; } + if (!typed.startsWith('-')) { + for (const sub of node.s ?? []) for (const name of sub.n) if (name.startsWith(typed)) result.push(make(name, sub.d, 'subcommand')); + const args = node.a ?? []; + choices(positional < args.length ? args[positional] : args.at(-1)?.v ? args.at(-1) : undefined); + } + if (typed.startsWith('-') || !typed) { + const seen = new Set(); + for (const option of [...(node.o ?? []), ...persistent]) for (const name of option.n) { + if (!seen.has(name) && name.startsWith(typed)) { seen.add(name); result.push(make(name, option.d, 'option')); } + } + } + return result; +} + +/** + * `shadowed(root)` names commands a higher-priority custom spec defines: the + * user's spec then replaces the bundled knowledge for that command entirely. + */ +export class BundledCatalogSource implements CompletionSource { + readonly id = 'catalog'; + constructor(readonly catalog = new BundledCatalog(), private readonly shadowed: (root: string) => boolean = () => false) {} + async query(context: CompletionContext, signal: AbortSignal): Promise { + if (signal.aborted) return []; + const root = context.buffer.trimStart().split(/\s+/u)[0] ?? ''; + if (this.shadowed(root)) return []; + return catalogCandidates(this.catalog, context, this.id); + } +} 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/CommandDescriptions.ts b/src/shell/CommandDescriptions.ts new file mode 100644 index 00000000..36585964 --- /dev/null +++ b/src/shell/CommandDescriptions.ts @@ -0,0 +1,113 @@ +import {readFile, realpath, stat} from 'node:fs/promises'; +import {dirname, join} from 'node:path'; +import {gunzip} from 'node:zlib'; +import {promisify} from 'node:util'; +import {resolveCommand} from '../providers/providers.js'; +import type {CompletionCandidate} from './completion.js'; + +const inflate = promisify(gunzip); +/** Man pages larger than this are not read for a one-line summary. */ +const MAX_PAGE_BYTES = 512 * 1024; +const CACHE_LIMIT = 512; +const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/u; + +/** The fallback for session names, which are never expanded or printed. */ +export function identityDescription(candidate: Pick): string | undefined { + switch (candidate.identity) { + case 'alias': return 'Alias in the current zsh session'; + case 'function': return 'Function in the current zsh session'; + case 'builtin': return 'zsh builtin'; + case 'keyword': return 'zsh reserved word'; + default: return undefined; + } +} + +const clean = (text: string) => text.replace(/\\f[BIRP]|\\\(.{2}|\\[&-]/gu, match => match === '\\-' ? '-' : '') + .replace(/[\u0000-\u001f\u007f-\u009f]/gu, '').replace(/\s+/gu, ' ').trim().slice(0, 120); + +/** + * The one-line summary from a man page's NAME section, in either man(7) + * (`ls \- list directory contents`) or mdoc(7) (`.Nd list directory contents`) + * form. Plain text parsing only; nothing is rendered or executed. + */ +export function parseManSummary(page: string): string | undefined { + const lines = page.split('\n'); + const start = lines.findIndex(line => /^\.S[Hh]\s+"?NAME"?\s*$/u.test(line)); + if (start === -1) return undefined; + const section: string[] = []; + for (const line of lines.slice(start + 1, start + 12)) { + if (/^\.S[Hh]\b/u.test(line)) break; + const nd = /^\.Nd\s+(.+)$/u.exec(line); + if (nd) return clean(nd[1]!) || undefined; + if (!line.startsWith('.') && !line.startsWith("'")) section.push(line); + } + const text = section.join(' '); + const split = text.search(/\s\\?-\s/u); + return split === -1 ? undefined : clean(text.slice(split).replace(/^\s\\?-\s/u, '')) || undefined; +} + +/** Candidate man page files for an executable: beside its prefix, then the system pages. Sections 1 and 8. */ +export function manPageCandidates(name: string, binaries: readonly string[]): string[] { + const prefixes = new Set(); + for (const binary of binaries) prefixes.add(join(dirname(dirname(binary)), 'share', 'man')); + prefixes.add('/usr/share/man'); + prefixes.add('/usr/local/share/man'); + return [...prefixes].flatMap(root => ['1', '8'].flatMap(section => + [join(root, `man${section}`, `${name}.${section}`), join(root, `man${section}`, `${name}.${section}.gz`)])); +} + +/** + * Local man-page summaries for PATH executables, read from the man page files + * on disk: never the candidate itself, never `--help`, never `man`/`whatis` + * (which can rebuild databases), never the network. Results (including + * "none") are cached; lookups are asynchronous and bounded. + */ +export class CommandDescriptions { + private readonly cache = new Map(); + private readonly pending = new Map>(); + private readonly lifetime = new AbortController(); + + constructor(private readonly lookup: (name: string, signal: AbortSignal) => Promise = manSummary) {} + + /** Cached description, if known. Never blocks. */ + cached(name: string): string | undefined { + return this.cache.get(name) ?? undefined; + } + + /** Start a lookup for a highlighted executable; resolves once, then cached. */ + request(name: string): Promise { + if (!SAFE_NAME.test(name)) return Promise.resolve(undefined); + if (this.cache.has(name)) return Promise.resolve(this.cache.get(name) ?? undefined); + const existing = this.pending.get(name); + if (existing) return existing; + const pending = this.lookup(name, this.lifetime.signal).catch(() => undefined).then(description => { + this.pending.delete(name); + if (this.lifetime.signal.aborted) return undefined; + if (this.cache.size >= CACHE_LIMIT) this.cache.delete(this.cache.keys().next().value!); + this.cache.set(name, description ?? null); + return description; + }); + this.pending.set(name, pending); + return pending; + } + + dispose(): void { this.lifetime.abort(); } +} + +async function manSummary(name: string, signal: AbortSignal): Promise { + const binary = resolveCommand(name); + const real = binary ? await realpath(binary).catch(() => binary) : undefined; + for (const path of manPageCandidates(name, [binary, real].filter((value): value is string => Boolean(value)))) { + if (signal.aborted) return undefined; + try { + const info = await stat(path); + if (!info.isFile() || info.size > MAX_PAGE_BYTES) continue; + const raw = await readFile(path); + const page = (path.endsWith('.gz') ? await inflate(raw, {maxOutputLength: MAX_PAGE_BYTES * 4}) : raw).toString('utf8'); + // `.so` pages redirect to another page; follow nothing, just skip. + const summary = parseManSummary(page); + if (summary) return summary; + } catch { /* Missing or unreadable: try the next location. */ } + } + return undefined; +} diff --git a/src/shell/CommandInspector.ts b/src/shell/CommandInspector.ts new file mode 100644 index 00000000..c7079d45 --- /dev/null +++ b/src/shell/CommandInspector.ts @@ -0,0 +1,58 @@ +import {inspectCommand, type InspectorContext} from './CommandKnowledge.js'; +import type {CommandSource} from './SemanticService.js'; +import {TOOLS} from '../tools/catalog.js'; +import {slashCommands} from '../commands/slashCommands.js'; +import {truncateText} from '../util/text.js'; +export {inspectCommand}; + +/** The curated optional tool an executable name belongs to, if any. */ +function curatedTool(name: string | undefined): string | undefined { + const tool = name ? TOOLS.find(item => item.executable === name) : undefined; + if (!tool) return undefined; + return tool.providerFamily ? `optional external ${tool.providerFamily} provider (${tool.label})` : `optional external tool (${tool.label})`; +} + +/** + * What a command word really resolves to in the user's zsh, in plain words. + * zsh stays authoritative: NMSh never claims a Native `ls` or similar, and an + * alias shows only the name it starts with, never its body. + */ +export function describeCommandSource(word: string, source: CommandSource | undefined): string { + if (!source) return 'Resolving in your zsh…'; + const path = source.path; + switch (source.kind) { + case 'alias': { + const target = source.aliasTarget; + const tool = curatedTool(target); + return target + ? `alias → ${target}${path ? ` · ${path}` : ''}${tool ? ` · ${tool}` : ''} (run \`alias ${word}\` to see it)` + : `alias (definition not shown; run \`alias ${word}\`)`; + } + case 'function': return `shell function${path ? ` · shadows executable ${path}` : ''}`; + case 'builtin': return `zsh builtin${path ? ` · an executable ${path} also exists` : ''}`; + case 'reserved': return 'zsh reserved word'; + case 'executable': { + const tool = curatedTool(word); + return `executable${path ? ` · ${path}` : ''}${tool ? ` · ${tool}` : ''}`; + } + case 'missing': return 'not found in your zsh (no alias, function, builtin or executable)'; + default: return 'unknown to your zsh'; + } +} + +/** Slash text is an NMSh command, not a shell command. */ +export function describeSlashCommand(text: string): string | undefined { + const name = text.trim().split(/\s+/u)[0]; + const command = slashCommands.find(item => item.name === name || item.name.startsWith(`${name} `)); + return command && command.name.split(' ')[0] === name ? `NMSh slash command · ${command.description}` : undefined; +} + +/** Plain presentation is deterministic and safe under all glyph/color policies. */ +export function renderInspector(context: InspectorContext | undefined, width: number, source?: string): string[] { + if (!context || width < 1) return []; + const title = `${context.kind === 'option' ? 'flag' : context.kind}: ${context.value}`; + if (width < 32) return [truncateText(`${title} - ${source ?? context.description}`, width)]; + return [truncateText(`Inspect ${title}`, width), + truncateText(`${context.description}${context.usage ? ` | ${context.usage}` : ''}`, width), + ...(source ? [truncateText(`Source: ${source}`, 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: '