diff --git a/docs/development/native-appearance-welcome/DECISIONS.md b/docs/development/native-appearance-welcome/DECISIONS.md index e121d3b2..7520c1b6 100644 --- a/docs/development/native-appearance-welcome/DECISIONS.md +++ b/docs/development/native-appearance-welcome/DECISIONS.md @@ -68,3 +68,12 @@ Ruling: preserve valid unchanged numeric blur above the normal UI range during u - Assets: absolute, cwd-relative and `~/` paths plus a local picker filtered to PNG/JPEG/WebP/GIF/APNG/.ans/.txt. Admission before decode: ≤8 MiB, ≤2,000,000 source pixels; GIF/APNG through the bounded decoder (#518: 75 frames, 32 MiB, 5 s). ANSI/ASCII ≤64 KiB, 40 rows, 240 columns, printable text + numeric SGR only. Originals are never modified; no persistent derived cache. External capture sanitization unchanged; one capture per provider, shared. - One composition: `composeWelcome` (pure) drives the editor preview, startup and the P preview through `NativeWelcomePresentation`/`paintWelcome`; one `WelcomeSequencer` on the shared PresentationClock (≤5 s, ≤~30 repaints/s at startup); Reduced Motion / Effects Off / NO_COLOR give frame 0 with no subscription. Still-image effects are whole-image Portable treatments (no segmentation or invented motion). - Startup playback never blocks the composer; input, resize, scroll, handover and shutdown stop it and release graphics by owner; a loading image says "Loading image…" and stores no still. P shows the current draft on NMSh's own alternate screen (no OS resize, no new session, no re-capture); any key closes it and is swallowed; draft and selection return unchanged. + +## Native host fixes (2026-10-29) + +- Host identity: one `terminalProfile()` for every surface. A terminal-specific `TERM` (`xterm-kitty`, `xterm-ghostty`) wins over `TERM_PROGRAM`. Cursor facts and `detectTerminalHost` use it. Inside tmux, the outer host's markers still name the host, as before. +- First run: `/cursor › Host native setup` in Kitty 0.49+ opens the Kitty studio even without the older include. Kitty below 0.49 keeps the built-in `cursor_trail` setup. +- A Kitty trail that is written must also be drawn. NMSh's own fragment sets `cursor_trail 3` when the effective value would otherwise be 0. This is judged without NMSh's own line, so later syncs keep it. A later line forcing 0 refuses. Confirming a trail sets `/cursor` Motion Off → Tail and Portable → Auto, and the review says so. Motion Off removes NMSh's trail, like a Portable renderer. +- Reload: after a confirmed write, a removal or a gate sync, NMSh sends Kitty's documented SIGUSR1 to `KITTY_PID`, but only when that process is one of NMSh's parents and its program is kitty. Otherwise it shows the reload keys. The review discloses this before writing. Ghostty is unchanged: it is never signalled, and L marks the reload. +- Host native chosen before setup: Portable draws, labelled "Portable fallback · native is not set up yet". Before this, the cursor looked frozen. A host with no native effects keeps "Host native is not available". +- Previews: each trail style has its own shape (Blaze band, Lightning bolt and forks, Motion-blur ghosts, Comet taper, plain tail). diff --git a/docs/development/native-appearance-welcome/QA_CHECKLIST.md b/docs/development/native-appearance-welcome/QA_CHECKLIST.md index a97a6920..b18a0095 100644 --- a/docs/development/native-appearance-welcome/QA_CHECKLIST.md +++ b/docs/development/native-appearance-welcome/QA_CHECKLIST.md @@ -17,6 +17,7 @@ Automated tests use private HOMEs, headless host parsers and proxy compiles. Non ## Kitty (0.49.x) ### Kitty shaders +0. First run: in a Kitty opened from a Ghostty or Zed shell, `/appearance` shows "Kitty shaders" (not Ghostty), and `/cursor` › Host native setup opens the Kitty studio. Switch Lightning ↔ Blaze: the bolt vs the amber band changes without a manual reload ("Kitty was asked to reload…"). 1. Open `/appearance` → Kitty shaders. Enable Lightning trail, Northern Lights, then CRT or CRT (blue) (only one can be on), and Bloom or Water. 2. Check the review diff. Your `custom_shaders` names stay in order, and placement is Before/After. 3. Kitty auto-reloads. Check that every effect is visible and that there is exactly one cursor trail. diff --git a/docs/development/native-appearance-welcome/RESEARCH.md b/docs/development/native-appearance-welcome/RESEARCH.md index f6f67869..cb175c2c 100644 --- a/docs/development/native-appearance-welcome/RESEARCH.md +++ b/docs/development/native-appearance-welcome/RESEARCH.md @@ -64,3 +64,12 @@ Kitty0.49.2 installed catalog probe (headless, private HOME): read_kitty_resourc - 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. + +## Native host fixes (verified 2026-10-29, Kitty 0.49.2, Ghostty 1.3.1; evidence /Volumes/T7Tmp/ai-dev/nmsh-native-host-fixes-20261029) + +- Host identity: Kitty sets `TERM=xterm-kitty`, `KITTY_WINDOW_ID`, `KITTY_PID` and passes an inherited `TERM_PROGRAM` through unchanged. A Kitty started from a Ghostty shell reported `TERM_PROGRAM=ghostty`, `TERM_PROGRAM_VERSION=1.3.1` and `GHOSTTY_RESOURCES_DIR` (kitty-env/env.txt), so NMSh treated it as Ghostty. The terminal's own `TERM` is the stronger signal. +- Shipped shaders read through `read_kitty_resource` (kitty-probe/out): Blaze is a filled amber parallelogram with a red edge (0.5 s). Lightning is a 12-segment jagged bolt with 3 forks (0.14 s, bolt #ffd1b8, glow #bd0000). Motion blur and Default differ again. The four pipelines share one structure (cursor-trail-move/stop); NMSh's emitted pipelines referenced the right shader. +- Kitty draws trail shaders only while `cursor_trail` > 0. `/cursor` Motion Off writes `cursor_trail 0`, and Kitty's default is 0. +- Kitty's config watcher (`kitten __watch_conf__`) watches only kitty.conf (and /etc/xdg/kitty/kitty.conf), not included files. NMSh's preset switch changes only its included pipeline, so Kitty kept drawing the previous effect. Kitty documents SIGUSR1 as "reload config". One SIGUSR1 reloaded an unchanged `custom_shaders` path (kq/shots/exp-*.png). +- Ghostty on macOS reads its macOS config even with a private HOME, and NMSh refuses `--config-file` launches by design, so an NMSh-driven Ghostty write cannot run against a private config. The generated passes were checked by loading them in an isolated instance instead. +- Official references: https://sw.kovidgoyal.net/kitty/custom-shaders/, https://sw.kovidgoyal.net/kitty/conf/ (cursor_trail, auto_reload_config, SIGUSR1 reload), https://ghostty.org/docs/config/reference (command, custom-shader). diff --git a/docs/development/native-appearance-welcome/TEST_RESULTS.md b/docs/development/native-appearance-welcome/TEST_RESULTS.md index 659b3a5b..304bd485 100644 --- a/docs/development/native-appearance-welcome/TEST_RESULTS.md +++ b/docs/development/native-appearance-welcome/TEST_RESULTS.md @@ -99,3 +99,9 @@ NOT RUN: physical terminal QA of image/animation playback and P; GPU/graphics pr - Welcome startup frame (compose + paint, 120 columns) 0.14–0.19 ms; at ≤30 repaints/s for ≤5 s that is under 30 ms CPU in total; asset admission (64×64 PNG 0.45 ms, 12-frame GIF 3.6 ms) runs off the readiness path. - CI recorded once per PR (2026-10-29 ~00:35): #517, #519, #520 all checks pass; #521 pending; #518 failed one live test on Ubuntu (`/resume shows LIVE and ARCHIVED…` timed out waiting for `sleep` to run; Verify jobs fail as its dependants) — a live session test unrelated to the graphics-only change, not rerun (policy). - NOT RUN: physical QA (QA_CHECKLIST.md), GPU output, whole-app idle RSS/CPU on real hosts. + +## Native host fixes (Claude, 2026-10-29; branch fix/native-host-shaders-20261029) + +- Related focused tests: 180/180 pass, run through the repository runner's isolation (appearanceHub, cursorCapabilities, cursorEffects, cursorVisuals, ghosttyShaderPanel, ghosttyShaders, graphicsCapabilities, hostAppearance, hostIntegration, hostProfiles, kittyShaderPanel, kittyShaders, renderRouting, setupCoverage, shaderPreview, terminalHost, textSizing). tsc is clean. Red check: the 7 new or changed regressions fail on the original source. +- Physical Kitty 0.49.2 QA (disposable config, inherited TERM_PROGRAM=ghostty, build ebe54eb). The Kitty studio was offered, and the first-run `/cursor` Host native route opened it. Lightning drew an electric bolt on the GPU; Blaze drew an amber flame band. NMSh-only switching was correct 4/4 in cycles, with 1 earlier miss in 7 switches on a freshly launched Kitty that did not recur. Blaze + Northern Lights + CRT were visible, with 5 groups and one trail. R removed everything, restored kitty.conf byte for byte, and the effects were gone. Screenshots: /Volumes/T7Tmp/ai-dev/nmsh-native-host-fixes-20261029/kq/shots. +- Physical Ghostty 1.3.1 GPU check: NMSh-generated Comet + Aurora loaded in an isolated instance were visible (gq/shots). NMSh-driven Ghostty write, the Portable switch and removal were NOT RUN physically, because they would write the real Ghostty config; private-HOME tests cover them. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 932b19fa..e2244f81 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -334,7 +334,7 @@ import {discoverKittyCatalog, kittyBinary, type KittyCatalog} from '../appearanc import {GlslShaderStudio} from '../appearance/glslStudio.js'; import {writtenNativePasses} from '../appearance/nativePasses.js'; import {renderRoutes} from '../motion/renderRouting.js'; -import {applyKittyShaderPlan, inspectKittyLaunch, inspectKittyShaders, kittyShaderPaths, planKittyShaderRemoval, planKittyShaders, readProcessCommand, syncKittyShaders, writtenKittyTrail, +import {type KittyShaderSettings, reloadKitty, applyKittyShaderPlan, inspectKittyLaunch, inspectKittyShaders, kittyShaderPaths, planKittyShaderRemoval, planKittyShaders, readProcessCommand, syncKittyShaders, writtenKittyTrail, type KittyShaderContext, type ShaderGates} from '../appearance/kittyShaders.js'; import {diffModules, progress, transitionPaint, Transitions, type MotionGate} from '../motion/transitions.js'; import {overlayRow, type CellPaint} from '../presentation/cellOverlay.js'; @@ -654,7 +654,7 @@ export class TerminalApp { || previous.nmsh.vibrance !== next.nmsh.vibrance; if (cursorLook || chromaLook || 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 + if (previous.cursor.renderer !== next.cursor.renderer || previous.cursor.motion !== next.cursor.motion || previous.presentation.reducedMotion !== next.presentation.reducedMotion || previous.presentation.effectsOff !== next.presentation.effectsOff) void this.syncKittyShaderFiles(next); } // Off: no model use from this window, and the shared service is told to unload. @@ -1061,6 +1061,8 @@ export class TerminalApp { private appearanceHub?: AppearanceHubState; /** /appearance › Kitty shaders (Kitty 0.49+ pipelines); opened from the hub, Esc returns there. */ private kittyShaderPanel?: KittyShaderPanelState; + /** /cursor › Host native is reading Kitty's catalog to choose the studio or the older setup; a second Enter waits for it. */ + private kittyRouteChecking = false; /** 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, @@ -3913,7 +3915,8 @@ export class TerminalApp { /** Reduced Motion, Effects Off and the cursor renderer decide what NMSh's Kitty pipeline may contain. */ private shaderGates(config: PromptConfiguration = this.promptConfiguration): ShaderGates { - return {reducedMotion: isReducedMotion() || config.presentation.reducedMotion, effectsOff: config.presentation.effectsOff, cursorRenderer: config.cursor.renderer}; + return {reducedMotion: isReducedMotion() || config.presentation.reducedMotion, effectsOff: config.presentation.effectsOff, cursorRenderer: config.cursor.renderer, + cursorMotion: config.cursor.motion}; } private kittyShaderContext(catalog: KittyCatalog, config: PromptConfiguration = this.promptConfiguration): KittyShaderContext { @@ -3946,10 +3949,14 @@ export class TerminalApp { } const catalog = panel.catalog; if (!catalog) return; + // Choosing a Kitty trail here is choosing that Kitty draws the cursor trail: /cursor's Motion and renderer follow, disclosed in the review. + const trail = this.kittyTrailFollowUp(panel.draft, catalog); if (action.kind === 'review' || action.kind === 'remove') { - const planned = action.kind === 'review' ? planKittyShaders(panel.draft, this.kittyShaderContext(catalog)) : planKittyShaderRemoval({env: process.env, platform: process.platform}); - if (planned.ok) panel.confirmation = {plan: planned.plan, offset: 0}; - else panel.message = planned.error; + const planned = action.kind === 'review' ? planKittyShaders(panel.draft, this.kittyShaderContext(catalog, trail.config)) : planKittyShaderRemoval({env: process.env, platform: process.platform}); + if (planned.ok) { + if (action.kind === 'review' && trail.changes.length) planned.plan.preview.unshift(...trail.changes.map(change => `Also changes ${change}`), ''); + panel.confirmation = {plan: planned.plan, offset: 0}; + } else panel.message = planned.error; return; } const reviewed = panel.confirmation?.plan; @@ -3957,8 +3964,11 @@ export class TerminalApp { if (!reviewed) return; // Gates or files may have changed while the review was open: confirm only the plan that would be made now. const plan = reviewed.operation === 'remove' ? reviewed : (() => { - const again = planKittyShaders(panel.draft, this.kittyShaderContext(catalog)); - return again.ok && again.plan.pipelineText === reviewed.pipelineText && JSON.stringify(again.plan.effectiveShaders) === JSON.stringify(reviewed.effectiveShaders) ? reviewed : undefined; + const again = planKittyShaders(panel.draft, this.kittyShaderContext(catalog, trail.config)); + // The /cursor changes shown in the review must be the ones applied now (Motion or the renderer may have changed meanwhile). + const shown = reviewed.preview.filter(line => line.startsWith('Also changes ')); + return again.ok && again.plan.pipelineText === reviewed.pipelineText && JSON.stringify(again.plan.effectiveShaders) === JSON.stringify(reviewed.effectiveShaders) + && JSON.stringify(shown) === JSON.stringify(trail.changes.map(change => `Also changes ${change}`)) ? reviewed : undefined; })(); if (!plan) { panel.message = 'Something changed since the review (Reduced Motion, the cursor renderer or Kitty’s configuration); nothing was written. Press S to review again.'; return; } const applied = applyKittyShaderPlan(plan); @@ -3966,11 +3976,32 @@ export class TerminalApp { this.cursorHost = undefined; // the Kitty trail shader decides what Portable may draw const saved = plan.operation === 'remove' ? {...panel.draft, effects: panel.draft.effects.map(effect => ({...effect, enabled: false}))} : structuredClone(panel.draft); - this.updateConfiguration(configuration => { configuration.hostShaders.kitty = saved; }); + this.updateConfiguration(configuration => { + configuration.hostShaders.kitty = saved; + if (plan.operation !== 'remove') configuration.cursor = {...configuration.cursor, motion: trail.config.cursor.motion, renderer: trail.config.cursor.renderer}; + }); panel.draft = structuredClone(saved); panel.dirty = false; try { panel.inspection = inspectKittyShaders(this.kittyShaderContext(catalog)); } catch { /* the message below still applies */ } - panel.message = plan.operation === 'remove' ? `Removed NMSh's Kitty shaders. ${plan.guidance}` : plan.guidance; + // Kitty does not watch included files: without a reload it keeps drawing the previous effect (Lightning switched to Blaze stayed Lightning). + const reload = reloadKitty(process.env, process.platform); + panel.message = `${plan.operation === 'remove' ? 'Removed NMSh\'s Kitty shaders. ' : ''}${reload.message} ${plan.guidance}`; + } + + /** + * A Kitty trail in the draft needs /cursor Motion on and a non-Portable renderer, or Kitty would draw nothing (Motion Off) or NMSh + * would remove it again (Portable). Returns the configuration the review is planned against and the /cursor changes it implies. + */ + private kittyTrailFollowUp(draft: KittyShaderSettings, catalog: KittyCatalog, config: PromptConfiguration = this.promptConfiguration): {config: PromptConfiguration; changes: string[]} { + const trail = draft.effects.some(effect => effect.enabled && catalog.offered.some(item => item.definition.id === effect.id && item.definition.category === 'cursor')); + // Under Reduced Motion or Effects Off the pipeline has no trail, so /cursor is left alone. + const gates = this.shaderGates(config); + if (!trail || gates.reducedMotion || gates.effectsOff || (config.cursor.motion !== 'off' && config.cursor.renderer !== 'portable')) return {config, changes: []}; + const changes: string[] = []; + const cursor = {...config.cursor}; + if (cursor.motion === 'off') { cursor.motion = 'tail'; changes.push('/cursor Motion: Off → Tail, so there is a trail for Kitty to draw.'); } + if (cursor.renderer === 'portable') { cursor.renderer = 'auto'; changes.push('/cursor renderer: Portable → Auto, so Kitty draws the trail and Portable draws no second one.'); } + return {config: {...config, cursor}, changes}; } /** @@ -3984,8 +4015,10 @@ export class TerminalApp { if (this.stopped) return; const result = syncKittyShaders(next.hostShaders.kitty, this.kittyShaderContext(catalog, next)); if (result.kind === 'written') this.cursorHost = undefined; + // A gate change (Portable, Motion off, Reduced Motion) must reach the loaded pipeline too, or Kitty and Portable would both draw a trail. + const reload = result.kind === 'written' ? reloadKitty(process.env, process.platform) : undefined; if (result.kind === 'written' || result.kind === 'review') { - const message = result.kind === 'review' ? `Kitty shaders were not updated: ${result.message}` : result.message!; + const message = result.kind === 'review' ? `Kitty shaders were not updated: ${result.message}` : `${reload!.message} ${result.message!}`; if (this.kittyShaderPanel) this.kittyShaderPanel.message = message; else this.output.addFrontendInteraction('/appearance', message, result.kind === 'review' ? ERROR : INFO); this.render(); @@ -10599,18 +10632,36 @@ export class TerminalApp { this.glslStudio.open(); return; } - if (host === 'kitty' && (facts.integrated || facts.kittyTrail)) { + const openKittyStudio = () => { // Kitty 0.49+ trail styles live in the one Kitty shader studio; /cursor keeps whether there is a trail and its color. this.closeCursorPanel(); this.appearanceHub = undefined; void this.openKittyShaders(); + }; + const offerIncludeSetup = () => { + const setup = setupPlan(host); + const blocked = setup.plan.kind === 'refuse' ? setup.plan.reason : undefined; + state.native = {host, configPath: setup.configPath, ...(setup.plan.kind === 'plan' || setup.plan.kind === 'noop' ? {line: includeLine(host)} : {}), related: setup.related, + ...(blocked ? {blocked} : {}), choice: 'no'}; + }; + if (host === 'kitty' && (facts.integrated || facts.kittyTrail)) { openKittyStudio(); return; } + if (facts.integrated) { state.message = `${nativeHostLabel(host)} native is already set up; changes here update NMSh's managed files.`; return; } + if (host === 'kitty') { + // First time: a Kitty with the shader catalog (0.49+) goes straight to the studio, which sets the trail up itself. + // An older Kitty keeps the built-in cursor_trail setup. + if (this.kittyRouteChecking) return; + this.kittyRouteChecking = true; + state.message = 'Checking Kitty’s shader support…'; + void this.kittyCatalog().finally(() => { this.kittyRouteChecking = false; }).then(catalog => { + if (this.stopped || (this.cursorPanel ?? this.setupState?.cursorPanel) !== state) return; + state.message = undefined; + if (catalog.supported) openKittyStudio(); + else offerIncludeSetup(); + this.render(); + }); return; } - if (facts.integrated) { state.message = `${nativeHostLabel(host)} native is already set up; changes here update NMSh's managed files.`; return; } - const setup = setupPlan(host); - const blocked = setup.plan.kind === 'refuse' ? setup.plan.reason : undefined; - state.native = {host, configPath: setup.configPath, ...(setup.plan.kind === 'plan' || setup.plan.kind === 'noop' ? {line: includeLine(host)} : {}), related: setup.related, - ...(blocked ? {blocked} : {}), choice: 'no'}; + offerIncludeSetup(); return; } if (action.kind === 'nativeConfirm' && state.native) { diff --git a/src/appearance/kittyShaders.ts b/src/appearance/kittyShaders.ts index 74ca53de..25b28af7 100644 --- a/src/appearance/kittyShaders.ts +++ b/src/appearance/kittyShaders.ts @@ -21,23 +21,31 @@ import {appearancePaths, type AppearanceContext} from './hostPaths.js'; import {readHostConfiguration} from './hostConfiguration.js'; import {digest, snapshot, transact, type FileChange, type FileSnapshot} from './hostFiles.js'; import {withoutHook} from './hostAppearance.js'; +import {readProcessTable, type ProcessRow} from './ghosttyShaders.js'; import {CATEGORY_LABELS, defaultPipeline, emitKittyPipeline, formatShaderValue, KITTY_MAX_GROUPS, mergeVars, parseKittyPipeline, pipelineTextures, subscribesToCursorTrail, type KittyCatalog, type KittyGroup, type KittyPipeline, type KittyVar, type OfferedParam, type OfferedPreset, type ShaderValue} from './kittyShaderCatalog.js'; -import type {CursorRenderer} from '../prompt/configuration.js'; +import type {CursorMotion, CursorRenderer} from '../prompt/configuration.js'; // ---- Settings ---------------------------------------------------------------------------------------------------- export {DEFAULT_HOST_SHADERS, normalizeHostShaders, type HostShaderSettings, type KittyShaderEffect, type KittyShaderSettings} from './kittyShaderSettings.js'; import type {KittyShaderEffect, KittyShaderSettings} from './kittyShaderSettings.js'; -export interface ShaderGates {reducedMotion: boolean; effectsOff: boolean; cursorRenderer: CursorRenderer} +/** cursorMotion: /cursor's Motion decides whether there is a trail at all; Off removes NMSh's Kitty trail like a Portable renderer does. */ +export interface ShaderGates {reducedMotion: boolean; effectsOff: boolean; cursorRenderer: CursorRenderer; cursorMotion?: CursorMotion} -/** What NMSh's pipeline may contain right now: nothing under Reduced Motion or Effects Off, no Kitty trail when the cursor renderer is Portable. */ +/** + * Kitty draws trail shaders only while cursor_trail is above 0. When a written NMSh trail would otherwise see 0 (Kitty's default, + * or /cursor's own file with Motion off), NMSh's fragment sets this value, the one /cursor's Kitty file uses for a trail. + */ +export const KITTY_TRAIL_ON = 3; + +/** What NMSh's pipeline may contain right now: nothing under Reduced Motion or Effects Off, no Kitty trail when the cursor renderer is Portable or Motion is Off. */ export function gatedEffects(settings: KittyShaderSettings, catalog: KittyCatalog, gates: ShaderGates): Array<{preset: OfferedPreset; effect: KittyShaderEffect}> { if (gates.reducedMotion || gates.effectsOff) return []; return settings.effects.filter(effect => effect.enabled).flatMap(effect => { const preset = catalog.offered.find(item => item.definition.id === effect.id); - if (!preset || (preset.definition.category === 'cursor' && gates.cursorRenderer === 'portable')) return []; + if (!preset || (preset.definition.category === 'cursor' && (gates.cursorRenderer === 'portable' || gates.cursorMotion === 'off'))) return []; return [{preset, effect}]; }); } @@ -277,10 +285,43 @@ export interface KittyShaderPlan { export type KittyShaderPlanResult = {ok: true; plan: KittyShaderPlan} | {ok: false; error: string}; export function kittyShaderGuidance(platform: NodeJS.Platform): string { - return `Configuration saved, not proof of GPU output. Kitty reloads its config automatically unless auto_reload_config is 0; otherwise reload with ${platform === 'darwin' ? '⌃⌘,' : 'Ctrl+Shift+F5'}. ` + return `Configuration saved, not proof of GPU output. Kitty watches only kitty.conf, not the files it includes, so a changed effect loads when Kitty reloads (${kittyReloadKeys(platform)}). ` + 'Kitty builds all custom shaders (yours and NMSh’s) as one program: if any part fails to build, Kitty turns all of them off and logs why; R here removes NMSh’s part.'; } +const kittyReloadKeys = (platform: NodeJS.Platform) => platform === 'darwin' ? '⌃⌘,' : 'Ctrl+Shift+F5'; + +/** + * The Kitty process NMSh may ask to reload: KITTY_PID only when it is one of NMSh's own parent processes and its program is + * kitty. A stale or inherited KITTY_PID (tmux, ssh, a session service, a reused number) gives undefined, never a guess. + */ +export function kittyReloadTarget(kittyPid: string | undefined, rows: readonly ProcessRow[] | undefined, selfPid: number): number | undefined { + const pid = Number(kittyPid); + if (!Number.isSafeInteger(pid) || pid <= 1 || !rows) return undefined; + const byPid = new Map(rows.map(row => [row.pid, row])); + let current = byPid.get(selfPid); + for (let depth = 0; current && depth < 32; depth++) { + if (current.pid === pid) return /(^|\/)kitty$/u.test(current.command.split(/\s+/u)[0] ?? '') ? pid : undefined; + current = current.ppid > 1 ? byPid.get(current.ppid) : undefined; + } + return undefined; +} + +export interface KittyReloadDeps {rows?: () => readonly ProcessRow[] | undefined; selfPid?: number; signal?: (pid: number) => void} + +/** + * Kitty does not watch included files, so after NMSh rewrites its fragment or pipeline (switching Lightning to Blaze changes only + * the pipeline) Kitty keeps drawing the old effect until it reloads; observed with Kitty 0.49.2. Kitty documents SIGUSR1 as + * "reload the config" (the same as its reload shortcut). NMSh sends it only to the verified Kitty it runs in. + */ +export function reloadKitty(env: NodeJS.ProcessEnv, platform: NodeJS.Platform, deps: KittyReloadDeps = {}): {reloaded: true; message: string} | {reloaded: false; message: string} { + const target = kittyReloadTarget(env.KITTY_PID, (deps.rows ?? readProcessTable)(), deps.selfPid ?? process.pid); + if (target === undefined) return {reloaded: false, message: `Reload Kitty to load it (${kittyReloadKeys(platform)}): NMSh could not confirm which Kitty it runs in.`}; + try { (deps.signal ?? (pid => process.kill(pid, 'SIGUSR1')))(target); } + catch { return {reloaded: false, message: `Reload Kitty to load it (${kittyReloadKeys(platform)}): asking Kitty to reload failed.`}; } + return {reloaded: true, message: 'Kitty was asked to reload its configuration (its documented SIGUSR1), so the change is loaded now.'}; +} + const decimals = (value: number) => (value.toString().split('.')[1] ?? '').length; /** A param's value as Kitty source; colors keep the shipped alpha when NMSh only edits RGB. */ const formatParam = (param: OfferedParam, value: ShaderValue) => formatShaderValue(param.type, param.kind === 'number' && typeof value === 'number' && param.type === 'float' @@ -357,7 +398,6 @@ function compose(settings: KittyShaderSettings, context: KittyShaderContext, mod if (file.content !== undefined) throw new Error(`${candidate} would replace Kitty's shipped ${shader} shader; NMSh only tunes shipped shaders.`); } } - if (preset.definition.requires === 'cursor_trail') notes.push(`${preset.definition.label} draws only while cursor_trail is above 0 (now ${inspection.cursorTrail}); /cursor Motion › Tail with Kitty native sets it.`); if (preset.definition.requires === 'opaque' && inspection.backgroundOpacity < 1) notes.push(`${preset.definition.label} does not work with background_opacity ${inspection.backgroundOpacity} (Kitty's own note).`); } const pipeline = buildNmshPipeline(active, catalog, leaked); @@ -369,7 +409,7 @@ function compose(settings: KittyShaderSettings, context: KittyShaderContext, mod const ours = quoteWord(paths.pipeline); const composed = !active.length ? undefined : inspection.userRaw ? (settings.placement === 'before' ? `${ours} ${inspection.userRaw}` : `${inspection.userRaw} ${ours}`) : ours; const expected = !active.length ? inspection.userEntries : settings.placement === 'before' ? [paths.pipeline, ...inspection.userEntries] : [...inspection.userEntries, paths.pipeline]; - const fragmentText = `${FRAGMENT_HEADER}${composed ? `custom_shaders ${composed}\n` : '# No NMSh effect is active; your own custom_shaders applies unchanged.\n'}`; + let fragmentText = `${FRAGMENT_HEADER}${composed ? `custom_shaders ${composed}\n` : '# No NMSh effect is active; your own custom_shaders applies unchanged.\n'}`; if (state.record && state.record.hostPath !== inspection.hostPath) throw new Error('Kitty’s configuration file changed; remove the old integration before setting up this one.'); const hostFile = snapshot(inspection.hostPath, paths.home, true); const original = hostFile.content ?? ''; @@ -380,19 +420,36 @@ function compose(settings: KittyShaderSettings, context: KittyShaderContext, mod if (mode === 'sync' && !state.record) throw new Error('Kitty shaders are not set up.'); const hostAfter = state.record ? original : `${original}${hook}`; // Prove the effective value is exactly the composed list, from NMSh's fragment. - const simulated = readHostConfiguration('kitty', context, new Map([[hostFile.path, hostAfter], [paths.fragment, fragmentText]])); + const simulate = () => readHostConfiguration('kitty', context, new Map([[hostFile.path, hostAfter], [paths.fragment, fragmentText]])); + let simulated = simulate(); if (simulated.problems.length) throw new Error(simulated.problems.join(' ')); + // A trail shader with cursor_trail at 0 is written but never drawn. Judged without NMSh's own trail line (so a later sync + // keeps it): when the value would be 0, NMSh's fragment turns the trail on and the review says so. + const trailActive = active.some(({preset}) => preset.definition.category === 'cursor'); + const trailWithout = Number(simulated.values.get('cursor_trail')?.value ?? 0); + if (trailActive && !(trailWithout > 0)) { + fragmentText += `# Kitty draws trail shaders only while cursor_trail is above 0.\ncursor_trail ${KITTY_TRAIL_ON}\n`; + notes.push(`Turns Kitty's cursor_trail on (${KITTY_TRAIL_ON}; it would be ${Number.isFinite(trailWithout) ? trailWithout : 'unreadable'}) so the trail shader draws.`); + simulated = simulate(); + if (simulated.problems.length) throw new Error(simulated.problems.join(' ')); + } const effective = simulated.values.get('custom_shaders'); const effectiveWords = shellWords(effective?.raw ?? '') ?? []; if (active.length && (effective?.path !== paths.fragment || JSON.stringify(effectiveWords) !== JSON.stringify(expected))) { throw new Error('A later or unresolved custom_shaders line would override NMSh’s composed list. Resolve that explicitly; no files were changed.'); } + const trailAfter = Number(simulated.values.get('cursor_trail')?.value ?? 0); + if (trailActive && !(trailAfter > 0)) { + const where = simulated.values.get('cursor_trail')?.path; + throw new Error(`cursor_trail would still be ${Number.isFinite(trailAfter) ? trailAfter : 'unreadable'}${where ? ` (set later in ${where})` : ''}, so Kitty would draw no trail. Turn on /cursor Motion or remove that line; no files were changed.`); + } const record: ShaderLedger = {version: 1, feature: 'kitty-shaders', hostPath: hostFile.path, hook, fragmentHash: digest(fragmentText), pipelineHash: digest(pipelineText), created: state.record?.created ?? hostFile.content === undefined, preserved: inspection.userRaw, placement: settings.placement, kittyVersion: catalog.version, presets: active.map(({preset}) => ({id: preset.definition.id, resources: preset.origin.resources}))}; const ledgerText = `${JSON.stringify(record, null, 2)}\n`; const changes: FileChange[] = [{before: state.pipeline, after: pipelineText}, {before: state.fragment, after: fragmentText}, ...(state.record ? [] : [{before: hostFile, after: hostAfter}]), {before: state.ledger, after: ledgerText}]; + if (mode === 'save') notes.push('After writing, NMSh asks the Kitty it runs in to reload its configuration: Kitty does not watch included files, so it would keep drawing the previous effect.'); const kept = inspection.userEntries.length ? `Kept, in order: ${inspection.userEntries.join(' · ')}` : 'You have no custom_shaders of your own.'; const shadowedNote = inspection.shadowed.length ? [`Kitty already ignores earlier custom_shaders lines at ${inspection.shadowed.join(', ')} (a later line replaces them); they are unchanged.`] : []; const preview = [kept, ...shadowedNote, `Effective after this change: ${effectiveWords.length ? effectiveWords.join(' · ') : '(none)'}`, '', diff --git a/src/appearance/shaderPreview.ts b/src/appearance/shaderPreview.ts index f735ecbf..00583866 100644 --- a/src/appearance/shaderPreview.ts +++ b/src/appearance/shaderPreview.ts @@ -13,11 +13,21 @@ import {colorEscape} from '../chroma/escape.js'; import type {ColorLevel} from '../presentation/capabilities.js'; import type {PresetCategory, ShaderValue} from './kittyShaderCatalog.js'; -export type PreviewKind = 'trail' | 'aurora' | 'dim' | 'vignette' | 'scanlines' | 'glow'; +export type PreviewKind = 'trail' | 'comet' | 'blaze' | 'lightning' | 'motion-blur' | 'aurora' | 'dim' | 'vignette' | 'scanlines' | 'glow'; + +/** + * Each trail style has its own shape, modelled on what the shader draws (Kitty 0.49.2's shipped sources; NMSh's Ghostty + * passes): Blaze fills a wide amber band with a red edge that eases out; Lightning flashes a thin jagged bolt with forks + * and is gone almost at once; Motion blur smears ghost copies of the cursor along its path; Default and Smear are a plain + * fading tail; Comet tapers. One shared shape with different colors made Blaze and Lightning look the same. + */ +const TRAIL_KINDS: Readonly> = {'cursor-trail-blaze': 'blaze', 'cursor-trail-lightning': 'lightning', + 'cursor-trail-motion-blur': 'motion-blur', comet: 'comet'}; +const TRAIL_PREVIEWS: ReadonlySet = new Set(['trail', 'comet', 'blaze', 'lightning', 'motion-blur']); /** The preview kind for a preset (Ghostty and Kitty ids), or undefined when NMSh has no faithful approximation. */ export function previewKind(id: string, category: PresetCategory): PreviewKind | undefined { - if (category === 'cursor') return 'trail'; + if (category === 'cursor') return TRAIL_KINDS[id] ?? 'trail'; if (id === 'aurora' || id === 'northern-lights') return 'aurora'; if (id === 'focus-dim' || id === 'dim-inactive-windows') return 'dim'; if (id === 'vignette' || id === 'spotlight') return 'vignette'; @@ -42,7 +52,8 @@ export function previewValues(id: string, values: Readonly = {trail: 1800, aurora: 3600, dim: 1500, vignette: 0, scanlines: 0, glow: 0}; +export const PREVIEW_MS: Record = {trail: 1800, comet: 1800, blaze: 2000, lightning: 1100, 'motion-blur': 1600, + aurora: 3600, dim: 1500, vignette: 0, scanlines: 0, glow: 0}; export interface PreviewInput { kind: PreviewKind; @@ -86,20 +97,82 @@ export function renderShaderPreview(input: PreviewInput): PreviewFrame { const rows = grid.length; const at = (x: number, y: number) => grid[y]?.[x]; const intensity = num(values.INTENSITY, 0.6); - if (kind === 'trail') { - // The cursor jumps from the end of one command line to the next prompt; the trail fades behind it. + if (TRAIL_PREVIEWS.has(kind)) { + // The cursor jumps from the end of one command line to the next prompt; each style draws its own trail behind it. const from = {x: SAMPLE[0]!.length, y: 0}; const to = {x: 2, y: 2}; - const travel = Math.min(1, t / 0.35); - const head = {x: from.x + (to.x - from.x) * travel, y: from.y + (to.y - from.y) * travel}; - const fade = t < 0.35 ? 1 : Math.max(0, 1 - (t - 0.35) / 0.65); + const point = (s: number) => ({x: from.x + (to.x - from.x) * s, y: from.y + (to.y - from.y) * s}); const headColor = rgb(values.HEAD, colors.cursor); const tailColor = rgb(values.TAIL, mix(colors.cursor, colors.background, 0.5)); - for (let step = 0; step <= 24; step++) { - const s = step / 24; - if (s > travel) break; - const cell = at(Math.round(from.x + (to.x - from.x) * s), Math.round(from.y + (to.y - from.y) * s)); - if (cell) cell.bg = mix(cell.bg, mix(tailColor, headColor, s / Math.max(travel, 0.01)), intensity * fade * (0.35 + 0.65 * (s / Math.max(travel, 0.01)))); + const paint = (x: number, y: number, color: RgbColor, amount: number) => { + const cell = at(Math.round(x), Math.round(y)); + if (cell && amount > 0) cell.bg = mix(cell.bg, color, Math.min(1, amount)); + }; + let head = to; + if (kind === 'trail' || kind === 'comet') { + const travel = Math.min(1, t / 0.35); + const fade = t < 0.35 ? 1 : Math.max(0, 1 - (t - 0.35) / 0.65); + head = point(travel); + for (let step = 0; step <= 24; step++) { + const s = step / 24; + if (s > travel) break; + const along = s / Math.max(travel, 0.01); + // Comet: a bright head on a thin tail that tapers away; the plain trail keeps its width. + const taper = kind === 'comet' ? along ** 1.6 : 0.35 + 0.65 * along; + const spot = point(s); + paint(spot.x, spot.y, mix(tailColor, headColor, along), intensity * fade * taper); + } + } else if (kind === 'blaze') { + // A filled band as wide as the cursor's travel, amber inside and red-orange on its edges, easing out from the far end. + // The band's far end retracts towards the cursor as it burns out. + const remaining = (1 - Math.min(1, t)) ** 2; + const life = Math.max(0, 1 - t * 0.6); + for (let step = 0; step <= 32; step++) { + const s = step / 32; + if (s < 1 - remaining) continue; + const spot = point(s); + for (const dx of [-2, -1, 0, 1, 2]) { + const edge = Math.abs(dx) === 2; + paint(spot.x + dx, spot.y, edge ? tailColor : headColor, (edge ? 0.75 : 0.9) * life * (0.4 + 0.6 * s) * (0.6 + 0.4 * intensity)); + } + } + } else if (kind === 'lightning') { + // A thin bolt with jagged interior nodes and three short forks; it flashes hard, flickers and fades within a moment. + const flash = Math.max(0, 1 - t / 0.55) ** 2; + const flicker = 0.75 + 0.25 * Math.sin(input.elapsed * 0.095); + const strength = flash * flicker; + const jag = (s: number) => { + const node = Math.round(s * 8); + const random = Math.sin(node * 12.9898) * 43758.5453; + const offset = ((random - Math.floor(random)) * 2 - 1) * 3 * Math.sin(Math.PI * s); + const spot = point(s); + return {x: spot.x + offset, y: spot.y}; + }; + for (let step = 0; step <= 40; step++) { + const spot = jag(step / 40); + for (const dx of [-1, 1]) paint(spot.x + dx, spot.y, tailColor, strength * 0.55); + } + for (let step = 0; step <= 40; step++) { + const spot = jag(step / 40); + paint(spot.x, spot.y, headColor, strength); + const cell = at(Math.round(spot.x), Math.round(spot.y)); + if (cell && strength > 0.2) cell.fg = colors.background; + } + for (const fork of [0.3, 0.55, 0.78]) { + const root = jag(fork); + const side = fork === 0.55 ? -1 : 1; + for (let k = 1; k <= 3; k++) paint(root.x + k * side, root.y + (k === 3 ? side : 0), headColor, strength * (0.85 - k * 0.15)); + } + } else { + // Motion blur: the cursor glides along a smoothed path and leaves fading ghost copies of itself, no colored band. + const travel = 1 - (1 - Math.min(1, t / 0.6)) ** 3; + const fade = t < 0.6 ? 1 : Math.max(0, 1 - (t - 0.6) / 0.4); + head = point(travel); + for (let ghost = 1; ghost <= 6; ghost++) { + const s = Math.max(0, travel - ghost * 0.09); + const spot = point(s); + paint(spot.x, spot.y, colors.cursor, intensity * fade * (0.7 - ghost * 0.1)); + } } const cursor = at(Math.round(head.x), Math.round(head.y)); if (cursor) { cursor.bg = colors.cursor; cursor.fg = colors.background; } diff --git a/src/cursor/backends.ts b/src/cursor/backends.ts index b6abbfc4..2600e188 100644 --- a/src/cursor/backends.ts +++ b/src/cursor/backends.ts @@ -1,4 +1,5 @@ import type {CursorEffect, CursorIdleEffect, CursorMotion, CursorRenderer, CursorSettings} from '../prompt/configuration.js'; +import {terminalProfile} from '../host/capabilities.js'; /** * Cursor-effect backends. Portable works wherever NMSh owns its input @@ -83,14 +84,16 @@ const KNOWN_HOSTS: Record = {zed: 'Zed', Apple_Terminal: 'Termin export function hostCursorFacts(env: NodeJS.ProcessEnv, integrated: (host: 'ghostty' | 'kitty') => boolean, kittyTrail: () => string | undefined = () => undefined, ghostty: () => {trail?: string; pending?: string} = () => ({})): HostCursorFacts { + // One host identity for every surface (terminalProfile): an inherited TERM_PROGRAM must not turn Kitty into Ghostty. + const profile = terminalProfile(env); 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') { + const caretColor = profile === 'zed' ? 'theme-controlled' as const : 'host-controlled' as const; + if (profile === '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') { + if (profile === 'kitty' || (profile === 'baseline' && env.KITTY_WINDOW_ID)) { const trail = kittyTrail(); return {host: 'kitty', hostName: 'Kitty', caretColor, integrated: integrated('kitty'), ...(trail ? {kittyTrail: trail} : {})}; } @@ -140,8 +143,10 @@ const isOff = (value: string) => value === 'off' || value === 'none'; /** * Auto uses native only when NMSh's managed integration is present on a * supporting host; Portable draws whatever native does not. Forced Host - * native draws only what the host backend does, and nothing at all where the - * host has none. Portable is always available. The person never ends up + * native draws only what a set-up host backend does; on a host with native + * effects that are not set up yet Portable draws meanwhile (labelled), so the + * cursor is never left motionless. A host with none draws nothing under Host + * native and says so. Portable is always available. The person never ends up * unsure which renderer is drawing. */ export function chooseBackend(settings: CursorSettings, facts: HostCursorFacts): BackendChoice { @@ -170,8 +175,9 @@ function chooseWithoutPending(settings: CursorSettings, facts: HostCursorFacts): 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`, - portableDraws: settings.renderer === 'native' ? nothing : all}; + // Host native chosen but nothing native is set up yet: nothing on the GPU would draw, so Portable does until setup. + reason: settings.renderer === 'native' ? `Portable fallback · ${native.label} is not set up yet (Host native setup shows every change first)` : `Portable · ${native.label} is available after setup`, + portableDraws: all}; } const motion = !isOff(settings.motion) && supports(native, 'motion', settings.motion); // Kitty's shader trails draw only while its cursor_trail runs, which NMSh's Kitty fragment turns on with a motion. @@ -204,7 +210,8 @@ 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 || facts.ghosttyTrail ? {available: true, via: 'native', suffix: ''} : {available: true, via: 'native-pending', suffix: `after ${native.host} setup`}; + // Not set up yet: Portable draws it meanwhile, so the value is usable now. + return facts.integrated || facts.ghosttyTrail || facts.kittyTrail ? {available: true, via: 'native', suffix: ''} : {available: true, via: 'native-pending', suffix: `Portable until ${native.host} setup`}; } 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/host/capabilities.ts b/src/host/capabilities.ts index 030b5150..13f778fe 100644 --- a/src/host/capabilities.ts +++ b/src/host/capabilities.ts @@ -48,7 +48,12 @@ const MOUSE_PROFILE = { export type TerminalProfile = 'ghostty' | 'iterm2' | 'kitty' | 'wezterm' | 'zed' | 'windows-terminal' | 'baseline'; export function terminalProfile(env: NodeJS.ProcessEnv): TerminalProfile { - // An explicit program wins over inherited outer-host variables. + // The terminal itself sets TERM for every child, so its own name there is the strongest evidence. TERM_PROGRAM is not: + // Kitty never sets it and passes an inherited one through, so a Kitty started from a Ghostty or Zed shell still carries + // TERM_PROGRAM=ghostty (observed with Kitty 0.49.2: TERM=xterm-kitty, KITTY_WINDOW_ID=1, TERM_PROGRAM=ghostty). + if (env.TERM === 'xterm-kitty') return 'kitty'; + if (env.TERM === 'xterm-ghostty') return 'ghostty'; + // Otherwise an explicit program wins over inherited outer-host variables. if (env.TERM_PROGRAM) { switch (env.TERM_PROGRAM) { case 'ghostty': return 'ghostty'; diff --git a/src/host/terminalHost.ts b/src/host/terminalHost.ts index 0f370262..05fcc882 100644 --- a/src/host/terminalHost.ts +++ b/src/host/terminalHost.ts @@ -1,6 +1,6 @@ import {hostIntegration, keyboardGuidance, type HostIntegration} from './integration.js'; import {spawn} from 'node:child_process'; -import {resolveHostCapabilities, type TerminalCapabilities} from './capabilities.js'; +import {resolveHostCapabilities, terminalProfile, type TerminalCapabilities} from './capabilities.js'; /** * The terminal NMSh runs in, and whether NMSh can ask it to open another @@ -52,13 +52,20 @@ export function detectTerminalHost(env: NodeJS.ProcessEnv = process.env, platfor const capabilities = resolveHostCapabilities(env); const integration = hostIntegration(env); const guidance = keyboardGuidance(env); - if (program === 'ghostty' || env.GHOSTTY_RESOURCES_DIR) { + // One host identity (terminalProfile): Kitty started from a Ghostty shell inherits TERM_PROGRAM and GHOSTTY_RESOURCES_DIR. + // Inside tmux (no profile) the outer host's own markers still name it, as before. + const profile = terminalProfile(env); + if (profile === 'ghostty' || (profile === 'baseline' && env.GHOSTTY_RESOURCES_DIR)) { return {capabilities, integration, keyboardGuidance: guidance, name: 'Ghostty', newWindow: argv => platform === 'darwin' // Ghostty's AppleScript API opens a normal window in the running app. The // command words arrive as osascript argv, never inside the script text. ? {command: 'osascript', args: [...GHOSTTY_NEW_WINDOW_SCRIPT.flatMap(line => ['-e', line]), ...argv]} : {command: 'ghostty', args: ['-e', ...argv]}}; } + if (profile === 'kitty') { + // Needs kitty remote control (allow_remote_control); failure falls back like any unsupported host. + return {capabilities, integration, keyboardGuidance: guidance, name: 'kitty', newWindow: argv => ({command: 'kitten', args: ['@', 'launch', '--type=os-window', ...argv]})}; + } if (program === 'Apple_Terminal' && platform === 'darwin') { return {capabilities, integration, keyboardGuidance: guidance, name: 'Terminal', newWindow: argv => ({command: 'osascript', args: ['-e', `tell application "Terminal" to do script ${appleScriptString(argv.map(shellQuote).join(' '))}`]})}; @@ -76,8 +83,8 @@ export function detectTerminalHost(env: NodeJS.ProcessEnv = process.env, platfor // Reachable from WSL when Windows Terminal forwards WT_SESSION; NMSh does not drive Windows from Linux. return {capabilities, integration, keyboardGuidance: guidance, name: 'Windows Terminal'}; } - if (env.KITTY_WINDOW_ID || program === 'kitty') { - // Needs kitty remote control (allow_remote_control); failure falls back like any unsupported host. + if (profile === 'baseline' && env.KITTY_WINDOW_ID) { + // Inside tmux the outer Kitty is named by KITTY_WINDOW_ID, after the hosts TERM_PROGRAM names (as before). return {capabilities, integration, keyboardGuidance: guidance, name: 'kitty', newWindow: argv => ({command: 'kitten', args: ['@', 'launch', '--type=os-window', ...argv]})}; } if (program === 'vscode') return {capabilities, integration, keyboardGuidance: guidance, name: 'VS Code'}; diff --git a/tests/cursorCapabilities.test.ts b/tests/cursorCapabilities.test.ts index e9f0793b..7a4c358b 100644 --- a/tests/cursorCapabilities.test.ts +++ b/tests/cursorCapabilities.test.ts @@ -80,9 +80,9 @@ test('forced Host native offers only what the host backend draws, and says why t assert.deepEqual(availabilityOf('effect', 'lightning', 'native', live), {available: false, reason: 'Ghostty Native does not provide this effect.'}); assert.equal(unavailableReason('effect', CURSOR_EFFECTS, 'native', kitty(true)), 'Kitty Native does not provide this effect.'); assert.equal(unavailableReason('motion', CURSOR_MOTIONS, 'native', terminalApp), 'Host native is not available in this terminal.'); - // Before setup the values a host WILL draw stay selectable, labelled as waiting for setup. + // Before setup the values a host WILL draw stay selectable; Portable draws them until the host is set up (never a motionless cursor). const pending = availabilityOf('effect', 'fire', 'native', ghostty(false)); - assert.deepEqual(pending, {available: true, via: 'native-pending', suffix: 'after Ghostty setup'}); + assert.deepEqual(pending, {available: true, via: 'native-pending', suffix: 'Portable until Ghostty setup'}); }); test('Auto says so when Portable draws what an active native backend does not', () => { diff --git a/tests/cursorEffects.test.ts b/tests/cursorEffects.test.ts index 4b338694..466f8b25 100644 --- a/tests/cursorEffects.test.ts +++ b/tests/cursorEffects.test.ts @@ -108,6 +108,11 @@ test('backends: Auto is native only when set up; forced native on another host f assert.equal(forced.backend.id, 'portable'); assert.match(forced.reason, /not available/u); assert.deepEqual(forced.portableDraws, {motion: false, effect: false, idle: false}, 'a forced host renderer without a host backend draws nothing, and says so'); + // Host native chosen in Ghostty with nothing native set up: Ghostty draws no trail, so Portable must, labelled as a fallback. + const unset = chooseBackend(settings({renderer: 'native', motion: 'smear'}), hostCursorFacts(env({TERM: 'xterm-ghostty', TERM_PROGRAM: 'ghostty', TERM_PROGRAM_VERSION: '1.3.1'}), () => false)); + assert.equal(unset.backend.id, 'portable'); + assert.deepEqual(unset.portableDraws, {motion: true, effect: true, idle: true}); + assert.match(unset.reason, /Portable fallback · Ghostty native is not set up yet/u); const ghostty = hostCursorFacts(env({TERM_PROGRAM: 'ghostty', TERM_PROGRAM_VERSION: '1.2.0'}), () => true); const native = chooseBackend(settings({renderer: 'auto', motion: 'smear', effect: 'fire'}), ghostty); assert.equal(native.backend, GHOSTTY_BACKEND); diff --git a/tests/hostProfiles.test.ts b/tests/hostProfiles.test.ts index 0549734d..e6fd2306 100644 --- a/tests/hostProfiles.test.ts +++ b/tests/hostProfiles.test.ts @@ -164,3 +164,32 @@ test('every host profile degrades inside a multiplexer and when TERM is dumb, fr assert.equal(resolveHostCapabilities({...env, TERM: 'dumb'}).hyperlinks, false); } }); + +test('host identity: the terminal’s own TERM wins over an inherited TERM_PROGRAM (Kitty started from Ghostty or Zed)', () => { + // Observed with Kitty 0.49.2 launched from a Ghostty shell: Kitty passes the outer TERM_PROGRAM through unchanged. + const kittyFromGhostty = {TERM: 'xterm-kitty', KITTY_WINDOW_ID: '1', KITTY_PID: '42', TERM_PROGRAM: 'ghostty', TERM_PROGRAM_VERSION: '1.3.1', + GHOSTTY_RESOURCES_DIR: '/Applications/Ghostty.app/Contents/Resources/ghostty'}; + assert.equal(terminalProfile(kittyFromGhostty), 'kitty'); + assert.equal(resolveHostCapabilities(kittyFromGhostty).graphicsProtocol, 'kitty'); + assert.equal(detectTerminalHost(kittyFromGhostty, 'darwin').name, 'kitty'); + assert.equal(terminalProfile({TERM: 'xterm-kitty', KITTY_WINDOW_ID: '1', TERM_PROGRAM: 'zed'}), 'kitty'); + assert.equal(detectTerminalHost({TERM: 'xterm-kitty', KITTY_WINDOW_ID: '1', TERM_PROGRAM: 'iTerm.app'}, 'darwin').name, 'kitty'); + // Ghostty started from a Kitty shell inherits KITTY_WINDOW_ID, but sets its own TERM. + assert.equal(terminalProfile({TERM: 'xterm-ghostty', TERM_PROGRAM: 'ghostty', KITTY_WINDOW_ID: '3'}), 'ghostty'); + assert.equal(detectTerminalHost({TERM: 'xterm-ghostty', KITTY_WINDOW_ID: '3'}, 'darwin').name, 'Ghostty'); + // Unchanged: an explicit program without a terminal-specific TERM, and the outer host's markers inside tmux. + assert.equal(terminalProfile({TERM: 'xterm-256color', TERM_PROGRAM: 'zed', KITTY_WINDOW_ID: '1'}), 'zed'); + assert.equal(detectTerminalHost({TERM: 'tmux-256color', TERM_PROGRAM: 'tmux', TMUX: '/t,1,0', GHOSTTY_RESOURCES_DIR: '/g'}, 'darwin').name, 'Ghostty'); + assert.equal(detectTerminalHost({TERM: 'tmux-256color', TERM_PROGRAM: 'tmux', TMUX: '/t,1,0', KITTY_WINDOW_ID: '1'}, 'darwin').name, 'kitty'); + // A stale KITTY_WINDOW_ID does not outrank a host TERM_PROGRAM names (Terminal.app started from a Kitty shell). + assert.equal(detectTerminalHost({TERM: 'xterm-256color', TERM_PROGRAM: 'Apple_Terminal', KITTY_WINDOW_ID: '1'}, 'darwin').name, 'Terminal'); +}); + +test('cursor host facts use the same identity: Kitty with an inherited Ghostty TERM_PROGRAM is Kitty', async () => { + const {hostCursorFacts} = await import('../src/cursor/backends.js'); + const facts = hostCursorFacts({TERM: 'xterm-kitty', KITTY_WINDOW_ID: '1', TERM_PROGRAM: 'ghostty', TERM_PROGRAM_VERSION: '1.3.1'}, () => false, () => 'cursor-trail-lightning'); + assert.equal(facts.host, 'kitty'); + assert.equal(facts.kittyTrail, 'cursor-trail-lightning'); + assert.equal(hostCursorFacts({TERM: 'xterm-ghostty', TERM_PROGRAM: 'ghostty', KITTY_WINDOW_ID: '9'}, () => false).host, 'ghostty'); + assert.equal(hostCursorFacts({TERM: 'tmux-256color', TERM_PROGRAM: 'tmux', KITTY_WINDOW_ID: '1'}, () => false).host, 'kitty', 'Kitty’s trail still draws around tmux'); +}); diff --git a/tests/kittyShaderPanel.test.ts b/tests/kittyShaderPanel.test.ts index e3792a93..11d85a24 100644 --- a/tests/kittyShaderPanel.test.ts +++ b/tests/kittyShaderPanel.test.ts @@ -9,6 +9,7 @@ import {appearanceHubKey, createAppearanceHub, renderAppearanceHub} from '../src import {DEFAULT_HOST_SHADERS} from '../src/appearance/kittyShaderSettings.js'; import {DEFAULT_PROMPT_CONFIGURATION} from '../src/prompt/configuration.js'; import {TerminalApp} from '../src/app/TerminalApp.js'; +import {cursorPanelRowKeys} from '../src/cursor/CursorPanel.js'; import {stripAnsi} from '../src/util/text.js'; const RESOURCES: Record = { @@ -147,3 +148,90 @@ test('app: /appearance › Kitty shaders reviews, writes only after Enter, saves assert.equal(app['kittyShaderPanel'], undefined); assert.ok(app['appearanceHub']?.kittyShaders?.startsWith('1 effect chosen')); }); + +test('app: first-time Kitty (inherited Ghostty TERM_PROGRAM, no NMSh setup, Motion Off): Host native opens the Kitty studio and a trail is really drawn', async t => { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-kfirst-'))); + const saved = {...process.env}; + // Kitty started from a Ghostty shell keeps the outer TERM_PROGRAM (observed with Kitty 0.49.2). + Object.assign(process.env, {HOME: home, XDG_CONFIG_HOME: join(home, '.config'), KITTY_WINDOW_ID: '1', KITTY_PID: String(process.pid), TERM: 'xterm-kitty', + TERM_PROGRAM: 'ghostty', TERM_PROGRAM_VERSION: '1.3.1'}); + for (const name of ['KITTY_CONFIG_DIRECTORY', 'TMUX']) delete process.env[name]; + const app = new TerminalApp(); + t.after(() => { + app['stop'](0); app['session'].kill(); + for (const name of Object.keys(process.env)) if (!(name in saved)) delete process.env[name]; + Object.assign(process.env, saved); + rmSync(home, {recursive: true, force: true}); + }); + const kittyConf = join(home, '.config/kitty/kitty.conf'); + mkdirSync(join(home, '.config/kitty'), {recursive: true, mode: 0o700}); + writeFileSync(kittyConf, 'font_size 12\n', {mode: 0o600}); + app['kittyCatalogRead'] = Promise.resolve(catalog); + app['updateConfiguration']((configuration: typeof DEFAULT_PROMPT_CONFIGURATION) => { configuration.cursor.motion = 'off'; configuration.cursor.renderer = 'portable'; }); + // /appearance offers Kitty's studio, not Ghostty's. + await app['startAppearance'](); + assert.equal(app['glslStudio'].available(), false); + assert.ok(app['appearanceHub']?.kittyShaders, 'the Kitty shaders row is shown'); + app['appearanceHub'] = undefined; + // /cursor › Host native setup, with no /cursor include in kitty.conf: straight to the studio. + app['openCursorPanel'](); + const cursor = app['cursorPanel']!; + cursor.selected = cursorPanelRowKeys(false, cursor.draft).indexOf('nativeSetup'); + assert.ok(cursor.selected >= 0); + app['handleCursorPanelKey'](key('enter'), cursor); + for (let wait = 0; wait < 50 && !app['kittyShaderPanel']?.inspection; wait++) await new Promise(resolve => setTimeout(resolve, 10)); + assert.equal(app['cursorPanel'], undefined, 'the cursor panel handed over to the studio'); + const state = app['kittyShaderPanel']!; + assert.ok(state?.inspection, state?.message); + select(state, 'Blaze trail'); + app['onInput']('\r'); + app['onInput']('s'); + assert.ok(state.confirmation, state.message); + const review = state.confirmation.plan.preview.join('\n'); + assert.match(review, /Also changes \/cursor Motion: Off → Tail/u); + assert.match(review, /Also changes \/cursor renderer: Portable → Auto/u); + assert.match(review, /Turns Kitty's cursor_trail on \(3; it would be 0\)/u); + assert.equal(readFileSync(kittyConf, 'utf8'), 'font_size 12\n', 'review writes nothing'); + app['onInput']('\r'); + assert.equal(state.confirmation, undefined, state.message); + assert.deepEqual([app['promptConfiguration'].cursor.motion, app['promptConfiguration'].cursor.renderer], ['tail', 'auto']); + assert.match(readFileSync(join(home, '.config/nmsh/host-appearance/kitty-shaders.conf'), 'utf8'), /^cursor_trail 3$/mu); + // Kitty draws the trail now; Portable does not draw a second one. + assert.equal(app['cursorFacts']().kittyTrail, 'cursor-trail-blaze'); + // KITTY_PID here is the test process (node), not a kitty parent: nothing is signalled and the reload keys are given. + assert.match(state.message ?? '', /Reload Kitty to load it/u); +}); + +test('app: the /cursor changes shown in a Kitty review are the ones applied; a change meanwhile refuses; Reduced Motion changes nothing', async t => { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-kstale-'))); + const saved = {...process.env}; + Object.assign(process.env, {HOME: home, XDG_CONFIG_HOME: join(home, '.config'), KITTY_WINDOW_ID: '1', KITTY_PID: String(process.pid), TERM: 'xterm-kitty'}); + for (const name of ['KITTY_CONFIG_DIRECTORY', 'TERM_PROGRAM', 'TERM_PROGRAM_VERSION', 'TMUX']) delete process.env[name]; + const app = new TerminalApp(); + t.after(() => { + app['stop'](0); app['session'].kill(); + for (const name of Object.keys(process.env)) if (!(name in saved)) delete process.env[name]; + Object.assign(process.env, saved); + rmSync(home, {recursive: true, force: true}); + }); + const kittyConf = join(home, '.config/kitty/kitty.conf'); + mkdirSync(join(home, '.config/kitty'), {recursive: true, mode: 0o700}); + writeFileSync(kittyConf, 'font_size 12\n', {mode: 0o600}); + app['kittyCatalogRead'] = Promise.resolve(catalog); + app['updateConfiguration']((configuration: typeof DEFAULT_PROMPT_CONFIGURATION) => { configuration.cursor.motion = 'off'; }); + await app['openKittyShaders'](); + const state = app['kittyShaderPanel']!; + select(state, 'Blaze trail'); + app['onInput']('\r'); + app['onInput']('s'); + assert.match(state.confirmation!.plan.preview.join('\n'), /Also changes \/cursor Motion: Off → Tail/u); + // Motion turned on elsewhere while the review is open: the shown change no longer applies, so nothing is written. + app['updateConfiguration']((configuration: typeof DEFAULT_PROMPT_CONFIGURATION) => { configuration.cursor.motion = 'smear'; }); + app['onInput']('\r'); + assert.match(state.message ?? '', /Something changed since the review/u); + assert.equal(readFileSync(kittyConf, 'utf8'), 'font_size 12\n'); + assert.equal(app['promptConfiguration'].cursor.motion, 'smear'); + // Under Reduced Motion a trail is not written, so /cursor is not changed for it either. + app['updateConfiguration']((configuration: typeof DEFAULT_PROMPT_CONFIGURATION) => { configuration.cursor.motion = 'off'; configuration.presentation.reducedMotion = true; }); + assert.deepEqual(app['kittyTrailFollowUp'](state.draft, catalog).changes, []); +}); diff --git a/tests/kittyShaders.test.ts b/tests/kittyShaders.test.ts index 21558a16..0e139d20 100644 --- a/tests/kittyShaders.test.ts +++ b/tests/kittyShaders.test.ts @@ -5,7 +5,7 @@ import {tmpdir} from 'node:os'; import {dirname, join} from 'node:path'; import {buildKittyCatalog, discoverKittyCatalog, emitKittyPipeline, formatShaderValue, parseKittyPipeline, parseShaderConstants, type KittyCatalog} from '../src/appearance/kittyShaderCatalog.js'; import {applyKittyShaderPlan, inspectKittyLaunch, inspectKittyShaders, kittyShaderPaths, launchOverrideReason, normalizeHostShaders, planKittyShaderRemoval, planKittyShaders, - shellWords, syncKittyShaders, validateEffects, type KittyShaderContext, type KittyShaderSettings, type ShaderGates} from '../src/appearance/kittyShaders.js'; + kittyReloadTarget, reloadKitty, shellWords, syncKittyShaders, validateEffects, type KittyShaderContext, type KittyShaderSettings, type ShaderGates} from '../src/appearance/kittyShaders.js'; import {readHostConfiguration} from '../src/appearance/hostConfiguration.js'; // Synthetic stand-ins with the SAME structure as Kitty 0.49.2's shipped resources (not copies of them). @@ -378,12 +378,12 @@ test('stale files and interrupted writes: edited or unowned NMSh files and chang assert.equal(existsSync(m.paths.pipeline), false); }); -test('notes: trail needs cursor_trail, CRT needs an opaque background, missing user shaders are kept as written', t => { +test('notes: a trail turns cursor_trail on, CRT needs an opaque background, missing user shaders are kept as written', t => { const f = fixture(t); f.put(f.kitty, 'background_opacity 0.8\ncustom_shaders not-installed\n'); const plan = planKittyShaders(settingsOf(['cursor-trail-blaze'], ['crt']), f.context); assert.ok(plan.ok); - assert.ok(plan.plan.notes.some(note => /cursor_trail is above 0 \(now 0\)/u.test(note))); + assert.ok(plan.plan.notes.some(note => /Turns Kitty's cursor_trail on \(3; it would be 0\)/u.test(note))); assert.ok(plan.plan.notes.some(note => /background_opacity 0\.8/u.test(note))); assert.ok(plan.plan.notes.some(note => /cannot find your shader “not-installed”; it is kept exactly as written/u.test(note))); assert.deepEqual(plan.plan.effectiveShaders, ['not-installed', kittyShaderPaths(f.context).pipeline]); @@ -476,3 +476,80 @@ test('a leaked user var that NMSh cannot pin (multi-line or repeated declaration g.put(g.kitty, 'custom_shaders ~other/shaders/x\n'); assert.match((planKittyShaders(settingsOf(['crt']), g.context) as {error: string}).error, /~user/u); }); + +test('a trail with cursor_trail at 0 is turned on by NMSh’s own fragment, kept by sync, refused when a later line forces 0', async t => { + const {writtenKittyTrail} = await import('../src/appearance/kittyShaders.js'); + // Kitty's default (no cursor_trail line), which is also what /cursor's file says with Motion off: the shader would never draw. + const f = fixture(t); + f.put(f.kitty, 'font_size 12\n'); + const settings = settingsOf(['cursor-trail-lightning']); + const plan = apply(planKittyShaders(settings, f.context)); + assert.ok(plan.notes.some(note => /Turns Kitty's cursor_trail on \(3; it would be 0\)/u.test(note)), plan.notes.join('\n')); + assert.match(readFileSync(f.paths.fragment, 'utf8'), /^cursor_trail 3$/mu); + assert.equal(Number(readHostConfiguration('kitty', f.context).values.get('cursor_trail')?.value), 3); + assert.equal(writtenKittyTrail(f.context), 'cursor-trail-lightning', 'Kitty owns the trail, so Portable draws no second one'); + // Sync judges without NMSh's own line, so it keeps that line instead of dropping it and refusing. + assert.equal(syncKittyShaders(settings, f.context).kind, 'unchanged'); + // /cursor Motion Off removes NMSh's trail (like a Portable renderer) and its cursor_trail line with it. + assert.equal(syncKittyShaders(settings, {...f.context, gates: {...openGates, cursorMotion: 'off'}}).kind, 'written'); + assert.doesNotMatch(readFileSync(f.paths.fragment, 'utf8'), /cursor_trail/u); + assert.equal(writtenKittyTrail(f.context), undefined); + // Back on: the trail and its line return. A later line forcing 0 after NMSh's include refuses with the reason. + assert.equal(syncKittyShaders(settings, f.context).kind, 'written'); + assert.match(readFileSync(f.paths.fragment, 'utf8'), /^cursor_trail 3$/mu); + writeFileSync(f.kitty, `${readFileSync(f.kitty, 'utf8')}cursor_trail 0\n`); + const refused = planKittyShaders(settings, f.context); + assert.ok(!refused.ok && /cursor_trail would still be 0 \(set later in .*kitty\.conf\)/u.test(refused.error), refused.ok ? 'planned' : refused.error); + // The person's own positive value is kept as it is: no second cursor_trail line. + const g = fixture(t); + g.put(g.kitty, 'cursor_trail 10\n'); + apply(planKittyShaders(settingsOf(['cursor-trail-blaze']), g.context)); + assert.doesNotMatch(readFileSync(g.paths.fragment, 'utf8'), /cursor_trail/u); + // A non-trail effect never touches cursor_trail. + const h = fixture(t); + h.put(h.kitty, ''); + apply(planKittyShaders(settingsOf(['northern-lights']), h.context)); + assert.doesNotMatch(readFileSync(h.paths.fragment, 'utf8'), /cursor_trail/u); +}); + +test('switching trail styles replaces the one trail group; Lightning and Blaze reference different shipped shaders', t => { + const f = fixture(t); + f.put(f.kitty, 'cursor_trail 3\n'); + apply(planKittyShaders(settingsOf(['cursor-trail-lightning']), f.context)); + const lightning = readFileSync(f.paths.pipeline, 'utf8'); + apply(planKittyShaders(settingsOf(['cursor-trail-blaze']), f.context)); + const blaze = readFileSync(f.paths.pipeline, 'utf8'); + assert.deepEqual([...lightning.matchAll(/^\s*shaders (.+)$/gmu)].map(match => match[1]), ['cursor-trail-lightning']); + assert.deepEqual([...blaze.matchAll(/^\s*shaders (.+)$/gmu)].map(match => match[1]), ['cursor-trail-blaze'], 'one trail, never both'); + assert.equal((blaze.match(/animation_start cursor-trail-move/gu) ?? []).length, 1); +}); + +test('reload: Kitty ignores changed included files, so NMSh asks only the verified Kitty among its parents to reload', t => { + const rows = [ + {pid: 10, ppid: 1, elapsedSeconds: 50, command: '/Applications/kitty.app/Contents/MacOS/kitty'}, + {pid: 20, ppid: 10, elapsedSeconds: 40, command: '/bin/zsh -l'}, + {pid: 30, ppid: 20, elapsedSeconds: 30, command: 'node /x/bin/nmsh'}, + {pid: 40, ppid: 1, elapsedSeconds: 30, command: '/usr/bin/kitty'}, + ]; + assert.equal(kittyReloadTarget('10', rows, 30), 10); + assert.equal(kittyReloadTarget('40', rows, 30), undefined, 'a Kitty that is not NMSh’s parent (stale or inherited KITTY_PID)'); + assert.equal(kittyReloadTarget('20', rows, 30), undefined, 'a parent that is not kitty (a reused number)'); + assert.equal(kittyReloadTarget(undefined, rows, 30), undefined); + assert.equal(kittyReloadTarget('10', undefined, 30), undefined, 'no process table: never a guess'); + const sent: number[] = []; + const signal = (pid: number) => { sent.push(pid); }; + assert.equal(reloadKitty({KITTY_PID: '10'}, 'darwin', {rows: () => rows, selfPid: 30, signal}).reloaded, true); + assert.deepEqual(sent, [10]); + const refused = reloadKitty({KITTY_PID: '40'}, 'darwin', {rows: () => rows, selfPid: 30, signal}); + assert.deepEqual([refused.reloaded, sent], [false, [10]]); + assert.match(refused.message, /Reload Kitty to load it \(⌃⌘,\)/u); + const failed = reloadKitty({KITTY_PID: '10'}, 'linux', {rows: () => rows, selfPid: 30, signal: () => { throw new Error('EPERM'); }}); + assert.equal(failed.reloaded, false); + assert.match(failed.message, /Ctrl\+Shift\+F5/u); + // The review says so before anything is written. + const f = fixture(t); + f.put(f.kitty, 'cursor_trail 3\n'); + const plan = planKittyShaders(settingsOf(['cursor-trail-blaze']), f.context); + assert.ok(plan.ok && plan.plan.preview.some(line => /NMSh asks the Kitty it runs in to reload its configuration/u.test(line))); + assert.ok(plan.ok && /Kitty watches only kitty\.conf, not the files it includes/u.test(plan.plan.guidance)); +}); diff --git a/tests/shaderPreview.test.ts b/tests/shaderPreview.test.ts index ecb56f16..be733710 100644 --- a/tests/shaderPreview.test.ts +++ b/tests/shaderPreview.test.ts @@ -15,7 +15,11 @@ const key = (kind: string, value = ''): Key => ({kind, value} as unknown as Key) test('every preset family maps to a preview or honestly to none', () => { assert.equal(previewKind('smear', 'cursor'), 'trail'); - assert.equal(previewKind('cursor-trail-blaze', 'cursor'), 'trail'); + assert.equal(previewKind('cursor-trail-default', 'cursor'), 'trail'); + assert.equal(previewKind('cursor-trail-blaze', 'cursor'), 'blaze'); + assert.equal(previewKind('cursor-trail-lightning', 'cursor'), 'lightning'); + assert.equal(previewKind('cursor-trail-motion-blur', 'cursor'), 'motion-blur'); + assert.equal(previewKind('comet', 'cursor'), 'comet'); assert.equal(previewKind('northern-lights', 'ambient'), 'aurora'); assert.equal(previewKind('focus-dim', 'focus'), 'dim'); assert.equal(previewKind('crt', 'display'), 'scanlines'); @@ -23,7 +27,7 @@ test('every preset family maps to a preview or honestly to none', () => { }); test('previews are deterministic, bounded, play once and settle; Reduced Motion is one still frame', () => { - for (const kind of ['trail', 'aurora', 'dim', 'vignette', 'scanlines', 'glow'] as const) { + for (const kind of ['trail', 'comet', 'blaze', 'lightning', 'motion-blur', 'aurora', 'dim', 'vignette', 'scanlines', 'glow'] as const) { const frame = renderShaderPreview(input({kind})); assert.equal(frame.rows.length, PREVIEW_ROWS, kind); assert.deepEqual(frame, renderShaderPreview(input({kind})), `${kind} is deterministic`); @@ -94,3 +98,19 @@ test('a dropped preview shows no replay hint and restarts when it can be seen; v assert.equal(state.previewStart, 5000, 'the run starts when the preview is visible'); assert.doesNotMatch(renderGhosttyShaderPanel(state, 20, undefined, studioPreviewContext(6000, false, true)).map(stripAnsi).join('\n'), /Preview ·/u); }); + +test('trail styles differ in shape, not only in color: Lightning is never drawn like Blaze', () => { + // The same colors for every style, so only the drawing can tell them apart. + const values = {HEAD: [1, 0.8, 0.6], TAIL: [0.8, 0.1, 0.1]}; + const cells = (kind: PreviewInput['kind'], elapsed: number) => renderShaderPreview(input({kind, values, elapsed})).rows.join('\n'); + const kinds = ['trail', 'comet', 'blaze', 'lightning', 'motion-blur'] as const; + for (const elapsed of [80, 300]) { + const frames = kinds.map(kind => cells(kind, elapsed)); + assert.equal(new Set(frames).size, kinds.length, `every style draws its own frame at ${elapsed} ms`); + } + // Lightning flashes and is gone long before Blaze burns out; both settle (no idle loop). + assert.equal(renderShaderPreview(input({kind: 'lightning', values, elapsed: 900})).rows.join(''), renderShaderPreview(input({kind: 'lightning', values, elapsed: 1000})).rows.join('')); + assert.notEqual(cells('blaze', 900), cells('blaze', 1300), 'Blaze is still burning'); + assert.ok(PREVIEW_MS.lightning < PREVIEW_MS.blaze); + for (const kind of kinds) assert.equal(renderShaderPreview(input({kind, values, elapsed: PREVIEW_MS[kind]})).busy, false, `${kind} settles`); +});