diff --git a/CHANGELOG.md b/CHANGELOG.md index d3e165a6..3a8a25b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **Kitty shaders in `/appearance`** (Kitty 0.49 or newer; [#399](https://github.com/raiseCatError/notMyShell/issues/399)). In Kitty, `/appearance` › **Kitty shaders** offers the GPU effects Kitty itself ships — Default, Blaze, Lightning and Motion-blur cursor trails (one at a time), Northern Lights, Matrix, Fireworks, Pond Ripple, Spotlight, Focus highlight, Dim inactive windows, Tab change and CRT — read from the installed Kitty, with the settings each installed shader really declares (durations, colors, intensity, speed…) in safe ranges. Effects are off until you choose them; Enter toggles, ←→ adjusts, Shift+↑↓ orders, and **S** shows the exact files and the resulting `custom_shaders` list before anything is written. Your own `custom_shaders` are kept exactly as written and in order, with NMSh's pipeline after (or before) them; NMSh refuses instead of guessing when it cannot prove that (dynamic includes, Kitty launch overrides, unreadable pipelines, a shader of yours replacing a shipped one, shared textures, a second cursor trail, more than 16 shader groups). **R** removes NMSh's files and its one include line, restoring your file byte for byte. Reduced Motion, Decorative effects Off and a Portable cursor take NMSh's effects (or its trail) back out of Kitty's pipeline. Effects cover the whole Kitty window, not only NMSh; saving configuration is not proof of GPU output. In `/cursor`, Host native setup on Kitty opens this screen, and a Kitty trail shader means NMSh never draws a second trail. - **Console for the browser preview** ([#466](https://github.com/raiseCatError/notMyShell/issues/466); [design](docs/design/document-workspace.md#console-runtime-diagnostics-of-the-browser-preview)). In the Chromium preview of a Markdown or HTML document, **`C`** lists what the page itself reported for the document now loaded: script exceptions, `console` errors, warnings and messages, failed loads and HTTP errors, Content Security Policy refusals and requests NMSh refused. It works like Problems — newest first, Tab filters by severity, typing searches, Shift+↑↓ marks, Del clears, Ctrl+C shows exactly what would be copied before copying — and Enter goes to the line in the file when that is certain (an HTML document unchanged since the page loaded); otherwise it says why. The Console only reads: it never turns scripts or the network on (with scripts off, only loading and policy messages appear), accepts no expressions, and ignores `/browser` pages and the Mermaid renderer. Entries stay in memory for the current load, bounded, with the preview's address, query values, tokens and likely secrets redacted before they are kept. The status line counts errors and warnings. - **`/doctor` reports document rendering** ([#462](https://github.com/raiseCatError/notMyShell/issues/462)). A Documents section names the Chrome or Chromium the previews would use (its version once a browser surface has checked it this session, and whether it is running), the Mermaid it found (path, version, a warning for a major version other than the tested 12), the Mermaid setting and, when diagrams would not be drawn, why. Nothing is launched and the Mermaid bundle is not read, so it says what was found, not that drawing works. - **Mermaid diagrams in Markdown** ([#462](https://github.com/raiseCatError/notMyShell/issues/462); [design](docs/design/diagrams.md)). A ```` ```mermaid ```` fence is drawn as a real diagram — flowcharts, sequence, class, state, ER, Gantt, pie, journey, git graph, mind map, timeline, quadrant, XY, requirement, Sankey, block and C4 diagrams — in Preview (NMSh's renderer and the Chromium preview), Split's preview pane, the viewer and Hybrid's inactive blocks, with the image backend your terminal supports (Kitty graphics, iTerm2, Sixel, or character art); `i` opens it in the image viewer to zoom and pan. NMSh uses the Mermaid you installed (`npm install -g mermaid`, or `NMSH_MERMAID`) inside the real browser it already drives, and downloads nothing. The page that draws runs no document script, loads nothing from the network or disk and returns only pixels; Mermaid's security settings are locked against directives. Settings → General → **Mermaid diagrams**: *On request* (default: press `D`, or F1 › Diagrams while editing — opening a document never starts a browser), *Automatic*, or *Off*. The fence stays the source: in Hybrid the block under the caret is its exact text, edits are drawn after a pause, and anything not drawn (not asked yet, no browser or Mermaid, a syntax error, too large, a timeout) shows the fence as written with `(diagram not drawn: reason)`. Drawings are cached by content and theme, bounded in number, bytes and time, and the renderer page closes when no document shows a diagram. @@ -25,6 +26,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Markdown editing helpers in `/edit`** ([#463](https://github.com/raiseCatError/notMyShell/issues/463), first slice; Markdown files only). **Enter** continues a bullet, numbered or task list (same marker and indentation, next number, unchecked box) and splits an item at the caret; on an empty item it ends the list (a nested one steps out a level). **Tab / Shift+Tab** on list items indent or outdent the item with its children (selected items together); elsewhere Tab is a tab as before. **F1** opens the Markdown actions: toggle a task checkbox, renumber the list at the caret, insert a link or image around the selection, wrap the selection in bold, italic, code or strikethrough (again to unwrap). Every action changes only the lines it names and is one undo step; text is never reflowed or regenerated. - **Crash recovery for `/edit` and the document workspace** ([#469](https://github.com/raiseCatError/notMyShell/issues/469)). Unsaved changes are written, a moment after you stop typing, to a private draft in NMSh's data folder (`recovery/`, mode 0700; files 0600). After a crash, a kill or a closed window, opening the file offers **Restore draft**, **Compare with file**, **Discard draft** or **Keep for later**; nothing is applied until you choose, a draft made from an older version of the file says so, and the file itself is only ever written by the editor's normal checked save (which also retires the draft). Two windows on one file keep separate drafts, and a draft of a window that is still running is never offered. Drafts are bounded (2 MiB each, 50 in all, 30 days) and are never sent anywhere. +### Fixed + +- With the Portable cursor renderer, NMSh's Kitty cursor setup no longer leaves Kitty's own `cursor_trail` on, which drew two trails. + ## [0.23.0] - 2026-10-21 — Vespyr Arcade The Arcade grows from five games to 33, and it can now be played while a command keeps running. diff --git a/docs/development/native-appearance-welcome/CHECKPOINT.json b/docs/development/native-appearance-welcome/CHECKPOINT.json new file mode 100644 index 00000000..54cab628 --- /dev/null +++ b/docs/development/native-appearance-welcome/CHECKPOINT.json @@ -0,0 +1,14 @@ +{ + "phase": "Slice 2 (Kitty native shaders) implemented, reviewed, canonically verified (1 load-induced live timeout, passes alone); committed on feature/kitty-native-shaders-20261028", + "updated": "2026-10-28", + "slice1": {"branch": "feature/appearance-welcome-20261028", "head": "4abd014d3bee8c19f2a1c25c5271cc6f5b3fe474", "pr": 515, "ci": "run 40640910786 success (not re-run)", "state": "draft, unmerged, unchanged"}, + "slice2": {"branch": "feature/kitty-native-shaders-20261028", "base": "4abd014d3bee8c19f2a1c25c5271cc6f5b3fe474", "pr": "stacked draft PR targeting feature/appearance-welcome-20261028 (see HANDOFF/progress for number)", + "source_manifest_sha256": "a658e9704941d4588014f137d22fc2129f3c72d9650e27e9e5ab113db6620519", + "tests": "canonical 4621/4636 pass, 1 fail (outputSelectionLive fish startup timeout; 3/3 alone), 13 skipped, 1 todo; Kitty focused 26/26; tsc clean; diff --check pass", + "review": "independent Sonnet review: no blocker; 4 should-fix fixed with regressions"}, + "evidence": "/Volumes/T7Tmp/ai-dev/nmsh-native-appearance-welcome-20261028 (CHECKPOINT.json, slice2-probe/, slice2-canonical-verify.log, slice2-live-rerun.log)", + "physical_qa": "NOT RUN", + "gpu_validation": "NOT RUN (headless Kitty parse + slangc compile only)", + "unrelated_prs_untouched": [513, 514], + "next": "Human: physical Kitty QA of /appearance › Kitty shaders (enable Blaze/Lightning trail with /cursor Motion Tail, Northern Lights, CRT; check user custom_shaders still run; R restores). Agents: Ghostty GLSL slice next (separate stacked PR); never merge automatically; after #515 merges, rebase/retarget only after review." +} diff --git a/docs/development/native-appearance-welcome/DECISIONS.md b/docs/development/native-appearance-welcome/DECISIONS.md index b3e885be..3b112818 100644 --- a/docs/development/native-appearance-welcome/DECISIONS.md +++ b/docs/development/native-appearance-welcome/DECISIONS.md @@ -1,5 +1,16 @@ # Architecture findings and recommended direction +## Slice 2 rulings (Kitty native shaders, 2026-10-28) + +- NMSh generates its OWN pipeline file (absolute path under NMSh's config folder) rather than naming shipped pipelines: each preset's shipped group structure (events, curve, step, textures, attach, output) is copied from the installed resource, and shipped pipeline-level vars move into each group. NMSh never emits pipeline-level vars (Kitty would apply them to the user's shaders). User pipeline-level vars that would leak into NMSh's groups are pinned back to the shipped source literal inside NMSh's groups. +- One NMSh-owned `custom_shaders` line holds the user's effective value VERBATIM (raw text, original quoting) plus NMSh's quoted pipeline path, after (default) or before it. A simulation through the real configuration graph must show that exact list as effective, from NMSh's fragment, or the plan is refused. Earlier shadowed lines are disclosed, not refused (Kitty's semantics are deterministic). +- Refuse (no write) when preservation or safety cannot be established: dynamic includes, Kitty launch overrides (`-o custom_shaders|cursor_trail`, `--config`; `ps` of KITTY_PID and `macos-launch-services-cmdline`; unreadable launch state also refuses), unbalanced quoting/control characters, user pipelines that are unreadable/unparseable/outside home, a user `.slang` that would replace a shipped shader NMSh tunes, named-texture overlap, a second cursor trail, more than 16 groups, an unrecorded or duplicated hook, edited/unowned NMSh files. A user entry Kitty cannot find is kept as written with a note (Kitty already skips it). +- Drift: the ledger records the user's value composed with. If it changes later (Kitty keeps loading NMSh's reviewed copy), the studio says so and sync refuses; a new review recomposes. This is the unavoidable cost of Kitty's single value; it is disclosed rather than hidden. +- Parameters: NMSh curates labels and SAFE bounds; a parameter is offered only if the installed shader declares it with the expected type and a literal default (the shipped pipeline's own value wins). Values are validated, never clamped at plan time. Presets without exposed constants say so. +- Gates: Reduced Motion and Decorative Effects Off write nothing new and, after setup, sync removes every NMSh group (the fragment then sets no custom_shaders, so the user's own value applies). A Portable cursor renderer removes NMSh's Kitty trail group. Sync rewrites only NMSh-owned files after the reviewed setup, mirroring the existing /cursor managed-file contract, and refuses on drift. +- One cursor engine: /cursor still decides whether there is a trail (Motion) and its color; the Kitty studio decides Kitty's trail style. A written Kitty trail shader makes Kitty native own the trail (Portable skips motion); the Lightning trail also owns the Lightning effect. Fixed an existing bug: NMSh's Kitty cursor fragment kept `cursor_trail 3` with the Portable renderer (two trails). +- First offered presets: Default/Blaze/Lightning/Motion-blur trails (one at a time), Northern Lights, Matrix, Fireworks, Pond Ripple, Spotlight, Focus highlight, Dim inactive windows (exclusive with Focus highlight: same shader), Tab change, CRT (opacity note). Other installed pipelines are listed as "installed, not offered yet". + The human explicitly approved this implementation direction and waived the design-document approval gate. Recommendations below guide implementation; meaningful trade-offs are recorded here. - Reuse PromptConfiguration and current canonical editors. AppearanceHub launches canonical editors, not copies. Extend cursor/native and existing host integration rather than create contradictory persistence. diff --git a/docs/development/native-appearance-welcome/HANDOFF.md b/docs/development/native-appearance-welcome/HANDOFF.md index 5cfb6e5c..b145b6eb 100644 --- a/docs/development/native-appearance-welcome/HANDOFF.md +++ b/docs/development/native-appearance-welcome/HANDOFF.md @@ -1,5 +1,13 @@ # Native appearance and Welcome Studio handoff +## Slice 2 state (Kitty native shaders) — read first + +- Slice 2 branch `feature/kitty-native-shaders-20261028` (worktree `/Volumes/T7Tmp/ai-dev/worktrees/notMyShell/kitty-native-shaders-20261028`) is stacked on Slice 1 `feature/appearance-welcome-20261028` @ 4abd014d (PR #515, draft, unmerged). Its PR targets the Slice 1 branch so the diff is Slice 2 only; after #515 merges, rebase/retarget to `dev` only after review — never automatically. +- Code map: `src/appearance/kittyShaderCatalog.ts` (Kitty-parity parser, constants, curated presets, `kitty +runpy` discovery), `src/appearance/kittyShaders.ts` (inspection, composition, refusals, ledger, plan/apply/remove, sync, launch inspection, `writtenKittyTrail`), `src/appearance/kittyShaderSettings.ts` (persisted `hostShaders.kitty`), `src/appearance/KittyShaderPanel.ts` (studio UI), wiring in `TerminalApp.ts` (`openKittyShaders`, `handleKittyShaderKey`, `syncKittyShaderFiles`, `addKittyShaderRow`), hub row in `AppearanceHub.ts`, cursor ownership in `cursor/backends.ts` (`kittyTrail`, `KITTY_LIGHTNING_BACKEND`) and the Portable `cursor_trail` fix in `cursor/native.ts`. +- Owned files (macOS default): `~/Library/Application Support/notMyShell/host-appearance/kitty-shaders.{conf,pipeline,json}` plus one recorded hook `# NMSh Kitty shaders …` / `include …kitty-shaders.conf` at the end of the effective kitty.conf. +- Next slices (all still required): Ghostty GLSL backend enhancements; GPU-enhanced prompt/Chroma/Clean-Rich/completion feedback (only where documented targeting exists); full Shader Studio (remaining catalog: crt-blue, fireworks-rockets, bloom/tft/water/underwater/negative shaders, per-preset viewport); animated Fastfetch/Neofetch and imported logos; Welcome Studio; real full-terminal animated welcome preview; integration, performance and physical QA. +- Physical Kitty/Ghostty/Zed QA: NOT RUN. GPU rendering: NOT verified (headless compile only). + ## Objective and required scope Implement native GPU Shader Studio and host appearance, plus a comprehensive Welcome Studio under /appearance. /cursor and /welcome must open canonical editors. Preserve persistent zsh/Bash/Fish PTY, input ownership, raw output, copy, viewport and existing settings. No duplicate engines or contradictory Clean/Rich, Chroma, cursor or welcome settings. diff --git a/docs/development/native-appearance-welcome/INDEPENDENT_REVIEW.md b/docs/development/native-appearance-welcome/INDEPENDENT_REVIEW.md index 1bfe4f76..ea4313d1 100644 --- a/docs/development/native-appearance-welcome/INDEPENDENT_REVIEW.md +++ b/docs/development/native-appearance-welcome/INDEPENDENT_REVIEW.md @@ -17,3 +17,16 @@ No blocker. Both new fixes verified correct by reading (`withoutHook` LF/CRLF/em - should-fix 2 (plausible): Kitty on macOS also reads `~/Library/Preferences/kitty/kitty.conf`; Kitty uses the FIRST existing config directory, unlike Ghostty's all-roots load. Verify against installed Kitty 0.49.2 (`kitty.constants.config_dir` logic) before changing hostPaths/readHostConfiguration. - minor: removal preview omits a restored line break; hub guidance not re-rendered after async read when hub closed; tiny window after apply where hub shows pre-write values; rename drops hardlinks/xattrs; stale XDG path blocks removal (fails closed). Recorded, not fixed in Slice 1. - Resolution: should-fix 1 FIXED (removal with the hook already absent deletes only the hash-verified fragment and ledger; the host file is a dependency, not changed). should-fix 2 CONFIRMED by probing installed Kitty (kitty-config-dir-evidence.md) and FIXED (worse than reported: Kitty would have loaded a new hook-only XDG file instead of the user's Preferences config). Minor items remain documented. + +## Slice 2 review (2026-10-28, separate read-only Sonnet reviewer, uncommitted diff on 4abd014d) + +Reviewer read the diff against Kitty v0.49.2 `slang.py`, ran tsc and the two focused files (pass). No blocker. + +Should-fix, all FIXED with regressions: +1. Removal refused when an owned fragment/pipeline was missing or hand-edited; and sync refused (drift, launch, inspection problems) even when the gates left no NMSh effect, so Reduced Motion could not turn effects off. → removal is lenient (deletes intact owned files, keeps an edited one unreferenced, skips a missing one, removes hook + ledger); taking every NMSh effect out writes NMSh-owned files directly, regardless of drift or launch state; adding reviewed groups back skips the launch re-check but still refuses on drift. +2. A written Kitty trail with cursor_trail 0 made Portable stop drawing while Kitty drew nothing. → `writtenKittyTrail` requires the recorded hook present, an active fragment, a trail group AND effective cursor_trail > 0; cursor facts are refreshed when NMSh's Kitty cursor fragment changes. +3. A failed/unsupported catalog read was cached for the process (and a rejection could leave the panel stuck). → only supported catalogs are cached; failures retry; rejections become an unsupported catalog. +4. One failing build disables the whole shared `end` slot (user shaders too). → disclosed in the review guidance; leaked vars that NMSh cannot pin (multi-line or repeated declarations) now refuse instead of being missed. + +Minor, handled: `~user/` entries refuse (not guessed); R works on unsupported Kitty; confirmation re-plans and refuses if gates/files changed since the review; opening the studio applies gate changes made while NMSh was not running. +Minor, documented only: symlinked user pipelines refuse (safe, by design of Slice 1 reads); `ps` launch check can block up to 2 s on open/review/sync; Unicode-space splitting in `shellWords` differs from shlex (resolution only; raw value preserved); `unitPair` accepts hex numbers Kitty rejects (user pipelines only; NMSh never emits them). diff --git a/docs/development/native-appearance-welcome/PLAN.md b/docs/development/native-appearance-welcome/PLAN.md index 409bea3c..d4d685e6 100644 --- a/docs/development/native-appearance-welcome/PLAN.md +++ b/docs/development/native-appearance-welcome/PLAN.md @@ -20,10 +20,10 @@ Interfaces: readHostAppearance(host,context) returns true values/origins/problem ## Slice2: real native shader backends Files: cursor/backends.ts/native.ts, new appearance shader catalog and host-specific pipeline generators; deterministic backend/config tests. -- [ ] Test version detection, installed catalog checks, controls, ordering/texture/event validation, opacity conflicts and no duplicate trail. -- [ ] Implement Kitty Slang/pipeline and Ghostty GLSL as distinct generators, referencing installed shipped sources when safe; original/community sources require provenance/individual license review. -- [ ] Add curated controls/persistence and safe ownership-based configuration plans; unknown/unconfigured hosts and Portable choice remain truthful. -- [ ] Verify host compilation in private configs where possible; distinguish files/compilation from physical rendered output. Review and open separate PR. +- [x] Test version detection, installed catalog checks, controls, ordering/texture/event validation, opacity conflicts and no duplicate trail. (Kitty) +- [x] Kitty Slang/pipeline generator referencing installed shipped shaders by name (kittyShaderCatalog.ts, kittyShaders.ts). [ ] Ghostty GLSL enhancements: next Ghostty slice (separate PR). +- [x] Curated controls/persistence and safe ownership-based plans for Kitty (KittyShaderPanel.ts, /appearance row, /cursor link); Portable/unsupported states truthful. +- [x] Kitty headless compile in private config (tests/kittyShadersLive.test.ts); NOT GPU rendering. Review and stacked PR: see TEST_RESULTS/HANDOFF. ## Slice3: semantic animation integration Files: motion/transitions.ts, existing Chroma/motion/cursor/queue/prompt/notification/idle call sites and tests. diff --git a/docs/development/native-appearance-welcome/RESEARCH.md b/docs/development/native-appearance-welcome/RESEARCH.md index 15f559a2..8ea8791d 100644 --- a/docs/development/native-appearance-welcome/RESEARCH.md +++ b/docs/development/native-appearance-welcome/RESEARCH.md @@ -1,5 +1,18 @@ # Preliminary verified research (2026-10-28) +## Slice 2 — Kitty 0.49.2 custom shaders (verified 2026-10-28) + +Sources: installed Kitty 0.49.2 (headless `kitty +runpy`, private HOME/config/cache under the T7 evidence folder `slice2-probe/`), official https://sw.kovidgoyal.net/kitty/custom-shaders/, and `kitty/shaders/slang.py` at tag v0.49.2 (saved as `slice2-probe/src/kitty_shaders_slang.py`). + +- Catalog: `list_kitty_resources('kitty.shaders.custom')` + `read_kitty_resource(name, 'kitty.shaders.custom')` return every shipped `.pipeline` and `.slang` (no loose files on disk). 0.49.2 ships pipelines crt-blue, cursor-trail-{blaze,default,lightning,motion-blur}, dim-inactive-windows, fireworks, fireworks-rockets, focus-highlight, inside-the-matrix, northern-lights, pond-ripple, spotlight, tab-change; extra shaders bloom, crt, negative, sample, tft, underwater, water; modules types/pipeline. +- `custom_shaders` is ONE value split with POSIX shlex; a later line replaces earlier ones (confirmed with Kitty's own `load_config` through an include). Names are loaded in order and their groups concatenated (`merge_pipelines`), at most 16 groups in total. +- Lookup: absolute path (`~` expanded) → `/shaders/.pipeline` → shipped pipeline → a shader of that name with a default one-group pipeline. Shaders in a group: `/.slang` → `/.slang` → shipped. A same-named user `.slang` therefore replaces a shipped shader. +- Grammar (`parse_pipeline_definition`): top level `slot end`, `textures a|b|persist`, `var`, `startgroup`; group `viewport_pos/size`, `output_texture`, `shaders`, `var`, `animation_start` (events joined with `|`), `animation_stop` (events, ms, `never`), `animation_curve` (CSS names, cubic-bezier/linear/steps), `animation_step` (ms ≥ 0), `attach`, `endgroup`. First group cannot attach; final group outputs default with no viewport. Events: pointer-left-button-press, os-window-focus-in/out, window-focus-in/out, tab-change, bell-in-window, user-activity, user-idle, cursor-trail-move, cursor-trail-stop. +- `var [=] `: types int/uint/float/double/bool and 2–4 vectors. It replaces only `static const = …` lines (regex); a type mismatch silently does nothing. Pipeline-level vars of ALL chained pipelines merge into one dict applied to every group (group vars win) — they leak across pipelines. Named textures are shared by name across pipelines. +- A group subscribing to `cursor-trail-move` suppresses Kitty's built-in trail; trail shaders draw only while `cursor_trail` > 0. +- Validation capability: Kitty bundles `slangc` (`/Applications/kitty.app/Contents/MacOS/slangc`). `parse_pipeline` + `merge_pipelines` + `build_custom_shader_pipeline_glsl` compile a configuration headlessly (~1–3 s) in a private cache. This proves parse + Slang→GLSL compilation only, NOT GPU rendering. Kitty logs build errors itself at load. No documented reload-on-command without remote control; `auto_reload_config` defaults to reloading changed config files. +- Licensing: shipped shaders are Kitty's (GPLv3; CRT's original is CC BY-NC-SA 3.0; Blaze ports a third-party gist). NMSh references them BY NAME from the installed Kitty and never copies their source into the repository or the user's files; tests use synthetic stand-ins with the same structure. + This is not completed implementation research. Pin exact upstream revisions and per-preset variables/licenses before vendoring or generating production pipelines. ## Kitty diff --git a/docs/development/native-appearance-welcome/TEST_RESULTS.md b/docs/development/native-appearance-welcome/TEST_RESULTS.md index ca033b09..1cbb5013 100644 --- a/docs/development/native-appearance-welcome/TEST_RESULTS.md +++ b/docs/development/native-appearance-welcome/TEST_RESULTS.md @@ -35,3 +35,20 @@ Extra private-file newline-boundary diagnostic FAILED as expected: actual backgr - Failures: `absolutePathLive` (live fish) and `ansiLiveRegion` (live 40-column frontend). Both: frontend wrote 0 bytes in 20 s (startup timeout); load averages 12–14 during the run. Neither touches appearance code. Codex's three earlier failures (executionTimelineLive zsh/bash, flowComposer fullscreen) PASSED in this run. - Each failing file rerun alone once (`slice1-live-reruns-claude.log`): absolutePathLive 3/3 pass, ansiLiveRegion 16/16 pass. Classified as load-induced startup timeouts; no comparison against dev needed since both pass alone. Not a clean canonical pass. - After verify, review fixes (Kitty first-existing config dir incl. macOS Library/Preferences; removal when the hook is already absent) added with regressions that failed first. Focused hostAppearance, appearanceHub, hostIntegration, hostProfiles, ghosttyKeyboard: 39/39 pass (`slice1-focused-claude.log`); typecheck 0 errors; diff --check pass. These three files are imported only by host appearance code, so the canonical run covers the rest of the tree. CI on the pushed head is the next independent check. + +## Slice 2 — Kitty native shaders (fresh Claude session, 2026-10-28) + +Source: branch feature/kitty-native-shaders-20261028 from 4abd014d. All Kitty runs were headless (`kitty +runpy`) with private HOME, KITTY_CONFIG_DIRECTORY and KITTY_CACHE_DIRECTORY under the T7 evidence folder or test temp dirs; no Kitty window, installed app, real config or running instance was touched. + +- `t7run --check`: PASSED. `npm ci` in the new worktree: PASSED (slice2-npm-ci.log). +- PR #515 at 4abd014d: OPEN draft; CI run 40640910786 completed success on 4abd014d (all Quality/Tests/Verify jobs; timing smoke and Fedora skipped by design). Not re-run. Slice 1's local canonical run had 2 load-induced live startup timeouts (absolutePathLive fish, ansiLiveRegion) that passed alone; that remains a non-clean local run, not proof they are harmless. +- Probes (evidence `slice2-probe/`): packaged catalog listed/extracted; option docs dumped; `custom_shaders` shlex parsing and later-line replacement confirmed with Kitty's own parser; headless `build_custom_shader_pipeline_glsl` compile of a generated pipeline: OK. +- Red→green: `cursor: a Portable renderer turns Kitty’s own trail off…` FAILED on the old src/cursor/native.ts ("Portable draws the trail itself"), PASSED after the fix. +- Live installed-Kitty test (tests/kittyShadersLive.test.ts): PASSED — Kitty 0.49.2's load_config sees exactly [crt-blue, "my glow", NMSh pipeline path with a space] through an include, and slangc compiled the 7 merged groups. Compilation only; GPU output NOT verified. +- Focused (kittyShaders, kittyShaderPanel, kittyShadersLive, cursorCapabilities, cursorEffects, hostAppearance, appearanceHub, hostIntegration, hostProfiles): all passed at the time of writing; `tsc --noEmit` clean; `git diff --check` clean. +- Independent review (INDEPENDENT_REVIEW.md, Slice 2): no blocker; four should-fix items fixed with regressions (lenient removal, strip-sync always allowed, written-trail requires cursor_trail > 0 and the hook, failed catalog not cached, unpinnable leaked vars refuse, ~user refuse, R on unsupported, re-plan at confirm, sync on studio open). Kitty files after fixes: kittyShaders + kittyShaderPanel + kittyShadersLive 26/26 pass; tsc clean. +- Canonical `npm run verify` on source manifest a658e970… (log `slice2-canonical-verify.log`, 13:33–13:44): build and typecheck passed; tests 4636, pass 4621, fail 1, skipped 13, todo 1 (existing Arcade TODO); npm exit 1, so `git diff --check` did not run inside verify (run separately: pass). NOT a clean canonical pass. + - Failure: `outputSelectionLive` "live fish: new Copy navigation…" — `timed out waiting for completed journal for echo READY` (fish frontend startup under suite load; no appearance code involved). Rerun of that file alone through the runner's private environment (`slice2-live-rerun.log`): 3/3 pass, code 0, no leftover processes. Classified as a load-induced live startup timeout like Slice 1's two; not proven harmless. +- Source manifest unchanged after verify (only docs edited since). + +NOT RUN: physical Kitty/Ghostty/Zed QA, GPU rendering, performance measurement, Ghostty shader work. diff --git a/docs/development/native-appearance-welcome/progress.md b/docs/development/native-appearance-welcome/progress.md index 3e096fbb..58ec3055 100644 --- a/docs/development/native-appearance-welcome/progress.md +++ b/docs/development/native-appearance-welcome/progress.md @@ -1,5 +1,14 @@ # Native appearance / welcome progress +## Slice 2 — Kitty native shaders (fresh Claude session, 2026-10-28) + +Branch `feature/kitty-native-shaders-20261028` from 4abd014d in worktree `/Volumes/T7Tmp/ai-dev/worktrees/notMyShell/kitty-native-shaders-20261028`. Slice 1 (PR #515, branch feature/appearance-welcome-20261028) is untouched; its one uncommitted progress.md line stays in its own worktree. + +- Verified: PR #515 open/draft at 4abd014d; CI run 40640910786 success at that SHA (not re-run). #513 (a57568be) and draft #514 (416bc51a) untouched. +- Implemented (see DECISIONS Slice 2): catalog discovery through Kitty's packaged-resource API; Kitty-parity pipeline parser; shader-constant extraction; curated presets; settings persistence (`hostShaders.kitty`); composition preserving the user's effective custom_shaders verbatim; refusal matrix; ownership ledger + snapshot transaction (Slice 1 primitives); removal restoring exact bytes; gate sync; launch-override inspection; `/appearance › Kitty shaders` studio (toggle, tune, order, placement, exact review, remove); /cursor integration (Kitty trail shader owns the trail; Portable fix). +- Tests: tests/kittyShaders.test.ts, tests/kittyShaderPanel.test.ts, tests/kittyShadersLive.test.ts (installed Kitty: own load_config parity + slangc compile, private HOME; skipped without Kitty 0.49+). See TEST_RESULTS. +- NOT done in this slice (remain in scope, PLAN slices 2–6): Ghostty GLSL backend enhancements, GPU prompt/Chroma/Clean-Rich/completion feedback, full Shader Studio, animated fetch logos, Welcome Studio, full-terminal welcome preview, performance and physical QA. Physical Kitty/Ghostty/Zed QA NOT RUN; GPU rendering NOT verified. + ## Claude continuation — 2026-10-28 (supersedes the Codex state below) Codex hit its usage limit mid-checkpoint; Claude Code resumed in the same worktree and branch. Nothing was reset or discarded. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 3f63d5ab..08cbe263 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -128,7 +128,7 @@ import { } from '../ui/SettingsPanel.js'; import {OUTPUT_FOLDING_MODES} from '../output/FoldPolicy.js'; import {accessSync, appendFileSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync} from 'node:fs'; -import {shouldProbeGraphics} from '../host/capabilities.js'; +import {shouldProbeGraphics, terminalProfile} from '../host/capabilities.js'; import {basename, delimiter, dirname, extname, join, resolve as resolvePath} from 'node:path'; import {spawn} from 'node:child_process'; import {completionMenuRows, renderCompletion, renderCompletionMore, COMPLETION_ACTIONS} from '../shell/CompletionMenu.js'; @@ -323,6 +323,10 @@ import {createDoctorPanel, doctorKey, renderDoctorPanel, type DoctorPanelState} import {signatureAccent} from '../session/signatures.js'; import {renameSession} from '../session/SocketSessionClient.js'; import {appearanceHubKey, createAppearanceHub, hubMotionPreview, renderAppearanceHub, type AppearanceHubState} from '../appearance/AppearanceHub.js'; +import {createKittyShaderPanel, kittyShaderKey, renderKittyShaderPanel, type KittyShaderPanelState} from '../appearance/KittyShaderPanel.js'; +import {discoverKittyCatalog, kittyBinary, type KittyCatalog} from '../appearance/kittyShaderCatalog.js'; +import {applyKittyShaderPlan, inspectKittyLaunch, inspectKittyShaders, kittyShaderPaths, planKittyShaderRemoval, planKittyShaders, readProcessCommand, syncKittyShaders, writtenKittyTrail, + type KittyShaderContext, type ShaderGates} from '../appearance/kittyShaders.js'; import {diffModules, progress, transitionPaint, Transitions, type MotionGate} from '../motion/transitions.js'; import {overlayRow, type CellPaint} from '../presentation/cellOverlay.js'; import {chooseBackend, hostCursorFacts, nativeBackendFor, setCursorHostProvider, type BackendChoice, type HostCursorFacts} from '../cursor/backends.js'; @@ -635,6 +639,8 @@ export class TerminalApp { this.renderer.setCursorStyle(cursorStyleSequence(next.cursor.shape, next.cursor.blink)); if (JSON.stringify(previous.cursor) !== JSON.stringify(next.cursor) || previous.nmsh.palette !== next.nmsh.palette || previous.nmsh.accent !== next.nmsh.accent || JSON.stringify(previous.customTheme) !== JSON.stringify(next.customTheme)) this.syncCursorNative(next); + if (previous.cursor.renderer !== next.cursor.renderer || previous.presentation.reducedMotion !== next.presentation.reducedMotion + || previous.presentation.effectsOff !== next.presentation.effectsOff) void this.syncKittyShaderFiles(next); } // Off: no model use from this window, and the shared service is told to unload. if (turnedOff) this.understanding?.modeChanged(); @@ -1036,6 +1042,10 @@ export class TerminalApp { private welcomeBlinkCount = 0; private contextGeneration = 0; private appearanceHub?: AppearanceHubState; + /** /appearance › Kitty shaders (Kitty 0.49+ pipelines); opened from the hub, Esc returns there. */ + private kittyShaderPanel?: KittyShaderPanelState; + /** The installed Kitty catalog, read once per process through Kitty's own resource API. */ + private kittyCatalogRead?: Promise; private keyboardState?: KeyboardState; /** * Where the currently-open top-level panel (prompt/transcript/appearance/ @@ -2467,6 +2477,11 @@ export class TerminalApp { } return; } + if (this.kittyShaderPanel) { + this.handleKittyShaderKey(key, this.kittyShaderPanel); + this.render(); + return; + } if (this.appearanceHub) { const action = appearanceHubKey(this.appearanceHub, key, this.promptConfiguration); if (action?.kind === 'close') { this.appearanceHub = undefined; this.returnFromPanel(); } @@ -2481,6 +2496,7 @@ export class TerminalApp { else if (action.destination === 'theme') this.openThemeStudio(); else if (action.destination === 'themeBridge') void this.openThemeBridge(); else if (action.destination === 'cursor') { this.panelOrigin = undefined; this.openCursorPanel(); } + else if (action.destination === 'kittyShaders') void this.openKittyShaders(); else if (action.destination === 'chroma') this.startChromaSettings(); else this.focusConfigRow('uiChrome'); } @@ -3818,14 +3834,111 @@ export class TerminalApp { if (inspected.problems.length) { this.appearanceHub = createAppearanceHub(this.host.name, undefined, inspected.problems.join(' ')); this.appearanceHub.canRestoreHost = true; + this.addKittyShaderRow(this.appearanceHub); this.render(); return; } } this.appearanceHub = createAppearanceHub(this.host.name, host, this.host.appearanceGuidance); this.appearanceHub.canRestoreHost = Boolean(this.host.integration); + this.addKittyShaderRow(this.appearanceHub); + this.render(); + } + + /** In Kitty the hub gains its shader row; the summary says what is chosen, never that it is loaded. */ + private addKittyShaderRow(hub: AppearanceHubState): void { + if (terminalProfile(process.env) !== 'kitty') return; + const chosen = this.promptConfiguration.hostShaders.kitty.effects.filter(effect => effect.enabled).length; + hub.kittyShaders = chosen ? `${chosen} effect${chosen === 1 ? '' : 's'} chosen · Kitty 0.49+` : 'Off · Kitty 0.49+ GPU effects'; + } + + /** A supported catalog is kept for the process; a failed or unsupported read is retried next time (a cold Kitty can time out). */ + private kittyCatalog(): Promise { + const read = this.kittyCatalogRead ??= discoverKittyCatalog(kittyBinary()).catch(() => discoverKittyCatalog(undefined)); + void read.then(catalog => { if (!catalog.supported && this.kittyCatalogRead === read) this.kittyCatalogRead = undefined; }); + return read; + } + + /** Reduced Motion, Effects Off and the cursor renderer decide what NMSh's Kitty pipeline may contain. */ + private shaderGates(config: PromptConfiguration = this.promptConfiguration): ShaderGates { + return {reducedMotion: isReducedMotion() || config.presentation.reducedMotion, effectsOff: config.presentation.effectsOff, cursorRenderer: config.cursor.renderer}; + } + + private kittyShaderContext(catalog: KittyCatalog, config: PromptConfiguration = this.promptConfiguration): KittyShaderContext { + const base = {env: process.env, platform: process.platform}; + return {...base, catalog, gates: this.shaderGates(config), launch: inspectKittyLaunch(base, readProcessCommand)}; + } + + private async openKittyShaders(): Promise { + const panel = createKittyShaderPanel(this.promptConfiguration.hostShaders.kitty, this.shaderGates()); + this.kittyShaderPanel = panel; + this.render(); + const catalog = await this.kittyCatalog(); + if (this.stopped || this.kittyShaderPanel !== panel) return; + panel.catalog = catalog; + // Opening the studio applies gate changes made while NMSh was not running (OS Reduced Motion, an edited config). + await this.syncKittyShaderFiles(this.promptConfiguration); + if (this.stopped || this.kittyShaderPanel !== panel) return; + try { panel.inspection = inspectKittyShaders(this.kittyShaderContext(catalog)); } catch (error) { panel.message = error instanceof Error ? error.message : String(error); } this.render(); } + private handleKittyShaderKey(key: Key, panel: KittyShaderPanelState): void { + const action = kittyShaderKey(panel, key); + if (!action) return; + if (action.kind === 'back') { + // Esc discards the unconfirmed draft and returns to /appearance. + this.kittyShaderPanel = undefined; + void this.startAppearance(); + return; + } + const catalog = panel.catalog; + if (!catalog) return; + if (action.kind === 'review' || action.kind === 'remove') { + const planned = action.kind === 'review' ? planKittyShaders(panel.draft, this.kittyShaderContext(catalog)) : planKittyShaderRemoval({env: process.env, platform: process.platform}); + if (planned.ok) panel.confirmation = {plan: planned.plan, offset: 0}; + else panel.message = planned.error; + return; + } + const reviewed = panel.confirmation?.plan; + panel.confirmation = undefined; + if (!reviewed) return; + // Gates or files may have changed while the review was open: confirm only the plan that would be made now. + const plan = reviewed.operation === 'remove' ? reviewed : (() => { + const again = planKittyShaders(panel.draft, this.kittyShaderContext(catalog)); + return again.ok && again.plan.pipelineText === reviewed.pipelineText && JSON.stringify(again.plan.effectiveShaders) === JSON.stringify(reviewed.effectiveShaders) ? reviewed : undefined; + })(); + if (!plan) { panel.message = 'Something changed since the review (Reduced Motion, the cursor renderer or Kitty’s configuration); nothing was written. Press S to review again.'; return; } + const applied = applyKittyShaderPlan(plan); + if (!applied.ok) { panel.message = applied.error; return; } + this.cursorHost = undefined; // the Kitty trail shader decides what Portable may draw + const saved = plan.operation === 'remove' + ? {...panel.draft, effects: panel.draft.effects.map(effect => ({...effect, enabled: false}))} : structuredClone(panel.draft); + this.updateConfiguration(configuration => { configuration.hostShaders.kitty = saved; }); + panel.draft = structuredClone(saved); + panel.dirty = false; + try { panel.inspection = inspectKittyShaders(this.kittyShaderContext(catalog)); } catch { /* the message below still applies */ } + panel.message = plan.operation === 'remove' ? `Removed NMSh's Kitty shaders. ${plan.guidance}` : plan.guidance; + } + + /** + * After a reviewed setup, keep NMSh's own Kitty pipeline in step with Reduced Motion, Effects Off and the cursor renderer. + * Only NMSh-owned files are rewritten; anything needing a new review is said, not written. + */ + private async syncKittyShaderFiles(next: PromptConfiguration): Promise { + const context = {env: process.env, platform: process.platform}; + try { if (!existsSync(kittyShaderPaths(context).ledger)) return; } catch { return; } + const catalog = await this.kittyCatalog(); + if (this.stopped) return; + const result = syncKittyShaders(next.hostShaders.kitty, this.kittyShaderContext(catalog, next)); + if (result.kind === 'written') this.cursorHost = undefined; + if (result.kind === 'written' || result.kind === 'review') { + const message = result.kind === 'review' ? `Kitty shaders were not updated: ${result.message}` : result.message!; + if (this.kittyShaderPanel) this.kittyShaderPanel.message = message; + else this.output.addFrontendInteraction('/appearance', message, result.kind === 'review' ? ERROR : INFO); + this.render(); + } + } + /** Whether a block's Copy control currently confirms a copy. */ private controlOptions(startId: number | undefined): {copied: boolean; safe: boolean} { const copied = this.copiedBlock; @@ -6370,7 +6483,7 @@ export class TerminalApp { private hasModalInput(ignorePalette = false): boolean { return Boolean(this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.themeBridgePanel || this.rowPanel || this.copyPicker || this.summaryReview || this.reportReview || this.comparePanel || this.arcade || this.browserPanel || this.docSession || this.imageView || this.viewer || this.textEditor || this.background.panel || this.batchReview || this.pinsPanel || this.stripStudio || this.configureList || this.tmuxPanel || this.dotfiles || this.setupState || this.installPrompt || this.presetPanel || this.toolsPanel || this.toolConfigurationLoading || this.toolConfiguration || this.promptPanelState || this.transcriptPanelState || this.providerPanelState || (!ignorePalette && this.paletteState) || this.syntaxPanelState || this.layoutPanelState || this.settingsPanelState - || this.resumeBrowser || this.appearanceHub || this.keyboardState || this.startupPanel || this.aboutPanel || this.shellPanel || this.openPanel || this.askState || this.agentView || this.agentPanel || this.launcher || this.modsPanel || this.worktreePanel || this.githubPanel || this.providersOverview || this.understandingPanel || this.cursorPanel || this.doctorPanel || this.watchPanel || this.queuePanel || this.queueHistoryPanel || this.pasteReview || this.misePanel || this.keepAwakePanel); + || this.resumeBrowser || this.appearanceHub || this.kittyShaderPanel || this.keyboardState || this.startupPanel || this.aboutPanel || this.shellPanel || this.openPanel || this.askState || this.agentView || this.agentPanel || this.launcher || this.modsPanel || this.worktreePanel || this.githubPanel || this.providersOverview || this.understandingPanel || this.cursorPanel || this.doctorPanel || this.watchPanel || this.queuePanel || this.queueHistoryPanel || this.pasteReview || this.misePanel || this.keepAwakePanel); } /** Complex panels declare the smallest size that shows their essential controls. */ @@ -6563,6 +6676,7 @@ export class TerminalApp { } return framePanel(rows, columns); } + if (this.kittyShaderPanel) return framePanel(renderKittyShaderPanel(this.kittyShaderPanel, columns, this.dimensions().rows - 4), columns); if (this.appearanceHub) { const theme = themeLabel(this.promptConfiguration.nmsh.palette); const now = Date.now(); @@ -10317,7 +10431,7 @@ export class TerminalApp { } private cursorFacts(): HostCursorFacts { - this.cursorHost ??= hostCursorFacts(process.env, host => nativeCursorIntegrated(host)); + this.cursorHost ??= hostCursorFacts(process.env, host => nativeCursorIntegrated(host), () => writtenKittyTrail({env: process.env, platform: process.platform})); return this.cursorHost; } @@ -10361,6 +10475,13 @@ export class TerminalApp { const host = facts.host === 'other' ? undefined : facts.host; if (action.kind === 'native') { if (!host || !nativeBackendFor(facts)) { state.message = 'This terminal has no native cursor effects NMSh can use; Portable works here.'; return; } + if (host === 'kitty' && (facts.integrated || facts.kittyTrail)) { + // Kitty 0.49+ trail styles live in the one Kitty shader studio; /cursor keeps whether there is a trail and its color. + this.closeCursorPanel(); + this.appearanceHub = undefined; + void this.openKittyShaders(); + return; + } if (facts.integrated) { state.message = `${nativeHostLabel(host)} native is already set up; changes here update NMSh's managed files.`; return; } const setup = setupPlan(host); const blocked = setup.plan.kind === 'refuse' ? setup.plan.reason : undefined; @@ -10378,7 +10499,7 @@ export class TerminalApp { const applied = applyPlan(setup.plan.plan); if (!applied.ok) { state.message = applied.reason; return; } } - this.cursorHost = hostCursorFacts(process.env, value => nativeCursorIntegrated(value)); + this.cursorHost = hostCursorFacts(process.env, value => nativeCursorIntegrated(value), () => writtenKittyTrail({env: process.env, platform: process.platform})); void written; state.message = this.cursorHost.integrated ? (reloadInstruction(native.host, {changed: ['fragment']}) ?? `Set up. Renderer Auto now uses ${nativeHostLabel(native.host)} native.`) @@ -10403,6 +10524,8 @@ export class TerminalApp { try { const written = writeManagedFiles(host, resolveCursorSettings(next.cursor, contextFor(next))); this.cursorReload = reloadInstruction(host, written); + // Kitty's cursor_trail decides whether a Kitty trail shader draws at all; re-read the facts after it changes. + if (host === 'kitty' && written.changed.length) this.cursorHost = undefined; } catch { this.cursorReload = 'Could not update the managed cursor files.'; } } private understandingPanel?: UnderstandingPanelState; diff --git a/src/appearance/AppearanceHub.ts b/src/appearance/AppearanceHub.ts index 852e8136..8441a24d 100644 --- a/src/appearance/AppearanceHub.ts +++ b/src/appearance/AppearanceHub.ts @@ -23,7 +23,7 @@ import {BRIDGE_TARGETS, BRIDGE_TARGET_LABELS, effectiveMode} from '../themeBridg * opacity/blur editing where the host supports it, and say who controls them * where it does not, so the hub is useful in every terminal. */ -export type HubDestination = 'theme' | 'prompt' | 'cursor' | 'chrome' | 'chroma' | 'themeBridge'; +export type HubDestination = 'theme' | 'prompt' | 'cursor' | 'chrome' | 'chroma' | 'themeBridge' | 'kittyShaders'; export interface AppearanceHubState { view: 'hub' | 'motion' | 'motionAdvanced'; @@ -40,15 +40,20 @@ export interface AppearanceHubState { canRestoreHost?: boolean; /** When the Motion preview last started (row selected, value changed, R); one run, never a loop. */ previewStart?: number; + /** Present in Kitty: the Kitty shaders row's summary (the row exists only there). */ + kittyShaders?: string; } export type HubAction = {kind: 'close'} | {kind: 'open'; destination: HubDestination} | {kind: 'motion'; motion: MotionSettings} | {kind: 'saveHost'} | {kind: 'confirmHost'} | {kind: 'restoreHost'}; /** 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}> = [ +const BASE_ROWS: Array<{id: HubDestination | 'motion'; label: string}> = [ {id: 'theme', label: 'Theme Studio'}, {id: 'prompt', label: 'Prompt'}, {id: 'cursor', label: 'Cursor & effects'}, {id: 'chrome', label: 'UI chrome'}, {id: 'chroma', label: 'Chroma'}, {id: 'motion', label: 'Motion'}, {id: 'themeBridge', label: 'Theme Bridge'}, ]; +/** The launcher rows for this host: Kitty adds its shader studio after Cursor & effects. */ +const hubRows = (state: AppearanceHubState) => state.kittyShaders === undefined ? BASE_ROWS + : [...BASE_ROWS.slice(0, 3), {id: 'kittyShaders' as const, label: 'Kitty shaders'}, ...BASE_ROWS.slice(3)]; export function createAppearanceHub(hostName: string, host?: AppearanceState, hostGuidance?: string): AppearanceHubState { return {view: 'hub', selected: 0, ...(host ? {host} : {}), hostName, ...(hostGuidance ? {hostGuidance} : {}), hostDirty: false}; @@ -61,6 +66,7 @@ function hostRowCount(state: AppearanceHubState): number { } export function appearanceHubKey(state: AppearanceHubState, key: Key, configuration: PromptConfiguration, now = Date.now()): HubAction | undefined { + const NMSH_ROWS = hubRows(state); if (state.confirmation) { if (key.kind === 'escape' || key.kind === 'interrupt') { state.confirmation = undefined; return undefined; } if (key.kind === 'up' || key.kind === 'down') state.confirmation.offset = Math.max(0, Math.min(state.confirmation.plan.preview.length - 1, state.confirmation.offset + (key.kind === 'up' ? -1 : 1))); @@ -136,6 +142,7 @@ export function hubMotionPreview(state: AppearanceHubState, configuration: Promp export function renderAppearanceHub(state: AppearanceHubState, configuration: PromptConfiguration, columns: number, themeLabel: string, cursorBackend: string, preview?: {gate: MotionGate; now: number}, height?: number): string[] { + const NMSH_ROWS = hubRows(state); const primary = foreground(UI_COLORS.primary); const secondary = foreground(UI_COLORS.secondary); const subtle = foreground(UI_COLORS.subtle); @@ -188,6 +195,7 @@ export function renderAppearanceHub(state: AppearanceHubState, configuration: Pr 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}`, + kittyShaders: state.kittyShaders ?? '', motion: anyMotion ? `${motion.rendering === 'rich' ? 'Rich' : 'Clean'} · Launch ${MOTION_LABELS[motion.commandLaunch]} · Context ${MOTION_LABELS[motion.contextTransitions]} · Events ${MOTION_LABELS[motion.eventFeedback]}` : 'Off', }; const rows = [`${primary} Appearance${reset}`, ` ${subtle}Everything visual in one place; each row opens its own editor.${reset}`, '', ` ${subtle}NMSh${reset}`]; diff --git a/src/appearance/KittyShaderPanel.ts b/src/appearance/KittyShaderPanel.ts new file mode 100644 index 00000000..034bbac9 --- /dev/null +++ b/src/appearance/KittyShaderPanel.ts @@ -0,0 +1,250 @@ +/** + * /appearance › Kitty shaders: choose Kitty 0.49+ shipped shader effects, + * tune the settings the installed shaders really expose, order them, and + * review the exact host change before anything is written. The draft is the + * panel's own until a reviewed plan is confirmed; Esc discards it. + */ +import type {Key} from '../terminal/keys.js'; +import {renderControls} from '../ui/controls.js'; +import {checkMark, GLYPHS} from '../ui/glyphs.js'; +import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; +import {padCells, truncateAnsi} from '../util/text.js'; +import {stripTerminalControls} from '../util/terminalControls.js'; +import {CATEGORY_LABELS, type KittyCatalog, type OfferedParam, type OfferedPreset, type PresetCategory, type ShaderValue} from './kittyShaderCatalog.js'; +import type {KittyShaderInspection, KittyShaderPlan, ShaderGates} from './kittyShaders.js'; +import type {KittyShaderEffect, KittyShaderSettings} from './kittyShaderSettings.js'; + +export interface KittyShaderPanelState { + catalog?: KittyCatalog; + inspection?: KittyShaderInspection; + draft: KittyShaderSettings; + /** The draft differs from the saved (last reviewed) settings. */ + dirty: boolean; + selected: number; + gates: ShaderGates; + confirmation?: {plan: KittyShaderPlan; offset: number}; + colorInput?: {preset: string; param: string; text: string}; + message?: string; +} + +export type KittyShaderAction = {kind: 'back'} | {kind: 'review'} | {kind: 'remove'} | {kind: 'confirm'}; + +type Row = {kind: 'placement'} | {kind: 'preset'; preset: OfferedPreset} | {kind: 'param'; preset: OfferedPreset; param: OfferedParam}; +const CATEGORY_ORDER: readonly PresetCategory[] = ['cursor', 'ambient', 'interaction', 'focus', 'display']; + +export function createKittyShaderPanel(saved: KittyShaderSettings, gates: ShaderGates): KittyShaderPanelState { + return {draft: structuredClone(saved), dirty: false, selected: 0, gates}; +} + +const effectOf = (state: KittyShaderPanelState, id: string) => state.draft.effects.find(effect => effect.id === id); +const enabled = (state: KittyShaderPanelState, id: string) => effectOf(state, id)?.enabled === true; + +/** Selectable rows: Placement, then presets by category, each enabled preset followed by its parameters. */ +function rows(state: KittyShaderPanelState): Row[] { + const offered = state.catalog?.offered ?? []; + const list: Row[] = [{kind: 'placement'}]; + for (const category of CATEGORY_ORDER) { + for (const preset of offered.filter(item => item.definition.category === category)) { + list.push({kind: 'preset', preset}); + if (enabled(state, preset.definition.id)) for (const param of preset.params) list.push({kind: 'param', preset, param}); + } + } + return list; +} + +export function paramValue(state: KittyShaderPanelState, preset: OfferedPreset, param: OfferedParam): ShaderValue { + return effectOf(state, preset.definition.id)?.values[param.name] ?? param.default; +} + +const hex = (value: ShaderValue) => Array.isArray(value) ? `#${value.slice(0, 3).map(part => Math.round(Math.min(1, Math.max(0, part)) * 255).toString(16).padStart(2, '0')).join('')}` : ''; +const stepped = (value: number, param: OfferedParam, direction: number) => { + const step = param.step ?? 0.01; + const next = Math.round((value + direction * step) / step) * step; + const places = (String(step).split('.')[1] ?? '').length; + return Math.min(param.max ?? Infinity, Math.max(param.min ?? -Infinity, Number(next.toFixed(places)))); +}; + +function display(param: OfferedParam, value: ShaderValue): string { + if (param.kind === 'bool') return value ? 'On' : 'Off'; + if (param.kind === 'color') return hex(value); + if (param.kind === 'choice') return param.choices?.find(([choice]) => choice === value)?.[1] ?? String(value); + const places = (String(param.step ?? 0.01).split('.')[1] ?? '').length; + return typeof value === 'number' ? value.toFixed(places) : String(value); +} + +function ensure(state: KittyShaderPanelState, id: string): KittyShaderEffect { + let effect = effectOf(state, id); + if (!effect) { effect = {id, enabled: false, values: {}}; state.draft.effects.push(effect); } + return effect; +} + +function setValue(state: KittyShaderPanelState, preset: OfferedPreset, param: OfferedParam, value: ShaderValue): void { + ensure(state, preset.definition.id).values[param.name] = value; + state.dirty = true; +} + +function toggle(state: KittyShaderPanelState, preset: OfferedPreset): void { + const effect = ensure(state, preset.definition.id); + effect.enabled = !effect.enabled; + // One choice per exclusive group (one cursor trail; one focus-highlight shader). + if (effect.enabled && preset.definition.exclusive) { + for (const other of state.catalog?.offered ?? []) { + if (other !== preset && other.definition.exclusive === preset.definition.exclusive) { const item = effectOf(state, other.definition.id); if (item) item.enabled = false; } + } + } + state.dirty = true; +} + +/** Move an enabled effect one place earlier or later in NMSh's pipeline order. */ +function move(state: KittyShaderPanelState, id: string, direction: -1 | 1): void { + const order = state.draft.effects.filter(effect => effect.enabled); + const index = order.findIndex(effect => effect.id === id); + const target = order[index + direction]; + if (index < 0 || !target) return; + const all = state.draft.effects; + const a = all.findIndex(effect => effect.id === id); + const b = all.findIndex(effect => effect.id === target.id); + [all[a], all[b]] = [all[b]!, all[a]!]; + state.dirty = true; +} + +export function kittyShaderKey(state: KittyShaderPanelState, key: Key): KittyShaderAction | undefined { + if (state.confirmation) { + if (key.kind === 'escape' || key.kind === 'interrupt') { state.confirmation = undefined; state.message = 'Nothing was changed.'; return undefined; } + if (key.kind === 'up' || key.kind === 'down') state.confirmation.offset = Math.max(0, Math.min(state.confirmation.plan.preview.length - 1, state.confirmation.offset + (key.kind === 'up' ? -1 : 1))); + return key.kind === 'enter' ? {kind: 'confirm'} : undefined; + } + if (state.colorInput) { + const input = state.colorInput; + if (key.kind === 'escape' || key.kind === 'interrupt') state.colorInput = undefined; + else if (key.kind === 'backspace') input.text = input.text.slice(0, -1); + else if (key.kind === 'text') input.text = (input.text + key.value.replace(/[^#0-9a-f]/giu, '')).slice(0, 7); + else if (key.kind === 'enter') { + const preset = state.catalog?.offered.find(item => item.definition.id === input.preset); + const param = preset?.params.find(item => item.name === input.param); + if (!preset || !param) { state.colorInput = undefined; return undefined; } + if (!/^#?[0-9a-f]{6}$/iu.test(input.text)) { state.message = 'Enter six RGB digits, such as #3399ff.'; return undefined; } + const digits = input.text.replace(/^#/u, ''); + const rgb = [0, 2, 4].map(offset => Number((parseInt(digits.slice(offset, offset + 2), 16) / 255).toFixed(3))); + const current = paramValue(state, preset, param); + setValue(state, preset, param, param.type === 'float4' ? [...rgb, Array.isArray(current) ? current[3] ?? 1 : 1] : rgb); + state.colorInput = undefined; + } + return undefined; + } + if (key.kind === 'escape' || key.kind === 'interrupt') return {kind: 'back'}; + // Removal needs no catalog: it works on an unsupported or downgraded Kitty too. + if (key.kind === 'text' && key.value.toLowerCase() === 'r' && state.catalog) return {kind: 'remove'}; + if (!state.catalog?.supported) return undefined; + const list = rows(state); + if (key.kind === 'up' || key.kind === 'down') { state.selected = (state.selected + (key.kind === 'up' ? -1 : 1) + list.length) % list.length; return undefined; } + if (key.kind === 'text' && key.value.toLowerCase() === 's') return {kind: 'review'}; + const row = list[Math.min(state.selected, list.length - 1)]!; + state.message = undefined; + if (row.kind === 'placement') { + if (key.kind === 'left' || key.kind === 'right' || key.kind === 'enter') { state.draft.placement = state.draft.placement === 'after' ? 'before' : 'after'; state.dirty = true; } + return undefined; + } + if (row.kind === 'preset') { + if (key.kind === 'enter' || (key.kind === 'text' && key.value === ' ')) toggle(state, row.preset); + else if (key.kind === 'selectUp' || key.kind === 'selectDown') move(state, row.preset.definition.id, key.kind === 'selectUp' ? -1 : 1); + return undefined; + } + const {preset, param} = row; + const value = paramValue(state, preset, param); + if (param.kind === 'color') { if (key.kind === 'enter') state.colorInput = {preset: preset.definition.id, param: param.name, text: ''}; return undefined; } + if (key.kind !== 'left' && key.kind !== 'right' && key.kind !== 'enter') return undefined; + const direction = key.kind === 'left' ? -1 : 1; + if (param.kind === 'bool') setValue(state, preset, param, !value); + else if (param.kind === 'choice') { + const choices = param.choices ?? []; + const index = choices.findIndex(([choice]) => choice === value); + setValue(state, preset, param, choices[(index + direction + choices.length) % choices.length]![0]); + } else if (typeof value === 'number' && key.kind !== 'enter') setValue(state, preset, param, stepped(value, param, direction)); + return undefined; +} + +function status(state: KittyShaderPanelState): string { + const inspection = state.inspection; + if (!inspection) return 'Reading Kitty’s configuration…'; + if (inspection.drifted) return 'Your own custom_shaders changed since NMSh composed with it; S reviews an updated plan.'; + if (inspection.configured) return `Set up · ${inspection.activePresets.length ? `${inspection.activePresets.length} NMSh effect${inspection.activePresets.length === 1 ? '' : 's'} in Kitty’s config` : 'no NMSh effect active'}`; + return 'Not set up · nothing in Kitty’s configuration is changed until you review and confirm'; +} + +export function renderKittyShaderPanel(state: KittyShaderPanelState, 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 clean = (text: string) => stripTerminalControls(text); + if (state.confirmation) { + const review = state.confirmation; + const available = Math.max(1, (height ?? 24) - 7); + return [`${primary} Review Kitty shader configuration${reset}`, ' Enter confirms exactly this plan; Esc keeps every file unchanged.', + ...review.plan.preview.slice(review.offset, review.offset + available).map(line => ` ${clean(line)}`), '', ` ${subtle}${clean(review.plan.guidance)}${reset}`, + renderControls([['↑↓', 'scroll'], ['Enter', 'confirm write'], ['Esc', 'cancel']])].map(row => truncateAnsi(row, columns)); + } + const head = [`${primary} Appearance › Kitty shaders${reset}`]; + const catalog = state.catalog; + if (!catalog) return [...head, '', ` ${subtle}Reading Kitty’s installed shader catalog…${reset}`, '', renderControls([['Esc', 'back']])].map(row => truncateAnsi(row, columns)); + if (!catalog.supported) { + return [...head, '', ` ${secondary}${clean(catalog.reason ?? 'Kitty custom shaders are not available.')}${reset}`, + ` ${subtle}Cursor effects stay with /cursor (Portable, or Kitty’s built-in trail).${reset}`, ...(state.inspection?.configured ? [` ${subtle}R reviews removal of NMSh’s existing Kitty shader files.${reset}`] : []), + '', renderControls([['Esc', 'back'], ...(state.inspection?.configured ? [['R', 'remove'] as [string, string]] : [])])].map(row => truncateAnsi(row, columns)); + } + const inspection = state.inspection; + const gate = state.gates.reducedMotion ? 'Reduced Motion is on: NMSh keeps Kitty effects out of its pipeline until it is off.' + : state.gates.effectsOff ? 'Decorative effects are Off: NMSh keeps Kitty effects out of its pipeline until they are on.' + : state.gates.cursorRenderer === 'portable' ? 'Cursor renderer is Portable: NMSh draws the trail, so no Kitty trail is added.' : undefined; + const intro = [` ${subtle}Kitty ${clean(catalog.version)} · shipped shaders read from Kitty itself · effects cover the whole Kitty window, not only NMSh${reset}`, + ` ${subtle}${clean(status(state))}${reset}`, + ` ${subtle}${inspection ? (inspection.userEntries.length ? `Your shaders (kept, in order): ${clean(inspection.userEntries.join(' · '))}` : 'You have no custom_shaders of your own.') : ''}${reset}`, + ...(gate ? [` ${secondary}${gate}${reset}`] : [])]; + const list = rows(state); + const selected = Math.min(state.selected, list.length - 1); + const mark = (active: boolean) => active ? `${accent}${GLYPHS.selection}${reset}` : ' '; + const order = state.draft.effects.filter(effect => effect.enabled).map(effect => effect.id); + const body: string[] = []; + let category: PresetCategory | undefined; + list.forEach((row, index) => { + const active = index === selected; + if (row.kind === 'placement') { + const value = state.draft.placement === 'after' ? 'After your shaders' : 'Before your shaders'; + body.push('', `${mark(active)} ${focusForeground(active)}${padCells('Placement', 24)}${reset}${active ? `${accent}‹ ${value} ›${reset}` : `${secondary}${value}${reset}`}`); + return; + } + if (row.kind === 'preset') { + if (row.preset.definition.category !== category) { category = row.preset.definition.category; body.push(` ${subtle}${CATEGORY_LABELS[category]}${category === 'cursor' ? ' (one)' : ''}${reset}`); } + const on = enabled(state, row.preset.definition.id); + const position = on && order.length > 1 ? ` ${subtle}#${order.indexOf(row.preset.definition.id) + 1}${reset}` : ''; + body.push(`${mark(active)} ${on ? accent : subtle}${checkMark(on)}${reset} ${focusForeground(active)}${row.preset.definition.label}${reset}${position}`); + return; + } + const value = display(row.param, paramValue(state, row.preset, row.param)); + const editing = state.colorInput && state.colorInput.preset === row.preset.definition.id && state.colorInput.param === row.param.name; + const shown = editing ? `RGB: ${state.colorInput!.text}_` : value; + body.push(`${mark(active)} ${focusForeground(active)}${padCells(row.param.label, 20)}${reset}${active && !editing && row.param.kind !== 'color' ? `${accent}‹ ${shown} ›${reset}` : `${secondary}${shown}${reset}`}`); + }); + const current = list[selected]; + const preset = current && current.kind !== 'placement' ? current.preset : undefined; + const note = current?.kind === 'placement' ? 'Kitty runs shaders in list order; your own stay in their order either way.' + : preset ? [preset.definition.note, !preset.params.length && !preset.definition.note ? 'Kitty exposes no adjustable settings for this shader.' : undefined, + preset.missing.length ? `Not offered here: ${preset.missing.join(', ')} (the installed shader does not declare them as NMSh expects).` : undefined, + current?.kind === 'param' && current.param.kind === 'color' ? 'Enter types an RGB color.' : undefined].filter(Boolean).join(' ') : ''; + const footer = [ + ...(note ? ['', ` ${subtle}${clean(note)}${reset}`] : []), + ...(catalog.notOffered.length ? [` ${subtle}Installed, not offered yet: ${clean(catalog.notOffered.join(', '))}${reset}`] : []), + ...(inspection?.problems.length ? [` ${secondary}${clean(inspection.problems[0]!)}${reset}`] : []), + ...(state.message ? [` ${secondary}${clean(state.message)}${reset}`] : []), + '', renderControls([['↑↓', 'select'], ['Enter', current?.kind === 'preset' ? 'on/off' : 'change'], ['←→', 'adjust'], ['⇧↑↓', 'order'], ['S', 'review'], ['R', 'remove'], ['Esc', 'back']]), + ]; + const all = [...head, ...intro, ...body, ...footer]; + if (height === undefined || all.length <= height) return all.map(row => truncateAnsi(row, columns)); + // Short windows keep the title, the selected row's neighbourhood and the footer. + const room = Math.max(1, height - head.length - footer.length); + const selectedLine = body.findIndex(line => line.startsWith(mark(true))); + const start = Math.max(0, Math.min(body.length - room, selectedLine - Math.floor(room / 2))); + return [...head, ...body.slice(start, start + room), ...footer].slice(0, Math.max(1, height)).map(row => truncateAnsi(row, columns)); +} diff --git a/src/appearance/hostAppearance.ts b/src/appearance/hostAppearance.ts index 7b52f6a0..7d286145 100644 --- a/src/appearance/hostAppearance.ts +++ b/src/appearance/hostAppearance.ts @@ -127,7 +127,7 @@ export function planHostAppearance(host: AppearanceHost, settings: HostAppearanc * that missing line break, so lines the user appended later need one back; with nothing * after the hook the original bytes return unchanged. */ -function withoutHook(content: string, hook: string): string { +export function withoutHook(content: string, hook: string): string { const at = content.indexOf(hook); const prefix = content.slice(0, at); const suffix = content.slice(at + hook.length); diff --git a/src/appearance/hostConfiguration.ts b/src/appearance/hostConfiguration.ts index ae564904..1b4f7abd 100644 --- a/src/appearance/hostConfiguration.ts +++ b/src/appearance/hostConfiguration.ts @@ -3,13 +3,16 @@ import {dirname, isAbsolute, resolve} from 'node:path'; import {snapshot, type FileSnapshot} from './hostFiles.js'; import {appearancePaths, type AppearanceContext, type AppearanceHost} from './hostPaths.js'; -export interface Definition {value: string; path: string; line: number} -export interface ConfigurationRead {hostPath: string; values: Map; files: FileSnapshot[]; problems: string[]} +/** `raw` is the trimmed value exactly as written (before outer-quote removal), for values with their own quoting rules. */ +export interface Definition {value: string; raw: string; path: string; line: number} +/** `definitions` lists every non-include definition in load order (later ones win), for disclosing shadowed lines. */ +export interface ConfigurationRead {hostPath: string; values: Map; definitions: Array; files: FileSnapshot[]; problems: string[]} /** Overrides simulate a reviewed integration; they never change files or the captured preconditions. */ export function readHostConfiguration(host: AppearanceHost, context: AppearanceContext, overrides?: ReadonlyMap): ConfigurationRead { const paths = appearancePaths(host, context); const files = new Map(); const values = new Map(); + const definitions: Array = []; const problems: string[] = []; const queue: Array<{path: string; stack: readonly string[]; optional: boolean}> = []; let totalBytes = 0; @@ -48,7 +51,9 @@ export function readHostConfiguration(host: AppearanceHost, context: AppearanceC if (/[\u0000-\u001f\u007f-\u009f]/u.test(raw!)) throw new Error('A configuration value contains terminal controls.'); if (host === 'kitty' && ['geninclude', 'envinclude', 'globinclude'].includes(key!)) throw new Error('Dynamic Kitty includes cannot be evaluated safely.'); if (key === (host === 'ghostty' ? 'config-file' : 'include')) { follow(raw!); return; } - values.set(key!, {value: unquote(raw!.trim()), path, line: index + 1}); + const definition = {value: unquote(raw!.trim()), raw: raw!.trim(), path, line: index + 1}; + values.set(key!, definition); + if (definitions.length < 4096) definitions.push({...definition, key: key!}); }); }; let hostPath = paths.fallback; @@ -70,5 +75,5 @@ export function readHostConfiguration(host: AppearanceHost, context: AppearanceC loaded.add(entry.path); walk(entry.path, entry.stack, entry.optional); } } catch (error) { problems.push(error instanceof Error ? error.message : String(error)); } - return {hostPath, values, files: [...files.values()], problems}; + return {hostPath, values, definitions, files: [...files.values()], problems}; } diff --git a/src/appearance/kittyShaderCatalog.ts b/src/appearance/kittyShaderCatalog.ts new file mode 100644 index 00000000..92a5f35a --- /dev/null +++ b/src/appearance/kittyShaderCatalog.ts @@ -0,0 +1,465 @@ +/** + * Kitty 0.49+ custom shader catalog: bounded data parsers for Kitty's + * `.pipeline` grammar and the tunable `static const` declarations of its + * shipped `.slang` shaders, plus discovery of the installed catalog through + * Kitty's own packaged-resource API (`kitty +runpy`, no shader execution). + * + * Grammar and rules mirror kitty/shaders/slang.py at v0.49.2 + * (parse_pipeline_definition, parse_var_directive, merge_pipelines, + * apply_pipeline_specializations). Nothing here compiles, runs or trusts + * shader source: it is read as text and only literal declarations are used. + */ +import {spawn} from 'node:child_process'; +import {createHash} from 'node:crypto'; +import {existsSync} from 'node:fs'; +import {delimiter, join} from 'node:path'; + +export const KITTY_SHADER_MIN_VERSION = [0, 49] as const; +/** Kitty's MAX_CUSTOM_SHADER_GROUPS: the limit for one pipeline and for all chained pipelines together. */ +export const KITTY_MAX_GROUPS = 16; +const MAX_PIPELINE_BYTES = 64 * 1024; +const MAX_PIPELINE_LINES = 512; +const SCALAR_TYPES = ['uint', 'int', 'float', 'double', 'bool'] as const; +export const KITTY_TEXTURES = ['a', 'b', 'persist', 'default'] as const; +export type KittyTexture = typeof KITTY_TEXTURES[number]; +export const KITTY_ANIMATION_EVENTS = ['pointer-left-button-press', 'os-window-focus-in', 'os-window-focus-out', 'window-focus-in', 'window-focus-out', + 'tab-change', 'bell-in-window', 'user-activity', 'user-idle', 'cursor-trail-move', 'cursor-trail-stop'] as const; +const NAMED_CURVES = ['ease-in-out', 'linear', 'ease', 'ease-out', 'ease-in', 'step-start', 'step-end']; + +export interface KittyVar {type: string; name: string; value: string} +export interface KittyGroup { + shaders: string[]; + /** Group-scoped vars in declaration order (a later one with the same name wins, as in Kitty). */ + vars: KittyVar[]; + animationStart?: string; + animationStop?: string; + animationCurve?: string; + animationStep?: number; + viewportPos?: string; + viewportSize?: string; + outputTexture?: KittyTexture; + attach: boolean; +} +export interface KittyPipeline {textures: KittyTexture[]; vars: KittyVar[]; groups: KittyGroup[]} +export type ParseResult = {ok: true; value: T} | {ok: false; error: string}; + +const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/u; +const isVarType = (type: string) => (SCALAR_TYPES as readonly string[]).includes(/[234]$/u.test(type) ? type.slice(0, -1) : type); +const isEvent = (name: string) => (KITTY_ANIMATION_EVENTS as readonly string[]).includes(name); + +function parseVar(parts: string[]): KittyVar { + if (parts.length < 4) throw new Error('var directive requires: var [=] '); + const [, type, name] = parts as [string, string, string]; + if (!isVarType(type)) throw new Error(`var type ${type} is not supported by Kitty`); + if (!IDENTIFIER.test(name)) throw new Error(`var name ${name} is not a valid identifier`); + const rest = parts[3] === '=' ? parts.slice(4) : parts.slice(3); + if (!rest.length) throw new Error('var directive missing value after ='); + return {type, name, value: rest.join(' ').replace(/;+$/u, '')}; +} + +function events(raw: string): string { + for (const event of raw.split('|')) if (!isEvent(event)) throw new Error(`${event} is not a Kitty shader animation event`); + return raw; +} + +function curve(spec: string): string { + const value = spec.trim(); + if (NAMED_CURVES.includes(value)) return value; + const match = /^([-a-zA-Z]+)\(([^)]+)\)$/u.exec(value); + if (!match || !['cubic-bezier', 'linear', 'steps'].includes(match[1]!)) throw new Error(`${value} is not a recognized animation curve`); + // Kitty validates parameters when it builds the easing function; accept only plain numbers (and steps' start/end). + if (!match[2]!.split(',').every(part => /^\s*(?:-?\d+(?:\.\d+)?%?|start|end|jump-\w+)\s*$/u.test(part))) throw new Error(`${value} has unsupported curve parameters`); + return value; +} + +const unitPair = (parts: string[]) => { + if (parts.length < 2 || parts.slice(1, 3).some(part => !Number.isFinite(Number(part)))) throw new Error(`${parts[0]} needs one or two numbers`); + return parts.slice(1, 3).join(' '); +}; + +/** Parse a `.pipeline` file exactly as Kitty 0.49 would accept or reject it. Bounded; never follows anything. */ +export function parseKittyPipeline(text: string, name: string): ParseResult { + try { + if (Buffer.byteLength(text) > MAX_PIPELINE_BYTES) throw new Error('Pipeline is larger than 64 KiB.'); + const lines = text.split(/\r?\n/u); + if (lines.length > MAX_PIPELINE_LINES) throw new Error('Pipeline has too many lines.'); + const pipeline: KittyPipeline = {textures: [], vars: [], groups: []}; + let group: KittyGroup | undefined; + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + if (/[\u0000-\u0008\u000b-\u001f\u007f]/u.test(line)) throw new Error('Pipeline contains control characters.'); + const parts = line.split(/\s+/u); + const key = parts[0]!; + if (!group) { + if (key === 'slot') { if (parts[1] !== 'end') throw new Error(`slot ${parts[1] ?? ''} is not valid`); } + else if (key === 'textures') { + for (const texture of parts.slice(1)) if (!(KITTY_TEXTURES as readonly string[]).includes(texture)) throw new Error(`${texture} is not a Kitty texture name`); + pipeline.textures = parts.slice(1) as KittyTexture[]; + } else if (key === 'startgroup') group = {shaders: [], vars: [], attach: false}; + else if (key === 'var') pipeline.vars.push(parseVar(parts)); + else throw new Error(`Unknown key ${key}`); + continue; + } + switch (key) { + case 'viewport_pos': group.viewportPos = unitPair(parts); break; + case 'viewport_size': group.viewportSize = unitPair(parts); break; + case 'output_texture': + if (!(KITTY_TEXTURES as readonly string[]).includes(parts[1] ?? '')) throw new Error(`output_texture ${parts[1] ?? ''} is not valid`); + group.outputTexture = parts[1] as KittyTexture; break; + case 'shaders': + for (const shader of parts.slice(1)) if (!/^[A-Za-z0-9_.~/-]+$/u.test(shader)) throw new Error(`Shader name ${shader} is not supported`); + group.shaders.push(...parts.slice(1)); break; + case 'var': group.vars.push(parseVar(parts)); break; + case 'animation_start': { + const value = parts.slice(1).join('') || 'none'; + if (value === 'none') delete group.animationStart; else group.animationStart = events(value); + break; + } + case 'animation_curve': + if (parts.length < 2) throw new Error('animation_curve requires a CSS curve name'); + group.animationCurve = curve(parts.slice(1).join(' ')); break; + case 'animation_step': { + const step = Number(parts[1]); + if (!/^\d+$/u.test(parts[1] ?? '') || !Number.isSafeInteger(step)) throw new Error('animation_step must be a non-negative integer'); + group.animationStep = step; break; + } + case 'animation_stop': { + const value = parts.slice(1).join('') || 'never'; + if (value !== 'never') for (const token of value.split('|')) if (!/^-?\d+$/u.test(token)) events(token); + group.animationStop = value; break; + } + case 'attach': group.attach = true; break; + case 'endgroup': pipeline.groups.push(group); group = undefined; break; + default: throw new Error(`Unknown key ${key}`); + } + } + if (group) throw new Error('Unclosed group present'); + if (!pipeline.groups.length) pipeline.groups.push({shaders: [name], vars: [], attach: false}); + if (pipeline.groups.length > KITTY_MAX_GROUPS) throw new Error(`Pipeline has ${pipeline.groups.length} groups; Kitty allows ${KITTY_MAX_GROUPS}`); + if (pipeline.groups[0]!.attach) throw new Error('The first group in a pipeline cannot use attach'); + const last = pipeline.groups.at(-1)!; + if (last.outputTexture && last.outputTexture !== 'default') throw new Error('The final group cannot output to a named texture'); + if (last.viewportPos || last.viewportSize) throw new Error('The final group must not specify a viewport'); + return {ok: true, value: pipeline}; + } catch (error) { return {ok: false, error: error instanceof Error ? error.message : String(error)}; } +} + +/** Kitty's default pipeline for a bare shader name: one group, every frame. */ +export const defaultPipeline = (shader: string): KittyPipeline => ({textures: [], vars: [], groups: [{shaders: [shader], vars: [], attach: false}]}); + +/** Every named texture a pipeline allocates or writes (Kitty shares these names across chained pipelines). */ +export const pipelineTextures = (pipeline: KittyPipeline): KittyTexture[] => + [...new Set([...pipeline.textures, ...pipeline.groups.flatMap(group => group.outputTexture ? [group.outputTexture] : [])])].filter(texture => texture !== 'default'); + +export const subscribesToCursorTrail = (group: KittyGroup) => (group.animationStart ?? '').split('|').includes('cursor-trail-move'); + +/** Canonical pipeline text. Every group var is written explicitly; no pipeline-level var is ever emitted. */ +export function emitKittyPipeline(pipeline: KittyPipeline, header: readonly string[] = []): string { + if (pipeline.vars.length) throw new Error('NMSh never writes pipeline-level vars (Kitty applies them to every chained pipeline).'); + const lines = header.map(line => `# ${line.replace(/[\r\n]/gu, ' ')}`); + if (pipeline.textures.length) lines.push(`textures ${pipeline.textures.join(' ')}`); + for (const group of pipeline.groups) { + lines.push('', 'startgroup'); + if (group.attach) lines.push(' attach'); + if (group.animationStart) lines.push(` animation_start ${group.animationStart}`); + if (group.animationStop) lines.push(` animation_stop ${group.animationStop}`); + if (group.animationCurve) lines.push(` animation_curve ${group.animationCurve}`); + if (group.animationStep !== undefined) lines.push(` animation_step ${group.animationStep}`); + if (group.viewportPos) lines.push(` viewport_pos ${group.viewportPos}`); + if (group.viewportSize) lines.push(` viewport_size ${group.viewportSize}`); + for (const variable of group.vars) lines.push(` var ${variable.type} ${variable.name} = ${variable.value}`); + lines.push(` shaders ${group.shaders.join(' ')}`); + if (group.outputTexture) lines.push(` output_texture ${group.outputTexture}`); + lines.push('endgroup'); + } + return `${lines.join('\n')}\n`; +} + +// ---- Shader constants -------------------------------------------------------------------------------------------- + +export type ShaderValue = number | boolean | number[]; +export interface ShaderConstant {type: string; name: string; raw: string; value?: ShaderValue} + +const NUMBER = /^-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?[fF]?$/u; +const num = (text: string) => Number(text.replace(/[fF]$/u, '')); + +/** A literal default Kitty's specialization could replace, or undefined for expressions and anything else. */ +export function parseLiteral(type: string, raw: string): ShaderValue | undefined { + const value = raw.trim(); + if (type === 'bool') return value === 'true' ? true : value === 'false' ? false : undefined; + if (type === 'int' || type === 'uint') return /^-?\d+$/u.test(value) && (type === 'int' || !value.startsWith('-')) ? Number(value) : undefined; + if (type === 'float' || type === 'double') return NUMBER.test(value) ? num(value) : undefined; + const vector = /^(float|int)([234])$/u.exec(type); + if (!vector) return undefined; + const match = new RegExp(`^${type}\\(([^()]*)\\)$`, 'u').exec(value); + if (!match) return undefined; + const parts = match[1]!.split(',').map(part => part.trim()); + const size = Number(vector[2]); + if (!parts.every(part => NUMBER.test(part)) || (parts.length !== size && parts.length !== 1)) return undefined; + const values = parts.map(num); + return parts.length === 1 ? Array.from({length: size}, () => values[0]!) : values; +} + +/** The `static const` declarations Kitty's `var` can replace (one line each, same regex shape as Kitty). */ +export function parseShaderConstants(source: string): Map { + const constants = new Map(); + for (const line of source.split(/\r?\n/u).slice(0, 4096)) { + const start = /^\s*static\s+const\s+([A-Za-z0-9]+)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=/u.exec(line); + if (!start) continue; + const [, type, name] = start as unknown as [string, string, string]; + if (!isVarType(type)) continue; + const match = /=\s*([^;]*);/u.exec(line.slice(start[0].length - 1)); + // A declaration continued on later lines, or repeated with another value, has no single line NMSh could pin (raw ''). + const raw = match ? match[1]!.trim() : ''; + const existing = constants.get(name); + if (existing) { if (existing.raw !== raw) constants.set(name, {type: existing.type, name, raw: ''}); continue; } + const value = raw ? parseLiteral(type, raw) : undefined; + constants.set(name, {type, name, raw, ...(value !== undefined ? {value} : {})}); + } + return constants; +} + +/** A value as Slang source. Floats always carry a decimal point (Kitty compiles with warnings as errors). */ +export function formatShaderValue(type: string, value: ShaderValue): string { + const float = (n: number) => { const text = Number(n.toFixed(6)).toString(); return /[.eE]/u.test(text) ? text : `${text}.0`; }; + if (type === 'bool') { if (typeof value !== 'boolean') throw new Error('Expected a boolean'); return String(value); } + if (type === 'int' || type === 'uint') { if (typeof value !== 'number' || !Number.isSafeInteger(value)) throw new Error('Expected an integer'); return String(value); } + if (type === 'float' || type === 'double') { if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error('Expected a number'); return float(value); } + const vector = /^(float|int)([234])$/u.exec(type); + if (!vector || !Array.isArray(value) || value.length !== Number(vector[2]) || !value.every(Number.isFinite)) throw new Error(`Expected ${type}`); + return `${type}(${value.map(item => vector[1] === 'int' ? String(Math.round(item)) : float(item)).join(', ')})`; +} + +// ---- Curated presets --------------------------------------------------------------------------------------------- + +export type PresetCategory = 'cursor' | 'ambient' | 'interaction' | 'focus' | 'display'; +export const CATEGORY_LABELS: Record = {cursor: 'Cursor trail', ambient: 'Ambient', interaction: 'Pointer', focus: 'Focus & tabs', display: 'Display'}; +export interface PresetParam { + name: string; label: string; kind: 'number' | 'bool' | 'color' | 'choice'; + /** Kitty's declared type this control expects; a different declaration means the parameter is not offered. */ + type: string; + min?: number; max?: number; step?: number; + choices?: ReadonlyArray; +} +export interface PresetDefinition { + id: string; label: string; category: PresetCategory; + /** A shipped pipeline (its group structure is reused) or a shipped shader with Kitty's default pipeline. */ + source: {pipeline: string} | {shader: string}; + params: readonly PresetParam[]; + /** Presets sharing a key cannot be enabled together. */ + exclusive?: string; + requires?: 'cursor_trail' | 'opaque'; + note?: string; +} + +const n = (name: string, label: string, min: number, max: number, step: number, type = 'float'): PresetParam => ({name, label, kind: 'number', type, min, max, step}); +const color = (name: string, label: string, type: 'float3' | 'float4'): PresetParam => ({name, label, kind: 'color', type}); +const flag = (name: string, label: string): PresetParam => ({name, label, kind: 'bool', type: 'bool'}); + +/** + * NMSh's first offered presets. Bounds are NMSh's (safe UI ranges); every + * parameter is offered only when the INSTALLED shader declares it with this + * type and a literal default, which becomes the starting value. + */ +export const KITTY_PRESETS: readonly PresetDefinition[] = [ + {id: 'cursor-trail-default', label: 'Default trail', category: 'cursor', source: {pipeline: 'cursor-trail-default'}, params: [], exclusive: 'cursor', requires: 'cursor_trail', + note: 'Kitty exposes no shader settings for this trail; its color is cursor_trail_color (set from /cursor).'}, + {id: 'cursor-trail-blaze', label: 'Blaze trail', category: 'cursor', source: {pipeline: 'cursor-trail-blaze'}, exclusive: 'cursor', requires: 'cursor_trail', + params: [n('DURATION', 'Duration (s)', 0.1, 2, 0.05), color('TRAIL_COLOR', 'Fill color', 'float4'), color('TRAIL_COLOR_ACCENT', 'Edge color', 'float4')]}, + {id: 'cursor-trail-lightning', label: 'Lightning trail', category: 'cursor', source: {pipeline: 'cursor-trail-lightning'}, exclusive: 'cursor', requires: 'cursor_trail', + params: [n('DURATION', 'Duration (s)', 0.05, 1, 0.01), n('JAGGEDNESS', 'Jaggedness', 0, 0.3, 0.005), n('CORE_WIDTH', 'Core width', 0.5, 4, 0.05), + n('GLOW_WIDTH', 'Glow width', 1, 12, 0.5), color('BOLT_COLOR', 'Bolt color', 'float3'), color('GLOW_COLOR', 'Glow color', 'float3')]}, + {id: 'cursor-trail-motion-blur', label: 'Motion blur trail', category: 'cursor', source: {pipeline: 'cursor-trail-motion-blur'}, exclusive: 'cursor', requires: 'cursor_trail', + params: [flag('MOTION_BLUR', 'Motion blur'), n('MOTION_BLUR_SAMPLES', 'Blur samples', 1, 64, 1, 'int'), flag('ANTIALIASING', 'Antialiasing'), flag('CONNECTED', 'Connected streak')]}, + {id: 'northern-lights', label: 'Northern Lights', category: 'ambient', source: {pipeline: 'northern-lights'}, + params: [n('EFFECT_INTENSITY', 'Intensity', 0, 1, 0.05), n('AURORA_INTENSITY', 'Aurora', 0, 1, 0.05), n('TIME_SCALE', 'Speed', 0.05, 2, 0.05), n('STAR_BRIGHTNESS', 'Stars', 0, 2, 0.1)]}, + {id: 'inside-the-matrix', label: 'Matrix', category: 'ambient', source: {pipeline: 'inside-the-matrix'}, params: [n('SPEED', 'Speed', 0.1, 2, 0.05)]}, + {id: 'fireworks', label: 'Fireworks', category: 'ambient', source: {pipeline: 'fireworks'}, + params: [n('TIME_SCALE', 'Speed', 0.1, 2, 0.05), n('NUM_EXPLOSIONS', 'Explosions', 1, 6, 1, 'int')]}, + {id: 'pond-ripple', label: 'Pond Ripple', category: 'interaction', source: {pipeline: 'pond-ripple'}, + params: [n('WAVE_SPEED', 'Wave speed', 0.1, 1.5, 0.05), n('AMPLITUDE', 'Strength', 0, 0.05, 0.001), n('WAVE_FREQUENCY', 'Frequency', 2, 40, 1)], + note: 'Starts on a left mouse click.'}, + {id: 'spotlight', label: 'Spotlight', category: 'interaction', source: {pipeline: 'spotlight'}, + params: [n('SPOTLIGHT_RADIUS', 'Radius', 0.05, 0.6, 0.01), n('AMBIENT_LIGHT', 'Ambient light', 0, 1, 0.05)]}, + {id: 'focus-highlight', label: 'Focus highlight', category: 'focus', source: {pipeline: 'focus-highlight'}, exclusive: 'focus-shader', + params: [n('HIGHLIGHT_INTENSITY', 'Highlight', 0, 1, 0.05), n('BORDER_WIDTH', 'Border width', 0, 0.2, 0.01), n('INACTIVE_DIM', 'Inactive kept', 0, 1, 0.05)]}, + {id: 'dim-inactive-windows', label: 'Dim inactive windows', category: 'focus', source: {pipeline: 'dim-inactive-windows'}, exclusive: 'focus-shader', + params: [n('INACTIVE_DIM', 'Inactive kept', 0, 1, 0.05)]}, + {id: 'tab-change', label: 'Tab change', category: 'focus', source: {pipeline: 'tab-change'}, + params: [{name: 'ANIMATION_TYPE', label: 'Style', kind: 'choice', type: 'int', choices: [[0, 'Fade'], [1, 'Wipe'], [2, 'Blinds'], [3, 'Iris']]}, n('NUM_BLINDS', 'Blinds', 2, 24, 1, 'int')]}, + {id: 'crt', label: 'CRT', category: 'display', source: {shader: 'crt'}, requires: 'opaque', + params: [n('WARP', 'Curvature', 0, 1, 0.05), n('SCAN', 'Scanlines', 0, 1, 0.05), color('TINT', 'Tint', 'float4')], + note: 'Kitty notes this shader does not work with background_opacity below 1. Runs every frame.'}, +]; + +// ---- Installed catalog ------------------------------------------------------------------------------------------- + +export interface CatalogShader {name: string; sha256: string; constants: Map} +export interface OfferedParam extends PresetParam {default: ShaderValue} +export interface OfferedPreset { + definition: PresetDefinition; + /** The groups Kitty would run, with the shipped pipeline-level vars moved into each group. */ + groups: KittyGroup[]; + textures: KittyTexture[]; + params: OfferedParam[]; + /** Curated parameters the installed shader does not declare as expected (not offered). */ + missing: string[]; + /** Origin metadata: the exact shipped resources this preset reads. */ + origin: {kittyVersion: string; resources: Array<{name: string; sha256: string}>}; +} +export interface KittyCatalog { + version: string; + supported: boolean; + reason?: string; + pipelines: Map; + shaders: Map; + offered: OfferedPreset[]; + /** Installed pipelines NMSh does not offer yet (kept visible as the catalog). */ + notOffered: string[]; + problems: string[]; +} + +const sha = (text: string) => createHash('sha256').update(text).digest('hex'); + +export function versionAtLeast(version: string, [major, minor]: readonly [number, number]): boolean { + const match = /^(\d+)\.(\d+)/u.exec(version); + return Boolean(match && (Number(match[1]) > major || (Number(match[1]) === major && Number(match[2]) >= minor))); +} + +/** Build the catalog from shipped resource texts (name → content, e.g. "crt.slang"). */ +export function buildKittyCatalog(version: string, resources: Readonly>): KittyCatalog { + const catalog: KittyCatalog = {version, supported: false, pipelines: new Map(), shaders: new Map(), offered: [], notOffered: [], problems: []}; + if (!versionAtLeast(version, KITTY_SHADER_MIN_VERSION)) { + catalog.reason = `Kitty ${version || '(unknown version)'} has no custom shader pipelines; they need Kitty ${KITTY_SHADER_MIN_VERSION.join('.')} or newer.`; + return catalog; + } + for (const [file, content] of Object.entries(resources)) { + const match = /^([a-z0-9-]+)\.(pipeline|slang)$/u.exec(file); + if (!match) continue; + const [, base, kind] = match as unknown as [string, string, string]; + if (kind === 'slang') catalog.shaders.set(base, {name: base, sha256: sha(content), constants: parseShaderConstants(content)}); + else { + const parsed = parseKittyPipeline(content, base); + if (parsed.ok) catalog.pipelines.set(base, {pipeline: parsed.value, sha256: sha(content)}); + else catalog.problems.push(`Shipped pipeline ${base} did not validate: ${parsed.error}`); + } + } + if (!catalog.shaders.has('types')) { catalog.reason = 'Kitty’s shader catalog could not be read (no shader types module).'; return catalog; } + catalog.supported = true; + const offeredPipelines = new Set(); + for (const definition of KITTY_PRESETS) { + const offered = offerPreset(catalog, definition); + if (typeof offered === 'string') { catalog.problems.push(`${definition.label}: ${offered}`); continue; } + catalog.offered.push(offered); + if ('pipeline' in definition.source) offeredPipelines.add(definition.source.pipeline); + } + catalog.notOffered = [...catalog.pipelines.keys()].filter(name => !offeredPipelines.has(name)).sort(); + return catalog; +} + +function offerPreset(catalog: KittyCatalog, definition: PresetDefinition): OfferedPreset | string { + const source = 'pipeline' in definition.source ? catalog.pipelines.get(definition.source.pipeline) : undefined; + if ('pipeline' in definition.source && !source) return 'not installed in this Kitty'; + const pipeline = source?.pipeline ?? defaultPipeline((definition.source as {shader: string}).shader); + const resources = source ? [{name: `${(definition.source as {pipeline: string}).pipeline}.pipeline`, sha256: source.sha256}] : []; + const shaderNames = [...new Set(pipeline.groups.flatMap(group => group.shaders))]; + for (const name of shaderNames) { + const shader = catalog.shaders.get(name); + if (!shader) return `shader ${name} is not shipped with this Kitty`; + resources.push({name: `${name}.slang`, sha256: shader.sha256}); + } + // Pipeline-level vars become group vars, so NMSh's pipeline never leaks values into other chained pipelines. + const groups = pipeline.groups.map(group => ({...group, shaders: [...group.shaders], vars: mergeVars(pipeline.vars, group.vars)})); + const params: OfferedParam[] = []; + const missing: string[] = []; + for (const param of definition.params) { + const declared = shaderNames.map(name => catalog.shaders.get(name)!.constants.get(param.name)).filter(Boolean) as ShaderConstant[]; + const usable = declared.length > 0 && declared.every(constant => constant.type === param.type && constant.value !== undefined); + if (!usable) { missing.push(param.name); continue; } + // The shipped pipeline's own value (e.g. dim-inactive-windows sets HIGHLIGHT_INTENSITY) is the starting value. + const shipped = groups.flatMap(group => group.vars).filter(variable => variable.name === param.name && variable.type === param.type).at(-1); + const fromPipeline = shipped ? parseLiteral(param.type, shipped.value) : undefined; + params.push({...param, default: fromPipeline ?? declared[0]!.value!}); + } + return {definition, groups, textures: pipelineTextures(pipeline), params, missing, origin: {kittyVersion: catalog.version, resources}}; +} + +export function mergeVars(...lists: ReadonlyArray): KittyVar[] { + const merged = new Map(); + for (const list of lists) for (const variable of list) { merged.delete(variable.name); merged.set(variable.name, {...variable}); } + return [...merged.values()]; +} + +// ---- Discovery --------------------------------------------------------------------------------------------------- + +/** + * Fixed NMSh helper run inside Kitty's own interpreter: it lists and reads + * Kitty's packaged shader resources and prints JSON. No shader is compiled or + * executed, no file is written and no user configuration is read. + */ +export const KITTY_CATALOG_SCRIPT = ` +import json, sys +from kitty.constants import str_version +out = {'version': str_version, 'resources': {}} +try: + from kitty.constants import list_kitty_resources, read_kitty_resource + total = 0 + for name in sorted(list_kitty_resources('kitty.shaders.custom')): + if not (name.endswith('.pipeline') or name.endswith('.slang')): + continue + data = read_kitty_resource(name, 'kitty.shaders.custom') + if len(data) > 65536: + continue + total += len(data) + if total > 1048576: + out['truncated'] = True + break + out['resources'][name] = data.decode('utf-8', 'replace') +except Exception as e: + out['error'] = type(e).__name__ +sys.stdout.write(json.dumps(out)) +`; + +export interface CatalogRunner {(binary: string, args: readonly string[], timeoutMs: number): Promise<{code: number | null; stdout: string}>} + +/** A detached child with no terminal: bounded output and time, minimal environment, no bytecode writes. */ +export const runKittyHelper: CatalogRunner = (binary, args, timeoutMs) => new Promise(resolve => { + const env: NodeJS.ProcessEnv = {PATH: process.env.PATH ?? '/usr/bin:/bin', PYTHONDONTWRITEBYTECODE: '1', LANG: 'C.UTF-8', + ...(process.env.HOME ? {HOME: process.env.HOME} : {})}; + let stdout = ''; + let settled = false; + const child = spawn(binary, [...args], {stdio: ['ignore', 'pipe', 'ignore'], detached: true, env}); + const finish = (code: number | null) => { if (settled) return; settled = true; clearTimeout(timer); resolve({code, stdout}); }; + const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } finish(null); }, timeoutMs); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { stdout += chunk; if (stdout.length > 2 * 1024 * 1024) { try { child.kill('SIGKILL'); } catch { /* gone */ } finish(null); } }); + child.on('error', () => finish(null)); + child.on('close', code => finish(code)); +}); + +/** The installed kitty binary: PATH first, then the macOS application bundle. */ +export function kittyBinary(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): string | undefined { + for (const directory of (env.PATH ?? '').split(delimiter).filter(Boolean)) { + const candidate = join(directory, 'kitty'); + if (existsSync(candidate)) return candidate; + } + const bundle = '/Applications/kitty.app/Contents/MacOS/kitty'; + return platform === 'darwin' && existsSync(bundle) ? bundle : undefined; +} + +/** Read the installed catalog. Unsupported or unreadable installations come back with a reason, never a guessed catalog. */ +export async function discoverKittyCatalog(binary: string | undefined, run: CatalogRunner = runKittyHelper): Promise { + const empty = (reason: string, version = ''): KittyCatalog => ({version, supported: false, reason, pipelines: new Map(), shaders: new Map(), offered: [], notOffered: [], problems: []}); + if (!binary) return empty('The kitty program was not found, so its installed shader catalog cannot be read.'); + const result = await run(binary, ['+runpy', KITTY_CATALOG_SCRIPT], 8000); + let parsed: {version?: unknown; resources?: unknown; error?: unknown; truncated?: unknown}; + try { parsed = JSON.parse(result.stdout); } catch { return empty('Kitty did not report its shader catalog (no readable answer within 8 s).'); } + const version = typeof parsed.version === 'string' && /^\d+\.\d+(?:\.\d+)?$/u.test(parsed.version) ? parsed.version : ''; + if (!versionAtLeast(version, KITTY_SHADER_MIN_VERSION)) return buildKittyCatalog(version, {}); + if (parsed.error || !parsed.resources || typeof parsed.resources !== 'object') return empty('Kitty’s packaged shader resources could not be listed.', version); + const resources = Object.fromEntries(Object.entries(parsed.resources as Record).filter(([, value]) => typeof value === 'string')) as Record; + const catalog = buildKittyCatalog(version, resources); + if (parsed.truncated) catalog.problems.push('Kitty’s catalog was larger than NMSh reads; some shipped shaders were skipped.'); + return catalog; +} diff --git a/src/appearance/kittyShaderSettings.ts b/src/appearance/kittyShaderSettings.ts new file mode 100644 index 00000000..60408afa --- /dev/null +++ b/src/appearance/kittyShaderSettings.ts @@ -0,0 +1,33 @@ +/** Persisted NMSh Kitty shader choices (shape only; validity against the installed catalog is checked when planning). */ +import type {ShaderValue} from './kittyShaderCatalog.js'; + +export interface KittyShaderEffect {id: string; enabled: boolean; values: Record} +export interface KittyShaderSettings { + /** Order is pipeline order. Disabled entries keep their tuned values. */ + effects: KittyShaderEffect[]; + /** NMSh's pipeline runs after (default) or before the person's own shaders. */ + placement: 'after' | 'before'; +} +export interface HostShaderSettings {kitty: KittyShaderSettings} +export const DEFAULT_HOST_SHADERS: HostShaderSettings = {kitty: {effects: [], placement: 'after'}}; + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); + +/** Bounded shape normalization only; whether a value is valid for the installed Kitty is decided by validateEffects. */ +export function normalizeHostShaders(value: unknown): HostShaderSettings { + const kitty = isRecord(value) && isRecord(value.kitty) ? value.kitty : {}; + const seen = new Set(); + const effects: KittyShaderEffect[] = []; + for (const item of Array.isArray(kitty.effects) ? kitty.effects.slice(0, 32) : []) { + if (!isRecord(item) || typeof item.id !== 'string' || !/^[a-z0-9-]{1,48}$/u.test(item.id) || seen.has(item.id)) continue; + seen.add(item.id); + const values: Record = {}; + for (const [name, raw] of Object.entries(isRecord(item.values) ? item.values : {}).slice(0, 32)) { + if (!/^[A-Za-z_][A-Za-z0-9_]{0,63}$/u.test(name)) continue; + if (typeof raw === 'boolean' || (typeof raw === 'number' && Number.isFinite(raw))) values[name] = raw; + else if (Array.isArray(raw) && raw.length >= 2 && raw.length <= 4 && raw.every(part => typeof part === 'number' && Number.isFinite(part))) values[name] = [...raw] as number[]; + } + effects.push({id: item.id, enabled: item.enabled === true, values}); + } + return {kitty: {effects, placement: kitty.placement === 'before' ? 'before' : 'after'}}; +} diff --git a/src/appearance/kittyShaders.ts b/src/appearance/kittyShaders.ts new file mode 100644 index 00000000..99a0f783 --- /dev/null +++ b/src/appearance/kittyShaders.ts @@ -0,0 +1,529 @@ +/** + * NMSh-managed Kitty shader pipeline: settings, validation, composition with + * the person's own `custom_shaders`, and reviewed, owned writes. + * + * Kitty has ONE effective `custom_shaders` value (a later line replaces an + * earlier one). NMSh never appends a competing line blindly: it reads the + * effective value through the real configuration graph, keeps it verbatim and + * in order, adds its own pipeline (an absolute path to an NMSh-owned file) + * before or after it, and proves by simulation that the result is what Kitty + * will load. Anything that prevents establishing that — dynamic includes, + * launch overrides, unreadable or unknown pipelines, shadowed shaders, shared + * textures, a second cursor trail, too many groups — refuses the edit. + * + * Files: one include hook in the effective kitty.conf (recorded), NMSh's own + * fragment (`custom_shaders …`), NMSh's own `.pipeline`, and an ownership + * ledger. Writes go through hostFiles' snapshot-verified transaction. + */ +import {execFileSync} from 'node:child_process'; +import {dirname, isAbsolute, join, resolve} from 'node:path'; +import {appearancePaths, type AppearanceContext} from './hostPaths.js'; +import {readHostConfiguration} from './hostConfiguration.js'; +import {digest, snapshot, transact, type FileChange, type FileSnapshot} from './hostFiles.js'; +import {withoutHook} from './hostAppearance.js'; +import {CATEGORY_LABELS, defaultPipeline, emitKittyPipeline, formatShaderValue, KITTY_MAX_GROUPS, mergeVars, parseKittyPipeline, pipelineTextures, + subscribesToCursorTrail, type KittyCatalog, type KittyGroup, type KittyPipeline, type KittyVar, type OfferedParam, type OfferedPreset, type ShaderValue} from './kittyShaderCatalog.js'; +import type {CursorRenderer} from '../prompt/configuration.js'; + +// ---- Settings ---------------------------------------------------------------------------------------------------- + +export {DEFAULT_HOST_SHADERS, normalizeHostShaders, type HostShaderSettings, type KittyShaderEffect, type KittyShaderSettings} from './kittyShaderSettings.js'; +import type {KittyShaderEffect, KittyShaderSettings} from './kittyShaderSettings.js'; + +export interface ShaderGates {reducedMotion: boolean; effectsOff: boolean; cursorRenderer: CursorRenderer} + +/** What NMSh's pipeline may contain right now: nothing under Reduced Motion or Effects Off, no Kitty trail when the cursor renderer is Portable. */ +export function gatedEffects(settings: KittyShaderSettings, catalog: KittyCatalog, gates: ShaderGates): Array<{preset: OfferedPreset; effect: KittyShaderEffect}> { + if (gates.reducedMotion || gates.effectsOff) return []; + return settings.effects.filter(effect => effect.enabled).flatMap(effect => { + const preset = catalog.offered.find(item => item.definition.id === effect.id); + if (!preset || (preset.definition.category === 'cursor' && gates.cursorRenderer === 'portable')) return []; + return [{preset, effect}]; + }); +} + +const valueError = (param: OfferedParam, value: ShaderValue): string | undefined => { + if (param.kind === 'bool') return typeof value === 'boolean' ? undefined : 'expects on or off'; + if (param.kind === 'color') { + const size = param.type === 'float4' ? 4 : 3; + return Array.isArray(value) && value.length === size && value.every(part => part >= 0 && part <= 1) ? undefined : `expects ${size} components between 0 and 1`; + } + if (typeof value !== 'number' || !Number.isFinite(value)) return 'expects a number'; + if ((param.type === 'int' || param.type === 'uint') && !Number.isSafeInteger(value)) return 'expects a whole number'; + if (param.kind === 'choice') return param.choices?.some(([choice]) => choice === value) ? undefined : 'is not one of its choices'; + return value < (param.min ?? -Infinity) || value > (param.max ?? Infinity) ? `must be between ${param.min} and ${param.max}` : undefined; +}; + +/** Every reason the saved settings cannot be applied to THIS Kitty's catalog; no value is clamped or invented. */ +export function validateEffects(settings: KittyShaderSettings, catalog: KittyCatalog): string[] { + const errors: string[] = []; + const exclusive = new Map(); + for (const effect of settings.effects) { + const preset = catalog.offered.find(item => item.definition.id === effect.id); + if (!preset) { errors.push(`${effect.id} is not offered for Kitty ${catalog.version || '(unknown)'}.`); continue; } + const {definition} = preset; + for (const [name, value] of Object.entries(effect.values)) { + const param = preset.params.find(item => item.name === name); + if (!param) { errors.push(`${definition.label}: ${name} is not an adjustable setting of the installed shader.`); continue; } + const problem = valueError(param, value); + if (problem) errors.push(`${definition.label}: ${param.label} ${problem}.`); + } + if (effect.enabled && definition.exclusive) { + const other = exclusive.get(definition.exclusive); + if (other) errors.push(`${definition.label} and ${other} cannot run together.`); + exclusive.set(definition.exclusive, definition.label); + } + } + return errors; +} + +// ---- Value parsing (custom_shaders uses POSIX shell words, as Kitty's shlex.split) --------------------------------- + +export function shellWords(text: string): string[] | undefined { + const words: string[] = []; + let current = ''; + let active = false; + let quote: '"' | "'" | undefined; + for (let index = 0; index < text.length; index++) { + const char = text[index]!; + if (quote === "'") { if (char === "'") quote = undefined; else current += char; continue; } + if (quote === '"') { + if (char === '"') quote = undefined; + else if (char === '\\' && (text[index + 1] === '"' || text[index + 1] === '\\')) current += text[++index]; + else current += char; + continue; + } + if (/\s/u.test(char)) { if (active) { words.push(current); current = ''; active = false; } continue; } + active = true; + if (char === "'" || char === '"') quote = char; + else if (char === '\\') { if (index + 1 >= text.length) return undefined; current += text[++index]; } + else current += char; + } + if (quote) return undefined; + if (active) words.push(current); + return words; +} + +const quoteWord = (word: string) => /^[A-Za-z0-9_@%+=:,./~-]+$/u.test(word) ? word : `'${word}'`; + +// ---- Paths, ledger ----------------------------------------------------------------------------------------------- + +export function kittyShaderPaths(context: AppearanceContext) { + const base = appearancePaths('kitty', context); + return {...base, fragment: join(base.directory, 'kitty-shaders.conf'), pipeline: join(base.directory, 'kitty-shaders.pipeline'), ledger: join(base.directory, 'kitty-shaders.json')}; +} +const HOOK_COMMENT = '# NMSh Kitty shaders (remove using /appearance)'; +const FRAGMENT_HEADER = '# Managed by NMSh (/appearance › Kitty shaders). Your own shaders are kept verbatim and in order.\n'; + +interface ShaderLedger { + version: 1; feature: 'kitty-shaders'; hostPath: string; hook: string; fragmentHash: string; pipelineHash: string; created: boolean; + /** The person's effective custom_shaders value (verbatim) that NMSh composed with; '' when there was none. */ + preserved: string; + placement: 'after' | 'before'; + kittyVersion: string; + presets: Array<{id: string; resources: Array<{name: string; sha256: string}>}>; +} + +/** Strict by default; `lenient` (removal only) reports damaged owned files instead of refusing, so a broken integration can still be removed. */ +function owned(context: AppearanceContext, lenient = false) { + const paths = kittyShaderPaths(context); + const fragment = snapshot(paths.fragment, paths.home, true); + const pipeline = snapshot(paths.pipeline, paths.home, true); + const ledger = snapshot(paths.ledger, paths.home, true); + let record: ShaderLedger | undefined; + if (ledger.content !== undefined) { + let parsed: Partial; + try { parsed = JSON.parse(ledger.content); } catch { throw new Error('The Kitty shader ownership record is malformed; nothing will be overwritten.'); } + const hash = (value: unknown) => typeof value === 'string' && /^[0-9a-f]{64}$/u.test(value); + if (parsed?.version !== 1 || parsed.feature !== 'kitty-shaders' || typeof parsed.created !== 'boolean' || !paths.candidates.includes(parsed.hostPath ?? '') + || typeof parsed.hook !== 'string' || !hash(parsed.fragmentHash) || !hash(parsed.pipelineHash) || typeof parsed.preserved !== 'string' + || (parsed.placement !== 'after' && parsed.placement !== 'before') || !Array.isArray(parsed.presets)) throw new Error('The Kitty shader ownership record is invalid.'); + record = parsed as ShaderLedger; + const newline = record.hook.includes('\r\n') ? '\r\n' : '\n'; + if (record.hook.replace(/\r\n/gu, '\n').trim() !== `${HOOK_COMMENT}\ninclude ${paths.fragment}` || !record.hook.endsWith(`include ${paths.fragment}${newline}`)) throw new Error('The recorded include does not match NMSh’s own Kitty shader hook.'); + } + const damaged = new Map<'fragment' | 'pipeline', 'edited' | 'missing'>(); + for (const [file, expected, label] of [[fragment, record?.fragmentHash, 'fragment'], [pipeline, record?.pipelineHash, 'pipeline']] as const) { + if (file.content !== undefined && (!record || digest(file.content) !== expected)) { + if (lenient && record) { damaged.set(label, 'edited'); continue; } + throw new Error(`The Kitty shader ${label} is unowned or was edited outside NMSh; it was preserved.`); + } + if (record && file.content === undefined) { + if (lenient) { damaged.set(label, 'missing'); continue; } + throw new Error(`The recorded Kitty shader ${label} is missing; R in /appearance › Kitty shaders removes the integration.`); + } + } + return {paths, fragment, pipeline, ledger, record, damaged}; +} + +// ---- Reading the person's shaders ---------------------------------------------------------------------------------- + +export interface UserPipeline {entry: string; kind: 'file' | 'builtin' | 'shader' | 'missing'; pipeline?: KittyPipeline; path?: string} +export interface KittyShaderInspection { + hostPath: string; + configDirectory: string; + /** The person's effective custom_shaders exactly as written ('' when unset), NMSh's own line excluded. */ + userRaw: string; + userEntries: string[]; + pipelines: UserPipeline[]; + /** Earlier custom_shaders lines Kitty already ignores (a later line replaces them). */ + shadowed: string[]; + /** Effective values after NMSh's line is excluded. */ + cursorTrail: number; + backgroundOpacity: number; + /** Reasons composition is impossible to establish safely. */ + problems: string[]; + notes: string[]; + dependencies: FileSnapshot[]; + configured: boolean; + /** The person's own custom_shaders changed since NMSh composed with it: Kitty is still loading the reviewed copy. */ + drifted: boolean; + activePresets: string[]; +} + +export interface KittyLaunch { + /** Why Kitty's launch options (-o/--override, -c/--config) could not be shown to leave custom_shaders alone; undefined when inspected and clear. */ + refusal?: string; +} + +export interface KittyShaderContext extends AppearanceContext {catalog: KittyCatalog; gates: ShaderGates; launch: KittyLaunch} + +function resolveUserPipeline(entry: string, configDirectory: string, home: string, catalog: KittyCatalog, dependencies: FileSnapshot[]): UserPipeline | string { + if (entry.startsWith('~') && entry !== '~' && !entry.startsWith('~/')) return `${entry} uses another user's home (~user), which NMSh does not inspect`; + const expanded = entry === '~' || entry.startsWith('~/') ? resolve(home, entry.slice(2)) : entry; + const read = (path: string) => { const file = snapshot(path, home); dependencies.push(file); return file.content; }; + const parse = (text: string, path?: string): UserPipeline | string => { + const parsed = parseKittyPipeline(text, entry); + return parsed.ok ? {entry, kind: path ? 'file' : 'builtin', pipeline: parsed.value, ...(path ? {path} : {})} : `${entry}: ${parsed.error}`; + }; + try { + if (isAbsolute(expanded)) { + const path = expanded.endsWith('.pipeline') ? expanded : `${expanded}.pipeline`; + const text = read(path); + if (text !== undefined) return parse(text, path); + if (read(`${expanded}.slang`) !== undefined) return {entry, kind: 'shader', pipeline: defaultPipeline(entry), path: `${expanded}.slang`}; + return {entry, kind: 'missing'}; + } + const file = expanded.endsWith('.pipeline') ? expanded : `${expanded}.pipeline`; + const own = read(join(configDirectory, 'shaders', file)); + if (own !== undefined) return parse(own, join(configDirectory, 'shaders', file)); + const base = file.slice(0, -'.pipeline'.length); + const builtin = catalog.pipelines.get(base); + if (builtin) return {entry, kind: 'builtin', pipeline: builtin.pipeline}; + if (read(join(configDirectory, `${entry}.slang`)) !== undefined || catalog.shaders.has(entry)) return {entry, kind: 'shader', pipeline: defaultPipeline(entry)}; + return {entry, kind: 'missing'}; + } catch (error) { + return `${entry} cannot be inspected safely (${error instanceof Error ? error.message : String(error)})`; + } +} + +export function inspectKittyShaders(context: KittyShaderContext): KittyShaderInspection { + const paths = kittyShaderPaths(context); + const result: KittyShaderInspection = {hostPath: paths.fallback, configDirectory: dirname(paths.fallback), userRaw: '', userEntries: [], pipelines: [], shadowed: [], + cursorTrail: 0, backgroundOpacity: 1, problems: [], notes: [], dependencies: [], configured: false, drifted: false, activePresets: []}; + let record: ShaderLedger | undefined; + try { record = owned(context).record; } catch (error) { result.problems.push(error instanceof Error ? error.message : String(error)); } + // NMSh's own fragment is read as empty, leaving exactly what the person's configuration says. + const read = readHostConfiguration('kitty', context, new Map([[paths.fragment, '']])); + result.hostPath = read.hostPath; + result.configDirectory = context.env.KITTY_CONFIG_DIRECTORY ? resolve(context.env.KITTY_CONFIG_DIRECTORY) : dirname(read.hostPath); + result.problems.push(...read.problems); + result.dependencies.push(...read.files); + if (context.launch.refusal) result.problems.push(context.launch.refusal); + const definition = read.values.get('custom_shaders'); + const all = read.definitions.filter(item => item.key === 'custom_shaders' && item.path !== paths.fragment); + result.shadowed = all.slice(0, -1).map(item => `${item.path}:${item.line}`); + result.userRaw = definition?.raw ?? ''; + const words = shellWords(result.userRaw); + if (!words) result.problems.push('Your custom_shaders value has unbalanced quoting; NMSh cannot preserve it exactly.'); + result.userEntries = words ?? []; + if (result.userEntries.some(word => /[\u0000-\u001f\u007f]/u.test(word))) result.problems.push('Your custom_shaders value contains control characters.'); + for (const entry of result.userEntries) { + const resolved = resolveUserPipeline(entry, result.configDirectory, paths.home, context.catalog, result.dependencies); + if (typeof resolved === 'string') { result.problems.push(`Your shader pipeline ${resolved}. NMSh will not compose with a pipeline it cannot read.`); continue; } + if (resolved.kind === 'missing') result.notes.push(`Kitty cannot find your shader “${entry}”; it is kept exactly as written.`); + result.pipelines.push(resolved); + } + const trail = Number(read.values.get('cursor_trail')?.value ?? 0); + result.cursorTrail = Number.isFinite(trail) ? trail : 0; + const opacity = Number(read.values.get('background_opacity')?.value ?? 1); + result.backgroundOpacity = Number.isFinite(opacity) ? opacity : 1; + if (record) { + result.configured = true; + result.activePresets = record.presets.map(preset => preset.id); + result.drifted = record.preserved !== result.userRaw; + } + return result; +} + +// ---- Composition ------------------------------------------------------------------------------------------------- + +export interface KittyShaderPlan { + host: 'kitty'; + operation: 'save' | 'remove' | 'sync'; + hostPath: string; + fragmentPath: string; + pipelinePath: string; + preview: string[]; + notes: string[]; + guidance: string; + home: string; + changes: readonly FileChange[]; + dependencies: readonly FileSnapshot[]; + /** The exact pipeline Kitty will load after this plan (for tests and review). */ + pipelineText: string; + effectiveShaders: string[]; +} +export type KittyShaderPlanResult = {ok: true; plan: KittyShaderPlan} | {ok: false; error: string}; + +export function kittyShaderGuidance(platform: NodeJS.Platform): string { + return `Configuration saved, not proof of GPU output. Kitty reloads its config automatically unless auto_reload_config is 0; otherwise reload with ${platform === 'darwin' ? '⌃⌘,' : 'Ctrl+Shift+F5'}. ` + + 'Kitty builds all custom shaders (yours and NMSh’s) as one program: if any part fails to build, Kitty turns all of them off and logs why; R here removes NMSh’s part.'; +} + +const decimals = (value: number) => (value.toString().split('.')[1] ?? '').length; +/** A param's value as Kitty source; colors keep the shipped alpha when NMSh only edits RGB. */ +const formatParam = (param: OfferedParam, value: ShaderValue) => formatShaderValue(param.type, param.kind === 'number' && typeof value === 'number' && param.type === 'float' + ? Number(value.toFixed(Math.max(2, decimals(param.step ?? 0.01)))) : value); + +/** NMSh's pipeline: each preset's shipped groups in order, every value group-scoped, leaked pipeline vars pinned to the shipped source. */ +export function buildNmshPipeline(active: ReadonlyArray<{preset: OfferedPreset; effect: KittyShaderEffect}>, catalog: KittyCatalog, leaked: readonly KittyVar[]): KittyPipeline { + const textures: string[] = []; + const groups: KittyGroup[] = []; + for (const {preset, effect} of active) { + for (const texture of preset.textures) { + if (textures.includes(texture)) throw new Error(`${preset.definition.label} needs Kitty's shared texture “${texture}”, which another NMSh effect already uses.`); + textures.push(texture); + } + for (const group of preset.groups) { + const constants = group.shaders.map(name => catalog.shaders.get(name)?.constants); + const declared = (name: string, type: string) => constants.find(map => map?.get(name)?.type === type)?.get(name); + const pinned: KittyVar[] = leaked.flatMap(variable => { + const constant = declared(variable.name, variable.type); + if (constant && !constant.raw) throw new Error(`Your shaders set ${variable.name} for every chained shader, and ${preset.definition.label} declares it in a form NMSh cannot pin safely.`); + return constant ? [{type: constant.type, name: constant.name, value: constant.raw}] : []; + }); + const chosen: KittyVar[] = preset.params.flatMap(param => { + const value = effect.values[param.name]; + return value !== undefined && declared(param.name, param.type) ? [{type: param.type, name: param.name, value: formatParam(param, value)}] : []; + }); + groups.push({...group, shaders: [...group.shaders], vars: mergeVars(pinned, group.vars, chosen)}); + } + } + return {textures: textures as KittyPipeline['textures'], vars: [], groups}; +} + +function previewFile(path: string, before: string | undefined, after: string | undefined): string[] { + if (before === after) return []; + if (after === undefined) return [`File: ${path}`, '- (NMSh-owned file removed)']; + if (before !== undefined && after.startsWith(before)) return [`File: ${path}`, ...after.slice(before.length).trimEnd().split(/\r?\n/u).filter(Boolean).map(line => `+ ${line}`)]; + return [`File: ${path} (NMSh-owned)`, ...after.trimEnd().split(/\r?\n/u).map(line => `+ ${line}`)]; +} + +function compose(settings: KittyShaderSettings, context: KittyShaderContext, mode: 'save' | 'sync'): KittyShaderPlanResult { + try { + const {catalog} = context; + if (!catalog.supported) throw new Error(catalog.reason ?? 'Kitty custom shaders are not available.'); + const errors = validateEffects(settings, catalog); + if (errors.length) throw new Error(errors.join(' ')); + const enabled = settings.effects.filter(effect => effect.enabled); + if (mode === 'save' && !enabled.length) throw new Error('No Kitty effect is enabled. Enable one, or press R to remove NMSh’s Kitty shaders.'); + if (mode === 'save' && enabled.length && (context.gates.reducedMotion || context.gates.effectsOff)) { + throw new Error(`${context.gates.reducedMotion ? 'Reduced Motion' : 'Decorative effects Off'} is on, so NMSh writes no Kitty effects. Turn it off first; R still removes existing ones.`); + } + const active = gatedEffects(settings, catalog, context.gates); + if (mode === 'save' && !active.length) throw new Error('The cursor renderer is Portable, so NMSh draws the trail itself and adds no Kitty trail. Choose Auto or Host native in /cursor.'); + const state = owned(context); + const inspection = inspectKittyShaders(context); + if (inspection.problems.length) throw new Error(inspection.problems.join(' ')); + if (mode === 'sync' && inspection.drifted) throw new Error('Your custom_shaders changed since NMSh composed with it; open /appearance › Kitty shaders and review an updated plan.'); + const {paths} = state; + if (/['\\\u0000-\u001f\u007f$]/u.test(paths.pipeline) || /['"\\\u0000-\u001f\u007f$]/u.test(paths.fragment)) throw new Error('NMSh’s configuration path cannot be referenced safely from Kitty.'); + if (inspection.userEntries.some(entry => resolve(entry.replace(/^~(?=\/)/u, paths.home)) === paths.pipeline)) throw new Error('Your custom_shaders already names NMSh’s pipeline; remove it there first.'); + const notes = [...inspection.notes]; + const dependencies = [...inspection.dependencies]; + const userGroups = inspection.pipelines.flatMap(item => item.pipeline?.groups ?? []); + const userTextures = new Set(inspection.pipelines.flatMap(item => item.pipeline ? pipelineTextures(item.pipeline) : [])); + const leaked = mergeVars(...inspection.pipelines.map(item => item.pipeline?.vars ?? [])); + for (const {preset} of active) { + const texture = preset.textures.find(name => userTextures.has(name)); + if (texture) throw new Error(`${preset.definition.label} needs Kitty's shared texture “${texture}”, which one of your shaders already uses.`); + if (preset.definition.category === 'cursor' && userGroups.some(subscribesToCursorTrail)) throw new Error(`Your custom_shaders already draws a cursor trail; NMSh will not add a second one (${preset.definition.label}).`); + for (const shader of new Set(preset.groups.flatMap(group => group.shaders))) { + // Kitty looks in NMSh's pipeline folder, then the kitty config folder, before its shipped shaders. + for (const candidate of [join(paths.directory, `${shader}.slang`), join(inspection.configDirectory, `${shader}.slang`)]) { + const file = snapshot(candidate, paths.home); + dependencies.push(file); + if (file.content !== undefined) throw new Error(`${candidate} would replace Kitty's shipped ${shader} shader; NMSh only tunes shipped shaders.`); + } + } + if (preset.definition.requires === 'cursor_trail') notes.push(`${preset.definition.label} draws only while cursor_trail is above 0 (now ${inspection.cursorTrail}); /cursor Motion › Tail with Kitty native sets it.`); + if (preset.definition.requires === 'opaque' && inspection.backgroundOpacity < 1) notes.push(`${preset.definition.label} does not work with background_opacity ${inspection.backgroundOpacity} (Kitty's own note).`); + } + const pipeline = buildNmshPipeline(active, catalog, leaked); + if (userGroups.length + pipeline.groups.length > KITTY_MAX_GROUPS) throw new Error(`Kitty allows ${KITTY_MAX_GROUPS} shader groups in total; your shaders use ${userGroups.length} and these effects need ${pipeline.groups.length}.`); + const header = ['Managed by NMSh (/appearance › Kitty shaders). Edits here are replaced; remove with R there.', + ...active.map(({preset}) => `${preset.definition.label}: Kitty ${preset.origin.kittyVersion} shipped ${preset.origin.resources.map(item => `${item.name} sha256:${item.sha256.slice(0, 12)}`).join(', ')}`)]; + const pipelineText = active.length ? emitKittyPipeline(pipeline, header) : `${header.map(line => `# ${line}`).join('\n')}\n# No NMSh effect is active (Reduced Motion, Effects Off or a Portable cursor).\n`; + if (active.length && !parseKittyPipeline(pipelineText, 'nmsh').ok) throw new Error('NMSh generated a pipeline Kitty would reject; nothing was changed.'); + const ours = quoteWord(paths.pipeline); + const composed = !active.length ? undefined : inspection.userRaw ? (settings.placement === 'before' ? `${ours} ${inspection.userRaw}` : `${inspection.userRaw} ${ours}`) : ours; + const expected = !active.length ? inspection.userEntries : settings.placement === 'before' ? [paths.pipeline, ...inspection.userEntries] : [...inspection.userEntries, paths.pipeline]; + const fragmentText = `${FRAGMENT_HEADER}${composed ? `custom_shaders ${composed}\n` : '# No NMSh effect is active; your own custom_shaders applies unchanged.\n'}`; + if (state.record && state.record.hostPath !== inspection.hostPath) throw new Error('Kitty’s configuration file changed; remove the old integration before setting up this one.'); + const hostFile = snapshot(inspection.hostPath, paths.home, true); + const original = hostFile.content ?? ''; + const newline = original.includes('\r\n') ? '\r\n' : '\n'; + const hook = state.record?.hook ?? `${original && !original.endsWith('\n') ? newline : ''}${newline}${HOOK_COMMENT}${newline}include ${paths.fragment}${newline}`; + if (state.record && original.split(hook).length !== 2) throw new Error('The recorded Kitty shader include was changed or duplicated; nothing was changed.'); + if (!state.record && original.includes(paths.fragment)) throw new Error('An unrecorded NMSh Kitty shader include already exists; remove it manually before setup.'); + if (mode === 'sync' && !state.record) throw new Error('Kitty shaders are not set up.'); + const hostAfter = state.record ? original : `${original}${hook}`; + // Prove the effective value is exactly the composed list, from NMSh's fragment. + const simulated = readHostConfiguration('kitty', context, new Map([[hostFile.path, hostAfter], [paths.fragment, fragmentText]])); + if (simulated.problems.length) throw new Error(simulated.problems.join(' ')); + const effective = simulated.values.get('custom_shaders'); + const effectiveWords = shellWords(effective?.raw ?? '') ?? []; + if (active.length && (effective?.path !== paths.fragment || JSON.stringify(effectiveWords) !== JSON.stringify(expected))) { + throw new Error('A later or unresolved custom_shaders line would override NMSh’s composed list. Resolve that explicitly; no files were changed.'); + } + const record: ShaderLedger = {version: 1, feature: 'kitty-shaders', hostPath: hostFile.path, hook, fragmentHash: digest(fragmentText), pipelineHash: digest(pipelineText), + created: state.record?.created ?? hostFile.content === undefined, preserved: inspection.userRaw, placement: settings.placement, kittyVersion: catalog.version, + presets: active.map(({preset}) => ({id: preset.definition.id, resources: preset.origin.resources}))}; + const ledgerText = `${JSON.stringify(record, null, 2)}\n`; + const changes: FileChange[] = [{before: state.pipeline, after: pipelineText}, {before: state.fragment, after: fragmentText}, + ...(state.record ? [] : [{before: hostFile, after: hostAfter}]), {before: state.ledger, after: ledgerText}]; + const kept = inspection.userEntries.length ? `Kept, in order: ${inspection.userEntries.join(' · ')}` : 'You have no custom_shaders of your own.'; + const shadowedNote = inspection.shadowed.length ? [`Kitty already ignores earlier custom_shaders lines at ${inspection.shadowed.join(', ')} (a later line replaces them); they are unchanged.`] : []; + const preview = [kept, ...shadowedNote, `Effective after this change: ${effectiveWords.length ? effectiveWords.join(' · ') : '(none)'}`, '', + ...previewFile(hostFile.path, hostFile.content, state.record ? hostFile.content : hostAfter), ...previewFile(paths.fragment, state.fragment.content, fragmentText), + ...previewFile(paths.pipeline, state.pipeline.content, pipelineText), ...(notes.length ? ['', ...notes] : [])]; + return {ok: true, plan: {host: 'kitty', operation: mode, hostPath: hostFile.path, fragmentPath: paths.fragment, pipelinePath: paths.pipeline, preview, notes, + guidance: kittyShaderGuidance(context.platform), home: paths.home, changes, dependencies: [...dependencies, ...(state.record ? [hostFile] : [])], pipelineText, + effectiveShaders: effectiveWords}}; + } catch (error) { return {ok: false, error: error instanceof Error ? error.message : String(error)}; } +} + +/** A reviewed plan for the selected effects: exact files, exact effective list, or a refusal with the reason. */ +export function planKittyShaders(settings: KittyShaderSettings, context: KittyShaderContext): KittyShaderPlanResult { return compose(settings, context, 'save'); } + +/** + * After setup, keep NMSh's own pipeline in step with Reduced Motion, Effects Off and the cursor renderer (never two trails). + * Only NMSh-owned files change; anything that would need a new review returns a reason instead of writing. + */ +export function syncKittyShaders(settings: KittyShaderSettings, context: KittyShaderContext): {kind: 'none' | 'unchanged' | 'written'; message?: string} | {kind: 'review'; message: string} { + let state: ReturnType; + try { state = owned(context); } catch (error) { return {kind: 'review', message: error instanceof Error ? error.message : String(error)}; } + const record = state.record; + if (!record) return {kind: 'none'}; + // Taking every NMSh effect out is always safe (the person's own value then applies unchanged), so it never waits for a review. + if (!context.catalog.supported || !gatedEffects(settings, context.catalog, context.gates).length) { + const pipelineText = `# Managed by NMSh (/appearance › Kitty shaders). Edits here are replaced; remove with R there.\n# No NMSh effect is active (Reduced Motion, Effects Off or a Portable cursor).\n`; + const fragmentText = `${FRAGMENT_HEADER}# No NMSh effect is active; your own custom_shaders applies unchanged.\n`; + if (state.pipeline.content === pipelineText && state.fragment.content === fragmentText) return {kind: 'unchanged'}; + const ledgerText = `${JSON.stringify({...record, fragmentHash: digest(fragmentText), pipelineHash: digest(pipelineText), presets: []}, null, 2)}\n`; + try { transact([{before: state.pipeline, after: pipelineText}, {before: state.fragment, after: fragmentText}, {before: state.ledger, after: ledgerText}], [], state.paths.home); } + catch (error) { return {kind: 'review', message: error instanceof Error ? error.message : String(error)}; } + return {kind: 'written', message: `NMSh's Kitty effects are off · ${kittyShaderGuidance(context.platform)}`}; + } + // The reviewed composition already passed the launch check; adding NMSh's reviewed groups back does not repeat it. + const planned = compose(settings, {...context, launch: {}}, 'sync'); + if (!planned.ok) return {kind: 'review', message: planned.error}; + if (planned.plan.changes.every(change => change.after === change.before.content || change.before.path === kittyShaderPaths(context).ledger)) return {kind: 'unchanged'}; + const applied = applyKittyShaderPlan(planned.plan); + return applied.ok ? {kind: 'written', message: `Kitty shaders updated · ${planned.plan.guidance}`} : {kind: 'review', message: applied.error}; +} + +export function planKittyShaderRemoval(context: AppearanceContext): KittyShaderPlanResult { + try { + const state = owned(context, true); + if (!state.record) throw new Error('No NMSh-owned Kitty shader integration is recorded.'); + const hostFile = snapshot(state.record.hostPath, state.paths.home, true); + const content = hostFile.content ?? ''; + const hooks = content.split(state.record.hook).length - 1; + if (hooks > 1) throw new Error('The owned Kitty shader include is duplicated; it was preserved.'); + // Intact owned files are deleted; a missing one needs nothing; one edited outside NMSh is kept (unreferenced once the hook is gone). + const own: FileChange[] = [...(state.damaged.has('fragment') ? [] : [{before: state.fragment}]), ...(state.damaged.has('pipeline') ? [] : [{before: state.pipeline}]), {before: state.ledger}]; + const notes = [...state.damaged].map(([label, kind]) => kind === 'missing' ? `NMSh's Kitty shader ${label} is already missing.` + : `NMSh's Kitty shader ${label} was edited outside NMSh; it is kept as it is and no longer included.`); + const guidance = kittyShaderGuidance(context.platform); + const base = {host: 'kitty' as const, operation: 'remove' as const, hostPath: hostFile.path, fragmentPath: state.paths.fragment, pipelinePath: state.paths.pipeline, notes, + guidance, home: state.paths.home, pipelineText: '', effectiveShaders: shellWords(state.record.preserved) ?? []}; + const ownPreview = [...own.map(change => `- ${change.before.path} (NMSh-owned)`), ...notes]; + if (hooks === 0) { + return {ok: true, plan: {...base, changes: own, dependencies: [hostFile], + preview: [`The NMSh include is already absent from ${hostFile.path}; that file is not changed.`, ...ownPreview]}}; + } + const after = withoutHook(content, state.record.hook); + return {ok: true, plan: {...base, changes: [{before: hostFile, after: state.record.created && after === '' ? undefined : after}, ...own], dependencies: [], + preview: [`File: ${hostFile.path}`, ...state.record.hook.trim().split(/\r?\n/u).map(line => `- ${line}`), ...ownPreview, + `Your own custom_shaders applies again: ${state.record.preserved || '(none)'}`]}}; + } catch (error) { return {ok: false, error: error instanceof Error ? error.message : String(error)}; } +} + +export function applyKittyShaderPlan(plan: KittyShaderPlan): {ok: true} | {ok: false; error: string} { + try { transact(plan.changes, plan.dependencies, plan.home); return {ok: true}; } + catch (error) { return {ok: false, error: error instanceof Error ? error.message : String(error)}; } +} + +export const categoryLabel = (preset: OfferedPreset) => CATEGORY_LABELS[preset.definition.category]; + +/** Flags in Kitty's own command line (or macOS launch-services file) that would change which custom_shaders Kitty loads. */ +export function launchOverrideReason(commandLine: string): string | undefined { + const words = commandLine.split(/\s+/u).filter(Boolean); + for (let index = 0; index < words.length; index++) { + const word = words[index]!; + const next = words[index + 1] ?? ''; + if (word === '-c' || word === '--config' || word.startsWith('--config=') || (word.startsWith('-c') && word.length > 2 && !word.startsWith('--'))) { + return 'Kitty was started with its own --config file, so NMSh cannot tell which configuration it loads; nothing will be changed.'; + } + const override = word === '-o' || word === '--override' ? next : word.startsWith('--override=') ? word.slice('--override='.length) : word.startsWith('-o') && !word.startsWith('--') ? word.slice(2) : undefined; + if (override !== undefined && /^(custom_shaders|cursor_trail)\b/u.test(override)) return `Kitty was started with an override (${override.split(/[=\s]/u)[0]}) that replaces its configuration; NMSh will not compose shaders it cannot see.`; + } + return undefined; +} + +/** + * Inspect the running Kitty's launch options read-only (`ps` of KITTY_PID) and + * the macOS launch-services command line. When they cannot be inspected, setup + * is refused rather than assuming nothing overrides custom_shaders. + */ +export function inspectKittyLaunch(context: AppearanceContext, readCommand: (pid: number) => string | undefined): KittyLaunch { + const pid = Number(context.env.KITTY_PID); + if (!Number.isSafeInteger(pid) || pid <= 1) return {refusal: 'NMSh is not running directly in Kitty (no KITTY_PID), so Kitty’s launch options cannot be checked; open /appearance from a Kitty window.'}; + const command = readCommand(pid); + if (command === undefined) return {refusal: 'Kitty’s launch options could not be read, so NMSh cannot rule out a custom_shaders override; nothing will be changed.'}; + const reason = launchOverrideReason(command); + if (reason) return {refusal: reason}; + const paths = kittyShaderPaths(context); + for (const directory of [...new Set([context.env.KITTY_CONFIG_DIRECTORY, dirname(paths.candidates[0]!), ...(context.platform === 'darwin' ? [dirname(paths.candidates.at(-1)!)] : [])].filter(Boolean))] as string[]) { + try { + const file = snapshot(join(directory, 'macos-launch-services-cmdline'), paths.home); + const fromFile = file.content !== undefined ? launchOverrideReason(file.content) : undefined; + if (fromFile) return {refusal: `${fromFile} (macos-launch-services-cmdline)`}; + } catch { return {refusal: 'Kitty’s macOS launch options file cannot be inspected safely; nothing will be changed.'}; } + } + return {}; +} + +/** One process's command line, read-only, bounded; undefined when it cannot be read. */ +export function readProcessCommand(pid: number): string | undefined { + try { + const output = execFileSync('ps', ['-o', 'command=', '-p', String(pid)], {encoding: 'utf8', timeout: 2000, maxBuffer: 64 * 1024, stdio: ['ignore', 'pipe', 'ignore']}).trim(); + return output || undefined; + } catch { return undefined; } +} + +/** The trail shader NMSh's written Kitty pipeline contains right now, if its fragment is active; never guessed from settings. */ +export function writtenKittyTrail(context: AppearanceContext): string | undefined { + try { + const state = owned(context); + if (!state.record || !(snapshot(state.record.hostPath, state.paths.home).content ?? '').includes(state.record.hook)) return undefined; + if (!/^custom_shaders /mu.test(state.fragment.content ?? '')) return undefined; + const trail = /^\s*shaders (cursor-trail-[a-z-]+)\s*$/mu.exec(state.pipeline.content ?? '')?.[1]; + if (!trail) return undefined; + // Kitty's trail shaders draw only while cursor_trail is above 0; otherwise Kitty draws no trail and Portable must. + const read = readHostConfiguration('kitty', context); + return !read.problems.length && Number(read.values.get('cursor_trail')?.value ?? 0) > 0 ? trail : undefined; + } catch { return undefined; } +} diff --git a/src/cursor/backends.ts b/src/cursor/backends.ts index d0406290..356f36b3 100644 --- a/src/cursor/backends.ts +++ b/src/cursor/backends.ts @@ -40,8 +40,14 @@ export const PORTABLE_BACKEND: CursorEffectBackend = {id: 'portable', label: 'Po */ export const GHOSTTY_BACKEND: CursorEffectBackend = {id: 'ghostty', label: 'Ghostty native', host: 'Ghostty', surfaceWide: true, support: {motion: ['smear', 'tail'], effect: ['fire', 'sparks', 'ripple'], idleEffect: []}}; -/** Kitty ≥ 0.37: the built-in cursor_trail (a tail with decay and color); no custom cursor shaders, so no effects. */ +/** Kitty ≥ 0.37: the built-in cursor_trail (a tail with decay and color); no NMSh cursor shader, so no effects. */ export const KITTY_BACKEND: CursorEffectBackend = {id: 'kitty', label: 'Kitty native', host: 'Kitty', surfaceWide: true, support: {motion: ['tail'], effect: [], idleEffect: []}}; +/** + * Kitty ≥ 0.49 with NMSh's reviewed Lightning trail shader written: Kitty draws the trail AND a lightning bolt along it, so + * Portable must not draw its own Lightning too. Other Kitty trail styles (Blaze, Motion blur, Default) are trail looks only. + */ +export const KITTY_LIGHTNING_BACKEND: CursorEffectBackend = {...KITTY_BACKEND, support: {motion: ['tail'], effect: ['lightning'], idleEffect: []}}; +const KITTY_TRAIL_LABELS: Record = {'cursor-trail-default': 'Default', 'cursor-trail-blaze': 'Blaze', 'cursor-trail-lightning': 'Lightning', 'cursor-trail-motion-blur': 'Motion blur'}; export interface HostCursorFacts { host: 'ghostty' | 'kitty' | 'other'; @@ -56,15 +62,20 @@ export interface HostCursorFacts { version?: string; /** NMSh's managed native integration is installed in the host's config (include line + managed file). */ integrated: boolean; + /** Kitty: the trail shader NMSh's reviewed Kitty pipeline currently contains (e.g. "cursor-trail-blaze"). */ + kittyTrail?: string; } const KNOWN_HOSTS: Record = {zed: 'Zed', Apple_Terminal: 'Terminal.app', 'iTerm.app': 'iTerm2', WezTerm: 'WezTerm', vscode: 'VS Code', ghostty: 'Ghostty'}; -export function hostCursorFacts(env: NodeJS.ProcessEnv, integrated: (host: 'ghostty' | 'kitty') => boolean): HostCursorFacts { +export function hostCursorFacts(env: NodeJS.ProcessEnv, integrated: (host: 'ghostty' | 'kitty') => boolean, kittyTrail: () => string | undefined = () => undefined): HostCursorFacts { const hostName = KNOWN_HOSTS[env.TERM_PROGRAM ?? '']; const caretColor = env.TERM_PROGRAM === 'zed' ? 'theme-controlled' as const : 'host-controlled' as const; if (env.TERM_PROGRAM === 'ghostty' || env.TERM === 'xterm-ghostty') return {host: 'ghostty', hostName: 'Ghostty', caretColor, ...(env.TERM_PROGRAM_VERSION ? {version: env.TERM_PROGRAM_VERSION} : {}), integrated: integrated('ghostty')}; - if (env.KITTY_WINDOW_ID || env.TERM === 'xterm-kitty') return {host: 'kitty', hostName: 'Kitty', caretColor, integrated: integrated('kitty')}; + if (env.KITTY_WINDOW_ID || env.TERM === 'xterm-kitty') { + const trail = kittyTrail(); + return {host: 'kitty', hostName: 'Kitty', caretColor, integrated: integrated('kitty'), ...(trail ? {kittyTrail: trail} : {})}; + } return {host: 'other', ...(hostName ? {hostName} : {}), caretColor, integrated: false}; } @@ -88,7 +99,7 @@ const atLeast = (version: string | undefined, major: number, minor: number) => { /** Whether this host can do native cursor effects at all (documented feature present), regardless of setup. */ export function nativeBackendFor(facts: HostCursorFacts): CursorEffectBackend | undefined { if (facts.host === 'ghostty' && (facts.version === undefined || atLeast(facts.version, 1, 2))) return GHOSTTY_BACKEND; - if (facts.host === 'kitty') return KITTY_BACKEND; + if (facts.host === 'kitty') return facts.kittyTrail === 'cursor-trail-lightning' ? KITTY_LIGHTNING_BACKEND : KITTY_BACKEND; return undefined; } @@ -124,17 +135,21 @@ export function chooseBackend(settings: CursorSettings, facts: HostCursorFacts): if (settings.renderer === 'native') return {backend: PORTABLE_BACKEND, reason: 'Host native is not available in this terminal; no effects are drawn', nativeHandles: none, portableDraws: nothing}; return {backend: PORTABLE_BACKEND, reason: 'Portable (this terminal has no native cursor effects)', nativeHandles: none, portableDraws: all}; } - if (!facts.integrated) { + // A Kitty trail shader NMSh wrote after review counts as set up: Kitty draws that trail, so Portable must not draw a second one. + const integrated = facts.integrated || Boolean(facts.kittyTrail); + if (!integrated) { return {backend: PORTABLE_BACKEND, native, nativeHandles: none, reason: settings.renderer === 'native' ? `${native.label} needs setup (shown before anything changes); no effects are drawn until then` : `Portable · ${native.label} is available after setup`, portableDraws: settings.renderer === 'native' ? nothing : all}; } const motion = !isOff(settings.motion) && supports(native, 'motion', settings.motion); - const effect = !isOff(settings.effect) && supports(native, 'effect', settings.effect); + // Kitty's shader trails draw only while its cursor_trail runs, which NMSh's Kitty fragment turns on with a motion. + const effect = !isOff(settings.effect) && supports(native, 'effect', settings.effect) && (native.id !== 'kitty' || !isOff(settings.motion)); const forced = settings.renderer === 'native'; const fallback = !forced && ((!isOff(settings.motion) && !motion) || (!isOff(settings.effect) && !effect) || !isOff(settings.idleEffect)); + const style = facts.kittyTrail ? ` · ${KITTY_TRAIL_LABELS[facts.kittyTrail] ?? 'custom'} trail shader` : ''; return {backend: native, native, nativeHandles: {motion, effect}, - reason: `${native.label}${fallback ? ' with Portable fallback for what it does not draw' : ''}`, + reason: `${native.label}${style}${fallback ? ' with Portable fallback for what it does not draw' : ''}`, portableDraws: forced ? nothing : {motion: !motion, effect: !effect, idle: true}}; } @@ -159,7 +174,7 @@ export function availabilityOf(feature: CursorFeature, value: string, renderer: if (!supports(native, feature, value)) return {available: false, reason: `${native.host ?? native.label} Native does not provide this ${FEATURE_WORD[feature]}.`}; return facts.integrated ? {available: true, via: 'native', suffix: ''} : {available: true, via: 'native-pending', suffix: `after ${native.host} setup`}; } - if (native && facts.integrated) return supports(native, feature, value) ? {available: true, via: 'native', suffix: ''} : {available: true, via: 'portable-fallback', suffix: 'Portable fallback'}; + if (native && (facts.integrated || facts.kittyTrail)) return supports(native, feature, value) ? {available: true, via: 'native', suffix: ''} : {available: true, via: 'portable-fallback', suffix: 'Portable fallback'}; return {available: true, via: 'portable', suffix: ''}; } diff --git a/src/cursor/native.ts b/src/cursor/native.ts index 6546a25b..88ed4d01 100644 --- a/src/cursor/native.ts +++ b/src/cursor/native.ts @@ -95,7 +95,8 @@ export function fragmentContent(host: NativeHost, settings: CursorSettings, env: } const palette = effectPalette(settings); const decay = {low: '0.15 0.5', medium: '0.1 0.4', high: '0.05 0.25'}[settings.speed]; - const trail = settings.motion === 'off' ? 0 : 3; + // Portable means NMSh draws the trail itself, so Kitty's own trail (and any trail shader) stays off: never two trails. + const trail = settings.motion === 'off' || settings.renderer === 'portable' ? 0 : 3; return `${header}cursor_trail ${trail}\ncursor_trail_decay ${decay}\ncursor_trail_start_threshold ${Math.max(1, Math.round(settings.advanced.moveThreshold + 1))}\n` + (settings.trail.source !== 'cursor' || settings.color.source !== 'host' ? `cursor_trail_color ${hex(palette.trail[0] ?? palette.caret)}\n` : ''); } diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index fe4f2a8a..4663835c 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -2,6 +2,7 @@ import {CONTEXT_MODULE_REGISTRY, firstPartyDefaultModules, isPackModuleId, modul export {CONTEXT_MODULE_REGISTRY, moduleDefinition, type ContextModuleDefinition, type ContextModuleId} from '../context/modules.js'; import {normalizeTreatmentSettings, DEFAULT_TREATMENT_SETTINGS, validCustomStops, type TreatmentSettings} from '../chroma/treatment.js'; import {COLOR_PRESET_IDS, COLOR_PRESET_LABELS} from '../chroma/colorPresets.js'; +import {DEFAULT_HOST_SHADERS, normalizeHostShaders, type HostShaderSettings} from '../appearance/kittyShaderSettings.js'; import {mkdirSync, readFileSync, renameSync, writeFileSync} from 'node:fs'; import {dirname} from 'node:path'; import {promptConfigurationPath} from '../configuration/paths.js'; @@ -680,6 +681,8 @@ export interface PromptConfiguration { /** Opt-in Theme Bridge: one independent mode per external tool target; all Independent by default. */ themeBridge: ThemeBridgeSettings; cursor: CursorSettings; + /** Host-native GPU shader choices NMSh manages after a reviewed setup (Kitty 0.49+ pipelines); decorative and opt-in. */ + hostShaders: HostShaderSettings; statusStrip: StatusStripSettings; /** How an active Keep Awake shows in NMSh chrome (placement, display, idle reminder, screensaver). Off shows nothing. */ keepAwake: KeepAwakePresentation; @@ -921,6 +924,7 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { promptSymbol: 'chevron', // Cursor effects start Off; choosing Fire starts on the Classic flame (the default particle model). cursor: {...structuredClone(DEFAULT_CURSOR), color: {source: 'theme'}}, + hostShaders: structuredClone(DEFAULT_HOST_SHADERS), statusStrip: {...DEFAULT_STATUS_STRIP}, keepAwake: {...DEFAULT_KEEP_AWAKE_PRESENTATION}, agentProfiles: [], @@ -1052,7 +1056,7 @@ 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 = {introSeen: value.introSeen !== false, motion: normalizeMotion(value.motion), pastePreview: (value.pastePreview === 'always' || value.pastePreview === 'off' ? value.pastePreview : 'smart') as 'smart' | 'always' | 'off', cursor: normalizeCursor(value.cursor), statusStrip: normalizeStatusStrip(value.statusStrip), keepAwake: normalizeKeepAwakePresentation(value.keepAwake), idleVisuals: normalizeIdleVisuals(value.idleVisuals), liveActivity: normalizeLiveActivity(value.liveActivity), uiChrome: normalizeUiChrome(value.uiChrome), + const tooling = {introSeen: value.introSeen !== false, motion: normalizeMotion(value.motion), pastePreview: (value.pastePreview === 'always' || value.pastePreview === 'off' ? value.pastePreview : 'smart') as 'smart' | 'always' | 'off', cursor: normalizeCursor(value.cursor), hostShaders: normalizeHostShaders(value.hostShaders), statusStrip: normalizeStatusStrip(value.statusStrip), keepAwake: normalizeKeepAwakePresentation(value.keepAwake), idleVisuals: normalizeIdleVisuals(value.idleVisuals), liveActivity: normalizeLiveActivity(value.liveActivity), uiChrome: normalizeUiChrome(value.uiChrome), sessionNotices: value.sessionNotices !== false, copy: normalizeCopy(value.copy), agentProfiles: normalizeProfiles(value.agentProfiles), agentActivity: value.agentActivity !== false, commandQueue: value.commandQueue !== false, queueFailurePolicy: value.queueFailurePolicy === 'continue' ? 'continue' as const : 'pause' as const, queue: normalizeQueuePresentation(value.queue), workspacePanels: value.workspacePanels === true, askRecord: value.askRecord !== false, askCommandSuggestions: value.askCommandSuggestions === 'local' ? 'local' as const : 'off' as const, graphicsMode: GRAPHICS_MODES.includes(value.graphicsMode as GraphicsMode) ? value.graphicsMode as GraphicsMode : 'auto', textSizing: TEXT_SIZING_SETTINGS.includes(value.textSizing as TextSizingSetting) ? value.textSizing as TextSizingSetting : 'auto', browser: BROWSER_SETTINGS.includes(value.browser as BrowserSetting) ? value.browser as BrowserSetting : 'auto', diagrams: DIAGRAM_SETTINGS.includes(value.diagrams as DiagramSetting) ? value.diagrams as DiagramSetting : 'manual', askPresentation: value.askPresentation === 'normal' ? 'normal' as const : 'chat' as const, localUnderstanding: normalizeLocalUnderstanding(value.localUnderstanding), shellBackend: isShellId(value.shellBackend) ? value.shellBackend : 'zsh', diff --git a/tests/kittyShaderPanel.test.ts b/tests/kittyShaderPanel.test.ts new file mode 100644 index 00000000..e3792a93 --- /dev/null +++ b/tests/kittyShaderPanel.test.ts @@ -0,0 +1,149 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {buildKittyCatalog} from '../src/appearance/kittyShaderCatalog.js'; +import {createKittyShaderPanel, kittyShaderKey, paramValue, renderKittyShaderPanel} from '../src/appearance/KittyShaderPanel.js'; +import {appearanceHubKey, createAppearanceHub, renderAppearanceHub} from '../src/appearance/AppearanceHub.js'; +import {DEFAULT_HOST_SHADERS} from '../src/appearance/kittyShaderSettings.js'; +import {DEFAULT_PROMPT_CONFIGURATION} from '../src/prompt/configuration.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {stripAnsi} from '../src/util/text.js'; + +const RESOURCES: Record = { + 'types.slang': '//\n', + 'cursor-trail-default.pipeline': 'startgroup\nanimation_start cursor-trail-move\nanimation_stop cursor-trail-stop\nshaders cursor-trail-default\nendgroup\n', + 'cursor-trail-default.slang': '//\n', + 'cursor-trail-blaze.pipeline': 'startgroup\nanimation_start cursor-trail-move\nanimation_stop cursor-trail-stop\nshaders cursor-trail-blaze\nendgroup\n', + 'cursor-trail-blaze.slang': 'static const float4 TRAIL_COLOR = float4(1.0, 0.725, 0.161, 1.0);\nstatic const float DURATION = 0.5;\n', + 'spotlight.pipeline': 'startgroup\nanimation_start user-activity\nshaders spotlight\nendgroup\n', + 'spotlight.slang': 'static const float SPOTLIGHT_RADIUS = 0.25;\nstatic const float AMBIENT_LIGHT = 0.5;\n', + 'tab-change.pipeline': 'startgroup\nanimation_start tab-change\nanimation_stop 600\nshaders tab-change\nendgroup\n', + 'tab-change.slang': 'static const int ANIMATION_TYPE = 1;\nstatic const int NUM_BLINDS = 8;\n', + 'fireworks-rockets.pipeline': 'startgroup\nshaders fireworks-rockets\nendgroup\n', +}; +const catalog = buildKittyCatalog('0.49.2', RESOURCES); +const gates = {reducedMotion: false, effectsOff: false, cursorRenderer: 'auto' as const}; +const key = (kind: string, value?: string) => (value === undefined ? {kind} : {kind, value}) as never; +const plain = (rows: string[]) => rows.map(row => stripAnsi(row)); +const panel = () => { const state = createKittyShaderPanel(DEFAULT_HOST_SHADERS.kitty, gates); state.catalog = catalog; return state; }; +const select = (state: ReturnType, label: string) => { + for (let index = 0; index < 40; index++) { + const rows = plain(renderKittyShaderPanel(state, 120)); + if (rows.some(row => row.startsWith('›') && row.includes(label))) return; + kittyShaderKey(state, key('down')); + } + throw new Error(`row ${label} not found`); +}; + +test('panel: one cursor trail at a time; parameters appear only for enabled effects and stay within bounds', () => { + const state = panel(); + select(state, 'Default trail'); + kittyShaderKey(state, key('enter')); + select(state, 'Blaze trail'); + kittyShaderKey(state, key('enter')); + assert.deepEqual(state.draft.effects.map(effect => [effect.id, effect.enabled]), [['cursor-trail-default', false], ['cursor-trail-blaze', true]]); + select(state, 'Duration (s)'); + for (let index = 0; index < 60; index++) kittyShaderKey(state, key('right')); + const blaze = catalog.offered.find(item => item.definition.id === 'cursor-trail-blaze')!; + assert.equal(paramValue(state, blaze, blaze.params.find(param => param.name === 'DURATION')!), 2, 'clamped to NMSh’s safe range'); + select(state, 'Fill color'); + kittyShaderKey(state, key('enter')); + for (const char of '#3399ff') kittyShaderKey(state, key('text', char)); + kittyShaderKey(state, key('enter')); + assert.deepEqual(state.draft.effects[1]!.values.TRAIL_COLOR, [0.2, 0.6, 1, 1], 'RGB from hex, shipped alpha kept'); + assert.equal(state.dirty, true); + // Choices cycle through the shader's documented values only. + select(state, 'Tab change'); + kittyShaderKey(state, key('text', ' ')); + select(state, 'Style'); + kittyShaderKey(state, key('right')); + assert.equal(state.draft.effects.find(effect => effect.id === 'tab-change')!.values.ANIMATION_TYPE, 2); +}); + +test('panel: Shift+↑↓ reorders enabled effects; S, R and Enter map to review, remove and confirm; Esc backs out', () => { + const state = panel(); + select(state, 'Spotlight'); kittyShaderKey(state, key('enter')); + select(state, 'Tab change'); kittyShaderKey(state, key('enter')); + assert.deepEqual(state.draft.effects.map(effect => effect.id), ['spotlight', 'tab-change']); + select(state, 'Tab change'); + kittyShaderKey(state, key('selectUp')); + assert.deepEqual(state.draft.effects.map(effect => effect.id), ['tab-change', 'spotlight']); + assert.ok(plain(renderKittyShaderPanel(state, 120)).some(row => /Tab change\s+#1/u.test(row))); + assert.deepEqual(kittyShaderKey(state, key('text', 's')), {kind: 'review'}); + assert.deepEqual(kittyShaderKey(state, key('text', 'R')), {kind: 'remove'}); + state.confirmation = {plan: {preview: ['File: x', '+ include y'], guidance: 'Reload Kitty.'} as never, offset: 0}; + assert.match(plain(renderKittyShaderPanel(state, 120)).join('\n'), /Review Kitty shader configuration[\s\S]*\+ include y[\s\S]*Reload Kitty/u); + assert.deepEqual(kittyShaderKey(state, key('enter')), {kind: 'confirm'}); + kittyShaderKey(state, key('escape')); + assert.equal(state.confirmation, undefined); + assert.deepEqual(kittyShaderKey(state, key('escape')), {kind: 'back'}); +}); + +test('panel: honest states — unsupported Kitty, gates, missing parameters, installed-but-not-offered; fits narrow and short windows', () => { + const unsupported = createKittyShaderPanel(DEFAULT_HOST_SHADERS.kitty, gates); + unsupported.catalog = buildKittyCatalog('0.48.0', {}); + assert.match(plain(renderKittyShaderPanel(unsupported, 100)).join('\n'), /need Kitty 0\.49 or newer[\s\S]*Cursor effects stay with \/cursor/u); + assert.equal(kittyShaderKey(unsupported, key('text', 's')), undefined, 'nothing to review without support'); + assert.deepEqual(kittyShaderKey(unsupported, key('text', 'r')), {kind: 'remove'}, 'removal still works on an unsupported Kitty'); + const reduced = panel(); + reduced.gates = {...gates, reducedMotion: true}; + assert.match(plain(renderKittyShaderPanel(reduced, 120)).join('\n'), /Reduced Motion is on/u); + const state = panel(); + select(state, 'Default trail'); + assert.match(plain(renderKittyShaderPanel(state, 160)).join('\n'), /Kitty exposes no shader settings for this trail/u); + assert.match(plain(renderKittyShaderPanel(state, 160)).join('\n'), /Installed, not offered yet: fireworks-rockets/u); + for (const columns of [40, 60]) for (const row of renderKittyShaderPanel(state, columns, 14)) assert.ok(stripAnsi(row).length <= columns, `${columns}: ${stripAnsi(row)}`); + assert.ok(renderKittyShaderPanel(state, 80, 14).length <= 14); + assert.ok(plain(renderKittyShaderPanel(state, 80, 14)).at(-1)!.includes('Esc'), 'controls survive a short window'); +}); + +test('hub: the Kitty shaders row exists only in Kitty and opens its studio', () => { + const config = structuredClone(DEFAULT_PROMPT_CONFIGURATION); + const zed = createAppearanceHub('Zed'); + assert.doesNotMatch(plain(renderAppearanceHub(zed, config, 120, 'Lavender', 'Portable')).join('\n'), /Kitty shaders/u); + const kitty = createAppearanceHub('Kitty'); + kitty.kittyShaders = 'Off · Kitty 0.49+ GPU effects'; + assert.match(plain(renderAppearanceHub(kitty, config, 120, 'Lavender', 'Portable')).join('\n'), /Kitty shaders\s+Off · Kitty 0\.49\+ GPU effects/u); + for (let index = 0; index < 3; index++) appearanceHubKey(kitty, key('down'), config); + assert.deepEqual(appearanceHubKey(kitty, key('enter'), config), {kind: 'open', destination: 'kittyShaders'}); +}); + +test('app: /appearance › Kitty shaders reviews, writes only after Enter, saves the settings, and Esc returns to the hub', async t => { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-kpanel-'))); + const saved = {...process.env}; + Object.assign(process.env, {HOME: home, XDG_CONFIG_HOME: join(home, '.config'), KITTY_WINDOW_ID: '1', KITTY_PID: String(process.pid), TERM: 'xterm-kitty'}); + for (const name of ['KITTY_CONFIG_DIRECTORY', 'TERM_PROGRAM', 'TERM_PROGRAM_VERSION', 'TMUX']) delete process.env[name]; + const app = new TerminalApp(); + t.after(() => { + app['stop'](0); app['session'].kill(); + for (const name of Object.keys(process.env)) if (!(name in saved)) delete process.env[name]; + Object.assign(process.env, saved); + rmSync(home, {recursive: true, force: true}); + }); + const kittyConf = join(home, '.config/kitty/kitty.conf'); + mkdirSync(join(home, '.config/kitty'), {recursive: true, mode: 0o700}); + writeFileSync(kittyConf, 'cursor_trail 3\ncustom_shaders spotlight\n', {mode: 0o600}); + app['kittyCatalogRead'] = Promise.resolve(catalog); + await app['openKittyShaders'](); + const state = app['kittyShaderPanel']!; + assert.ok(state.inspection); + assert.deepEqual(state.inspection.userEntries, ['spotlight']); + select(state, 'Blaze trail'); + app['onInput']('\r'); + app['onInput']('s'); + assert.ok(state.confirmation, state.message); + assert.equal(existsSync(join(home, '.config/nmsh/host-appearance/kitty-shaders.pipeline')), false, 'review writes nothing'); + assert.equal(readFileSync(kittyConf, 'utf8'), 'cursor_trail 3\ncustom_shaders spotlight\n'); + app['onInput']('\r'); + assert.equal(state.confirmation, undefined); + assert.match(readFileSync(join(home, '.config/nmsh/host-appearance/kitty-shaders.conf'), 'utf8'), /^custom_shaders spotlight .*kitty-shaders\.pipeline$/mu); + assert.deepEqual(app['promptConfiguration'].hostShaders.kitty.effects.map((effect: {id: string; enabled: boolean}) => [effect.id, effect.enabled]), [['cursor-trail-blaze', true]]); + // The cursor now knows Kitty draws the trail: Portable does not draw a second one. + assert.equal(app['cursorFacts']().kittyTrail, 'cursor-trail-blaze'); + app['onInput']('\u001b[27u'); + await new Promise(resolve => setTimeout(resolve, 20)); + assert.equal(app['kittyShaderPanel'], undefined); + assert.ok(app['appearanceHub']?.kittyShaders?.startsWith('1 effect chosen')); +}); diff --git a/tests/kittyShaders.test.ts b/tests/kittyShaders.test.ts new file mode 100644 index 00000000..2ed3ff09 --- /dev/null +++ b/tests/kittyShaders.test.ts @@ -0,0 +1,478 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join} from 'node:path'; +import {buildKittyCatalog, discoverKittyCatalog, emitKittyPipeline, formatShaderValue, parseKittyPipeline, parseShaderConstants, type KittyCatalog} from '../src/appearance/kittyShaderCatalog.js'; +import {applyKittyShaderPlan, inspectKittyLaunch, inspectKittyShaders, kittyShaderPaths, launchOverrideReason, normalizeHostShaders, planKittyShaderRemoval, planKittyShaders, + shellWords, syncKittyShaders, validateEffects, type KittyShaderContext, type KittyShaderSettings, type ShaderGates} from '../src/appearance/kittyShaders.js'; +import {readHostConfiguration} from '../src/appearance/hostConfiguration.js'; + +// Synthetic stand-ins with the SAME structure as Kitty 0.49.2's shipped resources (not copies of them). +const RESOURCES: Record = { + 'types.slang': '// types\n', + 'cursor-trail-blaze.pipeline': 'startgroup\n animation_start cursor-trail-move\n animation_stop cursor-trail-stop\n shaders cursor-trail-blaze\nendgroup\n', + 'cursor-trail-blaze.slang': 'import kitty_custom_shader_types;\nstatic const float4 TRAIL_COLOR = float4(1.0, 0.725, 0.161, 1.0);\nstatic const float4 TRAIL_COLOR_ACCENT = float4(1.0, 0.0, 0.0, 1.0);\n// Trail lifetime in seconds.\nstatic const float DURATION = 0.5;\n', + 'cursor-trail-default.pipeline': 'startgroup\n animation_start cursor-trail-move\n animation_stop cursor-trail-stop\n shaders cursor-trail-default\nendgroup\n', + 'cursor-trail-default.slang': 'import kitty_custom_shader_types;\n', + 'cursor-trail-lightning.pipeline': 'startgroup\n animation_start cursor-trail-move\n animation_stop cursor-trail-stop\n shaders cursor-trail-lightning\nendgroup\n', + // DURATION declared as int here: a mismatch with NMSh's expected float means it is not offered. + 'cursor-trail-lightning.slang': 'static const float3 BOLT_COLOR = float3(1.00, 0.82, 0.72);\nstatic const int DURATION = 1;\nstatic const float JAGGEDNESS = 0.085;\n', + 'northern-lights.pipeline': 'textures a persist\n\nstartgroup\n animation_start os-window-focus-in | user-activity\n animation_stop os-window-focus-out | user-idle\n var int STAGE = 0\n shaders northern-lights\n output_texture a\nendgroup\n\nstartgroup\n attach\n var int STAGE = 1\n shaders northern-lights\n output_texture persist\nendgroup\n\nstartgroup\n attach\n var int STAGE = 2\n shaders northern-lights\nendgroup\n', + 'northern-lights.slang': 'static const float PI = 3.14159265359f;\nstatic const float EFFECT_INTENSITY = 0.25f;\nstatic const float TIME_SCALE = 0.25f;\nstatic const float STAR_ROT = (2 * PI) / (86400 * TIME_SCALE);\nstatic const int STAGE = 0; // internal use only\nstatic const float LAYERS[4] = { 0.1f, 0.2f, 0.3f, 0.4f };\n', + 'dim-inactive-windows.pipeline': 'startgroup\n animation_step 0\n var float HIGHLIGHT_INTENSITY = 0\n var bool DIM_CENTRAL_AREA_ONLY = true\n shaders focus-highlight\nendgroup\n', + 'focus-highlight.pipeline': 'startgroup\n animation_start window-focus-in\n animation_stop 750\n animation_curve ease-in\n animation_step 16\n shaders focus-highlight\nendgroup\n', + 'focus-highlight.slang': 'static const float HIGHLIGHT_INTENSITY = 0.30;\nstatic const float BORDER_WIDTH = 0.04;\nstatic const float INACTIVE_DIM = 0.3;\nstatic const float3 INACTIVE_DIM_COLOR = float3(0.0);\n', + 'crt-blue.pipeline': 'var float4 TINT = float4(0, 0.8, 0.6, 1)\n\nstartgroup\n shaders crt\nendgroup\n', + 'crt.slang': 'static const float WARP = 0.25f; // curvature\nstatic const float SCAN = 0.50f;\nstatic const float4 TINT = float4(0, 0.8, 0.6, 0); // tint\n', + 'spotlight.pipeline': 'startgroup\n animation_start os-window-focus-in | user-activity\n animation_stop os-window-focus-out | user-idle\n shaders spotlight\nendgroup\n', + 'spotlight.slang': 'static const float SPOTLIGHT_RADIUS = 0.25;\nstatic const float AMBIENT_LIGHT = 0.5;\n', +}; +const catalog = (version = '0.49.2', resources = RESOURCES) => buildKittyCatalog(version, resources); +const openGates: ShaderGates = {reducedMotion: false, effectsOff: false, cursorRenderer: 'auto'}; + +function fixture(t: {after: (fn: () => void) => void}, options: {catalog?: KittyCatalog; gates?: ShaderGates; launch?: KittyShaderContext['launch']} = {}) { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-kshader-'))); + t.after(() => rmSync(home, {recursive: true, force: true})); + const context: KittyShaderContext = {env: {HOME: home, XDG_CONFIG_HOME: join(home, '.config')}, platform: 'linux', catalog: options.catalog ?? catalog(), + gates: options.gates ?? openGates, launch: options.launch ?? {}}; + const put = (path: string, content: string) => { mkdirSync(dirname(path), {recursive: true, mode: 0o700}); writeFileSync(path, content, {mode: 0o600}); }; + const kitty = join(home, '.config/kitty/kitty.conf'); + return {home, context, put, kitty, paths: kittyShaderPaths(context)}; +} +const settingsOf = (...effects: Array<[string, Record?, boolean?]>): KittyShaderSettings => + normalizeHostShaders({kitty: {effects: effects.map(([id, values, enabled]) => ({id, enabled: enabled ?? true, values: values ?? {}}))}}).kitty; +const effective = (context: KittyShaderContext) => shellWords(readHostConfiguration('kitty', context).values.get('custom_shaders')?.raw ?? ''); +const apply = (result: ReturnType) => { assert.ok(result.ok, result.ok ? '' : result.error); const applied = applyKittyShaderPlan(result.plan); assert.ok(applied.ok, applied.ok ? '' : applied.error); return result.plan; }; + +// ---- Grammar --------------------------------------------------------------------------------------------------- + +test('pipeline parser accepts Kitty 0.49 shapes and rejects what Kitty rejects', () => { + const ok = parseKittyPipeline(RESOURCES['northern-lights.pipeline']!, 'northern-lights'); + assert.ok(ok.ok); + assert.deepEqual(ok.value.textures, ['a', 'persist']); + assert.equal(ok.value.groups.length, 3); + assert.equal(ok.value.groups[0]!.animationStart, 'os-window-focus-in|user-activity'); + assert.equal(ok.value.groups[1]!.attach, true); + assert.deepEqual(parseKittyPipeline('var float4 TINT float4(0, 1, 0, 1);\nstartgroup\nshaders crt\nendgroup', 'x').ok && 'ok', 'ok'); // "=" is optional + const bad: Array<[string, RegExp]> = [ + ['startgroup\nshaders x\nfrobnicate 1\nendgroup', /Unknown key frobnicate/u], + ['startgroup\nanimation_start key-press\nshaders x\nendgroup', /not a Kitty shader animation event/u], + ['startgroup\nshaders x', /Unclosed group/u], + ['startgroup\nattach\nshaders x\nendgroup', /first group/u], + ['textures a\nstartgroup\nshaders x\noutput_texture a\nendgroup', /final group cannot output/u], + ['startgroup\nviewport_size 0.5 0.5\nshaders x\nendgroup', /must not specify a viewport/u], + ['startgroup\nvar vec3 X = 1\nshaders x\nendgroup', /not supported by Kitty/u], + ['startgroup\nvar float 9X = 1\nshaders x\nendgroup', /not a valid identifier/u], + ['startgroup\nanimation_step -1\nshaders x\nendgroup', /non-negative/u], + ['startgroup\nanimation_curve wobble\nshaders x\nendgroup', /not a recognized animation curve/u], + ['textures q\nstartgroup\nshaders x\nendgroup', /not a Kitty texture/u], + [Array.from({length: 17}, () => 'startgroup\nshaders x\nendgroup').join('\n'), /Kitty allows 16/u], + ]; + for (const [text, error] of bad) { const result = parseKittyPipeline(text, 'x'); assert.equal(result.ok, false, text); assert.match((result as {error: string}).error, error); } + // A file with no groups is Kitty's default pipeline for that name. + const bare = parseKittyPipeline('# only a comment\n', 'mine'); + assert.ok(bare.ok && bare.value.groups[0]!.shaders[0] === 'mine'); +}); + +test('shader constants: literals are tunable, expressions and arrays are not; emitted values keep Kitty-compilable form', () => { + const constants = parseShaderConstants(RESOURCES['northern-lights.slang']!); + assert.equal(constants.get('EFFECT_INTENSITY')?.value, 0.25); + assert.equal(constants.get('STAR_ROT')?.value, undefined); + assert.equal(constants.get('STAR_ROT')?.raw, '(2 * PI) / (86400 * TIME_SCALE)'); + assert.equal(constants.has('LAYERS'), false); + assert.deepEqual(parseShaderConstants(RESOURCES['focus-highlight.slang']!).get('INACTIVE_DIM_COLOR')?.value, [0, 0, 0]); + assert.equal(formatShaderValue('float', 1), '1.0'); + assert.equal(formatShaderValue('float', 0.3), '0.3'); + assert.equal(formatShaderValue('float4', [0.2, 0.6, 1, 1]), 'float4(0.2, 0.6, 1.0, 1.0)'); + assert.equal(formatShaderValue('int', 3), '3'); + assert.throws(() => formatShaderValue('int', 2.5)); + assert.throws(() => formatShaderValue('float3', [1, 2])); +}); + +// ---- Catalog --------------------------------------------------------------------------------------------------- + +test('catalog: Kitty below 0.49 is unsupported; 0.49+ offers only installed presets and verified parameters', () => { + assert.equal(catalog('0.48.1').supported, false); + assert.match(catalog('0.48.1').reason!, /0\.49 or newer/u); + assert.equal(catalog('').supported, false); + const c = catalog(); + assert.ok(c.supported); + const ids = c.offered.map(preset => preset.definition.id); + assert.ok(ids.includes('cursor-trail-blaze') && ids.includes('northern-lights') && ids.includes('crt')); + assert.equal(ids.includes('inside-the-matrix'), false); // not installed in this catalog → never advertised + const blaze = c.offered.find(preset => preset.definition.id === 'cursor-trail-blaze')!; + assert.deepEqual(blaze.params.map(param => [param.name, param.default]), [['DURATION', 0.5], ['TRAIL_COLOR', [1, 0.725, 0.161, 1]], ['TRAIL_COLOR_ACCENT', [1, 0, 0, 1]]]); + assert.equal(blaze.origin.kittyVersion, '0.49.2'); + assert.deepEqual(blaze.origin.resources.map(item => item.name), ['cursor-trail-blaze.pipeline', 'cursor-trail-blaze.slang']); + const lightning = c.offered.find(preset => preset.definition.id === 'cursor-trail-lightning')!; + assert.ok(lightning.missing.includes('DURATION')); // declared int, expected float: not offered + assert.ok(lightning.missing.includes('CORE_WIDTH')); // not declared at all + assert.deepEqual(lightning.params.map(param => param.name), ['JAGGEDNESS', 'BOLT_COLOR']); + // The shipped pipeline's own value is the starting value; pipeline-level vars move into groups. + assert.equal(c.offered.find(preset => preset.definition.id === 'dim-inactive-windows')!.params.find(param => param.name === 'INACTIVE_DIM')!.default, 0.3); + assert.deepEqual(c.offered.find(preset => preset.definition.id === 'cursor-trail-default')!.params, []); + assert.ok(c.notOffered.includes('crt-blue')); +}); + +test('discovery reads Kitty’s packaged resources through a bounded helper and never guesses', async () => { + const calls: string[][] = []; + const ok = await discoverKittyCatalog('/opt/kitty', async (binary, args) => { calls.push([binary, ...args]); return {code: 0, stdout: JSON.stringify({version: '0.49.2', resources: RESOURCES})}; }); + assert.ok(ok.supported); + assert.equal(calls[0]![1], '+runpy'); + assert.match(calls[0]![2]!, /read_kitty_resource/u); + assert.doesNotMatch(calls[0]![2]!, /open\(|load_config|subprocess|import os|exec\(/u); + assert.equal((await discoverKittyCatalog(undefined)).supported, false); + assert.match((await discoverKittyCatalog('/k', async () => ({code: null, stdout: ''}))).reason!, /did not report/u); + assert.match((await discoverKittyCatalog('/k', async () => ({code: 0, stdout: JSON.stringify({version: '0.47.0', resources: {}})}))).reason!, /0\.49/u); + assert.match((await discoverKittyCatalog('/k', async () => ({code: 0, stdout: JSON.stringify({version: '0.49.2', error: 'ImportError'})}))).reason!, /could not be listed/u); +}); + +// ---- Settings ---------------------------------------------------------------------------------------------------- + +test('settings: shape is normalized; values are checked against the installed shader, never clamped', () => { + const normalized = normalizeHostShaders({kitty: {placement: 'sideways', effects: [{id: 'crt', enabled: true, values: {WARP: 0.4, 'bad name': 1, SCAN: 'x'}}, {id: 'crt'}, {id: '../x'}]}}); + assert.deepEqual(normalized.kitty, {placement: 'after', effects: [{id: 'crt', enabled: true, values: {WARP: 0.4}}]}); + const c = catalog(); + assert.deepEqual(validateEffects(settingsOf(['crt', {WARP: 0.4}]), c), []); + assert.match(validateEffects(settingsOf(['crt', {WARP: 7}]), c).join(), /Curvature must be between 0 and 1/u); + assert.match(validateEffects(settingsOf(['crt', {MADE_UP: 1}]), c).join(), /MADE_UP is not an adjustable setting/u); + assert.match(validateEffects(settingsOf(['crt', {TINT: [1, 1, 1]}]), c).join(), /4 components/u); + assert.match(validateEffects(settingsOf(['inside-the-matrix']), c).join(), /not offered for Kitty 0\.49\.2/u); + assert.match(validateEffects(settingsOf(['cursor-trail-blaze'], ['cursor-trail-default']), c).join(), /cannot run together/u); + assert.deepEqual(validateEffects(settingsOf(['cursor-trail-blaze'], ['cursor-trail-default', {}, false]), c), []); +}); + +// ---- Composition and ownership ----------------------------------------------------------------------------------- + +test('setup without user shaders: one owned list, group-scoped values, exact preview; re-planning is idempotent', t => { + const f = fixture(t); + f.put(f.kitty, 'font_size 13\ncursor_trail 3\n'); + const settings = settingsOf(['cursor-trail-blaze', {DURATION: 0.3, TRAIL_COLOR: [0.2, 0.6, 1, 1]}]); + const plan = apply(planKittyShaders(settings, f.context)); + assert.deepEqual(effective(f.context), [f.paths.pipeline]); + assert.deepEqual(plan.effectiveShaders, [f.paths.pipeline]); + const pipeline = readFileSync(f.paths.pipeline, 'utf8'); + assert.match(pipeline, /animation_start cursor-trail-move/u); + assert.match(pipeline, /var float DURATION = 0\.3\n/u); + assert.match(pipeline, /var float4 TRAIL_COLOR = float4\(0\.2, 0\.6, 1\.0, 1\.0\)/u); + assert.match(pipeline, /cursor-trail-blaze\.slang sha256:[0-9a-f]{12}/u); + assert.doesNotMatch(pipeline, /^var /mu); // never pipeline-level + assert.ok(parseKittyPipeline(pipeline, 'nmsh').ok); + assert.ok(plan.preview.some(line => line === `+ include ${f.paths.fragment}`)); + assert.equal(readFileSync(f.kitty, 'utf8'), `font_size 13\ncursor_trail 3\n\n# NMSh Kitty shaders (remove using /appearance)\ninclude ${f.paths.fragment}\n`); + const again = planKittyShaders(settings, f.context); + assert.ok(again.ok); + assert.ok(again.plan.changes.every(change => change.after === change.before.content), 'second plan changes nothing'); +}); + +test('the person’s effective custom_shaders is kept verbatim, in order, from includes, with quoting, before or after', t => { + const f = fixture(t); + const shadersDir = join(dirname(f.kitty), 'shaders'); + f.put(join(shadersDir, 'my glow.pipeline'), 'startgroup\n shaders my-glow\nendgroup\n'); + f.put(join(dirname(f.kitty), 'theme.conf'), 'custom_shaders spotlight\n'); + const original = 'include theme.conf\ncustom_shaders crt-blue "my glow"\nmap f1 next_window\n'; + f.put(f.kitty, original); + const theme = readFileSync(join(dirname(f.kitty), 'theme.conf'), 'utf8'); + const plan = apply(planKittyShaders(settingsOf(['northern-lights', {EFFECT_INTENSITY: 0.5}]), f.context)); + assert.deepEqual(effective(f.context), ['crt-blue', 'my glow', f.paths.pipeline]); + assert.match(readFileSync(f.paths.fragment, 'utf8'), new RegExp(`custom_shaders crt-blue "my glow" '?${f.paths.pipeline.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}'?\n`, 'u')); + assert.ok(plan.preview[0]!.includes('Kept, in order: crt-blue · my glow')); + assert.ok(plan.preview.some(line => /already ignores earlier custom_shaders lines at .*theme\.conf:1/u.test(line))); + assert.equal(readFileSync(join(dirname(f.kitty), 'theme.conf'), 'utf8'), theme); + assert.ok(readFileSync(f.kitty, 'utf8').startsWith(original)); + // User pipeline-level vars (crt-blue's TINT) would leak into every chained group; NMSh's groups are unaffected (no TINT there), + // and its three northern-lights groups each carry the chosen value next to their shipped STAGE. + const pipeline = readFileSync(f.paths.pipeline, 'utf8'); + assert.equal(pipeline.match(/var float EFFECT_INTENSITY = 0\.5/gu)?.length, 3); + assert.match(pipeline, /textures a persist/u); + const before = planKittyShaders({...settingsOf(['northern-lights']), placement: 'before'}, f.context); + assert.ok(before.ok); + assert.deepEqual(before.plan.effectiveShaders, [f.paths.pipeline, 'crt-blue', 'my glow']); +}); + +test('leaked pipeline-level vars from the person’s shaders are pinned to the shipped source inside NMSh’s groups', t => { + const f = fixture(t); + f.put(f.kitty, 'custom_shaders crt-blue\n'); + apply(planKittyShaders(settingsOf(['crt', {WARP: 0.5}]), f.context)); + const pipeline = readFileSync(f.paths.pipeline, 'utf8'); + assert.match(pipeline, /var float4 TINT = float4\(0, 0\.8, 0\.6, 0\)/u); // shipped crt default, not crt-blue's leaked value + assert.match(pipeline, /var float WARP = 0\.5/u); +}); + +test('refusals: unsafe or unknowable compositions change nothing and say why', t => { + const cases: Array<{name: string; setup: (f: ReturnType) => void; settings: KittyShaderSettings; error: RegExp; launch?: KittyShaderContext['launch']}> = [ + {name: 'dynamic include', setup: f => f.put(f.kitty, 'geninclude gen.py\n'), settings: settingsOf(['crt']), error: /Dynamic Kitty includes/u}, + {name: 'launch override', setup: f => f.put(f.kitty, ''), settings: settingsOf(['crt']), launch: {refusal: 'Kitty was started with an override (custom_shaders)'}, error: /override/u}, + {name: 'unreadable user pipeline', setup: f => { f.put(join(dirname(f.kitty), 'shaders/mine.pipeline'), 'startgroup\nshaders x\n'); f.put(f.kitty, 'custom_shaders mine\n'); }, settings: settingsOf(['crt']), error: /cannot read|Unclosed group/u}, + {name: 'pipeline outside home', setup: f => f.put(f.kitty, 'custom_shaders /etc/kitty/elsewhere\n'), settings: settingsOf(['crt']), error: /cannot be inspected safely/u}, + {name: 'unbalanced quoting', setup: f => f.put(f.kitty, 'custom_shaders "oops\n'), settings: settingsOf(['crt']), error: /unbalanced quoting/u}, + {name: 'shadowed shipped shader', setup: f => { f.put(f.kitty, ''); f.put(join(dirname(f.kitty), 'crt.slang'), '// mine\n'); }, settings: settingsOf(['crt']), error: /would replace Kitty's shipped crt/u}, + {name: 'second cursor trail', setup: f => f.put(f.kitty, 'custom_shaders cursor-trail-default\n'), settings: settingsOf(['cursor-trail-blaze']), error: /already draws a cursor trail/u}, + {name: 'shared texture', setup: f => { f.put(join(dirname(f.kitty), 'shaders/keep.pipeline'), 'textures persist\nstartgroup\nshaders k\noutput_texture persist\nendgroup\nstartgroup\nattach\nshaders k\nendgroup\n'); f.put(f.kitty, 'custom_shaders keep\n'); }, + settings: settingsOf(['northern-lights']), error: /shared texture “persist”/u}, + {name: 'too many groups', setup: f => { f.put(join(dirname(f.kitty), 'shaders/big.pipeline'), Array.from({length: 14}, () => 'startgroup\nshaders b\nendgroup').join('\n')); f.put(f.kitty, 'custom_shaders big\n'); }, + settings: settingsOf(['northern-lights']), error: /16 shader groups in total; your shaders use 14 and these effects need 3/u}, + {name: 'unrecorded hook', setup: f => f.put(f.kitty, `include ${kittyShaderPaths(f.context).fragment}\n`), settings: settingsOf(['crt']), error: /unrecorded NMSh Kitty shader include/u}, + {name: 'nothing enabled', setup: f => f.put(f.kitty, ''), settings: settingsOf(['crt', {}, false]), error: /No Kitty effect is enabled/u}, + ]; + for (const item of cases) { + const f = fixture(t, item.launch ? {launch: item.launch} : {}); + item.setup(f); + const before = existsSync(f.kitty) ? readFileSync(f.kitty, 'utf8') : undefined; + const result = planKittyShaders(item.settings, f.context); + assert.equal(result.ok, false, item.name); + assert.match((result as {error: string}).error, item.error, item.name); + assert.equal(existsSync(f.kitty) ? readFileSync(f.kitty, 'utf8') : undefined, before, item.name); + assert.equal(existsSync(f.paths.pipeline), false, item.name); + } +}); + +test('symlinked, group-writable and other-owner paths are refused before any write', t => { + const f = fixture(t); + const real = join(f.home, 'dotfiles/kitty.conf'); + f.put(real, 'font_size 12\n'); + mkdirSync(dirname(f.kitty), {recursive: true}); + symlinkSync(real, f.kitty); + const linked = planKittyShaders(settingsOf(['crt']), f.context); + assert.equal(linked.ok, false); + assert.match((linked as {error: string}).error, /symbolic link/u); + const g = fixture(t); + g.put(g.kitty, 'font_size 12\n'); + chmodSync(dirname(g.kitty), 0o775); + const writable = planKittyShaders(settingsOf(['crt']), g.context); + assert.equal(writable.ok, false); + assert.match((writable as {error: string}).error, /writable by a group/u); +}); + +test('a later custom_shaders line after setup is detected: drift is reported and sync refuses to overwrite it', t => { + const f = fixture(t); + f.put(f.kitty, 'custom_shaders spotlight\n'); + apply(planKittyShaders(settingsOf(['crt']), f.context)); + // The person edits their own line (above NMSh's hook): Kitty still loads NMSh's reviewed copy until a new review. + writeFileSync(f.kitty, readFileSync(f.kitty, 'utf8').replace('custom_shaders spotlight', 'custom_shaders spotlight cursor-trail-default')); + const inspection = inspectKittyShaders(f.context); + assert.equal(inspection.drifted, true); + // Adding or keeping effects on a drifted value needs a review… + assert.equal(syncKittyShaders(settingsOf(['crt']), {...f.context, gates: {...openGates, cursorRenderer: 'portable'}}).kind, 'review'); + // …but taking every NMSh effect out never waits: the person's own (new) value applies at once. + assert.equal(syncKittyShaders(settingsOf(['crt']), {...f.context, gates: {...openGates, reducedMotion: true}, launch: {refusal: 'no KITTY_PID'}}).kind, 'written'); + assert.deepEqual(effective(f.context), ['spotlight', 'cursor-trail-default']); + // A new reviewed plan composes the new value. + const plan = apply(planKittyShaders(settingsOf(['crt']), f.context)); + assert.deepEqual(plan.effectiveShaders, ['spotlight', 'cursor-trail-default', f.paths.pipeline]); + // A competing line AFTER the hook would override NMSh: refused. + writeFileSync(f.kitty, `${readFileSync(f.kitty, 'utf8')}custom_shaders spotlight\n`); + const overridden = planKittyShaders(settingsOf(['crt']), f.context); + assert.equal(overridden.ok, false); + assert.match((overridden as {error: string}).error, /later or unresolved custom_shaders line/u); +}); + +test('Reduced Motion, Effects Off and a Portable cursor: nothing new is written; sync drops NMSh groups only', t => { + const f = fixture(t); + f.put(f.kitty, 'cursor_trail 3\ncustom_shaders spotlight\n'); + const reduced = planKittyShaders(settingsOf(['crt']), {...f.context, gates: {...openGates, reducedMotion: true}}); + assert.match((reduced as {error: string}).error, /Reduced Motion is on/u); + const off = planKittyShaders(settingsOf(['crt']), {...f.context, gates: {...openGates, effectsOff: true}}); + assert.match((off as {error: string}).error, /Decorative effects Off/u); + const portable = planKittyShaders(settingsOf(['cursor-trail-blaze']), {...f.context, gates: {...openGates, cursorRenderer: 'portable'}}); + assert.match((portable as {error: string}).error, /Portable/u); + const settings = settingsOf(['cursor-trail-blaze'], ['crt']); + apply(planKittyShaders(settings, f.context)); + assert.match(readFileSync(f.paths.pipeline, 'utf8'), /cursor-trail-blaze/u); + // Portable cursor: NMSh draws the trail, so Kitty's trail group goes; CRT stays. + assert.equal(syncKittyShaders(settings, {...f.context, gates: {...openGates, cursorRenderer: 'portable'}}).kind, 'written'); + assert.doesNotMatch(readFileSync(f.paths.pipeline, 'utf8'), /cursor-trail-blaze/u); + assert.match(readFileSync(f.paths.pipeline, 'utf8'), /shaders crt/u); + // Reduced Motion: no NMSh line at all, so the person's own value applies unchanged. + assert.equal(syncKittyShaders(settings, {...f.context, gates: {...openGates, reducedMotion: true}}).kind, 'written'); + assert.doesNotMatch(readFileSync(f.paths.fragment, 'utf8'), /^custom_shaders/mu); + assert.deepEqual(effective(f.context), ['spotlight']); + assert.equal(syncKittyShaders(settings, {...f.context, gates: {...openGates, reducedMotion: true}}).kind, 'unchanged'); + // Motion back on: restored from the reviewed settings. + assert.equal(syncKittyShaders(settings, f.context).kind, 'written'); + assert.deepEqual(effective(f.context), ['spotlight', f.paths.pipeline]); + assert.equal(syncKittyShaders(settings, fixture(t).context).kind, 'none'); +}); + +test('removal restores the exact original bytes and is not repeatable; interrupted removal recovers', t => { + const f = fixture(t); + const original = 'font_size 13\r\ncustom_shaders spotlight'; + f.put(f.kitty, original); + apply(planKittyShaders(settingsOf(['crt']), f.context)); + const removal = planKittyShaderRemoval(f.context); + assert.ok(removal.ok); + assert.ok(removal.plan.preview.some(line => line.includes('Your own custom_shaders applies again: spotlight'))); + assert.ok(applyKittyShaderPlan(removal.plan).ok); + assert.equal(readFileSync(f.kitty, 'utf8'), original); + for (const path of [f.paths.fragment, f.paths.pipeline, f.paths.ledger]) assert.equal(existsSync(path), false); + assert.equal(planKittyShaderRemoval(f.context).ok, false); + // Created config file: removal deletes the file NMSh created. + const g = fixture(t); + apply(planKittyShaders(settingsOf(['crt']), g.context)); + assert.ok(applyKittyShaderPlan((planKittyShaderRemoval(g.context) as {ok: true; plan: never}).plan).ok); + assert.equal(existsSync(g.kitty), false); + // Hook already removed by hand (or an interrupted removal): only NMSh's own files go. + const h = fixture(t); + h.put(h.kitty, 'font_size 9\n'); + apply(planKittyShaders(settingsOf(['crt']), h.context)); + writeFileSync(h.kitty, 'font_size 9\n'); + const recover = planKittyShaderRemoval(h.context); + assert.ok(recover.ok && recover.plan.preview[0]!.includes('already absent')); + assert.ok(applyKittyShaderPlan(recover.plan).ok); + assert.equal(readFileSync(h.kitty, 'utf8'), 'font_size 9\n'); + assert.equal(existsSync(h.paths.ledger), false); +}); + +test('stale files and interrupted writes: edited or unowned NMSh files and changed configs refuse; failures roll back', t => { + const f = fixture(t); + f.put(f.kitty, 'font_size 13\n'); + apply(planKittyShaders(settingsOf(['crt']), f.context)); + writeFileSync(f.paths.pipeline, `${readFileSync(f.paths.pipeline, 'utf8')}# hand edit\n`); + assert.match((planKittyShaders(settingsOf(['crt']), f.context) as {error: string}).error, /pipeline is unowned or was edited outside NMSh/u); + // Removal still works: the edited file is kept (no longer included), the hook and ledger go. + const removal = planKittyShaderRemoval(f.context); + assert.ok(removal.ok && removal.plan.preview.some(line => /pipeline was edited outside NMSh; it is kept/u.test(line))); + assert.ok(applyKittyShaderPlan(removal.plan).ok); + assert.equal(readFileSync(f.kitty, 'utf8'), 'font_size 13\n'); + assert.match(readFileSync(f.paths.pipeline, 'utf8'), /# hand edit/u); + assert.equal(existsSync(f.paths.ledger), false); + // A deleted owned file does not block removal either. + const d = fixture(t); + d.put(d.kitty, 'font_size 13\n'); + apply(planKittyShaders(settingsOf(['crt']), d.context)); + rmSync(d.paths.pipeline); + assert.match((planKittyShaders(settingsOf(['crt']), d.context) as {error: string}).error, /missing; R .* removes the integration/u); + const missing = planKittyShaderRemoval(d.context); + assert.ok(missing.ok && applyKittyShaderPlan(missing.plan).ok); + assert.equal(readFileSync(d.kitty, 'utf8'), 'font_size 13\n'); + assert.equal(existsSync(d.paths.fragment), false); + const g = fixture(t); + g.put(g.kitty, 'font_size 13\n'); + apply(planKittyShaders(settingsOf(['crt']), g.context)); + writeFileSync(g.paths.ledger, '{not json'); + assert.match((planKittyShaders(settingsOf(['crt']), g.context) as {error: string}).error, /malformed/u); + // Unowned leftovers from an interrupted first setup (files without a ledger) are preserved, not overwritten. + const h = fixture(t); + h.put(h.kitty, ''); + h.put(h.paths.pipeline, '# someone else\n'); + assert.match((planKittyShaders(settingsOf(['crt']), h.context) as {error: string}).error, /unowned/u); + // The config changes between preview and confirmation: nothing is written. + const k = fixture(t); + k.put(k.kitty, 'font_size 13\n'); + const planned = planKittyShaders(settingsOf(['crt']), k.context); + assert.ok(planned.ok); + writeFileSync(k.kitty, 'font_size 14\n'); + const applied = applyKittyShaderPlan(planned.plan); + assert.equal(applied.ok, false); + assert.equal(readFileSync(k.kitty, 'utf8'), 'font_size 14\n'); + assert.equal(existsSync(k.paths.pipeline), false); + // A dependency (the person's own pipeline) changes after preview: refused too. + const m = fixture(t); + m.put(join(dirname(m.kitty), 'shaders/mine.pipeline'), 'startgroup\nshaders m\nendgroup\n'); + m.put(m.kitty, 'custom_shaders mine\n'); + const mine = planKittyShaders(settingsOf(['crt']), m.context); + assert.ok(mine.ok); + writeFileSync(join(dirname(m.kitty), 'shaders/mine.pipeline'), 'textures persist\nstartgroup\nshaders m\nendgroup\n'); + assert.equal(applyKittyShaderPlan(mine.plan).ok, false); + assert.equal(existsSync(m.paths.pipeline), false); +}); + +test('notes: trail needs cursor_trail, CRT needs an opaque background, missing user shaders are kept as written', t => { + const f = fixture(t); + f.put(f.kitty, 'background_opacity 0.8\ncustom_shaders not-installed\n'); + const plan = planKittyShaders(settingsOf(['cursor-trail-blaze'], ['crt']), f.context); + assert.ok(plan.ok); + assert.ok(plan.plan.notes.some(note => /cursor_trail is above 0 \(now 0\)/u.test(note))); + assert.ok(plan.plan.notes.some(note => /background_opacity 0\.8/u.test(note))); + assert.ok(plan.plan.notes.some(note => /cannot find your shader “not-installed”; it is kept exactly as written/u.test(note))); + assert.deepEqual(plan.plan.effectiveShaders, ['not-installed', kittyShaderPaths(f.context).pipeline]); +}); + +test('launch options: overrides and foreign config files refuse; unreadable launch state refuses', t => { + assert.match(launchOverrideReason('/Applications/kitty.app/Contents/MacOS/kitty -o custom_shaders=crt')!, /override \(custom_shaders\)/u); + assert.match(launchOverrideReason('kitty --override=cursor_trail=1')!, /cursor_trail/u); + assert.match(launchOverrideReason('kitty -c /tmp/other.conf')!, /--config/u); + assert.match(launchOverrideReason('kitty --config=/x')!, /--config/u); + assert.equal(launchOverrideReason('kitty -o font_size=14 --single-instance'), undefined); + const f = fixture(t); + assert.match(inspectKittyLaunch(f.context, () => 'kitty').refusal!, /KITTY_PID/u); + const env = {...f.context.env, KITTY_PID: '4242'}; + assert.deepEqual(inspectKittyLaunch({...f.context, env}, () => '/usr/bin/kitty'), {}); + assert.match(inspectKittyLaunch({...f.context, env}, () => undefined).refusal!, /could not be read/u); + f.put(join(f.home, '.config/kitty/macos-launch-services-cmdline'), '-o custom_shaders=fireworks\n'); + assert.match(inspectKittyLaunch({...f.context, env}, () => '/usr/bin/kitty').refusal!, /macos-launch-services-cmdline/u); +}); + +test('emitted pipelines round-trip through the parser and never carry pipeline-level vars', () => { + const parsed = parseKittyPipeline(RESOURCES['northern-lights.pipeline']!, 'n'); + assert.ok(parsed.ok); + const text = emitKittyPipeline(parsed.value, ['header']); + const again = parseKittyPipeline(text, 'n'); + assert.ok(again.ok); + assert.deepEqual(again.value, parsed.value); + assert.throws(() => emitKittyPipeline({textures: [], vars: [{type: 'float', name: 'X', value: '1.0'}], groups: []}), /pipeline-level vars/u); + assert.deepEqual(shellWords(`a 'b c' "d \\"e\\"" f\\ g`), ['a', 'b c', 'd "e"', 'f g']); + assert.equal(shellWords('"open'), undefined); +}); + +// ---- One cursor engine: Kitty trail shader vs Portable ------------------------------------------------------------- + +test('cursor: a Portable renderer turns Kitty’s own trail off; a written Kitty trail shader means Portable never draws a second trail', async t => { + const {fragmentContent} = await import('../src/cursor/native.js'); + const {chooseBackend, hostCursorFacts, availabilityOf} = await import('../src/cursor/backends.js'); + const {DEFAULT_CURSOR} = await import('../src/prompt/configuration.js'); + const cursor = (extra: Record) => ({...structuredClone(DEFAULT_CURSOR), ...extra}) as typeof DEFAULT_CURSOR; + const env = {XDG_CONFIG_HOME: '/x'} as NodeJS.ProcessEnv; + assert.match(fragmentContent('kitty', cursor({motion: 'tail', renderer: 'portable'}), env), /^cursor_trail 0$/mu, 'Portable draws the trail itself'); + assert.match(fragmentContent('kitty', cursor({motion: 'tail', renderer: 'auto'}), env), /^cursor_trail 3$/mu); + const kittyEnv = {KITTY_WINDOW_ID: '1'} as NodeJS.ProcessEnv; + // Not set up through /cursor, but NMSh's reviewed Kitty pipeline has a Blaze trail: Kitty owns the trail. + const blaze = hostCursorFacts(kittyEnv, () => false, () => 'cursor-trail-blaze'); + const tail = chooseBackend(cursor({motion: 'tail', renderer: 'auto'}), blaze); + assert.equal(tail.backend.id, 'kitty'); + assert.equal(tail.portableDraws.motion, false); + assert.match(tail.reason, /Blaze trail shader/u); + // Blaze is a trail look; a Portable Lightning bolt is a different effect and still draws. + assert.equal(chooseBackend(cursor({motion: 'tail', effect: 'lightning'}), blaze).portableDraws.effect, true); + // The Lightning trail shader draws the bolt: Portable must not draw its own Lightning too. + const lightning = hostCursorFacts(kittyEnv, () => true, () => 'cursor-trail-lightning'); + const both = chooseBackend(cursor({motion: 'tail', effect: 'lightning'}), lightning); + assert.deepEqual([both.portableDraws.motion, both.portableDraws.effect], [false, false]); + assert.equal(availabilityOf('effect', 'lightning', 'auto', lightning).available && (availabilityOf('effect', 'lightning', 'auto', lightning) as {via: string}).via, 'native'); + // Motion Off means Kitty's cursor_trail is 0, so its shader draws nothing: Portable keeps its Lightning. + assert.equal(chooseBackend(cursor({motion: 'off', effect: 'lightning'}), lightning).portableDraws.effect, true); + // Without any NMSh Kitty trail, Kitty's built-in trail still has no effects. + assert.equal(chooseBackend(cursor({motion: 'tail', effect: 'lightning'}), hostCursorFacts(kittyEnv, () => true)).portableDraws.effect, true); +}); + +test('cursor: the written trail is read from NMSh’s files, not from settings', t => { + const f = fixture(t); + f.put(f.kitty, 'cursor_trail 3\n'); + return import('../src/appearance/kittyShaders.js').then(({writtenKittyTrail}) => { + assert.equal(writtenKittyTrail(f.context), undefined); + const settings = settingsOf(['cursor-trail-blaze']); + apply(planKittyShaders(settings, f.context)); + assert.equal(writtenKittyTrail(f.context), 'cursor-trail-blaze'); + // cursor_trail 0 means Kitty draws no shader trail at all: Portable must not be told Kitty has it. + writeFileSync(f.kitty, readFileSync(f.kitty, 'utf8').replace('cursor_trail 3', 'cursor_trail 0')); + assert.equal(writtenKittyTrail(f.context), undefined); + writeFileSync(f.kitty, readFileSync(f.kitty, 'utf8').replace('cursor_trail 0', 'cursor_trail 3')); + assert.equal(writtenKittyTrail(f.context), 'cursor-trail-blaze'); + syncKittyShaders(settings, {...f.context, gates: {...openGates, cursorRenderer: 'portable'}}); + assert.equal(writtenKittyTrail(f.context), undefined); + }); +}); + +test('a leaked user var that NMSh cannot pin (multi-line or repeated declaration) refuses; ~user entries are not guessed', t => { + const resources = {...RESOURCES, 'crt.slang': 'static const float WARP = 0.25f;\nstatic const float4 TINT =\n float4(0, 0.8, 0.6, 0);\n'}; + const f = fixture(t, {catalog: catalog('0.49.2', resources)}); + f.put(f.kitty, 'custom_shaders crt-blue\n'); + const refused = planKittyShaders(settingsOf(['crt']), f.context); + assert.equal(refused.ok, false); + assert.match((refused as {error: string}).error, /set TINT for every chained shader.*cannot pin safely/u); + assert.equal(parseShaderConstants('static const float A = 1.0;\nstatic const float A = 2.0;\n').get('A')?.raw, ''); + const g = fixture(t); + g.put(g.kitty, 'custom_shaders ~other/shaders/x\n'); + assert.match((planKittyShaders(settingsOf(['crt']), g.context) as {error: string}).error, /~user/u); +}); diff --git a/tests/kittyShadersLive.test.ts b/tests/kittyShadersLive.test.ts new file mode 100644 index 00000000..318e0065 --- /dev/null +++ b/tests/kittyShadersLive.test.ts @@ -0,0 +1,79 @@ +/** + * Installed-Kitty check (skipped without Kitty 0.49+): NMSh's composed configuration is loaded by Kitty's + * OWN config parser and its merged pipeline is compiled by Kitty's bundled slangc, all in a private HOME, + * config and cache directory. Nothing touches the installed app, a real config or a running Kitty. + * Headless compilation is not GPU rendering; physical QA remains separate. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join} from 'node:path'; +import {discoverKittyCatalog, kittyBinary, versionAtLeast} from '../src/appearance/kittyShaderCatalog.js'; +import {applyKittyShaderPlan, kittyShaderPaths, normalizeHostShaders, planKittyShaders, type KittyShaderContext} from '../src/appearance/kittyShaders.js'; + +const binary = kittyBinary(); +const version = binary ? spawnSync(binary, ['--version'], {encoding: 'utf8', timeout: 10_000}).stdout ?? '' : ''; +const supported = versionAtLeast(/kitty (\d+\.\d+(?:\.\d+)?)/u.exec(version)?.[1] ?? '', [0, 49]); + +const CHECK = ` +import json, os +from kitty.config import load_config +from kitty.constants import config_dir +from kitty.shaders.slang import parse_pipeline, merge_pipelines, build_custom_shader_pipeline_glsl +opts = load_config(os.path.join(config_dir, 'kitty.conf')) +out = {'custom_shaders': list(opts.custom_shaders), 'cursor_trail': opts.cursor_trail, 'config_dir': config_dir} +try: + merged = merge_pipelines([parse_pipeline(name) for name in opts.custom_shaders]) + out['groups'] = len(merged['groups']) + vert, frag, meta = build_custom_shader_pipeline_glsl(merged) + out['compiled'] = True + out['frag'] = frag +except Exception as e: + out['compiled'] = False + out['error'] = str(e)[:4000] +print(json.dumps(out)) +`; + +test('installed Kitty loads NMSh’s composed custom_shaders exactly and compiles the merged pipeline headlessly', {skip: !supported && 'Kitty 0.49+ is not installed', timeout: 120_000}, async t => { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-klive-'))); + t.after(() => rmSync(home, {recursive: true, force: true})); + // No XDG_CONFIG_HOME: on macOS NMSh's folder is "Library/Application Support", so the pipeline path has a space. + const env = {HOME: home, PATH: process.env.PATH ?? '/usr/bin:/bin'}; + const kittyDir = join(home, '.config/kitty'); + const put = (path: string, content: string) => { mkdirSync(dirname(path), {recursive: true, mode: 0o700}); writeFileSync(path, content, {mode: 0o600}); }; + put(join(kittyDir, 'shaders/my glow.pipeline'), 'startgroup\n shaders my-glow\nendgroup\n'); + put(join(kittyDir, 'shaders/my-glow.slang'), 'import kitty_custom_shader_types;\npublic float4 fragment_main(float4 color, KittyTextures t, KittyCustomShaderData d) { return color; }\n'); + put(join(kittyDir, 'kitty.conf'), 'cursor_trail 3\ninclude theme.conf\ncustom_shaders crt-blue "my glow"\n'); + put(join(kittyDir, 'theme.conf'), 'custom_shaders spotlight\n'); + const catalog = await discoverKittyCatalog(binary, (bin, args, timeout) => new Promise(resolve => { + const result = spawnSync(bin, [...args], {encoding: 'utf8', timeout, env: {...env, PYTHONDONTWRITEBYTECODE: '1', KITTY_CACHE_DIRECTORY: join(home, 'cache')}}); + resolve({code: result.status, stdout: result.stdout ?? ''}); + })); + assert.ok(catalog.supported, catalog.reason); + for (const id of ['cursor-trail-lightning', 'cursor-trail-blaze', 'northern-lights', 'spotlight', 'crt', 'inside-the-matrix', 'pond-ripple']) { + assert.ok(catalog.offered.some(preset => preset.definition.id === id), `${id} offered by installed Kitty`); + } + const context: KittyShaderContext = {env, platform: process.platform, catalog, gates: {reducedMotion: false, effectsOff: false, cursorRenderer: 'auto'}, launch: {}}; + const settings = normalizeHostShaders({kitty: {effects: [ + {id: 'cursor-trail-lightning', enabled: true, values: {DURATION: 0.2, BOLT_COLOR: [0.4, 0.7, 1]}}, + {id: 'northern-lights', enabled: true, values: {EFFECT_INTENSITY: 0.4}}, + {id: 'crt', enabled: true, values: {WARP: 0.1, TINT: [0.1, 0.5, 0.9, 1]}}, + ]}}).kitty; + const planned = planKittyShaders(settings, context); + assert.ok(planned.ok, planned.ok ? '' : planned.error); + const applied = applyKittyShaderPlan(planned.plan); + assert.ok(applied.ok, applied.ok ? '' : applied.error); + const paths = kittyShaderPaths(context); + const run = spawnSync(binary!, ['+runpy', CHECK], {encoding: 'utf8', timeout: 100_000, + env: {...env, PYTHONDONTWRITEBYTECODE: '1', KITTY_CACHE_DIRECTORY: join(home, 'cache')}}); + const result = JSON.parse(run.stdout) as {custom_shaders: string[]; cursor_trail: number; config_dir: string; groups?: number; compiled: boolean; error?: string; frag?: string}; + assert.equal(result.config_dir, kittyDir); + assert.deepEqual(result.custom_shaders, ['crt-blue', 'my glow', paths.pipeline], 'Kitty’s own parser sees the composed list'); + assert.deepEqual(planned.plan.effectiveShaders, result.custom_shaders); + assert.equal(result.groups, 2 + 5); + assert.equal(result.compiled, true, result.error); + assert.match(result.frag ?? '', /0\.2/u); + if (process.platform === 'darwin') assert.match(paths.pipeline, / /u); +});