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
23 changes: 18 additions & 5 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ import {type KittyShaderSettings, reloadKitty, applyKittyShaderPlan, inspectKitt
import {diffModules, progress, transitionPaint, Transitions, type MotionGate} from '../motion/transitions.js';
import {overlayRow, type CellPaint} from '../presentation/cellOverlay.js';
import {chooseBackend, hostCursorFacts, nativeBackendFor, setCursorHostProvider, type BackendChoice, type HostCursorFacts} from '../cursor/backends.js';
import type {HostReloadResult} from '../appearance/hostReload.js';
import {includeLine, installedNativeHost, nativeCursorIntegrated, nativeHostLabel, reloadInstruction, setupPlan, writeManagedFiles, type ManagedWrite} from '../cursor/native.js';
import {createCursorPanel, cursorPanelKey, openCursorEditor, renderCursorPanel, type CursorPanelEnv, type CursorPanelOptions, type CursorPanelState} from '../cursor/CursorPanel.js';
import {contextFor, resolveCursorSettings} from '../cursor/colors.js';
Expand Down Expand Up @@ -3821,16 +3822,19 @@ export class TerminalApp {
const pending = hub.confirmation?.plan;
hub.confirmation = undefined;
if (!pending) return;
const previous = (await integration.readAppearance()).settings?.opacity;
if (this.stopped || this.appearanceHub !== hub) return;
const result = integration.applyAppearance(pending);
hub.hostMessage = result.ok ? pending.guidance : result.error;
if (result.ok) {
hub.hostDirty = false;
const inspected = await integration.readAppearance();
if (this.stopped || this.appearanceHub !== hub) return;
hub.hostMessage = integration.reloadAfterAppearance(previous, inspected.settings?.opacity);
const settings = inspected.settings;
hub.host = settings ? {opacity: settings.opacity, blurModeIndex: Math.max(0, BLUR_MODES.indexOf(settings.blurMode)), blurStrength: settings.blurStrength,
...(settings.background ? {background: settings.background} : {}), allowedBlurModes: ['Off', 'Numeric'], selectedIndex: 0} : undefined;
if (inspected.problems.length) hub.hostMessage = `${inspected.problems.join(' ')} ${pending.guidance}`;
if (inspected.problems.length) hub.hostMessage = `${inspected.problems.join(' ')} ${hub.hostMessage}`;
}
} else {
const planned = remove ? integration.planAppearanceRemoval() : integration.planAppearance({
Expand Down Expand Up @@ -10676,15 +10680,23 @@ export class TerminalApp {
}
this.cursorHost = hostCursorFacts(process.env, value => nativeCursorIntegrated(value), () => writtenKittyTrail({env: process.env, platform: process.platform}), () => this.glslStudio.trailFacts());
void written;
state.message = this.cursorHost.integrated
? (reloadInstruction(native.host, {changed: ['fragment']}) ?? `Set up. Renderer Auto now uses ${nativeHostLabel(native.host)} native.`)
: 'Setup did not verify; Portable stays in use.';
const reload = this.cursorHost.integrated ? this.reloadNativeHost(native.host) : undefined;
state.message = !reload ? 'Setup did not verify; Portable stays in use.'
: reload.reloaded ? `Set up. Renderer Auto now uses ${nativeHostLabel(native.host)} native. ${reload.message}` : reload.message;
}
}

/** The reload line for the latest managed-file refresh, until the next one (shown in /cursor and after Setup applies). */
private cursorReload?: string;

/** Ask the verified terminal NMSh runs in to reload its configuration after NMSh rewrote its own files there. */
private reloadNativeHost(host: Parameters<typeof nativeHostLabel>[0]): HostReloadResult {
const result = this.glslStudio.reloadNativeHost(host);
// A reloaded host no longer draws anything NMSh stopped writing: re-read the cursor facts.
if (result.reloaded) this.cursorHost = undefined;
return result;
}

/**
* Keep NMSh's own native files in step with the cursor settings and the theme
* colors they resolve to: only when the host integration is already set up, only
Expand All @@ -10698,7 +10710,8 @@ export class TerminalApp {
if (!host) { this.cursorReload = undefined; return; }
try {
const written = this.glslStudio.writeCursorFiles(host, resolveCursorSettings(next.cursor, contextFor(next)));
this.cursorReload = reloadInstruction(host, written);
// The host loads NMSh's rewritten cursor files only after a reload: ask the verified terminal, else say how.
this.cursorReload = written.changed.length && !this.reloadNativeHost(host).reloaded ? reloadInstruction(host, written) : undefined;
// A native shader that stopped being written stays loaded until the host reloads: Portable waits rather than draw a second trail.
if (written.trailChanged) this.cursorHost = undefined;
// Kitty's cursor_trail decides whether a Kitty trail shader draws at all; re-read the facts after it changes.
Expand Down
6 changes: 4 additions & 2 deletions src/appearance/AppearanceHub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,14 +217,16 @@ export function renderAppearanceHub(state: AppearanceHubState, configuration: Pr
const selectedEntry = entries[state.selected];
return renderStudioLayout({title: 'Appearance', stacked: height === undefined, subtitle: 'Your terminal, composed · select an editor to shape it',
entries, selected: state.selected,
notice: state.hostMessage ?? (state.hostDirty ? 'Unsaved host changes · S reviews before writing' : undefined),
// Blur shows through the transparent part of the window only; at 100% opacity there is nothing to blur.
notice: [state.hostMessage ?? (state.hostDirty ? 'Unsaved host changes · S reviews, Enter writes and reloads the terminal' : undefined),
state.host && state.host.opacity >= 1 && BLUR_MODES[state.host.blurModeIndex] !== 'Off' ? 'Blur shows only below 100% opacity: lower Opacity first.' : undefined].filter(Boolean).join(' · ') || undefined,
controls: state.backgroundInput !== undefined ? [['Enter', 'apply RGB'], ['Esc', 'cancel']] : [
['↑↓', 'select'], ['Enter', 'open'], ...(state.host ? [['←→', 'adjust'], ['S', 'review']] as const : []),
...(state.host || state.canRestoreHost ? [['R', 'restore']] as const : []), ['Esc', 'close']],
details: width => [studioLabel(selectedEntry?.label ?? 'Appearance'),
...studioWrap(selectedEntry?.value ?? '', width), '', studioMuted('Host window · configuration snapshot'),
...studioWrap(`Host ${stripTerminalControls(state.hostName)} · CLI overrides are not visible`, width).map(studioMuted),
...studioWrap(state.host ? 'Changes are reviewed before writing. R reviews removal of NMSh-owned appearance overrides.' : state.hostGuidance ?? `Opacity and blur are controlled by ${state.hostName}.`, width).map(studioMuted), '',
...studioWrap(state.host ? 'Changes are reviewed before writing, then NMSh asks the terminal to reload so they show at once. R reviews removal of NMSh-owned appearance overrides.' : state.hostGuidance ?? `Opacity and blur are controlled by ${state.hostName}.`, width).map(studioMuted), '',
...studioWrap(`Chroma ${configuration.presentation.preset === 'off' ? 'OFF' : 'ON'} · ${chromaPreviewNote(configuration.presentation.preset !== 'off')}`, width).map(studioMuted)],
}, columns, height ?? 72);
}
28 changes: 26 additions & 2 deletions src/appearance/glslStudio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {AppearanceHubState} from './AppearanceHub.js';
import type {StudioPreviewContext} from './ShaderStudioPanel.js';
import {createGhosttyShaderPanel, ghosttyShaderKey, renderGhosttyShaderPanel, type GhosttyShaderPanelState} from './GhosttyShaderPanel.js';
import {buildGhosttyCatalog} from './ghosttyShaderCatalog.js';
import {reloadGhostty, reloadHost, type HostReloadResult} from './hostReload.js';
import {applyGhosttyShaderPlan, ghosttyReloadPending, ghosttyShaderPaths, inspectGhosttyLaunch, inspectGhosttyShaders, noteGhosttyTrailChange, planGhosttyShaderRemoval, planGhosttyShaders,
readProcessTable, setGhosttyReloadPending, syncGhosttyShaders, writtenGhosttyTrail, type GhosttyLaunch, type GhosttyShaderContext, type GhosttyShaderGates} from './ghosttyShaders.js';

Expand Down Expand Up @@ -137,8 +138,10 @@ export class GlslShaderStudio {
panel.draft = structuredClone(saved);
panel.dirty = false;
try { panel.inspection = inspectGhosttyShaders(this.context(this.services.configuration(), {})); } catch { /* the message below still applies */ }
const reload = this.reloadGhostty();
panel.reloadPending = this.pending();
panel.message = plan.operation === 'remove' ? `Removed NMSh's Ghostty shaders. ${plan.guidance}` : plan.guidance;
const outcome = reload.reloaded ? reload.message : plan.guidance;
panel.message = plan.operation === 'remove' ? `Removed NMSh's Ghostty shaders. ${outcome}` : outcome;
}

