Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ import {copyFeedback, copyStats, writeClipboard} from '../clipboard/clipboard.js
import {shouldPassthrough} from '../passthrough/PassthroughPolicy.js';
import {layoutInput, graphemes} from '../input/inputLayout.js';
import {editText} from '../ui/formControls.js';
import {helpMarkdown} from '../help/helpContent.js';
import {renderMarkdownText} from '../help/markdown.js';
import {shimmerText} from '../status/shimmer.js';
import {isReducedMotion, presentationAnimationElapsed, presentationCompletionTime, presentationNow} from '../presentation/environment.js';
import {TaskProgress} from '../status/TaskProgress.js';
Expand Down Expand Up @@ -1384,9 +1386,9 @@ export class TerminalApp {
}

private showHelp(command: string): void {
const summary = slashCommands.map(item => `${item.name} — ${item.description}`).join(' · ');
const helpText = `${summary}\n\n${INFO}✻ Large multiline paste is one editable atom; Enter submits its original text. Press Ctrl+O beside it to inspect or unwrap.\n✻ Portable Select-All: Alt+A\n✻ VS Code Cmd+A Keybinding JSON:\n { "key": "cmd+a", "command": "workbench.action.terminal.sendSequence", "args": { "text": "\\u001b[97;9u" }, "when": "terminalFocus" }${RESET}`;
this.output.addFrontendInteraction(command, helpText, ACCENT);
const helpText = renderMarkdownText(helpMarkdown(), {columns: Math.max(20, this.dimensions().columns - 6),
hyperlinks: false}); // the transcript cell model has no OSC 8 support
this.output.addFrontendInteraction(command, helpText, INFO);
}

