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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **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
- **Interactive terminal UIs that draw inline** (agent CLIs such as Claude Code, including under a wrapper or alias like `claude-account2`) now get the terminal. NMSh hands over as soon as a running program turns on terminal input modes (bracketed paste, mouse, focus events or the kitty keyboard protocol), not only when it switches to the alternate screen or has a known name. Keys reach the program, NMSh's composer steps aside, no control sequences leak into the transcript, and the same applies after reattaching. Ordinary commands, including progress output, stay in NMSh.
- **Restoring several detached sessions** (the startup picker or Open all) opens every selected session again. NMSh used to exit while waiting for the first extra window to open, printing "Detected unsettled top-level await": only that window appeared, and the current window never attached. In Terminal.app this looked like `nmsh` failing to start. A window launcher that hangs now times out after 15 s, and that session's `nmsh --attach` command is named instead.
- Ctrl+Z suspends the foreground job again (raw `^Z` and the Kitty keyboard encoding), and `jobs` and `fg` work as in plain zsh.
- After Ctrl+Z, `jobs` could list a stray `suspended (tty output)` job. NMSh's own prompt hook ran `stty` as a job, and it could be stopped when it ran before zsh had taken the terminal back. The hooks now change terminal modes outside job control.
- The session service no longer crashes when a window resize races a shell's exit. node-pty could throw `EBADF` for a PTY that had just closed, which inside nmshd would have ended every live session. Other resize failures are now reported to that window instead of ending the service.
Expand Down
9 changes: 8 additions & 1 deletion src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import {installGhosttyKeybinding} from '../keyboard/ghosttyKeyboard.js';
import {detectGhosttyConfigPath, readGhosttySettings, saveGhosttySettings} from '../appearance/ghostty.js';
import {Highlighter} from '../input/Highlighter.js';
import {handleSyntaxPanelKey, renderSyntaxPanel, type SyntaxPanelState} from '../input/SyntaxPanel.js';
import {AlternateScreenTracker} from '../session/TerminalModes.js';
import {createLayoutPanel, handleLayoutPanelKey, renderLayoutPanel, type LayoutPanelState} from '../ui/LayoutPanel.js';
import {syntaxCharStyles, syntaxSgrForConfiguration, type SyntaxSgr} from '../input/syntaxTheme.js';
import {SemanticService} from '../shell/SemanticService.js';
Expand Down Expand Up @@ -151,6 +152,8 @@ export class TerminalApp {
private welcomeGeneration = 0;
private syntaxPanelState?: SyntaxPanelState;
private layoutPanelState?: LayoutPanelState;
/** Terminal modes the running command has set, for handing the terminal to it mid-command. */
private readonly commandModes = new AlternateScreenTracker();
private settingsPanelState?: SettingsPanelState;
private running?: {command: string; startedAt: number; interrupted: boolean; cleared: boolean; startId: number; cwd: string};
private hoveredLineIndex?: number;
Expand Down Expand Up @@ -319,7 +322,8 @@ export class TerminalApp {
if (this.replaying) return;
if (mode === 'PASSTHROUGH' && !this.passthrough) {
this.passthrough = true;
this.renderer.suspendForPassthrough();
// Modes the program set in earlier output never reached the terminal; hand them over with it.
this.renderer.suspendForPassthrough(this.commandModes.restoreSequence());
const dimensions = this.dimensions();
this.session.resize(dimensions.columns, dimensions.rows);
}
Expand All @@ -332,6 +336,7 @@ export class TerminalApp {
*/
private onShellExec(command: string, at = Date.now()): void {
if (this.running) return;
this.commandModes.reset();
const startId = this.output.beginCommand(command, this.formatCommandAnsi(command, null), mode => this.onActiveModeChange(mode),
{cwd: this.shellCwd, project: this.context.project, branch: this.context.branch, prompt: this.currentPromptSnapshot(command)});
this.tapActivityObserver.reset(this.output.activeOutputStartId ?? startId);
Expand Down Expand Up @@ -1034,6 +1039,7 @@ export class TerminalApp {
}

const contextAtSubmission = this.context;
this.commandModes.reset();
const startId = this.output.beginCommand(command, this.formatCommandAnsi(command, null), (mode) => {
if (mode === 'PASSTHROUGH' && !this.passthrough) {
this.passthrough = true;
Expand Down Expand Up @@ -1385,6 +1391,7 @@ export class TerminalApp {
if (this.passthrough) {
process.stdout.write(data);
} else {
this.commandModes.observeModes(data);
this.lastOutputTime = Date.now();
const wasPassthrough = this.passthrough;
this.output.write(data);
Expand Down
40 changes: 32 additions & 8 deletions src/host/terminalHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,16 +61,40 @@ export function detectTerminalHost(env: NodeJS.ProcessEnv = process.env, platfor
return {name: program || 'this terminal'};
}

export type Spawner = (command: string, args: string[]) => Promise<boolean>;
export type Spawner = (command: string, args: string[], timeoutMs?: number) => Promise<boolean>;

/** Run the launcher detached; resolves whether it started and exited cleanly. */
export const spawnLauncher: Spawner = (command, args) => new Promise(resolve => {
/** How long a window launcher may take before its window is treated as not opened. */
export const LAUNCH_TIMEOUT_MS = 15_000;

/**
* Run the launcher and resolve whether it exited cleanly. The child stays
* referenced while it is awaited: an unref'd child does not keep the event
* loop alive, so startup, which is waiting on it with stdin paused, would end
* the process with its top-level await unsettled. A launcher that hangs (for
* example on a macOS Automation prompt) counts as failed after the timeout,
* so startup continues and names the session's attach command instead.
*/
export const spawnLauncher: Spawner = (command, args, timeoutMs = LAUNCH_TIMEOUT_MS) => new Promise(resolve => {
let settled = false;
const finish = (ok: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(ok);
};
const timer = setTimeout(() => {
finish(false);
// Leave a slow launcher running on its own; it no longer holds up NMSh.
child?.unref();
}, timeoutMs);
let child: ReturnType<typeof spawn> | undefined;
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); }
child = spawn(command, args, {stdio: 'ignore', detached: true});
child.once('error', () => finish(false));
child.once('exit', code => finish(code === 0));
} catch {
finish(false);
}
});

/**
Expand Down
10 changes: 9 additions & 1 deletion src/output/Classifier.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {PresentationMode} from './PresentationMode.js';
import type {StreamFacts} from './FoldPolicy.js';
import {AlternateScreenTracker} from '../session/TerminalModes.js';

/**
* Live presentation of a running command: INLINE, LIVE, or PASSTHROUGH.
Expand All @@ -13,6 +14,8 @@ export class CommandClassifier {
private firstOutputTime = 0;
private lastOutputTime = 0;
private hasAltScreen = false;
/** Terminal modes the command turned on; input modes mean it is an interactive UI. */
private readonly terminalModes = new AlternateScreenTracker();
private hasCursorMovement = false;
private hasProgressRewrites = false;

Expand Down Expand Up @@ -45,6 +48,7 @@ export class CommandClassifier {
if (chunk.includes('\u001B[?1049h') || chunk.includes('\u001B[?47h')) {
this.hasAltScreen = true;
}
this.terminalModes.observeModes(chunk);
if (chunk.includes('\u001B[A') || chunk.includes('\u001B[H') || /\u001B\[[0-9;]*[HfA-D]/.test(chunk)) {
this.hasCursorMovement = true;
}
Expand Down Expand Up @@ -79,7 +83,11 @@ export class CommandClassifier {

let nextMode: PresentationMode = this.mode;

if (this.hasAltScreen) {
// Evidence the program owns the terminal: the alternate screen, or input
// modes only an interactive UI turns on (an inline agent UI, a REPL with
// bracketed paste). Output volume, progress rewrites or cursor movement
// alone never qualify.
if (this.hasAltScreen || this.terminalModes.interactive) {
nextMode = 'PASSTHROUGH';
} else if (this.mode !== 'LIVE') {
if (this.sustainedStreamingScore >= 2 && now - this.startTime >= 2000) {
Expand Down
2 changes: 1 addition & 1 deletion src/session/SessionClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export interface AttachedSession {
sessionId: string;
pid: number;
cwd: string;
/** Nonzero while the foreground app holds the alternate screen. */
/** Nonzero while the foreground app owns the terminal: the alternate screen, or an interactive UI (input modes on). */
fullscreen: number;
/** Terminal input modes the fullscreen app set (mouse, bracketed paste, ...), to restore on reattach. */
modes?: string;
Expand Down
76 changes: 4 additions & 72 deletions src/session/SessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,76 +38,8 @@ interface ManagedSession {
evidence: SessionEvidence;
}

// DECSET/DECRST 1049, 1047 and 47: the alternate-screen switches.
const ALT_SCREEN = /\u001b\[\?(?:1049|1047|47)([hl])/g;

/** Follows whether the PTY's foreground app is on the alternate screen. */
export class AlternateScreenTracker {
active = false;
private carry = '';

/**
* Observe a chunk and return the part of it that belongs to the ordinary
* transcript: what a fullscreen app draws on the alternate screen is
* repainted on reattach, never replayed as transcript text.
*/
push(data: string): string {
const text = this.carry + data;
let kept = '';
let from = this.carry.length;
for (const match of text.matchAll(ALT_SCREEN)) {
const index = match.index;
if (!this.active && index >= from) kept += text.slice(from, index);
from = Math.max(from, index + match[0].length);
this.active = match[1] === 'h';
}
if (!this.active) kept += text.slice(from);
// Keep a tail so a sequence split across reads is still seen.
const escape = text.lastIndexOf('\u001b');
this.carry = escape !== -1 && text.length - escape < 8 ? text.slice(escape) : '';
return kept;
}

reset(): void { this.active = false; this.carry = ''; this.modes.clear(); this.keypad = false; }

private readonly modes = new Map<number, boolean>();
private keypad = false;

/**
* Track the input-affecting terminal modes the foreground app set (mouse
* reporting, bracketed paste, application cursor keys and keypad, focus
* events, cursor visibility). They were sent to whichever terminal was
* attached then; a reattaching frontend replays them from here.
*/
observeModes(data: string): void {
const text = this.modeCarry + data;
for (const match of text.matchAll(DEC_MODE)) {
for (const param of match[1]!.split(';')) {
const mode = Number(param);
if (TRACKED_MODES.has(mode)) this.modes.set(mode, match[2] === 'h');
}
}
for (const match of text.matchAll(KEYPAD)) this.keypad = match[1] === '=';
const escape = text.lastIndexOf('\u001b');
this.modeCarry = escape !== -1 && text.length - escape < 16 ? text.slice(escape) : '';
}

private modeCarry = '';

/** Sequences that put a fresh terminal into the app's current input modes. */
restoreSequence(): string {
let sequence = '';
for (const [mode, on] of this.modes) {
if (mode === 25) { if (!on) sequence += '\u001b[?25l'; } else if (on) sequence += `\u001b[?${mode}h`;
}
return this.keypad ? `${sequence}\u001b=` : sequence;
}
}

const DEC_MODE = /\u001b\[\?([\d;]+)([hl])/g;
const KEYPAD = /\u001b([=>])/g;
/** DECCKM, cursor visibility, mouse protocols, focus events, bracketed paste. */
const TRACKED_MODES = new Set([1, 25, 1000, 1002, 1003, 1004, 1005, 1006, 1015, 2004]);
export {AlternateScreenTracker} from './TerminalModes.js';
import {AlternateScreenTracker} from './TerminalModes.js';

/** Delay between the two resizes that force a fullscreen app to repaint on attach. */
const REDRAW_NUDGE_MS = 40;
Expand Down Expand Up @@ -258,8 +190,8 @@ export class SessionService {
this.bind(session, send);
const info = this.info(session);
const {backlog} = session;
send({type: 'attached', sessionId: info.id, pid: info.pid, cwd: info.cwd, fullscreen: session.screen.active ? 1 : 0,
...(session.screen.active && session.screen.restoreSequence() ? {modes: session.screen.restoreSequence()} : {}),
send({type: 'attached', sessionId: info.id, pid: info.pid, cwd: info.cwd, fullscreen: session.screen.ownsTerminal ? 1 : 0,
...(session.screen.ownsTerminal && session.screen.restoreSequence() ? {modes: session.screen.restoreSequence()} : {}),
...(info.running ? {running: info.running, runningSince: info.runningSince} : {}),
...(backlog.journalId ? {journalId: backlog.journalId} : {}), ackedSeq: backlog.ackedSeq});
// Everything the journal does not have yet, then the live stream continues.
Expand Down
112 changes: 112 additions & 0 deletions src/session/TerminalModes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// DECSET/DECRST 1049, 1047 and 47: the alternate-screen switches.
const ALT_SCREEN = /\u001b\[\?(?:1049|1047|47)([hl])/g;
const DEC_MODE = /\u001b\[\?([\d;]+)([hl])/g;
const KEYPAD = /\u001b([=>])/g;
// Kitty keyboard protocol: push (CSI > flags u) and pop (CSI < n u).
const KITTY_PUSH = /\u001b\[>(\d*)u/g;
const KITTY_POP = /\u001b\[<\d*u/g;
/** DECCKM, cursor visibility, mouse protocols, focus events, bracketed paste. */
const TRACKED_MODES = new Set([1, 25, 1000, 1002, 1003, 1004, 1005, 1006, 1015, 2004]);
/**
* Modes only a program reading keys, pastes or clicks from the terminal turns
* on: bracketed paste, mouse reporting and focus events. Cursor visibility and
* cursor-key mode are left out because progress bars and pagers touch them too.
*/
const INPUT_MODES = new Set([1000, 1002, 1003, 1004, 1005, 1006, 1015, 2004]);

/**
* Follows what the PTY's foreground program has asked of the terminal: whether
* it is on the alternate screen, the input modes it turned on, and whether
* that makes it an interactive terminal UI.
*/
export class AlternateScreenTracker {
active = false;
/**
* The program turned on a terminal input mode (bracketed paste, mouse,
* focus events or the kitty keyboard protocol), so it reads interactive
* input from the terminal even without the alternate screen (for example
* an inline agent UI). Reset when the command ends.
*/
interactive = false;
private carry = '';

/**
* Observe a chunk and return the part of it that belongs to the ordinary
* transcript: what a fullscreen or interactive app draws is repainted on
* reattach, never replayed as transcript text.
*/
push(data: string): string {
const text = this.carry + data;
let kept = '';
let from = this.carry.length;
for (const match of text.matchAll(ALT_SCREEN)) {
const index = match.index;
if (!this.active && !this.interactive && index >= from) kept += text.slice(from, index);
from = Math.max(from, index + match[0].length);
this.active = match[1] === 'h';
}
if (!this.active && !this.interactive) kept += text.slice(from);
// Keep a tail so a sequence split across reads is still seen.
const escape = text.lastIndexOf('\u001b');
this.carry = escape !== -1 && text.length - escape < 8 ? text.slice(escape) : '';
return kept;
}

reset(): void {
this.active = false;
this.interactive = false;
this.carry = '';
this.modes.clear();
this.keypad = false;
this.kittyFlags = undefined;
}

private readonly modes = new Map<number, boolean>();
private keypad = false;
private kittyFlags?: string;
private modeCarry = '';

/**
* Track the input-affecting terminal modes the foreground app set (mouse
* reporting, bracketed paste, application cursor keys and keypad, focus
* events, cursor visibility, kitty keyboard flags). They were sent to
* whichever terminal was showing the program then; a frontend that takes
* over later replays them from here.
*/
observeModes(data: string): void {
const text = this.modeCarry + data;
for (const match of text.matchAll(DEC_MODE)) {
for (const param of match[1]!.split(';')) {
const mode = Number(param);
if (!TRACKED_MODES.has(mode)) continue;
this.modes.set(mode, match[2] === 'h');
if (match[2] === 'h' && INPUT_MODES.has(mode)) this.interactive = true;
}
}
for (const match of text.matchAll(KEYPAD)) this.keypad = match[1] === '=';
for (const match of text.matchAll(KITTY_PUSH)) {
this.kittyFlags = match[1] || '1';
this.interactive = true;
}
if (KITTY_POP.test(text)) this.kittyFlags = undefined;
KITTY_POP.lastIndex = 0;
const escape = text.lastIndexOf('\u001b');
this.modeCarry = escape !== -1 && text.length - escape < 16 ? text.slice(escape) : '';
}

/** Sequences that put a fresh terminal into the app's current input modes. */
restoreSequence(): string {
let sequence = '';
for (const [mode, on] of this.modes) {
if (mode === 25) { if (!on) sequence += '\u001b[?25l'; } else if (on) sequence += `\u001b[?${mode}h`;
}
if (this.keypad) sequence += '\u001b=';
if (this.kittyFlags) sequence += `\u001b[>${this.kittyFlags}u`;
return sequence;
}

/** The program owns the terminal: fullscreen, or an interactive UI. */
get ownsTerminal(): boolean {
return this.active || this.interactive;
}
}
Loading
Loading