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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]

### Added
- **Live session status:** `/resume` LIVE rows and `nmsh --list` show what each live session is doing, from evidence only: the running command and elapsed time, the foreground process or a known CLI's name (Claude Code, Codex, Aider, …), active or quiet output, *needs attention* when the program sent a terminal notification or bell, fullscreen, the title it set, and the last command's result while idle. Nothing is guessed, and paths under your home directory are shown as `~/…`.
- **`/layout` showcase:** preview composer position (Bottom, Top, Flow) × transcript presentation (Normal, Chat) on sample content through the real renderer, then save and apply live. Also under Config → Layout. Nothing in the preview runs or reaches the transcript, journal or `/copy`.
- **Flow composer:** Config → Composer position → Flow (the command palette's Toggle composer position cycles Bottom, Top and Flow). The prompt and input follow the newest output inside NMSh's document, like a conventional terminal, and scroll with it. Typing while scrolled back returns to them; scrolling alone does not. Menus open below the input, panels pin to the bottom, and Chat presentation and fullscreen passthrough work as before.
- **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.
Expand Down
7 changes: 7 additions & 0 deletions docs/design/session-interaction-ux.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,13 @@ Live sessions are owned by the per-user session service (`nmshd`), which listens
- 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.
- **Live status (#174):** `/resume` LIVE rows and `nmsh --list` show evidence-based status for each live session. The service gathers it from the session's own output and reads it only when sessions are listed. Nothing polls, and nothing reads the screen.
- What runs and for how long. The foreground process is shown when it differs from the command (for example `process node` for `npm test`). A known interactive CLI (Claude Code, Codex, Aider, Gemini CLI, OpenCode, Goose, …) gets its display name. The table only supplies names: every program gets the same states.
- Output recency: *active* if the session wrote in the last 10 s, otherwise *quiet* for how long.
- *needs attention*: the running program asked for it with a terminal notification (OSC 9 excluding progress, OSC 777 notify) or a bell. It clears when someone types into the session or the command ends.
- *fullscreen* while the program holds the alternate screen, and the window title it set (OSC 0/2, sanitized and bounded).
- While idle: how long, and whether the last command succeeded or failed with its exit code.
- NMSh never claims what a program is doing or waiting for (thinking, approval, input). What the stream does not say is not shown. Status lives in the service, so it survives detach and reattach. Older services omit it, and the rows simply show less.
- **Idle age:** `/resume` shows how long each idle live session has been at its prompt.

Known limitations:
Expand Down
80 changes: 80 additions & 0 deletions src/session/SessionEvidence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Cheap, factual evidence about what a live session's foreground program is
* doing, gathered from its own output stream: when it last wrote, the title it
* set, and whether it asked for attention. Nothing here guesses intent; what
* the stream does not say stays unknown.
*/

// OSC sequences end in BEL or ST; everything up to the terminator is payload.
const OSC = /\u001b\](\d+);([^\u0007\u001b]*)(?:\u0007|\u001b\\)/g;
const TITLE_LIMIT = 80;

export interface SessionEvidenceSnapshot {
lastOutputAt?: number;
/** The window title the foreground program set (OSC 0/2), sanitized. */
title?: string;
/** When the program asked for attention (a terminal notification or bell) while a command ran. */
attentionSince?: number;
/** Exit code of the last finished command. */
lastExit?: number;
}

export class SessionEvidence {
private lastOutputAt?: number;
private title?: string;
private attentionSince?: number;
private lastExit?: number;
private running = false;
private carry = '';

observe(data: string, now: number): void {
this.lastOutputAt = now;
let text = this.carry + data;
// Keep an unfinished OSC for the next chunk so a split sequence is still seen.
const open = text.lastIndexOf('\u001b]');
this.carry = '';
if (open !== -1 && !/\u0007|\u001b\\/u.test(text.slice(open)) && text.length - open < 512) {
this.carry = text.slice(open);
text = text.slice(0, open);
}
let attention = false;
const rest = text.replace(OSC, (_match, code: string, payload: string) => {
if (code === '0' || code === '2') {
const title = payload.replace(/[\u0000-\u001f\u007f]/gu, '').trim().slice(0, TITLE_LIMIT);
this.title = title || undefined;
} else if (code === '9' && !payload.startsWith('4;')) attention = true; // OSC 9 notification; 9;4 is progress
else if (code === '777' && payload.startsWith('notify;')) attention = true;
return '';
});
if (rest.includes('\u0007')) attention = true;
if (attention && this.running && this.attentionSince === undefined) this.attentionSince = now;
}

/** A command started: a new program, so its title and attention start fresh. */
onExec(): void {
this.running = true;
this.title = undefined;
this.attentionSince = undefined;
}

onPrompt(exitCode: number): void {
this.running = false;
this.lastExit = exitCode;
this.title = undefined;
this.attentionSince = undefined;
}

/** Someone typed into the session: any attention request has been seen. */
onInput(): void {
this.attentionSince = undefined;
}

snapshot(): SessionEvidenceSnapshot {
return {
...(this.lastOutputAt !== undefined ? {lastOutputAt: this.lastOutputAt} : {}),
...(this.title ? {title: this.title} : {}),
...(this.attentionSince !== undefined ? {attentionSince: this.attentionSince} : {}),
...(this.lastExit !== undefined ? {lastExit: this.lastExit} : {}),
};
}
}
16 changes: 15 additions & 1 deletion src/session/SessionProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ export interface SessionInfo {
idleSince?: number;
/** Journal of the session's most recent frontend, as it last acknowledged. */
journalId?: string;
// Evidence for /resume status (#174); absent from older services, and unknown stays absent.
/** Foreground process name while a command runs, when the platform can tell. */
process?: string;
/** 1 while the running program holds the alternate screen. */
fullscreen?: number;
/** When the session last produced output. */
lastOutputAt?: number;
/** Title the running program set for its window (OSC 0/2). */
title?: string;
/** When the running program asked for attention (terminal notification or bell) and nobody has typed since. */
attentionSince?: number;
/** Exit code of the last finished command, while idle. */
lastExit?: number;
}

export type ProtocolMessage = ClientMessage | ServerMessage;
Expand Down Expand Up @@ -112,7 +125,8 @@ function isEnv(value: unknown): value is Record<string, string> {
}

const INFO_SHAPE: Shape = {id: 'string', pid: 'int', state: 'string', cwd: 'string', createdAt: 'int',
running: 'string?', runningSince: 'int?', idleSince: 'int?', journalId: 'string?'};
running: 'string?', runningSince: 'int?', idleSince: 'int?', journalId: 'string?',
process: 'string?', fullscreen: 'int?', lastOutputAt: 'int?', title: 'string?', attentionSince: 'int?', lastExit: 'int?'};