private onShellData(data: string): void {
Expand Down
25 changes: 25 additions & 0 deletions src/help/helpContent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {slashCommands} from '../commands/slashCommands.js';
import {authoredMarkdown, type AuthoredMarkdown} from './markdown.js';

/** The /help page. Every input is NMSh source (command table, fixed guidance); nothing comes from the shell. */
export function helpMarkdown(): AuthoredMarkdown {
const commands = slashCommands.map(item => `| \`${item.name}\` | ${item.description} |`).join('\n');
return authoredMarkdown(`# NMSh help

## Commands

| Command | What it does |
| --- | --- |
${commands}

## Tips

- A large multiline paste is **one editable atom**; Enter submits its original text. Press Ctrl+O beside it to inspect or unwrap.
- Portable Select-All is Alt+A.
- VS Code Cmd+A keybinding JSON:

\`\`\`
{ "key": "cmd+a", "command": "workbench.action.terminal.sendSequence", "args": { "text": "\\u001b[97;9u" }, "when": "terminalFocus" }
\`\`\`
`);
}
227 changes: 227 additions & 0 deletions src/help/markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import {backgroundOf, foregroundOf, theme} from '../chroma/chroma.js';
import {graphemes} from '../input/inputLayout.js';
import {colorLevel} from '../presentation/capabilities.js';
import {getCurrentGlyphMode} from '../ui/glyphs.js';
import {displayWidth, truncateAnsi} from '../util/text.js';

/**
* Renders explicitly NMSh-authored Markdown (help, setup guidance, provider
* docs, release notes) into styled terminal rows.
*
* SAFETY BOUNDARY: this is never applied to shell output, PTY streams,
* transcript command output, archived output or `/copy` data. The input type is
* branded so a plain string cannot be passed by accident; the only way in is
* `authoredMarkdown()`, called on content that ships in NMSh's own source.
* A test pins the modules allowed to import this file.
*/
declare const AUTHORED: unique symbol;
export type AuthoredMarkdown = string & {readonly [AUTHORED]: true};

export function authoredMarkdown(source: string): AuthoredMarkdown {
return source as AuthoredMarkdown;
}

export interface MarkdownOptions {
columns: number;
/** Emit OSC 8 hyperlinks; otherwise links read `text (url)`. */
hyperlinks?: boolean;
}

const RESET = '\u001B[0m';
const BOLD = '\u001B[1m';
const ITALIC = '\u001B[3m';
const UNDERLINE = '\u001B[4m';
const CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/gu;

/** Terminals known to implement OSC 8. `NMSH_HYPERLINKS=1|0` overrides detection. */
export function supportsHyperlinks(env: NodeJS.ProcessEnv = process.env): boolean {
if (env.NMSH_HYPERLINKS === '1') return true;
if (env.NMSH_HYPERLINKS === '0' || env.TERM === 'dumb') return false;
return ['ghostty', 'iTerm.app', 'WezTerm', 'vscode', 'Hyper'].includes(env.TERM_PROGRAM ?? '') || Boolean(env.KITTY_WINDOW_ID);
}

// ---- Inline ---------------------------------------------------------------

type Style = 'text' | 'code' | 'strong' | 'em' | 'link';
interface Piece {text: string; style: Style; url?: string}

const INLINE = /`([^`]+)`|\*\*([^*]+)\*\*|\*([^*\s][^*]*)\*|\[([^\]]+)\]\(([^)\s]+)\)/gu;

function safeUrl(url: string): string | undefined {
return /^https?:\/\/[^\s\u0000-\u001F\u007F]+$/u.test(url) ? url : undefined;
}

function parseInline(source: string): Piece[] {
const pieces: Piece[] = [];
let last = 0;
for (const match of source.matchAll(INLINE)) {
if (match.index! > last) pieces.push({text: source.slice(last, match.index), style: 'text'});
if (match[1] !== undefined) pieces.push({text: match[1], style: 'code'});
else if (match[2] !== undefined) pieces.push({text: match[2], style: 'strong'});
else if (match[3] !== undefined) pieces.push({text: match[3], style: 'em'});
else pieces.push({text: match[4]!, style: 'link', url: match[5]});
last = match.index! + match[0].length;
}
if (last < source.length) pieces.push({text: source.slice(last), style: 'text'});
return pieces;
}

function styleFor(style: Style): string {
switch (style) {
case 'code': return `${backgroundOf(theme('selection'))}${foregroundOf(theme('primary'))}`;
case 'strong': return BOLD;
case 'em': return ITALIC;
case 'link': return `${UNDERLINE}${foregroundOf(theme('accent'))}`;
case 'text': return '';
}
}

/** Plain-terminal fallback for code so it is never distinguished by color alone. */
function plainCode(text: string): string {
return colorLevel() === 'none' ? `\`${text}\`` : text;
}

interface Word {text: string; style: Style; url?: string; /** Attached to the previous word with no space between (e.g. punctuation after a link). */ glue: boolean}

function toWords(pieces: Piece[], hyperlinks: boolean): Word[] {
const words: Word[] = [];
let previousEndsSpace = true;
for (const piece of pieces) {
const url = piece.style === 'link' ? safeUrl(piece.url ?? '') : undefined;
const style: Style = piece.style === 'link' && !url ? 'text' : piece.style;
const parts = piece.style === 'code' ? [plainCode(piece.text)] : piece.text.split(/\s+/u).filter(Boolean);
const startsSpace = /^\s/u.test(piece.text);
parts.forEach((part, index) => words.push({text: part, style, url, glue: index === 0 && !startsSpace && !previousEndsSpace}));
if (parts.length) previousEndsSpace = piece.style !== 'code' && /\s$/u.test(piece.text);
if (piece.style === 'link' && url && !hyperlinks) {
words.push({text: `(${url})`, style: 'text', glue: false});
previousEndsSpace = false;
}
}
return words;
}

function paintWord(word: Word, hyperlinks: boolean): string {
const style = styleFor(word.style);
const body = word.style === 'link' && word.url && hyperlinks ? `\u001B]8;;${word.url}\u001B\\${word.text}\u001B]8;;\u001B\\` : word.text;
return style ? `${style}${body}${RESET}` : body;
}

