Skip to content
Draft
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 @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Added

- **Rex support** (experimental). In Rex (`TERM_PROGRAM=rex`), NMSh now sends OSC 7 (the directory) and OSC 133 (command zones) as it does on Ghostty, names the host Rex, and reports its state with Rex's OSC 7501 program status in a record of its own: `working` while a command runs, `blocked` when a program is confirmed to read hidden input or a line, or a held queued line waits for your approval, and `error` when the queue paused after a failure; otherwise nothing. Only states NMSh has evidence for are reported, with fixed messages, never a command line or typing. Nothing is sent inside tmux, screen or Zellij, and a crash still clears the record; `NMSH_PROGRAM_STATUS=0` turns the status off. NMSh does not change Rex's configuration or key bindings and opens no Rex blocks or windows. See [Host semantics](docs/design/theme-bridge.md#host-semantics).
- **Console for the browser preview** ([#466](https://github.com/raiseCatError/notMyShell/issues/466); [design](docs/design/document-workspace.md#console-runtime-diagnostics-of-the-browser-preview)). In the Chromium preview of a Markdown or HTML document, **`C`** lists what the page itself reported for the document now loaded: script exceptions, `console` errors, warnings and messages, failed loads and HTTP errors, Content Security Policy refusals and requests NMSh refused. It works like Problems — newest first, Tab filters by severity, typing searches, Shift+↑↓ marks, Del clears, Ctrl+C shows exactly what would be copied before copying — and Enter goes to the line in the file when that is certain (an HTML document unchanged since the page loaded); otherwise it says why. The Console only reads: it never turns scripts or the network on (with scripts off, only loading and policy messages appear), accepts no expressions, and ignores `/browser` pages and the Mermaid renderer. Entries stay in memory for the current load, bounded, with the preview's address, query values, tokens and likely secrets redacted before they are kept. The status line counts errors and warnings.
- **`/doctor` reports document rendering** ([#462](https://github.com/raiseCatError/notMyShell/issues/462)). A Documents section names the Chrome or Chromium the previews would use (its version once a browser surface has checked it this session, and whether it is running), the Mermaid it found (path, version, a warning for a major version other than the tested 12), the Mermaid setting and, when diagrams would not be drawn, why. Nothing is launched and the Mermaid bundle is not read, so it says what was found, not that drawing works.
- **Mermaid diagrams in Markdown** ([#462](https://github.com/raiseCatError/notMyShell/issues/462); [design](docs/design/diagrams.md)). A ```` ```mermaid ```` fence is drawn as a real diagram — flowcharts, sequence, class, state, ER, Gantt, pie, journey, git graph, mind map, timeline, quadrant, XY, requirement, Sankey, block and C4 diagrams — in Preview (NMSh's renderer and the Chromium preview), Split's preview pane, the viewer and Hybrid's inactive blocks, with the image backend your terminal supports (Kitty graphics, iTerm2, Sixel, or character art); `i` opens it in the image viewer to zoom and pan. NMSh uses the Mermaid you installed (`npm install -g mermaid`, or `NMSH_MERMAID`) inside the real browser it already drives, and downloads nothing. The page that draws runs no document script, loads nothing from the network or disk and returns only pixels; Mermaid's security settings are locked against directives. Settings → General → **Mermaid diagrams**: *On request* (default: press `D`, or F1 › Diagrams while editing — opening a document never starts a browser), *Automatic*, or *Off*. The fence stays the source: in Hybrid the block under the caret is its exact text, edits are drawn after a pause, and anything not drawn (not asked yet, no browser or Mermaid, a syntax error, too large, a timeout) shows the fence as written with `(diagram not drawn: reason)`. Drawings are cached by content and theme, bounded in number, bytes and time, and the renderer page closes when no document shows a diagram.
Expand Down
3 changes: 2 additions & 1 deletion docs/design/theme-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ Every include tolerates a missing file, so an Independent target with an include
- **OSC 7**: `file://host/path` (percent-encoded) when the shell reports a new directory.
- **OSC 133**: projected from NMSh's authenticated OSC 777 lifecycle: ready → `D;status` (if a command ran) then `A`, `B`; exec → `C`; a shell that ends mid-command closes its zone with `D` and no invented status. `C` is written as soon as a command starts, even when it is a fullscreen program about to take the screen, so the host knows a command is running (close confirmation, prompt navigation); prompt-time markers wait until NMSh owns the screen again. A reattach replays no history to the host: it ends with one coherent current state (directory, and the running command if any).
- After a program owned the screen (ssh, a nested shell, an editor) NMSh reports the directory again even if it did not change, since the host may have been told another one.
- Sent on Ghostty, Kitty, WezTerm, iTerm2, Windows Terminal and inside tmux; OSC 7 only on Terminal.app; nothing on unknown hosts. `NMSH_SEMANTIC=0` turns both off, `=1` forces them on. NMSh never depends on them.
- Sent on Ghostty, Kitty, WezTerm, iTerm2, Windows Terminal, Rex and inside tmux; OSC 7 only on Terminal.app; nothing on unknown hosts. `NMSH_SEMANTIC=0` turns both off, `=1` forces them on. NMSh never depends on them.
- **OSC 7501 program status** (experimental; Rex documents it): one record of NMSh's own (`id=nmsh`, `app=nmsh`), never the root record a program inside NMSh may use and never `clear` for every record. A running command is `working`; a confirmed hidden read is `blocked` with `kind=auth`; a confirmed line read is `blocked` with `kind=question`; a held queued line waiting for approval is `blocked` with `kind=permission`; a queue paused because an entry failed is `error`. Otherwise the record is cleared: a free prompt reports nothing, and a likely-but-unconfirmed read or a pause you asked for are not reported. Messages are fixed NMSh text: no command line, path or typing reaches the host. A report is written only on change and only while NMSh owns the screen, except the start of a command (written ahead of the program's output, like `C`); NMSh clears its record when it stops or is suspended. Sent only when `TERM_PROGRAM=rex` and not inside tmux, screen or Zellij (Rex's OSC 133/OSC 7 claim is skipped inside Zellij too); also cleared when the frontend ends on an uncaught error; NMSh does not send the protocol's `?` query, so other hosts get nothing. `NMSH_PROGRAM_STATUS=0` turns it off, `=1` forces it on. NMSh never reads it back or depends on it, and never changes Rex's configuration or key bindings.
- **OSC 0/2 title** (Settings → Sessions → Terminal title: Off by default, Project, or Project and session): NMSh sets the window/tab title only while it owns the screen — the project or directory name, the running command's name in transcript mode, the session name when chosen. A program in passthrough sets its own title; NMSh writes nothing meanwhile and re-asserts its title when the screen returns. Titles derive from directory, session and command names, so they cross the display boundary and lose control characters, escape terminators and bidi formatting (at most 80 cells). Where the host keeps a title stack (Kitty, WezTerm, iTerm2 outside tmux) NMSh pushes the title it found and restores it on exit; elsewhere turning the setting Off clears NMSh's title. `NMSH_TITLE=0` or `TERM=dumb` refuses all title writes.
- **OSC 8**: NMSh-authored links (help/docs, dev-server URLs) are stored as authored cells after a target check (http/https without credentials, local `file:`) and painted only on hosts with hyperlink support; raw program OSC 8 stays the separate preserved program-output path. Copy/plain text never contains escape bytes.

Expand Down
31 changes: 30 additions & 1 deletion src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ import {CommandEditor} from '../input/CommandEditor.js';
import {authoredLink, closeAuthoredLinks} from '../output/Hyperlinks.js';
import {HostSemantics, semanticSupport} from '../host/semanticMarks.js';
import {HostTitle, composeTitle, sessionTitleName, titleSupport} from '../host/terminalTitle.js';
import {HostProgramStatus, programStatusSupported, type ProgramStatus} from '../host/programStatus.js';
import {OutputBuffer, renderHistoricalContext, serializeCopyPayload, type CompletedCommand, type HistoricalContextSnapshot} from '../output/OutputBuffer.js';
import {createWelcomeSnapshot, renderWelcome, vespyrSprite, WELCOME_BLINK_CLOSED_MS, welcomeBlinkDelay} from '../output/Welcome.js';
import {captureWelcome, WELCOME_PROVIDERS, welcomeProvider} from '../output/WelcomeProviders.js';
Expand Down Expand Up @@ -1119,11 +1120,14 @@ export class TerminalApp {
}
// Host OSC 7 / OSC 133: a projection of this authoritative marker, never the other way round.
this.hostSemantics.prompt(marker.cwd, marker.exitCode);
this.hostProgramStatus.forget();
this.completedInputTiming = marker.inputWaits ? {waitedMs: marker.inputWaitMs ?? 0, waits: marker.inputWaits} : undefined;
this.onShellPrompt(marker.exitCode, marker.cwd, stamp.at);
}
});
this.session.on('exec', (command, stamp) => { if (!this.stopped && this.inStream(stamp)) { this.hostSemantics.exec(); this.onShellExec(command, stamp.at, stamp.historyAllowed, stamp.queued); } });
this.session.on('exec', (command, stamp) => { if (!this.stopped && this.inStream(stamp)) { this.hostSemantics.exec(); this.onShellExec(command, stamp.at, stamp.historyAllowed, stamp.queued);
// Like the OSC 133 C mark: written at once, ahead of the output of a program that may be taking the screen.
if (this.presentationStarted && !this.frontendSuspended && !this.replaying) this.hostProgramStatus.set(this.desiredProgramStatus(), true); } });
this.session.on('replayed', summary => this.finishReplay(summary));
this.session.on('inputState', state => this.onInputState(state));
this.session.on('queueState', (state, news) => this.onQueueState(state, news));
Expand Down Expand Up @@ -1645,6 +1649,8 @@ export class TerminalApp {

private readonly onProcessExit = (): void => {
if (!this.stopped) {
// The frontend ends without stop() (an uncaught error): NMSh's host status record must not stay behind.
this.hostProgramStatus.end();
if (process.stdin.isTTY) process.stdin.setRawMode(this.originalRawMode);
this.terminalFocus = 'unknown';
this.renderer.leave();
Expand All @@ -1663,6 +1669,7 @@ export class TerminalApp {
private readonly onSuspend = (): void => {
if (this.stopped || this.externalPassthrough) return;
this.cancelPresentation();
this.hostProgramStatus.end();
this.frontendSuspended = true;
this.noteForeignScreen();
this.terminalFocus = 'unknown';
Expand Down Expand Up @@ -7084,6 +7091,26 @@ export class TerminalApp {
private readonly hostTitle = new HostTitle(titleSupport(), data => { process.stdout.write(data); },
() => this.presentationStarted && !this.stopped && !this.passthrough && !this.externalPassthrough && !this.frontendSuspended && !this.replaying);

/** Opt-in OSC 7501 status for hosts that document it (Rex): written only on change, from NMSh's own evidence. */
private readonly hostProgramStatus = new HostProgramStatus(programStatusSupported(), data => { process.stdout.write(data); },
() => this.presentationStarted && !this.stopped && !this.passthrough && !this.externalPassthrough && !this.frontendSuspended && !this.replaying);

/** What NMSh can say for certain about this terminal, or nothing (the prompt is free). */
private desiredProgramStatus(): ProgramStatus | undefined {
if (this.shellEnded) return undefined;
if (this.running) {
const request = this.inputState?.request;
if (request?.confidence === 'confirmed' && request.mode === 'hidden') return {state: 'blocked', kind: 'auth', message: 'A program is waiting for hidden input'};
if (request?.confidence === 'confirmed') return {state: 'blocked', kind: 'question', message: 'A program is waiting for input'};
return {state: 'working'};
}
const queue = this.queueState;
if (queue.paused?.reason === 'failed' && queue.entries.length) return {state: 'error', message: 'A queued command failed; the queue is paused'};
const head = queue.entries[0];
if (head && head.condition === 'approve' && !head.approved) return {state: 'blocked', kind: 'permission', message: 'A queued command waits for your approval'};
return undefined;
}

/** A program, a nested shell or another frontend owned the terminal: cwd and title are re-asserted when NMSh has it back. */
private noteForeignScreen(): void {
this.hostSemantics.foreignScreen();
Expand Down Expand Up @@ -12128,6 +12155,7 @@ export class TerminalApp {
if (this.stopped || this.passthrough || this.externalPassthrough || this.frontendSuspended) { this.cancelPresentation(); return; }
this.hostSemantics.flush();
this.hostTitle.set(this.desiredTitle());
this.hostProgramStatus.set(this.desiredProgramStatus());
if (this.idle) { this.paintIdle(); return; }
// A real frame (a key, a prompt) ends the Arcade's recede at once; output chunks and the shell's clocks never do (below).
this.arcadeClosing = undefined;
Expand Down Expand Up @@ -14177,6 +14205,7 @@ export class TerminalApp {
this.restoreWindowAfterEnding = false;
this.firstRunWindow?.restoreNow();
this.hostTitle.end();
this.hostProgramStatus.end();
this.stopped = true;
this.cancelOutputRender();
this.endOutputNavigation(false);
Expand Down
80 changes: 80 additions & 0 deletions src/host/programStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Host program status (OSC 7501, documented by Rex): an optional projection of what NMSh itself knows, for hosts that
* show a status per terminal. NMSh never reads it back and nothing depends on it.
*
* Every report addresses NMSh's own record (`id=nmsh`), never the root record a program inside NMSh may use, and
* only states NMSh has evidence for are reported:
*
* a command runs → working
* the shell confirms a program reads hidden input → blocked, kind=auth
* the shell confirms a program reads a line → blocked, kind=question
* a held queued line waits for approval → blocked, kind=permission
* the queue paused because an entry failed → error
* anything else (the prompt is free) → the record is cleared
*
* A report is written only when the state changes and only while NMSh owns the screen (the start of a command is
* written at once, ahead of the program's output, like the OSC 133 C mark). The record is cleared when NMSh stops.
* Messages are fixed NMSh text: no command line, path or typed input ever reaches the host.
*/

const ST = '\u001B\\';

export type ProgramStatus =
| {state: 'working'}
| {state: 'blocked'; kind: 'auth' | 'question' | 'permission'; message: string}
| {state: 'error'; message: string};

/**
* Hosts that document OSC 7501. Unknown hosts get nothing, and nothing inside tmux or screen (they own the real
* terminal and drop or reroute unknown OSCs; Zellij too). NMSH_PROGRAM_STATUS=0 turns it off; NMSH_PROGRAM_STATUS=1 turns it on.
*/
export function programStatusSupported(env: NodeJS.ProcessEnv = process.env): boolean {
if (env.NMSH_PROGRAM_STATUS === '0' || env.TERM === 'dumb') return false;
if (env.NMSH_PROGRAM_STATUS === '1') return true;
if (env.TMUX || env.STY || env.ZELLIJ !== undefined || /^(tmux|screen)/u.test(env.TERM ?? '')) return false;
return env.TERM_PROGRAM === 'rex';
}

const base64 = (text: string): string => Buffer.from(text, 'utf8').toString('base64');
const TITLE = base64('NMSh');

/** The OSC 7501 report for NMSh's record; `undefined` clears it. */
export function osc7501(status: ProgramStatus | undefined): string {
if (!status) return `\u001B]7501;state=clear:id=nmsh${ST}`;
const fields = [`state=${status.state}`, 'id=nmsh', 'app=nmsh', `title=${TITLE}`];
if (status.state === 'blocked') fields.push(`kind=${status.kind}`);
if (status.state !== 'working') fields.push(`msg=${base64(status.message.replace(/[\u0000-\u001f\u007f]/gu, ' '))}`);
return `\u001B]7501;${fields.join(':')}${ST}`;
}

const key = (status: ProgramStatus | undefined): string => status ? JSON.stringify(status) : 'clear';

export class HostProgramStatus {
/** What the host was last told; `clear` before anything was written, so an idle NMSh writes nothing at all. */
private written = 'clear';

constructor(private readonly supported: boolean, private readonly write: (data: string) => void,
private readonly owned: () => boolean) {}

/**
* Reports `status` if it changed. `now` writes even while a program owns the screen: only for the start of a
* command, before that program's own output.
*/
set(status: ProgramStatus | undefined, now = false): void {
if (!this.supported) return;
const next = key(status);
if (next === this.written || (!now && !this.owned())) return;
this.write(osc7501(status));
this.written = next;
}

/** A new prompt zone may have dropped the host's working or blocked record (OSC 133 A): the next state is resent. */
forget(): void { if (this.written !== 'clear') this.written = 'stale'; }

/** NMSh stops presenting this terminal: its record is removed. */
end(): void {
if (!this.supported || this.written === 'clear') return;
this.write(osc7501(undefined));
this.written = 'clear';
}
}
3 changes: 3 additions & 0 deletions src/host/semanticMarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ export function semanticSupport(env: NodeJS.ProcessEnv = process.env): SemanticS
if (env.NMSH_SEMANTIC === '1') return {marks: true, cwd: true};
if (env.TMUX || /^(tmux|screen)/u.test(env.TERM ?? '')) return {marks: Boolean(env.TMUX), cwd: true};
if (env.TERM_PROGRAM === 'Apple_Terminal') return {marks: false, cwd: true};
// Rex documents OSC 133 prompt zones (its program-status lifetime) and reports OSC 7 as the block's directory.
// Not through Zellij, which owns the real terminal (TERM_PROGRAM is inherited from the outer host).
if (env.TERM_PROGRAM === 'rex' && env.ZELLIJ === undefined) return {marks: true, cwd: true};
const profile = terminalProfile(env);
const known = profile === 'ghostty' || profile === 'kitty' || profile === 'wezterm' || profile === 'iterm2' || profile === 'windows-terminal';
return {marks: known, cwd: known};
Expand Down
2 changes: 2 additions & 0 deletions src/host/terminalHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export function detectTerminalHost(env: NodeJS.ProcessEnv = process.env, platfor
// terminal, so sessions beyond the first use /resume or `nmsh --attach`.
if (program === 'zed' || env.ZED_TERM) return {capabilities, integration, keyboardGuidance: guidance, name: 'Zed',
appearanceGuidance: 'Appearance is configured by Zed.'};
// Rex keeps its own sessions; NMSh opens no Rex blocks or windows and never changes Rex's configuration.
if (program === 'rex') return {capabilities, integration, keyboardGuidance: guidance, name: 'Rex'};
return {capabilities, integration, keyboardGuidance: guidance, name: program || 'this terminal'};
}

Expand Down
Loading
Loading