From 8bb9464c293d8e88214f362e46d6bcae80d9da64 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:17:08 +0530 Subject: [PATCH 1/3] #388: rows are the terminal's rows, so a redraw over a wrapped line never erases earlier output The live region (#412) moved the cursor by stored lines. Stored lines are logical, but programs count terminal rows: a progress display whose line is wider than the window (two rows at 40 columns) moved up three rows to redraw itself, NMSh moved up three lines, and `CSI J` erased the program's own log lines above the display, one more per frame. Reproduced in the parser, in a real zsh PTY at 40 columns, and through the frontend; the new tests fail on the previous parser. - The frontend reports the PTY's width (setScreenColumns). A line wider than it occupies several rows: cursor up/down, `ESC M/D/E` and line feed move by rows (the next row of a wrapped line, or the next line), within the same bounds. - Carriage return, backspace, `CSI G/C/D` and `CSI K` act on the cursor's row within its line; erasing never removes the row the cursor is on. The pending wrap after a row is filled exactly is tracked from the character that filled it. - A resize re-wraps stored lines at the new width, as a reflowing terminal does. - The contract stays deliberately limited and is now explicit and tested: absolute rows (`CSI H/f/d`) are not mapped (no cursor position reports are answered, so an inline program cannot know its rows); scroll regions are not emulated, except that text drawn below a region the program set (an installer's reserved status row) is not stored, so it cannot leak into the log rows. NMSh's own lines are never dropped. - Unknown width keeps the previous line-based behavior. Refs #388 --- CHANGELOG.md | 2 +- docs/design/v021-terminal-intelligence.md | 12 +- docs/testing/v021-physical-qa.md | 5 + src/app/TerminalApp.ts | 1 + src/output/AnsiOutputParser.ts | 189 ++++++++++++++++++---- src/output/OutputBuffer.ts | 5 + tests/ansiLiveRegion.test.ts | 119 ++++++++++++++ 7 files changed, 302 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6162170d..c0d7aff0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed -- Multi-line progress displays that redraw their own rows (cursor up and erase line, as concurrent downloads and container pulls do) now update in place instead of leaving a copy of every frame in the transcript ([#388](https://github.com/raiseCatError/notMyShell/issues/388)). A program can redraw only its own recent rows, at most a screen height up, never NMSh's lines or earlier commands; `/copy` copies the final rows once. Absolute cursor positioning keeps the previous behavior. +- Multi-line progress displays that redraw their own rows (cursor up and erase line, as concurrent downloads and container pulls do) now update in place instead of leaving a copy of every frame in the transcript ([#388](https://github.com/raiseCatError/notMyShell/issues/388)). A program can redraw only its own recent rows, at most a screen height up, never NMSh's lines or earlier commands; `/copy` copies the final rows once. Rows are the terminal's rows: a line wider than the window counts as the rows it occupies, so a display that redraws over its own wrapped line no longer erases the log lines above it at narrow widths. A status row an installer reserves below a scroll region is not written into the log. Absolute cursor positioning is not mapped. - A queued command whose command word was still being classified when the next prompt reported new shell knowledge no longer keeps the unclassified color ([#408](https://github.com/raiseCatError/notMyShell/issues/408)). - Paste review and `/track`: a redirect into a file is recognized wherever `>/dev/null` also appears (`echo x > ~/.zshrc 2>/dev/null` was labelled read-only), and `>|` is a redirection, not a pipe. - `/track` and `/watch` no longer start, without asking, commands that change things through the network or another command: `env `, `NAME=value` prefixes, git's `-c`/`--ext-diff`, `npm publish`/`login`/`x`, `pnpm dlx`, `ssh`, `curl -X POST`/`-d`/`-o file`, plain `wget`; options that write files (`git … --output`, `sort -o`, `jest -u`, `--fix`) are refused. Read-only probes (`ping`, `dig`, `nc -z`, a curl GET) still start at once, and `npm test 2>&1` is no longer refused as a background job ([#393](https://github.com/raiseCatError/notMyShell/issues/393)). diff --git a/docs/design/v021-terminal-intelligence.md b/docs/design/v021-terminal-intelligence.md index 860aaa04..d62f5e55 100644 --- a/docs/design/v021-terminal-intelligence.md +++ b/docs/design/v021-terminal-intelligence.md @@ -55,8 +55,16 @@ lines. Fix: a bounded **live region**: the cursor may move up and down only with never above the most recent NMSh-authored line or the command's start, and never more than the PTY's screen height (a terminal cannot address scrollback either). Supported: `CSI A/B/E/F` (cursor up/down/next/previous line), `ESC M`, `ESC D`, `ESC E`, save/restore (`ESC 7/8`, `CSI s/u`), `CSI J` 0/1 within the region, `CSI K` 0/1/2. -Absolute row addressing (`CSI H/f/d` with a row) is not mapped: inline programs cannot know where their rows are, so -it keeps today's behavior (appends), documented. Copy and search see what the terminal would show at the end; the +**Rows are the terminal's rows** (stabilization): once the frontend reports the PTY's width, a stored line wider than +it occupies several rows, so a program that counts the rows it drew (as progress libraries measure wrapping) moves +over exactly those rows. Moving by stored lines instead climbed past a wrapped line into the program's own earlier +output and erased it at narrow widths (reproduced, and in a real PTY at 40 columns). Carriage return, backspace, +`CSI G/C/D` and `CSI K` act on the cursor's row within its line; a resize re-wraps stored lines at the new width, as +a reflowing terminal does. The contract stays deliberately limited: absolute row addressing (`CSI H/f/d` with a row) +is not mapped, because inline programs cannot know where their rows are (cursor position reports are not answered), +so the cursor stays where it is; scroll regions (`CSI r`) are not emulated, except that text a program draws below a +region it set (a reserved status row, such as an installer's progress line) is not stored, so it cannot leak into the +log rows. Copy and search see what the terminal would show at the end; the intermediate frames are not kept, as a terminal's scrollback does not keep them. Raw events in the session spool are unchanged and replay through the same parser. Tests: single and multi-row redraws, interleaved stdout, split sequences, clamping at the fence, NMSh lines inserted mid-redraw, metadata never edited, a real-PTY run of the diff --git a/docs/testing/v021-physical-qa.md b/docs/testing/v021-physical-qa.md index 7dfc545c..98f81c59 100644 --- a/docs/testing/v021-physical-qa.md +++ b/docs/testing/v021-physical-qa.md @@ -36,6 +36,11 @@ - [ ] 1.3 Resize the window narrower and wider while 1.2 runs: no earlier command, prompt or NMSh notice is erased. - [ ] 1.4 `/copy` of that block copies the final rows once, not every frame. - [ ] 1.5 `printf '10%%\r20%%\r100%%\n'` still shows one `100%` line; `clear` still clears. +- [ ] 1.5a At 40 columns, a progress display whose line is wider than the window (for example `pip install` of a + large wheel, `cargo build` of a crate with a long name, or the wrapped-row fixture in `tests/ansiLiveRegion.test.ts`) + redraws in place and every log line printed before it stays. +- [ ] 1.5b On Linux, `sudo apt install ` (fancy progress on): the transcript keeps the dpkg log lines and + contains no `Progress: [ nn%]` fragments. - [ ] 1.6 `vim`, `less` and `htop` still take the full screen and return to the composer. - [ ] 1.7 Queue two quick commands (`ls` then `pwd`) with Ctrl+Q while a short command runs: both stored command lines end with the known-command color (#408). diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 9dd9eec2..c3126a5d 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -10577,6 +10577,7 @@ export class TerminalApp { this.lastPtyColumns = columns; this.session.resize(columns, plan.ptyRows); this.output.setScreenRows(plan.ptyRows); + this.output.setScreenColumns(columns); } const wrapped = this.output.wrapped(columns); diff --git a/src/output/AnsiOutputParser.ts b/src/output/AnsiOutputParser.ts index 7e0863dc..45916ba7 100644 --- a/src/output/AnsiOutputParser.ts +++ b/src/output/AnsiOutputParser.ts @@ -63,9 +63,20 @@ const DEFAULT_SCREEN_ROWS = 24; * the row the cursor is on. The cursor never moves above the fence (the line after the newest one NMSh wrote: a * command header, a notice, an earlier command) nor more than a screen height above the newest line, because a * terminal cannot address its scrollback either. So a multi-row progress display updates its rows instead of adding - * a copy of them per frame, and nothing outside the running program's own rows can be changed. Absolute row - * positioning (`CSI H/f/d` rows) is not mapped: an inline program cannot know where its rows are, so it keeps the - * previous behavior. The stored lines are what a terminal would show at the end; intermediate frames are not kept. + * a copy of them per frame, and nothing outside the running program's own rows can be changed. + * + * Rows are the terminal's rows. Stored lines are logical (a long line is one line), but once the owner reports the + * PTY's width a line wider than it occupies several rows, as on the screen: moving up three rows from below a + * two-row line lands on that line's first row, not on the line above it, so a program that counts the rows it drew + * redraws exactly them (moving by lines, it erased its own earlier output at narrow widths). Carriage return, + * backspace, `CSI G/C/D` and `CSI K` act on the row the cursor is on within its line. A resize re-wraps stored lines + * at the new width, as a reflowing terminal does. + * + * Deliberately not emulated: absolute row positioning (`CSI H/f/d` rows) is not mapped, because an inline program + * cannot know where its rows are (cursor position reports are not answered); the cursor stays where it is. Scroll + * regions (`CSI r`) are not emulated either, except that text a program draws below the region it set (a reserved + * status line, such as an installer's progress row) is not stored: it would otherwise be written into the log rows. + * The stored lines are what a terminal would show at the end; intermediate frames are not kept. */ export class AnsiOutputParser { readonly lines: StyledLine[] = []; @@ -75,6 +86,17 @@ export class AnsiOutputParser { /** The lowest row the program may address (see the live region). */ private fence = 0; private screenRows = DEFAULT_SCREEN_ROWS; + /** The PTY's columns, once reported: rows wrap at this width. Unknown (Infinity), each line is one row. */ + private screenColumns = Number.POSITIVE_INFINITY; + /** The bottom row of a scroll region the program set above the last row, if any (1-based). */ + private regionBottom?: number; + /** The cursor was moved below that region (a status row): what is drawn there is not stored. */ + private statusRow = false; + /** + * The column at which the last character filled its row exactly: the terminal keeps the cursor on that row (a + * pending wrap) until the next character. Any move to another column ends it. + */ + private pendingWrap?: number; /** DECSC / SCOSC saved cursor (row, column, and for ESC 7 the style). */ private saved?: {row: number; column: number; style?: string}; private column = 0; @@ -111,6 +133,11 @@ export class AnsiOutputParser { this.screenRows = Math.max(1, Math.min(1000, Math.floor(rows) || DEFAULT_SCREEN_ROWS)); } + /** The PTY's columns: the width at which a long line occupies more than one row. */ + setScreenColumns(columns: number): void { + this.screenColumns = Number.isFinite(columns) && columns >= 1 ? Math.min(10_000, Math.floor(columns)) : Number.POSITIVE_INFINITY; + } + /** * Program output from here on may redraw only rows from `index` (clamped to the stored lines); NMSh-written rows * and earlier commands stay out of reach. Used when a running command is resumed from a journal. @@ -126,21 +153,107 @@ export class AnsiOutputParser { return this.row < this.lines.length ? this.lines[this.row]! : this.current; } - /** The highest row the cursor may move to: the fence, or a screen height above the newest line. */ + /** The highest line the cursor may move to: the fence, or the line on the row a screen height above the newest. */ private get topRow(): number { - return Math.max(this.fence, this.lines.length - (this.screenRows - 1)); + if (!this.wraps) return Math.max(this.fence, this.lines.length - (this.screenRows - 1)); + let row = this.lines.length; + let used = this.rowsOf(row); + while (row > this.fence && used < this.screenRows) { row -= 1; used += this.rowsOf(row); } + return row; + } + + private get wraps(): boolean { return Number.isFinite(this.screenColumns); } + + private lineAt(row: number): StyledLine { return row < this.lines.length ? this.lines[row]! : this.current; } + + /** The rows a line occupies at the PTY's width (the cursor's line also counts the cursor). */ + private rowsOf(row: number): number { + const length = row === this.row ? Math.max(this.lineAt(row).length, this.column) : this.lineAt(row).length; + return Math.max(1, Math.ceil(length / this.screenColumns)); + } + + /** + * The row of its line the cursor is on. A row exactly filled leaves the cursor on it (the terminal's pending + * wrap) until the next character. + */ + private get subRow(): number { + if (!this.wraps) return 0; + const columns = this.screenColumns; + return this.column > 0 && this.column === this.pendingWrap ? this.column / columns - 1 : Math.floor(this.column / columns); + } + + /** Where the cursor's row starts, as a column of its line. */ + private get rowStart(): number { return this.wraps ? this.subRow * this.screenColumns : 0; } + + /** The cursor's column on its row (the last column while a wrap is pending). */ + private get screenColumn(): number { + return this.wraps ? Math.min(this.column - this.rowStart, this.screenColumns - 1) : this.column; } private moveRow(delta: number): void { this.row = Math.max(this.topRow, Math.min(this.lines.length, this.row + delta)); } - /** Line feed: down a row, or a new line at the bottom. */ - private lineFeed(): void { - if (this.row < this.lines.length) { this.row += 1; return; } - this.lines.push(this.current); - this.current = []; - this.row = this.lines.length; + /** Cursor up (negative) or down by terminal rows, keeping its column on the row (or going to the row's start). */ + private moveRows(delta: number, toRowStart = false): void { + if (!this.wraps) { + this.moveRow(delta); + if (toRowStart) this.column = 0; + return; + } + const columns = this.screenColumns; + const x = toRowStart ? 0 : this.screenColumn; + let row = this.row; + let sub = this.subRow; + if (delta < 0) { + // No higher than a screen above the newest row: count the rows from the cursor down to it. + let below = this.rowsOf(row) - 1 - sub; + for (let next = row + 1; next <= this.lines.length && below < this.screenRows; next += 1) below += this.rowsOf(next); + let steps = Math.min(-delta, Math.max(0, this.screenRows - 1 - below)); + while (steps > 0) { + if (sub > 0) sub -= 1; + else if (row > this.fence) { row -= 1; sub = this.rowsOf(row) - 1; } + else break; + steps -= 1; + } + } else { + let steps = delta; + while (steps > 0) { + if (sub < this.rowsOf(row) - 1) sub += 1; + else if (row < this.lines.length) { row += 1; sub = 0; } + else break; + steps -= 1; + } + } + this.row = row; + this.column = sub * columns + x; + } + + /** Line feed: down a row (the next row of a wrapped line, or the next line), or a new line at the bottom. */ + private lineFeed(carriageReturn: boolean): void { + this.statusRow = false; + const x = this.screenColumn; + if (this.wraps && this.subRow < this.rowsOf(this.row) - 1) { + this.column = (this.subRow + 1) * this.screenColumns + (carriageReturn ? 0 : x); + return; + } + if (this.row < this.lines.length) this.row += 1; + else { + this.lines.push(this.current); + this.current = []; + this.row = this.lines.length; + } + this.column = carriageReturn ? 0 : x; + } + + /** + * Shorten a line to `length`, but never past the start of the cursor's row: that row stays (blank) on the screen, + * so it stays in the row count. + */ + private truncate(line: StyledLine, length: number): void { + line.splice(length); + const start = this.rowStart; + if (start > 0 && line.length <= start) { line.length = start + 1; line[start] = undefined; } } /** NMSh wrote a line: the cursor returns below it and nothing at or above it can be redrawn. */ @@ -192,24 +305,23 @@ export class AnsiOutputParser { continue; } if (character === '\n') { - this.lineFeed(); - this.column = 0; + this.lineFeed(true); this.ending = true; index += 1; continue; } if (character === '\r') { - this.column = 0; + this.column = this.rowStart; index += 1; continue; } if (character === '\b') { - this.column = Math.max(0, this.column - 1); + this.column = Math.max(this.rowStart, this.column - 1); index += 1; continue; } if (character === '\t') { - const spaces = 8 - (this.column % 8); + const spaces = 8 - (this.screenColumn % 8); for (let count = 0; count < spaces; count += 1) { this.put(' ', 1); const cell = this.target[this.column - 1]!; @@ -242,6 +354,8 @@ export class AnsiOutputParser { addLine(text: string, style = ''): void { this.ensureLineBoundary(); + // NMSh's own line is never a program's status row. + this.statusRow = false; this.hyperlink = undefined; this.style = style; this.write(text); @@ -275,7 +389,9 @@ export class AnsiOutputParser { const oldRow = this.row; const oldFence = this.fence; const oldSaved = this.saved; + const oldStatusRow = this.statusRow; const oldLength = this.lines.length; + this.statusRow = false; this.current = []; this.column = 0; @@ -302,6 +418,7 @@ export class AnsiOutputParser { this.row = oldRow; this.fence = oldFence; this.saved = oldSaved; + this.statusRow = oldStatusRow; } completedCount(): number { @@ -422,13 +539,14 @@ export class AnsiOutputParser { // Two-byte escapes that move within the live region; others (keypad modes, RIS…) are dropped as before. if (kind === '7') this.saved = {row: this.row, column: this.column, style: this.style}; else if (kind === '8') this.restoreCursor(true); - else if (kind === 'M') this.moveRow(-1); - else if (kind === 'D') { this.lineFeed(); this.ending = true; } - else if (kind === 'E') { this.lineFeed(); this.column = 0; this.ending = true; } + else if (kind === 'M') this.moveRows(-1); + else if (kind === 'D') { this.lineFeed(false); this.ending = true; } + else if (kind === 'E') { this.lineFeed(true); this.ending = true; } return {complete: true, next: Math.min(input.length, start + 2)}; } private restoreCursor(withStyle: boolean): void { + this.statusRow = false; if (!this.saved) return; this.row = Math.max(this.topRow, Math.min(this.lines.length, this.saved.row)); this.column = this.saved.column; @@ -473,15 +591,27 @@ export class AnsiOutputParser { if (/^[<=>?]/u.test(params)) return; this.applySgr(params); } else if (final === 'K') { + if (this.statusRow) return; const line = this.target; const mode = values[0] ?? 0; - if (mode === 2) line.length = 0; - else if (mode === 1) { for (let position = 0; position <= this.column && position < line.length; position += 1) line[position] = undefined; } - else line.splice(this.column); + // The cursor's row only: a long line's other rows are other rows on the screen. + const start = this.rowStart; + const end = this.wraps ? start + this.screenColumns : Number.POSITIVE_INFINITY; + const from = mode === 0 ? this.column : start; + const to = mode === 1 ? Math.min(this.column + 1, end) : end; + if (to >= line.length && mode !== 1) this.truncate(line, from); + else for (let position = from; position < to && position < line.length; position += 1) line[position] = undefined; this.touch(line); } else if (plain && (final === 'A' || final === 'B' || final === 'E' || final === 'F')) { - this.moveRow(final === 'A' || final === 'F' ? -amount : amount); - if (final === 'E' || final === 'F') this.column = 0; + this.moveRows(final === 'A' || final === 'F' ? -amount : amount, final === 'E' || final === 'F'); + } else if (plain && final === 'r') { + // A scroll region ending above the last row reserves the rows below it (see statusRow); the region is not emulated. + const bottom = values[1] ?? 0; + this.regionBottom = bottom > 0 && bottom < this.screenRows ? bottom : undefined; + if (this.regionBottom === undefined) this.statusRow = false; + } else if (plain && (final === 'H' || final === 'f' || final === 'd')) { + // Absolute rows are not mapped (see the class comment); only a move below the program's scroll region matters. + this.statusRow = this.regionBottom !== undefined && (values[0] || 1) > this.regionBottom; } else if (plain && params === '' && final === 's') { this.saved = {row: this.row, column: this.column}; } else if (plain && params === '' && final === 'u') { @@ -489,11 +619,11 @@ export class AnsiOutputParser { } else if (plain && final === 'J' && (values[0] ?? 0) <= 1) { this.eraseDisplay(values[0] ?? 0); } else if (final === 'G') { - this.column = Math.max(0, amount - 1); + this.column = this.rowStart + (this.wraps ? Math.min(amount - 1, this.screenColumns - 1) : amount - 1); } else if (final === 'C') { - this.column += amount; + this.column = this.wraps ? this.rowStart + Math.min(this.screenColumns - 1, this.screenColumn + amount) : this.column + amount; } else if (final === 'D') { - this.column = Math.max(0, this.column - amount); + this.column = this.rowStart + Math.max(0, this.screenColumn - amount); } else if (final === 'J') { if (amount === 2 || amount === 3) { this.lines.length = 0; @@ -509,12 +639,14 @@ export class AnsiOutputParser { private put(text: string, width: number): void { this.ending = false; + if (this.statusRow) return; const line = this.target; for (let position = 0; position < width; position += 1) line[this.column + position] = undefined; line[this.column] = {text, width, style: this.style, ...(this.hyperlink ? {hyperlink: this.hyperlink, ...(this.authoring ? {authored: true as const} : {})} : {})}; this.touch(); for (let position = 1; position < width; position += 1) line[this.column + position] = null; this.column += width; + this.pendingWrap = this.wraps && this.column % this.screenColumns === 0 ? this.column : undefined; } private touch(line: StyledLine = this.target): void { revisions.set(line, lineRevision(line) + 1); } @@ -530,9 +662,10 @@ export class AnsiOutputParser { /** Erase in display within the live region: 0 below the cursor, 1 above it. */ private eraseDisplay(mode: number): void { + if (this.statusRow) return; const line = this.target; if (mode === 0) { - line.splice(this.column); + this.truncate(line, this.column); if (this.row < this.lines.length) { // The cursor's row becomes the newest line; the program's rows below it are gone, as on the screen. this.current = line; diff --git a/src/output/OutputBuffer.ts b/src/output/OutputBuffer.ts index 0b42aabf..4ac1211d 100644 --- a/src/output/OutputBuffer.ts +++ b/src/output/OutputBuffer.ts @@ -376,6 +376,11 @@ export class OutputBuffer { this.parser.setScreenRows(rows); } + /** The PTY's columns: a program's long lines occupy rows at this width when it moves its cursor over them. */ + setScreenColumns(columns: number): void { + this.parser.setScreenColumns(columns); + } + updateCommandHighlight(startId: number, formattedLines: string[]): void { for (let i = 0; i < formattedLines.length; i++) { this.parser.replaceLine(startId + i, formattedLines[i] ?? ''); diff --git a/tests/ansiLiveRegion.test.ts b/tests/ansiLiveRegion.test.ts index 08281714..64c267d9 100644 --- a/tests/ansiLiveRegion.test.ts +++ b/tests/ansiLiveRegion.test.ts @@ -7,6 +7,7 @@ import {join} from 'node:path'; import {AnsiOutputParser, plainLine} from '../src/output/AnsiOutputParser.js'; import {OutputBuffer} from '../src/output/OutputBuffer.js'; import {ShellSession} from '../src/shell/ShellSession.js'; +import {LiveSandbox, until} from './helpers/liveFrontend.js'; /** * #388: programs that redraw several of their own rows in place (multi-line progress) update those rows instead of @@ -90,6 +91,79 @@ test('absolute row positioning is not mapped: it appends as before instead of gu assert.deepEqual(plain(parser), ['a', 'b', 'c']); }); +/** An indicatif-style display: it moves up by the terminal rows it drew (it measures wrapping), erases below, redraws. */ +function wrappedProgress(columns: number, frames = 10): string { + const bar = (n: number) => `Downloading big-archive-name.tar.gz [${'='.repeat(n)}${' '.repeat(frames - n)}] ${n * 10}%`; + const rowsOf = (text: string) => Math.max(1, Math.ceil(text.length / columns)); + let out = 'LOG kept line one\r\nLOG kept line two\r\n'; + let drawn = 0; + for (let n = 0; n <= frames; n += 1) { + if (drawn) out += `\r${up(drawn)}${ESC}[J`; + const frame = [bar(n), `eta ${frames - n}s`]; + out += `${frame.join('\r\n')}\r\n`; + drawn = frame.reduce((sum, line) => sum + rowsOf(line), 0); + } + return `${out}LOG done\r\n`; +} + +test('rows are the terminal\'s rows: a display that moves over its own wrapped line never erases earlier output', () => { + const parser = new AnsiOutputParser(); + parser.setScreenRows(24); + parser.setScreenColumns(40); + parser.write(wrappedProgress(40)); + assert.deepEqual(plain(parser), ['LOG kept line one', 'LOG kept line two', 'Downloading big-archive-name.tar.gz [==========] 100%', 'eta 0s', 'LOG done']); + // The same bytes at a width where nothing wraps behave the same. + const wide = new AnsiOutputParser(); + wide.setScreenRows(24); + wide.setScreenColumns(120); + wide.write(wrappedProgress(120)); + assert.deepEqual(plain(wide), plain(parser)); +}); + +test('on a wrapped line, carriage return, erase-line and line feed act on the cursor\'s row, as on the screen', () => { + const parser = new AnsiOutputParser(); + parser.setScreenColumns(10); + parser.write('0123456789abcdefghij'); // two rows, cursor at the end (pending wrap) + parser.write('\rABC'); // back to the start of the second row + assert.deepEqual(plain(parser), ['0123456789ABCdefghij']); + parser.write(`${up(1)}${ESC}[2K`); // up to the first row and clear that row only + assert.deepEqual(plain(parser), [' ABCdefghij']); + parser.write('\r\nxy'); // line feed from the first row goes to the line's second row + assert.deepEqual(plain(parser), [' xyCdefghij']); + parser.write(`\r${ESC}[K`); // erase to the end of the second row: the row stays, blank + parser.write(`${up(1)}first\r\nnext\r\n`); + // The second row is still the same (wrapped) line, as on the screen: drawn at 10 columns it reads "first" / "next". + assert.deepEqual(plain(parser), ['first next']); +}); + +test('a resize re-wraps: the rows a program counts at the new width are the ones it reaches', () => { + const parser = new AnsiOutputParser(); + parser.setScreenColumns(40); + parser.write('kept\r\n'); + parser.write(`${'x'.repeat(30)}\r\n`); + parser.setScreenColumns(20); // the 30-column line now takes two rows + parser.write(`${up(2)}${ESC}[J${'y'.repeat(5)}\r\n`); + assert.deepEqual(plain(parser), ['kept', 'yyyyy']); +}); + +test('a status row below a scroll region the program set is not stored; NMSh lines and log rows are', () => { + const parser = new AnsiOutputParser(); + parser.setScreenRows(24); + parser.setScreenColumns(80); + const status = (percent: number) => `${ESC}7${ESC}[24;0f${ESC}[42mProgress: [${percent}%]${ESC}[0m${ESC}8`; + parser.write(`\r\n${ESC}7${ESC}[0;23r${ESC}8${up(1)}`); + for (let step = 1; step <= 3; step += 1) parser.write(`Unpacking package-${step}\r\n${status(step * 30)}`); + parser.addLine('NMSh note while the status row is reserved'); + parser.write(`Setting up package-3\r\n${ESC}7${ESC}[0;24r${ESC}8${ESC}[J`); + const lines = plain(parser); + assert.equal(lines.some(line => /Progress/u.test(line)), false, 'the status row never enters the transcript'); + assert.deepEqual(lines.filter(line => line), ['Unpacking package-1', 'Unpacking package-2', 'Unpacking package-3', 'NMSh note while the status row is reserved', 'Setting up package-3']); + // Without a region, an absolute move is not mapped: the text stays where the cursor is. + const plainParser = new AnsiOutputParser(); + plainParser.write(`a\r\n${ESC}[5;1Hb\r\n`); + assert.deepEqual(plain(plainParser), ['a', 'b']); +}); + test('a resumed command keeps redrawing its own rows, and a command-line recolor never moves its cursor', () => { const buffer = new OutputBuffer(); const startId = buffer.beginCommand('pull', ['$ pull']); @@ -119,6 +193,33 @@ test('ten thousand frames of a three-row display stay three rows and parse in bo assert.ok(elapsed < 5000, `took ${Math.round(elapsed)}ms`); }); +test('a real PTY at 40 columns: a display that counts its wrapped rows keeps the log lines above it', {timeout: 60_000}, async () => { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-wrapped-'))); + const shell = new ShellSession(home, 40, 30, home, {...process.env, HOME: home}); + let output = ''; + try { + await once(shell, 'prompt'); + shell.on('data', data => { output += data; }); + const done = once(shell, 'prompt'); + // The program measures its rows with the PTY's own width, as progress libraries do. + const script = 'const c=process.stdout.columns,r=t=>Math.max(1,Math.ceil(t.length/c));let d=0;process.stdout.write("LOG one\\nLOG two\\n");' + + 'for(let n=0;n<=10;n++){if(d)process.stdout.write("\\r\\x1b["+d+"A\\x1b[J");const f=["Downloading big-archive-name.tar.gz ["+"=".repeat(n)+" ".repeat(10-n)+"]","eta "+(10-n)+"s"];' + + 'process.stdout.write(f.join("\\n")+"\\n");d=f.reduce((s,l)=>s+r(l),0)}process.stdout.write("LOG done\\n")'; + shell.submit(`${JSON.stringify(process.execPath)} -e '${script}'`); + await done; + const parser = new AnsiOutputParser(); + parser.setScreenRows(30); + parser.setScreenColumns(40); + parser.write(output); + const lines = plain(parser); + assert.ok(lines.includes('LOG one') && lines.includes('LOG two') && lines.includes('LOG done'), lines.join('\n')); + assert.deepEqual(lines.filter(line => line.startsWith('Downloading')), ['Downloading big-archive-name.tar.gz [==========]']); + } finally { + shell.kill(); + rmSync(home, {recursive: true, force: true, maxRetries: 5, retryDelay: 100}); + } +}); + test('a real PTY: the #388 fixture run by zsh leaves one progress line', {timeout: 60_000}, async () => { const home = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-redraw-'))); const shell = new ShellSession(home, 100, 30, home, {...process.env, HOME: home}); @@ -139,3 +240,21 @@ test('a real PTY: the #388 fixture run by zsh leaves one progress line', {timeou rmSync(home, {recursive: true, force: true, maxRetries: 5, retryDelay: 100}); } }); + +test('live: through the real frontend at 40 columns, the wrapped display redraws in place and the log lines stay in the transcript', {timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh'}); + const frontend = sandbox.launch([], {cols: 40, rows: 30}); + try { + await frontend.waitFor(/❯/u); + await frontend.run('echo READY', /READY/u); + const script = 'const c=process.stdout.columns,r=t=>Math.max(1,Math.ceil(t.length/c));let d=0;process.stdout.write("LOG one\\nLOG two\\n");' + + 'for(let n=0;n<=10;n++){if(d)process.stdout.write("\\r\\x1b["+d+"A\\x1b[J");const f=["Downloading big-archive-name.tar.gz ["+"=".repeat(n)+" ".repeat(10-n)+"]","eta "+(10-n)+"s"];' + + 'process.stdout.write(f.join("\\n")+"\\n");d=f.reduce((s,l)=>s+r(l),0)}process.stdout.write("LOG done\\n")'; + await frontend.run(`${JSON.stringify(process.execPath)} -e '${script}'`, /LOG done/u); + await until(async () => (await sandbox.transcripts().list()).some(session => JSON.stringify(session.transcript).includes('LOG done')), 15_000, 'the journal'); + const sessions = await sandbox.transcripts().list(); + const lines = sessions.flatMap(session => (session.transcript.lines as Array>).map(line => line.map(cell => cell?.text ?? ' ').join('').trimEnd())); + assert.ok(lines.includes('LOG one') && lines.includes('LOG two'), lines.join('\n')); + assert.equal(lines.filter(line => line.startsWith('Downloading')).length, 1, 'one final progress row, not one per frame'); + } finally { await sandbox.dispose(); } +}); From af475c316ceb231eb92bda9c655fa5011c8bc4e0 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:20:42 +0530 Subject: [PATCH 2/3] Command colors survive a slow zsh configuration: lookups are timed from when the helper reads them Found while re-verifying #408. The semantic helper (a zsh that sources the user's configuration and answers `whence` lookups) reads requests only after its startup files finish, but each lookup's 1.5 s limit started when it was sent. A configuration that takes longer to load (nvm, a large framework: 2 s reproduced) made the first lookup time out, which ends the helper; every later executable or builtin then stayed uncolored (neither known nor unknown) for the whole session. The helper now says when it reads requests; lookup limits (and raw source/facts lookups) start then, and startup itself has its own 20 s bound. The new test (a 2 s .zshrc) fails on the old service: `ls` came back unknown after 1.5 s and the helper was dead. #408's own synchronization was re-checked: newer shell knowledge bumps the generation and clears pending lookups, so a late answer finds no pending entry and is never cached; superseded lookups ask again (at most three times). Refs #408 --- CHANGELOG.md | 1 + src/shell/SemanticService.ts | 49 ++++++++++++++++++++++++++++++------ tests/semantic.test.ts | 14 ++++++++++- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0d7aff0..7a1c628b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Multi-line progress displays that redraw their own rows (cursor up and erase line, as concurrent downloads and container pulls do) now update in place instead of leaving a copy of every frame in the transcript ([#388](https://github.com/raiseCatError/notMyShell/issues/388)). A program can redraw only its own recent rows, at most a screen height up, never NMSh's lines or earlier commands; `/copy` copies the final rows once. Rows are the terminal's rows: a line wider than the window counts as the rows it occupies, so a display that redraws over its own wrapped line no longer erases the log lines above it at narrow widths. A status row an installer reserves below a scroll region is not written into the log. Absolute cursor positioning is not mapped. - A queued command whose command word was still being classified when the next prompt reported new shell knowledge no longer keeps the unclassified color ([#408](https://github.com/raiseCatError/notMyShell/issues/408)). +- With a slow zsh configuration (one that takes longer than a second and a half to load, as nvm or a large framework can), command colors no longer stop for the rest of the session: the first lookup waited on the configuration, timed out, and ended the helper that tells known commands from unknown ones. - Paste review and `/track`: a redirect into a file is recognized wherever `>/dev/null` also appears (`echo x > ~/.zshrc 2>/dev/null` was labelled read-only), and `>|` is a redirection, not a pipe. - `/track` and `/watch` no longer start, without asking, commands that change things through the network or another command: `env `, `NAME=value` prefixes, git's `-c`/`--ext-diff`, `npm publish`/`login`/`x`, `pnpm dlx`, `ssh`, `curl -X POST`/`-d`/`-o file`, plain `wget`; options that write files (`git … --output`, `sort -o`, `jest -u`, `--fix`) are refused. Read-only probes (`ping`, `dig`, `nc -z`, a curl GET) still start at once, and `npm test 2>&1` is no longer refused as a background job ([#393](https://github.com/raiseCatError/notMyShell/issues/393)). - `/track stop` and a closing window end every process a tracked command left in its process group, including one that ignored TERM; a tracker whose directory disappeared names it instead of reporting `spawn /bin/sh ENOENT`. diff --git a/src/shell/SemanticService.ts b/src/shell/SemanticService.ts index 6ffb9efa..5ee2b819 100644 --- a/src/shell/SemanticService.ts +++ b/src/shell/SemanticService.ts @@ -48,6 +48,10 @@ export function parseCompletionFacts(line: string): CompletionFacts | undefined /** How many times a lookup superseded by newer shell knowledge is asked again before it settles as unknown. */ const REASKS = 3; +/** How long the helper may take to source the user's configuration before it is given up on. */ +const STARTUP_MS = 20_000; +/** How long one lookup may take once the helper reads requests. */ +const LOOKUP_MS = 1500; export class SemanticService { private child: ReturnType; @@ -135,7 +139,12 @@ PS1="" this.child.stdin!.on('error', () => { /* ignore EPIPE */ }); + // Lookups are timed from when the helper reads them: sourcing a slow configuration (nvm, a large framework) took + // longer than one lookup's limit, and the first lookup's timeout then ended the helper for the whole session. + this.startupTimer = setTimeout(() => { if (!this.ready) this.kill(); }, STARTUP_MS); + this.startupTimer.unref?.(); this.child.stdin!.write(` +builtin printf 'ready\\n' while builtin read -r id mode cmd; do if [[ $mode == source ]]; then nmsh_kind=$(builtin whence -w -- "$cmd" 2>/dev/null) @@ -183,6 +192,7 @@ done\n`); this.buffer = lines.pop() || ''; for (const line of lines) { if (!line.trim()) continue; + if (line === 'ready') { this.markReady(); continue; } const space = line.indexOf(' '); if (space === -1) continue; const idStr = line.substring(0, space); @@ -210,6 +220,21 @@ done\n`); } private isDead = false; + private ready = false; + private startupTimer?: NodeJS.Timeout; + /** Time limits of lookups sent before the helper was ready, started once it is. */ + private readonly whenReady: Array<() => void> = []; + + private markReady(): void { + if (this.ready) return; + this.ready = true; + clearTimeout(this.startupTimer); + for (const arm of this.whenReady.splice(0)) arm(); + } + + private armWhenReady(arm: () => void): void { + if (this.ready) arm(); else this.whenReady.push(arm); + } async classifyCommand(cmd: string): Promise { return this.lookup(cmd, 0); @@ -234,11 +259,7 @@ done\n`); const id = this.nextId++; const generation = this.generation; return new Promise((resolve) => { - const timer = setTimeout(() => { - this.pending.delete(id); - this.kill(); - resolve('unknown'); - }, 1500); + let timer: NodeJS.Timeout | undefined; this.pending.set(id, (res) => { clearTimeout(timer); if (generation !== this.generation) { resolve(reasked < REASKS ? this.lookup(cmd, reasked + 1) : 'unknown'); return; } @@ -246,6 +267,14 @@ done\n`); this.cache.set(cmd, res); resolve(res); }); + this.armWhenReady(() => { + if (!this.pending.has(id)) return; + timer = setTimeout(() => { + this.pending.delete(id); + this.kill(); + resolve('unknown'); + }, LOOKUP_MS); + }); // Safety: cmd should not contain newlines or null bytes const safeCmd = cmd.replace(/[\r\n\0]/g, ''); try { @@ -264,9 +293,13 @@ done\n`); if (this.isDead || !this.child.stdin?.writable || this.rawPending.size >= 32) return Promise.resolve(undefined); const id = this.nextId++; return new Promise(resolve => { - const timer = setTimeout(() => { this.rawPending.delete(id); resolve(undefined); }, 1500); - timer.unref?.(); + let timer: NodeJS.Timeout | undefined; this.rawPending.set(id, line => { clearTimeout(timer); resolve(line || undefined); }); + this.armWhenReady(() => { + if (!this.rawPending.has(id)) return; + timer = setTimeout(() => { this.rawPending.delete(id); resolve(undefined); }, LOOKUP_MS); + timer.unref?.(); + }); try { this.child.stdin!.write(`${id} ${mode} ${word.replace(/[\r\n\0\s]/gu, '')}\n`); } catch { clearTimeout(timer); this.rawPending.delete(id); resolve(undefined); } }); @@ -293,6 +326,8 @@ done\n`); kill() { this.isDead = true; + clearTimeout(this.startupTimer); + this.whenReady.length = 0; try { this.child.stdin?.end(); } catch (e) { /* ignore */ } diff --git a/tests/semantic.test.ts b/tests/semantic.test.ts index 2a4b7ff8..8e64921b 100644 --- a/tests/semantic.test.ts +++ b/tests/semantic.test.ts @@ -1,5 +1,5 @@ import test, {beforeEach, afterEach} from 'node:test'; -import {mkdtempSync, rmSync} from 'node:fs'; +import {mkdtempSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import assert from 'node:assert'; @@ -100,3 +100,15 @@ test('SemanticService ignores commands when dead and cleans resources', async () assert.equal((service as any).zdotdir, '', 'zdotdir must be cleared'); assert.equal((service as any).isDead, true, 'service must be marked dead'); }); + +test('a slow configuration delays the first answer, not the helper\'s life: lookups are timed from when it reads them', {timeout: 30_000}, async () => { + // Sourcing takes longer than one lookup's limit (nvm, a large framework); the first lookup used to time out, end the + // helper, and leave every later command without its known/unknown color for the session. + writeFileSync(join(fixtureHome, '.zshrc'), 'sleep 2\n'); + const service = createService(); + const [first, source] = await Promise.all([service.classifyCommand('ls'), service.resolveSource('ls')]); + assert.strictEqual(first, 'executable'); + assert.strictEqual(source?.kind, 'executable'); + assert.strictEqual((service as any).isDead, false); + assert.strictEqual(await service.classifyCommand('cd'), 'builtin', 'later lookups are still answered'); +}); From 19f5a36a5202128736289fc250f28b30300a581a Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:37:10 +0530 Subject: [PATCH 3/3] /modules explain says when the Rail or Strip left a module out, or shortened it, for width (#365) #365 asks why a module is visible, hidden or compacted. Explain covered routing, visibility, conditions and source state; width was missing. The Context Rail fits module groups by priority (place, compact, then drop) and the Status Strip narrows forms, then drops the lowest priorities; both now fill an optional fit report on their live render (previews of a draft never overwrite it), and `/modules explain` adds "left out for width in the last frame (higher-priority modules kept)" or "shortened to fit the width in the last frame". A strip too narrow to show at all reports every routed module as left out. A module turned off is still explained as off. Nothing is collected or measured to produce it. The Main Prompt and Right Context truncate text rather than drop modules; a per-segment truncation trace there is left as future work (documented). Refs #365 --- CHANGELOG.md | 2 +- docs/design/native-module-coverage.md | 5 ++- docs/testing/v021-physical-qa.md | 3 ++ src/app/TerminalApp.ts | 20 +++++++++--- src/prompt/fitReport.ts | 13 ++++++++ src/prompt/moduleReport.ts | 9 ++++-- src/prompt/prompt.ts | 8 ++++- src/prompt/railComposition.ts | 5 +-- src/status/StatusStrip.ts | 18 ++++++++--- tests/moduleReport.test.ts | 44 +++++++++++++++++++++++++++ 10 files changed, 112 insertions(+), 15 deletions(-) create mode 100644 src/prompt/fitReport.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a1c628b..41472b72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Limits: your own regular UTF-8 text files up to 1 MiB with one hard link, writable, with one kind of line ending. Others are refused with the reason and stay readable in `/view`. Extended attributes and ACLs are not carried over by a save. Unsaved text is lost if the NMSh window itself is closed. - **Semantic Output** for test runs and builds ([#386](https://github.com/raiseCatError/notMyShell/issues/386), first phase). A completed block whose output NMSh recognizes gets one overview row with the counts the tool printed (`✔ node --test · 1,772 passed · 0 failed · 25 skipped`, `✘ TypeScript · 3 errors in 2 files`) and, when it listed failures or diagnostics, a details row that opens them, taken verbatim from the output. Recognized: Node's test runner (spec and TAP), Jest, Vitest, pytest, `cargo test`, `go test`, TypeScript, Rust and GCC/Clang diagnostics; anything else stays raw only, and short passing output gets nothing. Details start open when the raw output is folded; click the details row or use Actions → Show or hide details. The output itself, its folding, search, selection and plain `/copy` are unchanged. `/copy --semantic` copies the reading (raw for an output without one, as the note says) and `/copy --both` the raw output and the reading under labelled NMSh separators. Settings → Transcript → Semantic Output turns it off. - **Native modules: Bun, Nix shell and container** ([#364](https://github.com/raiseCatError/notMyShell/issues/364)). Bun's requested version comes from `.bun-version` or `package.json` (`packageManager`, `engines.bun`) and the active one from its install location; the Nix shell module shows `pure`/`impure` from `IN_NIX_SHELL`; the container module names Docker, Podman, a dev container or Codespaces from marker files and host variables. None runs anything; all are off until enabled in `/modules`, which recommends Bun and Nix when their files are present. -- **`/modules explain` and `/modules timings`** ([#365](https://github.com/raiseCatError/notMyShell/issues/365)) write a plain-text report into the transcript: each configured module's surface, whether it is on screen and why, and its sources' state, age and last collection time; or the slowest last collections. They read what the Context Engine already has and collect nothing. +- **`/modules explain` and `/modules timings`** ([#365](https://github.com/raiseCatError/notMyShell/issues/365)) write a plain-text report into the transcript: each configured module's surface, whether it is on screen and why (including when the Context Rail or Status Strip left it out, or shortened it, to fit the width in the last frame), and its sources' state, age and last collection time; or the slowest last collections. They read what the Context Engine already has and collect nothing. - **Five more bundled theme families** ([#401](https://github.com/raiseCatError/notMyShell/issues/401)): Ayu (Dark, Mirage, Light), Kanagawa (Wave, Dragon, Lotus), Everforest (Dark, Light), Flexoki (Dark, Light) and Night Owl (Night Owl, Light Owl), with colors copied exactly from each project's MIT-licensed source at a recorded revision. They theme NMSh's own prompt fills and chrome only; the terminal's own colors and configuration are never changed. Existing theme ids and saved choices are unchanged, and Lavender stays the default. Theme Studio's Built-in list gains `/` to filter by name, family, or `dark`/`light`. - **`/track`** keeps one live result of a finite command in the transcript, refreshed in place ([#393](https://github.com/raiseCatError/notMyShell/issues/393)). `/track df -h` or `/track --every 5s -- docker system df` writes the command once, then shows a status row (`live · every 5s · updated 13:49:25 · exit 0`, a failing exit, a timeout, paused, stale, refreshing) and the latest result under it, with a stable height. The shell stays free; nothing accumulates per refresh, and find, selection and `/copy` never include the live rows. `/track copy` copies the latest result, `/track refresh`, `pause`, `resume` and `stop` control it, and stopping leaves the last result in the transcript once. Samples run `sh -c` from the starting directory with your shell's PATH (not its aliases or functions), never overlap, and are bounded (200 kB, a timeout, at most 8 trackers); destructive and unknown commands are refused or need a Yes, as for `/watch`. Trackers stop with the window that started them. `/watch` keeps its manager and its behavior; both share one scheduler, which gains stricter interval parsing, refusals for substitutions, background jobs and heredocs, UTF-8-safe bounded capture, visible timeouts and a stop grace period (from draft #394). diff --git a/docs/design/native-module-coverage.md b/docs/design/native-module-coverage.md index c51ecf07..36d910ac 100644 --- a/docs/design/native-module-coverage.md +++ b/docs/design/native-module-coverage.md @@ -64,4 +64,7 @@ capabilities as it may hold modules. Kotlin/Scala, Lua and Haskell stay candidat source that is reliable enough to show (a Gradle Kotlin plugin version, a rockspec, a Stack resolver) and was not judged worth a module per language in this release. Diagnostics (#365): `/modules explain` and `/modules timings` give a plain-text report of routing, visibility reasons, source states and the slowest last collections, from the engine's -snapshots only. +snapshots only. The Context Rail and Status Strip record how their width fitting treated each routed module in the last +live frame (left out, or shortened), and explain says so; previews never overwrite that record. The Main Prompt and +Right Context truncate their text rather than drop modules, so they have nothing to report; a per-segment truncation +trace there remains future work. diff --git a/docs/testing/v021-physical-qa.md b/docs/testing/v021-physical-qa.md index 98f81c59..42d2a7e1 100644 --- a/docs/testing/v021-physical-qa.md +++ b/docs/testing/v021-physical-qa.md @@ -131,6 +131,9 @@ - [ ] 6.3 In a Docker or Podman container (or a Codespace), the container module names it; on the host it shows nothing. - [ ] 6.4 `/modules explain` lists every configured module with its surface and reason; `/modules timings` lists the slowest collections; neither changes what is collected (the Status Strip CPU stays as it was). +- [ ] 6.5 Route three modules to the Context Rail (Modules → Surface), narrow the window until one disappears, then run + `/modules explain`: that module says "left out for width in the last frame"; widen the window and run it again: the + note is gone. The same with the Status Strip. ## 7. Theme families diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index c3126a5d..62148e4a 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -5,6 +5,7 @@ import {queuePresentationChanges, queueRowKey, staleQueueSnapshot} from '../stat import {shellSubmission} from '../shell/submission.js'; import {railNeedsPromptConversion, railPreviewConfiguration} from '../prompt/railLayout.js'; import {prepareRail, paintRailComposition, railCompositionPreview, type PreparedRail} from '../prompt/railComposition.js'; +import {fitOutcome, newFitReport, type FitReport} from '../prompt/fitReport.js'; import {routeModule} from '../context/surfaceRouter.js'; import {promptFacts, moduleFactContext, updateFact, factAllowed} from '../context/facts.js'; import {chromaPreviewNote} from '../appearance/chromaNotes.js'; @@ -2945,7 +2946,8 @@ export class TerminalApp { if (this.appearanceHub) { this.appearanceHub.view = 'motion'; this.appearanceHub.selected = 0; this.appearanceHub.previewStart = Date.now(); } } else if (slash.kind === 'prompt') { this.panelOrigin = undefined; await this.startPromptSettings(false); } - else if (slash.kind === 'modules' && slash.report === 'explain') this.output.addFrontendBlock(command.trim(), explainModules(this.promptConfiguration.modules, this.modulesContext(), this.promptConfiguration)); + else if (slash.kind === 'modules' && slash.report === 'explain') this.output.addFrontendBlock(command.trim(), explainModules(this.promptConfiguration.modules, this.modulesContext(), this.promptConfiguration, Date.now(), + (id, surface) => fitOutcome(surface === 'contextRail' ? this.lastFit.rail : surface === 'statusStrip' ? this.lastFit.strip : undefined, id))); else if (slash.kind === 'modules' && slash.report === 'timings') this.output.addFrontendBlock(command.trim(), moduleTimings(id => this.contextEngine.status(id))); else if (slash.kind === 'modules') { this.panelOrigin = undefined; this.openModulesManager(); } else if (slash.kind === 'chroma') { this.panelOrigin = undefined; this.startChromaSettings(); } @@ -4489,7 +4491,10 @@ export class TerminalApp { if (this.renderRail?.columns === columns) return this.renderRail.prepared; const configuration = this.passthrough || this.externalPassthrough || this.effectivePromptProvider !== 'nmsh' ? {...this.promptConfiguration, contextRail: {...this.promptConfiguration.contextRail, mode: 'off' as const}} : this.promptConfiguration; - return prepareRail(this.promptContext(), columns, configuration); + const report = newFitReport(); + const prepared = prepareRail(this.promptContext(), columns, configuration, 0, report); + this.lastFit.rail = report; + return prepared; } private contextRailRows(columns: number): string[] { @@ -8467,11 +8472,18 @@ export class TerminalApp { const record = this.awakeRecord; const display = configuration.keepAwake.display; // Modules routed to the strip are resolved facts like every other surface; the strip never collects anything itself. - return renderStatusStrip(configuration.statusStrip, stripStatsFromFacts(this.contextEngine.facts()), columns, undefined, + // The live row records how it fitted routed modules, for /modules explain; previews of a draft do not. + const report = configuration === this.promptConfiguration ? newFitReport() : undefined; + const row = renderStatusStrip(configuration.statusStrip, stripStatsFromFacts(this.contextEngine.facts()), columns, undefined, record ? {full: awakeLabel(record, display, 'full'), short: awakeLabel(record, display, 'short'), glyph: awakeLabel(record, display, 'glyph')} : undefined, - this.statusStripModuleItems(configuration)); + this.statusStripModuleItems(configuration), report); + if (report) this.lastFit.strip = report; + return row; } + /** How the last live frame fitted routed modules on the Rail and the Strip (#365). */ + private readonly lastFit: {rail?: FitReport; strip?: FitReport} = {}; + /** Strip-routed modules with their group and, for the path, a shorter form the fitter may use first. */ private statusStripModuleItems(configuration = this.promptConfiguration): StripModuleItem[] { const context = this.promptContext(); diff --git a/src/prompt/fitReport.ts b/src/prompt/fitReport.ts new file mode 100644 index 00000000..6304236b --- /dev/null +++ b/src/prompt/fitReport.ts @@ -0,0 +1,13 @@ +/** + * What a surface's width fitting did to routed modules in one live render (#365): which it left out and which it + * shortened. `/modules explain` reads the last frame's report; nothing is measured or collected to produce it. + */ +export interface FitReport {dropped: Set; compacted: Set} + +export const newFitReport = (): FitReport => ({dropped: new Set(), compacted: new Set()}); + +/** How the last frame fitted one module on its surface, if it had to. */ +export function fitOutcome(report: FitReport | undefined, id: string): 'dropped' | 'compacted' | undefined { + if (!report) return undefined; + return report.dropped.has(id) ? 'dropped' : report.compacted.has(id) ? 'compacted' : undefined; +} diff --git a/src/prompt/moduleReport.ts b/src/prompt/moduleReport.ts index 7ad74098..35663f30 100644 --- a/src/prompt/moduleReport.ts +++ b/src/prompt/moduleReport.ts @@ -19,7 +19,8 @@ const age = (status: Status, now: number) => status.collectedAt === undefined ? const took = (status: Status) => status.durationMs === undefined ? '' : `, took ${status.durationMs < 1 ? '<1' : Math.round(status.durationMs)} ms`; const STATE: Record = {fresh: 'fresh', stale: 'stale', pending: 'collecting', absent: 'nothing to show', timeout: 'timed out', failed: 'failed', idle: 'not collected'}; -export function explainModules(modules: readonly ContextModuleConfig[], context: ModulesContext, configuration: PromptConfiguration, now = Date.now()): string[] { +export function explainModules(modules: readonly ContextModuleConfig[], context: ModulesContext, configuration: PromptConfiguration, now = Date.now(), + fit?: (id: string, surface: string) => 'dropped' | 'compacted' | undefined): string[] { const rows: string[] = []; for (const module of modules) { const definition = moduleDefinition(module.id); @@ -33,7 +34,11 @@ export function explainModules(modules: readonly ContextModuleConfig[], context: : context.conditionMet?.(module) === false ? 'hidden: its condition does not match here' : whyText(module, surface, statuses, definition.triggers); const where = surface === 'hidden' ? 'Hidden' : MODULE_SURFACE_LABELS[surface as ModuleSurface]; - rows.push(`${inert(definition.label, 48)} (${module.id}) · ${where} · ${inert(why, 200)}`); + // Width: what the last frame's fitting did on its surface (higher priorities are kept, then shortened, longest). + const fitted = module.visible && surface !== 'hidden' && (!availability || availability.available) ? fit?.(module.id, surface) : undefined; + const width = fitted === 'dropped' ? ' · left out for width in the last frame (higher-priority modules kept)' + : fitted === 'compacted' ? ' · shortened to fit the width in the last frame' : ''; + rows.push(`${inert(definition.label, 48)} (${module.id}) · ${where} · ${inert(why, 200)}${width}`); const sources = sourced.map(({id, status}) => `${id} ${STATE[status.state] ?? status.state}${age(status, now)}${took(status)}${status.evidence ? ` (${inert(status.evidence, 80)})` : ''}`); if (sources.length) rows.push(` reads ${sources.join('; ')}`); } diff --git a/src/prompt/prompt.ts b/src/prompt/prompt.ts index 906bc8d9..69e0c6fa 100644 --- a/src/prompt/prompt.ts +++ b/src/prompt/prompt.ts @@ -1,4 +1,5 @@ import {colorLevel} from '../presentation/capabilities.js'; +import type {FitReport} from './fitReport.js'; import {promptFacts, moduleFactContext, safeContextText, factAllowed} from '../context/facts.js'; import {routeModule} from '../context/surfaceRouter.js'; import {moduleDefinition} from '../context/modules.js'; @@ -644,7 +645,7 @@ export function buildContextLine( } /** Resolve appearance through the existing semantic theme context, restoring it after a chosen-theme render. */ -export function buildContextRail(context: PromptContext, width: number, configuration: PromptConfiguration, time = 0): string[] { +export function buildContextRail(context: PromptContext, width: number, configuration: PromptConfiguration, time = 0, report?: FitReport): string[] { const rail = configuration.contextRail; if (configuration.provider !== 'nmsh' || rail.mode === 'off') return []; const saved = themeContext(); @@ -682,10 +683,15 @@ export function buildContextRail(context: PromptContext, width: number, configur compact[0] = true; } } + if (report) for (const group of groups) { + const index = rows.findIndex(row => row.includes(group[0]!)); + if (index === -1) report.dropped.add(group[0]!.id); else if (compact[index]) report.compacted.add(group[0]!.id); + } const paintedRows = rows.map((blocks, index) => { if (!blocks.length || width <= 0) return ''; const painted = paint(blocks, compact[index]!); if (displayWidth(painted) <= width) return painted; + for (const block of blocks) report?.compacted.add(block.id); if (mirrored) return fitRightPowerlineBlocks([...blocks].reverse(), width, fitted => paintPhysical(fitted, true)); return fitPowerlineBlocks(blocks, 0, 0, width, nmsh.endStyle, false, nmsh.startStyle, nmsh.connector, resolveConnectorFade('off', nmsh.connector), nmsh.connectorFadeColors, promptRenderExtras(draft, time)); diff --git a/src/prompt/railComposition.ts b/src/prompt/railComposition.ts index 0225491e..0eb5026e 100644 --- a/src/prompt/railComposition.ts +++ b/src/prompt/railComposition.ts @@ -1,6 +1,7 @@ import {colorLevel} from '../presentation/capabilities.js'; import type {PromptContext} from '../shell/ShellContext.js'; import type {PromptConfiguration} from './configuration.js'; +import type {FitReport} from './fitReport.js'; import {buildContextParts, buildContextRail, buildInlineContextPrefix, buildRightContext} from './prompt.js'; import {railPresentation, type RailPresentation} from './railLayout.js'; import {planScreen, regionOf, type ScreenPlan} from '../app/screenPlan.js'; @@ -12,11 +13,11 @@ const RESET = '\u001b[0m'; export interface PreparedRail {presentation: RailPresentation; content: string[]; time: number} /** Facts arrive resolved. This projection never requests or collects a value. */ -export function prepareRail(context: PromptContext, columns: number, configuration: PromptConfiguration, time = 0): PreparedRail { +export function prepareRail(context: PromptContext, columns: number, configuration: PromptConfiguration, time = 0, report?: FitReport): PreparedRail { const enabled = configuration.provider === 'nmsh' && configuration.contextRail.mode !== 'off'; const parts = buildContextParts(context, columns, configuration, time); const budget = railPresentation(configuration, enabled ? configuration.contextRail.rows : 0, columns, parts); - const content = enabled && !(budget.width === 0 && configuration.contextRail.mode === 'auto') ? buildContextRail(context, budget.width, configuration, time) : []; + const content = enabled && !(budget.width === 0 && configuration.contextRail.mode === 'auto') ? buildContextRail(context, budget.width, configuration, time, report) : []; return {content, time, presentation: railPresentation(configuration, content.length, columns, parts)}; } diff --git a/src/status/StatusStrip.ts b/src/status/StatusStrip.ts index 5297df13..40a48d6e 100644 --- a/src/status/StatusStrip.ts +++ b/src/status/StatusStrip.ts @@ -1,4 +1,5 @@ import {linuxBattery, parseMeminfo, parsePmset, parseVmStat, type SystemStats} from './systemStats.js'; +import type {FitReport} from '../prompt/fitReport.js'; import type {StatusStripSettings, StripZone} from '../prompt/configuration.js'; import type {ContextFacts} from '../context/facts.js'; import {safeContextText} from '../context/facts.js'; @@ -97,6 +98,8 @@ interface Entry { tone: 'secondary' | 'failure' | 'accent'; /** Keep Awake narrows only after everything else has gone (it outranks every other item). */ last?: boolean; + /** The routed module this entry shows, for the fit report. */ + id?: string; } /** Hostile module text loses whole control sequences, then is bounded like every other display string. */ @@ -105,7 +108,7 @@ const clean = (text: string) => safeContextText(stripTerminalControls(text), 120 function entries(settings: StatusStripSettings, stats: SystemStats, now: Date | undefined, awake: StripAwake | undefined, modules: readonly StripModuleItem[]): Entry[] { // Routed modules outlast the strip's own items; among them, higher module priority stays longest. const routed: Entry[] = modules.map(module => ({zone: module.zone ?? 'right', forms: [...new Set([clean(module.text), ...(module.compact ?? []).map(clean)])].filter(Boolean), - form: 0, rank: -1 - module.priority / 1000, tone: module.failure ? 'failure' as const : 'secondary' as const})).filter(entry => entry.forms.length); + form: 0, rank: -1 - module.priority / 1000, tone: module.failure ? 'failure' as const : 'secondary' as const, ...(module.id ? {id: module.id} : {})})).filter(entry => entry.forms.length); const native: Entry[] = stripItems(settings, stats, now).map(item => { const icon = item.icon ? semanticIcon(item.icon) : ''; return {zone: settings.nativeZone, forms: item.compact ? [item.text, item.compact] : [item.text], form: 0, ...(icon ? {icon} : {}), rank: item.priority, tone: 'secondary' as const}; @@ -191,10 +194,17 @@ function fit(all: Entry[], settings: StatusStripSettings, columns: number): Entr * room and otherwise pushed between its neighbours. */ export function renderStatusStrip(settings: StatusStripSettings, stats: SystemStats & {now?: number}, columns: number, now?: Date, awake?: StripAwake, - modules: readonly StripModuleItem[] = []): string { - if (!settings.enabled || columns < STRIP_MIN_COLUMNS) return ''; + modules: readonly StripModuleItem[] = [], report?: FitReport): string { + if (!settings.enabled) return ''; + if (columns < STRIP_MIN_COLUMNS) { for (const module of modules) if (module.id) report?.dropped.add(module.id); return ''; } const time = now ?? (stats.now !== undefined ? new Date(stats.now) : undefined); - const list = fit(entries(settings, stats, time, awake, modules), settings, columns); + const candidates = entries(settings, stats, time, awake, modules); + const list = fit(candidates, settings, columns); + if (report) for (const entry of candidates) { + if (!entry.id) continue; + const kept = list.find(item => item.id === entry.id); + if (!kept) report.dropped.add(entry.id); else if (kept.form > 0) report.compacted.add(entry.id); + } if (!list.length) return ''; const group = (zone: StripZone) => list.filter(entry => entry.zone === zone); const [left, center, right] = ZONES.map(zone => ({zone, painted: paintZone(zone, group(zone), settings), width: zoneWidth(zone, group(zone), settings)})); diff --git a/tests/moduleReport.test.ts b/tests/moduleReport.test.ts index c593bbfb..798fa72a 100644 --- a/tests/moduleReport.test.ts +++ b/tests/moduleReport.test.ts @@ -42,3 +42,47 @@ test('timings: the slowest last collections first, bounded, honest when nothing assert.match(rows[3]!, /^… 1 faster ones not listed$/u); assert.match(rows.at(-1)!, /excluding time queued; nothing was collected/u); }); + +import {newFitReport} from '../src/prompt/fitReport.js'; +import {buildContextRail} from '../src/prompt/prompt.js'; +import {DEFAULT_PROMPT_CONFIGURATION, DEFAULT_STATUS_STRIP} from '../src/prompt/configuration.js'; +import {renderStatusStrip} from '../src/status/StatusStrip.js'; + +test('the Status Strip reports which routed modules its width fitting shortened or left out', () => { + const settings = {...DEFAULT_STATUS_STRIP, enabled: true, clock: false, cpu: false, ram: false, uptime: false, battery: false}; + const modules = [ + {id: 'gitBranch', text: 'feature/a-rather-long-branch-name', priority: 90, compact: ['feature/a-r…']}, + {id: 'nodeVersion', text: 'node 22.11.0', priority: 40}, + {id: 'time', text: '06:44:12 local time', priority: 10}, + ]; + const wide = newFitReport(); + renderStatusStrip(settings, {}, 160, undefined, undefined, modules, wide); + assert.deepEqual([...wide.dropped, ...wide.compacted], [], 'everything fits: nothing to explain'); + const narrow = newFitReport(); + renderStatusStrip(settings, {}, 36, undefined, undefined, modules, narrow); + assert.ok(narrow.dropped.has('time') && !narrow.dropped.has('gitBranch'), 'the lowest priority goes first'); + assert.ok(narrow.compacted.has('gitBranch'), 'a narrower form is used before anything with a higher priority drops'); + const tiny = newFitReport(); + renderStatusStrip(settings, {}, 10, undefined, undefined, modules, tiny); + assert.deepEqual([...tiny.dropped].sort(), ['gitBranch', 'nodeVersion', 'time'], 'a strip too narrow to show shows none of them'); +}); + +test('the Context Rail reports the modules its priority fitting left out; explain says so in words', () => { + const configuration = structuredClone(DEFAULT_PROMPT_CONFIGURATION); + configuration.contextRail.mode = 'always'; + configuration.modules = [{id: 'project', visible: true, condition: 'always', surface: 'contextRail'}, + {id: 'exitStatus', visible: true, condition: 'always', surface: 'contextRail'}]; + const context = {cwd: '/qa', project: 'A-project-name-that-is-long', exitStatus: 42}; + const wide = newFitReport(); + buildContextRail(context, 120, configuration, 0, wide); + assert.equal(wide.dropped.size, 0); + const narrow = newFitReport(); + buildContextRail(context, 12, configuration, 0, narrow); + assert.ok(narrow.dropped.size + narrow.compacted.size > 0, 'something had to give at 12 columns'); + const fit = (id: string) => narrow.dropped.has(id) ? 'dropped' as const : narrow.compacted.has(id) ? 'compacted' as const : undefined; + const rows = explainModules(configuration.modules, {conditionMet: () => true}, configuration, 0, fit).join('\n'); + for (const id of narrow.dropped) assert.match(rows, new RegExp(`\\(${id}\\) · Context Rail · .* · left out for width in the last frame`, 'u')); + for (const id of narrow.compacted) assert.match(rows, new RegExp(`\\(${id}\\) · Context Rail · .* · shortened to fit the width in the last frame`, 'u')); + const off = explainModules([{...configuration.modules[0]!, visible: false}], {}, configuration, 0, () => 'dropped'); + assert.doesNotMatch(off[0]!, /left out for width/u, 'a module turned off is explained as off, not as cut for width'); +});