/** Word-wraps inline Markdown into rows no wider than `width`; every row carries its own resets. */
function wrapInline(source: string, width: number, hyperlinks: boolean, firstIndent: string, restIndent: string): string[] {
const words = toWords(parseInline(source), hyperlinks);
const rows: string[] = [];
let line = firstIndent;
let used = displayWidth(firstIndent);
let empty = true;
const push = () => { rows.push(line); line = restIndent; used = displayWidth(restIndent); empty = true; };
for (const word of words) {
const size = displayWidth(word.text);
const gap = word.glue ? 0 : 1;
if (!empty && used + gap + size > width) push();
if (empty && used + size > width) {
// A single word wider than the row is hard-split rather than overflowing.
let chunk = '';
for (const glyph of graphemes(word.text)) {
if (used + displayWidth(chunk + glyph) > width && chunk) { line += paintWord({...word, text: chunk}, hyperlinks); push(); chunk = ''; }
chunk += glyph;
}
line += paintWord({...word, text: chunk}, hyperlinks);
used += displayWidth(chunk);
empty = false;
continue;
}
if (!empty && !word.glue) { line += ' '; used += 1; }
line += paintWord(word, hyperlinks);
used += size;
empty = false;
}
if (!empty || rows.length === 0) rows.push(line);
return rows;
}

// ---- Blocks ---------------------------------------------------------------

function plainInline(source: string): string {
return parseInline(source).map(piece => piece.text).join('');
}

function splitRow(line: string): string[] {
return line.trim().replace(/^\|/u, '').replace(/\|$/u, '').split('|').map(cell => cell.trim());
}

function renderTable(rows: string[][], width: number, hyperlinks: boolean): string[] {
const [header, ...body] = rows;
const widths = header!.map((_, column) => Math.max(...rows.map(row => displayWidth(plainInline(row[column] ?? '')))));
const total = widths.reduce((sum, value) => sum + value, 0) + 3 * (widths.length - 1) + 2;
if (total > width) {
// Too narrow for columns: each row becomes a labelled list.
return body.flatMap(row => wrapInline(row.map((cell, column) => (column === 0 ? `**${cell}**` : cell)).join(' — '), width, hyperlinks, ' ', ' '));
}
const cellText = (cell: string, column: number, bold: boolean) => {
const rendered = wrapInline(bold ? `**${cell}**` : cell, widths[column]!, hyperlinks, '', '')[0] ?? '';
return rendered + ' '.repeat(Math.max(0, widths[column]! - displayWidth(rendered)));
};
const line = (row: string[], bold: boolean) => ` ${header!.map((_, column) => cellText(row[column] ?? '', column, bold)).join(' │ ')}`
.replace(/│/gu, getCurrentGlyphMode() === 'nerd' ? '│' : '|').trimEnd();
const rule = ` ${widths.map(size => (getCurrentGlyphMode() === 'nerd' ? '─' : '-').repeat(size)).join(getCurrentGlyphMode() === 'nerd' ? '─┼─' : '-+-')}`;
return [line(header!, true), rule, ...body.map(row => line(row, false))];
}

