From 47383153c843d023a44fd6293a17a01f3cf94730 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:25:46 +0530 Subject: [PATCH 1/3] Make live-session startup restore user-controlled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launch no longer reattaches silently. Two settings (Config → Sessions): Startup restore (Ask by default, Always, Never) and Multiple detached sessions (Ask which by default, Open all). With one detached session and Ask, a prompt shows its directory, command and age, and offers Resume (R/Enter), Not now (N/Esc), Always resume (A) or Don't resume at startup (D); A and D save the setting. With several, a picker selects which to resume (Space, A for all, Enter, Esc for none). Neither has a destructive key, and Never only skips restoring at launch. This window attaches the first chosen session; each other one opens in a new host window through a small TerminalHost abstraction (Ghostty, Terminal.app, kitty). Hosts that cannot open windows, or launcher failures, leave those sessions running and name their nmsh --attach command. Also: show the launch notice after a reattached transcript is restored (it was being erased), treat a read ending in a lone ESC as Esc in the startup screens, and wait for the specific gated command in a lifecycle test instead of any running command. --- CHANGELOG.md | 4 + docs/design/session-interaction-ux.md | 13 ++ src/app/TerminalApp.ts | 3 +- src/host/terminalHost.ts | 68 ++++++++++ src/index.ts | 28 +++-- src/prompt/configuration.ts | 19 ++- src/session/StartupPicker.ts | 114 ++++++++++++----- src/session/liveSessions.ts | 26 ++-- src/session/startupRestore.ts | 52 ++++++++ src/ui/SettingsPanel.ts | 7 ++ tests/helpers/liveFrontend.ts | 4 +- tests/resumeLive.test.ts | 25 ---- tests/sessionLifecycle.test.ts | 3 +- tests/startupDiscovery.test.ts | 171 +++++++++++++++++++++----- tests/startupRestore.test.ts | 166 +++++++++++++++++++++++++ 15 files changed, 591 insertions(+), 112 deletions(-) create mode 100644 src/host/terminalHost.ts create mode 100644 src/session/startupRestore.ts create mode 100644 tests/startupRestore.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 376dedc..ab224f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- **Startup restore is your choice:** Config → Sessions → Startup restore (Ask, the default; Always; or Never) and Multiple detached sessions (Ask which, or Open all). With one detached session, NMSh asks: Resume, Not now, Always or Don't resume at startup. With several, a picker restores the ones you select: this window takes one, and the others open in new Ghostty, Terminal.app or kitty windows. Where a host can't open windows, NMSh names the `nmsh --attach` command for each. Never only skips restoring at launch; it never ends a session. - **Live-session hardening:** live sessions that end while no window is attached are archived at the next launch or `/resume`, with the real exit code or a note that the service stopped or the system restarted. Output captured while detached is kept. A frontend that loses its service reports it and archives the transcript. `/resume` shows how long each idle live session has been at its prompt. - **Exactly-once archiving:** launch recovery, Kill Session and live journal checkpoints share a cross-process lock per journal, so concurrent launches never archive a session twice or overwrite a complete archive with partial state. - **Fullscreen reattach:** reattaching to a running fullscreen app restores the terminal modes it had turned on, including mouse reporting and bracketed paste. - **Safe updates:** each session-service protocol version has its own socket, so live sessions owned by an older service keep running after an update. A newer frontend never touches sessions it cannot verify, and it tells you they exist. - **Session limit:** at most 16 live sessions per service (`NMSH_MAX_SESSIONS`). When the limit is reached, a new window falls back to an in-process shell with a notice. Detached sessions are never ended to make room. +### Fixed +- A launch notice (for example, sessions archived while no window was attached) is no longer erased when the window reattaches to a live session. + ### Removed - `scripts/pty-history-smoke.mjs`: its checks had gone stale (it asserted retired UI text), it ran against the real config and live session service, and deterministic tests now cover everything it checked. diff --git a/docs/design/session-interaction-ux.md b/docs/design/session-interaction-ux.md index 3c6d10e..1d231c3 100644 --- a/docs/design/session-interaction-ux.md +++ b/docs/design/session-interaction-ux.md @@ -105,6 +105,19 @@ Live sessions are owned by the per-user session service (`nmshd`), which listens - **Reattaching to a fullscreen app:** the service remembers the terminal modes the app turned on (mouse reporting, bracketed paste, application cursor keys and keypad, focus events, hidden cursor). A reattaching window re-applies them, so mouse and paste keep working in `vim`, `htop`, `less` and similar apps. - **Service death under an attached window:** the frontend reports that it lost the service, archives the transcript, and exits. It never claims the session survived. - **Session limit:** at most 16 live sessions per service (`NMSH_MAX_SESSIONS`). When the limit is reached, a new window falls back to an in-process shell with a notice. That window's shell ends when the window closes; it cannot be detached or reattached. Detached sessions are never ended to make room; end one, or kill it from `/resume`. +- **Startup restore:** two settings in Config → Sessions. + - **Startup restore:** Ask (default), Always or Never. + - Ask: with one detached session, the launch shows its directory, what it is running and its age, then offers Resume (R/Enter), Not now (N/Esc), Always resume (A) or Don't resume at startup (D). A and D also save the setting. + - Never only skips restoring at launch. It never ends a session, and `/resume`, detach and reattach are unaffected. + - **Multiple detached sessions:** Ask which (default) or Open all. + - The picker uses ↑↓ to move, Space to select, A to select all (A again clears), and Enter to resume the selected sessions. Esc, or Enter with nothing selected, starts fresh. + - Neither screen has a destructive key: killing stays a confirmed `/resume` action. `--new` still skips restoring, and `--attach ` attaches one session explicitly. + - When several sessions are restored, this window attaches the first. Each other one opens in a new window of the host terminal, running `nmsh --attach `: + - Ghostty on macOS: `open -na Ghostty.app --args -e …`. Ghostty has no API to open a window in the running instance, so this starts a separate Ghostty instance. + - Ghostty on Linux: `ghostty -e`. + - Terminal.app: AppleScript `do script`. macOS asks once for Automation permission. + - kitty: `kitten @ launch --type=os-window`, which needs kitty remote control. + - Hosts without a way to open windows (VS Code, Zed, others), or a launcher that fails: the remaining sessions keep running, and this window names the `nmsh --attach` command for each one. - **Idle age:** `/resume` shows how long each idle live session has been at its prompt. Known limitations: diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 13882b3..16d6beb 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -198,7 +198,6 @@ export class TerminalApp { const dimensions = this.dimensions(); this.session = connection?.client ?? new InProcessSessionClient({cwd: this.initialCwd, columns: dimensions.columns, rows: Math.max(2, dimensions.rows - 4)}); - if (connection?.notice) this.output.addFrontendInteraction('session', connection.notice, ERROR); this.semanticService = new SemanticService(this.initialCwd); this.done = new Promise(resolve => { this.finish = resolve; @@ -219,6 +218,8 @@ export class TerminalApp { this.sessionMode = connection?.mode ?? 'in-process'; this.sessionId = connection?.sessionId; if (connection?.attached) this.beginReattach(connection.attached, connection.journal); + // After any restored transcript, or reattaching would erase the launch notice. + if (connection?.notice) this.output.addFrontendInteraction('session', connection.notice, ERROR); this.session.start(); } diff --git a/src/host/terminalHost.ts b/src/host/terminalHost.ts new file mode 100644 index 0000000..1576b14 --- /dev/null +++ b/src/host/terminalHost.ts @@ -0,0 +1,68 @@ +import {spawn} from 'node:child_process'; + +/** + * The terminal NMSh runs in, and whether NMSh can ask it to open another + * independent window running a command. NMSh never manages windows itself; + * hosts without a supported way to do this simply report `newWindow: undefined`. + */ +export interface TerminalHost { + /** Human-readable name for messages. */ + name: string; + /** How to open a new window running `argv`, if this host supports it. */ + newWindow?: (argv: readonly string[]) => {command: string; args: string[]}; +} + +/** Quote one argument for a POSIX shell. */ +export function shellQuote(value: string): string { + return /^[\w@%+=:,./-]+$/u.test(value) ? value : `'${value.replace(/'/gu, `'\\''`)}'`; +} + +const appleScriptString = (value: string) => `"${value.replace(/\\/gu, '\\\\').replace(/"/gu, '\\"')}"`; + +export function detectTerminalHost(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): TerminalHost { + const program = env.TERM_PROGRAM ?? ''; + if (program === 'ghostty' || env.GHOSTTY_RESOURCES_DIR) { + return {name: 'Ghostty', newWindow: argv => platform === 'darwin' + // No scripting API opens a window in the running instance; this starts a new Ghostty instance. + ? {command: 'open', args: ['-na', 'Ghostty.app', '--args', '-e', ...argv]} + : {command: 'ghostty', args: ['-e', ...argv]}}; + } + if (program === 'Apple_Terminal' && platform === 'darwin') { + return {name: 'Terminal', newWindow: argv => ({command: 'osascript', args: ['-e', + `tell application "Terminal" to do script ${appleScriptString(argv.map(shellQuote).join(' '))}`]})}; + } + if (env.KITTY_WINDOW_ID) { + // Needs kitty remote control (allow_remote_control); failure falls back like any unsupported host. + return {name: 'kitty', newWindow: argv => ({command: 'kitten', args: ['@', 'launch', '--type=os-window', ...argv]})}; + } + if (program === 'vscode') return {name: 'VS Code'}; + if (program === 'zed' || env.ZED_TERM) return {name: 'Zed'}; + return {name: program || 'this terminal'}; +} + +export type Spawner = (command: string, args: string[]) => Promise; + +/** Run the launcher detached; resolves whether it started and exited cleanly. */ +export const spawnLauncher: Spawner = (command, args) => new Promise(resolve => { + try { + const child = spawn(command, args, {stdio: 'ignore', detached: true}); + child.once('error', () => resolve(false)); + child.once('exit', code => resolve(code === 0)); + child.unref(); + } catch { resolve(false); } +}); + +/** + * Open one new host window per command line. Returns the ones that could not + * be opened (host unsupported or launcher failed), so the caller can tell the + * user how to open them manually instead of silently dropping them. + */ +export async function openWindows(host: TerminalHost, commands: readonly (readonly string[])[], spawner: Spawner = spawnLauncher): +Promise<(readonly string[])[]> { + const failed: (readonly string[])[] = []; + for (const argv of commands) { + const launch = host.newWindow?.(argv); + if (!launch || !(await spawner(launch.command, launch.args))) failed.push(argv); + } + return failed; +} diff --git a/src/index.ts b/src/index.ts index 11b39aa..15818c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,7 +50,6 @@ if (isVersionInvocation(args)) { } else { const {TerminalApp} = await import('./app/TerminalApp.js'); const {attachSession, connectSession, listLiveSessions, SESSION_SERVICE_ENV} = await import('./session/connectSession.js'); - const {planLaunch} = await import('./session/liveSessions.js'); const size = () => ({cwd: process.cwd(), columns: process.stdout.columns || 80, rows: Math.max(2, (process.stdout.rows || 24) - 4)}); const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error)); @@ -77,13 +76,26 @@ if (isVersionInvocation(args)) { if (!explicit && !args.includes('--new') && process.env[SESSION_SERVICE_ENV] !== '0') { let live: Awaited> = []; try { live = await listLiveSessions(); } catch { /* no usable service: start fresh */ } - const plan = planLaunch(live); - if (plan.kind === 'attach') target = plan.session.id; - else if (plan.kind === 'pick') { - const {runStartupPicker} = await import('./session/StartupPicker.js'); - const choice = await runStartupPicker(plan.sessions); - if (choice.kind === 'attach') target = choice.sessionId; - } + const {restoreAtStartup} = await import('./session/startupRestore.js'); + const picker = await import('./session/StartupPicker.js'); + const {detectTerminalHost} = await import('./host/terminalHost.js'); + const {loadPromptConfiguration, savePromptConfiguration} = await import('./prompt/configuration.js'); + const config = loadPromptConfiguration(); + const restored = await restoreAtStartup(live, { + policy: {startup: config.liveSessionStartup, multiple: config.liveSessionMultiple}, + saveStartup: startup => { + try { savePromptConfiguration({...loadPromptConfiguration(), liveSessionStartup: startup}); } catch { /* keep going; applies this launch */ } + }, + askOne: session => picker.runStartupScreen(columns => picker.renderSinglePrompt(session, columns, Date.now()), picker.singlePromptKey), + pick: sessions => { + const state = picker.createMultiPicker(sessions); + return picker.runStartupScreen(columns => picker.renderMultiPicker(state, columns, Date.now()), key => picker.multiPickerKey(state, key)); + }, + host: detectTerminalHost(), + selfCommand: [process.execPath, ...process.execArgv.filter(arg => !arg.startsWith('--inspect')), process.argv[1]!], + }); + target = restored.target; + notice = [notice, restored.notice].filter(Boolean).join(' ') || undefined; } // The loop lets /resume switch this window to another live session. diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index 3516752..89b246f 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -2,6 +2,11 @@ import {mkdirSync, readFileSync, renameSync, writeFileSync} from 'node:fs'; import {dirname} from 'node:path'; import {promptConfigurationPath} from '../configuration/paths.js'; import {UPDATE_CHECK_FREQUENCIES, type UpdateCheckFrequency} from '../update/update.js'; + +export const LIVE_SESSION_STARTUP = ['ask', 'always', 'never'] as const; +export type LiveSessionStartup = typeof LIVE_SESSION_STARTUP[number]; +export const LIVE_SESSION_MULTIPLE = ['ask', 'open-all'] as const; +export type LiveSessionMultiple = typeof LIVE_SESSION_MULTIPLE[number]; import type {OutputFoldingMode} from '../output/FoldPolicy.js'; import {SUGGESTION_PROVIDER_IDS, type SuggestionProviderId} from '../suggestions/types.js'; import { @@ -164,6 +169,10 @@ export interface PromptConfiguration { sessionRetention: SessionRetention; /** Background release checks are opt-in; `/update` always checks on request. */ updateChecks: UpdateCheckFrequency; + /** Whether launch restores a detached live session: ask, always, or never (never only skips; it ends nothing). */ + liveSessionStartup: LiveSessionStartup; + /** With several detached live sessions at launch: ask which, or open them all. */ + liveSessionMultiple: LiveSessionMultiple; /** Whether long, boring finished output starts collapsed. Presentation only. */ outputFolding: OutputFoldingMode; /** What new presentation sessions show at the top; archived sessions keep theirs. */ @@ -217,6 +226,8 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { glyphChoiceComplete: false, sessionRetention: 1000, updateChecks: 'off', + liveSessionStartup: 'ask', + liveSessionMultiple: 'ask', outputFolding: 'smart', welcome: 'vespyr', suggestions: 'nmsh', @@ -274,6 +285,10 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio ? value.sessionRetention as SessionRetention : 1000; const updateChecks: UpdateCheckFrequency = UPDATE_CHECK_FREQUENCIES.includes(value.updateChecks as UpdateCheckFrequency) ? value.updateChecks as UpdateCheckFrequency : 'off'; + const liveSessionStartup: LiveSessionStartup = LIVE_SESSION_STARTUP.includes(value.liveSessionStartup as LiveSessionStartup) + ? value.liveSessionStartup as LiveSessionStartup : 'ask'; + const liveSessionMultiple: LiveSessionMultiple = LIVE_SESSION_MULTIPLE.includes(value.liveSessionMultiple as LiveSessionMultiple) + ? value.liveSessionMultiple as LiveSessionMultiple : 'ask'; // Off persists as `never`, so v0.4 configs load unchanged. const outputFolding: OutputFoldingMode = value.outputFolding === 'never' || value.outputFolding === 'always' ? value.outputFolding : 'smart'; const welcome: WelcomeProviderId = WELCOME_PROVIDER_IDS.includes(value.welcome as WelcomeProviderId) @@ -326,7 +341,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio if (!Array.isArray(value.modules)) { return {...structuredClone(DEFAULT_PROMPT_CONFIGURATION), provider, onboardingComplete: value.onboardingComplete === true, - glyphStyle, glyphChoiceComplete, sessionRetention, updateChecks, outputFolding, welcome, suggestions, suggestionsOnEmpty, + glyphStyle, glyphChoiceComplete, sessionRetention, updateChecks, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, suggestionsOnEmpty, nmsh, starship: {configPath: starshipConfigPath}, powerlevel10k, transcript, syntax, placement, composerLayout, composerPosition, transcriptPresentation, spacing, gap, separator}; } @@ -367,7 +382,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio modules.splice(before === -1 ? modules.length : before, 0, {...fallback}); }); - return {provider, onboardingComplete: value.onboardingComplete === true, glyphStyle, glyphChoiceComplete, sessionRetention, updateChecks, outputFolding, welcome, suggestions, suggestionsOnEmpty, nmsh, transcript, syntax, powerlevel10k, + return {provider, onboardingComplete: value.onboardingComplete === true, glyphStyle, glyphChoiceComplete, sessionRetention, updateChecks, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, suggestionsOnEmpty, nmsh, transcript, syntax, powerlevel10k, starship: {configPath: starshipConfigPath}, placement, composerLayout, composerPosition, transcriptPresentation, modules, separator, spacing, gap}; } diff --git a/src/session/StartupPicker.ts b/src/session/StartupPicker.ts index b051a37..568aa3e 100644 --- a/src/session/StartupPicker.ts +++ b/src/session/StartupPicker.ts @@ -1,63 +1,109 @@ -import {KeyDecoder} from '../terminal/keys.js'; +import {homedir} from 'node:os'; +import {KeyDecoder, type Key} from '../terminal/keys.js'; import type {SessionInfo} from './SessionProtocol.js'; -import {describeLiveSession} from '../sessions/ResumeBrowser.js'; +import {formatAge} from './sessionList.js'; -export type PickerChoice = {kind: 'attach'; sessionId: string} | {kind: 'new'}; +// Launch-time restore screens, shown before any session is attached. Neither +// has a destructive key: killing a live session stays a confirmed /resume action. -export interface PickerState { +const clipTo = (columns: number) => (text: string) => (text.length > columns - 1 ? `${text.slice(0, Math.max(0, columns - 2))}…` : text); +const tildePath = (path: string, home = homedir()) => (home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path); +const activity = (session: SessionInfo) => session.running?.replace(/\s+/gu, ' ').slice(0, 60) ?? 'zsh'; +const age = (session: SessionInfo, now: number) => formatAge(now - (session.runningSince ?? session.idleSince ?? session.createdAt)); + +// ── One detached session ───────────────────────────────────────────────────── + +/** resume: attach · not-now: fresh session, this launch only · always/never: also persist the startup setting. */ +export type SinglePromptChoice = 'resume' | 'not-now' | 'always' | 'never'; + +export function renderSinglePrompt(session: SessionInfo, columns: number, now: number): string[] { + const clip = clipTo(columns); + return ['NMSh · 1 detached live session', '', + clip(` ${tildePath(session.cwd)}`), + clip(` ${session.running ? `running ${activity(session)}` : 'idle at the prompt'} · ${age(session, now)}`), + clip(` started ${formatAge(now - session.createdAt)} ago`), '', + ' R Resume', ' N Not now (start a new session; it keeps running)', + ' A Always resume', ' D Don\'t resume at startup (it keeps running)', '', + clip('Enter resume · Esc not now · sessions stay in /resume')]; +} + +export function singlePromptKey(key: Key): SinglePromptChoice | undefined { + if (key.kind === 'enter') return 'resume'; + if (key.kind === 'escape' || key.kind === 'interrupt') return 'not-now'; + if (key.kind !== 'text') return undefined; + return ({r: 'resume', n: 'not-now', a: 'always', d: 'never'} as const)[key.value.toLowerCase() as 'r' | 'n' | 'a' | 'd']; +} + +// ── Several detached sessions ──────────────────────────────────────────────── + +export interface MultiPickerState { sessions: SessionInfo[]; - /** sessions.length selects "New session". */ - selectedIndex: number; + cursor: number; + selected: Set; } -/** Plain lines for the launch-time restore picker. */ -export function renderPicker(state: PickerState, columns: number, now: number): string[] { - const clip = (text: string) => (text.length > columns - 1 ? `${text.slice(0, Math.max(0, columns - 2))}…` : text); - const rows = ['NMSh · detached live sessions', '']; +export function createMultiPicker(sessions: SessionInfo[]): MultiPickerState { + return {sessions, cursor: 0, selected: new Set()}; +} + +export function renderMultiPicker(state: MultiPickerState, columns: number, now: number): string[] { + const clip = clipTo(columns); + const width = Math.min(32, Math.max(...state.sessions.map(session => tildePath(session.cwd).length), 4)); + const commandWidth = Math.min(20, Math.max(...state.sessions.map(session => activity(session).length), 3)); + const rows = [`NMSh · ${state.sessions.length} detached live sessions`, '']; state.sessions.forEach((session, index) => { - rows.push(clip(`${index === state.selectedIndex ? '›' : ' '} ● ${describeLiveSession(session, now)}`)); + const mark = state.selected.has(session.id) ? '[x]' : '[ ]'; + rows.push(clip(`${index === state.cursor ? '›' : ' '} ${mark} ${tildePath(session.cwd).padEnd(width)} ${activity(session).padEnd(commandWidth)} ${age(session, now)}`)); }); - rows.push(clip(`${state.selectedIndex === state.sessions.length ? '›' : ' '} + New session`), '', - '↑↓ move · Enter choose · Esc new session'); + const count = state.selected.size; + rows.push('', clip(`↑↓ move · Space select · A all · Enter resume ${count === 0 ? 'none' : count} · Esc none`), + clip('Unselected sessions keep running and stay in /resume.')); return rows; } -/** Apply one decoded key; returns a choice once the user has made one. */ -export function pickerKey(state: PickerState, kind: string): PickerChoice | undefined { - if (kind === 'up') state.selectedIndex = Math.max(0, state.selectedIndex - 1); - else if (kind === 'down') state.selectedIndex = Math.min(state.sessions.length, state.selectedIndex + 1); - else if (kind === 'escape' || kind === 'interrupt') return {kind: 'new'}; - else if (kind === 'enter') { - const session = state.sessions[state.selectedIndex]; - return session ? {kind: 'attach', sessionId: session.id} : {kind: 'new'}; +/** Apply one key; returns the ids to resume (in list order) once the user is done, [] for none. */ +export function multiPickerKey(state: MultiPickerState, key: Key): string[] | undefined { + const count = state.sessions.length; + if (key.kind === 'up') state.cursor = (state.cursor - 1 + count) % count; + else if (key.kind === 'down') state.cursor = (state.cursor + 1) % count; + else if (key.kind === 'escape' || key.kind === 'interrupt') return []; + else if (key.kind === 'enter') return state.sessions.filter(session => state.selected.has(session.id)).map(session => session.id); + else if (key.kind === 'text' && key.value === ' ') { + const id = state.sessions[state.cursor]!.id; + if (!state.selected.delete(id)) state.selected.add(id); + } else if (key.kind === 'text' && key.value.toLowerCase() === 'a') { + // Toggle like common pickers: select all, or clear once everything is selected. + if (state.selected.size === count) state.selected.clear(); + else for (const session of state.sessions) state.selected.add(session.id); } return undefined; } -/** Interactive picker on the real terminal, shown before any session is attached. */ -export function runStartupPicker(sessions: SessionInfo[]): Promise { - const state: PickerState = {sessions, selectedIndex: 0}; +// ── Terminal runner ───────────────────────────────────────────────────────── + +/** Run a full-screen prompt on the real terminal until `onKey` returns a result. */ +export function runStartupScreen(render: (columns: number) => string[], onKey: (key: Key) => T | undefined): Promise { const decoder = new KeyDecoder(); - const draw = () => { - const lines = renderPicker(state, process.stdout.columns || 80, Date.now()); - process.stdout.write(`\u001b[H\u001b[2J${lines.join('\r\n')}`); - }; + const draw = () => process.stdout.write(`\u001b[H\u001b[2J${render(process.stdout.columns || 80).join('\r\n')}`); return new Promise(resolve => { const wasRaw = process.stdin.isRaw; process.stdin.setRawMode(true); process.stdin.setEncoding('utf8'); process.stdout.write('\u001b[?1049h\u001b[?25l'); const onData = (data: string) => { - // A read of exactly ESC is the Esc key, not the start of a sequence. - const kinds = data === '\u001b' ? ['escape'] : decoder.push(data).map(key => key.kind); - for (const kind of kinds) { - const choice = pickerKey(state, kind); - if (choice) { + // A read ending in a lone ESC ends with the Esc key, not the start of a + // sequence (e.g. Space then Esc typed quickly arrive as one read). + const trailingEscape = data.endsWith('\u001b') && !data.endsWith('\u001b\u001b'); + const keys: Key[] = decoder.push(trailingEscape ? data.slice(0, -1) : data); + if (trailingEscape) keys.push({kind: 'escape'}); + for (const key of keys) { + const result = onKey(key); + if (result !== undefined) { process.stdin.off('data', onData); process.stdin.setRawMode(wasRaw); process.stdin.pause(); process.stdout.write('\u001b[?25h\u001b[?1049l'); - resolve(choice); + resolve(result); return; } } diff --git a/src/session/liveSessions.ts b/src/session/liveSessions.ts index 570dc83..a691103 100644 --- a/src/session/liveSessions.ts +++ b/src/session/liveSessions.ts @@ -2,22 +2,32 @@ import {killSession} from './SocketSessionClient.js'; import {finalizeLiveSession} from './recovery.js'; import {defaultRuntimeDir, socketPathFor} from './runtimeDir.js'; import type {SessionInfo} from './SessionProtocol.js'; +import type {LiveSessionMultiple, LiveSessionStartup} from '../prompt/configuration.js'; import {TranscriptStore, type TranscriptSession} from '../sessions/TranscriptStore.js'; export type LaunchPlan = | {kind: 'new'} - | {kind: 'attach'; session: SessionInfo} + /** One detached session and the startup setting is Ask. */ + | {kind: 'ask'; session: SessionInfo} + /** Resume these, newest first: the first in this window, the rest in new windows. */ + | {kind: 'attach'; sessions: SessionInfo[]} | {kind: 'pick'; sessions: SessionInfo[]}; +export interface StartupPolicy { + startup: LiveSessionStartup; + multiple: LiveSessionMultiple; +} + /** - * What a plain `nmsh` launch does. Only detached sessions are candidates: - * one attached elsewhere is never taken over, so it leads to a new session. + * What a plain `nmsh` launch does. Only detached sessions are candidates: one + * attached elsewhere is never taken over. Never skips restoring at startup and + * ends nothing; every session stays available through /resume. */ -export function planLaunch(sessions: readonly SessionInfo[]): LaunchPlan { - const detached = sessions.filter(session => session.state === 'detached'); - if (detached.length === 0) return {kind: 'new'}; - if (detached.length === 1) return {kind: 'attach', session: detached[0]!}; - return {kind: 'pick', sessions: [...detached].sort((a, b) => b.createdAt - a.createdAt)}; +export function planLaunch(sessions: readonly SessionInfo[], policy: StartupPolicy = {startup: 'ask', multiple: 'ask'}): LaunchPlan { + const detached = sessions.filter(session => session.state === 'detached').sort((a, b) => b.createdAt - a.createdAt); + if (detached.length === 0 || policy.startup === 'never') return {kind: 'new'}; + if (detached.length === 1) return policy.startup === 'always' ? {kind: 'attach', sessions: detached} : {kind: 'ask', session: detached[0]!}; + return policy.multiple === 'open-all' ? {kind: 'attach', sessions: detached} : {kind: 'pick', sessions: detached}; } export interface KillOptions { diff --git a/src/session/startupRestore.ts b/src/session/startupRestore.ts new file mode 100644 index 0000000..99999ee --- /dev/null +++ b/src/session/startupRestore.ts @@ -0,0 +1,52 @@ +import type {LiveSessionStartup} from '../prompt/configuration.js'; +import {openWindows, shellQuote, type Spawner, type TerminalHost} from '../host/terminalHost.js'; +import {planLaunch, type StartupPolicy} from './liveSessions.js'; +import type {SessionInfo} from './SessionProtocol.js'; +import type {SinglePromptChoice} from './StartupPicker.js'; + +export interface StartupRestoreDeps { + policy: StartupPolicy; + /** Persist a new startup setting chosen from the one-session prompt. */ + saveStartup: (startup: LiveSessionStartup) => void; + askOne: (session: SessionInfo) => Promise; + /** Ids to resume in list order; [] for none. */ + pick: (sessions: SessionInfo[]) => Promise; + host: TerminalHost; + /** The command line that starts this NMSh, before `--attach `. */ + selfCommand: readonly string[]; + spawner?: Spawner; +} + +export interface StartupRestore { + /** Session this window attaches, if any. */ + target?: string; + /** Shown in this window: what could not be opened elsewhere, and how to attach it. */ + notice?: string; +} + +/** + * Decide which detached live sessions this launch restores. This window takes + * the first; each other one gets a new host window where the host supports it. + * Anything that cannot be opened stays detached and is named in the notice, so + * nothing selected is silently dropped, and nothing is ever ended here. + */ +export async function restoreAtStartup(live: readonly SessionInfo[], deps: StartupRestoreDeps): Promise { + const plan = planLaunch(live, deps.policy); + let ids: string[] = []; + if (plan.kind === 'attach') ids = plan.sessions.map(session => session.id); + else if (plan.kind === 'pick') ids = await deps.pick(plan.sessions); + else if (plan.kind === 'ask') { + const choice = await deps.askOne(plan.session); + if (choice === 'always') deps.saveStartup('always'); + if (choice === 'never') deps.saveStartup('never'); + if (choice === 'resume' || choice === 'always') ids = [plan.session.id]; + } + const [target, ...others] = ids; + if (others.length === 0) return target ? {target} : {}; + const failed = await openWindows(deps.host, others.map(id => [...deps.selfCommand, '--attach', id]), deps.spawner); + if (failed.length === 0) return {target}; + const commands = failed.map(argv => `nmsh --attach ${shellQuote(argv[argv.length - 1]!)}`); + const reason = deps.host.newWindow ? `could not be opened in new ${deps.host.name} windows` : `need their own windows, which NMSh cannot open in ${deps.host.name}`; + return {target, notice: `${failed.length} more selected live session${failed.length === 1 ? '' : 's'} ${reason}; ` + + `they keep running. Open a terminal window for each and run: ${commands.join(' · ')} (or use /resume).`}; +} diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index d92c98a..7957b7e 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -1,5 +1,6 @@ import {OUTPUT_FOLDING_MODES} from '../output/FoldPolicy.js'; import {UPDATE_CHECK_FREQUENCIES} from '../update/update.js'; +import {LIVE_SESSION_MULTIPLE, LIVE_SESSION_STARTUP} from '../prompt/configuration.js'; import { DEFAULT_PROMPT_CONFIGURATION, type DividerDensity, @@ -130,6 +131,12 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ enumRow({id: 'updateChecks', label: 'Update checks', description: 'Quietly check GitHub for new releases; /update checks on demand', category: 'Updates', values: UPDATE_CHECK_FREQUENCIES, labels: ['Off', 'Daily', 'Weekly'], get: config => config.updateChecks, set: (config, updateChecks) => ({...config, updateChecks})}), + enumRow({id: 'liveSessionStartup', label: 'Startup restore', description: 'Resume a detached live session at launch: Ask, Always, or Never (never ends none)', category: 'Sessions', + values: LIVE_SESSION_STARTUP, labels: ['Ask', 'Always', 'Never'], + get: config => config.liveSessionStartup, set: (config, liveSessionStartup) => ({...config, liveSessionStartup})}), + enumRow({id: 'liveSessionMultiple', label: 'Multiple detached sessions', description: 'At launch with several: ask which, or open all in new windows', category: 'Sessions', + values: LIVE_SESSION_MULTIPLE, labels: ['Ask which', 'Open all'], + get: config => config.liveSessionMultiple, set: (config, liveSessionMultiple) => ({...config, liveSessionMultiple})}), {id: 'welcome', label: 'Welcome provider', description: 'What new sessions show first: Vespyr, Fastfetch, Neofetch, or None', category: 'Welcome', control: 'child', destination: 'welcome', value: config => welcomeProvider(config.welcome).label}, {id: 'suggestions', label: 'Suggestions', description: 'Ghost-text prediction provider: NMSh Native, Deja, or None', category: 'Suggestions', diff --git a/tests/helpers/liveFrontend.ts b/tests/helpers/liveFrontend.ts index 0c987e8..762b6b1 100644 --- a/tests/helpers/liveFrontend.ts +++ b/tests/helpers/liveFrontend.ts @@ -43,7 +43,9 @@ export class LiveSandbox { get env(): NodeJS.ProcessEnv { return {...process.env, HOME: this.home, XDG_CONFIG_HOME: this.config, NMSH_RUNTIME_DIR: this.runtime, - TERM: 'xterm-256color', NMSH_SESSION_SERVICE: '1', NMSH_ACTIVE: '', ...this.extraEnv}; + TERM: 'xterm-256color', NMSH_SESSION_SERVICE: '1', NMSH_ACTIVE: '', + // A host NMSh cannot open windows in, so tests never launch real terminal windows. + TERM_PROGRAM: 'nmsh-test', GHOSTTY_RESOURCES_DIR: '', KITTY_WINDOW_ID: '', ...this.extraEnv}; } sessions() { return listLiveSessions({runtimeDir: this.runtime}); } diff --git a/tests/resumeLive.test.ts b/tests/resumeLive.test.ts index 14731c1..e32adfe 100644 --- a/tests/resumeLive.test.ts +++ b/tests/resumeLive.test.ts @@ -3,8 +3,6 @@ import assert from 'node:assert/strict'; import {mkdtempSync, rmSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; -import {planLaunch} from '../src/session/liveSessions.js'; -import {pickerKey, renderPicker} from '../src/session/StartupPicker.js'; import {createResumeBrowser, describeLiveSession, resumeRowCount, resumeSelection, visibleResumeSessions} from '../src/sessions/ResumeBrowser.js'; import {archiveLiveSession} from '../src/sessions/archiveLive.js'; import {TranscriptStore, type TranscriptSummary} from '../src/sessions/TranscriptStore.js'; @@ -14,29 +12,6 @@ import type {SessionInfo} from '../src/session/SessionProtocol.js'; const info = (id: string, state: SessionInfo['state'], extra: Partial = {}): SessionInfo => ({id, pid: 100, state, cwd: `/w/${id}`, createdAt: 1_000, ...extra}); -test('launch plan: none, one, several detached; attached sessions are never candidates', () => { - assert.deepEqual(planLaunch([]), {kind: 'new'}); - assert.deepEqual(planLaunch([info('a', 'attached')]), {kind: 'new'}); - assert.deepEqual(planLaunch([info('a', 'attached'), info('b', 'detached')]), {kind: 'attach', session: info('b', 'detached')}); - const plan = planLaunch([info('old', 'detached', {createdAt: 1}), info('a', 'attached'), info('new', 'detached', {createdAt: 9})]); - assert.equal(plan.kind, 'pick'); - assert.deepEqual(plan.kind === 'pick' && plan.sessions.map(session => session.id), ['new', 'old']); -}); - -test('startup picker: choose a session, choose new, or escape to new', () => { - const state = {sessions: [info('a', 'detached'), info('b', 'detached', {running: 'npm test', runningSince: 500})], selectedIndex: 0}; - const lines = renderPicker(state, 120, 61_000); - assert.match(lines.join('\n'), /› ● \/w\/a · detached · idle · started 1m ago/); - assert.match(lines.join('\n'), /running npm test · 1m/); - assert.match(lines.join('\n'), /\+ New session/); - pickerKey(state, 'down'); - assert.deepEqual(pickerKey(state, 'enter'), {kind: 'attach', sessionId: 'b'}); - pickerKey(state, 'down'); - pickerKey(state, 'down'); - assert.deepEqual(pickerKey(state, 'enter'), {kind: 'new'}); - assert.deepEqual(pickerKey({...state, selectedIndex: 0}, 'escape'), {kind: 'new'}); -}); - function summary(id: string, createdAt: string): TranscriptSummary { return {id, createdAt, commandCount: 1, startCwd: '/w', finalCwd: '/w', project: 'p', pinned: false, journaled: true, endedAt: createdAt}; } diff --git a/tests/sessionLifecycle.test.ts b/tests/sessionLifecycle.test.ts index 9822881..9ea91af 100644 --- a/tests/sessionLifecycle.test.ts +++ b/tests/sessionLifecycle.test.ts @@ -36,7 +36,8 @@ test('SIGKILLed frontend detaches; the same shell keeps running and a new fronte // A running command gated on a file, so nothing depends on timing. const gate = join(sandbox.home, 'go'); first.pty.write(`while [ ! -f ${gate} ]; do sleep 0.05; done; echo RELEASED-$$\r`); - await until(async () => (await sandbox.sessions())[0]?.running !== undefined, 15000, 'running command'); + // Wait for this command specifically: the previous echo can still be `running` until its prompt arrives. + await until(async () => /RELEASED/.test((await sandbox.sessions())[0]?.running ?? ''), 15000, 'running command'); first.pty.kill('SIGKILL'); const detached = await waitState(sandbox, 'detached'); diff --git a/tests/startupDiscovery.test.ts b/tests/startupDiscovery.test.ts index 2fe1038..e4eeb7f 100644 --- a/tests/startupDiscovery.test.ts +++ b/tests/startupDiscovery.test.ts @@ -1,5 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import {readFileSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; import {LiveSandbox, processAlive, strip, until, type Frontend} from './helpers/liveFrontend.js'; /** Launch-time discovery and /resume LIVE with real frontends, nmshd and zsh. */ @@ -26,57 +28,162 @@ async function shellPid(frontend: Frontend): Promise { return Number(/PID=(\d+)/.exec(strip(frontend.output.slice(mark)))![1]); } -test('launch: none -> new, one detached -> auto-reattach, --new forces new, attached elsewhere is not hijacked', async () => { +function savedConfig(sandbox: LiveSandbox): Record { + return JSON.parse(readFileSync(join(sandbox.config, 'nmsh', 'config.json'), 'utf8')) as Record; +} + +async function detachedSessions(sandbox: LiveSandbox, count: number): Promise { + const pids: number[] = []; + for (let index = 0; index < count; index += 1) { + const frontend = await started(sandbox, ['--new']); + pids.push(await shellPid(frontend)); + frontend.pty.kill('SIGKILL'); + } + await detachedCount(sandbox, count); + return pids; +} + +const PROMPT = /1 detached live session[\s\S]*R {2}Resume[\s\S]*N {2}Not now/; + +test('one detached session asks by default: Not now and Esc start fresh and leave it; Resume attaches; nothing is hijacked', async () => { const sandbox = new LiveSandbox(); try { - const a = await started(sandbox); - const aPid = await shellPid(a); - a.pty.kill('SIGKILL'); + const [aPid] = await detachedSessions(sandbox, 1); + + const notNow = sandbox.launch(); + await notNow.waitFor(PROMPT); + assert.match(strip(notNow.output), /idle at the prompt/); + notNow.pty.write('n'); + await notNow.waitFor(/❯/); + assert.notEqual(await shellPid(notNow), aPid, 'Not now starts a new session'); + assert.ok(processAlive(aPid!)); + await detachedCount(sandbox, 1); + + const escape = sandbox.launch(); + await escape.waitFor(PROMPT); + escape.pty.write('\u001b'); + await escape.waitFor(/❯/); + assert.notEqual(await shellPid(escape), aPid, 'Esc is Not now'); await detachedCount(sandbox, 1); - const b = sandbox.launch(); - await b.waitFor(/Reattached live session/); - assert.equal(await shellPid(b), aPid, 'exactly one detached session is reattached automatically'); - assert.equal((await count(sandbox, 1))[0]!.state, 'attached'); + const resume = sandbox.launch(); + await resume.waitFor(PROMPT); + resume.pty.write('\r'); + await resume.waitFor(/Reattached live session/); + assert.equal(await shellPid(resume), aPid); + + // Every session is attached now: a plain launch neither prompts nor takes one over. + const fresh = await started(sandbox); + assert.doesNotMatch(strip(fresh.output), /detached live session/); + assert.ok(![aPid].includes(await shellPid(fresh))); + assert.equal((await count(sandbox, 4)).every(session => session.state === 'attached'), true); + assert.equal(savedConfig(sandbox).liveSessionStartup ?? 'ask', 'ask', 'one-off choices are not persisted'); + } finally { + await sandbox.dispose(); + } +}); - const c = await started(sandbox); - assert.notEqual(await shellPid(c), aPid, 'a session attached elsewhere leads to a new session'); - assert.equal((await count(sandbox, 2)).every(session => session.state === 'attached'), true); - assert.ok(processAlive(aPid)); +test('Always and Never from the prompt persist; Never ends nothing and /resume still lists the session', async () => { + const sandbox = new LiveSandbox(); + try { + const [aPid] = await detachedSessions(sandbox, 1); + const never = sandbox.launch(); + await never.waitFor(PROMPT); + never.pty.write('d'); + await never.waitFor(/❯/); + assert.notEqual(await shellPid(never), aPid); + assert.equal(savedConfig(sandbox).liveSessionStartup, 'never'); + assert.ok(processAlive(aPid!), 'Never does not kill the session'); + + never.pty.kill('SIGKILL'); + await detachedCount(sandbox, 2); + const later = sandbox.launch(); + await later.waitFor(/❯/); + assert.doesNotMatch(strip(later.output), /detached live session/, 'Never: no prompt, no picker'); + assert.ok(![aPid].includes(await shellPid(later))); + await detachedCount(sandbox, 2); + const mark = later.mark; + later.pty.write('/resume\r'); + await later.waitFor(/LIVE[\s\S]*detached/, mark); + later.pty.write('\u001b'); + + // Always: resume without asking from now on. + writeFileSync(join(sandbox.config, 'nmsh', 'config.json'), JSON.stringify({...savedConfig(sandbox), liveSessionStartup: 'ask'})); + later.pty.kill('SIGKILL'); + await detachedCount(sandbox, 3); + const pick = sandbox.launch(); + await pick.waitFor(/3 detached live sessions/); + pick.pty.write('\u001b'); + await pick.waitFor(/❯/); + await detachedCount(sandbox, 3); + } finally { + await sandbox.dispose(); + } +}); - c.pty.kill('SIGKILL'); +test('Always resumes one detached session without asking', async () => { + const sandbox = new LiveSandbox(); + try { + const [aPid] = await detachedSessions(sandbox, 1); + const always = sandbox.launch(); + await always.waitFor(PROMPT); + always.pty.write('a'); + await always.waitFor(/Reattached live session/); + assert.equal(await shellPid(always), aPid); + assert.equal(savedConfig(sandbox).liveSessionStartup, 'always'); + + always.pty.kill('SIGKILL'); await detachedCount(sandbox, 1); - const d = await started(sandbox, ['--new']); - await count(sandbox, 3); - assert.doesNotMatch(strip(d.output), /Reattached/); + const next = sandbox.launch(); + await next.waitFor(/Reattached live session/); + assert.doesNotMatch(strip(next.output), /1 detached live session/); + assert.equal(await shellPid(next), aPid); } finally { await sandbox.dispose(); } }); -test('launch with several detached sessions shows the picker; choose one or start new', async () => { +test('several detached sessions: pick some, Esc for none; this window owns one, the rest stay detached and are named', async () => { const sandbox = new LiveSandbox(); try { - const pids: number[] = []; - for (let index = 0; index < 3; index += 1) { - const frontend = await started(sandbox, ['--new']); - pids.push(await shellPid(frontend)); - frontend.pty.kill('SIGKILL'); - } + const pids = await detachedSessions(sandbox, 3); + const none = sandbox.launch(); + await none.waitFor(/3 detached live sessions[\s\S]*Space select/); + none.pty.write(' '); + none.pty.write('\u001b'); + await none.waitFor(/❯/); + assert.ok(!pids.includes(await shellPid(none)), 'Esc resumes none'); await detachedCount(sandbox, 3); const picker = sandbox.launch(); - await picker.waitFor(/detached live sessions[\s\S]*\+ New session/); + await picker.waitFor(/3 detached live sessions/); + // Newest first: select the newest and the oldest. + picker.pty.write(' '); + picker.pty.write('\u001b[B'); + picker.pty.write('\u001b[B'); + picker.pty.write(' '); picker.pty.write('\r'); await picker.waitFor(/Reattached live session/); - assert.equal(await shellPid(picker), pids[2], 'the newest detached session is first'); - - const fresh = sandbox.launch(); - await fresh.waitFor(/\+ New session/); - fresh.pty.write('\u001b'); - await fresh.waitFor(/❯/); - assert.ok(!pids.includes(await shellPid(fresh))); - await count(sandbox, 4); + assert.equal(await shellPid(picker), pids[2], 'this window takes the first selected'); + await picker.waitFor(/1 more selected live session need their own windows[\s\S]*they keep running/); + await detachedCount(sandbox, 2); + assert.ok(pids.every(pid => processAlive(pid)), 'nothing selected or unselected was ended'); + } finally { + await sandbox.dispose(); + } +}); + +test('Open all restores every detached session: one here, the others as far as the host allows', async () => { + const sandbox = new LiveSandbox({liveSessionMultiple: 'open-all'}); + try { + const pids = await detachedSessions(sandbox, 2); + const all = sandbox.launch(); + await all.waitFor(/Reattached live session/); + assert.doesNotMatch(strip(all.output), /detached live sessions/, 'no picker'); + assert.equal(await shellPid(all), pids[1]); + // The test host cannot open windows: the other session is named with its attach command. + await all.waitFor(/nmsh --attach [\w-]+/); + await detachedCount(sandbox, 1); } finally { await sandbox.dispose(); } diff --git a/tests/startupRestore.test.ts b/tests/startupRestore.test.ts new file mode 100644 index 0000000..5ad82ee --- /dev/null +++ b/tests/startupRestore.test.ts @@ -0,0 +1,166 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {planLaunch} from '../src/session/liveSessions.js'; +import {createMultiPicker, multiPickerKey, renderMultiPicker, renderSinglePrompt, singlePromptKey} from '../src/session/StartupPicker.js'; +import {restoreAtStartup, type StartupRestoreDeps} from '../src/session/startupRestore.js'; +import {detectTerminalHost, openWindows, shellQuote, type TerminalHost} from '../src/host/terminalHost.js'; +import {DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import type {SessionInfo} from '../src/session/SessionProtocol.js'; +import type {Key} from '../src/terminal/keys.js'; + +const info = (id: string, state: SessionInfo['state'], extra: Partial = {}): SessionInfo => + ({id, pid: 100, state, cwd: `/w/${id}`, createdAt: 1_000, ...extra}); +const text = (value: string): Key => ({kind: 'text', value}); +const key = (kind: string): Key => ({kind} as Key); + +test('settings default to Ask/Ask and reject unknown values', () => { + assert.equal(DEFAULT_PROMPT_CONFIGURATION.liveSessionStartup, 'ask'); + assert.equal(DEFAULT_PROMPT_CONFIGURATION.liveSessionMultiple, 'ask'); + const loaded = normalizePromptConfiguration({liveSessionStartup: 'never', liveSessionMultiple: 'open-all'}); + assert.equal(loaded.liveSessionStartup, 'never'); + assert.equal(loaded.liveSessionMultiple, 'open-all'); + const bogus = normalizePromptConfiguration({liveSessionStartup: 'kill', liveSessionMultiple: 'kill-all'}); + assert.equal(bogus.liveSessionStartup, 'ask'); + assert.equal(bogus.liveSessionMultiple, 'ask'); +}); + +test('launch plan follows the two settings; attached sessions are never candidates', () => { + const one = [info('a', 'attached'), info('b', 'detached')]; + const two = [info('old', 'detached', {createdAt: 1}), info('a', 'attached'), info('new', 'detached', {createdAt: 9})]; + assert.deepEqual(planLaunch([]), {kind: 'new'}); + assert.deepEqual(planLaunch([info('a', 'attached')]), {kind: 'new'}); + assert.deepEqual(planLaunch(one), {kind: 'ask', session: info('b', 'detached')}, 'Ask is the default: no silent attach'); + assert.deepEqual(planLaunch(one, {startup: 'always', multiple: 'ask'}), {kind: 'attach', sessions: [info('b', 'detached')]}); + assert.deepEqual(planLaunch(one, {startup: 'never', multiple: 'open-all'}), {kind: 'new'}); + assert.deepEqual(planLaunch(two, {startup: 'never', multiple: 'open-all'}), {kind: 'new'}); + const pick = planLaunch(two); + assert.deepEqual(pick.kind === 'pick' && pick.sessions.map(session => session.id), ['new', 'old']); + const all = planLaunch(two, {startup: 'ask', multiple: 'open-all'}); + assert.deepEqual(all.kind === 'attach' && all.sessions.map(session => session.id), ['new', 'old']); + assert.equal(planLaunch(two, {startup: 'always', multiple: 'ask'}).kind, 'pick'); +}); + +test('one-session prompt: identifying details, and R/Enter, N/Esc, A, D; no destructive key', () => { + const session = info('a', 'detached', {cwd: '/w/project', running: 'vim notes.md', runningSince: 1_000}); + const lines = renderSinglePrompt(session, 100, 181_000).join('\n'); + assert.match(lines, /1 detached live session/); + assert.match(lines, /\/w\/project/); + assert.match(lines, /running vim notes\.md · 3m/); + assert.doesNotMatch(lines, /kill/i); + assert.equal(singlePromptKey(key('enter')), 'resume'); + assert.equal(singlePromptKey(text('r')), 'resume'); + assert.equal(singlePromptKey(text('N')), 'not-now'); + assert.equal(singlePromptKey(key('escape')), 'not-now'); + assert.equal(singlePromptKey(key('interrupt')), 'not-now'); + assert.equal(singlePromptKey(text('a')), 'always'); + assert.equal(singlePromptKey(text('d')), 'never'); + assert.equal(singlePromptKey(text('k')), undefined); + assert.equal(singlePromptKey(key('down')), undefined); +}); + +test('multi picker: move, Space toggles, A selects all then clears, Enter resumes selected in order, Esc none', () => { + const sessions = [info('s1', 'detached'), info('s2', 'detached', {running: 'npm test'}), info('s3', 'detached')]; + const state = createMultiPicker(sessions); + assert.match(renderMultiPicker(state, 100, 61_000).join('\n'), /3 detached live sessions[\s\S]*› \[ \] \/w\/s1[\s\S]*npm test/); + assert.equal(multiPickerKey(state, key('down')), undefined); + multiPickerKey(state, text(' ')); + multiPickerKey(state, key('down')); + multiPickerKey(state, text(' ')); + multiPickerKey(state, text(' ')); // toggled back off + assert.match(renderMultiPicker(state, 100, 61_000).join('\n'), /\[x\] \/w\/s2[\s\S]*Enter resume 1/); + multiPickerKey(state, key('up')); + multiPickerKey(state, key('up')); + multiPickerKey(state, text(' ')); + assert.deepEqual(multiPickerKey({...state, selected: new Set(state.selected)}, key('enter')), ['s1', 's2']); + multiPickerKey(state, text('a')); + assert.equal(state.selected.size, 3); + multiPickerKey(state, text('A')); + assert.equal(state.selected.size, 0, 'A again clears everything'); + assert.deepEqual(multiPickerKey(state, key('enter')), [], 'Enter with nothing selected resumes none'); + multiPickerKey(state, text('a')); + assert.deepEqual(multiPickerKey(state, key('escape')), [], 'Esc resumes none even with a selection'); + assert.equal(multiPickerKey(state, text('k')), undefined, 'no destructive key'); +}); + +test('host detection only offers new windows where the host supports it', () => { + const ghosttyMac = detectTerminalHost({TERM_PROGRAM: 'ghostty'}, 'darwin'); + assert.deepEqual(ghosttyMac.newWindow?.(['/n/node', '/n/i.js', '--attach', 'x']), + {command: 'open', args: ['-na', 'Ghostty.app', '--args', '-e', '/n/node', '/n/i.js', '--attach', 'x']}); + assert.deepEqual(detectTerminalHost({TERM_PROGRAM: 'ghostty'}, 'linux').newWindow?.(['nmsh']), {command: 'ghostty', args: ['-e', 'nmsh']}); + const terminal = detectTerminalHost({TERM_PROGRAM: 'Apple_Terminal'}, 'darwin').newWindow?.(['/a b/node', "it's"]); + assert.equal(terminal?.command, 'osascript'); + assert.equal(terminal?.args[1], `tell application "Terminal" to do script "'/a b/node' 'it'\\\\''s'"`); + assert.equal(detectTerminalHost({KITTY_WINDOW_ID: '1', TERM_PROGRAM: ''}, 'linux').newWindow?.(['x'])?.command, 'kitten'); + assert.equal(detectTerminalHost({TERM_PROGRAM: 'vscode'}, 'darwin').newWindow, undefined); + assert.equal(detectTerminalHost({TERM_PROGRAM: 'zed'}, 'darwin').newWindow, undefined); + assert.equal(detectTerminalHost({}, 'darwin').newWindow, undefined); + assert.equal(shellQuote('/plain/path'), '/plain/path'); + assert.equal(shellQuote('a b'), "'a b'"); +}); + +test('openWindows reports every command it could not open, without real GUI windows', async () => { + const calls: string[][] = []; + const host: TerminalHost = {name: 'Fake', newWindow: argv => ({command: 'launch', args: [...argv]})}; + const failed = await openWindows(host, [['a'], ['b'], ['c']], async (command, args) => { calls.push([command, ...args]); return args[0] !== 'b'; }); + assert.deepEqual(calls, [['launch', 'a'], ['launch', 'b'], ['launch', 'c']]); + assert.deepEqual(failed, [['b']]); + assert.deepEqual(await openWindows({name: 'None'}, [['a']], async () => { throw new Error('never called'); }), [['a']]); +}); + +function deps(overrides: Partial = {}) { + const saved: string[] = []; + const spawned: string[][] = []; + const result: StartupRestoreDeps & {saved: string[]; spawned: string[][]} = { + saved, spawned, + policy: {startup: 'ask', multiple: 'ask'}, + saveStartup: startup => saved.push(startup), + askOne: async () => { throw new Error('unexpected prompt'); }, + pick: async () => { throw new Error('unexpected picker'); }, + host: {name: 'Ghostty', newWindow: argv => ({command: 'open', args: [...argv]})}, + selfCommand: ['node', '/nmsh/index.js'], + spawner: async (_command, args) => { spawned.push(args); return true; }, + ...overrides, + }; + return result; +} + +test('one detached session: Resume, Not now, Always and Never; Never ends nothing', async () => { + const live = [info('only', 'detached')]; + assert.deepEqual(await restoreAtStartup(live, deps({askOne: async () => 'resume'})), {target: 'only'}); + assert.deepEqual(await restoreAtStartup(live, deps({askOne: async () => 'not-now'})), {}); + const always = deps({askOne: async () => 'always'}); + assert.deepEqual(await restoreAtStartup(live, always), {target: 'only'}); + assert.deepEqual(always.saved, ['always']); + const never = deps({askOne: async () => 'never'}); + assert.deepEqual(await restoreAtStartup(live, never), {}); + assert.deepEqual(never.saved, ['never']); + assert.deepEqual(never.spawned, []); + // With the settings saved, later launches do not ask. + assert.deepEqual(await restoreAtStartup(live, deps({policy: {startup: 'always', multiple: 'ask'}})), {target: 'only'}); + assert.deepEqual(await restoreAtStartup(live, deps({policy: {startup: 'never', multiple: 'ask'}})), {}); + assert.deepEqual(await restoreAtStartup([], deps()), {}); +}); + +test('several sessions: this window takes the first chosen, others open in new host windows', async () => { + const live = [info('s1', 'detached', {createdAt: 3}), info('s2', 'detached', {createdAt: 2}), info('s3', 'detached', {createdAt: 1}), info('busy', 'attached')]; + const picked = deps({pick: async sessions => { assert.deepEqual(sessions.map(s => s.id), ['s1', 's2', 's3']); return ['s1', 's3']; }}); + assert.deepEqual(await restoreAtStartup(live, picked), {target: 's1'}); + assert.deepEqual(picked.spawned, [['node', '/nmsh/index.js', '--attach', 's3']], 'only the selected, never the attached one'); + + assert.deepEqual(await restoreAtStartup(live, deps({pick: async () => []})), {}, 'Esc / none: start fresh'); + + const all = deps({policy: {startup: 'ask', multiple: 'open-all'}}); + assert.deepEqual(await restoreAtStartup(live, all), {target: 's1'}); + assert.deepEqual(all.spawned.map(args => args.at(-1)), ['s2', 's3']); +}); + +test('hosts that cannot open windows lose nothing: one attaches here, the rest are named with their attach command', async () => { + const live = [info('s1', 'detached', {createdAt: 2}), info('s2', 'detached', {createdAt: 1})]; + const unsupported = await restoreAtStartup(live, deps({policy: {startup: 'ask', multiple: 'open-all'}, host: {name: 'VS Code'}})); + assert.equal(unsupported.target, 's1'); + assert.match(unsupported.notice ?? '', /1 more selected live session need their own windows, which NMSh cannot open in VS Code; they keep running/); + assert.match(unsupported.notice ?? '', /nmsh --attach s2/); + + const failing = await restoreAtStartup(live, deps({policy: {startup: 'ask', multiple: 'open-all'}, spawner: async () => false})); + assert.match(failing.notice ?? '', /could not be opened in new Ghostty windows[\s\S]*nmsh --attach s2/); +}); From c13aa97d10658b0f20048321fcfdf290c0a5f5dd Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:30:34 +0530 Subject: [PATCH 2/3] Wait for the interrupted job before typing in the Ctrl+Z test The next command was typed right after Ctrl+C, before the interrupted job's prompt returned; the tty can flush input queued around an interrupt, so the command was sometimes lost (seen in CI). Wait until the sleep has exited and NMSh has recorded the interruption. --- tests/ctrlZJobControl.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/ctrlZJobControl.test.ts b/tests/ctrlZJobControl.test.ts index b6c3a3d..2e5d252 100644 --- a/tests/ctrlZJobControl.test.ts +++ b/tests/ctrlZJobControl.test.ts @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {KeyDecoder, decodeKeys} from '../src/terminal/keys.js'; import {LiveSandbox, until} from './helpers/liveFrontend.js'; -import {inForeground, stopped, uniqueSleep} from './helpers/processState.js'; +import {inForeground, sleepState, stopped, uniqueSleep} from './helpers/processState.js'; test('Ctrl+Z decodes to suspend in raw and Kitty forms without disturbing Ctrl+C/Ctrl+D', () => { assert.deepEqual(decodeKeys('\u001a'), [{kind: 'suspend'}]); @@ -49,7 +49,12 @@ async function jobControlThroughFrontend(mode: 'service' | 'in-process', ctrlZ: app.pty.write('fg\r'); await app.waitFor(/continued/, mark); await until(() => inForeground(duration), 20000, 'sleep resumed in the foreground'); + mark = app.mark; app.pty.write('\u0003'); + // Type the next command only once the job is gone and its prompt is back: + // the tty flushes input queued around an interrupt. + await until(() => sleepState(duration) === '', 20000, 'sleep ended'); + await app.waitFor(/Interrupted/, mark); await app.run('echo JOBCOUNT=$(jobs | wc -l | tr -d " ")', /JOBCOUNT=0/); // Ctrl+D on an empty composer still ends the session and NMSh. From 742abd6ca00cc3a324d2160091b06cdbcbb0f522 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:47:03 +0530 Subject: [PATCH 3/3] Open extra Ghostty windows in the running app on macOS Replace open -na Ghostty.app, which started a separate Ghostty instance per window, with Ghostty's AppleScript API: a new surface configuration whose command is the nmsh --attach line, then new window with that configuration. The script is fixed text; the command words are passed as osascript argv and shell-quoted with quoted form of, so no path or session id is interpolated into AppleScript. A disabled scripting interface, denied Automation permission or a Ghostty error exits non-zero, which keeps the existing fallback: the session stays detached and its attach command is named. --- docs/design/session-interaction-ux.md | 2 +- src/host/terminalHost.ts | 25 ++++++++++++++++-- tests/startupRestore.test.ts | 37 +++++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/docs/design/session-interaction-ux.md b/docs/design/session-interaction-ux.md index 1d231c3..1b18dfc 100644 --- a/docs/design/session-interaction-ux.md +++ b/docs/design/session-interaction-ux.md @@ -113,7 +113,7 @@ Live sessions are owned by the per-user session service (`nmshd`), which listens - The picker uses ↑↓ to move, Space to select, A to select all (A again clears), and Enter to resume the selected sessions. Esc, or Enter with nothing selected, starts fresh. - Neither screen has a destructive key: killing stays a confirmed `/resume` action. `--new` still skips restoring, and `--attach ` attaches one session explicitly. - When several sessions are restored, this window attaches the first. Each other one opens in a new window of the host terminal, running `nmsh --attach `: - - Ghostty on macOS: `open -na Ghostty.app --args -e …`. Ghostty has no API to open a window in the running instance, so this starts a separate Ghostty instance. + - Ghostty on macOS: Ghostty's AppleScript API (`new surface configuration`, then `new window with configuration`). It opens a normal window in the running Ghostty app. The command words are passed as `osascript` arguments and shell-quoted with `quoted form of`, never written into the script. macOS asks once for Automation permission. If AppleScript is disabled or permission is denied, NMSh names the session's `nmsh --attach` command instead. - Ghostty on Linux: `ghostty -e`. - Terminal.app: AppleScript `do script`. macOS asks once for Automation permission. - kitty: `kitten @ launch --type=os-window`, which needs kitty remote control. diff --git a/src/host/terminalHost.ts b/src/host/terminalHost.ts index 1576b14..4e0f198 100644 --- a/src/host/terminalHost.ts +++ b/src/host/terminalHost.ts @@ -19,12 +19,33 @@ export function shellQuote(value: string): string { const appleScriptString = (value: string) => `"${value.replace(/\\/gu, '\\\\').replace(/"/gu, '\\"')}"`; +/** + * Fixed script for Ghostty on macOS: builds a shell command from its argv with + * `quoted form of` (POSIX quoting) and opens a window running it in the + * running Ghostty instance. Fails (non-zero exit) if AppleScript is disabled, + * Automation permission is denied, or Ghostty rejects the request. + */ +export const GHOSTTY_NEW_WINDOW_SCRIPT = [ + 'on run argv', + 'set commandLine to ""', + 'repeat with word_ in argv', + 'set commandLine to commandLine & quoted form of (word_ as text) & " "', + 'end repeat', + 'tell application "Ghostty"', + 'set cfg to new surface configuration', + 'set command of cfg to commandLine', + 'new window with configuration cfg', + 'end tell', + 'end run', +] as const; + export function detectTerminalHost(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): TerminalHost { const program = env.TERM_PROGRAM ?? ''; if (program === 'ghostty' || env.GHOSTTY_RESOURCES_DIR) { return {name: 'Ghostty', newWindow: argv => platform === 'darwin' - // No scripting API opens a window in the running instance; this starts a new Ghostty instance. - ? {command: 'open', args: ['-na', 'Ghostty.app', '--args', '-e', ...argv]} + // 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 (program === 'Apple_Terminal' && platform === 'darwin') { diff --git a/tests/startupRestore.test.ts b/tests/startupRestore.test.ts index 5ad82ee..07204e7 100644 --- a/tests/startupRestore.test.ts +++ b/tests/startupRestore.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import {planLaunch} from '../src/session/liveSessions.js'; import {createMultiPicker, multiPickerKey, renderMultiPicker, renderSinglePrompt, singlePromptKey} from '../src/session/StartupPicker.js'; import {restoreAtStartup, type StartupRestoreDeps} from '../src/session/startupRestore.js'; -import {detectTerminalHost, openWindows, shellQuote, type TerminalHost} from '../src/host/terminalHost.js'; +import {GHOSTTY_NEW_WINDOW_SCRIPT, detectTerminalHost, openWindows, shellQuote, type TerminalHost} from '../src/host/terminalHost.js'; import {DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration} from '../src/prompt/configuration.js'; import type {SessionInfo} from '../src/session/SessionProtocol.js'; import type {Key} from '../src/terminal/keys.js'; @@ -85,7 +85,7 @@ test('multi picker: move, Space toggles, A selects all then clears, Enter resume test('host detection only offers new windows where the host supports it', () => { const ghosttyMac = detectTerminalHost({TERM_PROGRAM: 'ghostty'}, 'darwin'); assert.deepEqual(ghosttyMac.newWindow?.(['/n/node', '/n/i.js', '--attach', 'x']), - {command: 'open', args: ['-na', 'Ghostty.app', '--args', '-e', '/n/node', '/n/i.js', '--attach', 'x']}); + {command: 'osascript', args: [...GHOSTTY_NEW_WINDOW_SCRIPT.flatMap(line => ['-e', line]), '/n/node', '/n/i.js', '--attach', 'x']}); assert.deepEqual(detectTerminalHost({TERM_PROGRAM: 'ghostty'}, 'linux').newWindow?.(['nmsh']), {command: 'ghostty', args: ['-e', 'nmsh']}); const terminal = detectTerminalHost({TERM_PROGRAM: 'Apple_Terminal'}, 'darwin').newWindow?.(['/a b/node', "it's"]); assert.equal(terminal?.command, 'osascript'); @@ -98,6 +98,39 @@ test('host detection only offers new windows where the host supports it', () => assert.equal(shellQuote('a b'), "'a b'"); }); +test('Ghostty on macOS opens windows in the running app through its AppleScript API, with data only in argv', async () => { + const script = GHOSTTY_NEW_WINDOW_SCRIPT.join('\n'); + assert.match(script, /^on run argv$/m); + assert.match(script, /quoted form of \(word_ as text\)/, 'each word is shell-quoted by AppleScript'); + assert.match(script, /tell application "Ghostty"\nset cfg to new surface configuration\nset command of cfg to commandLine\nnew window with configuration cfg/); + assert.doesNotMatch(script, /open -na|Ghostty\.app/, 'never a separate app instance'); + + const host = detectTerminalHost({TERM_PROGRAM: 'ghostty'}, 'darwin'); + const hostile = ['/Apps/My "NMSh"/node', "it's; rm -rf ~", '--attach', 'id"\ntell application "Finder" to quit']; + const launch = host.newWindow!(hostile); + const scriptArgs = launch.args.slice(0, GHOSTTY_NEW_WINDOW_SCRIPT.length * 2); + assert.deepEqual(launch.args.slice(scriptArgs.length), hostile, 'values are passed through untouched as argv'); + for (const value of hostile) assert.ok(!scriptArgs.some(arg => arg.includes(value)), 'no value is interpolated into the script'); + + const calls: string[][] = []; + const ok = await openWindows(host, [['node', 'nmsh', '--attach', 's2']], async (command, args) => { calls.push([command, ...args]); return true; }); + assert.deepEqual(ok, []); + assert.equal(calls[0]![0], 'osascript'); + assert.deepEqual(calls[0]!.slice(-4), ['node', 'nmsh', '--attach', 's2']); + + // AppleScript disabled, Automation denied, or Ghostty refusing: osascript exits non-zero. + const denied = await openWindows(host, [['node', 'nmsh', '--attach', 's2']], async () => false); + assert.deepEqual(denied, [['node', 'nmsh', '--attach', 's2']], 'the session is reported back, not lost'); +}); + +test('a denied Ghostty launch leaves the session detached and names its attach command', async () => { + const live = [info('s1', 'detached', {createdAt: 2}), info('s2', 'detached', {createdAt: 1})]; + const result = await restoreAtStartup(live, deps({policy: {startup: 'ask', multiple: 'open-all'}, + host: detectTerminalHost({TERM_PROGRAM: 'ghostty'}, 'darwin'), spawner: async command => command !== 'osascript'})); + assert.equal(result.target, 's1'); + assert.match(result.notice ?? '', /could not be opened in new Ghostty windows; they keep running[\s\S]*nmsh --attach s2/); +}); + test('openWindows reports every command it could not open, without real GUI windows', async () => { const calls: string[][] = []; const host: TerminalHost = {name: 'Fake', newWindow: argv => ({command: 'launch', args: [...argv]})};