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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
13 changes: 13 additions & 0 deletions docs/design/session-interaction-ux.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` 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 <id>`:
- 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.
- 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:
Expand Down
3 changes: 2 additions & 1 deletion src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
}

Expand Down
89 changes: 89 additions & 0 deletions src/host/terminalHost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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, '\\"')}"`;

/**
* 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'
// 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') {
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<boolean>;

/** 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;
}
28 changes: 20 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand All @@ -77,13 +76,26 @@ if (isVersionInvocation(args)) {
if (!explicit && !args.includes('--new') && process.env[SESSION_SERVICE_ENV] !== '0') {
let live: Awaited<ReturnType<typeof listLiveSessions>> = [];
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.
Expand Down
19 changes: 17 additions & 2 deletions src/prompt/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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};
}

Expand Down Expand Up @@ -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};
}

Expand Down
Loading
Loading