/** Rows for `source`, each at most `columns` wide. Paragraphs wrap; code and tables never reflow. */
export function renderMarkdown(source: AuthoredMarkdown, options: MarkdownOptions): string[] {
const width = Math.max(8, options.columns);
const hyperlinks = options.hyperlinks ?? supportsHyperlinks();
const lines = source.replace(/\r\n?/gu, '\n').replace(CONTROL, match => (match === '\t' ? ' ' : '')).split('\n');
const out: string[] = [];
const gap = () => { if (out.length && out[out.length - 1] !== '') out.push(''); };
const bullet = getCurrentGlyphMode() === 'nerd' ? '•' : '-';
let paragraph: string[] = [];
const flush = () => {
if (paragraph.length) out.push(...wrapInline(paragraph.join(' '), width, hyperlinks, '', ''));
paragraph = [];
};

for (let index = 0; index < lines.length; index += 1) {
const line = lines[index]!;
const heading = /^(#{1,3})\s+(.*)$/u.exec(line);
const item = /^(\s*)([-*]|\d+\.)\s+(.*)$/u.exec(line);
if (/^```/u.test(line)) {
flush();
const code: string[] = [];
for (index += 1; index < lines.length && !/^```/u.test(lines[index]!); index += 1) code.push(lines[index]!);
gap();
out.push(...code.map(row => truncateAnsi(` ${foregroundOf(theme('secondary'))}${row}${RESET}`, width)));
out.push('');
} else if (heading) {
flush();
gap();
const role = heading[1]!.length === 1 ? 'accent' : heading[1]!.length === 2 ? 'primary' : 'secondary';
out.push(truncateAnsi(`${BOLD}${foregroundOf(theme(role))}${plainInline(heading[2]!)}${RESET}`, width));
} else if (/^\s*\|/u.test(line) && /^\s*\|[\s:|-]+\|\s*$/u.test(lines[index + 1] ?? '')) {
flush();
const table = [splitRow(line)];
for (index += 2; index < lines.length && /^\s*\|/u.test(lines[index]!); index += 1) table.push(splitRow(lines[index]!));
index -= 1;
gap();
out.push(...renderTable(table, width, hyperlinks), '');
} else if (item) {
flush();
const marker = /\d/u.test(item[2]!) ? item[2]! : bullet;
const indent = `${item[1]!.replace(/\t/gu, ' ')}${marker} `;
out.push(...wrapInline(item[3]!, width, hyperlinks, indent, ' '.repeat(displayWidth(indent))));
} else if (line.trim() === '') {
flush();
gap();
} else paragraph.push(line.trim());
}
flush();
while (out.length && out[out.length - 1] === '') out.pop();
return out.map(row => truncateAnsi(row, width));
}

/** Rendered rows joined for the transcript's multi-line result slot. */
export function renderMarkdownText(source: AuthoredMarkdown, options: MarkdownOptions): string {
return renderMarkdown(source, options).join('\n');
}

110 changes: 110 additions & 0 deletions tests/authoredMarkdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import assert from 'node:assert/strict';
import {readdirSync, readFileSync, statSync} from 'node:fs';
import {join} from 'node:path';
import test from 'node:test';
import {helpMarkdown} from '../src/help/helpContent.js';
import {authoredMarkdown, renderMarkdown, supportsHyperlinks} from '../src/help/markdown.js';
import {slashCommands} from '../src/commands/slashCommands.js';
import {displayWidth, stripAnsi} from '../src/util/text.js';

const render = (source: string, columns = 40, hyperlinks = false) => renderMarkdown(authoredMarkdown(source), {columns, hyperlinks});
const plain = (rows: string[]) => rows.map(stripAnsi);

test('headings, paragraphs and emphasis', () => {
const rows = plain(render('# Title\n\nSome **bold** and *italic* and `code` text.\n\n## Next\nmore'));
assert.deepEqual(rows, ['Title', '', 'Some bold and italic and code text.', '', 'Next', 'more']);
});

test('paragraphs wrap to the width and never exceed it', () => {
const rows = render('word '.repeat(40), 22);
assert.ok(rows.length > 5);
for (const row of rows) assert.ok(displayWidth(row) <= 22);
const long = render('x'.repeat(100), 15);
for (const row of long) assert.ok(displayWidth(row) <= 15);
assert.equal(plain(long).join(''), 'x'.repeat(100));
});

test('lists keep hanging indents', () => {
const rows = plain(render('- first item that wraps around\n- second\n1. numbered', 16));
assert.equal(rows[0], '• first item');
assert.ok(rows[1]!.startsWith(' '));
assert.ok(rows.includes('• second'));
assert.ok(rows.includes('1. numbered'));
});