/**
Expand All @@ -149,13 +152,15 @@ export class GlslShaderStudio {
sync(next: PromptConfiguration, options: {render?: boolean} = {}): void {
try { if (!existsSync(ghosttyShaderPaths(this.base).ledger)) return; } catch { return; }
const result = syncGhosttyShaders(next.hostShaders.ghostty, this.context(next, {}));
let reload: HostReloadResult | undefined;
if (result.kind === 'written') {
this.noteTrailChange(result.trailBefore, result.trailAfter);
reload = this.reloadGhostty();
this.services.invalidateCursor();
if (this.panel) this.panel.reloadPending = this.pending();
}
if (result.kind === 'written' || result.kind === 'review') {
const message = result.kind === 'review' ? `Ghostty shaders were not updated: ${result.message}` : result.message;
const message = result.kind === 'review' ? `Ghostty shaders were not updated: ${result.message}` : reload?.reloaded ? `${result.message} ${reload.message}` : result.message;
if (this.panel) this.panel.message = message;
else this.services.notify(message, result.kind === 'review');
if (options.render !== false) this.services.render();
Expand Down Expand Up @@ -195,6 +200,25 @@ export class GlslShaderStudio {
* Write NMSh's /cursor managed files for a host. In Ghostty a written studio trail keeps the older /cursor shader
* paused, and a shader that stops being written is remembered until Ghostty reloads (never two trails).
*/
/**
* Ask the verified Ghostty NMSh runs in to reload after NMSh rewrote its files. On success nothing is pending any more:
* Ghostty has dropped any trail NMSh stopped writing, so Portable may draw at once.
*/
reloadGhostty(): HostReloadResult {
const result = reloadGhostty(this.env, this.platform);
if (result.reloaded) {
try { setGhosttyReloadPending(this.base, undefined); } catch { /* a stale record only delays Portable until the next reload */ }
this.unsavedPending = [];
if (this.panel) this.panel.reloadPending = undefined;
}
return result;
}

