diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index e2244f81..ae1b8a8b 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -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'; @@ -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({ @@ -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[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 @@ -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. diff --git a/src/appearance/AppearanceHub.ts b/src/appearance/AppearanceHub.ts index 95fa1bf1..9dbec638 100644 --- a/src/appearance/AppearanceHub.ts +++ b/src/appearance/AppearanceHub.ts @@ -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); } diff --git a/src/appearance/glslStudio.ts b/src/appearance/glslStudio.ts index 8fddb698..d153b4c1 100644 --- a/src/appearance/glslStudio.ts +++ b/src/appearance/glslStudio.ts @@ -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'; @@ -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; } /** @@ -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(); @@ -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)); diff --git a/src/appearance/hostAppearance.ts b/src/appearance/hostAppearance.ts index 7d286145..43e1d9b8 100644 --- a/src/appearance/hostAppearance.ts +++ b/src/appearance/hostAppearance.ts @@ -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 { diff --git a/src/appearance/hostReload.ts b/src/appearance/hostReload.ts new file mode 100644 index 00000000..ef3995bf --- /dev/null +++ b/src/appearance/hostReload.ts @@ -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); +} diff --git a/src/appearance/kittyShaders.ts b/src/appearance/kittyShaders.ts index 25b28af7..2fdd1b39 100644 --- a/src/appearance/kittyShaders.ts +++ b/src/appearance/kittyShaders.ts @@ -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.'}; } diff --git a/src/cursor/native.ts b/src/cursor/native.ts index f0e15383..5c28ca60 100644 --- a/src/cursor/native.ts +++ b/src/cursor/native.ts @@ -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; diff --git a/src/host/integration.ts b/src/host/integration.ts index 4a9b7b36..917f5150 100644 --- a/src/host/integration.ts +++ b/src/host/integration.ts @@ -1,6 +1,7 @@ -import {applyHostAppearance, planHostAppearance, planHostRemoval, readHostAppearance, appearanceGuidance, type AppearanceRead, type AppearancePlanResult, type HostAppearancePlan, type HostAppearanceSettings} from '../appearance/hostAppearance.js'; +import {applyHostAppearance, planHostAppearance, planHostRemoval, readHostAppearance, appearanceGuidance, appearanceAppliedMessage, type AppearanceRead, type AppearancePlanResult, type HostAppearancePlan, type HostAppearanceSettings} from '../appearance/hostAppearance.js'; import {terminalProfile} from './capabilities.js'; import {installGhosttyKeybinding} from '../keyboard/ghosttyKeyboard.js'; +import {reloadHost} from '../appearance/hostReload.js'; import {resolveHostCapabilities} from './capabilities.js'; /** Explicit panel actions only. Constructing an adapter never reads or writes preferences. */ @@ -9,6 +10,8 @@ export interface HostIntegration { planAppearance(settings: HostAppearanceSettings): AppearancePlanResult; planAppearanceRemoval(): AppearancePlanResult; applyAppearance(plan: HostAppearancePlan): ReturnType; + /** Ask the verified terminal NMSh runs in to reload, and say what happened (opacity before/after shape the note). */ + reloadAfterAppearance(before: number | undefined, after: number | undefined): string; /** Present only where NMSh can install a host key binding (Ghostty); other hosts show guidance. */ installKeyboard?(): Promise<{success: boolean; error?: string}>; keyboardReload?: string; @@ -25,6 +28,7 @@ export function hostIntegration(env: NodeJS.ProcessEnv = process.env): HostInteg planAppearance: settings => planHostAppearance(profile, settings, context), planAppearanceRemoval: () => planHostRemoval(profile, context), applyAppearance: applyHostAppearance, + reloadAfterAppearance: (before, after) => appearanceAppliedMessage(profile, reloadHost(profile, env, process.platform), before, after), ...(profile === 'ghostty' ? {installKeyboard: installGhosttyKeybinding, keyboardReload: 'Reload Ghostty config (Cmd+Shift+,) for changes to take effect.'} : {}), appearanceRestart: appearanceGuidance(profile, process.platform), }; diff --git a/tests/hostReload.test.ts b/tests/hostReload.test.ts new file mode 100644 index 00000000..b29e9523 --- /dev/null +++ b/tests/hostReload.test.ts @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {ghosttyReloadTarget, reloadGhostty, reloadHost} from '../src/appearance/hostReload.js'; +import {reloadKitty} from '../src/appearance/kittyShaders.js'; +import {appearanceAppliedMessage, appearanceGuidance} from '../src/appearance/hostAppearance.js'; +import {createAppearanceHub, renderAppearanceHub} from '../src/appearance/AppearanceHub.js'; +import {DEFAULT_PROMPT_CONFIGURATION} from '../src/prompt/configuration.js'; +import {stripAnsi} from '../src/util/text.js'; + +const row = (pid: number, ppid: number, command: string) => ({pid, ppid, elapsedSeconds: 60, command}); +// Ghostty → login → zsh → node (NMSh, pid 400). +const inGhostty = [row(100, 1, '/Applications/Ghostty.app/Contents/MacOS/ghostty'), row(200, 100, 'login -flp me /bin/zsh'), row(300, 200, '-zsh'), row(400, 300, 'node /x/bin/nmsh')]; +// Inside tmux the pane's parents end at the tmux server, never the terminal. +const inTmux = [row(100, 1, '/Applications/Ghostty.app/Contents/MacOS/ghostty'), row(150, 1, 'tmux -u new'), row(300, 150, '-zsh'), row(400, 300, 'node /x/bin/nmsh')]; +const ghosttyEnv = (version: string) => ({TERM: 'xterm-ghostty', TERM_PROGRAM: 'ghostty', TERM_PROGRAM_VERSION: version}); + +test('Ghostty reload: only the verified Ghostty parent, only Ghostty 1.3+, never from tmux', () => { + assert.equal(ghosttyReloadTarget(inGhostty, 400), 100); + assert.equal(ghosttyReloadTarget(inTmux, 400), undefined); + assert.equal(ghosttyReloadTarget(undefined, 400), undefined); + const sent: number[] = []; + const deps = {rows: () => inGhostty, selfPid: 400, signal: (pid: number) => { sent.push(pid); }}; + const done = reloadGhostty(ghosttyEnv('1.3.1'), 'darwin', deps); + assert.equal(done.reloaded, true); + assert.deepEqual(sent, [100]); + // Older Ghostty may not handle the signal (its default action would close the window): instruction only. + const old = reloadGhostty(ghosttyEnv('1.2.3'), 'darwin', deps); + assert.equal(old.reloaded, false); + assert.match(old.message, /Reload Ghostty to load it \(⌘⇧,\): automatic reload needs Ghostty 1\.3\.0/u); + assert.equal(reloadGhostty(ghosttyEnv('1.3.1'), 'linux', {...deps, rows: () => inTmux}).message, 'Reload Ghostty to load it (Ctrl+Shift+,): NMSh could not confirm which Ghostty it runs in.'); + // Kitty with an inherited Ghostty TERM_PROGRAM is not Ghostty. + assert.equal(reloadGhostty({...ghosttyEnv('1.3.1'), TERM: 'xterm-kitty', KITTY_WINDOW_ID: '1'}, 'darwin', deps).reloaded, false); + assert.deepEqual(sent, [100], 'nothing else was signalled'); + const failing = reloadGhostty(ghosttyEnv('1.3.1'), 'darwin', {...deps, signal: () => { throw new Error('EPERM'); }}); + assert.equal(failing.reloaded, false); +}); + +test('under the test runner nothing is signalled unless a test injects the signal', () => { + assert.ok(process.env.NODE_TEST_CONTEXT, 'the runner marks its children'); + const ghostty = reloadGhostty(ghosttyEnv('1.3.1'), 'darwin', {rows: () => inGhostty, selfPid: 400}); + assert.deepEqual(ghostty, {reloaded: false, message: 'Reload Ghostty to load it (⌘⇧,): signals are disabled here.'}); + const kittyRows = [row(100, 1, '/Applications/kitty.app/Contents/MacOS/kitty'), row(300, 100, '-zsh'), row(400, 300, 'node nmsh')]; + const kitty = reloadKitty({KITTY_PID: '100', TERM: 'xterm-kitty'}, 'darwin', {rows: () => kittyRows, selfPid: 400}); + assert.equal(kitty.reloaded, false); + assert.match(kitty.message, /signals are disabled here/u); + assert.equal(reloadHost('kitty', {KITTY_PID: '100', TERM: 'xterm-kitty'}, 'darwin', {rows: () => kittyRows, selfPid: 400}).reloaded, false); +}); + +test('appearance messages: automatic reload stated, Kitty opaque-start limit disclosed only when it applies', () => { + const reloaded = {reloaded: true, message: 'Kitty was asked to reload its configuration.'}; + assert.match(appearanceAppliedMessage('kitty', reloaded, 1, 0.85), /quit and reopen Kitty once to see transparency/u); + assert.equal(appearanceAppliedMessage('kitty', reloaded, 0.9, 0.85), reloaded.message, 'already transparent: changes are live'); + assert.equal(appearanceAppliedMessage('kitty', reloaded, 0.85, 1), reloaded.message); + assert.equal(appearanceAppliedMessage('ghostty', {reloaded: true, message: 'Ghostty reloaded.'}, 1, 0.7), 'Ghostty reloaded.'); + assert.match(appearanceGuidance('ghostty', 'darwin'), /NMSh asks the Ghostty it runs in to reload/u); + assert.doesNotMatch(appearanceGuidance('ghostty', 'darwin'), /requires quitting/u); + assert.match(appearanceGuidance('kitty', 'darwin'), /opened fully opaque stays opaque until Kitty is restarted once/u); +}); + +test('the hub warns that blur needs opacity below 100%', () => { + const hub = createAppearanceHub('Kitty', {opacity: 1, blurModeIndex: 1, blurStrength: 20, allowedBlurModes: ['Off', 'Numeric'], selectedIndex: 0}); + const config = structuredClone(DEFAULT_PROMPT_CONFIGURATION); + const text = () => stripAnsi(renderAppearanceHub(hub, config, 100, 'Theme', 'Portable', undefined, 40).join('\n')); + assert.match(text(), /Blur shows only below 100% opacity/u); + hub.host!.opacity = 0.85; + assert.doesNotMatch(text(), /Blur shows only below/u); + hub.host!.opacity = 1; hub.host!.blurModeIndex = 0; + assert.doesNotMatch(text(), /Blur shows only below/u, 'no blur, no warning'); +});