From 66b60f3ce76560b1b747f77c87ca2edb9d1cf416 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:00:53 +0530 Subject: [PATCH] NMSh's own lines wrap at words in narrow windows /copy confirmations, notices and completion rows split mid-word at narrow widths ('5 l' / 'ines'). Lines NMSh writes itself now break after a space, with continuation rows hanging under the text after a leading glyph; a word wider than the row still breaks by character. Each continuation row's indent covers only added padding, so a selection copies the line exactly. Shell output keeps the terminal's column wrapping. Live tests that read joined screen rows match these messages across a word break. --- CHANGELOG.md | 1 + src/output/TranscriptPresenter.ts | 6 ++-- src/output/viewport.ts | 60 +++++++++++++++++++++++++++++++ tests/liveHardening.test.ts | 2 +- tests/noticeWordWrap.test.ts | 40 +++++++++++++++++++++ tests/startupDiscovery.test.ts | 4 +-- 6 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 tests/noticeWordWrap.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ae92aa6..322d01a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ Context Engine platform, Status Strip 2.0, managed Claude Code, worktrees and a - An absolute executable path typed in the composer (`/Volumes/tools/adb devices`, `/bin/zsh`, `/usr/bin/env …`) runs in the shell exactly as typed instead of being refused as an unknown NMSh command, and keeps shell completion and suggestions. One text-only rule decides: a first word with a `/` after the leading one is a path (no NMSh command name contains one), so `/zsh` still switches shells while `/bin/zsh` runs; a directory used as a command gets the shell's own behavior. Verified end to end on zsh, Bash and Fish. - Fish: keys typed quickly around a `read` are no longer lost to NMSh's round trip. Fish's `read` is fish's own line editor, which queries the terminal and drops keys that arrive while it waits for the answer. NMSh now answers those queries itself, as it already did at fish's prompt, instead of routing them through the host terminal. For any program, keys never overtake the answer to a device-attributes query. Type-ahead while a fish `read` is still running can still be dropped by fish itself, as in any terminal (`docs/development/fish-typeahead.md`). - After a screen clear (`clear`, tmux, or any program writing `ESC[2J`/`ESC[3J`), fold rows such as `15 lines shown · Ctrl+O` no longer appear inside the next command's output, and Ctrl+O no longer folds another command's lines together with `/copy` and status rows. The clear removed the transcript's lines but left the earlier commands' records pointing at line numbers that the new commands then reused. Those records now go with their lines. A command that clears the screen owns, and `/copy` copies, what it writes afterwards. Transcripts saved before this fix are repaired when opened. A record is kept only if its range fits, its header lines are command lines and the lines it covers read as the output it recorded. The first record that fails marks an unrecorded clear, so it and everything older lose their fold control and `/copy N`; their lines stay visible. Full-screen programs, partial erases (`ESC[J`, `ESC[1J`, `ESC[K`) and progress redraws never clear the transcript. +- NMSh's own lines (`/copy` confirmations, notices, completion rows) wrap at word boundaries in narrow windows, with continuation rows indented under the text instead of splitting words such as `5 l`/`ines`. Copying a selection still gives the line exactly as stored. Shell output keeps the terminal's column wrapping. - `/copy` and `/copy N` copy only the command's own output (stdout and stderr, line breaks and spacing kept), never NMSh's completion row such as `✔ Completed · 21.6s · 15:42`; output that merely looks like one is kept. A command that printed nothing leaves the clipboard as it was and says so. The block actions already used the same source. - Ask's config edits no longer refuse files in a home folder or project reached through a symlink (macOS `/tmp` and `/var` are symlinks); a symlink leading out of them is still refused. diff --git a/src/output/TranscriptPresenter.ts b/src/output/TranscriptPresenter.ts index 7d1a79b3..bfdf5c79 100644 --- a/src/output/TranscriptPresenter.ts +++ b/src/output/TranscriptPresenter.ts @@ -3,7 +3,7 @@ import {askFoldLabel} from '../ask/transcriptSummary.js'; import {paintDivider, DEFAULT_TREATMENT_SETTINGS, type TreatmentSettings} from '../chroma/treatment.js'; import {HyperlinkPresenter} from './Hyperlinks.js'; import {type StyledLine} from './AnsiOutputParser.js'; -import {wrapStyledLine, type WrappedRow} from './viewport.js'; +import {wrapStyledLine, wrapStyledWords, type WrappedRow} from './viewport.js'; import {background, foreground, UI_COLORS, lazyForeground} from '../ui/palette.js'; import {mixRgb} from '../chroma/chroma.js'; import {GLYPHS} from '../ui/glyphs.js'; @@ -97,7 +97,9 @@ export class TranscriptPresenter { const owner = view.ownerOf(index); const context = owner === undefined ? undefined : view.historicalContexts.get(owner); const line = view.lines[index] ?? []; - return wrapStyledLine(this.hyperlinks ? this.links.line(line, context?.cwd) : line, width, this.hyperlinks); + // NMSh's own lines wrap at words; shell output wraps by column, as a terminal does. + const wrap = view.lineTypes.get(index) === 'metadata' ? wrapStyledWords : wrapStyledLine; + return wrap(this.hyperlinks ? this.links.line(line, context?.cwd) : line, width, this.hyperlinks); } private treatment = DEFAULT_TREATMENT_SETTINGS; diff --git a/src/output/viewport.ts b/src/output/viewport.ts index 0bbec302..e15c9b25 100644 --- a/src/output/viewport.ts +++ b/src/output/viewport.ts @@ -86,6 +86,66 @@ export function wrapStyledLine(line: StyledLine, width: number, hyperlinks = fal return rows; } +const isBlank = (cell: StyledCell | null | undefined) => cell === undefined || (cell !== null && cell.text === ' '); + +/** + * Word wrapping for NMSh's own lines (notices, confirmations, lifecycle rows): breaks fall after a space, and + * continuation rows hang under the text after a leading glyph (` ⎿ Copied … · 5` / ` lines`). A word wider than + * the row still breaks by character. Shell output never comes here: it wraps by column, as a terminal does. + * + * Content is exact: every cell lands on exactly one row, and each continuation row's `indent` covers only the + * padding NMSh added, so selection, which joins one line's rows and drops that indent, reads the line as stored. + * A space that falls at a break and does not fit is drawn as the last column of the next row's padding. + */ +export function wrapStyledWords(line: StyledLine, width: number, hyperlinks = false): WrappedRow[] { + if (width <= 0) return []; + const cellWidth = (cell: StyledCell | null | undefined) => cell === null ? 0 : (cell?.width ?? 1); + const total = line.reduce((sum, cell) => sum + cellWidth(cell), 0); + if (total <= width) return wrapStyledLine(line, width, hyperlinks); + // Hang under the text: leading blanks, then one glyph and its space when the line starts with one. + let lead = 0; + while (lead < line.length && isBlank(line[lead])) lead += 1; + const glyph = line[lead]; + if (glyph && glyph.text !== ' ' && glyph.width === 1 && isBlank(line[lead + 1])) lead += 2; + const hang = lead <= width / 2 ? lead : 0; + + const segments: Array<{cells: StyledLine; leadingSpace: boolean}> = []; + let start = 0; + let leadingSpace = false; + while (start < line.length) { + const available = segments.length === 0 ? width : width - hang; + let column = 0; + let breakAfter = -1; + let index = start; + for (; index < line.length; index += 1) { + const cell = line[index]; + if (column + cellWidth(cell) > available) break; + column += cellWidth(cell); + if (isBlank(cell)) breakAfter = index; + } + if (index >= line.length) { segments.push({cells: line.slice(start), leadingSpace}); break; } + if (isBlank(line[index]) && hang > 0) { + // The break falls on a space that does not fit: it becomes the last column of the next row's padding. + segments.push({cells: line.slice(start, index), leadingSpace}); + start = index + 1; + leadingSpace = true; + continue; + } + const end = breakAfter >= start ? breakAfter + 1 : Math.max(index, start + 1); + segments.push({cells: line.slice(start, end), leadingSpace}); + start = end; + leadingSpace = false; + } + return segments.flatMap((segment, position) => { + if (position === 0) return wrapStyledLine(segment.cells, width, hyperlinks); + const padding = ' '.repeat(hang); + // A carried space is content: it is the last padding column, outside the indent selection drops. + const indent = segment.leadingSpace ? hang - 1 : hang; + return wrapStyledLine(segment.cells, width - hang, hyperlinks) + .map(row => ({...row, ansi: `${padding}${row.ansi}`, plain: `${padding}${row.plain}`, ...(indent > 0 ? {indent} : {})})); + }); +} + export function wrapLines(lines: StyledLine[], width: number): WrappedRow[] { return lines.flatMap(line => wrapStyledLine(line, width)); } diff --git a/tests/liveHardening.test.ts b/tests/liveHardening.test.ts index dbbdd560..419dfd07 100644 --- a/tests/liveHardening.test.ts +++ b/tests/liveHardening.test.ts @@ -152,7 +152,7 @@ test('the live-session limit falls back factually and never ends detached sessio await until(async () => (await sandbox.sessions()).filter(s => s.state === 'detached').length === 2, 15000, '2 detached'); pids.push(...(await sandbox.sessions()).map(s => s.pid)); const third = sandbox.launch(['--new']); - await third.waitFor(/live sessions are already running \(the limit\)/); + await third.waitFor(/live\s+sessions\s+are\s+already\s+running\s+\(the\s+limit\)/); await third.waitFor(/cannot be detached or reattached/); await third.run('echo MODE=$NMSH_SESSION_MODE', /MODE=in-process/); assert.ok(pids.every(processAlive)); diff --git a/tests/noticeWordWrap.test.ts b/tests/noticeWordWrap.test.ts new file mode 100644 index 00000000..b888208e --- /dev/null +++ b/tests/noticeWordWrap.test.ts @@ -0,0 +1,40 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {OutputBuffer} from '../src/output/OutputBuffer.js'; +import {selectedText} from '../src/output/TranscriptSelection.js'; +import {displayWidth} from '../src/util/text.js'; + +const CONFIRMATION = 'Copied to clipboard · 113 characters · 5 lines'; +const LIFECYCLE = '✔ Completed · 1m 25s · 18s waiting for input (2 prompts) · 15:42'; + +const all = (rows: readonly unknown[]) => ({anchor: 0, head: rows.length - 1, dragging: false, moved: true, pointerY: 0}); + +test('NMSh confirmations wrap at words with a hanging indent, never mid-word', () => { + const output = new OutputBuffer(); + output.addFrontendInteraction('/copy', CONFIRMATION); + const rows = output.wrapped(46).map(row => row.plain); + assert.deepEqual(rows, ['❯ /copy', ' ⎿ Copied to clipboard · 113 characters · 5 ', ' lines']); + assert.deepEqual(output.wrapped(30).map(row => row.plain), ['❯ /copy', ' ⎿ Copied to clipboard · 113 ', ' characters · 5 lines']); +}); + +test('word-wrapped NMSh lines stay exact for selection and fit the width at every size', () => { + for (let width = 6; width <= 70; width += 1) { + const output = new OutputBuffer(); + output.addFrontendInteraction('/copy', CONFIRMATION); + output.addHistoryLine(LIFECYCLE); + output.addHistoryLine(' ⎿ 漢字の通知 · wide characters 漢字 stay whole'); + const rows = output.wrapped(width); + for (const row of rows) assert.ok(displayWidth(row.plain) <= width, `width ${width}: "${row.plain}"`); + assert.equal(selectedText(rows, all(rows)), + ['❯ /copy', ` ⎿ ${CONFIRMATION}`, '', LIFECYCLE, '', ' ⎿ 漢字の通知 · wide characters 漢字 stay whole'].join('\n'), `width ${width}`); + } +}); + +test('shell output keeps terminal column wrapping', () => { + const output = new OutputBuffer(); + output.beginCommand('echo', ['❯ echo']); + output.write(` ⎿ ${CONFIRMATION}\r\n`); + output.complete(0); + const rows = output.wrapped(28).map(row => row.plain); + assert.deepEqual(rows.slice(1), [' ⎿ Copied to clipboard · 11', '3 characters · 5 lines']); +}); diff --git a/tests/startupDiscovery.test.ts b/tests/startupDiscovery.test.ts index ab39cf43..fe834422 100644 --- a/tests/startupDiscovery.test.ts +++ b/tests/startupDiscovery.test.ts @@ -167,7 +167,7 @@ test('several detached sessions: pick some, Esc for none; this window owns one, picker.pty.write('\r'); await picker.waitFor(/Reattached live session/); assert.equal(await shellPid(picker), pids[2], 'this window takes the first selected'); - await picker.waitFor(/1 more selected live session need their own windows[\s\S]*they keep running/); + await picker.waitFor(/1\s+more\s+selected\s+live\s+session\s+need\s+their\s+own\s+windows[\s\S]*they\s+keep\s+running/); await detachedCount(sandbox, 2); assert.ok(pids.every(pid => processAlive(pid)), 'nothing selected or unselected was ended'); } finally { @@ -184,7 +184,7 @@ test('Open all restores every detached session: one here, the others as far as t assert.doesNotMatch(strip(all.output), /detached live sessions/, 'no picker'); assert.equal(await shellPid(all), pids[1]); // The test host cannot open windows: the other session is named with its attach command. - await all.waitFor(/nmsh --attach [\w-]+/); + await all.waitFor(/nmsh\s+--attach\s+[\w-]+/); await detachedCount(sandbox, 1); } finally { await sandbox.dispose();