Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/development/native-appearance-welcome/RESEARCH.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,12 @@ Kitty0.49.2 installed catalog probe (headless, private HOME): read_kitty_resourc
- 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).

## NMSh's own shaders (verified 2026-10-29, Kitty 0.49.2, Ghostty 1.3.1; evidence /Volumes/T7Tmp/ai-dev/nmsh-native-host-fixes-20261029/shaders)
- Eight original effects in two new studio categories. **Cursor & typing** (one at a time, and exclusive with the trails): Ink Bloom, Type Garden, Filament, Glitch Hop, Gravity Lens. **Welcome & images** (combinable with one cursor effect): Holo Foil, Shine Sweep, Halo Bloom. Each has at most three choices: Color (a curated palette or "From theme" = the host's live cursor color), Strength (Subtle/Balanced/Vivid) and Speed (Calm/Normal/Lively), or Shine Sweep's cadence.
- One source, two hosts (`src/appearance/nmshShaders.ts`). The effect cores are a small GLSL subset that is mechanically translated to Slang. Only the wrappers differ. Kitty's colors are linear, so its wrapper converts samples to gamma 2 and back, which keeps thresholds and palettes identical on both hosts.
- Welcome effects act only on *solid colorful* pixels: a pixel and most of a 5 px ring around it must be saturated. Blocks, images and filled logo art pass; plain and colored words stay untouched (screenshots gpu/shots/*-holo-foil-*, *-halo-bloom-*, *-shine-burst-*).
- **Kitty** reports the cursor (`cursor_trail_edge`, `cursor_trail_prev_edge`, `cursor_trail_change_time`) only while its trail animates. All fields read zero otherwise (probe shots k/shots/p*, q*, t-*). Its default `cursor_trail_start_threshold 2` skips one-cell moves, so typing never started a trail; 0 does. With the default decay a one-cell trail ends in under 0.5 s; with `cursor_trail_decay 3 6` it lasted about 1.3 s. While a typing effect is written, NMSh's fragment therefore adds `cursor_trail_start_threshold 0` and `cursor_trail_decay 6 12`, and the review notes any later line that overrides them. The group subscribes to `cursor-trail-move`/`cursor-trail-stop`, which also hides Kitty's built-in trail. A new move restarts the group's animation.
- Kitty looks up shader names in the pipeline's own folder first. NMSh writes `nmsh-<effect>.slang` there, records each in the ledger with its hash, never overwrites an unrecorded or edited file, deletes the ones it no longer uses, and R removes exactly them. Values travel as group `var int` lines, so the `.slang` files never change with settings. Kitty compiles with warnings as errors; every effect compiles with Kitty's own `build_custom_shader_pipeline_glsl` (tests/kittyShadersLive).
- **Ghostty** on macOS renders through Metal and fragCoord y grows *downward* (observed: sprouts first grew down). The cursor math (`xy` = the "+Y" corner) is self-consistent either way. Only the direction of "up" differs, so the generator emits `NMSH_UP` from the platform: -1 on darwin, +1 elsewhere. Linux was not verified. The existing Aurora preset assumes y-up and may draw from the bottom on macOS (not changed here).
- Shine Sweep needs Ghostty 1.3 (`iTimeFocus`). On Kitty, "On focus only" uses `animation_start os-window-focus-in`; the timed cadences run every frame (`animation_step 33`). Halo Bloom samples 48 neighbours per pixel and is the most expensive effect.
2 changes: 1 addition & 1 deletion src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4040,7 +4040,7 @@ export class TerminalApp {
* 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'));
const trail = draft.effects.some(effect => effect.enabled && catalog.offered.some(item => item.definition.id === effect.id && (item.definition.category === 'cursor' || item.definition.category === 'typing')));
// 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: []};
Expand Down
2 changes: 1 addition & 1 deletion src/appearance/KittyShaderPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export type KittyShaderAction = Exclude<StudioAction, {kind: 'reloaded'}>;
export const KITTY_STUDIO: StudioHost<ShaderGates> = {
name: 'Kitty',
placement: true,
intro: catalog => `Kitty ${catalog.version} · shipped shaders read from Kitty itself · effects cover the whole Kitty window, not only NMSh`,
intro: catalog => `Kitty ${catalog.version} · shipped shaders read from Kitty itself, plus NMSh’s own (written beside NMSh’s pipeline) · effects cover the whole Kitty window, not only NMSh`,
unsupportedHint: 'Cursor effects stay with /cursor (Portable, or Kitty’s built-in trail).',
status: inspection => inspection.drifted ? 'Your own custom_shaders changed since NMSh composed with it; S reviews an updated plan.'
: inspection.configured ? `Set up · ${inspection.activePresets.length ? `${inspection.activePresets.length} NMSh effect${inspection.activePresets.length === 1 ? '' : 's'} in Kitty’s config` : 'no NMSh effect active'}`
Expand Down
9 changes: 7 additions & 2 deletions src/appearance/ShaderStudioPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {stripTerminalControls} from '../util/terminalControls.js';
import {CATEGORY_LABELS, type OfferedParam, type PresetCategory, type ShaderValue} from './kittyShaderCatalog.js';
import type {KittyShaderEffect} from './kittyShaderSettings.js';
import {PREVIEW_MS, previewKind, previewValues, renderShaderPreview} from './shaderPreview.js';
import {NMSH_EFFECTS} from './nmshShaders.js';
import type {ColorLevel} from '../presentation/capabilities.js';
import type {RgbColor} from '../ui/palette.js';

Expand Down Expand Up @@ -75,7 +76,7 @@ export type StudioAction = {kind: 'back'} | {kind: 'review'} | {kind: 'remove'}

type Row = {kind: 'placement'} | {kind: 'preset'; preset: StudioPreset} | {kind: 'param'; preset: StudioPreset; param: OfferedParam};
type AnyState = ShaderStudioState<StudioCatalog, StudioInspection, StudioPlan, unknown, StudioDraft>;
const CATEGORY_ORDER: readonly PresetCategory[] = ['cursor', 'ambient', 'interaction', 'focus', 'display'];
const CATEGORY_ORDER: readonly PresetCategory[] = ['cursor', 'typing', 'welcome', 'ambient', 'interaction', 'focus', 'display'];
const PREVIEW_DESCRIPTIONS: Record<string, string> = {
trail: 'A fading smear follows the cursor between positions.',
comet: 'A bright head pulls a soft tail behind the cursor.',
Expand All @@ -87,6 +88,8 @@ const PREVIEW_DESCRIPTIONS: Record<string, string> = {
vignette: 'The edges darken to draw attention toward the center.',
scanlines: 'Fine horizontal lines give the display a CRT texture.',
glow: 'Bright text gains a soft halo.',
// NMSh's own effects: one plain line each (the same words on both hosts).
...Object.fromEntries(NMSH_EFFECTS.map(effect => [effect.id, effect.description])),
};

export function createShaderStudio<C extends StudioCatalog, I extends StudioInspection, P extends StudioPlan, G, D extends StudioDraft>(saved: D, gates: G): ShaderStudioState<C, I, P, G, D> {
Expand Down Expand Up @@ -283,7 +286,9 @@ export function renderShaderStudio<G>(state: ShaderStudioState<StudioCatalog, St
if (preset) {
out.push(studioLabel(preset.definition.label));
text(`${enabled(any, preset.definition.id) ? 'Enabled in draft' : 'Disabled in draft'} · ${passState(preset.definition.id)}`);
if (preset.definition.exclusive) text(`Choose one ${CATEGORY_LABELS[preset.definition.category].toLowerCase()} effect at a time.`);
if (preset.definition.exclusive) text(preset.definition.exclusive === 'cursor' ? 'One cursor effect at a time: a trail or a cursor & typing effect.'
: `Choose one ${CATEGORY_LABELS[preset.definition.category].toLowerCase()} effect at a time.`);
if (preset.definition.category === 'welcome') text('Only colorful blocks and images (welcome logo, fastfetch art) change; plain text stays as it is.');
text(kind ? PREVIEW_DESCRIPTIONS[kind] ?? 'A host shader changes the whole terminal surface.' : preset.definition.note ?? 'A host-wide effect; no faithful cell approximation is available.');
}
const note = current?.kind === 'placement' ? host.placementNote : preset?.definition.note || (preset && !preset.params.length ? host.noParams : '');
Expand Down
11 changes: 10 additions & 1 deletion src/appearance/ghosttyShaderCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
*/
import {createHash} from 'node:crypto';
import type {OfferedParam, PresetCategory, PresetParam, ShaderValue} from './kittyShaderCatalog.js';
import {NMSH_EFFECTS, nmshGhosttyBody, type NmshEffectId} from './nmshShaders.js';

export type GhosttyVersion = readonly [number, number, number];

Expand Down Expand Up @@ -120,6 +121,8 @@ export interface GenerationContext {
cursorColors: {head: readonly number[]; tail: readonly number[]};
/** The Chroma palette's ends (0–1 RGB; the theme's colors while Chroma is off), for effects that follow Chroma. */
chroma: {a: readonly number[]; b: readonly number[]};
/** Where Ghostty runs (its fragment y axis points down under Metal on macOS); defaults to this process's platform. */
platform?: NodeJS.Platform;
}

/** Ambient colors: follow Chroma (rewritten when the Chroma palette or theme changes; Ghostty applies it on reload) or the studio's own. */
Expand Down Expand Up @@ -158,7 +161,11 @@ export const GHOSTTY_PRESETS: readonly GhosttyPresetDefinition[] = [
{id: 'glow', label: 'Text glow', category: 'display', animated: false,
params: [n('INTENSITY', 'Intensity', 0, 1.5, 0.05), n('RADIUS', 'Radius (px)', 1, 6, 0.5)],
defaults: {INTENSITY: 0.45, RADIUS: 2},
note: 'A soft bloom around bright text. Samples 16 neighbours per pixel: the most expensive NMSh effect.'},
note: 'A soft bloom around bright text. Samples 16 neighbours per pixel.'},
// NMSh's own effects (shared with Kitty, see nmshShaders.ts). Cursor & typing effects are the one cursor effect.
...NMSH_EFFECTS.map((effect): GhosttyPresetDefinition => ({id: effect.id, label: effect.label, category: effect.category, params: effect.params, defaults: effect.defaults,
animated: effect.animated, ...(effect.category === 'typing' ? {exclusive: 'cursor', trail: true as const} : {}),
...(effect.id === 'halo-bloom' ? {note: 'Samples 48 neighbours per pixel: the most expensive NMSh effect.'} : {})})),
];

export const GHOSTTY_MAX_EFFECTS = 6;
Expand Down Expand Up @@ -213,7 +220,9 @@ function trailColors(values: Record<string, ShaderValue>, context: GenerationCon
return `vec3 headColor() { return ${vec3(head)}; }\nvec3 tailColor() { return ${vec3(tail)}; }`;
}

const nmshBody = (id: NmshEffectId) => (_values: Record<string, ShaderValue>, context: GenerationContext) => nmshGhosttyBody(id, context.platform ?? process.platform);
const BODIES: Record<string, (values: Record<string, ShaderValue>, context: GenerationContext) => string> = {
...Object.fromEntries(NMSH_EFFECTS.map(effect => [effect.id, nmshBody(effect.id)])),
smear: (values, context) => `${TRAIL_HELPERS}
${trailColors(values, context)}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
Expand Down
2 changes: 1 addition & 1 deletion src/appearance/ghosttyShaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ function compose(settings: GhosttyShaderSettings, context: GhosttyShaderContext,
if (unknown) throw new Error(`NMSh cannot read your shader ${unknown.path} (${unknown.reason}), so it cannot rule out a second cursor trail.`);
}
if (inspection.entries.some(entry => entry.path.startsWith(`${paths.shaderDirectory}/`))) throw new Error('Your configuration already names a file in NMSh’s Ghostty shader folder; remove that line first.');
const shaders = active.map(({preset, effect}) => ({id: preset.definition.id, path: passPath(paths, preset.definition.id), source: generateGhosttyShader(preset.definition, effect.values, context.colors).source}));
const shaders = active.map(({preset, effect}) => ({id: preset.definition.id, path: passPath(paths, preset.definition.id), source: generateGhosttyShader(preset.definition, effect.values, {...context.colors, platform: context.platform}).source}));
for (const shader of shaders) if (!state.passes.has(shader.id)) {
const file = snapshot(shader.path, paths.home, true);
if (file.content !== undefined) throw new Error(`${shader.path} exists but is not recorded as NMSh’s; it was preserved.`);
Expand Down
35 changes: 31 additions & 4 deletions src/appearance/kittyShaderCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {spawn} from 'node:child_process';
import {createHash} from 'node:crypto';
import {existsSync} from 'node:fs';
import {delimiter, join} from 'node:path';
import {kittyShaderName, NMSH_EFFECTS, nmshKittyGroups, nmshKittySource, type NmshEffectId} from './nmshShaders.js';

export const KITTY_SHADER_MIN_VERSION = [0, 49] as const;
/** Kitty's MAX_CUSTOM_SHADER_GROUPS: the limit for one pipeline and for all chained pipelines together. */
Expand Down Expand Up @@ -233,8 +234,11 @@ export function formatShaderValue(type: string, value: ShaderValue): string {

// ---- Curated presets ---------------------------------------------------------------------------------------------

export type PresetCategory = 'cursor' | 'ambient' | 'interaction' | 'focus' | 'display';
export const CATEGORY_LABELS: Record<PresetCategory, string> = {cursor: 'Cursor trail', ambient: 'Ambient', interaction: 'Pointer', focus: 'Focus & tabs', display: 'Display'};
export type PresetCategory = 'cursor' | 'typing' | 'welcome' | 'ambient' | 'interaction' | 'focus' | 'display';
export const CATEGORY_LABELS: Record<PresetCategory, string> = {cursor: 'Cursor trail', typing: 'Cursor & typing', welcome: 'Welcome & images', ambient: 'Ambient',
interaction: 'Pointer', focus: 'Focus & tabs', display: 'Display'};
/** Categories that draw at the cursor: one at a time, and only while /cursor wants a native trail (never two trails). */
export const drawsCursor = (category: PresetCategory) => category === 'cursor' || category === 'typing';
export interface PresetParam {
name: string; label: string; kind: 'number' | 'bool' | 'color' | 'choice';
/** Kitty's declared type this control expects; a different declaration means the parameter is not offered. */
Expand All @@ -244,8 +248,8 @@ export interface PresetParam {
}
export interface PresetDefinition {
id: string; label: string; category: PresetCategory;
/** A shipped pipeline (its group structure is reused) or a shipped shader with Kitty's default pipeline. */
source: {pipeline: string} | {shader: string};
/** A shipped pipeline (its group structure is reused), a shipped shader with Kitty's default pipeline, or NMSh's own shader (written by NMSh). */
source: {pipeline: string} | {shader: string} | {nmsh: NmshEffectId};
params: readonly PresetParam[];
/** Presets sharing a key cannot be enabled together. */
exclusive?: string;
Expand Down Expand Up @@ -308,8 +312,17 @@ export const KITTY_PRESETS: readonly PresetDefinition[] = [
note: 'Light rays from above. Kitty’s settings for it are internal, so NMSh offers it as is. Runs every frame.'},
{id: 'negative', label: 'Negative', category: 'display', source: {shader: 'negative'}, params: [],
note: 'Inverts every color in the window.'},
// NMSh's own effects: NMSh writes their .slang next to its pipeline (Kitty looks there first).
...NMSH_EFFECTS.map((effect): PresetDefinition => ({id: effect.id, label: effect.label, category: effect.category, source: {nmsh: effect.id}, params: effect.params,
...(effect.category === 'typing' ? {exclusive: 'cursor', requires: 'cursor_trail' as const,
note: 'Runs while Kitty’s cursor trail moves; NMSh starts it on one-cell moves too, so typing shows it.'} : {}),
...(effect.id === 'shine-sweep' ? {note: 'On focus only starts when the window gains focus; the timed cadences redraw continuously.'} : {})})),
];

/** The constants each NMSh Kitty shader declares (what its pipeline vars may replace). */
const NMSH_CONSTANTS = new Map(NMSH_EFFECTS.map(effect => [kittyShaderName(effect.id), parseShaderConstants(nmshKittySource(effect.id))]));
export const nmshShaderConstants = (name: string) => NMSH_CONSTANTS.get(name);

// ---- Installed catalog -------------------------------------------------------------------------------------------

export interface CatalogShader {name: string; sha256: string; constants: Map<string, ShaderConstant>}
Expand Down Expand Up @@ -376,6 +389,20 @@ export function buildKittyCatalog(version: string, resources: Readonly<Record<st
}

function offerPreset(catalog: KittyCatalog, definition: PresetDefinition): OfferedPreset | string {
if ('nmsh' in definition.source) {
const id = definition.source.nmsh;
const name = kittyShaderName(id);
const constants = NMSH_CONSTANTS.get(name)!;
const defaults = NMSH_EFFECTS.find(effect => effect.id === id)!.defaults;
const params: OfferedParam[] = [];
const missing: string[] = [];
for (const param of definition.params) {
if (constants.get(param.name)?.type === param.type && defaults[param.name] !== undefined) params.push({...param, default: defaults[param.name]!});
else missing.push(param.name);
}
return {definition, groups: nmshKittyGroups(id, defaults), textures: [], params, missing,
origin: {kittyVersion: catalog.version, resources: [{name: `${name}.slang`, sha256: sha(nmshKittySource(id))}]}};
}
const source = 'pipeline' in definition.source ? catalog.pipelines.get(definition.source.pipeline) : undefined;
if ('pipeline' in definition.source && !source) return 'not installed in this Kitty';
const pipeline = source?.pipeline ?? defaultPipeline((definition.source as {shader: string}).shader);
Expand Down
Loading
Loading