/** /cursor's managed files changed for this host: Ghostty also clears its reload-pending record on success. */
reloadNativeHost(host: NativeHost): HostReloadResult {
return host === 'ghostty' ? this.reloadGhostty() : reloadHost(host, this.env, this.platform);
}

writeCursorFiles(host: NativeHost, settings: CursorSettings): ManagedWrite & {trailChanged: boolean} {
if (host !== 'ghostty') return {...writeManagedFiles(host, settings, this.env), trailChanged: false};
const studioTrail = Boolean(writtenGhosttyTrail(this.base));
Expand Down
14 changes: 12 additions & 2 deletions src/appearance/hostAppearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,18 @@ export function readHostAppearance(host: AppearanceHost, context = appearanceCon

export function appearanceGuidance(host: AppearanceHost, platform: NodeJS.Platform): string {
return host === 'ghostty'
? `Configuration saved, not proof of loaded GPU output. Reload Ghostty (${platform === 'darwin' ? 'Cmd' : 'Ctrl'}+Shift+,). Opacity on macOS requires quitting and reopening Ghostty; NMSh will not restart it.`
: 'Configuration saved, not proof of loaded GPU output. Reload Kitty with its reload_config action (normally Ctrl+Shift+F5). Opacity changes may require a new OS window unless dynamic_background_opacity was already enabled; NMSh does not enable it.';
? `After writing, NMSh asks the Ghostty it runs in to reload (Ghostty 1.3 or newer), so the change shows at once; otherwise reload Ghostty (${platform === 'darwin' ? 'Cmd' : 'Ctrl'}+Shift+,). NMSh never restarts Ghostty.`
: `After writing, NMSh asks the Kitty it runs in to reload, so the change shows at once; otherwise reload Kitty (${platform === 'darwin' ? '⌃⌘,' : 'Ctrl+Shift+F5'}). A Kitty window that opened fully opaque stays opaque until Kitty is restarted once; NMSh never restarts it.`;
}

/**
* What to say after a written appearance change, given whether the terminal reloaded. Observed with Kitty 0.49.2: a window that
* opened at opacity 1 stays opaque after a reload, while one that opened transparent follows every later change. Ghostty 1.3.1
* applied opacity and blur on reload in the same test, even from an opaque start.
*/
export function appearanceAppliedMessage(host: AppearanceHost, reload: {reloaded: boolean; message: string}, before: number | undefined, after: number | undefined): string {
const opaqueStart = host === 'kitty' && after !== undefined && after < 1 && (before === undefined || before >= 1);
return [reload.message, opaqueStart ? 'If this Kitty window opened fully opaque, quit and reopen Kitty once to see transparency; after that, opacity and blur change live.' : ''].filter(Boolean).join(' ');
}

function render(host: AppearanceHost, settings: HostAppearanceSettings): string {
Expand Down
54 changes: 54 additions & 0 deletions src/appearance/hostReload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Ask the terminal NMSh runs in to reload its configuration after NMSh rewrote one of its own files, so a change applies
* without the person pressing the reload shortcut. Only a verified parent process is signalled, and only with the signal that
* terminal documents or was observed to handle; anything uncertain falls back to a plain instruction.
*/
import {parseGhosttyVersion} from './ghosttyShaderCatalog.js';
import {readProcessTable, type ProcessRow} from './ghosttyShaders.js';
import {reloadKitty} from './kittyShaders.js';
import {terminalProfile} from '../host/capabilities.js';

export type ReloadHost = 'kitty' | 'ghostty';
export interface HostReloadDeps {rows?: () => readonly ProcessRow[] | undefined; selfPid?: number; signal?: (pid: number) => void}
export type HostReloadResult = {reloaded: boolean; message: string};

/** Ghostty's macOS app logs "reloading configuration in response to SIGUSR2"; verified on 1.3.1. Older builds may not handle it. */
export const GHOSTTY_SIGUSR2_SINCE = [1, 3, 0] as const;
const ghosttyReloadKeys = (platform: NodeJS.Platform) => platform === 'darwin' ? '⌘⇧,' : 'Ctrl+Shift+,';
const atLeast = (version: readonly number[], floor: readonly number[]) => {
for (let index = 0; index < floor.length; index++) if ((version[index] ?? 0) !== floor[index]) return (version[index] ?? 0) > floor[index]!;
return true;
};

/** The Ghostty process among NMSh's parents, or undefined (tmux, ssh, a session service or another terminal in between). */
export function ghosttyReloadTarget(rows: readonly ProcessRow[] | undefined, selfPid: number): number | undefined {
if (!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 (/(^|\/)ghostty$/u.test(current.command.split(/\s+/u)[0] ?? '')) return current.pid;
current = current.ppid > 1 ? byPid.get(current.ppid) : undefined;
}
return undefined;
}

/** Under the test runner nothing is signalled unless a test injects its own signal: a developer's real terminal is never touched. */
const realSignal = (name: NodeJS.Signals) => process.env.NODE_TEST_CONTEXT ? undefined : (pid: number) => process.kill(pid, name);

export function reloadGhostty(env: NodeJS.ProcessEnv, platform: NodeJS.Platform, deps: HostReloadDeps = {}): HostReloadResult {
const manual = (why: string) => ({reloaded: false, message: `Reload Ghostty to load it (${ghosttyReloadKeys(platform)}): ${why}`});
const version = terminalProfile(env) === 'ghostty' ? parseGhosttyVersion(env.TERM_PROGRAM_VERSION) : undefined;
if (!version) return manual('NMSh could not confirm the Ghostty version it runs in.');
if (!atLeast(version, GHOSTTY_SIGUSR2_SINCE)) return manual(`automatic reload needs Ghostty ${GHOSTTY_SIGUSR2_SINCE.join('.')} or newer.`);
const target = ghosttyReloadTarget((deps.rows ?? readProcessTable)(), deps.selfPid ?? process.pid);
if (target === undefined) return manual('NMSh could not confirm which Ghostty it runs in.');
const signal = deps.signal ?? realSignal('SIGUSR2');
if (!signal) return manual('signals are disabled here.');
try { signal(target); } catch { return manual('asking Ghostty to reload failed.'); }
return {reloaded: true, message: 'Ghostty was asked to reload its configuration, so the change is loaded now.'};
}

/** The running host's reload, or an instruction. Kitty: its documented SIGUSR1 to the verified KITTY_PID parent. */
export function reloadHost(host: ReloadHost, env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, deps: HostReloadDeps = {}): HostReloadResult {
return host === 'ghostty' ? reloadGhostty(env, platform, deps) : reloadKitty(env, platform, deps);
}
5 changes: 4 additions & 1 deletion src/appearance/kittyShaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,10 @@ export interface KittyReloadDeps {rows?: () => readonly ProcessRow[] | undefined
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); }
// Under the test runner nothing is signalled unless a test injects its own signal: a developer's real Kitty is never touched.
const signal = deps.signal ?? (process.env.NODE_TEST_CONTEXT ? undefined : (pid: number) => process.kill(pid, 'SIGUSR1'));
if (!signal) return {reloaded: false, message: `Reload Kitty to load it (${kittyReloadKeys(platform)}): signals are disabled here.`};
try { signal(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.'};
}
Expand Down
10 changes: 4 additions & 6 deletions src/cursor/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,12 +201,10 @@ export function writeManagedFiles(host: NativeHost, settings: CursorSettings, en
}

/**
* The one line to show after managed files changed, or undefined when nothing
* needs doing. NMSh never restarts the terminal and never signals its process
* (an unhandled signal would end the window): a changed fragment is part of
* the host's configuration, so the person reloads it with the host's own
* documented shortcut; a shader-only change leaves the configuration alone,
* and the line says what to do if the terminal has not picked it up.
* The one line to show after managed files changed when NMSh could not ask the
* terminal to reload (src/appearance/hostReload.ts signals only a verified parent
* Kitty, or Ghostty 1.3+, whose reload signal is known to be handled; NMSh never
* restarts the terminal). Undefined when nothing needs doing.
*/
export function reloadInstruction(host: NativeHost, write: ManagedWrite, platform: NodeJS.Platform = process.platform): string | undefined {
if (!write.changed.length) return undefined;
Expand Down
Loading
Loading