diff --git a/docs/development/native-appearance-welcome/CHECKPOINT.json b/docs/development/native-appearance-welcome/CHECKPOINT.json index 54cab628..d71dbbc7 100644 --- a/docs/development/native-appearance-welcome/CHECKPOINT.json +++ b/docs/development/native-appearance-welcome/CHECKPOINT.json @@ -1,14 +1,51 @@ { - "phase": "Slice 2 (Kitty native shaders) implemented, reviewed, canonically verified (1 load-induced live timeout, passes alone); committed on feature/kitty-native-shaders-20261028", + "phase": "Slice 3 (Ghostty native shaders) implemented, independently reviewed, fixed, canonically verified clean; committed on feature/ghostty-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)", + "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"}, + "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." + "gpu_validation": "NOT RUN (headless Kitty parse, Ghostty +validate-config, slangc proxy compile only)", + "unrelated_prs_untouched": [ + 513, + 514 + ], + "next": "Slice 4: render routing (src/motion/renderRouting.ts) + Chroma-driven Aurora colors; then Slices 5-7. Human: physical Ghostty QA of /appearance \u203a Ghostty shaders.", + "slice3": { + "branch": "feature/ghostty-native-shaders-20261028", + "base": "de2afd0254327390205a5cde62af5d886f8efaaf", + "pr": "stacked draft PR targeting feature/kitty-native-shaders-20261028 (see progress)", + "source_manifest_sha256": "4f0f9e10b9a98000bb2d1d38b13f665c42235e5f91aa5597f1fca02b6404d063", + "tests": "canonical 4648/4662 pass, 0 fail, 13 skipped, 1 todo; focused 132/132; live 1.3.1+1.4.0 12/12; tsc clean", + "review": "Codex gpt-6.1-sol read-only: no blocker; 5 should-fix + 2 minor fixed with regressions" + }, + "animated_decode": { + "branch": "feature/animated-image-decode-20261028", + "commits": [ + "86aad528", + "ca90b2bc" + ], + "tests": "graphics 113/113 under guard", + "review": "Sonnet: no blocker; should-fix items fixed by Codex follow-up" + }, + "mermaid_ci": { + "failure": "#516 run 40656373289 browserConsoleChrome.test.ts:106 (not rerun)", + "diagnosis": "mermaid-diag/REPORT.md (Codex + lead addendum)", + "local": "pinned mermaid@12.2.0, 3/3 pass at dev c690de05+diag and at #516 de2afd02", + "diag_commit": "9270894 on local diag/mermaid-ci-20261028 (unpushed)", + "verdict": "not reproduced; deadline-under-load hypothesis unproven" + } } diff --git a/docs/development/native-appearance-welcome/DECISIONS.md b/docs/development/native-appearance-welcome/DECISIONS.md index 3b112818..0c0a9e87 100644 --- a/docs/development/native-appearance-welcome/DECISIONS.md +++ b/docs/development/native-appearance-welcome/DECISIONS.md @@ -31,3 +31,16 @@ Ruling: Ghostty version-tagged source loads legacy config before preferred confi Ruling: preserve the existing Theme Bridge ledger design concept but isolate host ownership records; Theme Bridge targets and current write primitives do not provide this transaction/dependency contract. Use exclusive staging, captured file identity/content, checks before staging and immediately before rename, and rollback of only unchanged task writes. POSIX rename is not atomic compare-and-swap; do not claim protection against arbitrary concurrent malicious directory swaps. Group/world-writable paths are refused. Ruling: preserve valid unchanged numeric blur above the normal UI range during unrelated saves. Require nonnegative safe integers in generated data; UI normally caps increases at50 but can decrease a larger existing value incrementally. Display bars clamp without changing stored values. + +## Slice 3 rulings (Ghostty native shaders, 2026-10-28) + +- NMSh writes its OWN GLSL (GPL-3.0-only, original code) generated from validated settings; no third-party shader is copied or downloaded. Seven presets: Smear and Comet trails (one at a time), Aurora (background-only tint), Dim when unfocused, Vignette, Scanlines, Text glow. Every parameter is compiled in as a constant and a test proves each one changes the source. Values are validated, never clamped. +- Version gates come from the uniforms each generated shader actually reads (scanned, comments stripped), against a table verified byte-for-byte: 1.2 = cursor uniforms; 1.3/1.4 add focus, cursor style/visibility and terminal colors (identical prefix in 1.3.0, 1.3.1, 1.4.0). No reported version (tmux, ssh: *hidden*), <1.2, or anything newer than 1.4.x (an unverified interface) fails closed for new setup. Sync substitutes the version recorded at review only when the running version is hidden; a running version known to be unsupported makes sync remove NMSh's passes and say why. +- Composition: Ghostty's custom-shader list is append-only and NMSh's fragment is an optional include (`config-file = ?…`) appended to the effective root file, so NMSh's passes run after every pass in the person's root files. NMSh never writes a `custom-shader =` reset, never re-lists or reorders the person's passes, and never sets `custom-shader-animation` (the older /cursor fragment did; the studio only reads and discloses it). A simulation of the whole configuration graph must show the person's passes unchanged and in order and NMSh's passes present in order, or the plan is refused (e.g. a later include that clears the list). +- Placement: Kitty's Before/After choice has no Ghostty equivalent without re-listing the person's passes (which would make NMSh own lines it did not write); Ghostty always runs NMSh's passes after. Disclosed in the studio. +- Never two trails: a trail pass refuses when any of the person's shaders reads the cursor-change uniforms, or when one cannot be read (outside the home, symlink). NMSh's older /cursor Ghostty shader is paused (its own managed file, exact content checked) while a studio trail is written; the ledger records `legacyPaused`, and the reviewed removal resumes it inside the same transaction only if the file still holds exactly the paused content and /cursor's settings still want it (otherwise the review says it is left alone). The studio never calls /cursor's writer outside its transaction. Cursor backends: a written studio trail makes Ghostty draw Smear/Tail and Portable draw effects. +- Reload: Ghostty never reloads its config by itself and NMSh never signals it. A trail NMSh stops writing (Portable chosen, motion off, Reduced Motion, removal, the older /cursor shader paused) is recorded as pending; while pending, Portable draws no cursor motion (and no effects if the older /cursor shader drew them) so a not-yet-reloaded Ghostty trail is never doubled. Pending trails accumulate across changes until a reload (legacy → Smear → Comet keeps the older /cursor shader, and its effects, pending). Pending clears when the person presses L in the studio, or when the Ghostty process found among NMSh's parents started after the change. If the record cannot be saved, the controller keeps it in memory for this process. A studio trail still written while Portable is chosen (a refused update) also suppresses Portable motion. +- Launch: the Ghostty process among NMSh's parents is read (one bounded `ps`); `--custom-shader`, `--config-file`, `--config-default-files` on its command line refuse. Not finding Ghostty (tmux, ssh, session service) is a disclosed note, not a refusal (GUI launches carry no flags). +- Architecture: TerminalApp must not mention host products (existing guard test). All Ghostty wiring lives in `src/appearance/glslStudio.ts`; the Kitty panel and the Ghostty panel are one shared `ShaderStudioPanel` with host wording supplied per host. The footer wraps with the shared `renderControlRows`. +- Sync: gate changes rewrite only NMSh-owned files; removing passes never waits for review; adding reviewed passes back re-simulates. Inside tmux (hidden version) sync uses the version recorded at review, so it never strips passes for an unrelated reason. NMSh also syncs once at startup (no process-table read), so gate changes made while it was not running (OS Reduced Motion, an edited config) reach the written passes. +- Animation disclosure: Ghostty runs its animation loop whenever any custom shader is listed (focused surface by default; `always` = every surface; `false` = only on terminal updates). Every plan discloses this; no preset claims to redraw "only while it fades". diff --git a/docs/development/native-appearance-welcome/HANDOFF.md b/docs/development/native-appearance-welcome/HANDOFF.md index b145b6eb..d512b6ab 100644 --- a/docs/development/native-appearance-welcome/HANDOFF.md +++ b/docs/development/native-appearance-welcome/HANDOFF.md @@ -1,5 +1,14 @@ # Native appearance and Welcome Studio handoff +## Slice 3 state (Ghostty native shaders) — read first + +- Branch `feature/ghostty-native-shaders-20261028` (worktree `/Volumes/T7Tmp/ai-dev/worktrees/notMyShell/ghostty-native-shaders-20261028`) is stacked on Slice 2 `feature/kitty-native-shaders-20261028` @ de2afd02 (PR #516, draft). Its draft PR targets the Slice 2 branch; retarget only after review, never automatically. +- Code map: `src/appearance/ghosttyShaderCatalog.ts` (version table, uniforms, presets, GLSL generation), `src/appearance/ghosttyShaders.ts` (inspection, effective-list simulation, compose/plan/apply/remove, sync, reload-pending, launch inspection), `src/appearance/glslStudio.ts` (app controller; TerminalApp never names hosts), `src/appearance/ShaderStudioPanel.ts` (shared Kitty/Ghostty studio), `GhosttyShaderPanel.ts`, cursor integration in `cursor/backends.ts` (`GHOSTTY_TRAIL_BACKEND`, pending suppression) and `cursor/native.ts` (legacy fragment pause). +- Owned files (host-appearance dir): `ghostty-shaders.conf`, `ghostty-shaders/.glsl`, `ghostty-shaders.json` (ledger), `ghostty-reload.json` (pending trails), plus one hook `config-file = ?…ghostty-shaders.conf` at the end of the effective root config. +- Next slices (all still required): 4 render routing (semantic surfaces → native/portable/off with reasons; native only for cursor trail and host focus) + Chroma-driven native colors via `idleStops`; 5 Shader Studio UX with previews; 6 Welcome Studio (uses the animation decoder branch); 7 integration, performance, QA, docs. +- Known follow-up (Slice 2 scope, not fixed here): Kitty integrated with motion Smear may draw Kitty's tail while Portable draws smear. +- Physical Ghostty/Kitty/Zed QA: NOT RUN. GPU rendering: NOT verified. + ## 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. diff --git a/docs/development/native-appearance-welcome/INDEPENDENT_REVIEW.md b/docs/development/native-appearance-welcome/INDEPENDENT_REVIEW.md index ea4313d1..444272ea 100644 --- a/docs/development/native-appearance-welcome/INDEPENDENT_REVIEW.md +++ b/docs/development/native-appearance-welcome/INDEPENDENT_REVIEW.md @@ -30,3 +30,15 @@ Should-fix, all FIXED with regressions: 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). + +## Slice 3 (Ghostty native shaders) — Codex gpt-6.1-sol, read-only, 2026-10-28 + +Full review: evidence `slice3-review/REVIEW.md`. No blocker. All items fixed by the lead with regressions: + +1. Should-fix: successive trail changes replaced the pending record (legacy → Smear → Comet lost the /cursor shader). Fixed: pending trails accumulate; 'nmsh-cursor' wins. +2. Should-fix: a refused sync or unsaved reload record let Portable draw over a written trail. Fixed: a written studio trail suppresses Portable motion whenever native does not handle motion; in-memory fallback in the controller. +3. Should-fix: studio confirm called /cursor's writer outside the reviewed transaction. Fixed: removed; ledger `legacyPaused`; removal resumes the exact paused fragment in the same transaction, or says it was left alone. +4. Should-fix: no startup reconciliation. Fixed: one sync at app construction (no process-table read). +5. Should-fix: sync substituted the recorded version for an explicitly unsupported running version. Fixed: `hidden` capability; >1.4.x fails closed; sync removes passes with the reason. +6. Minor: Smear note claimed redraws only while fading. Fixed; every plan discloses Ghostty's animation loop. +7. Minor: short windows hid the reload warning. Fixed: compact pending line. diff --git a/docs/development/native-appearance-welcome/RESEARCH.md b/docs/development/native-appearance-welcome/RESEARCH.md index 8ea8791d..f6f67869 100644 --- a/docs/development/native-appearance-welcome/RESEARCH.md +++ b/docs/development/native-appearance-welcome/RESEARCH.md @@ -54,3 +54,13 @@ Correction from independent source review: the Ghostty1.4.0 default roots load l Installed/staged host apps remain untouched. The attempted staged +show-config invocation does not accept generic configuration CLI flags in this action; its exit1 is retained and is not host parse/GPU validation. Do not retry it and claim validation from docs alone. Kitty0.49.2 installed catalog probe (headless, private HOME): read_kitty_resource("shaders/custom/pond-ripple.pipeline") returns the shipped event-driven pipeline even though loose .pipeline files are absent. kitty.constants lists list_kitty_resources(package='kitty') and read_kitty_resource(name,package_name='kitty'); top-level listing does not recurse. pkgutil.get_data returned no pipeline, and inspect.getsource failed for frozen code. A future bounded helper should use the terminal's packaged resource API or native named catalog lookup, not advertise missing capability based solely on loose paths. Direct read succeeded; no GPU compile/render/physical QA claim. + +## Slice 3 research (verified 2026-10-28; evidence slice3-probe/) + +- Installed Ghostty 1.3.1 and staged signature-verified 1.4.0: the compiled-in shadertoy prefix (`strings`) matches source `src/renderer/shaders/shadertoy_prefix.glsl` at v1.3.0, v1.3.1 and v1.4.0 (sha256 8cb58b8b…, identical). v1.2.0 (887bffa3…) lacks iCurrentCursorStyle, iPreviousCursorStyle, iCursorVisible, iTimeFocus, iFocus, iPalette[256], iBackgroundColor, iForegroundColor, iCursorColor, iCursorText, iSelectionForegroundColor, iSelectionBackgroundColor and the CURSORSTYLE defines. Release notes: 1.3.0 "custom shaders have many new uniforms"; 1.4.0 "no longer receive swapped selection foreground and background color uniforms" (a value fix, same interface). +- `custom-shader` is a RepeatablePath (src/config/path.zig, same semantics 1.2.0–1.4.0): an empty value clears the list; `?` marks optional; surrounding quotes stripped; `?` alone or `""` is ignored (no reset); relative paths resolve against the defining file's directory (realpath), `~/` expands with HOME. Config.zig declaration text identical in 1.3.1 and 1.4.0. `custom-shader-animation` = true (default; focused only) | false | always. +- Shader compile errors are logged on Ghostty's render thread, never as config errors; NMSh cannot observe them and says so. +- `ghostty +validate-config --config-file=` loads only that file and its includes (validate_config.zig v1.3.1/v1.4.0): a safe, isolated syntax check used by tests/ghosttyShadersLive.test.ts. `+show-config` on macOS read the REAL `~/Library/Application Support/com.mitchellh.ghostty/config` despite a private HOME (observed: font-size 14 and an existing NMSh appearance include appeared); read-only, nothing written, and NOT used by tests. +- Observed ordering with `+show-config` (both versions): root `config` → root `config.ghostty` → macOS roots → include queue in encounter order; relative include and shader paths resolved per file. +- Compile check: Kitty's bundled `slangc -lang glsl -allow-glsl -stage fragment -target spirv` compiles prefix+shader (rejects re-declaring gl_FragCoord, so that one prefix line is dropped). It accepts every NMSh preset and rejects a 1.3-only preset against the 1.2 prefix and real type errors. It is a PROXY: Ghostty uses glslang + SPIRV-Cross/Metal; GPU output is not verified. +- Third-party shaders (sahaj-b/cursor-shaders MIT, hackrdotsh/ghostty-shaders unverified) were not downloaded, executed or used; NMSh's presets are original. diff --git a/docs/development/native-appearance-welcome/TEST_RESULTS.md b/docs/development/native-appearance-welcome/TEST_RESULTS.md index 1cbb5013..f7950ff7 100644 --- a/docs/development/native-appearance-welcome/TEST_RESULTS.md +++ b/docs/development/native-appearance-welcome/TEST_RESULTS.md @@ -52,3 +52,17 @@ Source: branch feature/kitty-native-shaders-20261028 from 4abd014d. All Kitty ru - Source manifest unchanged after verify (only docs edited since). NOT RUN: physical Kitty/Ghostty/Zed QA, GPU rendering, performance measurement, Ghostty shader work. + +## Slice 3 — Ghostty native shaders (Claude, 2026-10-28) + +Source: branch feature/ghostty-native-shaders-20261028 from de2afd02. Every automated run used private HOME/XDG/NMSH config dirs; no real Ghostty config, window or installed app was touched. Evidence: `/Volumes/T7Tmp/ai-dev/nmsh-native-appearance-welcome-20261028/` (slice3-probe/, logs below). + +- `t7run --check`: PASSED (after clearing regenerable caches on T7 at the user's request: node-compile, pycache, clang-module). +- Probes: shader prefixes of Ghostty v1.2.0/v1.3.0/v1.3.1/v1.4.0 compared (1.3.0–1.4.0 identical); path.zig / validate_config.zig / Config.zig read; installed 1.3.1 and staged signature-verified 1.4.0 used headlessly (`+validate-config --config-file`) only. +- Live (tests/ghosttyShadersLive.test.ts, both binaries via NMSH_TEST_GHOSTTY_BINARIES): uniform table equals each binary's compiled-in prefix; `+validate-config` accepts NMSh's composed config; slangc (Kitty-bundled) compiles every preset against its prefix and rejects a 1.3-only preset against the 1.2 prefix. PROXY compile only: Ghostty's own glslang/SPIRV-Cross/Metal path and GPU output NOT verified. +- Independent Codex review: no blocker; 7 items fixed with regressions (INDEPENDENT_REVIEW.md). +- Focused after fixes: ghosttyShaders, ghosttyShaderPanel, ghosttyShadersLive, kittyShader*, cursor*, appearanceHub, hostAppearance*, hostIntegration — 132/132 (slice3-fixes-2.log); live on 1.3.1 + 1.4.0 with hostProfiles 12/12 (slice3-fixes-live.log); tsc clean; diff --check pass. +- Canonical `npm run verify` on source manifest 4f0f9e10… (slice3-canonical-verify.log): build and typecheck passed; tests 4662, pass 4648, fail 0, skipped 13, todo 1 (existing Arcade TODO); diff --check passed; exit 0. Manifest unchanged afterwards. A clean local canonical pass. +- Mermaid (#516 run 40656373289, browserConsoleChrome.test.ts:106): not rerun in CI; local controlled runs 3/3 pass at dev+diagnostic and at #516 (mermaid-diag/REPORT.md). Unexplained in CI; deadline-under-load hypothesis, not proven. + +NOT RUN: physical Ghostty/Kitty/Zed QA, GPU rendering, performance measurement. diff --git a/docs/development/native-appearance-welcome/progress.md b/docs/development/native-appearance-welcome/progress.md index 58ec3055..e028c55d 100644 --- a/docs/development/native-appearance-welcome/progress.md +++ b/docs/development/native-appearance-welcome/progress.md @@ -1,5 +1,16 @@ # Native appearance / welcome progress +## Slice 3 — Ghostty native shaders (Claude, 2026-10-28) + +Branch `feature/ghostty-native-shaders-20261028` from de2afd02 (head of draft PR #516, Slice 2) in worktree `/Volumes/T7Tmp/ai-dev/worktrees/notMyShell/ghostty-native-shaders-20261028`. Slice 1/2 worktrees untouched; #513/#514/#515/#516 unmerged and unchanged. + +- Implemented (DECISIONS, Slice 3): version table verified against Ghostty 1.2.0/1.3.0/1.3.1/1.4.0 shader prefixes; seven NMSh-original GLSL presets (Smear, Comet, Aurora, Dim when unfocused, Vignette, Scanlines, Text glow) with real compiled-in parameters; multi-pass composition after the person's passes through one optional include; exact effective-list simulation (roots, include queue, clears, `?`, quotes, relative paths); refusals; ownership ledger + Slice 1 snapshot transaction; exact removal; gate sync (Reduced Motion, Effects Off, Portable, motion) incl. startup; reload-pending state so Portable never draws a second trail; the older /cursor Ghostty shader pauses/resumes inside reviewed transactions; launch-option inspection; `/appearance › Ghostty shaders` studio sharing one panel with Kitty (`ShaderStudioPanel`). TerminalApp stays host-neutral (`glslStudio.ts`). +- Tests: tests/ghosttyShaders.test.ts, tests/ghosttyShaderPanel.test.ts, tests/ghosttyShadersLive.test.ts (binary uniform parity, `+validate-config` in a private HOME, slangc proxy compile; staged 1.4.0 via NMSH_TEST_GHOSTTY_BINARIES). See TEST_RESULTS. +- Independent Codex review (gpt-6.1-sol, read-only): no blocker; 5 should-fix + 2 minor, all fixed with regressions (INDEPENDENT_REVIEW.md, Slice 3). +- Mermaid CI failure on #516 (run 40656373289, browserConsoleChrome.test.ts:106): diagnosed separately, not reproduced locally (3/3 pass at dev+diag and at #516), consistent with a deadline under CI load; diagnostic commit 9270894 on local branch `diag/mermaid-ci-20261028` (not pushed). Nothing rerun or bundled here. +- Parallel (independent PR to dev, Slice 6 prerequisite): bounded animated GIF/APNG decoding by Codex in `feature/animated-image-decode-20261028` (commit 86aad528), lead rerun 103/103 under the guard; independent review pending at time of writing. +- NOT done: Slices 4–7 (render routing + Chroma-driven native colors, Shader Studio previews, Welcome Studio, integration/performance/QA). Physical Ghostty/Kitty/Zed QA NOT RUN; GPU rendering NOT verified (headless parse + proxy compile only). + ## 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. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 08cbe263..a9609511 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -325,6 +325,7 @@ 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 {GlslShaderStudio} from '../appearance/glslStudio.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'; @@ -637,8 +638,11 @@ export class TerminalApp { // session at once, and NMSh's managed native cursor files follow the cursor and theme settings. if (this.renderer) { this.renderer.setCursorStyle(cursorStyleSequence(next.cursor.shape, next.cursor.blink)); - if (JSON.stringify(previous.cursor) !== JSON.stringify(next.cursor) || previous.nmsh.palette !== next.nmsh.palette || previous.nmsh.accent !== next.nmsh.accent - || JSON.stringify(previous.customTheme) !== JSON.stringify(next.customTheme)) this.syncCursorNative(next); + const cursorLook = 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); + // The GLSL studio's passes first: a written studio trail pauses NMSh's older /cursor shader (never two trails). + if (cursorLook || previous.presentation.reducedMotion !== next.presentation.reducedMotion || previous.presentation.effectsOff !== next.presentation.effectsOff) this.glslStudio.sync(next); + if (cursorLook) 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); } @@ -1044,6 +1048,15 @@ export class TerminalApp { private appearanceHub?: AppearanceHubState; /** /appearance › Kitty shaders (Kitty 0.49+ pipelines); opened from the hub, Esc returns there. */ private kittyShaderPanel?: KittyShaderPanelState; + /** The GLSL shader studio (NMSh's own custom-shader passes); host detection and writes live in the appearance layer. */ + private readonly glslStudio = new GlslShaderStudio({ + configuration: () => this.promptConfiguration, + update: mutate => this.updateConfiguration(mutate), + invalidateCursor: () => { this.cursorHost = undefined; }, + notify: (message, error) => this.output.addFrontendInteraction('/appearance', message, error ? ERROR : INFO), + render: () => this.render(), + back: () => { void this.startAppearance(); }, + }); /** The installed Kitty catalog, read once per process through Kitty's own resource API. */ private kittyCatalogRead?: Promise; private keyboardState?: KeyboardState; @@ -1105,6 +1118,8 @@ export class TerminalApp { this.renderer.setCursorStyle(cursorStyleSequence(this.promptConfiguration.cursor.shape, this.promptConfiguration.cursor.blink)); // Settings and Setup rows read the host's cursor capabilities through the one provider this window owns. setCursorHostProvider(() => this.cursorFacts()); + // Gate changes made while NMSh was not running (OS Reduced Motion, an edited config) reach NMSh's written shader passes. + this.glslStudio.sync(this.promptConfiguration, {render: false}); const dimensions = this.dimensions(); this.session = connection?.client ?? new InProcessSessionClient({cwd: this.initialCwd, columns: dimensions.columns, rows: Math.max(2, dimensions.rows - 4)}); @@ -2482,6 +2497,11 @@ export class TerminalApp { this.render(); return; } + if (this.glslStudio.panel) { + this.glslStudio.key(key); + this.render(); + return; + } if (this.appearanceHub) { const action = appearanceHubKey(this.appearanceHub, key, this.promptConfiguration); if (action?.kind === 'close') { this.appearanceHub = undefined; this.returnFromPanel(); } @@ -2497,6 +2517,7 @@ export class TerminalApp { 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 === 'glslShaders') this.glslStudio.open(); else if (action.destination === 'chroma') this.startChromaSettings(); else this.focusConfigRow('uiChrome'); } @@ -3846,6 +3867,7 @@ export class TerminalApp { /** 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 (this.glslStudio.available()) { this.glslStudio.addHubRow(hub); return; } 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'; @@ -6483,7 +6505,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.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); + || this.resumeBrowser || this.appearanceHub || this.kittyShaderPanel || this.glslStudio.panel || 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. */ @@ -6677,6 +6699,8 @@ export class TerminalApp { return framePanel(rows, columns); } if (this.kittyShaderPanel) return framePanel(renderKittyShaderPanel(this.kittyShaderPanel, columns, this.dimensions().rows - 4), columns); + const glsl = this.glslStudio.render(columns, this.dimensions().rows - 4); + if (glsl) return framePanel(glsl, columns); if (this.appearanceHub) { const theme = themeLabel(this.promptConfiguration.nmsh.palette); const now = Date.now(); @@ -10431,7 +10455,7 @@ export class TerminalApp { } private cursorFacts(): HostCursorFacts { - this.cursorHost ??= hostCursorFacts(process.env, host => nativeCursorIntegrated(host), () => writtenKittyTrail({env: process.env, platform: process.platform})); + this.cursorHost ??= hostCursorFacts(process.env, host => nativeCursorIntegrated(host), () => writtenKittyTrail({env: process.env, platform: process.platform}), () => this.glslStudio.trailFacts()); return this.cursorHost; } @@ -10475,6 +10499,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 (this.glslStudio.ownsCursorSetup(facts)) { + // Trail styles set up through the GLSL studio live there; /cursor keeps whether there is a trail and its colors. + this.closeCursorPanel(); + this.appearanceHub = undefined; + this.glslStudio.open(); + 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(); @@ -10499,7 +10530,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), () => writtenKittyTrail({env: process.env, platform: process.platform})); + this.cursorHost = hostCursorFacts(process.env, value => nativeCursorIntegrated(value), () => writtenKittyTrail({env: process.env, platform: process.platform}), () => this.glslStudio.trailFacts()); void written; state.message = this.cursorHost.integrated ? (reloadInstruction(native.host, {changed: ['fragment']}) ?? `Set up. Renderer Auto now uses ${nativeHostLabel(native.host)} native.`) @@ -10522,8 +10553,10 @@ export class TerminalApp { : installedNativeHost(); if (!host) { this.cursorReload = undefined; return; } try { - const written = writeManagedFiles(host, resolveCursorSettings(next.cursor, contextFor(next))); + const written = this.glslStudio.writeCursorFiles(host, resolveCursorSettings(next.cursor, contextFor(next))); this.cursorReload = reloadInstruction(host, written); + // A native shader that stopped being written stays loaded until the host reloads: Portable waits rather than draw a second trail. + if (written.trailChanged) this.cursorHost = undefined; // 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.'; } diff --git a/src/appearance/AppearanceHub.ts b/src/appearance/AppearanceHub.ts index 8441a24d..3b39fcdd 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' | 'kittyShaders'; +export type HubDestination = 'theme' | 'prompt' | 'cursor' | 'chrome' | 'chroma' | 'themeBridge' | 'kittyShaders' | 'glslShaders'; export interface AppearanceHubState { view: 'hub' | 'motion' | 'motionAdvanced'; @@ -42,6 +42,8 @@ export interface AppearanceHubState { previewStart?: number; /** Present in Kitty: the Kitty shaders row's summary (the row exists only there). */ kittyShaders?: string; + /** Present in Ghostty: the Ghostty shaders row's summary (the row exists only there). */ + ghosttyShaders?: string; } export type HubAction = {kind: 'close'} | {kind: 'open'; destination: HubDestination} | {kind: 'motion'; motion: MotionSettings} | {kind: 'saveHost'} | {kind: 'confirmHost'} | {kind: 'restoreHost'}; @@ -51,9 +53,10 @@ 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)]; +/** The launcher rows for this host: Kitty or Ghostty adds its shader studio after Cursor & effects. */ +const hubRows = (state: AppearanceHubState) => state.kittyShaders !== undefined + ? [...BASE_ROWS.slice(0, 3), {id: 'kittyShaders' as const, label: 'Kitty shaders'}, ...BASE_ROWS.slice(3)] + : state.ghosttyShaders !== undefined ? [...BASE_ROWS.slice(0, 3), {id: 'glslShaders' as const, label: 'Ghostty shaders'}, ...BASE_ROWS.slice(3)] : BASE_ROWS; export function createAppearanceHub(hostName: string, host?: AppearanceState, hostGuidance?: string): AppearanceHubState { return {view: 'hub', selected: 0, ...(host ? {host} : {}), hostName, ...(hostGuidance ? {hostGuidance} : {}), hostDirty: false}; @@ -196,6 +199,7 @@ export function renderAppearanceHub(state: AppearanceHubState, configuration: Pr chrome: configuration.uiChrome.source === 'theme' ? 'Follow theme' : 'Custom', chroma: configuration.presentation.preset === 'off' ? 'Off' : `${configuration.presentation.preset} · ${configuration.presentation.motion}`, kittyShaders: state.kittyShaders ?? '', + glslShaders: state.ghosttyShaders ?? '', 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/GhosttyShaderPanel.ts b/src/appearance/GhosttyShaderPanel.ts new file mode 100644 index 00000000..5641c286 --- /dev/null +++ b/src/appearance/GhosttyShaderPanel.ts @@ -0,0 +1,42 @@ +/** + * /appearance › Ghostty shaders: NMSh's own GLSL passes for Ghostty 1.2+, + * offered by what this Ghostty's shader interface provides, tuned with real + * parameters and written only after an exact review. The panel is the shared + * shader studio (ShaderStudioPanel); this file holds Ghostty's wording. + */ +import type {Key} from '../terminal/keys.js'; +import type {GhosttyCatalog} from './ghosttyShaderCatalog.js'; +import {trailGateReason, type GhosttyShaderGates, type GhosttyShaderInspection, type GhosttyShaderPlan} from './ghosttyShaders.js'; +import type {GhosttyShaderSettings} from './kittyShaderSettings.js'; +import {createShaderStudio, renderShaderStudio, shaderStudioKey, type ShaderStudioState, type StudioAction, type StudioHost} from './ShaderStudioPanel.js'; + +export type GhosttyShaderPanelState = ShaderStudioState; + +export const GHOSTTY_STUDIO: StudioHost = { + name: 'Ghostty', + placement: false, + intro: catalog => `Ghostty ${catalog.version} · NMSh’s own GLSL passes · effects cover the whole Ghostty surface (vim, less…), not only NMSh`, + unsupportedHint: 'Cursor effects stay with /cursor (Portable).', + status: inspection => inspection.configured + ? `Set up · ${inspection.activePresets.length ? `${inspection.activePresets.length} NMSh pass${inspection.activePresets.length === 1 ? '' : 'es'} in Ghostty’s config` : 'no NMSh effect active'}` + : 'Not set up · nothing in Ghostty’s configuration is changed until you review and confirm', + userShaders: inspection => inspection.userEntries.length ? `Your shaders (kept, in order; NMSh’s run after them): ${inspection.userEntries.join(' · ')}` : 'You have no custom shaders of your own.', + gate: gates => gates.reducedMotion ? 'Reduced Motion is on: NMSh writes no Ghostty effects until it is off.' + : gates.effectsOff ? 'Decorative effects are Off: NMSh writes no Ghostty effects until they are on.' + : trailGateReason(gates), + placementNote: '', + noParams: 'This effect has no adjustable settings.', + reloadKey: true, +}; + +export function createGhosttyShaderPanel(saved: GhosttyShaderSettings, gates: GhosttyShaderGates): GhosttyShaderPanelState { + return createShaderStudio(saved, gates); +} + +export function ghosttyShaderKey(state: GhosttyShaderPanelState, key: Key): StudioAction | undefined { + return shaderStudioKey(state, key, GHOSTTY_STUDIO); +} + +export function renderGhosttyShaderPanel(state: GhosttyShaderPanelState, columns: number, height?: number): string[] { + return renderShaderStudio(state, GHOSTTY_STUDIO, columns, height); +} diff --git a/src/appearance/KittyShaderPanel.ts b/src/appearance/KittyShaderPanel.ts index 034bbac9..037c719e 100644 --- a/src/appearance/KittyShaderPanel.ts +++ b/src/appearance/KittyShaderPanel.ts @@ -1,250 +1,48 @@ /** * /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. + * review the exact host change before anything is written. The panel itself + * is the shared shader studio (ShaderStudioPanel); this file holds Kitty's + * wording and keeps the Kitty-named entry points. */ 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 {KittyCatalog, OfferedParam, OfferedPreset, 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']; +import type {KittyShaderSettings} from './kittyShaderSettings.js'; +import {createShaderStudio, renderShaderStudio, shaderStudioKey, studioParamValue, type ShaderStudioState, type StudioAction, type StudioHost} from './ShaderStudioPanel.js'; + +export type KittyShaderPanelState = ShaderStudioState; +export type KittyShaderAction = Exclude; + +export const KITTY_STUDIO: StudioHost = { + name: 'Kitty', + placement: true, + intro: catalog => `Kitty ${catalog.version} · shipped shaders read from Kitty itself · effects cover the whole Kitty window, not only NMSh`, + unsupportedHint: 'Cursor effects stay with /cursor (Portable, or Kitty’s built-in trail).', + status: inspection => inspection.drifted ? 'Your own custom_shaders changed since NMSh composed with it; S reviews an updated plan.' + : inspection.configured ? `Set up · ${inspection.activePresets.length ? `${inspection.activePresets.length} NMSh effect${inspection.activePresets.length === 1 ? '' : 's'} in Kitty’s config` : 'no NMSh effect active'}` + : 'Not set up · nothing in Kitty’s configuration is changed until you review and confirm', + userShaders: inspection => inspection.userEntries.length ? `Your shaders (kept, in order): ${inspection.userEntries.join(' · ')}` : 'You have no custom_shaders of your own.', + gate: gates => gates.reducedMotion ? 'Reduced Motion is on: NMSh keeps Kitty effects out of its pipeline until it is off.' + : gates.effectsOff ? 'Decorative effects are Off: NMSh keeps Kitty effects out of its pipeline until they are on.' + : gates.cursorRenderer === 'portable' ? 'Cursor renderer is Portable: NMSh draws the trail, so no Kitty trail is added.' : undefined, + placementNote: 'Kitty runs shaders in list order; your own stay in their order either way.', + noParams: 'Kitty exposes no adjustable settings for this shader.', +}; 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; + return createShaderStudio(saved, gates); } 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; + return studioParamValue(state, preset, param); } 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'; + const action = shaderStudioKey(state, key, KITTY_STUDIO); + return action?.kind === 'reloaded' ? undefined : action; } 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)); + return renderShaderStudio(state, KITTY_STUDIO, columns, height); } diff --git a/src/appearance/ShaderStudioPanel.ts b/src/appearance/ShaderStudioPanel.ts new file mode 100644 index 00000000..54032ca8 --- /dev/null +++ b/src/appearance/ShaderStudioPanel.ts @@ -0,0 +1,278 @@ +/** + * The one shader studio panel shared by every host (/appearance › Kitty + * shaders, /appearance › Ghostty shaders): choose effects, tune the settings + * each really exposes, order them, and review the exact host change before + * anything is written. Host differences (wording, Kitty's placement, Ghostty's + * reload confirmation) come from a small host description; host-specific + * shader compilation stays in each host's own module. The draft is the panel's + * own until a reviewed plan is confirmed; Esc discards it. + */ +import type {Key} from '../terminal/keys.js'; +import {renderControlRows, 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 OfferedParam, type PresetCategory, type ShaderValue} from './kittyShaderCatalog.js'; +import type {KittyShaderEffect} from './kittyShaderSettings.js'; + +export interface StudioPreset {definition: {id: string; label: string; category: PresetCategory; exclusive?: string; note?: string}; params: OfferedParam[]; missing: string[]} +export interface StudioCatalog {version: string; supported: boolean; reason?: string; offered: readonly StudioPreset[]; notOffered: readonly string[]} +export interface StudioInspection {userEntries: string[]; configured: boolean; drifted: boolean; activePresets: string[]; problems: string[]} +export interface StudioPlan {preview: string[]; guidance: string} +export interface StudioDraft {effects: KittyShaderEffect[]; placement?: 'after' | 'before'} + +export interface ShaderStudioState { + catalog?: C; + inspection?: I; + draft: D; + /** The draft differs from the saved (last reviewed) settings. */ + dirty: boolean; + selected: number; + gates: G; + confirmation?: {plan: P; offset: number}; + colorInput?: {preset: string; param: string; text: string}; + message?: string; + /** Ghostty: a trail Ghostty may still draw until its configuration is reloaded (L confirms the reload). */ + reloadPending?: string; +} + +export interface StudioHost { + /** "Kitty", "Ghostty". */ + name: string; + /** Kitty can place NMSh's pipeline before or after the person's; Ghostty always runs NMSh's passes after. */ + placement: boolean; + intro(catalog: StudioCatalog): string; + unsupportedHint: string; + status(inspection: StudioInspection): string; + userShaders(inspection: StudioInspection): string; + gate(gates: G): string | undefined; + placementNote: string; + noParams: string; + /** Ghostty: L confirms a config reload. */ + reloadKey?: boolean; +} + +export type StudioAction = {kind: 'back'} | {kind: 'review'} | {kind: 'remove'} | {kind: 'confirm'} | {kind: 'reloaded'}; + +type Row = {kind: 'placement'} | {kind: 'preset'; preset: StudioPreset} | {kind: 'param'; preset: StudioPreset; param: OfferedParam}; +type AnyState = ShaderStudioState; +const CATEGORY_ORDER: readonly PresetCategory[] = ['cursor', 'ambient', 'interaction', 'focus', 'display']; + +export function createShaderStudio(saved: D, gates: G): ShaderStudioState { + return {draft: structuredClone(saved), dirty: false, selected: 0, gates}; +} + +const effectOf = (state: AnyState, id: string) => state.draft.effects.find(effect => effect.id === id); +const enabled = (state: AnyState, id: string) => effectOf(state, id)?.enabled === true; + +/** Selectable rows: Placement (Kitty), then presets by category, each enabled preset followed by its parameters. */ +function rows(state: AnyState, host: StudioHost): Row[] { + const offered = state.catalog?.offered ?? []; + const list: Row[] = host.placement ? [{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 studioParamValue(state: AnyState, preset: StudioPreset, param: OfferedParam): ShaderValue { + return effectOf(state, preset.definition.id)?.values[param.name] ?? param.default; +} + +export const colorHex = (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('')}` : ''; +export const steppedValue = (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)))); +}; + +export function displayParam(param: OfferedParam, value: ShaderValue): string { + if (param.kind === 'bool') return value ? 'On' : 'Off'; + if (param.kind === 'color') return colorHex(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: AnyState, 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: AnyState, preset: StudioPreset, param: OfferedParam, value: ShaderValue): void { + ensure(state, preset.definition.id).values[param.name] = value; + state.dirty = true; +} + +function toggle(state: AnyState, preset: StudioPreset): 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 pass order. */ +function move(state: AnyState, 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 shaderStudioKey(state: ShaderStudioState, key: Key, host: StudioHost): StudioAction | undefined { + const any = state as AnyState; + 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 = studioParamValue(any, preset, param); + setValue(any, 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 host too. + if (key.kind === 'text' && key.value.toLowerCase() === 'r' && state.catalog) return {kind: 'remove'}; + if (host.reloadKey && key.kind === 'text' && key.value.toLowerCase() === 'l' && state.reloadPending) return {kind: 'reloaded'}; + if (!state.catalog?.supported) return undefined; + const list = rows(any, host as StudioHost); + if (!list.length) return undefined; + 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(any, row.preset); + else if (key.kind === 'selectUp' || key.kind === 'selectDown') move(any, row.preset.definition.id, key.kind === 'selectUp' ? -1 : 1); + return undefined; + } + const {preset, param} = row; + const value = studioParamValue(any, 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(any, preset, param, !value); + else if (param.kind === 'choice') { + const choices = param.choices ?? []; + const index = choices.findIndex(([choice]) => choice === value); + setValue(any, preset, param, choices[(index + direction + choices.length) % choices.length]![0]); + } else if (typeof value === 'number' && key.kind !== 'enter') setValue(any, preset, param, steppedValue(value, param, direction)); + return undefined; +} + +export function renderShaderStudio(state: ShaderStudioState, host: StudioHost, columns: number, height?: number): string[] { + const any = state as AnyState; + 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 ${host.name} 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 › ${host.name} shaders${reset}`]; + const reload = state.reloadPending ? [` ${secondary}${host.name} may still draw NMSh’s previous trail until you reload its configuration; Portable motion waits. Press L once you have reloaded.${reset}`] : []; + const catalog = state.catalog; + if (!catalog) return [...head, '', ` ${subtle}Reading ${host.name}’s shader support…${reset}`, '', renderControls([['Esc', 'back']])].map(row => truncateAnsi(row, columns)); + if (!catalog.supported) { + return [...head, '', ` ${secondary}${clean(catalog.reason ?? `${host.name} custom shaders are not available.`)}${reset}`, + ` ${subtle}${host.unsupportedHint}${reset}`, ...reload, ...(state.inspection?.configured ? [` ${subtle}R reviews removal of NMSh’s existing ${host.name} shader files.${reset}`] : []), + '', renderControls([['Esc', 'back'], ...(state.inspection?.configured ? [['R', 'remove'] as [string, string]] : []), ...(reload.length ? [['L', 'reloaded'] as [string, string]] : [])])].map(row => truncateAnsi(row, columns)); + } + const inspection = state.inspection; + const gate = host.gate(state.gates); + const intro = [` ${subtle}${clean(host.intro(catalog))}${reset}`, + ` ${subtle}${inspection ? clean(host.status(inspection)) : `Reading ${host.name}’s configuration…`}${reset}`, + ` ${subtle}${inspection ? clean(host.userShaders(inspection)) : ''}${reset}`, + ...(gate ? [` ${secondary}${gate}${reset}`] : []), ...reload]; + const list = rows(any, host as StudioHost); + const selected = Math.min(state.selected, Math.max(0, 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(...(host.placement || body.length ? [] : ['']), ` ${subtle}${CATEGORY_LABELS[category]}${category === 'cursor' ? ' (one)' : ''}${reset}`); + } + const on = enabled(any, 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 = displayParam(row.param, studioParamValue(any, 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' ? host.placementNote + : preset ? [preset.definition.note, !preset.params.length && !preset.definition.note ? host.noParams : 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}${host.placement ? 'Installed, not offered yet' : 'Not available in this version'}: ${clean(catalog.notOffered.join(', '))}${reset}`] : []), + ...(inspection?.problems.length ? [` ${secondary}${clean(inspection.problems[0]!)}${reset}`] : []), + ...(state.message ? [` ${secondary}${clean(state.message)}${reset}`] : []), + // Narrow terminals wrap the controls at control boundaries rather than cutting off Esc. + '', ...renderControlRows([['↑↓', 'select'], ['Enter', current?.kind === 'preset' ? 'on/off' : 'change'], ['←→', 'adjust'], ['⇧↑↓', 'order'], ['S', 'review'], ['R', 'remove'], + ...(reload.length ? [['L', 'reloaded'] as [string, string]] : []), ['Esc', 'back']], Math.max(20, columns - 2)), + ]; + const all = [...head, ...intro, ...body, ...footer]; + if (height === undefined || all.length <= height) return all.map(row => truncateAnsi(row, columns)); + // Short windows keep the title, a pending-reload line (Portable motion waits on it), the selected row's neighbourhood and the footer. + const compact = reload.length ? [` ${secondary}Reload pending: press L after reloading ${host.name}.${reset}`] : []; + const room = Math.max(1, height - head.length - compact.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, ...compact, ...body.slice(start, start + room), ...footer].slice(0, Math.max(1, height)).map(row => truncateAnsi(row, columns)); +} diff --git a/src/appearance/ghosttyShaderCatalog.ts b/src/appearance/ghosttyShaderCatalog.ts new file mode 100644 index 00000000..bf95bf5f --- /dev/null +++ b/src/appearance/ghosttyShaderCatalog.ts @@ -0,0 +1,363 @@ +/** + * Ghostty custom-shader catalog: the verified GLSL interface per Ghostty + * version and NMSh's own curated shader presets. + * + * Ghostty runs each `custom-shader` as one pass of a Shadertoy-style GLSL + * fragment program (`mainImage`), with the previous pass's output in + * iChannel0. NMSh writes ORIGINAL shaders (GPL-3.0-only, no third-party code) + * generated from validated settings; every parameter offered here is compiled + * into the generated source as a constant and actually changes what is drawn. + * + * Version facts (2026-10-28; evidence in docs/development/native-appearance-welcome/RESEARCH.md): + * the shader prefix of v1.3.0, v1.3.1 and v1.4.0 is byte-identical (source at + * those tags and the installed 1.3.1 / staged 1.4.0 binaries); v1.2.0 lacks the + * focus, cursor-style/visibility and terminal-color uniforms. Older versions + * were not verified and are not offered. + */ +import {createHash} from 'node:crypto'; +import type {OfferedParam, PresetCategory, PresetParam, ShaderValue} from './kittyShaderCatalog.js'; + +export type GhosttyVersion = readonly [number, number, number]; + +export function parseGhosttyVersion(text: string | undefined): GhosttyVersion | undefined { + const match = /^(\d{1,4})\.(\d{1,4})\.(\d{1,4})/u.exec(text ?? ''); + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined; +} + +const compare = (a: GhosttyVersion, b: GhosttyVersion) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; + +/** The interface levels NMSh has verified, oldest first. */ +export type UniformLevel = '1.2' | '1.3'; +const LEVEL_FLOOR: Record = {'1.2': [1, 2, 0], '1.3': [1, 3, 0]}; +/** The newest version whose shader interface NMSh compared byte-for-byte. */ +export const GHOSTTY_VERIFIED_THROUGH: GhosttyVersion = [1, 4, 0]; + +/** Ghostty's shader uniforms (name, GLSL type, first verified level). Mirrors src/renderer/shaders/shadertoy_prefix.glsl. */ +export const GHOSTTY_UNIFORMS: ReadonlyArray<{name: string; type: string; array?: number; since: UniformLevel}> = [ + {name: 'iResolution', type: 'vec3', since: '1.2'}, {name: 'iTime', type: 'float', since: '1.2'}, {name: 'iTimeDelta', type: 'float', since: '1.2'}, + {name: 'iFrameRate', type: 'float', since: '1.2'}, {name: 'iFrame', type: 'int', since: '1.2'}, {name: 'iChannelTime', type: 'float', array: 4, since: '1.2'}, + {name: 'iChannelResolution', type: 'vec3', array: 4, since: '1.2'}, {name: 'iMouse', type: 'vec4', since: '1.2'}, {name: 'iDate', type: 'vec4', since: '1.2'}, + {name: 'iSampleRate', type: 'float', since: '1.2'}, {name: 'iCurrentCursor', type: 'vec4', since: '1.2'}, {name: 'iPreviousCursor', type: 'vec4', since: '1.2'}, + {name: 'iCurrentCursorColor', type: 'vec4', since: '1.2'}, {name: 'iPreviousCursorColor', type: 'vec4', since: '1.2'}, + {name: 'iCurrentCursorStyle', type: 'int', since: '1.3'}, {name: 'iPreviousCursorStyle', type: 'int', since: '1.3'}, {name: 'iCursorVisible', type: 'int', since: '1.3'}, + {name: 'iTimeCursorChange', type: 'float', since: '1.2'}, {name: 'iTimeFocus', type: 'float', since: '1.3'}, {name: 'iFocus', type: 'int', since: '1.3'}, + {name: 'iPalette', type: 'vec3', array: 256, since: '1.3'}, {name: 'iBackgroundColor', type: 'vec3', since: '1.3'}, {name: 'iForegroundColor', type: 'vec3', since: '1.3'}, + {name: 'iCursorColor', type: 'vec3', since: '1.3'}, {name: 'iCursorText', type: 'vec3', since: '1.3'}, + {name: 'iSelectionForegroundColor', type: 'vec3', since: '1.3'}, {name: 'iSelectionBackgroundColor', type: 'vec3', since: '1.3'}, +]; +/** Declared by Ghostty but documented as not provided (always zero); NMSh never relies on them. */ +const UNPROVIDED = new Set(['iMouse', 'iChannelTime', 'iChannelResolution', 'iSampleRate', 'iDate']); + +export interface GhosttyCapability { + supported: boolean; + version: string; + level?: UniformLevel; + reason?: string; + /** No version was reported (tmux, ssh): unknown, not known to be unsupported. */ + hidden?: boolean; +} + +/** What this Ghostty's shader interface is, from its reported version; unknown or unverified versions fail closed. */ +export function ghosttyCapability(versionText: string | undefined): GhosttyCapability { + const version = parseGhosttyVersion(versionText); + const text = versionText ?? ''; + if (!version) return {supported: false, hidden: true, version: text, reason: 'Ghostty did not report its version (TERM_PROGRAM_VERSION), so NMSh cannot tell which shader features it has. Open /appearance directly in a Ghostty window (not inside tmux or ssh).'}; + if (compare(version, LEVEL_FLOOR['1.2']) < 0) return {supported: false, version: text, reason: `Ghostty ${text} predates the cursor shader interface NMSh verified (1.2.0 and later).`}; + // Unverified versions fail closed (recorded ruling): a newer minor or major waits until its shader interface is checked. + if (version[0] > GHOSTTY_VERIFIED_THROUGH[0] || (version[0] === GHOSTTY_VERIFIED_THROUGH[0] && version[1] > GHOSTTY_VERIFIED_THROUGH[1])) { + return {supported: false, version: text, reason: `Ghostty ${text} is newer than NMSh has verified (through ${GHOSTTY_VERIFIED_THROUGH.slice(0, 2).join('.')}.x); NMSh's shaders stay off until its shader interface is checked.`}; + } + const level: UniformLevel = compare(version, LEVEL_FLOOR['1.3']) >= 0 ? '1.3' : '1.2'; + return {supported: true, version: text, level}; +} + +const levelAllows = (have: UniformLevel, need: UniformLevel) => have === '1.3' || need === '1.2'; + +/** The Ghostty uniforms a GLSL source reads (identifiers only; comments removed first). */ +export function uniformsUsed(source: string): string[] { + const code = source.replace(/\/\*[\s\S]*?\*\//gu, '').replace(/\/\/[^\n]*/gu, ''); + const names = new Set(GHOSTTY_UNIFORMS.map(item => item.name)); + return [...new Set([...code.matchAll(/\bi[A-Z][A-Za-z]*\b/gu)].map(match => match[0]).filter(name => names.has(name)))]; +} + +/** The oldest interface level that provides every uniform a source reads, or a reason it cannot run on any. */ +export function requiredLevel(source: string): {level: UniformLevel} | {error: string} { + const used = uniformsUsed(source); + const unprovided = used.filter(name => UNPROVIDED.has(name)); + if (unprovided.length) return {error: `uses ${unprovided.join(', ')}, which Ghostty does not provide`}; + return {level: used.some(name => GHOSTTY_UNIFORMS.find(item => item.name === name)?.since === '1.3') ? '1.3' : '1.2'}; +} + +/** A GLSL prefix equivalent to Ghostty's for one interface level (used only for offline compile checks in tests). */ +export function ghosttyPrefix(level: UniformLevel): string { + const fields = GHOSTTY_UNIFORMS.filter(item => levelAllows(level, item.since)).map(item => ` uniform ${item.type} ${item.name}${item.array ? `[${item.array}]` : ''};`); + return ['#version 430 core', 'layout(binding = 1, std140) uniform Globals {', ...fields, '};', + ...(level === '1.3' ? ['#define CURSORSTYLE_BLOCK 0', '#define CURSORSTYLE_BLOCK_HOLLOW 1', '#define CURSORSTYLE_BAR 2', '#define CURSORSTYLE_UNDERLINE 3', '#define CURSORSTYLE_LOCK 4'] : []), + 'layout(binding = 0) uniform sampler2D iChannel0;', 'layout(location = 0) in vec4 gl_FragCoord;', 'layout(location = 0) out vec4 _fragColor;', '#define texture2D texture', + 'void mainImage( out vec4 fragColor, in vec2 fragCoord );', 'void main() { mainImage (_fragColor, gl_FragCoord.xy); }', ''].join('\n'); +} + +// ---- Presets ----------------------------------------------------------------------------------------------------- + +/** Where a trail takes its colors: /cursor's palette (one cursor color everywhere), the studio's own colors, or Ghostty's live cursor color. */ +export const COLOR_SOURCES = [[0, 'From /cursor'], [1, 'Custom'], [2, 'Ghostty cursor color']] as const; + +export interface GhosttyPresetDefinition { + id: string; label: string; category: PresetCategory; + params: readonly PresetParam[]; + defaults: Readonly>; + /** Presets sharing a key cannot be enabled together (one cursor trail). */ + exclusive?: string; + /** A cursor trail: Ghostty then draws the trail and Portable must not (never two trails). */ + trail?: true; + /** Reads iTime continuously, so it redraws every frame while Ghostty's custom-shader-animation runs. */ + animated: boolean; + note?: string; +} + +export interface GenerationContext { + /** /cursor's effect colors (0–1 RGB): the trail head and tail. */ + cursorColors: {head: readonly number[]; tail: readonly number[]}; +} + +const n = (name: string, label: string, min: number, max: number, step: number, type = 'float'): PresetParam => ({name, label, kind: 'number', type, min, max, step}); +const rgb = (name: string, label: string): PresetParam => ({name, label, kind: 'color', type: 'float3'}); +const flag = (name: string, label: string): PresetParam => ({name, label, kind: 'bool', type: 'bool'}); +const colorSource: PresetParam = {name: 'COLOR_SOURCE', label: 'Colors', kind: 'choice', type: 'int', choices: COLOR_SOURCES}; + +export const GHOSTTY_PRESETS: readonly GhosttyPresetDefinition[] = [ + {id: 'smear', label: 'Smear trail', category: 'cursor', exclusive: 'cursor', trail: true, animated: false, + params: [colorSource, rgb('HEAD', 'Head color'), rgb('TAIL', 'Tail color'), n('INTENSITY', 'Intensity', 0.1, 1, 0.05), n('DURATION', 'Duration (s)', 0.05, 1.5, 0.05), n('WIDTH', 'Width (× cell)', 0.2, 1.5, 0.05)], + defaults: {COLOR_SOURCE: 0, HEAD: [0.55, 0.75, 1], TAIL: [0.35, 0.3, 0.9], INTENSITY: 0.75, DURATION: 0.25, WIDTH: 1}, + note: 'A gradient smear from where the cursor was to where it is; it fades over the duration.'}, + {id: 'comet', label: 'Comet trail', category: 'cursor', exclusive: 'cursor', trail: true, animated: false, + params: [colorSource, rgb('HEAD', 'Head color'), rgb('TAIL', 'Tail color'), n('INTENSITY', 'Intensity', 0.1, 1, 0.05), n('DURATION', 'Duration (s)', 0.1, 2, 0.05), + n('GLOW', 'Glow radius (× cell)', 0, 3, 0.1), flag('SPARKS', 'Sparks')], + defaults: {COLOR_SOURCE: 0, HEAD: [1, 0.85, 0.45], TAIL: [0.95, 0.3, 0.2], INTENSITY: 0.85, DURATION: 0.45, GLOW: 1.2, SPARKS: true}, + note: 'A tapered comet tail with a soft glow and optional sparks along the path.'}, + {id: 'aurora', label: 'Aurora', category: 'ambient', animated: true, + params: [rgb('COLOR_A', 'Color A'), rgb('COLOR_B', 'Color B'), n('INTENSITY', 'Intensity', 0.05, 0.8, 0.05), n('SPEED', 'Speed', 0.05, 2, 0.05), n('HEIGHT', 'Height', 0.1, 1, 0.05)], + defaults: {COLOR_A: [0.2, 0.9, 0.6], COLOR_B: [0.45, 0.35, 1], INTENSITY: 0.3, SPEED: 0.4, HEIGHT: 0.45}, + note: 'Slow color bands over the background only (text keeps its colors). Animates every frame while Ghostty is focused.'}, + {id: 'focus-dim', label: 'Dim when unfocused', category: 'focus', animated: false, + params: [n('DIM', 'Dim', 0, 0.9, 0.05), n('DESATURATE', 'Desaturate', 0, 1, 0.05), n('FADE', 'Fade back (s)', 0, 1, 0.05)], + defaults: {DIM: 0.35, DESATURATE: 0.4, FADE: 0.2}, + note: 'Unfocused Ghostty windows and splits fade toward the background; focus brings them back.'}, + {id: 'vignette', label: 'Vignette', category: 'display', animated: false, + params: [n('INTENSITY', 'Intensity', 0, 1, 0.05), n('RADIUS', 'Radius', 0.3, 1.5, 0.05), n('SOFTNESS', 'Softness', 0.05, 1, 0.05), rgb('COLOR', 'Edge color')], + defaults: {INTENSITY: 0.35, RADIUS: 0.85, SOFTNESS: 0.45, COLOR: [0, 0, 0]}}, + {id: 'scanlines', label: 'Scanlines', category: 'display', animated: false, + params: [n('INTENSITY', 'Intensity', 0, 0.6, 0.02), n('SPACING', 'Spacing (px)', 2, 8, 1, 'int')], + defaults: {INTENSITY: 0.15, SPACING: 3}}, + {id: 'glow', label: 'Text glow', category: 'display', animated: false, + params: [n('INTENSITY', 'Intensity', 0, 1.5, 0.05), n('RADIUS', 'Radius (px)', 1, 6, 0.5)], + defaults: {INTENSITY: 0.45, RADIUS: 2}, + note: 'A soft bloom around bright text. Samples 16 neighbours per pixel: the most expensive NMSh effect.'}, +]; + +export const GHOSTTY_MAX_EFFECTS = 6; + +// ---- Values and source generation --------------------------------------------------------------------------------- + +const float = (value: number) => { const text = Number(value.toFixed(6)).toString(); return /[.eE]/u.test(text) ? text : `${text}.0`; }; +const vec3 = (value: readonly number[]) => `vec3(${value.slice(0, 3).map(float).join(', ')})`; + +/** Why a value is not acceptable for a parameter (undefined when it is). Values are validated, never clamped. */ +export function paramError(param: PresetParam, value: ShaderValue): string | undefined { + if (param.kind === 'bool') return typeof value === 'boolean' ? undefined : 'expects on or off'; + if (param.kind === 'color') return Array.isArray(value) && value.length === 3 && value.every(part => Number.isFinite(part) && part >= 0 && part <= 1) ? undefined : 'expects 3 components between 0 and 1'; + if (typeof value !== 'number' || !Number.isFinite(value)) return 'expects a number'; + if (param.type === 'int' && !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; +} + +export const presetById = (id: string) => GHOSTTY_PRESETS.find(preset => preset.id === id); + +/** Every value a preset will compile with: defaults overlaid by the saved (validated) values. */ +export function resolvedValues(preset: GhosttyPresetDefinition, values: Readonly>): Record { + const result: Record = {...preset.defaults}; + for (const param of preset.params) if (values[param.name] !== undefined) result[param.name] = values[param.name]!; + return result; +} + +const constant = (param: PresetParam, value: ShaderValue): string => { + if (param.kind === 'bool') return `const bool ${param.name} = ${value ? 'true' : 'false'};`; + if (param.kind === 'color') return `const vec3 ${param.name} = ${vec3(value as number[])};`; + if (param.type === 'int') return `const int ${param.name} = ${Math.round(value as number)};`; + return `const float ${param.name} = ${float(value as number)};`; +}; + +const TRAIL_HELPERS = `vec2 nmshCenter(vec4 rect) { return rect.xy + vec2(rect.z * 0.5, -rect.w * 0.5); } +float nmshHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } +float nmshSegment(vec2 p, vec2 a, vec2 b, out float along) { + vec2 pa = p - a, ba = b - a; + along = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-4), 0.0, 1.0); + return length(pa - ba * along); +}`; + +/** The trail colors as GLSL: /cursor's and custom colors are constants; Ghostty's cursor color is read live. */ +function trailColors(values: Record, context: GenerationContext): string { + const source = values.COLOR_SOURCE; + if (source === 2) return 'vec3 headColor() { return iCurrentCursorColor.rgb; }\nvec3 tailColor() { return iCurrentCursorColor.rgb * 0.6; }'; + const head = source === 0 ? context.cursorColors.head : values.HEAD as number[]; + const tail = source === 0 ? context.cursorColors.tail : values.TAIL as number[]; + return `vec3 headColor() { return ${vec3(head)}; }\nvec3 tailColor() { return ${vec3(tail)}; }`; +} + +const BODIES: Record, context: GenerationContext) => string> = { + smear: (values, context) => `${TRAIL_HELPERS} +${trailColors(values, context)} +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec4 base = texture(iChannel0, fragCoord / iResolution.xy); + vec2 head = nmshCenter(iCurrentCursor); + vec2 from = nmshCenter(iPreviousCursor); + float progress = clamp((iTime - iTimeCursorChange) / DURATION, 0.0, 1.0); + vec2 travel = head - from; + if (progress >= 1.0 || dot(travel, travel) < 4.0) { fragColor = base; return; } + vec2 tail = mix(from, head, smoothstep(0.0, 1.0, progress)); + float along; + float d = nmshSegment(fragCoord, tail, head, along); + float halfWidth = max(iCurrentCursor.z, 2.0) * 0.5 * WIDTH * mix(0.4, 1.0, along); + float band = 1.0 - smoothstep(halfWidth - 1.0, halfWidth + 1.0, d); + float alpha = band * (1.0 - progress) * INTENSITY; + fragColor = vec4(mix(base.rgb, mix(tailColor(), headColor(), along), alpha), base.a); +} +`, + comet: (values, context) => `${TRAIL_HELPERS} +${trailColors(values, context)} +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec4 base = texture(iChannel0, fragCoord / iResolution.xy); + vec2 head = nmshCenter(iCurrentCursor); + vec2 from = nmshCenter(iPreviousCursor); + float age = iTime - iTimeCursorChange; + float fade = 1.0 - clamp(age / DURATION, 0.0, 1.0); + vec2 travel = head - from; + if (fade <= 0.0 || dot(travel, travel) < 4.0) { fragColor = base; return; } + float along; + float d = nmshSegment(fragCoord, from, head, along); + float cell = max(iCurrentCursor.z, 2.0); + float width = cell * 0.5 * pow(along, 1.5); + float core = 1.0 - smoothstep(width, width + 1.5, d); + float glow = GLOW > 0.0 ? exp(-d / (cell * GLOW)) * along * 0.6 : 0.0; + vec3 color = mix(tailColor(), headColor(), along); + float alpha = clamp(core + glow, 0.0, 1.0) * fade * INTENSITY; + if (SPARKS) { + vec2 grid = floor(fragCoord / 3.0); + float spark = step(0.992, nmshHash(grid + floor(iTime * 24.0))) * (1.0 - smoothstep(cell, cell * 3.0, d)); + alpha = max(alpha, spark * fade * INTENSITY); + color = mix(color, headColor(), spark); + } + fragColor = vec4(mix(base.rgb, color, alpha), base.a); +} +`, + aurora: () => `void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec4 base = texture(iChannel0, fragCoord / iResolution.xy); + vec2 uv = fragCoord / iResolution.xy; + float background = 1.0 - smoothstep(0.03, 0.12, distance(base.rgb, iBackgroundColor)); + float t = iTime * SPEED; + float wave = sin(uv.x * 5.0 + t) * 0.6 + sin(uv.x * 11.0 - t * 1.4) * 0.3 + sin(uv.x * 23.0 + t * 0.6) * 0.1; + float band = smoothstep(1.0 - HEIGHT, 1.0, uv.y + wave * 0.06); + vec3 tint = mix(COLOR_A, COLOR_B, 0.5 + 0.5 * sin(uv.x * 3.0 + t * 0.7)); + fragColor = vec4(mix(base.rgb, tint, band * background * INTENSITY), base.a); +} +`, + 'focus-dim': () => `void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec4 base = texture(iChannel0, fragCoord / iResolution.xy); + float back = iFocus > 0 ? clamp((iTime - iTimeFocus) / max(FADE, 0.001), 0.0, 1.0) : 0.0; + float amount = 1.0 - back; + vec3 gray = vec3(dot(base.rgb, vec3(0.2126, 0.7152, 0.0722))); + vec3 color = mix(base.rgb, gray, DESATURATE * amount); + fragColor = vec4(mix(color, iBackgroundColor, DIM * amount), base.a); +} +`, + vignette: () => `void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec4 base = texture(iChannel0, fragCoord / iResolution.xy); + vec2 centered = fragCoord / iResolution.xy - 0.5; + centered.x *= iResolution.x / max(iResolution.y, 1.0); + float edge = smoothstep(RADIUS * 0.5, RADIUS * 0.5 + SOFTNESS, length(centered)); + fragColor = vec4(mix(base.rgb, COLOR, edge * INTENSITY), base.a); +} +`, + scanlines: () => `void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec4 base = texture(iChannel0, fragCoord / iResolution.xy); + float line = mod(floor(fragCoord.y), float(SPACING)) < 1.0 ? 1.0 : 0.0; + fragColor = vec4(base.rgb * (1.0 - INTENSITY * line), base.a); +} +`, + glow: () => `void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 pixel = 1.0 / iResolution.xy; + vec4 base = texture(iChannel0, fragCoord * pixel); + vec3 bloom = vec3(0.0); + for (int ring = 1; ring <= 2; ring++) { + for (int i = 0; i < 8; i++) { + float angle = float(i) * 0.785398; + vec2 offset = vec2(cos(angle), sin(angle)) * RADIUS * float(ring) * 0.5; + vec3 sample1 = texture(iChannel0, (fragCoord + offset) * pixel).rgb; + bloom += max(sample1 - vec3(0.45), vec3(0.0)); + } + } + fragColor = vec4(base.rgb + bloom / 16.0 * INTENSITY, base.a); +} +`, +}; + +export interface GeneratedShader {id: string; source: string; sha256: string; level: UniformLevel} + +/** The exact GLSL NMSh writes for one preset; throws on invalid values instead of clamping. */ +export function generateGhosttyShader(preset: GhosttyPresetDefinition, saved: Readonly>, context: GenerationContext): GeneratedShader { + const values = resolvedValues(preset, saved); + for (const param of preset.params) { + const problem = paramError(param, values[param.name]!); + if (problem) throw new Error(`${preset.label}: ${param.label} ${problem}.`); + } + for (const color of [context.cursorColors.head, context.cursorColors.tail]) { + if (color.length < 3 || !color.slice(0, 3).every(part => Number.isFinite(part) && part >= 0 && part <= 1)) throw new Error('The /cursor colors are not valid RGB values.'); + } + const body = BODIES[preset.id]; + if (!body) throw new Error(`${preset.label} has no shader generator.`); + const code = `${preset.params.map(param => constant(param, values[param.name]!)).join('\n')}\n${body(values, context)}`; + const level = requiredLevel(code); + if ('error' in level) throw new Error(`${preset.label} ${level.error}.`); + const source = `// NMSh ${preset.label} for Ghostty. Generated by NMSh (/appearance › Ghostty shaders) from validated settings. +// Original NMSh code, GPL-3.0-only. Edits are replaced; remove with R in that studio. +// Needs the Ghostty ${level.level} shader interface (uses ${uniformsUsed(code).join(', ')}). +${code}`; + return {id: preset.id, source, sha256: createHash('sha256').update(source).digest('hex'), level: level.level}; +} + +export interface GhosttyOfferedPreset { + definition: GhosttyPresetDefinition; + params: OfferedParam[]; + /** Always empty: NMSh writes these shaders itself, so every curated parameter exists. Kept for the shared studio panel. */ + missing: string[]; + level: UniformLevel; +} + +export interface GhosttyCatalog { + version: string; + supported: boolean; + reason?: string; + capability: GhosttyCapability; + offered: GhosttyOfferedPreset[]; + /** Presets this Ghostty's shader interface cannot run, with the version they need. */ + notOffered: string[]; +} + +const SAMPLE_CONTEXT: GenerationContext = {cursorColors: {head: [1, 1, 1], tail: [0.5, 0.5, 0.5]}}; + +/** The presets this Ghostty can run, decided from the uniforms each generated shader actually reads. */ +export function buildGhosttyCatalog(versionText: string | undefined): GhosttyCatalog { + const capability = ghosttyCapability(versionText); + const offered: GhosttyOfferedPreset[] = []; + const notOffered: string[] = []; + for (const definition of GHOSTTY_PRESETS) { + // The default source and the live-cursor-color variant together cover every uniform the preset can read. + const variants = [definition.defaults, ...(definition.params.some(param => param.name === 'COLOR_SOURCE') ? [{...definition.defaults, COLOR_SOURCE: 2}] : [])]; + const levels = variants.map(values => generateGhosttyShader(definition, values, SAMPLE_CONTEXT).level); + const level: UniformLevel = levels.includes('1.3') ? '1.3' : '1.2'; + if (!capability.supported || !capability.level || !levelAllows(capability.level, level)) { notOffered.push(`${definition.label} (Ghostty ${level}+)`); continue; } + offered.push({definition, level, missing: [], params: definition.params.map(param => ({...param, default: definition.defaults[param.name]!}))}); + } + return {version: capability.version, supported: capability.supported, ...(capability.reason ? {reason: capability.reason} : {}), capability, offered, notOffered}; +} diff --git a/src/appearance/ghosttyShaders.ts b/src/appearance/ghosttyShaders.ts new file mode 100644 index 00000000..655648fa --- /dev/null +++ b/src/appearance/ghosttyShaders.ts @@ -0,0 +1,550 @@ +/** + * NMSh-managed Ghostty custom shaders: inspection of the person's own shader + * passes, composition, reviewed and owned writes, gate sync, removal and the + * reload state that keeps native and Portable trails from overlapping. + * + * Ghostty's `custom-shader` is a REPEATABLE list (each line appends a pass; an + * empty value clears the list; `?` marks an optional file; a relative path is + * relative to the file that names it). Ghostty loads every default root file + * before one appended queue of `config-file` includes, so NMSh's passes — in + * NMSh's own included fragment — run after every pass listed in the person's + * root files. NMSh never rewrites, reorders or removes the person's lines: it + * simulates the whole configuration graph with its fragment and refuses when + * that would not keep their passes, in their order, with NMSh's added. + * + * Files: one recorded optional include hook in the effective host file, NMSh's + * fragment (`custom-shader = …` per pass), one generated `.glsl` per pass, and + * an ownership ledger. Writes go through hostFiles' snapshot-verified + * transaction (the reviewed Slice 1 primitive). + */ +import {execFileSync} from 'node:child_process'; +import {basename, dirname, isAbsolute, join, resolve} from 'node:path'; +import {appearancePaths, type AppearanceContext} from './hostPaths.js'; +import {readHostConfiguration, type ConfigurationRead} from './hostConfiguration.js'; +import {digest, snapshot, transact, type FileChange, type FileSnapshot} from './hostFiles.js'; +import {withoutHook} from './hostAppearance.js'; +import {buildGhosttyCatalog, GHOSTTY_MAX_EFFECTS, generateGhosttyShader, paramError, type GenerationContext, type GhosttyCatalog, type GhosttyOfferedPreset} from './ghosttyShaderCatalog.js'; +import type {GhosttyShaderSettings, KittyShaderEffect} from './kittyShaderSettings.js'; +import type {CursorMotion, CursorRenderer} from '../prompt/configuration.js'; +import {fragmentPath as cursorFragmentPath, ghosttyCursorFragment, shaderPath as cursorShaderPath} from '../cursor/native.js'; + +export interface GhosttyShaderGates {reducedMotion: boolean; effectsOff: boolean; cursorRenderer: CursorRenderer; cursorMotion: CursorMotion} + +/** Why a trail cannot be native right now (undefined when it can). /cursor decides whether there is a trail; the studio decides its look. */ +export function trailGateReason(gates: GhosttyShaderGates): string | undefined { + if (gates.cursorRenderer === 'portable') return 'The cursor renderer is Portable, so NMSh draws the trail itself and adds no Ghostty trail.'; + if (gates.cursorMotion !== 'smear' && gates.cursorMotion !== 'tail') return `/cursor Motion is ${gates.cursorMotion === 'off' ? 'Off' : 'Smooth (a caret glide, not a trail)'}, so no Ghostty trail is drawn; choose Smear or Tail there.`; + return undefined; +} + +/** What NMSh's passes may contain now: nothing under Reduced Motion or Effects Off; trails only when /cursor wants a native trail. */ +export function gatedGhosttyEffects(settings: GhosttyShaderSettings, catalog: GhosttyCatalog, gates: GhosttyShaderGates): Array<{preset: GhosttyOfferedPreset; 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.trail && trailGateReason(gates))) return []; + return [{preset, effect}]; + }); +} + +/** Every reason saved settings cannot be applied to THIS Ghostty; nothing is clamped or invented. */ +export function validateGhosttyEffects(settings: GhosttyShaderSettings, catalog: GhosttyCatalog): 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 Ghostty ${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 a setting of this shader.`); continue; } + const problem = paramError(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 (one cursor trail).`); + exclusive.set(definition.exclusive, definition.label); + } + } + if (settings.effects.filter(effect => effect.enabled).length > GHOSTTY_MAX_EFFECTS) errors.push(`At most ${GHOSTTY_MAX_EFFECTS} NMSh effects can run at once (each is a full-window pass).`); + return errors; +} + +// ---- Paths, ledger ----------------------------------------------------------------------------------------------- + +export function ghosttyShaderPaths(context: AppearanceContext) { + const base = appearancePaths('ghostty', context); + return {...base, fragment: join(base.directory, 'ghostty-shaders.conf'), shaderDirectory: join(base.directory, 'ghostty-shaders'), + ledger: join(base.directory, 'ghostty-shaders.json'), reload: join(base.directory, 'ghostty-reload.json')}; +} +const passPath = (paths: ReturnType, id: string) => join(paths.shaderDirectory, `${id}.glsl`); +const HOOK_COMMENT = '# NMSh Ghostty shaders (remove using /appearance)'; +const FRAGMENT_HEADER = '# Managed by NMSh (/appearance › Ghostty shaders). Your own custom-shader lines are never changed; these passes run after them.\n'; +const INACTIVE_FRAGMENT = `${FRAGMENT_HEADER}# No NMSh effect is active (Reduced Motion, Effects Off, or none chosen).\n`; + +interface GhosttyLedger { + version: 1; feature: 'ghostty-shaders'; hostPath: string; hook: string; fragmentHash: string; created: boolean; + ghosttyVersion: string; + passes: Array<{id: string; path: string; sha256: string}>; + /** The trail preset written now, if any (Portable must not draw a second trail). */ + trail?: string; + /** NMSh's older /cursor Ghostty shader was paused by this integration (removal resumes it in the same reviewed transaction). */ + legacyPaused?: boolean; +} + +function owned(context: AppearanceContext, lenient = false) { + const paths = ghosttyShaderPaths(context); + const fragment = snapshot(paths.fragment, paths.home, true); + const ledger = snapshot(paths.ledger, paths.home, true); + let record: GhosttyLedger | undefined; + if (ledger.content !== undefined) { + let parsed: Partial; + try { parsed = JSON.parse(ledger.content); } catch { throw new Error('The Ghostty 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 !== 'ghostty-shaders' || typeof parsed.created !== 'boolean' || !paths.candidates.includes(parsed.hostPath ?? '') + || typeof parsed.hook !== 'string' || !hash(parsed.fragmentHash) || !Array.isArray(parsed.passes) + || !parsed.passes.every(pass => pass && typeof pass.id === 'string' && /^[a-z0-9-]{1,48}$/u.test(pass.id) && pass.path === passPath(paths, pass.id) && hash(pass.sha256)) + || (parsed.trail !== undefined && typeof parsed.trail !== 'string') || (parsed.legacyPaused !== undefined && typeof parsed.legacyPaused !== 'boolean')) throw new Error('The Ghostty shader ownership record is invalid.'); + record = parsed as GhosttyLedger; + const newline = record.hook.includes('\r\n') ? '\r\n' : '\n'; + if (record.hook.replace(/\r\n/gu, '\n').trim() !== `${HOOK_COMMENT}\nconfig-file = ?${paths.fragment}` || !record.hook.endsWith(`config-file = ?${paths.fragment}${newline}`)) { + throw new Error('The recorded include does not match NMSh’s own Ghostty shader hook.'); + } + } + const damaged = new Map(); + const check = (file: FileSnapshot, expected: string | undefined, label: string) => { + if (file.content !== undefined && (!record || digest(file.content) !== expected)) { + if (lenient && record) { damaged.set(label, 'edited'); return; } + throw new Error(`NMSh’s Ghostty ${label} is unowned or was edited outside NMSh; it was preserved.`); + } + if (record && expected !== undefined && file.content === undefined) { + if (lenient) { damaged.set(label, 'missing'); return; } + throw new Error(`NMSh’s recorded Ghostty ${label} is missing; R in /appearance › Ghostty shaders removes the integration.`); + } + }; + check(fragment, record?.fragmentHash, 'shader fragment'); + const passes = new Map(); + for (const pass of record?.passes ?? []) { + const file = snapshot(pass.path, paths.home, true); + passes.set(pass.id, file); + check(file, pass.sha256, `${pass.id} shader`); + } + return {paths, fragment, ledger, record, passes, damaged}; +} + +// ---- Reading the person's shaders ---------------------------------------------------------------------------------- + +export interface ShaderListEntry {raw: string; path: string; optional: boolean; source: string} + +/** Ghostty's effective custom-shader list from a configuration read, with its RepeatablePath rules. */ +export function effectiveShaderList(read: ConfigurationRead, home: string): {entries: ShaderListEntry[]; resets: string[]} { + let entries: ShaderListEntry[] = []; + const resets: string[] = []; + for (const definition of read.definitions) { + if (definition.key !== 'custom-shader') continue; + const where = `${definition.path}:${definition.line}`; + // An empty value clears every earlier pass; `?` alone or "" is ignored (Ghostty 1.2–1.4 path.zig). + if (definition.raw === '') { entries = []; resets.push(where); continue; } + let value = definition.raw; + const optional = value.startsWith('?'); + if (optional) value = value.slice(1); + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1); + if (!value) continue; + const path = value.startsWith('~/') ? resolve(home, value.slice(2)) : isAbsolute(value) ? value : resolve(dirname(definition.path), value); + entries.push({raw: definition.raw, path, optional, source: where}); + } + return {entries, resets}; +} + +const CURSOR_UNIFORMS = /\b(iCurrentCursor|iPreviousCursor|iTimeCursorChange)\b/u; +const stripComments = (source: string) => source.replace(/\/\*[\s\S]*?\*\//gu, '').replace(/\/\/[^\n]*/gu, ''); + +export interface GhosttyShaderEntry extends ShaderListEntry { + owner: 'person' | 'nmsh-cursor'; + state: 'readable' | 'missing' | 'unreadable'; + /** Reads Ghostty's cursor-change uniforms: almost certainly a cursor trail or cursor effect. */ + cursor: boolean; + reason?: string; +} + +export interface GhosttyLaunch { + /** Ghostty's own command line sets shader or config options NMSh cannot see; nothing will be composed. */ + refusal?: string; + note?: string; + /** When the Ghostty process NMSh runs in started (ms since epoch), if found. */ + startedAt?: number; +} + +export interface GhosttyShaderInspection { + hostPath: string; + /** Ghostty's effective pass list without NMSh's studio passes, in order. */ + entries: GhosttyShaderEntry[]; + /** Display labels for the person's own passes (shared studio panel). */ + userEntries: string[]; + /** Where a `custom-shader =` line clears earlier passes. */ + resets: string[]; + animation: {value: string; source: 'default' | 'person' | 'nmsh-cursor'}; + /** NMSh's older /cursor shader is in the effective list. */ + cursorShaderActive: boolean; + problems: string[]; + notes: string[]; + dependencies: FileSnapshot[]; + configured: boolean; + drifted: false; + activePresets: string[]; + trail?: string; +} + +export interface GhosttyShaderContext extends AppearanceContext {catalog: GhosttyCatalog; gates: GhosttyShaderGates; launch: GhosttyLaunch; colors: GenerationContext} + +export function inspectGhosttyShaders(context: AppearanceContext & {launch?: GhosttyLaunch}): GhosttyShaderInspection { + const paths = ghosttyShaderPaths(context); + const result: GhosttyShaderInspection = {hostPath: paths.fallback, entries: [], userEntries: [], resets: [], animation: {value: 'true', source: 'default'}, cursorShaderActive: false, + problems: [], notes: [], dependencies: [], configured: false, drifted: false, activePresets: []}; + let record: GhosttyLedger | undefined; + try { record = owned(context).record; } catch (error) { result.problems.push(error instanceof Error ? error.message : String(error)); } + // NMSh's studio fragment reads as empty, leaving exactly what the rest of the configuration says. + const read = readHostConfiguration('ghostty', context, new Map([[paths.fragment, '']])); + result.hostPath = read.hostPath; + result.problems.push(...read.problems); + result.dependencies.push(...read.files); + if (context.launch?.refusal) result.problems.push(context.launch.refusal); + if (context.launch?.note) result.notes.push(context.launch.note); + const legacyShader = cursorShaderPath(context.env); + const legacyFragment = cursorFragmentPath('ghostty', context.env); + const list = effectiveShaderList(read, paths.home); + result.resets = list.resets; + for (const entry of list.entries) { + if (entry.path === legacyShader) { result.entries.push({...entry, owner: 'nmsh-cursor', state: 'readable', cursor: true}); result.cursorShaderActive = true; continue; } + let item: GhosttyShaderEntry; + try { + const file = snapshot(entry.path, paths.home); + result.dependencies.push(file); + item = file.content === undefined ? {...entry, owner: 'person', state: 'missing', cursor: false} + : {...entry, owner: 'person', state: 'readable', cursor: CURSOR_UNIFORMS.test(stripComments(file.content))}; + } catch (error) { + item = {...entry, owner: 'person', state: 'unreadable', cursor: false, reason: error instanceof Error ? error.message : String(error)}; + } + result.entries.push(item); + if (item.state === 'missing') result.notes.push(`Ghostty cannot find your shader ${entry.path} (${entry.source}); it is kept as written${entry.optional ? '' : ' and Ghostty logs it'}.`); + } + result.userEntries = result.entries.map(entry => entry.owner === 'nmsh-cursor' ? 'NMSh /cursor shader' : basename(entry.path)); + const animation = read.values.get('custom-shader-animation'); + if (animation) result.animation = {value: animation.value, source: animation.path === legacyFragment ? 'nmsh-cursor' : 'person'}; + if (record) { + result.configured = true; + result.activePresets = record.passes.map(pass => pass.id); + if (record.trail) result.trail = record.trail; + } + return result; +} + +// ---- Composition ------------------------------------------------------------------------------------------------- + +export interface GhosttyShaderPlan { + host: 'ghostty'; + operation: 'save' | 'remove' | 'sync'; + hostPath: string; + fragmentPath: string; + preview: string[]; + notes: string[]; + guidance: string; + home: string; + changes: readonly FileChange[]; + dependencies: readonly FileSnapshot[]; + /** NMSh's generated passes in order (for tests and the confirm-time re-check). */ + shaders: Array<{id: string; path: string; source: string}>; + /** Ghostty's whole effective pass list after this plan. */ + effectiveShaders: string[]; + /** The trail Ghostty may draw before / after this plan ('nmsh-cursor' = NMSh's older /cursor shader). */ + trailBefore?: string; + trailAfter?: string; +} +export type GhosttyShaderPlanResult = {ok: true; plan: GhosttyShaderPlan} | {ok: false; error: string}; + +export function ghosttyShaderGuidance(platform: NodeJS.Platform): string { + return `Configuration saved, not proof of GPU output. Reload Ghostty’s configuration (${platform === 'darwin' ? '⌘⇧,' : 'Ctrl+Shift+,'}): Ghostty does not reload it by itself and NMSh never signals it. ` + + 'Ghostty compiles shaders after loading the configuration and only logs a shader that fails; if an effect does not appear, check Ghostty’s log.'; +} + +function previewFile(path: string, before: string | undefined, after: string | undefined, owned = true): 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}${owned ? ' (NMSh-owned)' : ''}`, ...after.trimEnd().split(/\r?\n/u).map(line => `+ ${line}`)]; +} + +const UNSAFE_PATH = /["'\\\u0000-\u001f\u007f$`]/u; + +function compose(settings: GhosttyShaderSettings, context: GhosttyShaderContext, mode: 'save' | 'sync'): GhosttyShaderPlanResult { + try { + const {catalog, gates} = context; + if (!catalog.supported) throw new Error(catalog.reason ?? 'Ghostty custom shaders are not available.'); + const errors = validateGhosttyEffects(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 Ghostty effect is enabled. Enable one, or press R to remove NMSh’s Ghostty shaders.'); + if (mode === 'save' && (gates.reducedMotion || gates.effectsOff)) { + throw new Error(`${gates.reducedMotion ? 'Reduced Motion' : 'Decorative effects Off'} is on, so NMSh writes no Ghostty effects. Turn it off first; R still removes existing ones.`); + } + const active = gatedGhosttyEffects(settings, catalog, gates); + if (mode === 'save' && !active.length) throw new Error(trailGateReason(gates) ?? 'Nothing to write.'); + const state = owned(context); + const inspection = inspectGhosttyShaders(context); + if (inspection.problems.length) throw new Error(inspection.problems.join(' ')); + const {paths} = state; + if (UNSAFE_PATH.test(paths.fragment) || UNSAFE_PATH.test(paths.shaderDirectory)) throw new Error('NMSh’s configuration path cannot be referenced safely from Ghostty.'); + const notes = [...inspection.notes]; + const dependencies = [...inspection.dependencies]; + const trail = active.find(item => item.preset.definition.trail); + // Never two trails: the person's own cursor shader, or one NMSh cannot read, rules out a second. + if (trail) { + const theirs = inspection.entries.find(entry => entry.owner === 'person' && entry.cursor); + if (theirs) throw new Error(`Your shader ${theirs.path} reads Ghostty’s cursor uniforms (a cursor trail or effect); NMSh will not add a second trail (${trail.preset.definition.label}).`); + const unknown = inspection.entries.find(entry => entry.owner === 'person' && entry.state === 'unreadable'); + if (unknown) throw new Error(`NMSh cannot read your shader ${unknown.path} (${unknown.reason}), so it cannot rule out a second cursor trail.`); + } + if (inspection.entries.some(entry => entry.path.startsWith(`${paths.shaderDirectory}/`))) throw new Error('Your configuration already names a file in NMSh’s Ghostty shader folder; remove that line first.'); + const shaders = active.map(({preset, effect}) => ({id: preset.definition.id, path: passPath(paths, preset.definition.id), source: generateGhosttyShader(preset.definition, effect.values, context.colors).source})); + for (const shader of shaders) if (!state.passes.has(shader.id)) { + const file = snapshot(shader.path, paths.home, true); + if (file.content !== undefined) throw new Error(`${shader.path} exists but is not recorded as NMSh’s; it was preserved.`); + state.passes.set(shader.id, file); + } + for (const {preset} of active) { + if (preset.definition.animated) notes.push(`${preset.definition.label} animates every frame while Ghostty is focused (custom-shader-animation)${inspection.animation.value === 'false' ? '; yours is false, so it only moves when the terminal redraws' : ''}.`); + } + if (trail && inspection.animation.value === 'false') notes.push(`Your custom-shader-animation is false: ${trail.preset.definition.label} only advances when the terminal redraws, so it may not fade smoothly. NMSh does not change that setting.`); + // Ghostty runs its animation loop on the focused surface whenever any custom shader is listed, whatever the shader draws. + if (active.length) notes.push(`While any custom shader is listed, Ghostty ${inspection.animation.value === 'false' ? 'redraws only when the terminal updates (your custom-shader-animation is false)' + : inspection.animation.value === 'always' ? 'runs its animation loop on every surface, focused or not (your custom-shader-animation is always)' + : 'runs its animation loop on the focused surface (custom-shader-animation true, the default)'}; NMSh does not change that setting.`); + const fragmentText = active.length ? `${FRAGMENT_HEADER}${shaders.map(shader => `custom-shader = ${shader.path}`).join('\n')}\n` : INACTIVE_FRAGMENT; + if (state.record && state.record.hostPath !== inspection.hostPath) throw new Error('Ghostty’s configuration file changed; remove the old integration before setting up this one.'); + if (mode === 'sync' && !state.record) throw new Error('Ghostty shaders are not set up.'); + 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}config-file = ?${paths.fragment}${newline}`; + if (state.record && original.split(hook).length !== 2) throw new Error('The recorded Ghostty shader include was changed or duplicated; nothing was changed.'); + if (!state.record && original.includes(paths.fragment)) throw new Error('An unrecorded NMSh Ghostty shader include already exists; remove it manually before setup.'); + const hostAfter = state.record ? original : `${original}${hook}`; + // NMSh's older /cursor shader pauses while a studio trail is written (it would be a second trail). + const legacyPath = cursorFragmentPath('ghostty', context.env); + const legacy = snapshot(legacyPath, paths.home, true); + const legacyOn = ghosttyCursorFragment(true, context.env); + const legacyAfter = trail && legacy.content === legacyOn ? ghosttyCursorFragment(false, context.env) : legacy.content; + const legacyPaused = Boolean(state.record?.legacyPaused) || legacyAfter !== legacy.content; + if (trail && inspection.cursorShaderActive && legacy.content !== legacyOn) throw new Error('NMSh’s /cursor Ghostty file was edited outside NMSh, so NMSh cannot pause its shader for this trail.'); + // Prove the result: the person's passes unchanged and in order, NMSh's passes present in order. + const overrides = new Map([[hostFile.path, hostAfter], [paths.fragment, fragmentText], ...(legacyAfter !== undefined && legacyAfter !== legacy.content ? [[legacyPath, legacyAfter] as [string, string]] : [])]); + const simulated = readHostConfiguration('ghostty', context, overrides); + if (simulated.problems.length) throw new Error(simulated.problems.join(' ')); + const effective = effectiveShaderList(simulated, paths.home).entries.map(entry => entry.path); + const ours = new Set(shaders.map(shader => shader.path)); + const theirsBefore = inspection.entries.filter(entry => entry.owner === 'person').map(entry => entry.path); + const theirsAfter = effective.filter(path => !ours.has(path) && path !== cursorShaderPath(context.env)); + if (JSON.stringify(effective.filter(path => ours.has(path))) !== JSON.stringify(shaders.map(shader => shader.path))) { + throw new Error('A later `custom-shader =` line (which clears the list) or an unresolved include would drop NMSh’s passes. Resolve that explicitly; no files were changed.'); + } + if (JSON.stringify(theirsAfter) !== JSON.stringify(theirsBefore)) throw new Error('NMSh’s change would alter your own shader passes; nothing was changed.'); + const trailAfter = trail?.preset.definition.id; + const record: GhosttyLedger = {version: 1, feature: 'ghostty-shaders', hostPath: hostFile.path, hook, fragmentHash: digest(fragmentText), created: state.record?.created ?? hostFile.content === undefined, + ghosttyVersion: catalog.version, passes: shaders.map(shader => ({id: shader.id, path: shader.path, sha256: digest(shader.source)})), ...(trailAfter ? {trail: trailAfter} : {}), + ...(legacyPaused ? {legacyPaused: true} : {})}; + const stale = [...state.passes.entries()].filter(([id, file]) => !shaders.some(shader => shader.id === id) && file.content !== undefined).map(([, file]) => ({before: file} as FileChange)); + const changes: FileChange[] = [...shaders.map(shader => ({before: state.passes.get(shader.id)!, after: shader.source})), ...stale, {before: state.fragment, after: fragmentText}, + ...(state.record ? [] : [{before: hostFile, after: hostAfter}]), ...(legacyAfter !== legacy.content ? [{before: legacy, after: legacyAfter}] : []), + {before: state.ledger, after: `${JSON.stringify(record, null, 2)}\n`}]; + const kept = theirsBefore.length ? `Kept, in order: ${theirsBefore.map(path => basename(path)).join(' · ')}` : 'You have no custom shaders of your own.'; + const resetNote = inspection.resets.length ? [`A \`custom-shader =\` line at ${inspection.resets.join(', ')} clears earlier passes; it is unchanged.`] : []; + const legacyNote = legacyAfter !== legacy.content ? ['NMSh’s older /cursor Ghostty shader pauses while this trail is written (never two trails); it returns if the trail is removed.'] : []; + const preview = [kept, ...resetNote, `Effective after this change: ${effective.length ? effective.map(path => basename(path)).join(' · ') : '(none)'}`, ...legacyNote, '', + ...previewFile(hostFile.path, hostFile.content, hostAfter, false), ...previewFile(paths.fragment, state.fragment.content, fragmentText), + ...shaders.flatMap(shader => previewFile(shader.path, state.passes.get(shader.id)?.content, shader.source)), + ...stale.flatMap(change => previewFile(change.before.path, change.before.content, undefined)), + ...(legacyAfter !== legacy.content ? previewFile(legacyPath, legacy.content, legacyAfter) : []), ...(notes.length ? ['', ...notes] : [])]; + const trailBefore = state.record?.trail ?? (inspection.cursorShaderActive ? 'nmsh-cursor' : undefined); + return {ok: true, plan: {host: 'ghostty', operation: mode, hostPath: hostFile.path, fragmentPath: paths.fragment, preview, notes, guidance: ghosttyShaderGuidance(context.platform), + home: paths.home, changes, dependencies: [...dependencies, ...(state.record ? [hostFile] : [])], shaders, effectiveShaders: effective, + ...(trailBefore ? {trailBefore} : {}), ...(trailAfter ? {trailAfter} : {})}}; + } catch (error) { return {ok: false, error: error instanceof Error ? error.message : String(error)}; } +} + +/** A reviewed plan for the selected effects: exact files and effective pass list, or a refusal with the reason. */ +export function planGhosttyShaders(settings: GhosttyShaderSettings, context: GhosttyShaderContext): GhosttyShaderPlanResult { return compose(settings, context, 'save'); } + +export type GhosttySyncResult = {kind: 'none' | 'unchanged'} | {kind: 'written'; message: string; trailBefore?: string; trailAfter?: string} | {kind: 'review'; message: string}; + +/** + * After setup, keep NMSh's passes in step with Reduced Motion, Effects Off and /cursor (renderer, motion, colors). + * Only NMSh-owned files change; taking every effect out never waits for a review. + */ +export function syncGhosttyShaders(settings: GhosttyShaderSettings, context: GhosttyShaderContext): GhosttySyncResult { + 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'}; + // Inside tmux or ssh the running Ghostty's version is HIDDEN: keep using the version the setup was reviewed with, so a gate + // change still updates (or removes) NMSh's passes. A version known to be unsupported is not overridden: passes come out. + if (!context.catalog.supported && context.catalog.capability.hidden) context = {...context, catalog: buildGhosttyCatalog(record.ghosttyVersion)}; + if (!context.catalog.supported || !gatedGhosttyEffects(settings, context.catalog, context.gates).length) { + if (state.fragment.content === INACTIVE_FRAGMENT && !record.passes.length) return {kind: 'unchanged'}; + const removals: FileChange[] = [...state.passes.values()].filter(file => file.content !== undefined).map(file => ({before: file})); + const {trail: _trail, ...rest} = record; + const ledgerText = `${JSON.stringify({...rest, fragmentHash: digest(INACTIVE_FRAGMENT), passes: []}, null, 2)}\n`; + try { transact([...removals, {before: state.fragment, after: INACTIVE_FRAGMENT}, {before: state.ledger, after: ledgerText}], [], state.paths.home); } + catch (error) { return {kind: 'review', message: error instanceof Error ? error.message : String(error)}; } + const why = context.catalog.supported ? '' : ` (${context.catalog.capability.reason ?? 'this Ghostty version is not supported'})`; + return {kind: 'written', message: `NMSh’s Ghostty effects are off${why} · ${ghosttyShaderGuidance(context.platform)}`, ...(record.trail ? {trailBefore: record.trail} : {})}; + } + // The reviewed setup already passed the launch check; putting NMSh's reviewed passes 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 === state.paths.ledger)) return {kind: 'unchanged'}; + const applied = applyGhosttyShaderPlan(planned.plan); + return applied.ok ? {kind: 'written', message: `Ghostty shaders updated · ${planned.plan.guidance}`, ...(planned.plan.trailBefore ? {trailBefore: planned.plan.trailBefore} : {}), + ...(planned.plan.trailAfter ? {trailAfter: planned.plan.trailAfter} : {})} : {kind: 'review', message: applied.error}; +} + +/** + * `legacyActive`: whether NMSh's older /cursor Ghostty shader should be on under the current /cursor settings. When this + * integration paused it, removal resumes it inside the same reviewed transaction (exact paused content only). + */ +export function planGhosttyShaderRemoval(context: AppearanceContext, options: {legacyActive?: boolean} = {}): GhosttyShaderPlanResult { + try { + const state = owned(context, true); + if (!state.record) throw new Error('No NMSh-owned Ghostty 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 Ghostty shader include is duplicated; it was preserved.'); + const intact = (label: string) => !state.damaged.has(label); + const own: FileChange[] = [...(intact('shader fragment') && state.fragment.content !== undefined ? [{before: state.fragment}] : []), + ...[...state.passes.entries()].filter(([id, file]) => intact(`${id} shader`) && file.content !== undefined).map(([, file]) => ({before: file})), {before: state.ledger}]; + const notes = [...state.damaged].map(([label, kind]) => kind === 'missing' ? `NMSh’s Ghostty ${label} is already missing.` : `NMSh’s Ghostty ${label} was edited outside NMSh; it is kept and no longer included.`); + const legacyPath = cursorFragmentPath('ghostty', context.env); + const legacy = state.record.legacyPaused ? snapshot(legacyPath, state.paths.home, true) : undefined; + const resume = legacy && options.legacyActive && legacy.content === ghosttyCursorFragment(false, context.env) ? [{before: legacy, after: ghosttyCursorFragment(true, context.env)} as FileChange] : []; + if (legacy && options.legacyActive && !resume.length && legacy.content !== ghosttyCursorFragment(true, context.env)) notes.push('NMSh’s older /cursor Ghostty file was changed while paused; it is left as it is.'); + own.push(...resume); + const guidance = ghosttyShaderGuidance(context.platform); + const base = {host: 'ghostty' as const, operation: 'remove' as const, hostPath: hostFile.path, fragmentPath: state.paths.fragment, notes, guidance, home: state.paths.home, + shaders: [], effectiveShaders: [], ...(state.record.trail ? {trailBefore: state.record.trail} : {})}; + const ownPreview = [...own.map(change => change.after === undefined ? `- ${change.before.path} (NMSh-owned)` : `~ ${change.before.path}: NMSh’s older /cursor shader resumes`), ...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-shader lines are unchanged and keep applying.']}}; + } catch (error) { return {ok: false, error: error instanceof Error ? error.message : String(error)}; } +} + +export function applyGhosttyShaderPlan(plan: GhosttyShaderPlan): {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)}; } +} + +// ---- Written state and reload ------------------------------------------------------------------------------------ + +/** The trail preset NMSh's written Ghostty configuration contains right now (hook present, pass listed); never guessed from settings. */ +export function writtenGhosttyTrail(context: AppearanceContext): string | undefined { + try { + const state = owned(context); + if (!state.record?.trail || !(snapshot(state.record.hostPath, state.paths.home).content ?? '').includes(state.record.hook)) return undefined; + return (state.fragment.content ?? '').split('\n').includes(`custom-shader = ${passPath(state.paths, state.record.trail)}`) ? state.record.trail : undefined; + } catch { return undefined; } +} + +/** Every trail Ghostty may still have loaded since its last confirmed reload ('nmsh-cursor' first when present). */ +interface ReloadRecord {version: 1; since: number; previous: string[]} +const strongest = (list: readonly string[]) => list.includes('nmsh-cursor') ? 'nmsh-cursor' : list[0]; +function readReload(context: AppearanceContext): ReloadRecord | undefined { + const paths = ghosttyShaderPaths(context); + const file = snapshot(paths.reload, paths.home); + if (file.content === undefined) return undefined; + const record = JSON.parse(file.content) as {version?: number; since?: number; previous?: unknown}; + const previous = typeof record.previous === 'string' ? [record.previous] : Array.isArray(record.previous) ? record.previous.filter((item): item is string => typeof item === 'string') : []; + return record.version === 1 && typeof record.since === 'number' && previous.length ? {version: 1, since: record.since, previous} : undefined; +} + +/** + * A native trail NMSh just stopped writing that Ghostty may still draw: Ghostty keeps its loaded shaders until + * the person reloads its configuration, so Portable waits rather than draw a second trail. Cleared by the person + * confirming the reload, or by a Ghostty process that started after the change. + */ +export function ghosttyReloadPending(context: AppearanceContext, launch?: GhosttyLaunch): string | undefined { + try { + const record = readReload(context); + if (!record || (launch?.startedAt !== undefined && launch.startedAt > record.since)) return undefined; + return strongest(record.previous); + } catch { return undefined; } +} + +/** Record (or clear, with undefined) the trail Ghostty may still draw until a reload. Only NMSh's own state file changes. */ +export function setGhosttyReloadPending(context: AppearanceContext, previous: readonly string[] | undefined, now = Date.now()): void { + const paths = ghosttyShaderPaths(context); + const before = snapshot(paths.reload, paths.home, true); + const after = !previous?.length ? undefined : `${JSON.stringify({version: 1, since: now, previous: [...new Set(previous)]} satisfies ReloadRecord)}\n`; + if (before.content === after) return; + transact([{before, after}], [], paths.home); +} + +/** + * After a written change: a trail that was (possibly) loaded and is now gone or different stays pending until a reload. + * Successive changes before a reload ACCUMULATE (legacy → Smear → Comet keeps the older /cursor shader pending). + */ +export function noteGhosttyTrailChange(context: AppearanceContext, before: string | undefined, after: string | undefined, now = Date.now()): string | undefined { + if (!before || before === after) return undefined; + let earlier: string[] = []; + try { earlier = readReload(context)?.previous ?? []; } catch { /* an unreadable record is replaced */ } + const previous = [...earlier, before].filter(item => item !== after); + setGhosttyReloadPending(context, previous.length ? previous : [before], now); + return strongest(previous.length ? previous : [before]); +} + +// ---- Launch ------------------------------------------------------------------------------------------------------ + +export interface ProcessRow {pid: number; ppid: number; elapsedSeconds: number; command: string} + +/** `ps` etime: [[dd-]hh:]mm:ss. */ +export function parseElapsed(text: string): number | undefined { + const match = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/u.exec(text.trim()); + if (!match) return undefined; + return Number(match[1] ?? 0) * 86400 + Number(match[2] ?? 0) * 3600 + Number(match[3]) * 60 + Number(match[4]); +} + +/** Flags on Ghostty's own command line that change which shaders or configuration it loads. */ +export function ghosttyLaunchOverride(command: string): string | undefined { + const flag = /(?:^|\s)--(custom-shader|config-file|config-default-files)(?:=|\s|$)/u.exec(command); + return flag ? `Ghostty was started with --${flag[1]}, which NMSh cannot see in its configuration files; nothing will be changed.` : undefined; +} + +/** Find the Ghostty process among NMSh's parents (read-only) and check its launch options. */ +export function inspectGhosttyLaunch(rows: readonly ProcessRow[] | undefined, selfPid: number, now = Date.now()): GhosttyLaunch { + const unseen = {note: 'NMSh could not find Ghostty among its parent processes (tmux, ssh or a session service), so Ghostty’s own command-line options are not checked.'}; + if (!rows) return unseen; + const byPid = new Map(rows.map(row => [row.pid, row])); + let current = byPid.get(selfPid); + for (let depth = 0; current && depth < 16; depth++) { + const program = current.command.split(/\s+/u)[0] ?? ''; + if (/(^|\/)ghostty$/u.test(program)) { + const refusal = ghosttyLaunchOverride(current.command.slice(program.length)); + return {...(refusal ? {refusal} : {}), startedAt: now - current.elapsedSeconds * 1000}; + } + current = current.ppid > 1 ? byPid.get(current.ppid) : undefined; + } + return unseen; +} + +/** All processes' pid, parent, elapsed time and command (bounded, read-only); undefined when unavailable. */ +export function readProcessTable(): ProcessRow[] | undefined { + try { + const output = execFileSync('ps', ['-A', '-o', 'pid=,ppid=,etime=,command='], {encoding: 'utf8', timeout: 2000, maxBuffer: 4 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore']}); + return output.split('\n').flatMap(line => { + const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/u.exec(line); + const elapsed = match ? parseElapsed(match[3]!) : undefined; + return match && elapsed !== undefined ? [{pid: Number(match[1]), ppid: Number(match[2]), elapsedSeconds: elapsed, command: match[4]!}] : []; + }); + } catch { return undefined; } +} diff --git a/src/appearance/glslStudio.ts b/src/appearance/glslStudio.ts new file mode 100644 index 00000000..c1e1cafb --- /dev/null +++ b/src/appearance/glslStudio.ts @@ -0,0 +1,198 @@ +/** + * The GLSL shader studio controller: everything the app needs for NMSh's + * Ghostty custom-shader passes, kept out of the general frontend (which must + * not branch on host products). TerminalApp only calls these host-neutral + * methods; host detection, catalog, gates, reviewed writes, sync, reload state + * and the /cursor hand-off all live here. + */ +import {existsSync} from 'node:fs'; +import type {Key} from '../terminal/keys.js'; +import type {PromptConfiguration} from '../prompt/configuration.js'; +import type {CursorSettings} from '../prompt/configuration.js'; +import {terminalProfile} from '../host/capabilities.js'; +import {isReducedMotion} from '../presentation/environment.js'; +import {contextFor, resolveCursorSettings} from '../cursor/colors.js'; +import {effectPalette} from '../cursor/palette.js'; +import type {HostCursorFacts} from '../cursor/backends.js'; +import {fragmentContent, ghosttyCursorFragment, ghosttyCursorShaderWritten, writeManagedFiles, type ManagedWrite, type NativeHost} from '../cursor/native.js'; +import type {AppearanceHubState} from './AppearanceHub.js'; +import {createGhosttyShaderPanel, ghosttyShaderKey, renderGhosttyShaderPanel, type GhosttyShaderPanelState} from './GhosttyShaderPanel.js'; +import {buildGhosttyCatalog} from './ghosttyShaderCatalog.js'; +import {applyGhosttyShaderPlan, ghosttyReloadPending, ghosttyShaderPaths, inspectGhosttyLaunch, inspectGhosttyShaders, noteGhosttyTrailChange, planGhosttyShaderRemoval, planGhosttyShaders, + readProcessTable, setGhosttyReloadPending, syncGhosttyShaders, writtenGhosttyTrail, type GhosttyLaunch, type GhosttyShaderContext, type GhosttyShaderGates} from './ghosttyShaders.js'; + +/** What the controller may ask of the app. */ +export interface StudioServices { + configuration(): PromptConfiguration; + update(mutate: (configuration: PromptConfiguration) => void): void; + /** The cursor facts changed (a trail was written, removed or confirmed reloaded). */ + invalidateCursor(): void; + notify(message: string, error: boolean): void; + render(): void; + /** Esc: back to /appearance. */ + back(): void; +} + +export class GlslShaderStudio { + panel?: GhosttyShaderPanelState; + /** Trails Ghostty may still draw when the reload record could not be saved (this process only; never two trails). */ + private unsavedPending: string[] = []; + + constructor(private readonly services: StudioServices, private readonly env: NodeJS.ProcessEnv = process.env, private readonly platform: NodeJS.Platform = process.platform) {} + + private get base() { return {env: this.env, platform: this.platform}; } + + /** The studio exists only when NMSh runs directly in Ghostty. */ + available(): boolean { return terminalProfile(this.env) === 'ghostty'; } + + /** In Ghostty the /appearance hub gains the studio row; the summary says what is chosen, never that it is loaded. */ + addHubRow(hub: AppearanceHubState): void { + if (!this.available()) return; + const chosen = this.services.configuration().hostShaders.ghostty.effects.filter(effect => effect.enabled).length; + hub.ghosttyShaders = chosen ? `${chosen} effect${chosen === 1 ? '' : 's'} chosen · Ghostty 1.2+` : 'Off · Ghostty GPU effects'; + } + + gates(config: PromptConfiguration = this.services.configuration()): GhosttyShaderGates { + return {reducedMotion: isReducedMotion() || config.presentation.reducedMotion, effectsOff: config.presentation.effectsOff, cursorRenderer: config.cursor.renderer, cursorMotion: config.cursor.motion}; + } + + /** + * Everything a plan needs: the catalog from the RUNNING Ghostty's reported version (inside tmux or ssh it is unknown + * and nothing is offered), the gates, /cursor's trail colors and, unless given, Ghostty's launch options. + */ + context(config: PromptConfiguration = this.services.configuration(), launch?: GhosttyLaunch): GhosttyShaderContext { + const cursor = resolveCursorSettings(config.cursor, contextFor(config)); + const palette = effectPalette(cursor); + const rgb = (color: {red: number; green: number; blue: number}) => [color.red / 255, color.green / 255, color.blue / 255]; + const head = palette.trail[0] ?? palette.caret; + const tail = palette.trail.length > 1 ? palette.trail.at(-1)! : {red: head.red * 0.6, green: head.green * 0.6, blue: head.blue * 0.6}; + return {...this.base, catalog: buildGhosttyCatalog(terminalProfile(this.env) === 'ghostty' ? this.env.TERM_PROGRAM_VERSION : undefined), + gates: this.gates(config), launch: launch ?? inspectGhosttyLaunch(readProcessTable(), process.pid), colors: {cursorColors: {head: rgb(head), tail: rgb(tail)}}}; + } + + open(): void { + const panel = createGhosttyShaderPanel(this.services.configuration().hostShaders.ghostty, this.gates()); + this.panel = panel; + // Opening the studio applies gate changes made while NMSh was not running (OS Reduced Motion, an edited config). + this.sync(this.services.configuration()); + const context = this.context(); + panel.catalog = context.catalog; + try { panel.inspection = inspectGhosttyShaders(context); } catch (error) { panel.message = error instanceof Error ? error.message : String(error); } + panel.reloadPending = ghosttyReloadPending(context, context.launch); + // A Ghostty process that started after the change has loaded it: the pending record is stale. + if (!panel.reloadPending) { try { setGhosttyReloadPending(context, undefined); } catch { /* best effort */ } } + this.services.render(); + } + + render(columns: number, height: number): string[] | undefined { + return this.panel ? renderGhosttyShaderPanel(this.panel, columns, height) : undefined; + } + + key(key: Key): void { + const panel = this.panel; + if (!panel) return; + const action = ghosttyShaderKey(panel, key); + if (!action) return; + if (action.kind === 'back') { this.panel = undefined; this.services.back(); return; } + if (action.kind === 'reloaded') { + try { setGhosttyReloadPending(this.base, undefined); } catch (error) { panel.message = error instanceof Error ? error.message : String(error); return; } + this.unsavedPending = []; + panel.reloadPending = undefined; + this.services.invalidateCursor(); + panel.message = 'Noted: Ghostty has reloaded, so Portable draws whatever Ghostty no longer does.'; + return; + } + if (!panel.catalog) return; + if (action.kind === 'review' || action.kind === 'remove') { + const planned = action.kind === 'review' ? planGhosttyShaders(panel.draft, this.context()) : planGhosttyShaderRemoval(this.base, {legacyActive: this.legacyCursorShaderWanted()}); + 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 = planGhosttyShaders(panel.draft, this.context()); + return again.ok && JSON.stringify(again.plan.shaders) === JSON.stringify(reviewed.shaders) && JSON.stringify(again.plan.effectiveShaders) === JSON.stringify(reviewed.effectiveShaders) ? reviewed : undefined; + })(); + if (!plan) { panel.message = 'Something changed since the review (Reduced Motion, /cursor or Ghostty’s configuration); nothing was written. Press S to review again.'; return; } + const applied = applyGhosttyShaderPlan(plan); + if (!applied.ok) { panel.message = applied.error; return; } + this.noteTrailChange(plan.trailBefore, plan.trailAfter); + this.services.invalidateCursor(); + const saved = plan.operation === 'remove' ? {...panel.draft, effects: panel.draft.effects.map(effect => ({...effect, enabled: false}))} : structuredClone(panel.draft); + // The older /cursor Ghostty shader, when paused, resumed inside the reviewed removal itself. + this.services.update(configuration => { configuration.hostShaders.ghostty = saved; }); + panel.draft = structuredClone(saved); + panel.dirty = false; + try { panel.inspection = inspectGhosttyShaders(this.context(this.services.configuration(), {})); } catch { /* the message below still applies */ } + panel.reloadPending = this.pending(); + panel.message = plan.operation === 'remove' ? `Removed NMSh's Ghostty shaders. ${plan.guidance}` : plan.guidance; + } + + /** + * After a reviewed setup, keep NMSh's passes in step with Reduced Motion, Effects Off and /cursor. Only NMSh-owned files + * are rewritten; anything needing a new review is said, not written. A trail that stops being written stays pending + * until Ghostty reloads, so Portable does not draw a second one meanwhile. + */ + sync(next: PromptConfiguration, options: {render?: boolean} = {}): void { + try { if (!existsSync(ghosttyShaderPaths(this.base).ledger)) return; } catch { return; } + const result = syncGhosttyShaders(next.hostShaders.ghostty, this.context(next, {})); + if (result.kind === 'written') { + this.noteTrailChange(result.trailBefore, result.trailAfter); + this.services.invalidateCursor(); + if (this.panel) this.panel.reloadPending = this.pending(); + } + if (result.kind === 'written' || result.kind === 'review') { + const message = result.kind === 'review' ? `Ghostty shaders were not updated: ${result.message}` : result.message; + if (this.panel) this.panel.message = message; + else this.services.notify(message, result.kind === 'review'); + if (options.render !== false) this.services.render(); + } + } + + /** Ghostty's written studio trail and any trail it may still draw until a reload (the process table is read only while one is pending). */ + trailFacts(): {trail?: string; pending?: string} { + const trail = writtenGhosttyTrail(this.base); + const pending = (ghosttyReloadPending(this.base) && ghosttyReloadPending(this.base, inspectGhosttyLaunch(readProcessTable(), process.pid))) ?? this.unsaved(); + return {...(trail ? {trail} : {}), ...(pending ? {pending} : {})}; + } + + private unsaved(): string | undefined { + return this.unsavedPending.includes('nmsh-cursor') ? 'nmsh-cursor' : this.unsavedPending[0]; + } + + private pending(): string | undefined { return ghosttyReloadPending(this.base) ?? this.unsaved(); } + + /** Record a trail Ghostty may still draw; if the record cannot be saved, this process still keeps Portable from doubling it. */ + private noteTrailChange(before: string | undefined, after: string | undefined): void { + try { noteGhosttyTrailChange(this.base, before, after); } catch { if (before && before !== after) this.unsavedPending = [...new Set([...this.unsavedPending, before])]; } + } + + /** Whether /cursor's settings would list NMSh's older Ghostty cursor shader with no studio trail written. */ + private legacyCursorShaderWanted(): boolean { + const config = this.services.configuration(); + return fragmentContent('ghostty', resolveCursorSettings(config.cursor, contextFor(config)), this.env, false) === ghosttyCursorFragment(true, this.env); + } + + /** /cursor's native setup hands over to this studio once it owns the trail (set up, or a trail written). */ + ownsCursorSetup(facts: HostCursorFacts): boolean { + return facts.host === 'ghostty' && (Boolean(facts.ghosttyTrail) || existsSync(ghosttyShaderPaths(this.base).ledger)); + } + + /** + * Write NMSh's /cursor managed files for a host. In Ghostty a written studio trail keeps the older /cursor shader + * paused, and a shader that stops being written is remembered until Ghostty reloads (never two trails). + */ + writeCursorFiles(host: NativeHost, settings: CursorSettings): ManagedWrite & {trailChanged: boolean} { + if (host !== 'ghostty') return {...writeManagedFiles(host, settings, this.env), trailChanged: false}; + const studioTrail = Boolean(writtenGhosttyTrail(this.base)); + const before = ghosttyCursorShaderWritten(this.env); + const written = writeManagedFiles(host, settings, this.env, studioTrail); + const stopped = before && !ghosttyCursorShaderWritten(this.env); + if (stopped) this.noteTrailChange('nmsh-cursor', undefined); + return {...written, trailChanged: stopped}; + } +} diff --git a/src/appearance/kittyShaderSettings.ts b/src/appearance/kittyShaderSettings.ts index 60408afa..e495c911 100644 --- a/src/appearance/kittyShaderSettings.ts +++ b/src/appearance/kittyShaderSettings.ts @@ -8,17 +8,24 @@ export interface KittyShaderSettings { /** 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'}}; +/** Ghostty runs NMSh's passes in this order, after the shaders listed in the person's own config files. */ +export interface GhosttyShaderSettings {effects: KittyShaderEffect[]} +export interface HostShaderSettings {kitty: KittyShaderSettings; ghostty: GhosttyShaderSettings} +export const DEFAULT_HOST_SHADERS: HostShaderSettings = {kitty: {effects: [], placement: 'after'}, ghostty: {effects: []}}; 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 ghostty = isRecord(value) && isRecord(value.ghostty) ? value.ghostty : {}; + return {kitty: {effects: normalizeEffects(kitty.effects), placement: kitty.placement === 'before' ? 'before' : 'after'}, ghostty: {effects: normalizeEffects(ghostty.effects)}}; +} + +function normalizeEffects(list: unknown): KittyShaderEffect[] { const seen = new Set(); const effects: KittyShaderEffect[] = []; - for (const item of Array.isArray(kitty.effects) ? kitty.effects.slice(0, 32) : []) { + for (const item of Array.isArray(list) ? list.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 = {}; @@ -29,5 +36,5 @@ export function normalizeHostShaders(value: unknown): HostShaderSettings { } effects.push({id: item.id, enabled: item.enabled === true, values}); } - return {kitty: {effects, placement: kitty.placement === 'before' ? 'before' : 'after'}}; + return effects; } diff --git a/src/cursor/backends.ts b/src/cursor/backends.ts index 356f36b3..b6abbfc4 100644 --- a/src/cursor/backends.ts +++ b/src/cursor/backends.ts @@ -47,6 +47,12 @@ export const KITTY_BACKEND: CursorEffectBackend = {id: 'kitty', label: 'Kitty na * 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: []}}; +/** + * Ghostty with a trail from /appearance › Ghostty shaders written (NMSh's reviewed studio pass): Ghostty draws the Smear or + * Tail trail; NMSh's older /cursor shader is paused then, so its Fire/Sparks/Ripple are drawn by Portable. + */ +export const GHOSTTY_TRAIL_BACKEND: CursorEffectBackend = {id: 'ghostty', label: 'Ghostty native', host: 'Ghostty', surfaceWide: true, support: {motion: ['smear', 'tail'], effect: [], idleEffect: []}}; +const GHOSTTY_TRAIL_LABELS: Record = {smear: 'Smear', comet: 'Comet'}; 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 { @@ -64,14 +70,26 @@ export interface HostCursorFacts { integrated: boolean; /** Kitty: the trail shader NMSh's reviewed Kitty pipeline currently contains (e.g. "cursor-trail-blaze"). */ kittyTrail?: string; + /** Ghostty: the studio trail NMSh's reviewed Ghostty configuration currently contains (e.g. "comet"). */ + ghosttyTrail?: string; + /** + * Ghostty: a native trail NMSh stopped writing that Ghostty may still draw until its config is reloaded + * ('nmsh-cursor' = NMSh's older /cursor shader). Portable cursor motion waits meanwhile (never two trails). + */ + ghosttyTrailPending?: 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, kittyTrail: () => string | undefined = () => undefined): HostCursorFacts { +export function hostCursorFacts(env: NodeJS.ProcessEnv, integrated: (host: 'ghostty' | 'kitty') => boolean, kittyTrail: () => string | undefined = () => undefined, + ghostty: () => {trail?: string; pending?: string} = () => ({})): 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.TERM_PROGRAM === 'ghostty' || env.TERM === 'xterm-ghostty') { + const studio = ghostty(); + return {host: 'ghostty', hostName: 'Ghostty', caretColor, ...(env.TERM_PROGRAM_VERSION ? {version: env.TERM_PROGRAM_VERSION} : {}), integrated: integrated('ghostty'), + ...(studio.trail ? {ghosttyTrail: studio.trail} : {}), ...(studio.pending ? {ghosttyTrailPending: studio.pending} : {})}; + } if (env.KITTY_WINDOW_ID || env.TERM === 'xterm-kitty') { const trail = kittyTrail(); return {host: 'kitty', hostName: 'Kitty', caretColor, integrated: integrated('kitty'), ...(trail ? {kittyTrail: trail} : {})}; @@ -98,6 +116,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.ghosttyTrail) return GHOSTTY_TRAIL_BACKEND; if (facts.host === 'ghostty' && (facts.version === undefined || atLeast(facts.version, 1, 2))) return GHOSTTY_BACKEND; if (facts.host === 'kitty') return facts.kittyTrail === 'cursor-trail-lightning' ? KITTY_LIGHTNING_BACKEND : KITTY_BACKEND; return undefined; @@ -126,6 +145,18 @@ const isOff = (value: string) => value === 'off' || value === 'none'; * unsure which renderer is drawing. */ export function chooseBackend(settings: CursorSettings, facts: HostCursorFacts): BackendChoice { + const choice = chooseWithoutPending(settings, facts); + // A trail Ghostty may still draw: one NMSh stopped writing (until a reload), or one still written although it should not be + // (Portable chosen but the update was refused). Portable must not add a second trail either way. + const loaded = facts.ghosttyTrailPending ?? (facts.ghosttyTrail && !choice.nativeHandles.motion ? facts.ghosttyTrail : undefined); + if (!loaded) return choice; + const effects = loaded === 'nmsh-cursor'; + return {...choice, portableDraws: {...choice.portableDraws, motion: false, effect: choice.portableDraws.effect && !effects}, + reason: facts.ghosttyTrailPending ? `${choice.reason} · Portable cursor motion waits for a Ghostty config reload (then L in /appearance › Ghostty shaders)` + : `${choice.reason} · Ghostty still lists NMSh’s trail (its update was refused); open /appearance › Ghostty shaders`}; +} + +function chooseWithoutPending(settings: CursorSettings, facts: HostCursorFacts): BackendChoice { const native = nativeBackendFor(facts); const all = {motion: true, effect: true, idle: true}; const nothing = {motion: false, effect: false, idle: false}; @@ -136,7 +167,7 @@ export function chooseBackend(settings: CursorSettings, facts: HostCursorFacts): return {backend: PORTABLE_BACKEND, reason: 'Portable (this terminal has no native cursor effects)', nativeHandles: none, portableDraws: all}; } // 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); + const integrated = facts.integrated || Boolean(facts.kittyTrail) || Boolean(facts.ghosttyTrail); 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`, @@ -147,7 +178,8 @@ export function chooseBackend(settings: CursorSettings, facts: HostCursorFacts): 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` : ''; + const style = facts.kittyTrail ? ` · ${KITTY_TRAIL_LABELS[facts.kittyTrail] ?? 'custom'} trail shader` + : facts.ghosttyTrail ? ` · ${GHOSTTY_TRAIL_LABELS[facts.ghosttyTrail] ?? 'studio'} trail shader` : ''; return {backend: native, native, nativeHandles: {motion, effect}, reason: `${native.label}${style}${fallback ? ' with Portable fallback for what it does not draw' : ''}`, portableDraws: forced ? nothing : {motion: !motion, effect: !effect, idle: true}}; @@ -172,9 +204,9 @@ export function availabilityOf(feature: CursorFeature, value: string, renderer: if (renderer === 'native') { if (!native) return {available: false, reason: 'Host native is not available in this terminal.'}; if (!supports(native, feature, value)) return {available: false, reason: `${native.host ?? native.label} Native does not provide this ${FEATURE_WORD[feature]}.`}; - return facts.integrated ? {available: true, via: 'native', suffix: ''} : {available: true, via: 'native-pending', suffix: `after ${native.host} setup`}; + return facts.integrated || facts.ghosttyTrail ? {available: true, via: 'native', suffix: ''} : {available: true, via: 'native-pending', suffix: `after ${native.host} setup`}; } - if (native && (facts.integrated || facts.kittyTrail)) return supports(native, feature, value) ? {available: true, via: 'native', suffix: ''} : {available: true, via: 'portable-fallback', suffix: 'Portable fallback'}; + if (native && (facts.integrated || facts.kittyTrail || facts.ghosttyTrail)) 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 88ed4d01..f0e15383 100644 --- a/src/cursor/native.ts +++ b/src/cursor/native.ts @@ -83,15 +83,25 @@ export function setupPlan(host: NativeHost, env: NodeJS.ProcessEnv = process.env const hex = (color: {red: number; green: number; blue: number}) => `#${[color.red, color.green, color.blue].map(value => Math.round(value).toString(16).padStart(2, '0')).join('')}`; const vec3 = (color: {red: number; green: number; blue: number}) => `vec3(${(color.red / 255).toFixed(3)}, ${(color.green / 255).toFixed(3)}, ${(color.blue / 255).toFixed(3)})`; -/** The managed fragment for a host, from the person's cursor settings. */ -export function fragmentContent(host: NativeHost, settings: CursorSettings, env: NodeJS.ProcessEnv = process.env): string { - const header = '# Managed by NMSh (/cursor). Edits here are replaced; your own config is never changed by NMSh after setup.\n'; +export const MANAGED_CURSOR_HEADER = '# Managed by NMSh (/cursor). Edits here are replaced; your own config is never changed by NMSh after setup.\n'; + +/** NMSh's /cursor Ghostty fragment with its shader on or off (the only two contents NMSh ever writes there). */ +export function ghosttyCursorFragment(active: boolean, env: NodeJS.ProcessEnv = process.env): string { + return `${MANAGED_CURSOR_HEADER}${active ? `custom-shader = ${shaderPath(env)}\ncustom-shader-animation = true\n` : ''}`; +} + +/** + * The managed fragment for a host, from the person's cursor settings. `ghosttyStudioTrail`: a trail from + * /appearance › Ghostty shaders is written, so this older /cursor shader stays off (never two trails). + */ +export function fragmentContent(host: NativeHost, settings: CursorSettings, env: NodeJS.ProcessEnv = process.env, ghosttyStudioTrail = false): string { + const header = MANAGED_CURSOR_HEADER; if (host === 'ghostty') { // Only what NMSh's shader really draws counts: Smooth, Lightning, Railgun, Wireframe and idle effects leave the shader off. // Renderer Portable means NMSh draws everything itself, so Ghostty's shader is off too (never two trails). - const active = settings.renderer !== 'portable' + const active = !ghosttyStudioTrail && settings.renderer !== 'portable' && (GHOSTTY_BACKEND.support.motion.includes(settings.motion) || GHOSTTY_BACKEND.support.effect.includes(settings.effect)); - return `${header}${active ? `custom-shader = ${shaderPath(env)}\ncustom-shader-animation = true\n` : ''}`; + return ghosttyCursorFragment(active, env); } const palette = effectPalette(settings); const decay = {low: '0.15 0.5', medium: '0.1 0.4', high: '0.05 0.25'}[settings.speed]; @@ -175,7 +185,7 @@ export type ManagedFile = 'fragment' | 'shader'; export interface ManagedWrite {changed: ManagedFile[]} /** Write NMSh's own managed files (fragment and shader), only the ones whose content differs. Never touches the host's main config. */ -export function writeManagedFiles(host: NativeHost, settings: CursorSettings, env: NodeJS.ProcessEnv = process.env): ManagedWrite { +export function writeManagedFiles(host: NativeHost, settings: CursorSettings, env: NodeJS.ProcessEnv = process.env, ghosttyStudioTrail = false): ManagedWrite { const changed: ManagedFile[] = []; const write = (kind: ManagedFile, path: string, content: string) => { try { if (readFileSync(path, 'utf8') === content) return; } catch { /* missing: written below */ } @@ -186,7 +196,7 @@ export function writeManagedFiles(host: NativeHost, settings: CursorSettings, en changed.push(kind); }; if (host === 'ghostty') write('shader', shaderPath(env), shaderSource(settings)); - write('fragment', fragmentPath(host, env), fragmentContent(host, settings, env)); + write('fragment', fragmentPath(host, env), fragmentContent(host, settings, env, ghosttyStudioTrail)); return {changed}; } @@ -208,6 +218,11 @@ export function reloadInstruction(host: NativeHost, write: ManagedWrite, platfor return `Reload Kitty config: ${mac ? '⌃⌘,' : 'Ctrl+Shift+F5'}`; } +/** Whether NMSh's /cursor Ghostty fragment currently lists its shader (read-only; false when absent or unreadable). */ +export function ghosttyCursorShaderWritten(env: NodeJS.ProcessEnv = process.env): boolean { + try { return readFileSync(fragmentPath('ghostty', env), 'utf8') === ghosttyCursorFragment(true, env); } catch { return false; } +} + /** The host whose NMSh-managed cursor files are installed, for when the running terminal is hidden (inside tmux). */ export function installedNativeHost(env: NodeJS.ProcessEnv = process.env): NativeHost | undefined { return (['ghostty', 'kitty'] as const).find(host => nativeCursorIntegrated(host, env)); diff --git a/tests/ghosttyShaderPanel.test.ts b/tests/ghosttyShaderPanel.test.ts new file mode 100644 index 00000000..29c38396 --- /dev/null +++ b/tests/ghosttyShaderPanel.test.ts @@ -0,0 +1,189 @@ +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 {TerminalApp} from '../src/app/TerminalApp.js'; +import {buildGhosttyCatalog} from '../src/appearance/ghosttyShaderCatalog.js'; +import {createGhosttyShaderPanel, ghosttyShaderKey, renderGhosttyShaderPanel} from '../src/appearance/GhosttyShaderPanel.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 {applyGhosttyShaderPlan, ghosttyShaderPaths, planGhosttyShaders, type GhosttyShaderGates, type GhosttyShaderInspection} from '../src/appearance/ghosttyShaders.js'; +import {GlslShaderStudio} from '../src/appearance/glslStudio.js'; +import {stripAnsi} from '../src/util/text.js'; +import type {Key} from '../src/terminal/keys.js'; + +const key = (kind: string, value = ''): Key => ({kind, value} as unknown as Key); +const plain = (rows: string[]) => rows.map(stripAnsi); +const gates: GhosttyShaderGates = {reducedMotion: false, effectsOff: false, cursorRenderer: 'auto', cursorMotion: 'tail'}; +const inspection = (value: Partial = {}): GhosttyShaderInspection => ({hostPath: '/h/config.ghostty', entries: [], userEntries: ['mine.glsl'], resets: [], + animation: {value: 'true', source: 'default'}, cursorShaderActive: false, problems: [], notes: [], dependencies: [], configured: false, drifted: false, activePresets: [], ...value}); + +function panel(version = '1.3.1', g = gates) { + const state = createGhosttyShaderPanel(structuredClone(DEFAULT_HOST_SHADERS.ghostty), g); + state.catalog = buildGhosttyCatalog(version); + state.inspection = inspection(); + return state; +} +const select = (state: ReturnType, label: string) => { + for (let i = 0; i < 40; i++) { + if (plain(renderGhosttyShaderPanel(state, 160)).some(row => row.startsWith('›') && row.includes(label))) return; + ghosttyShaderKey(state, key('down')); + } + assert.fail(`row ${label} not found`); +}; + +test('studio lists capability-aware presets by category; one trail at a time; parameters appear when enabled', () => { + const state = panel(); + const text = plain(renderGhosttyShaderPanel(state, 160)).join('\n'); + assert.match(text, /Appearance › Ghostty shaders[\s\S]*Ghostty 1\.3\.1 · NMSh’s own GLSL passes[\s\S]*Your shaders \(kept, in order; NMSh’s run after them\): mine\.glsl/u); + assert.match(text, /Cursor trail \(one\)[\s\S]*Smear trail[\s\S]*Comet trail[\s\S]*Ambient[\s\S]*Aurora[\s\S]*Focus & tabs[\s\S]*Dim when unfocused[\s\S]*Display[\s\S]*Vignette/u); + assert.ok(!/Placement/u.test(text), 'Ghostty has no placement choice'); + select(state, 'Smear trail'); + ghosttyShaderKey(state, key('enter')); + select(state, 'Comet trail'); + ghosttyShaderKey(state, key('enter')); + assert.deepEqual(state.draft.effects.map(effect => [effect.id, effect.enabled]), [['smear', false], ['comet', true]], 'exclusive trails'); + select(state, 'Glow radius'); + ghosttyShaderKey(state, key('right')); + assert.equal(state.draft.effects.find(effect => effect.id === 'comet')!.values.GLOW, 1.3); + select(state, 'Colors'); + ghosttyShaderKey(state, key('right')); + assert.equal(state.draft.effects.find(effect => effect.id === 'comet')!.values.COLOR_SOURCE, 1); + assert.equal(state.dirty, true); + assert.deepEqual(ghosttyShaderKey(state, key('text', 's')), {kind: 'review'}); +}); + +test('Ghostty 1.2 shows what needs 1.3; unsupported and unknown versions explain and still allow removal', () => { + const old = panel('1.2.0'); + assert.match(plain(renderGhosttyShaderPanel(old, 200)).join('\n'), /Not available in this version: Aurora \(Ghostty 1\.3\+\), Dim when unfocused \(Ghostty 1\.3\+\)/u); + const unknown = panel(''); + unknown.catalog = buildGhosttyCatalog(undefined); + unknown.inspection = inspection({configured: true}); + const text = plain(renderGhosttyShaderPanel(unknown, 160)).join('\n'); + assert.match(text, /did not report its version[\s\S]*Cursor effects stay with \/cursor/u); + assert.equal(ghosttyShaderKey(unknown, key('text', 's')), undefined); + assert.deepEqual(ghosttyShaderKey(unknown, key('text', 'r')), {kind: 'remove'}); +}); + +test('gates and reload state are visible; L confirms a reload only while one is pending', () => { + const portable = panel('1.3.1', {...gates, cursorRenderer: 'portable'}); + assert.match(plain(renderGhosttyShaderPanel(portable, 160)).join('\n'), /Portable, so NMSh draws the trail itself/u); + const smooth = panel('1.3.1', {...gates, cursorMotion: 'smooth'}); + assert.match(plain(renderGhosttyShaderPanel(smooth, 160)).join('\n'), /Smooth \(a caret glide, not a trail\)/u); + const reduced = panel('1.3.1', {...gates, reducedMotion: true}); + assert.match(plain(renderGhosttyShaderPanel(reduced, 160)).join('\n'), /Reduced Motion is on: NMSh writes no Ghostty effects/u); + const state = panel(); + assert.equal(ghosttyShaderKey(state, key('text', 'l')), undefined); + state.reloadPending = 'smear'; + assert.match(plain(renderGhosttyShaderPanel(state, 200)).join('\n'), /may still draw NMSh’s previous trail[\s\S]*L reloaded/u); + assert.deepEqual(ghosttyShaderKey(state, key('text', 'l')), {kind: 'reloaded'}); + const short = plain(renderGhosttyShaderPanel(state, 80, 12)); + assert.ok(short.at(-1)!.includes('Esc'), 'controls survive a short window'); + assert.ok(short.some(row => row.includes('Reload pending: press L')), 'a pending reload stays visible in a short window'); + assert.ok(short.length <= 12); +}); + +test('the review screen shows the exact plan and Esc changes nothing', () => { + const state = panel(); + state.confirmation = {plan: {host: 'ghostty', operation: 'save', hostPath: '/h', fragmentPath: '/f', preview: ['File: /h', '+ config-file = ?/f'], notes: [], guidance: 'Reload Ghostty’s configuration', + home: '/', changes: [], dependencies: [], shaders: [], effectiveShaders: []}, offset: 0}; + assert.match(plain(renderGhosttyShaderPanel(state, 120)).join('\n'), /Review Ghostty shader configuration[\s\S]*\+ config-file = \?\/f[\s\S]*Reload Ghostty/u); + assert.equal(ghosttyShaderKey(state, key('escape')), undefined); + assert.equal(state.confirmation, undefined); + assert.equal(state.message, 'Nothing was changed.'); +}); + +test('the /appearance hub offers Ghostty shaders only in Ghostty', () => { + const config = structuredClone(DEFAULT_PROMPT_CONFIGURATION); + const hub = createAppearanceHub('Ghostty'); + hub.ghosttyShaders = 'Off · Ghostty GPU effects'; + assert.match(plain(renderAppearanceHub(hub, config, 120, 'Lavender', 'Portable')).join('\n'), /Ghostty shaders\s+Off · Ghostty GPU effects/u); + for (let index = 0; index < 3; index++) appearanceHubKey(hub, key('down'), config); + assert.deepEqual(appearanceHubKey(hub, key('enter'), config), {kind: 'open', destination: 'glslShaders'}); + const other = createAppearanceHub('Terminal.app'); + assert.ok(!plain(renderAppearanceHub(other, config, 120, 'Lavender', 'Portable')).join('\n').includes('Ghostty shaders')); +}); + +test('app: /appearance › Ghostty shaders reviews, writes only after Enter, makes Ghostty own the trail, and Esc returns to the hub', async t => { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-gpanel-'))); + const saved = {...process.env}; + Object.assign(process.env, {HOME: home, XDG_CONFIG_HOME: join(home, '.config'), NMSH_CONFIG_HOME: join(home, '.config/nmsh'), TERM_PROGRAM: 'ghostty', TERM_PROGRAM_VERSION: '1.3.1', TERM: 'xterm-ghostty'}); + for (const name of ['KITTY_WINDOW_ID', 'KITTY_PID', '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 config = join(home, '.config/ghostty/config.ghostty'); + mkdirSync(join(home, '.config/ghostty'), {recursive: true, mode: 0o700}); + writeFileSync(config, 'font-size = 13\n', {mode: 0o600}); + app['promptConfiguration'].cursor.motion = 'tail'; + app['glslStudio'].open(); + const state = app['glslStudio'].panel!; + assert.ok(state.inspection && state.catalog?.supported, state.message); + select(state, 'Smear trail'); + app['onInput']('\r'); + app['onInput']('s'); + assert.ok(state.confirmation, state.message); + assert.equal(existsSync(join(home, '.config/nmsh/host-appearance/ghostty-shaders.conf')), false, 'review writes nothing'); + assert.equal(readFileSync(config, 'utf8'), 'font-size = 13\n'); + app['onInput']('\r'); + assert.equal(state.confirmation, undefined, state.message); + assert.match(readFileSync(join(home, '.config/nmsh/host-appearance/ghostty-shaders.conf'), 'utf8'), /^custom-shader = .*ghostty-shaders\/smear\.glsl$/mu); + assert.deepEqual(app['promptConfiguration'].hostShaders.ghostty.effects.map((effect: {id: string; enabled: boolean}) => [effect.id, effect.enabled]), [['smear', true]]); + // Ghostty now draws the trail: Portable does not draw a second one. + assert.equal(app['cursorFacts']().ghosttyTrail, 'smear'); + assert.equal(app['cursorBackend']().portableDraws.motion, false); + app['onInput']('\u001b[27u'); + await new Promise(resolve => setTimeout(resolve, 20)); + assert.equal(app['glslStudio'].panel, undefined); + assert.ok(app['appearanceHub']?.ghosttyShaders?.startsWith('1 effect chosen'), app['appearanceHub']?.ghosttyShaders); +}); + +test('controller: a reload record that cannot be saved still keeps Portable from drawing a second trail in this process', t => { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-gstudio-'))); + t.after(() => rmSync(home, {recursive: true, force: true})); + const env = {HOME: home, XDG_CONFIG_HOME: join(home, '.config'), NMSH_CONFIG_HOME: join(home, '.config/nmsh'), TERM_PROGRAM: 'ghostty', TERM_PROGRAM_VERSION: '1.3.1'}; + const configuration = structuredClone(DEFAULT_PROMPT_CONFIGURATION); + const studio = new GlslShaderStudio({configuration: () => configuration, update: () => {}, invalidateCursor: () => {}, notify: () => {}, render: () => {}, back: () => {}}, env, 'linux'); + // A directory where the record belongs: saving it fails. + mkdirSync(ghosttyShaderPaths({env, platform: 'linux'}).reload, {recursive: true}); + studio['noteTrailChange']('nmsh-cursor', 'smear'); + studio['noteTrailChange']('smear', undefined); + assert.equal(studio.trailFacts().pending, 'nmsh-cursor'); + const state = createGhosttyShaderPanel(structuredClone(DEFAULT_HOST_SHADERS.ghostty), gates); + state.catalog = buildGhosttyCatalog('1.3.1'); + state.reloadPending = 'nmsh-cursor'; + studio.panel = state; + rmSync(ghosttyShaderPaths({env, platform: 'linux'}).reload, {recursive: true}); + studio.key(key('text', 'l')); + assert.equal(studio.trailFacts().pending, undefined, 'L clears the in-memory record too'); +}); + +test('app: starting NMSh applies gate changes made while it was not running (Reduced Motion removes NMSh’s passes)', t => { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-gstart-'))); + const saved = {...process.env}; + Object.assign(process.env, {HOME: home, XDG_CONFIG_HOME: join(home, '.config'), NMSH_CONFIG_HOME: join(home, '.config/nmsh'), TERM_PROGRAM: 'ghostty', TERM_PROGRAM_VERSION: '1.3.1', TERM: 'xterm-ghostty'}); + for (const name of ['KITTY_WINDOW_ID', 'KITTY_PID', 'TMUX', 'NMSH_REDUCED_MOTION']) delete process.env[name]; + let app: TerminalApp | undefined; + t.after(() => { + if (app) { 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}); + }); + mkdirSync(join(home, '.config/ghostty'), {recursive: true, mode: 0o700}); + writeFileSync(join(home, '.config/ghostty/config.ghostty'), '', {mode: 0o600}); + const context = {env: process.env, platform: process.platform, catalog: buildGhosttyCatalog('1.3.1'), gates, launch: {}, colors: {cursorColors: {head: [1, 1, 1], tail: [0, 0, 0]}}}; + const planned = planGhosttyShaders({effects: [{id: 'vignette', enabled: true, values: {}}]}, context); + assert.ok(planned.ok && applyGhosttyShaderPlan(planned.plan).ok); + const fragment = ghosttyShaderPaths(context).fragment; + assert.match(readFileSync(fragment, 'utf8'), /vignette\.glsl/u); + process.env.NMSH_REDUCED_MOTION = '1'; + app = new TerminalApp(); + assert.doesNotMatch(readFileSync(fragment, 'utf8'), /^custom-shader =/mu, 'startup removed NMSh’s passes'); +}); diff --git a/tests/ghosttyShaders.test.ts b/tests/ghosttyShaders.test.ts new file mode 100644 index 00000000..8ab75160 --- /dev/null +++ b/tests/ghosttyShaders.test.ts @@ -0,0 +1,401 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join} from 'node:path'; +import {buildGhosttyCatalog, generateGhosttyShader, ghosttyCapability, ghosttyPrefix, GHOSTTY_PRESETS, GHOSTTY_UNIFORMS, paramError, presetById, requiredLevel, uniformsUsed} from '../src/appearance/ghosttyShaderCatalog.js'; +import {applyGhosttyShaderPlan, effectiveShaderList, gatedGhosttyEffects, ghosttyLaunchOverride, ghosttyReloadPending, ghosttyShaderPaths, inspectGhosttyLaunch, inspectGhosttyShaders, + noteGhosttyTrailChange, parseElapsed, planGhosttyShaderRemoval, planGhosttyShaders, setGhosttyReloadPending, syncGhosttyShaders, validateGhosttyEffects, writtenGhosttyTrail, + type GhosttyShaderContext, type GhosttyShaderGates} from '../src/appearance/ghosttyShaders.js'; +import {normalizeHostShaders, type GhosttyShaderSettings} from '../src/appearance/kittyShaderSettings.js'; +import {readHostConfiguration} from '../src/appearance/hostConfiguration.js'; +import {chooseBackend, hostCursorFacts, type HostCursorFacts} from '../src/cursor/backends.js'; +import {fragmentContent, fragmentPath as cursorFragmentPath, ghosttyCursorFragment, shaderPath as cursorShaderPath} from '../src/cursor/native.js'; +import {DEFAULT_CURSOR, type CursorSettings} from '../src/prompt/configuration.js'; + +const openGates: GhosttyShaderGates = {reducedMotion: false, effectsOff: false, cursorRenderer: 'auto', cursorMotion: 'tail'}; +const colors = {cursorColors: {head: [1, 0.5, 0], tail: [0, 0, 1]}}; + +function fixture(t: {after: (fn: () => void) => void}, options: {version?: string; gates?: GhosttyShaderGates; launch?: GhosttyShaderContext['launch']} = {}) { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-gshader-'))); + t.after(() => rmSync(home, {recursive: true, force: true})); + const context: GhosttyShaderContext = {env: {HOME: home, XDG_CONFIG_HOME: join(home, '.config'), NMSH_CONFIG_HOME: join(home, '.config/nmsh')}, platform: 'linux', + catalog: buildGhosttyCatalog(options.version ?? '1.3.1'), gates: options.gates ?? openGates, launch: options.launch ?? {}, colors}; + const put = (path: string, content: string) => { mkdirSync(dirname(path), {recursive: true, mode: 0o700}); writeFileSync(path, content, {mode: 0o600}); }; + const legacy = join(home, '.config/ghostty/config'); + const preferred = join(home, '.config/ghostty/config.ghostty'); + return {home, context, put, legacy, preferred, paths: ghosttyShaderPaths(context)}; +} +const settingsOf = (...effects: Array<[string, Record?, boolean?]>): GhosttyShaderSettings => + normalizeHostShaders({ghostty: {effects: effects.map(([id, values, enabled]) => ({id, enabled: enabled ?? true, values: values ?? {}}))}}).ghostty; +const effective = (context: GhosttyShaderContext) => effectiveShaderList(readHostConfiguration('ghostty', context), context.env.HOME!).entries.map(entry => entry.path); +const apply = (result: ReturnType) => { assert.ok(result.ok, result.ok ? '' : result.error); const applied = applyGhosttyShaderPlan(result.plan); assert.ok(applied.ok, applied.ok ? '' : applied.error); return result.plan; }; + +// ---- Version gates and uniforms ------------------------------------------------------------------------------------ + +test('version gates: unknown, too old and any unverified newer version fail closed; 1.2 offers trails only; 1.3 and 1.4 share one interface', () => { + assert.equal(ghosttyCapability(undefined).supported, false); + assert.match(ghosttyCapability(undefined).reason!, /did not report its version/u); + assert.equal(ghosttyCapability('1.1.3').supported, false); + assert.equal(ghosttyCapability('2.0.0').supported, false); + assert.deepEqual([ghosttyCapability('1.2.3').level, ghosttyCapability('1.3.1').level, ghosttyCapability('1.4.0').level], ['1.2', '1.3', '1.3']); + assert.equal(ghosttyCapability(undefined).hidden, true, 'no reported version is hidden, not known to be unsupported'); + assert.ok(ghosttyCapability('1.4.7').supported, 'a 1.4 patch release shares the verified interface'); + for (const newer of ['1.5.0', '2.0.0']) { + assert.equal(ghosttyCapability(newer).supported, false); + assert.equal(ghosttyCapability(newer).hidden, undefined); + assert.match(ghosttyCapability(newer).reason!, /newer than NMSh has verified \(through 1\.4\.x\)/u); + } + const old = buildGhosttyCatalog('1.2.0'); + assert.deepEqual(old.offered.map(item => item.definition.id).sort(), ['comet', 'glow', 'scanlines', 'smear', 'vignette']); + assert.deepEqual(old.notOffered, ['Aurora (Ghostty 1.3+)', 'Dim when unfocused (Ghostty 1.3+)']); + assert.deepEqual(buildGhosttyCatalog('1.3.1').offered.map(item => item.definition.id), buildGhosttyCatalog('1.4.0').offered.map(item => item.definition.id)); + assert.equal(buildGhosttyCatalog('1.3.1').offered.length, GHOSTTY_PRESETS.length); + assert.equal(buildGhosttyCatalog(undefined).offered.length, 0); +}); + +test('uniforms: the 1.2 interface lacks focus and terminal colors; comments never count; unprovided uniforms are refused', () => { + assert.ok(!ghosttyPrefix('1.2').includes('iFocus') && ghosttyPrefix('1.3').includes('uniform int iFocus;')); + assert.ok(ghosttyPrefix('1.3').includes('uniform vec3 iPalette[256];') && ghosttyPrefix('1.3').includes('#define CURSORSTYLE_BAR')); + assert.equal(GHOSTTY_UNIFORMS.filter(item => item.since === '1.3').length, 12); + assert.deepEqual(uniformsUsed('// iFocus\n/* iBackgroundColor */ float x = iTime;'), ['iTime']); + assert.deepEqual(requiredLevel('void f() { float a = iTime + float(iFocus); }'), {level: '1.3'}); + assert.deepEqual(requiredLevel('vec4 m = iMouse;'), {error: 'uses iMouse, which Ghostty does not provide'}); +}); + +// ---- Generation ------------------------------------------------------------------------------------------------------ + +test('every parameter really changes the generated GLSL; invalid values are refused, never clamped', () => { + for (const preset of GHOSTTY_PRESETS) { + const base = generateGhosttyShader(preset, {}, colors).source; + assert.match(base, /void mainImage\(out vec4 fragColor, in vec2 fragCoord\)/u); + assert.match(base, /GPL-3\.0-only/u); + for (const param of preset.params) { + const current = preset.defaults[param.name]!; + const other = param.kind === 'bool' ? !current : param.kind === 'color' ? [0.123, 0.456, 0.789] + : param.kind === 'choice' ? (current === 1 ? 2 : 1) : current === param.max ? param.min! : param.max!; + const changed = generateGhosttyShader(preset, {[param.name]: other}, colors).source; + // /cursor-sourced colors ignore the custom color constants' use but still emit them; the source still differs. + assert.notEqual(changed, base, `${preset.id}.${param.name} has no effect`); + if (param.kind === 'number') { + assert.throws(() => generateGhosttyShader(preset, {[param.name]: param.max! + 1}, colors), /must be between/u); + assert.throws(() => generateGhosttyShader(preset, {[param.name]: Number.NaN}, colors), /expects a number/u); + } + if (param.kind === 'color') assert.throws(() => generateGhosttyShader(preset, {[param.name]: [2, 0, 0]}, colors), /between 0 and 1/u); + } + } + assert.equal(paramError({name: 'S', label: 'S', kind: 'number', type: 'int', min: 2, max: 8, step: 1}, 2.5), 'expects a whole number'); + const smear = presetById('smear')!; + assert.match(generateGhosttyShader(smear, {COLOR_SOURCE: 0}, colors).source, /headColor\(\) \{ return vec3\(1\.0, 0\.5, 0\.0\); \}/u, '/cursor colors are compiled in'); + assert.match(generateGhosttyShader(smear, {COLOR_SOURCE: 1, HEAD: [0, 1, 0]}, colors).source, /headColor\(\) \{ return vec3\(0\.0, 1\.0, 0\.0\); \}/u); + assert.match(generateGhosttyShader(smear, {COLOR_SOURCE: 2}, colors).source, /return iCurrentCursorColor\.rgb;/u, 'Ghostty’s live cursor color'); + assert.throws(() => generateGhosttyShader(smear, {}, {cursorColors: {head: [1, 1], tail: [0, 0, 0]}}), /not valid RGB/u); +}); + +test('settings: unknown presets, foreign parameters, two trails and too many passes are reported', () => { + const catalog = buildGhosttyCatalog('1.2.0'); + const errors = validateGhosttyEffects(settingsOf(['smear'], ['comet'], ['aurora'], ['vignette', {NOPE: 1}], ['glow', {INTENSITY: 9}]), catalog); + assert.ok(errors.some(error => /cannot run together/u.test(error))); + assert.ok(errors.some(error => /aurora is not offered for Ghostty 1\.2\.0/u.test(error))); + assert.ok(errors.some(error => /NOPE is not a setting/u.test(error))); + assert.ok(errors.some(error => /Text glow: Intensity must be between/u.test(error))); + assert.ok(validateGhosttyEffects(settingsOf(['smear'], ['vignette'], ['scanlines'], ['glow'], ['aurora'], ['focus-dim'], ['comet', {}, false]), buildGhosttyCatalog('1.3.1')).length === 0); + const normalized = normalizeHostShaders({ghostty: {effects: [{id: 'BAD ID', enabled: true}, {id: 'smear', enabled: 'yes', values: {INTENSITY: '1'}}]}}); + assert.deepEqual(normalized.ghostty.effects, [{id: 'smear', enabled: false, values: {}}]); + assert.deepEqual(normalized.kitty, {effects: [], placement: 'after'}, 'Kitty settings are untouched'); +}); + +// ---- Ghostty's list rules ------------------------------------------------------------------------------------------- + +test('effective custom-shader list follows Ghostty: roots then includes, relative to each file, empty value clears, ? and "" handled', t => { + const {context, put, legacy, preferred, home} = fixture(t); + put(legacy, 'custom-shader = early.glsl\nconfig-file = inc/more.conf\n'); + put(preferred, 'custom-shader = ?"~/shaders/quoted.glsl"\ncustom-shader = ?\ncustom-shader = ""\n'); + put(join(home, '.config/ghostty/inc/more.conf'), 'custom-shader = rel.glsl\n'); + assert.deepEqual(effective(context), [join(home, '.config/ghostty/early.glsl'), join(home, 'shaders/quoted.glsl'), join(home, '.config/ghostty/inc/rel.glsl')]); + put(join(home, '.config/ghostty/inc/more.conf'), 'custom-shader =\ncustom-shader = /abs/after.glsl\n'); + const list = effectiveShaderList(readHostConfiguration('ghostty', context), home); + assert.deepEqual(list.entries.map(entry => entry.path), ['/abs/after.glsl'], 'an empty value clears everything before it'); + assert.equal(list.resets.length, 1); +}); + +// ---- Setup, preservation, removal ---------------------------------------------------------------------------------- + +test('setup keeps the person’s passes in order and adds NMSh’s after them; review is exact; re-planning is idempotent', t => { + const {context, put, preferred, home, paths} = fixture(t); + const original = 'font-size = 13\ncustom-shader = mine.glsl\ncustom-shader-animation = always'; + put(preferred, original); // no final newline on purpose + put(join(home, '.config/ghostty/mine.glsl'), 'void mainImage(out vec4 c, in vec2 p) { c = texture(iChannel0, p / iResolution.xy); }\n'); + const plan = apply(planGhosttyShaders(settingsOf(['vignette'], ['comet']), context)); + assert.deepEqual(plan.shaders.map(shader => shader.id), ['vignette', 'comet'], 'NMSh order is the studio order'); + assert.deepEqual(effective(context), [join(home, '.config/ghostty/mine.glsl'), join(paths.shaderDirectory, 'vignette.glsl'), join(paths.shaderDirectory, 'comet.glsl')]); + assert.match(plan.preview.join('\n'), /Kept, in order: mine\.glsl[\s\S]*Effective after this change: mine\.glsl · vignette\.glsl · comet\.glsl/u); + assert.match(plan.preview.join('\n'), /\+ config-file = \?.*ghostty-shaders\.conf/u); + assert.ok(readFileSync(preferred, 'utf8').startsWith(`${original}\n\n# NMSh Ghostty shaders`), 'only an append'); + assert.ok(!readFileSync(join(paths.directory, 'ghostty-shaders.conf'), 'utf8').includes('custom-shader-animation'), 'NMSh never sets the animation mode'); + assert.equal(readFileSync(join(paths.shaderDirectory, 'comet.glsl'), 'utf8'), plan.shaders[1]!.source); + const again = planGhosttyShaders(settingsOf(['vignette'], ['comet']), context); + assert.ok(again.ok); + assert.ok(again.plan.changes.every(change => change.after === change.before.content || change.before.path === paths.ledger), 'nothing new to write'); + const inspection = inspectGhosttyShaders(context); + assert.deepEqual([inspection.configured, inspection.activePresets, inspection.trail, inspection.animation], [true, ['vignette', 'comet'], 'comet', {value: 'always', source: 'person'}]); + assert.equal(writtenGhosttyTrail(context), 'comet'); + // Removal restores the exact original bytes (no final newline) and is not repeatable. + const removal = planGhosttyShaderRemoval(context); + assert.ok(removal.ok); + assert.ok(applyGhosttyShaderPlan(removal.plan).ok); + assert.equal(readFileSync(preferred, 'utf8'), original); + assert.ok(!existsSync(paths.ledger) && !existsSync(join(paths.shaderDirectory, 'comet.glsl')) && !existsSync(join(paths.directory, 'ghostty-shaders.conf'))); + assert.equal(planGhosttyShaderRemoval(context).ok, false); + assert.equal(writtenGhosttyTrail(context), undefined); +}); + +test('a created config is deleted again on removal; CRLF configs keep CRLF; a user line appended later survives removal', t => { + const {context, put, preferred} = fixture(t); + apply(planGhosttyShaders(settingsOf(['scanlines']), context)); + assert.ok(existsSync(preferred)); + assert.ok(applyGhosttyShaderPlan((planGhosttyShaderRemoval(context) as {ok: true; plan: never}).plan).ok); + assert.ok(!existsSync(preferred), 'NMSh created it, so removal deletes it'); + put(preferred, 'font-size = 12\r\n'); + apply(planGhosttyShaders(settingsOf(['scanlines']), context)); + assert.match(readFileSync(preferred, 'utf8'), /\r\n# NMSh Ghostty shaders \(remove using \/appearance\)\r\nconfig-file = \?/u); + writeFileSync(preferred, `${readFileSync(preferred, 'utf8')}theme = dark\r\n`); + assert.ok(applyGhosttyShaderPlan((planGhosttyShaderRemoval(context) as {ok: true; plan: never}).plan).ok); + assert.equal(readFileSync(preferred, 'utf8'), 'font-size = 12\r\ntheme = dark\r\n'); +}); + +test('refusals: a later clearing line, a person’s cursor shader, an unreadable shader for a trail, unowned and edited NMSh files', t => { + const {context, put, preferred, home, paths} = fixture(t); + put(preferred, 'config-file = later.conf\n'); + put(join(home, '.config/ghostty/later.conf'), 'custom-shader =\n'); + // NMSh's include is queued after later.conf, so its passes survive; a clear in an include queued AFTER NMSh's would not. + const fine = planGhosttyShaders(settingsOf(['vignette']), context); + assert.ok(fine.ok, fine.ok ? '' : fine.error); + assert.match(fine.plan.preview.join('\n'), /custom-shader =` line at .*later\.conf:1 clears earlier passes/u); + apply(fine); + writeFileSync(preferred, `${readFileSync(preferred, 'utf8')}config-file = last.conf\n`); + put(join(home, '.config/ghostty/last.conf'), 'custom-shader =\n'); + const dropped = planGhosttyShaders(settingsOf(['vignette', {INTENSITY: 0.5}]), context); + assert.ok(!dropped.ok && /clears the list/u.test(dropped.error)); + assert.equal(syncGhosttyShaders(settingsOf(['vignette', {INTENSITY: 0.5}]), context).kind, 'review', 'sync refuses too'); + + const second = fixture(t); + second.put(second.preferred, 'custom-shader = trail.glsl\n'); + second.put(join(second.home, '.config/ghostty/trail.glsl'), '// a cursor trail\nvoid mainImage(out vec4 c, in vec2 p) { c = vec4(iCurrentCursor.x); }\n'); + const twoTrails = planGhosttyShaders(settingsOf(['smear']), second.context); + assert.ok(!twoTrails.ok && /reads Ghostty’s cursor uniforms/u.test(twoTrails.error)); + assert.ok(planGhosttyShaders(settingsOf(['glow']), second.context).ok, 'non-trail effects are fine'); + + const third = fixture(t); + const outside = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-gshader-out-'))); + t.after(() => rmSync(outside, {recursive: true, force: true})); + writeFileSync(join(outside, 'x.glsl'), 'void mainImage(out vec4 c, in vec2 p) { c = vec4(0.0); }\n'); + mkdirSync(join(third.home, '.config/ghostty'), {recursive: true}); + symlinkSync(join(outside, 'x.glsl'), join(third.home, '.config/ghostty/link.glsl')); + third.put(third.preferred, 'custom-shader = link.glsl\n'); + const unknown = planGhosttyShaders(settingsOf(['comet']), third.context); + assert.ok(!unknown.ok && /cannot rule out a second cursor trail/u.test(unknown.error)); + assert.ok(planGhosttyShaders(settingsOf(['vignette']), third.context).ok); + + const fourth = fixture(t); + fourth.put(join(fourth.paths.shaderDirectory, 'glow.glsl'), '// someone else\n'); + const unowned = planGhosttyShaders(settingsOf(['glow']), fourth.context); + assert.ok(!unowned.ok && /not recorded as NMSh’s/u.test(unowned.error)); + void paths; + + const fifth = fixture(t); + apply(planGhosttyShaders(settingsOf(['glow']), fifth.context)); + writeFileSync(join(fifth.paths.shaderDirectory, 'glow.glsl'), '// edited by hand\n'); + const edited = planGhosttyShaders(settingsOf(['glow', {RADIUS: 3}]), fifth.context); + assert.ok(!edited.ok && /edited outside NMSh; it was preserved/u.test(edited.error)); + // Removal still works and keeps the hand-edited file. + const removal = planGhosttyShaderRemoval(fifth.context); + assert.ok(removal.ok && removal.plan.notes.some(note => /edited outside NMSh; it is kept/u.test(note))); + assert.ok(applyGhosttyShaderPlan(removal.plan).ok); + assert.equal(readFileSync(join(fifth.paths.shaderDirectory, 'glow.glsl'), 'utf8'), '// edited by hand\n'); +}); + +test('a configuration edited between review and confirmation is not overwritten; nothing is half-written', t => { + const {context, put, preferred, paths} = fixture(t); + put(preferred, 'font-size = 12\n'); + const plan = planGhosttyShaders(settingsOf(['vignette']), context); + assert.ok(plan.ok); + writeFileSync(preferred, 'font-size = 14\n'); + const applied = applyGhosttyShaderPlan(plan.plan); + assert.ok(!applied.ok && /changed since the preview/u.test(applied.error)); + assert.equal(readFileSync(preferred, 'utf8'), 'font-size = 14\n'); + assert.ok(!existsSync(paths.ledger) && !existsSync(join(paths.shaderDirectory, 'vignette.glsl'))); +}); + +test('launch options that change shaders or config refuse; Ghostty is found among parents; elapsed time parses', t => { + assert.equal(parseElapsed('05:07'), 307); + assert.equal(parseElapsed('2-01:00:01'), 2 * 86400 + 3601); + assert.equal(parseElapsed('bad'), undefined); + assert.match(ghosttyLaunchOverride(' --custom-shader=/x.glsl')!, /--custom-shader/u); + assert.match(ghosttyLaunchOverride(' --config-default-files=false')!, /config-default-files/u); + assert.equal(ghosttyLaunchOverride(' --font-size=13'), undefined); + const rows = [{pid: 50, ppid: 40, elapsedSeconds: 5, command: 'node nmsh'}, {pid: 40, ppid: 30, elapsedSeconds: 6, command: '/usr/bin/login -flp me /bin/zsh'}, + {pid: 30, ppid: 1, elapsedSeconds: 100, command: '/Applications/Ghostty.app/Contents/MacOS/ghostty --custom-shader=/tmp/x.glsl'}]; + const found = inspectGhosttyLaunch(rows, 50, 1_000_000); + assert.equal(found.startedAt, 900_000); + assert.match(found.refusal!, /--custom-shader/u); + assert.equal(inspectGhosttyLaunch([{...rows[2]!, command: '/Applications/Ghostty.app/Contents/MacOS/ghostty'}, rows[1]!, rows[0]!], 50).refusal, undefined); + assert.match(inspectGhosttyLaunch(rows.slice(0, 2), 50).note!, /could not find Ghostty/u); + const {context, put, preferred} = fixture(t, {launch: found}); + put(preferred, ''); + const refused = planGhosttyShaders(settingsOf(['vignette']), context); + assert.ok(!refused.ok && /--custom-shader/u.test(refused.error)); +}); + +// ---- Gates, Portable, reload -------------------------------------------------------------------------------------- + +test('Reduced Motion, Effects Off, a Portable cursor and Smooth motion: nothing new is written and sync removes only NMSh passes', t => { + const {context, put, preferred, paths, home} = fixture(t); + const mine = join(home, 'mine.glsl'); + put(mine, 'void mainImage(out vec4 c, in vec2 p) { c = texture(iChannel0, p / iResolution.xy); }\n'); + put(preferred, `custom-shader = ${mine}\n`); + for (const gates of [{...openGates, reducedMotion: true}, {...openGates, effectsOff: true}]) { + const refused = planGhosttyShaders(settingsOf(['vignette']), {...context, gates}); + assert.ok(!refused.ok && /writes no Ghostty effects/u.test(refused.error)); + } + const portable = planGhosttyShaders(settingsOf(['smear']), {...context, gates: {...openGates, cursorRenderer: 'portable'}}); + assert.ok(!portable.ok && /Portable, so NMSh draws the trail itself/u.test(portable.error)); + assert.equal(gatedGhosttyEffects(settingsOf(['smear']), context.catalog, {...openGates, cursorMotion: 'smooth'}).length, 0); + // A shader outside the home is not read, so a trail beside it fails closed; other effects are fine. + const outside = fixture(t); + outside.put(outside.preferred, 'custom-shader = /abs/elsewhere.glsl\n'); + const blocked = planGhosttyShaders(settingsOf(['smear']), outside.context); + assert.ok(!blocked.ok && /outside your home[\s\S]*cannot rule out a second cursor trail/u.test(blocked.error)); + assert.ok(planGhosttyShaders(settingsOf(['vignette']), outside.context).ok); + apply(planGhosttyShaders(settingsOf(['vignette'], ['smear']), context)); + // Portable: the trail pass goes, the vignette stays. + const toPortable = syncGhosttyShaders(settingsOf(['vignette'], ['smear']), {...context, gates: {...openGates, cursorRenderer: 'portable'}}); + assert.ok(toPortable.kind === 'written' && toPortable.trailBefore === 'smear' && toPortable.trailAfter === undefined); + assert.deepEqual(effective(context), [mine, join(paths.shaderDirectory, 'vignette.glsl')]); + assert.ok(!existsSync(join(paths.shaderDirectory, 'smear.glsl')), 'the stopped pass file is removed'); + assert.equal(writtenGhosttyTrail(context), undefined); + // Reduced Motion: every NMSh pass goes; the person's stays. + const reduced = syncGhosttyShaders(settingsOf(['vignette'], ['smear']), {...context, gates: {...openGates, reducedMotion: true}}); + assert.equal(reduced.kind, 'written'); + assert.deepEqual(effective(context), [mine]); + assert.equal(syncGhosttyShaders(settingsOf(['vignette'], ['smear']), {...context, gates: {...openGates, reducedMotion: true}}).kind, 'unchanged', 'idempotent'); + // Back on: the reviewed passes return; inside tmux (unknown version) the recorded version is used rather than stripping. + const back = syncGhosttyShaders(settingsOf(['vignette'], ['smear']), {...context, catalog: buildGhosttyCatalog(undefined)}); + assert.ok(back.kind === 'written' && back.trailAfter === 'smear', JSON.stringify(back)); + assert.equal(writtenGhosttyTrail(context), 'smear'); + // A running Ghostty KNOWN to be unsupported is not overridden by the recorded version: NMSh's passes come out, saying why. + const newer = syncGhosttyShaders(settingsOf(['vignette'], ['smear']), {...context, catalog: buildGhosttyCatalog('1.5.0')}); + assert.ok(newer.kind === 'written' && newer.trailBefore === 'smear' && /effects are off \(Ghostty 1\.5\.0 is newer than NMSh has verified/u.test(newer.message), JSON.stringify(newer)); + assert.deepEqual(effective(context), [mine]); +}); + +test('reload: a trail Ghostty may still draw stays pending until confirmed or Ghostty restarts; Portable never draws a second trail', t => { + const {context} = fixture(t); + assert.equal(noteGhosttyTrailChange(context, 'smear', 'smear'), undefined, 'unchanged trail'); + assert.equal(noteGhosttyTrailChange(context, undefined, 'comet'), undefined, 'adding a trail: Ghostty draws none yet, Portable already stopped'); + assert.equal(noteGhosttyTrailChange(context, 'smear', undefined, 1000), 'smear'); + assert.equal(ghosttyReloadPending(context), 'smear'); + assert.equal(ghosttyReloadPending(context, {startedAt: 500}), 'smear', 'Ghostty started before the change'); + assert.equal(ghosttyReloadPending(context, {startedAt: 2000}), undefined, 'a Ghostty started after the change loaded it'); + const facts: HostCursorFacts = {host: 'ghostty', hostName: 'Ghostty', caretColor: 'host-controlled', version: '1.3.1', integrated: false, ghosttyTrailPending: 'smear'}; + const settings = (value: Partial): CursorSettings => ({...structuredClone(DEFAULT_CURSOR), ...value}); + const portable = chooseBackend(settings({renderer: 'portable', motion: 'tail', effect: 'fire'}), facts); + assert.deepEqual([portable.portableDraws.motion, portable.portableDraws.effect], [false, true], 'a studio trail drew no effects, so Fire still draws'); + assert.match(portable.reason, /waits for a Ghostty config reload/u); + const legacy = chooseBackend(settings({renderer: 'portable', motion: 'tail', effect: 'fire'}), {...facts, ghosttyTrailPending: 'nmsh-cursor'}); + assert.equal(legacy.portableDraws.effect, false, 'the older /cursor shader drew Fire'); + setGhosttyReloadPending(context, undefined); + assert.equal(ghosttyReloadPending(context), undefined); + assert.equal(chooseBackend(settings({renderer: 'portable', motion: 'tail'}), {...facts, ghosttyTrailPending: undefined}).portableDraws.motion, true); + // Successive changes before a reload accumulate: legacy → Smear → Comet keeps the older /cursor shader (and its effects) pending. + assert.equal(noteGhosttyTrailChange(context, 'nmsh-cursor', 'smear', 3000), 'nmsh-cursor'); + assert.equal(noteGhosttyTrailChange(context, 'smear', 'comet', 4000), 'nmsh-cursor'); + assert.equal(ghosttyReloadPending(context), 'nmsh-cursor'); + assert.deepEqual(JSON.parse(readFileSync(ghosttyShaderPaths(context).reload, 'utf8')).previous, ['nmsh-cursor', 'smear']); + assert.equal(noteGhosttyTrailChange(context, 'comet', 'smear', 5000), 'nmsh-cursor', 'the trail now written is not itself pending'); + assert.deepEqual(JSON.parse(readFileSync(ghosttyShaderPaths(context).reload, 'utf8')).previous, ['nmsh-cursor', 'comet']); + setGhosttyReloadPending(context, undefined); + // A trail still written although Portable was chosen (its sync was refused): Portable still draws no second trail. + const stale = chooseBackend(settings({renderer: 'portable', motion: 'tail', effect: 'fire'}), {...facts, ghosttyTrailPending: undefined, ghosttyTrail: 'smear'}); + assert.deepEqual([stale.portableDraws.motion, stale.portableDraws.effect], [false, true]); + assert.match(stale.reason, /still lists NMSh’s trail/u); +}); + +test('cursor: a written studio trail makes Ghostty native draw the trail and Portable the effects; facts come from the files', t => { + const {context, put, preferred} = fixture(t); + put(preferred, ''); + apply(planGhosttyShaders(settingsOf(['comet']), context)); + const facts = hostCursorFacts({TERM_PROGRAM: 'ghostty', TERM_PROGRAM_VERSION: '1.3.1'}, () => false, () => undefined, () => ({trail: writtenGhosttyTrail(context)})); + assert.equal(facts.ghosttyTrail, 'comet'); + const choice = chooseBackend({...structuredClone(DEFAULT_CURSOR), renderer: 'auto', motion: 'tail', effect: 'ripple'}, facts); + assert.deepEqual([choice.nativeHandles.motion, choice.portableDraws.motion, choice.portableDraws.effect], [true, false, true]); + assert.match(choice.reason, /Comet trail shader with Portable fallback/u); + // A hand-removed hook means Ghostty no longer loads the trail: Portable draws it again. + writeFileSync(preferred, ''); + assert.equal(writtenGhosttyTrail(context), undefined); +}); + +test('NMSh’s older /cursor Ghostty shader pauses while a studio trail is written and never stacks a second trail', t => { + const {context, put, preferred, home} = fixture(t); + const legacyFragment = cursorFragmentPath('ghostty', context.env); + put(legacyFragment, ghosttyCursorFragment(true, context.env)); + put(cursorShaderPath(context.env), 'void mainImage(out vec4 c, in vec2 p) { c = vec4(iCurrentCursor.x); }\n'); + put(preferred, `config-file = ?${legacyFragment}\n`); + assert.equal(inspectGhosttyShaders(context).cursorShaderActive, true); + const plan = apply(planGhosttyShaders(settingsOf(['smear']), context)); + assert.equal(plan.trailBefore, 'nmsh-cursor'); + assert.equal(plan.trailAfter, 'smear'); + assert.match(plan.preview.join('\n'), /older \/cursor Ghostty shader pauses/u); + assert.equal(readFileSync(legacyFragment, 'utf8'), ghosttyCursorFragment(false, context.env)); + assert.deepEqual(effective(context), [join(ghosttyShaderPaths(context).shaderDirectory, 'smear.glsl')], 'exactly one trail pass'); + // /cursor's writer keeps it paused while the studio trail is written. + const cursor = {...structuredClone(DEFAULT_CURSOR), renderer: 'auto' as const, motion: 'tail' as const}; + assert.equal(fragmentContent('ghostty', cursor, context.env, true), ghosttyCursorFragment(false, context.env)); + assert.equal(fragmentContent('ghostty', cursor, context.env, false), ghosttyCursorFragment(true, context.env)); + // An edited /cursor file is not overwritten. + const other = fixture(t); + const otherFragment = cursorFragmentPath('ghostty', other.context.env); + other.put(otherFragment, `${ghosttyCursorFragment(true, other.context.env)}# mine\n`); + other.put(other.preferred, `config-file = ?${otherFragment}\n`); + const refused = planGhosttyShaders(settingsOf(['smear']), other.context); + assert.ok(!refused.ok && /\/cursor Ghostty file was edited/u.test(refused.error)); + // Removal resumes the paused /cursor shader inside the same reviewed transaction, only when /cursor still wants it. + assert.equal(JSON.parse(readFileSync(ghosttyShaderPaths(context).ledger, 'utf8')).legacyPaused, true); + const keepPaused = planGhosttyShaderRemoval(context, {legacyActive: false}); + assert.ok(keepPaused.ok && !keepPaused.plan.preview.join('\n').includes('resumes')); + const removal = planGhosttyShaderRemoval(context, {legacyActive: true}); + assert.ok(removal.ok && /older \/cursor shader resumes/u.test(removal.plan.preview.join('\n')), removal.ok ? removal.plan.preview.join('\n') : removal.error); + assert.ok(applyGhosttyShaderPlan(removal.plan).ok); + assert.equal(readFileSync(legacyFragment, 'utf8'), ghosttyCursorFragment(true, context.env)); + assert.deepEqual(effective(context), [cursorShaderPath(context.env)], 'the one trail is the older /cursor shader again'); + // A paused file the person edited is left alone and the review says so. + const edited = fixture(t); + const editedFragment = cursorFragmentPath('ghostty', edited.context.env); + edited.put(editedFragment, ghosttyCursorFragment(true, edited.context.env)); + edited.put(cursorShaderPath(edited.context.env), 'void mainImage(out vec4 c, in vec2 p) { c = vec4(iCurrentCursor.x); }\n'); + edited.put(edited.preferred, `config-file = ?${editedFragment}\n`); + apply(planGhosttyShaders(settingsOf(['smear']), edited.context)); + writeFileSync(editedFragment, `${ghosttyCursorFragment(false, edited.context.env)}# mine\n`); + const careful = planGhosttyShaderRemoval(edited.context, {legacyActive: true}); + assert.ok(careful.ok && /changed while paused; it is left as it is/u.test(careful.plan.preview.join('\n'))); + assert.ok(applyGhosttyShaderPlan(careful.plan).ok); + assert.equal(readFileSync(editedFragment, 'utf8'), `${ghosttyCursorFragment(false, edited.context.env)}# mine\n`); + void home; +}); + +test('animation: the person’s custom-shader-animation is read, never written; false is disclosed for animated effects', t => { + const {context, put, preferred} = fixture(t); + put(preferred, 'custom-shader-animation = false\n'); + const plan = planGhosttyShaders(settingsOf(['aurora'], ['smear']), context); + assert.ok(plan.ok); + assert.ok(plan.plan.notes.some(note => /yours is false, so it only moves when the terminal redraws/u.test(note))); + assert.ok(plan.plan.notes.some(note => /may not fade smoothly/u.test(note))); + assert.ok(!plan.plan.shaders.some(shader => shader.source.includes('custom-shader-animation'))); + assert.ok(plan.plan.notes.some(note => /redraws only when the terminal updates \(your custom-shader-animation is false\)/u.test(note))); + // Ghostty runs its animation loop whenever any custom shader is listed, even a still effect: disclosed for every preset. + const still = fixture(t); + still.put(still.preferred, ''); + const vignette = planGhosttyShaders(settingsOf(['vignette']), still.context); + assert.ok(vignette.ok && vignette.plan.notes.some(note => /runs its animation loop on the focused surface \(custom-shader-animation true, the default\)/u.test(note))); + assert.ok(!presetById('smear')!.note!.includes('Redraws only')); +}); diff --git a/tests/ghosttyShadersLive.test.ts b/tests/ghosttyShadersLive.test.ts new file mode 100644 index 00000000..ed6e2e24 --- /dev/null +++ b/tests/ghosttyShadersLive.test.ts @@ -0,0 +1,95 @@ +/** + * Installed-Ghostty checks (each skipped when its tool is absent), all in a private HOME and config: + * 1. NMSh's uniform table matches the shader prefix compiled into each available Ghostty binary + * (installed app, plus extra binaries in NMSH_TEST_GHOSTTY_BINARIES, colon-separated). + * 2. NMSh's composed configuration passes Ghostty's own `+validate-config --config-file` (which loads only + * that file and its includes; it never reads the real configuration). + * 3. Every offered preset compiles with the GLSL front end of Kitty's bundled slangc against an equivalent + * prefix; a 1.3 preset fails against the 1.2 prefix (the version gate is real). + * Nothing launches a Ghostty window or touches an installed app or real config. Compiling is a PROXY: Ghostty + * itself uses glslang and SPIRV-Cross/Metal on its render thread, and neither proves GPU output. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join} from 'node:path'; +import {buildGhosttyCatalog, generateGhosttyShader, ghosttyCapability, ghosttyPrefix, GHOSTTY_UNIFORMS, presetById} from '../src/appearance/ghosttyShaderCatalog.js'; +import {applyGhosttyShaderPlan, ghosttyShaderPaths, planGhosttyShaders, type GhosttyShaderContext} from '../src/appearance/ghosttyShaders.js'; +import {normalizeHostShaders} from '../src/appearance/kittyShaderSettings.js'; + +const binaries = ['/Applications/Ghostty.app/Contents/MacOS/ghostty', ...(process.env.NMSH_TEST_GHOSTTY_BINARIES ?? '').split(':').filter(Boolean)].filter(path => existsSync(path)); +const slangc = ['/Applications/kitty.app/Contents/MacOS/slangc'].find(path => existsSync(path)); +const colors = {cursorColors: {head: [1, 0.6, 0.2], tail: [0.2, 0.3, 1]}}; + +function privateHome(t: {after: (fn: () => void) => void}) { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-gshader-live-'))); + t.after(() => rmSync(home, {recursive: true, force: true})); + return home; +} +const versionOf = (binary: string, home: string) => /Ghostty (\d+\.\d+\.\d+)/u.exec(spawnSync(binary, ['+version'], {encoding: 'utf8', timeout: 20_000, env: {...process.env, HOME: home}}).stdout ?? '')?.[1]; + +/** The uniform names in the binary's compiled-in shadertoy prefix (the `Globals` block). */ +function binaryUniforms(binary: string): string[] { + const text = readFileSync(binary).toString('latin1'); + const start = text.indexOf('uniform Globals {'); + assert.ok(start >= 0, 'shader prefix not found in the binary'); + const end = text.indexOf('};', start); + assert.ok(end > start && end - start < 4096, 'bounded Globals block'); + const block = text.slice(start, end); + return [...block.matchAll(/uniform\s+\w+\s+(i[A-Z]\w*)/gu)].map(match => match[1]!); +} + +for (const binary of binaries) { + test(`Ghostty binary ${binary}: its shader uniforms are exactly NMSh's table for its version`, t => { + const home = privateHome(t); + const version = versionOf(binary, home); + assert.ok(version, 'version'); + const capability = ghosttyCapability(version); + assert.ok(capability.supported && capability.level, `NMSh supports Ghostty ${version}`); + const expected = GHOSTTY_UNIFORMS.filter(item => capability.level === '1.3' || item.since === '1.2').map(item => item.name); + assert.deepEqual(binaryUniforms(binary), expected); + }); + + test(`Ghostty binary ${binary}: +validate-config accepts NMSh's composed configuration (private files only)`, t => { + const home = privateHome(t); + const version = versionOf(binary, home)!; + const context: GhosttyShaderContext = {env: {HOME: home, XDG_CONFIG_HOME: join(home, '.config'), NMSH_CONFIG_HOME: join(home, '.config/nmsh')}, platform: process.platform, + catalog: buildGhosttyCatalog(version), gates: {reducedMotion: false, effectsOff: false, cursorRenderer: 'auto', cursorMotion: 'tail'}, launch: {}, colors}; + const config = join(home, '.config/ghostty/config.ghostty'); + mkdirSync(dirname(config), {recursive: true, mode: 0o700}); + writeFileSync(config, 'font-size = 13\ncustom-shader = mine.glsl\n', {mode: 0o600}); + writeFileSync(join(dirname(config), 'mine.glsl'), 'void mainImage(out vec4 c, in vec2 p) { c = texture(iChannel0, p / iResolution.xy); }\n'); + const effects = buildGhosttyCatalog(version).offered.filter(item => item.definition.id !== 'comet').map(item => ({id: item.definition.id, enabled: true, values: {}})); + const planned = planGhosttyShaders(normalizeHostShaders({ghostty: {effects}}).ghostty, context); + assert.ok(planned.ok, planned.ok ? '' : planned.error); + assert.ok(applyGhosttyShaderPlan(planned.plan).ok); + const run = spawnSync(binary, ['+validate-config', `--config-file=${config}`], {encoding: 'utf8', timeout: 30_000, env: {...process.env, HOME: home, XDG_CONFIG_HOME: join(home, '.config')}}); + const output = `${run.stdout}${run.stderr}`.split('\n').filter(line => line.trim() && !/sentry/iu.test(line)); + assert.equal(run.status, 0, output.join('\n')); + assert.deepEqual(output, []); + assert.ok(existsSync(join(ghosttyShaderPaths(context).shaderDirectory, 'vignette.glsl'))); + }); +} + +test('every offered preset compiles as GLSL 430 (slangc proxy); a 1.3 preset does not compile against 1.2', {skip: slangc ? false : 'Kitty’s bundled slangc is not installed'}, t => { + const dir = privateHome(t); + const compile = (name: string, level: '1.2' | '1.3', source: string) => { + // slangc's GLSL mode rejects re-declaring the built-in gl_FragCoord; Ghostty's own compiler accepts it. Nothing else changes. + const file = join(dir, `${name}.glsl`); + writeFileSync(file, ghosttyPrefix(level).replace('layout(location = 0) in vec4 gl_FragCoord;\n', '') + source); + return spawnSync(slangc!, ['-lang', 'glsl', '-allow-glsl', '-stage', 'fragment', '-entry', 'main', '-target', 'spirv', '-o', join(dir, `${name}.spv`), file], + {encoding: 'utf8', timeout: 60_000, env: {...process.env, HOME: dir}}); + }; + for (const preset of buildGhosttyCatalog('1.4.0').offered) { + for (const values of [{}, ...(preset.params.some(param => param.name === 'COLOR_SOURCE') ? [{COLOR_SOURCE: 2}] : []), + Object.fromEntries(preset.params.filter(param => param.kind === 'number').map(param => [param.name, param.max!]))]) { + const shader = generateGhosttyShader(preset.definition, values, colors); + const result = compile(`${preset.definition.id}-${Object.keys(values).length}`, shader.level, shader.source); + assert.equal(result.status, 0, `${preset.definition.id} ${JSON.stringify(values)}: ${result.stdout}${result.stderr}`); + } + } + const aurora = generateGhosttyShader(presetById('aurora')!, {}, colors); + assert.notEqual(compile('aurora-on-1.2', '1.2', aurora.source).status, 0, 'iBackgroundColor does not exist in the 1.2 interface'); +});