test('code blocks are literal and not reflowed or interpreted', () => {
const rows = plain(render('```\n**not bold** [x](http://a.b)\n```', 60));
assert.deepEqual(rows, [' **not bold** [x](http://a.b)']);
});

test('tables align columns and fall back to labelled rows when narrow', () => {
const source = '| Command | Meaning |\n| --- | --- |\n| `/a` | first |\n| `/bb` | second |';
const wide = plain(render(source, 40));
assert.equal(wide.length, 4);
assert.match(wide[0]!, /^ {2}Command │ Meaning$/u);
assert.match(wide[2]!, /^ {2}\/a {6}│ first$/u);
const narrow = render(source, 12);
for (const row of narrow) assert.ok(displayWidth(row) <= 12);
assert.ok(plain(narrow).join(' ').includes('/bb'));
});

test('links use OSC 8 only when enabled and only for http(s)', () => {
const on = render('See [docs](https://example.com/a).', 60, true).join('');
assert.ok(on.includes('\u001B]8;;https://example.com/a\u001B\\'));
const off = plain(render('See [docs](https://example.com/a).', 60, false));
assert.equal(off[0], 'See docs (https://example.com/a).');
const unsafe = render('[x](javascript:alert) [y](https://a.b/\u001B]8;;)', 60, true).join('');
assert.ok(!unsafe.includes('\u001B]8;;javascript'));
assert.ok(!/\u001B\]8;;https:\/\/a\.b\/\u001B/u.test(unsafe));
});

test('control sequences in source cannot reach the terminal', () => {
const rows = render('hi \u001B[2J\u001B]0;title\u0007 there\u0000', 60);
assert.ok(!rows.join('').includes('\u001B[2J'));
assert.ok(!rows.join('').includes('\u0007'));
});

test('no-color path carries no color escapes and keeps code visible', () => {
const saved = process.env.NO_COLOR;
try {
process.env.NO_COLOR = '1';
const rows = render('# H\n`code` and **b**', 40);
assert.ok(rows.every(row => !/\u001B\[(38|48);/u.test(row)));
assert.ok(plain(rows).join('\n').includes('`code`'));
} finally {
if (saved === undefined) delete process.env.NO_COLOR; else process.env.NO_COLOR = saved;
}
});

test('hyperlink support detection', () => {
assert.equal(supportsHyperlinks({TERM_PROGRAM: 'ghostty'}), true);
assert.equal(supportsHyperlinks({TERM_PROGRAM: 'Apple_Terminal'}), false);
assert.equal(supportsHyperlinks({TERM_PROGRAM: 'Apple_Terminal', NMSH_HYPERLINKS: '1'}), true);
assert.equal(supportsHyperlinks({TERM_PROGRAM: 'ghostty', NMSH_HYPERLINKS: '0'}), false);
});

test('/help renders every command and fits narrow terminals', () => {
for (const columns of [20, 40, 100]) {
const rows = renderMarkdown(helpMarkdown(), {columns, hyperlinks: false});
for (const row of rows) assert.ok(displayWidth(row) <= columns);
const text = plain(rows).join('\n');
for (const command of slashCommands) assert.ok(text.includes(command.name.split(' ')[0]!), command.name);
assert.ok(text.includes('Alt+A'));
}
});

test('the Markdown renderer is imported only by NMSh-owned help code', () => {
const allowed = new Set(['src/help/helpContent.ts', 'src/app/TerminalApp.ts', 'src/help/markdown.ts']);
const offenders: string[] = [];
const walk = (dir: string) => {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) walk(path);
else if (path.endsWith('.ts') && /help\/markdown\.js/u.test(readFileSync(path, 'utf8')) && !allowed.has(path)) offenders.push(path);
}
};
walk('src');
assert.deepEqual(offenders, []);
const app = readFileSync('src/app/TerminalApp.ts', 'utf8');
assert.equal((app.match(/renderMarkdownText\(/gu) ?? []).length, 1, 'only /help renders authored Markdown');
});
Loading