function validField(kind: Kind, value: unknown): boolean {
switch (kind) {
Expand Down
22 changes: 19 additions & 3 deletions src/session/SessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {randomUUID} from 'node:crypto';
import {chmodSync, lstatSync, unlinkSync} from 'node:fs';
import {connect, createServer, type Server, type Socket} from 'node:net';
import {ShellSession} from '../shell/ShellSession.js';
import {SessionEvidence} from './SessionEvidence.js';
import {FrameDecoder, PROTOCOL_VERSION, encodeMessage, type ServerMessage, type SessionInfo, type SessionState} from './SessionProtocol.js';
import {SESSION_MODE_ENV} from './SessionClient.js';
import {ensurePrivateRuntimeDir, socketPathFor, spoolPathFor} from './runtimeDir.js';
Expand Down Expand Up @@ -33,6 +34,8 @@ interface ManagedSession {
resizes: number;
seq: number;
backlog: StreamBacklog;
/** What the foreground program's own output says: recency, title, attention, last exit. */
evidence: SessionEvidence;
}

// DECSET/DECRST 1049, 1047 and 47: the alternate-screen switches.
Expand Down Expand Up @@ -166,9 +169,18 @@ export class SessionService {

private info(session: ManagedSession): SessionInfo {
const {record, running} = session;
const evidence = session.evidence.snapshot();
// Read only when someone lists sessions; nothing polls the process table.
const process = running ? session.shell.foregroundProcess : undefined;
return {id: record.id, pid: record.pid, state: record.state, cwd: record.cwd, createdAt: Date.parse(record.createdAt),
...(running ? {running: running.command, runningSince: running.since} : {idleSince: session.idleSince}),
...(session.backlog.journalId ? {journalId: session.backlog.journalId} : {})};
...(session.backlog.journalId ? {journalId: session.backlog.journalId} : {}),
...(process && process !== 'zsh' ? {process} : {}),
...(running && session.screen.active ? {fullscreen: 1} : {}),
...(evidence.lastOutputAt !== undefined ? {lastOutputAt: evidence.lastOutputAt} : {}),
...(evidence.title ? {title: evidence.title} : {}),
...(evidence.attentionSince !== undefined ? {attentionSince: evidence.attentionSince} : {}),
...(evidence.lastExit !== undefined && !running ? {lastExit: evidence.lastExit} : {})};
}

async start(): Promise<void> {
Expand Down Expand Up @@ -268,7 +280,7 @@ export class SessionService {
case 'list':
send({type: 'sessions', sessions: [...this.sessions.values()].map(session => this.info(session))});
break;
case 'input': owned?.shell.write(message.data); break;
case 'input': owned?.evidence.onInput(); owned?.shell.write(message.data); break;
case 'resize':
if (owned) { owned.resizes += 1; owned.shell.resize(message.columns, message.rows); }
break;
Expand Down Expand Up @@ -327,7 +339,8 @@ export class SessionService {
const record: SessionRecord = {id: randomUUID(), pid: shell.pid, cwd, createdAt: new Date().toISOString(),
state: 'attached', protocolVersion: PROTOCOL_VERSION};
const session: ManagedSession = {record, shell, controller: send, idleSince: Date.now(), screen: new AlternateScreenTracker(), resizes: 0,
seq: 0, backlog: new StreamBacklog(spoolPathFor(this.options.runtimeDir, record.id), this.options.backlogLimits)};
seq: 0, backlog: new StreamBacklog(spoolPathFor(this.options.runtimeDir, record.id), this.options.backlogLimits),
evidence: new SessionEvidence()};
this.sessions.set(record.id, session);
// Every event is retained until a frontend journal acknowledges it, and
// sent live when a frontend is attached.
Expand All @@ -337,6 +350,7 @@ export class SessionService {
};
shell.on('data', data => {
const at = Date.now();
session.evidence.observe(data, at);
session.screen.observeModes(data);
const kept = session.screen.push(data);
if (kept) emit({kind: 'output', seq: ++session.seq, at, data: kept}, {type: 'output', data, seq: session.seq, at});
Expand All @@ -345,12 +359,14 @@ export class SessionService {
shell.on('exec', command => {
const at = Date.now();
session.running = {command, since: at};
session.evidence.onExec();
emit({kind: 'exec', seq: ++session.seq, at, command});
});
shell.on('prompt', marker => {
record.cwd = marker.cwd;
session.running = undefined;
session.idleSince = Date.now();
session.evidence.onPrompt(marker.exitCode);
session.screen.reset();
emit({kind: 'prompt', seq: ++session.seq, at: Date.now(), exitCode: marker.exitCode, cwd: marker.cwd});
});
Expand Down
4 changes: 1 addition & 3 deletions src/session/StartupPicker.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import {homedir} from 'node:os';
import {KeyDecoder, type Key} from '../terminal/keys.js';
import type {SessionInfo} from './SessionProtocol.js';
import {formatAge} from './sessionList.js';
import {formatAge, tildePath} from './sessionList.js';

// Launch-time restore screens, shown before any session is attached. Neither
// has a destructive key: killing a live session stays a confirmed /resume action.

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));

Expand Down
57 changes: 57 additions & 0 deletions src/session/liveStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type {SessionInfo} from './SessionProtocol.js';
import {formatAge} from './sessionList.js';

/**
* Display names for well-known interactive CLIs, agents included. Labels only:
* every program gets the same evidence and the same states, recognized or not.
*/
export const KNOWN_CLI_NAMES: Readonly<Record<string, string>> = {
claude: 'Claude Code', codex: 'Codex', aider: 'Aider', gemini: 'Gemini CLI', opencode: 'OpenCode',
goose: 'Goose', 'cursor-agent': 'Cursor Agent', amp: 'Amp', crush: 'Crush', qwen: 'Qwen Code',
};

/** Output newer than this reads as active; older as quiet. */
export const ACTIVE_OUTPUT_MS = 10_000;

const baseName = (path: string) => path.slice(path.lastIndexOf('/') + 1);

/** The program word of a command line, skipping leading VAR=value assignments. */
export function commandWord(command: string): string | undefined {
for (const word of command.trim().split(/\s+/u)) {
if (!word || /^[A-Za-z_][A-Za-z0-9_]*=/u.test(word)) continue;
return baseName(word);
}
return undefined;
}

/** A known CLI's display name, from the command line or the foreground process. */
export function knownProgram(session: SessionInfo): string | undefined {
const word = session.running ? commandWord(session.running) : undefined;
return (word && KNOWN_CLI_NAMES[word]) || (session.process && KNOWN_CLI_NAMES[baseName(session.process)]) || undefined;
}

/**
* Factual status from service evidence. Every clause is something the session
* reported: what runs, whether it wrote recently, whether it asked for
* attention, the title it set, or how the last command ended. Nothing is
* inferred about what a program is waiting for; unknown stays unsaid.
*/
export function liveStatusParts(session: SessionInfo, now: number): string[] {
if (!session.running) {
const parts = [`idle${session.idleSince ? ` ${formatAge(now - session.idleSince)}` : ''}`];
if (session.lastExit !== undefined) parts.push(session.lastExit === 0 ? 'last command succeeded' : `last command failed (exit ${session.lastExit})`);
return parts;
}
const parts = [`running ${session.running.replace(/\s+/gu, ' ').slice(0, 60)} · ${formatAge(now - (session.runningSince ?? now))}`];
const known = knownProgram(session);
if (known) parts.push(known);
else if (session.process && session.process !== commandWord(session.running)) parts.push(`process ${session.process}`);
if (session.attentionSince !== undefined) parts.push(`needs attention ${formatAge(now - session.attentionSince)}`);
else if (session.lastOutputAt !== undefined) {
const quietFor = now - session.lastOutputAt;
parts.push(quietFor < ACTIVE_OUTPUT_MS ? 'active' : `quiet ${formatAge(quietFor)}`);
}
if (session.fullscreen) parts.push('fullscreen');
if (session.title) parts.push(`“${session.title.slice(0, 40)}”`);
return parts;
}
9 changes: 8 additions & 1 deletion src/session/sessionList.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import {homedir} from 'node:os';
import type {SessionInfo} from './SessionProtocol.js';
import {liveStatusParts} from './liveStatus.js';

/** A path under the home directory as ~/…, so status stays visible in narrow rows. */
export function tildePath(path: string, home = homedir()): string {
return home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path;
}

export function formatAge(ms: number): string {
const seconds = Math.max(0, Math.floor(ms / 1000));
Expand All @@ -20,5 +27,5 @@ export function formatBytes(bytes: number): string {
export function formatSessionList(sessions: readonly SessionInfo[], now: number): string {
if (sessions.length === 0) return 'No live NMSh sessions.\n';
return sessions.map(session => [session.id, session.state, `pid ${session.pid}`, `age ${formatAge(now - session.createdAt)}`,
session.cwd, ...(session.running ? [`running: ${session.running}`] : [])].join(' ')).join('\n') + '\n';
session.cwd, liveStatusParts(session, now).join(' · ')].join(' ')).join('\n') + '\n';
}
8 changes: 3 additions & 5 deletions src/sessions/ResumeBrowser.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {TranscriptSummary} from './TranscriptStore.js';
import type {SessionInfo} from '../session/SessionProtocol.js';
import {formatAge} from '../session/sessionList.js';
import {formatAge, tildePath} from '../session/sessionList.js';
import {liveStatusParts} from '../session/liveStatus.js';

export interface ResumeBrowserState {
/** Live service sessions other than this frontend's own; listed first. */
Expand Down Expand Up @@ -63,10 +64,7 @@ export function resumeRowCount(state: ResumeBrowserState): number {
/** Facts only: where, how old, attached or not, and what zsh reports running. */
export function describeLiveSession(session: SessionInfo, now: number): string {
const state = session.state === 'attached' ? 'attached in another window' : 'detached';
const activity = session.running
? `running ${session.running.replace(/\s+/gu, ' ').slice(0, 60)} · ${formatAge(now - (session.runningSince ?? now))}`
: `idle${session.idleSince ? ` ${formatAge(now - session.idleSince)}` : ''}`;
return `${session.cwd} · ${state} · ${activity} · started ${formatAge(now - session.createdAt)} ago`;
return [tildePath(session.cwd), state, ...liveStatusParts(session, now), `started ${formatAge(now - session.createdAt)} ago`].join(' · ');
}

export function visibleResumeSessions(state: ResumeBrowserState): TranscriptSummary[] {
Expand Down
5 changes: 5 additions & 0 deletions src/shell/ShellSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ add-zsh-hook preexec nmsh_preexec
return this.pty.pid;
}

/** Name of the terminal's foreground process, read on demand; undefined if the platform cannot tell. */
get foregroundProcess(): string | undefined {
try { return this.pty.process || undefined; } catch { return undefined; }
}

submit(command: string): void {
this.pty.write(`${command}\r`);
}
Expand Down
Loading
Loading