From 52f8324d76c8160729c92f7b74ad088b81ac1c9f Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:29:48 +0530 Subject: [PATCH] Render authored help as Markdown --- src/app/TerminalApp.ts | 8 +- src/help/helpContent.ts | 25 ++++ src/help/markdown.ts | 227 +++++++++++++++++++++++++++++++++ tests/authoredMarkdown.test.ts | 110 ++++++++++++++++ 4 files changed, 367 insertions(+), 3 deletions(-) create mode 100644 src/help/helpContent.ts create mode 100644 src/help/markdown.ts create mode 100644 tests/authoredMarkdown.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 4848fd9b..0b0230c8 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -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'; @@ -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 { diff --git a/src/help/helpContent.ts b/src/help/helpContent.ts new file mode 100644 index 00000000..cd64c7aa --- /dev/null +++ b/src/help/helpContent.ts @@ -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" } +\`\`\` +`); +} diff --git a/src/help/markdown.ts b/src/help/markdown.ts new file mode 100644 index 00000000..6363fee6 --- /dev/null +++ b/src/help/markdown.ts @@ -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'); +} + diff --git a/tests/authoredMarkdown.test.ts b/tests/authoredMarkdown.test.ts new file mode 100644 index 00000000..a981952e --- /dev/null +++ b/tests/authoredMarkdown.test.ts @@ -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'); +});