Skip to content
Open
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 @@ -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.

Expand Down
6 changes: 4 additions & 2 deletions src/output/TranscriptPresenter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
60 changes: 60 additions & 0 deletions src/output/viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
2 changes: 1 addition & 1 deletion tests/liveHardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
40 changes: 40 additions & 0 deletions tests/noticeWordWrap.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
4 changes: 2 additions & 2 deletions tests/startupDiscovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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();
Expand Down
Loading