From 53b86c394156acdf9a571d404469ebe8dff95e06 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:55:38 +0530 Subject: [PATCH 1/5] Kitty native shaders: correct host identity, first-run route, a trail that draws, distinct trail previews - terminalProfile: a terminal-specific TERM (xterm-kitty, xterm-ghostty) wins over an inherited TERM_PROGRAM. Kitty passes the outer TERM_PROGRAM through (Kitty 0.49.2 started from Ghostty: TERM=xterm-kitty, KITTY_WINDOW_ID=1, TERM_PROGRAM=ghostty), so NMSh showed Ghostty's studio in Kitty. Cursor host facts and detectTerminalHost now use the same identity (tmux keeps the outer host's markers as before). - /cursor > Host native in Kitty 0.49+ opens the Kitty shader studio on first use too, instead of the older include setup; Kitty below 0.49 keeps the built-in cursor_trail setup. - A Kitty trail shader draws only while cursor_trail > 0. NMSh's own fragment now turns it on when it would be 0 (judged without NMSh's own line, so sync keeps it), refuses when a later line forces 0, and the review says so. Confirming a trail sets /cursor Motion Off -> Tail and Portable -> Auto (disclosed in the review); Motion Off later removes NMSh's trail like a Portable renderer. - Previews: Blaze, Lightning, Motion blur and Comet each draw their own shape. All trails shared one preview that differed only in color, so Lightning looked like Blaze. The emitted pipelines already referenced the right shipped shader (regression added). --- src/app/TerminalApp.ts | 66 +++++++++++++++++----- src/appearance/kittyShaders.ts | 36 +++++++++--- src/appearance/shaderPreview.ts | 99 ++++++++++++++++++++++++++++----- src/cursor/backends.ts | 9 ++- src/host/capabilities.ts | 7 ++- src/host/terminalHost.ts | 15 +++-- tests/hostProfiles.test.ts | 27 +++++++++ tests/kittyShaderPanel.test.ts | 52 +++++++++++++++++ tests/kittyShaders.test.ts | 51 ++++++++++++++++- tests/shaderPreview.test.ts | 24 +++++++- 10 files changed, 338 insertions(+), 48 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 932b19fa..3d86fa62 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, 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. @@ -3913,7 +3913,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 +3947,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,7 +3962,7 @@ 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)); + const again = planKittyShaders(panel.draft, this.kittyShaderContext(catalog, trail.config)); return again.ok && again.plan.pipelineText === reviewed.pipelineText && JSON.stringify(again.plan.effectiveShaders) === JSON.stringify(reviewed.effectiveShaders) ? reviewed : undefined; })(); if (!plan) { panel.message = 'Something changed since the review (Reduced Motion, the cursor renderer or Kitty’s configuration); nothing was written. Press S to review again.'; return; } @@ -3966,13 +3971,30 @@ 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; } + /** + * 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')); + if (!trail || (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}; + } + /** * After a reviewed setup, keep NMSh's own Kitty pipeline in step with Reduced Motion, Effects Off and the cursor renderer. * Only NMSh-owned files are rewritten; anything needing a new review is said, not written. @@ -10599,18 +10621,34 @@ 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. + state.message = 'Checking Kitty’s shader support…'; + void this.kittyCatalog().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..afc36ed3 100644 --- a/src/appearance/kittyShaders.ts +++ b/src/appearance/kittyShaders.ts @@ -23,21 +23,28 @@ import {digest, snapshot, transact, type FileChange, type FileSnapshot} from './ import {withoutHook} from './hostAppearance.js'; import {CATEGORY_LABELS, defaultPipeline, emitKittyPipeline, formatShaderValue, KITTY_MAX_GROUPS, mergeVars, parseKittyPipeline, pipelineTextures, subscribesToCursorTrail, type KittyCatalog, type KittyGroup, type KittyPipeline, type KittyVar, type OfferedParam, type OfferedPreset, type ShaderValue} from './kittyShaderCatalog.js'; -import type {CursorRenderer} from '../prompt/configuration.js'; +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}]; }); } @@ -357,7 +364,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 +375,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,13 +386,29 @@ 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}))}; 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..4178aee3 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} : {})}; } 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..5d3e5621 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' || (profile === 'baseline' && env.KITTY_WINDOW_ID)) { + // 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,10 +83,6 @@ 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. - 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'}; // Zed exposes no documented way to open a command in a new integrated // terminal, so sessions beyond the first use /resume or `nmsh --attach`. diff --git a/tests/hostProfiles.test.ts b/tests/hostProfiles.test.ts index 0549734d..fd719772 100644 --- a/tests/hostProfiles.test.ts +++ b/tests/hostProfiles.test.ts @@ -164,3 +164,30 @@ 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'); +}); + +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..9a79f8e9 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,54 @@ 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'); +}); diff --git a/tests/kittyShaders.test.ts b/tests/kittyShaders.test.ts index 21558a16..5ebd200f 100644 --- a/tests/kittyShaders.test.ts +++ b/tests/kittyShaders.test.ts @@ -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,50 @@ 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); +}); 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`); +}); From acc4d6c8f36aeeef25e416d49c92dd1dd0495f1a Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:58:04 +0530 Subject: [PATCH 2/5] Cursor: Host native chosen before setup draws Portable meanwhile instead of a motionless cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Renderer = Host native in Ghostty or Kitty and nothing native set up yet, no GPU trail exists, but Portable was told to draw nothing, so the cursor looked frozen. Portable now draws until setup, labelled 'Portable fallback · native is not set up yet', and the /cursor rows say 'Portable until setup'. A host with no native effects at all keeps the existing 'Host native is not available' state. --- src/cursor/backends.ts | 14 +++++++++----- tests/cursorCapabilities.test.ts | 4 ++-- tests/cursorEffects.test.ts | 5 +++++ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/cursor/backends.ts b/src/cursor/backends.ts index 4178aee3..2600e188 100644 --- a/src/cursor/backends.ts +++ b/src/cursor/backends.ts @@ -143,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 { @@ -173,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. @@ -207,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/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); From ebe54eba46922fc523b3d4c609e3a6f812f5d3e0 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:07:36 +0530 Subject: [PATCH 3/5] Kitty shaders: ask the Kitty NMSh runs in to reload after a change, or say how Kitty watches only kitty.conf, not the files it includes. Switching Lightning to Blaze rewrites only NMSh's pipeline file, so Kitty kept drawing the previous effect until a manual reload; the guidance wrongly said Kitty reloads automatically. Observed in Kitty 0.49.2: Lightning written, Blaze still drawn; after SIGUSR1, Lightning drawn. After a confirmed write, a removal or a gate sync, NMSh now sends Kitty's documented reload signal (SIGUSR1) to KITTY_PID only when that process is one of NMSh's own parents and its program is kitty; otherwise it gives the reload keys. The review discloses this before anything is written. --- src/app/TerminalApp.ts | 10 ++++++--- src/appearance/kittyShaders.ts | 37 +++++++++++++++++++++++++++++++++- tests/kittyShaderPanel.test.ts | 2 ++ tests/kittyShaders.test.ts | 32 ++++++++++++++++++++++++++++- 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 3d86fa62..3c6a8b5e 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 {type KittyShaderSettings, 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'; @@ -3978,7 +3978,9 @@ export class TerminalApp { 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}`; } /** @@ -4006,8 +4008,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(); diff --git a/src/appearance/kittyShaders.ts b/src/appearance/kittyShaders.ts index afc36ed3..25b28af7 100644 --- a/src/appearance/kittyShaders.ts +++ b/src/appearance/kittyShaders.ts @@ -21,6 +21,7 @@ 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 {CursorMotion, CursorRenderer} from '../prompt/configuration.js'; @@ -284,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' @@ -415,6 +449,7 @@ function compose(settings: KittyShaderSettings, context: KittyShaderContext, mod 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/tests/kittyShaderPanel.test.ts b/tests/kittyShaderPanel.test.ts index 9a79f8e9..7f76300c 100644 --- a/tests/kittyShaderPanel.test.ts +++ b/tests/kittyShaderPanel.test.ts @@ -198,4 +198,6 @@ test('app: first-time Kitty (inherited Ghostty TERM_PROGRAM, no NMSh setup, Moti 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); }); diff --git a/tests/kittyShaders.test.ts b/tests/kittyShaders.test.ts index 5ebd200f..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). @@ -523,3 +523,33 @@ test('switching trail styles replaces the one trail group; Lightning and Blaze r 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)); +}); From b8ad110755db951db67e67b66fa625e2d3f32d6d Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:16:33 +0530 Subject: [PATCH 4/5] Docs: native host fixes research, rulings, QA results and first-run checklist step --- docs/development/native-appearance-welcome/DECISIONS.md | 9 +++++++++ .../native-appearance-welcome/QA_CHECKLIST.md | 1 + docs/development/native-appearance-welcome/RESEARCH.md | 9 +++++++++ .../native-appearance-welcome/TEST_RESULTS.md | 6 ++++++ 4 files changed, 25 insertions(+) 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. From 0499f8b5d7aed4bb4564dab42001b864ce401bf2 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:27:33 +0530 Subject: [PATCH 5/5] Review fixes: exact /cursor follow-up at confirm, one Kitty catalog check, tmux Kitty fallback order - Confirming a Kitty review refuses when the /cursor changes it showed are no longer the ones that would apply (Motion or renderer changed meanwhile); under Reduced Motion or Effects Off a trail is not written, so /cursor is left alone. - A second Enter on /cursor > Host native setup while Kitty's catalog is read does nothing. - detectTerminalHost: the tmux fallback (KITTY_WINDOW_ID without a profile) returns to its old place after the hosts TERM_PROGRAM names, so a stale KITTY_WINDOW_ID does not outrank Terminal.app. --- src/app/TerminalApp.ts | 15 ++++++++++++--- src/host/terminalHost.ts | 6 +++++- tests/hostProfiles.test.ts | 2 ++ tests/kittyShaderPanel.test.ts | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 3c6a8b5e..e2244f81 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -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, @@ -3963,7 +3965,10 @@ export class TerminalApp { // 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, trail.config)); - return again.ok && again.plan.pipelineText === reviewed.pipelineText && JSON.stringify(again.plan.effectiveShaders) === JSON.stringify(reviewed.effectiveShaders) ? reviewed : undefined; + // 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); @@ -3989,7 +3994,9 @@ export class TerminalApp { */ 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')); - if (!trail || (config.cursor.motion !== 'off' && config.cursor.renderer !== 'portable')) return {config, changes: []}; + // 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.'); } @@ -10642,8 +10649,10 @@ export class TerminalApp { 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().then(catalog => { + 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(); diff --git a/src/host/terminalHost.ts b/src/host/terminalHost.ts index 5d3e5621..05fcc882 100644 --- a/src/host/terminalHost.ts +++ b/src/host/terminalHost.ts @@ -62,7 +62,7 @@ export function detectTerminalHost(env: NodeJS.ProcessEnv = process.env, platfor ? {command: 'osascript', args: [...GHOSTTY_NEW_WINDOW_SCRIPT.flatMap(line => ['-e', line]), ...argv]} : {command: 'ghostty', args: ['-e', ...argv]}}; } - if (profile === 'kitty' || (profile === 'baseline' && env.KITTY_WINDOW_ID)) { + 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]})}; } @@ -83,6 +83,10 @@ 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 (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'}; // Zed exposes no documented way to open a command in a new integrated // terminal, so sessions beyond the first use /resume or `nmsh --attach`. diff --git a/tests/hostProfiles.test.ts b/tests/hostProfiles.test.ts index fd719772..e6fd2306 100644 --- a/tests/hostProfiles.test.ts +++ b/tests/hostProfiles.test.ts @@ -181,6 +181,8 @@ test('host identity: the terminal’s own TERM wins over an inherited TERM_PROGR 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 () => { diff --git a/tests/kittyShaderPanel.test.ts b/tests/kittyShaderPanel.test.ts index 7f76300c..11d85a24 100644 --- a/tests/kittyShaderPanel.test.ts +++ b/tests/kittyShaderPanel.test.ts @@ -201,3 +201,37 @@ test('app: first-time Kitty (inherited Ghostty TERM_PROGRAM, no NMSh setup, Moti // 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, []); +});