tokenmaw 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { readFile, writeFile, readdir, mkdir, stat, rename, rm } from 'node:fs/promises';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
- import { exec, execFile } from 'node:child_process';
3
+ import { exec, execFile, spawn } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
  import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'node:path';
6
6
  import { ToolRegistry } from '../tools/registry.js';
@@ -63,6 +63,36 @@ async function writeViaWorkspace(targetPath, content, ctx) {
63
63
  await atomicWrite(absoluteTarget, content);
64
64
  return absoluteTarget;
65
65
  }
66
+ /**
67
+ * Staged, all-or-nothing write: content goes to a staging file next to the
68
+ * target, is verified from disk, and only then is atomically swapped in via
69
+ * rename. The target is never observable in a partially written or unverified
70
+ * state, and any failure (including failed readback verification) leaves the
71
+ * target byte-for-byte unchanged — no post-failure restore pass needed.
72
+ */
73
+ async function stagedWrite(path, content, ctx, verify) {
74
+ const absoluteTarget = resolveWriteTarget(path, ctx);
75
+ const root = workspaceRoot(ctx);
76
+ const rel = absoluteTarget.startsWith(root)
77
+ ? relative(root, absoluteTarget)
78
+ : join('__external__', absoluteTarget.replace(/^([a-zA-Z]:)?[/\\]+/, ''));
79
+ const workspacePath = join(root, '.agent-workspace', rel);
80
+ const staging = `${absoluteTarget}.${process.pid}.${randomUUID()}.staging`;
81
+ await mkdir(dirname(absoluteTarget), { recursive: true });
82
+ try {
83
+ await writeFile(staging, content, 'utf8');
84
+ const written = await readFile(staging, 'utf8');
85
+ await verify(written);
86
+ await mkdir(dirname(workspacePath), { recursive: true });
87
+ await atomicWrite(workspacePath, content);
88
+ await rename(staging, absoluteTarget);
89
+ }
90
+ catch (error) {
91
+ await rm(staging, { force: true }).catch(() => undefined);
92
+ throw error;
93
+ }
94
+ return absoluteTarget;
95
+ }
66
96
  async function atomicWrite(path, content) {
67
97
  const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
68
98
  await writeFile(temp, content, 'utf8');
@@ -196,6 +226,54 @@ function closestLineHints(content, search) {
196
226
  return hints;
197
227
  return lineNumbersOf(content, firstLine.slice(0, 24), 3);
198
228
  }
229
+ function normalizedForSimilarity(value) {
230
+ return value.replace(/\r\n/g, '\n').replace(/[\t ]+/g, ' ').trim();
231
+ }
232
+ function editDistance(a, b) {
233
+ if (a === b)
234
+ return 0;
235
+ if (!a.length)
236
+ return b.length;
237
+ if (!b.length)
238
+ return a.length;
239
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
240
+ for (let i = 1; i <= a.length; i += 1) {
241
+ const current = [i];
242
+ for (let j = 1; j <= b.length; j += 1) {
243
+ current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
244
+ }
245
+ previous = current;
246
+ }
247
+ return previous[b.length];
248
+ }
249
+ function closestMatch(content, search) {
250
+ const requested = search.split('\n');
251
+ const lines = content.split('\n');
252
+ const count = requested.length;
253
+ if (!search.trim() || !lines.length)
254
+ return undefined;
255
+ let best;
256
+ for (let i = 0; i <= lines.length - count; i += 1) {
257
+ const matched = lines.slice(i, i + count).join('\n');
258
+ const left = normalizedForSimilarity(search);
259
+ const right = normalizedForSimilarity(matched);
260
+ const max = Math.max(left.length, right.length, 1);
261
+ const similarity = 1 - editDistance(left, right) / max;
262
+ if (!best || similarity > best.similarity)
263
+ best = { line: i + 1, matched, similarity };
264
+ }
265
+ return best;
266
+ }
267
+ function diagnosticDiff(requested, matched) {
268
+ const requestedLines = requested.split('\n');
269
+ const matchedLines = matched.split('\n');
270
+ const lines = ['```diff', '- requested'];
271
+ lines.push(...requestedLines.map((line) => `- ${line}`));
272
+ lines.push('+ matched');
273
+ lines.push(...matchedLines.map((line) => `+ ${line}`));
274
+ lines.push('```');
275
+ return lines.join('\n');
276
+ }
199
277
  function stripReadFileLineNumbers(search) {
200
278
  const lines = search.split('\n');
201
279
  const contentLines = lines.filter((line) => !line.startsWith('... (showing lines '));
@@ -258,7 +336,17 @@ export function getToolPolicy() {
258
336
  return clonePolicy(defaultToolPolicy);
259
337
  }
260
338
  async function withWriteLock(ctx, path, action) {
261
- const release = await ctx?.acquireWriteLock?.(path);
339
+ if (!ctx) {
340
+ // Bare toolkit call without a runtime context (direct library use): no
341
+ // lock service exists to consult. Documented as unlocked.
342
+ return action();
343
+ }
344
+ if (!ctx.acquireWriteLock) {
345
+ // Never write unlocked when a runtime context is present: a silent
346
+ // unlocked write would lose updates against concurrent processes.
347
+ throw new Error(`write lock unavailable for ${path}: the runtime context did not provide acquireWriteLock; refusing unlocked write`);
348
+ }
349
+ const release = await ctx.acquireWriteLock(path);
262
350
  try {
263
351
  return await action();
264
352
  }
@@ -535,8 +623,7 @@ function parseStringArray(value) {
535
623
  return undefined;
536
624
  }
537
625
  }
538
- async function readLineRange(filePath, offset = 1, limit) {
539
- const raw = await readFile(filePath, 'utf8');
626
+ function formatLineRange(raw, offset = 1, limit) {
540
627
  if (raw === '')
541
628
  return '';
542
629
  const allLines = raw.split('\n');
@@ -548,11 +635,17 @@ async function readLineRange(filePath, offset = 1, limit) {
548
635
  ? `${numbered}\n... (showing lines ${startLine}-${endLine} of ${totalLines}; use offset/limit to read more)`
549
636
  : numbered;
550
637
  }
638
+ async function readLineRange(filePath, offset = 1, limit) {
639
+ return formatLineRange(await readFile(filePath, 'utf8'), offset, limit);
640
+ }
551
641
  function boundedOutput(value, maxChars = 4 * 1024 * 1024) {
552
642
  if (value.length <= maxChars)
553
643
  return value;
554
644
  return `${value.slice(0, maxChars)}\n... (output truncated at ${maxChars} characters)`;
555
645
  }
646
+ function contentVersion(content) {
647
+ return createHash('sha256').update(content).digest('hex');
648
+ }
556
649
  // ── Pure-Node search fallback (used when `rg` is not installed) ──────────────
557
650
  // Mirrors the rg invocations in `search_text`/`search_files`: hidden files are
558
651
  // included, only `.git` and `node_modules` are skipped.
@@ -934,7 +1027,10 @@ async function executeBuiltinTool(name, args, ctx) {
934
1027
  const offsetArg = typeof args['offset'] === 'number' ? args['offset'] : undefined;
935
1028
  const limitArg = typeof args['limit'] === 'number' ? args['limit'] : undefined;
936
1029
  try {
937
- return await readLineRange(resolveToolPath(path, ctx), offsetArg, limitArg);
1030
+ const targetPath = resolveToolPath(path, ctx);
1031
+ const content = await readFile(targetPath, 'utf8');
1032
+ ctx?.recordReadVersion?.(targetPath, contentVersion(content));
1033
+ return formatLineRange(content, offsetArg, limitArg);
938
1034
  }
939
1035
  catch (error) {
940
1036
  return `Error reading file: ${String(error)}`;
@@ -955,7 +1051,10 @@ async function executeBuiltinTool(name, args, ctx) {
955
1051
  continue;
956
1052
  }
957
1053
  try {
958
- sections.push(`===== ${path} =====\n${await readLineRange(resolveToolPath(path, ctx), 1, maxLines)}`);
1054
+ const targetPath = resolveToolPath(path, ctx);
1055
+ const content = await readFile(targetPath, 'utf8');
1056
+ ctx?.recordReadVersion?.(targetPath, contentVersion(content));
1057
+ sections.push(`===== ${path} =====\n${formatLineRange(content, 1, maxLines)}`);
959
1058
  }
960
1059
  catch (error) {
961
1060
  sections.push(`===== ${path} =====\nError reading file: ${String(error)}`);
@@ -1006,6 +1105,16 @@ async function executeBuiltinTool(name, args, ctx) {
1006
1105
  catch (error) {
1007
1106
  return `Error reading file for edit: ${String(error)}`;
1008
1107
  }
1108
+ const currentVersion = contentVersion(src);
1109
+ if (ctx?.requirePriorRead) {
1110
+ const readVersion = ctx.getReadVersion?.(targetPath);
1111
+ if (!readVersion) {
1112
+ return `Error: edit_file requires a prior read_file of ${path} in this session. Read the file, then retry the edit.`;
1113
+ }
1114
+ if (readVersion !== currentVersion) {
1115
+ return `Error: edit_file read lease is stale for ${path}; the file changed after it was read. Read it again before editing.`;
1116
+ }
1117
+ }
1009
1118
  let parsed;
1010
1119
  try {
1011
1120
  if (Array.isArray(editsArg)) {
@@ -1022,13 +1131,18 @@ async function executeBuiltinTool(name, args, ctx) {
1022
1131
  catch (error) {
1023
1132
  return `Error parsing edits JSON: ${String(error)}`;
1024
1133
  }
1025
- const noMatchError = (index, search) => {
1026
- const hints = closestLineHints(src, search);
1134
+ const noMatchError = (index, search, haystack = content) => {
1135
+ const hints = closestLineHints(haystack, search);
1027
1136
  const hint = hints.length > 0 ? `\nFirst line of the search loosely appears near lines: ${hints.join(', ')}.` : '';
1028
- return `Error: edit[${index}]: Could not find old text in ${path}. It must match exactly, including whitespace, indentation, and line endings.\nSearch string was:\n${search}${hint}`;
1137
+ const closest = closestMatch(haystack, search);
1138
+ const detail = closest
1139
+ ? `\nClosest normalized window: lines ${closest.line}-${closest.line + search.split('\n').length - 1} (similarity ${closest.similarity.toFixed(3)}).\n${diagnosticDiff(search, closest.matched)}`
1140
+ : '';
1141
+ return `Error: edit[${index}]: Could not find old text in ${path}. It must match exactly, including whitespace, indentation, and line endings.\nSearch string was:\n${search}${hint}${detail}\nNext action: resubmit the exact matched text.`;
1029
1142
  };
1030
1143
  let content = src;
1031
1144
  const log = [];
1145
+ const diagnostics = [];
1032
1146
  const applied = [];
1033
1147
  for (let i = 0; i < parsed.length; i += 1) {
1034
1148
  const entry = parsed[i];
@@ -1047,10 +1161,12 @@ async function executeBuiltinTool(name, args, ctx) {
1047
1161
  const numberedSearch = stripReadFileLineNumbers(search);
1048
1162
  const unescapedSearch = unescapeEscapes(search);
1049
1163
  const variants = [search];
1050
- if (numberedSearch !== undefined && numberedSearch !== search && !variants.includes(numberedSearch))
1051
- variants.push(numberedSearch);
1052
- if (unescapedSearch !== search && !variants.includes(unescapedSearch))
1053
- variants.push(unescapedSearch);
1164
+ if (!replaceAll) {
1165
+ if (numberedSearch !== undefined && numberedSearch !== search && !variants.includes(numberedSearch))
1166
+ variants.push(numberedSearch);
1167
+ if (unescapedSearch !== search && !variants.includes(unescapedSearch))
1168
+ variants.push(unescapedSearch);
1169
+ }
1054
1170
  if (replaceAll) {
1055
1171
  let usedVariant;
1056
1172
  let count = 0;
@@ -1096,33 +1212,52 @@ async function executeBuiltinTool(name, args, ctx) {
1096
1212
  content = content.replace(matched, effectiveReplace);
1097
1213
  applied.push({ search: matched, replace: effectiveReplace, made: 1 });
1098
1214
  const normalizedLineNumbers = numberedSearch !== undefined && matchedVariant === numberedSearch;
1099
- log.push(`edit[${i}]: replaced ${matched.length} chars via ${outcome.strategy}${normalizedLineNumbers ? ' (line-number normalized)' : ''}`);
1215
+ log.push(`edit[${i}]: replaced ${matched.length} chars via ${outcome.strategy}${normalizedLineNumbers ? ' (line-number normalized)' : ''}${outcome.strategy === 'exact' ? '' : ' [non-exact; use exact text next time]'}`);
1216
+ if (outcome.strategy !== 'exact' || normalizedLineNumbers) {
1217
+ diagnostics.push(`edit[${i}] matched span vs requested (strategy: ${outcome.strategy}${normalizedLineNumbers ? ', line-number normalized' : ''}):\n${diagnosticDiff(search, matched)}`);
1218
+ }
1100
1219
  }
1101
1220
  if (content === src) {
1102
1221
  return `OK: no changes made to ${path}${log.length > 0 ? ` (${log.join('; ')})` : ''}`;
1103
1222
  }
1223
+ // Optimistic conflict check: while holding the write lock, confirm the
1224
+ // file on disk still matches the content these edits were based on.
1225
+ // The cross-process lock excludes other runtime processes, so a
1226
+ // mismatch means an external writer (editor, script) touched the file
1227
+ // between our read and this write — refuse instead of clobbering it.
1104
1228
  try {
1105
- const writtenPath = await writeViaWorkspace(path, content, ctx);
1106
- const written = await readFile(writtenPath, 'utf8');
1107
- for (const edit of applied) {
1108
- if (edit.replace === '') {
1109
- if (countOccurrences(written, edit.search) !== 0) {
1110
- return `Error: readback verification failed for ${path}: deleted text is still present after write.`;
1229
+ const fresh = await readFile(targetPath, 'utf8');
1230
+ if (contentVersion(fresh) !== currentVersion) {
1231
+ return `Error: ${path} changed underneath this edit (modified by another process after it was read). No changes were applied. Read the file again and retry with fresh content.`;
1232
+ }
1233
+ }
1234
+ catch {
1235
+ return `Error: ${path} could not be re-read before applying edits (it may have been deleted externally). No changes were applied.`;
1236
+ }
1237
+ try {
1238
+ const writtenPath = await stagedWrite(path, content, ctx, (written) => {
1239
+ for (const edit of applied) {
1240
+ if (edit.replace === '') {
1241
+ if (countOccurrences(written, edit.search) !== 0) {
1242
+ throw new Error(`readback verification failed for ${path}: deleted text is still present after write.`);
1243
+ }
1244
+ }
1245
+ else if (countOccurrences(written, edit.replace) < edit.made) {
1246
+ throw new Error(`readback verification failed for ${path}: expected at least ${edit.made} occurrence(s) of the replaced text in the written file.`);
1111
1247
  }
1112
1248
  }
1113
- else if (countOccurrences(written, edit.replace) < edit.made) {
1114
- return `Error: readback verification failed for ${path}: expected at least ${edit.made} occurrence(s) of the replaced text in the written file.`;
1115
- }
1116
- }
1249
+ });
1117
1250
  await gitAutoCommit(writtenPath, `edit: ${path} (${applied.length} change${applied.length === 1 ? '' : 's'})`, ctx);
1118
1251
  const diff = unifiedDiff(path, src, content);
1119
- const sha256 = createHash('sha256').update(written).digest('hex').slice(0, 12);
1252
+ const sha256 = createHash('sha256').update(content).digest('hex').slice(0, 12);
1253
+ ctx?.recordWriteVersion?.(targetPath, contentVersion(content));
1120
1254
  const linesBefore = src.split('\n').length;
1121
- const linesAfter = written.split('\n').length;
1122
- return [`OK: ${log.join('; ')} (${writtenPath}); ${linesBefore} → ${linesAfter} lines; sha256:${sha256}`, diff].filter(Boolean).join('\n\n');
1255
+ const linesAfter = content.split('\n').length;
1256
+ return [`OK: ${log.join('; ')} (${writtenPath}); ${linesBefore} → ${linesAfter} lines; sha256:${sha256}`, diagnostics.join('\n\n'), diff].filter(Boolean).join('\n\n');
1123
1257
  }
1124
1258
  catch (error) {
1125
- return `Error writing edited file: ${String(error)}`;
1259
+ const message = error instanceof Error ? error.message : String(error);
1260
+ return `Error writing edited file: ${message} (target left unchanged)`;
1126
1261
  }
1127
1262
  });
1128
1263
  }
@@ -1170,6 +1305,15 @@ async function executeBuiltinTool(name, args, ctx) {
1170
1305
  catch {
1171
1306
  previous = '';
1172
1307
  }
1308
+ // Stale-read check: when this session read the file before and it has
1309
+ // since changed (another process wrote it), a blind overwrite would
1310
+ // silently destroy that work. Force a fresh read + retry instead.
1311
+ if (ctx?.requirePriorRead && existed) {
1312
+ const readVersion = ctx.getReadVersion?.(targetPath);
1313
+ if (readVersion && readVersion !== contentVersion(previous)) {
1314
+ return `Error: ${path} changed after it was read in this session (another process may have written it). Read the file again, then retry write_file.`;
1315
+ }
1316
+ }
1173
1317
  const snapshot = existed ? await snapshotBeforeWrite(targetPath, workspaceRoot(ctx)) : { path: null };
1174
1318
  const writtenPath = await writeViaWorkspace(path, content, ctx);
1175
1319
  await gitAutoCommit(writtenPath, `write: ${path}`, ctx);
@@ -1298,3 +1442,105 @@ export function listTools(options = {}) {
1298
1442
  export async function executeTool(name, args, ctx) {
1299
1443
  return toolRegistry.execute(name, args, ctx);
1300
1444
  }
1445
+ /** Spawn a command without a shell layer around it, wired for incremental
1446
+ * output: stdout/stderr chunks arrive through callbacks as the process runs,
1447
+ * and `signal` (or the runtime timeout) kills the process tree. */
1448
+ function shellSpawn(file, args, options) {
1449
+ return new Promise((resolveExit) => {
1450
+ let settled = false;
1451
+ const settle = (code, signal) => {
1452
+ if (settled)
1453
+ return;
1454
+ settled = true;
1455
+ resolveExit({ code, signal });
1456
+ };
1457
+ let spawnError;
1458
+ const child = spawn(file, args, {
1459
+ cwd: options.cwd,
1460
+ signal: options.signal,
1461
+ windowsVerbatimArguments: options.windowsVerbatimArguments,
1462
+ env: process.env,
1463
+ stdio: ['ignore', 'pipe', 'pipe'],
1464
+ });
1465
+ child.stdout?.on('data', (chunk) => options.onStdout(chunk.toString('utf8')));
1466
+ child.stderr?.on('data', (chunk) => options.onStderr(chunk.toString('utf8')));
1467
+ child.on('error', (error) => {
1468
+ spawnError = error;
1469
+ // Spawn failures (missing shell, bad cwd) behave like a 127 exit.
1470
+ settle(error.code === 'ENOENT' ? 127 : 1, undefined);
1471
+ });
1472
+ child.on('close', (code, signal) => {
1473
+ if (spawnError)
1474
+ return;
1475
+ settle(code ?? undefined, signal ?? undefined);
1476
+ });
1477
+ });
1478
+ }
1479
+ /** Run a user-typed `!command` directly in a shell, bypassing the agent loop
1480
+ * and tool policy entirely: the user is the authorizer. Output is streamed to
1481
+ * the TUI via `onChunk` (stdout and stderr interleaved as they arrive), and
1482
+ * `abort` kills the process tree. Like a normal shell, a nonzero exit code is
1483
+ * not an error for the caller — the exit code rides in the result. */
1484
+ export function runShellCommand(command, options) {
1485
+ const timeoutMs = Math.max(100, Math.min(Number(options.timeoutMs ?? 300_000), 600_000));
1486
+ const nl = String.fromCharCode(10);
1487
+ const maxChars = 16 * 1024 * 1024;
1488
+ return new Promise((resolveShell) => {
1489
+ const argv0 = process.platform === 'win32'
1490
+ ? { file: process.env.ComSpec ?? 'cmd.exe', args: ['/d', '/s', '/c', command], verbatim: true }
1491
+ : { file: '/bin/sh', args: ['-c', command], verbatim: false };
1492
+ const controller = new AbortController();
1493
+ if (options.signal) {
1494
+ if (options.signal.aborted)
1495
+ controller.abort(options.signal.reason);
1496
+ else
1497
+ options.signal.addEventListener('abort', () => controller.abort(), { once: true });
1498
+ }
1499
+ let output = '';
1500
+ let truncated = false;
1501
+ let timedOut = false;
1502
+ let settled = false;
1503
+ const push = (text) => {
1504
+ if (!text)
1505
+ return;
1506
+ const room = maxChars - output.length;
1507
+ if (room <= 0) {
1508
+ truncated = true;
1509
+ return;
1510
+ }
1511
+ output += text.length > room ? text.slice(0, room) : text;
1512
+ if (text.length > room)
1513
+ truncated = true;
1514
+ options.onChunk?.(text);
1515
+ };
1516
+ const settle = (exitCode, note) => {
1517
+ if (settled)
1518
+ return;
1519
+ settled = true;
1520
+ clearTimeout(timer);
1521
+ resolveShell({ output: `${output}${truncated ? `${nl}(output truncated)` : ''}${note ?? ''}`, exitCode });
1522
+ };
1523
+ const timer = setTimeout(() => {
1524
+ timedOut = true;
1525
+ controller.abort();
1526
+ }, timeoutMs);
1527
+ timer.unref?.();
1528
+ void shellSpawn(argv0.file, argv0.args, {
1529
+ cwd: options.workspaceRoot,
1530
+ signal: controller.signal,
1531
+ windowsVerbatimArguments: argv0.verbatim,
1532
+ onStdout: push,
1533
+ onStderr: push,
1534
+ }).then(({ code, signal }) => {
1535
+ if (timedOut) {
1536
+ settle(undefined, `${nl}Error: command timed out`);
1537
+ return;
1538
+ }
1539
+ if (controller.signal.aborted || signal) {
1540
+ settle(undefined, `${nl}(stopped)`);
1541
+ return;
1542
+ }
1543
+ settle(code ?? 0);
1544
+ });
1545
+ });
1546
+ }
package/dist/markdown.js CHANGED
@@ -1,21 +1,49 @@
1
1
  /**
2
2
  * markdown.ts — Lightweight terminal markdown renderer.
3
3
  *
4
- * Exported so it can be unit-tested independently of the TUI.
5
- * No external dependencies pure string transformation.
4
+ * Emits Blessed style tags (not raw ANSI) so the TUI renders every highlight
5
+ * with theme-driven colors, and exports the theme shape so unit tests and the
6
+ * TUI share one definition. No external dependencies — pure string transform.
6
7
  */
7
- import chalk from 'chalk';
8
- const MARKDOWN_THEME = {
8
+ /** Blessed tags quantize hex colors to the terminal palette; chalk's
9
+ * truecolor SGR sequences are dropped by Blessed's attribute parser, which is
10
+ * why highlights previously rendered as plain white. */
11
+ export const DEFAULT_MARKDOWN_THEME = {
9
12
  text: '#d7e0ea',
10
13
  muted: '#7f92a6',
11
14
  accent: '#6fb1d6',
12
- accentStrong: '#8ac3e6',
15
+ heading: '#8ac3e6',
16
+ headingStrong: '#d7e0ea',
13
17
  codeBg: '#16212d',
14
18
  codeText: '#c7d7e6',
15
19
  codeFence: '#5f7388',
16
- codeGreen: '#8ebd93',
17
- codeRed: '#c97c7c',
20
+ diffAddBg: '#10281a',
21
+ diffAddText: '#9fd0a6',
22
+ diffDelBg: '#2b1215',
23
+ diffDelText: '#d99f9f',
18
24
  };
25
+ let currentTheme = DEFAULT_MARKDOWN_THEME;
26
+ export function setMarkdownTheme(theme) {
27
+ currentTheme = theme;
28
+ }
29
+ export function getMarkdownTheme() {
30
+ return currentTheme;
31
+ }
32
+ /** Blessed reserves braces for style tags; escape literal ones. */
33
+ export function escapeTags(text) {
34
+ return text.replace(/{/g, '{open}').replace(/}/g, '{close}');
35
+ }
36
+ function fg(color, text) {
37
+ return `{${color}-fg}${text}{/${color}-fg}`;
38
+ }
39
+ /** Italic and strikethrough are not Blessed flags; raw SGR is honored by the
40
+ * content parser and excluded from width measurement. */
41
+ function italic(text) {
42
+ return `\x1b[3m${text}\x1b[23m`;
43
+ }
44
+ function strike(text) {
45
+ return `\x1b[9m${text}\x1b[29m`;
46
+ }
19
47
  function isDiffLanguage(lang) {
20
48
  return lang.toLowerCase() === 'diff' || lang.toLowerCase() === 'patch';
21
49
  }
@@ -31,27 +59,30 @@ export function diffKind(line) {
31
59
  return 'context';
32
60
  }
33
61
  export function renderDiffLine(line) {
62
+ const theme = getMarkdownTheme();
34
63
  switch (diffKind(line)) {
35
64
  case 'add':
36
- return chalk.hex(MARKDOWN_THEME.codeGreen)(line);
65
+ return fg(theme.diffAddText, escapeTags(line));
37
66
  case 'del':
38
- return chalk.hex(MARKDOWN_THEME.codeRed)(line);
67
+ return fg(theme.diffDelText, escapeTags(line));
39
68
  case 'hunk':
40
- return chalk.hex(MARKDOWN_THEME.accent)(line);
69
+ return fg(theme.accent, escapeTags(line));
41
70
  case 'file':
42
- return chalk.bold.hex(MARKDOWN_THEME.text)(line);
71
+ return fg(theme.text, escapeTags(line));
43
72
  case 'context':
44
73
  default:
45
- return chalk.hex(MARKDOWN_THEME.muted)(line);
74
+ return fg(theme.muted, escapeTags(line));
46
75
  }
47
76
  }
48
77
  function renderCodeLine(lang, line) {
49
- return isDiffLanguage(lang) ? renderDiffLine(line) : chalk.hex(MARKDOWN_THEME.codeText)(line);
78
+ return isDiffLanguage(lang) ? renderDiffLine(line) : fg(getMarkdownTheme().codeText, escapeTags(line));
50
79
  }
51
80
  const ANSI_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g;
52
- /** Display width ignoring ANSI escapes, counting East-Asian wide chars as 2 columns. */
81
+ const TAG_PATTERN = /\{[^{}]*\}/g;
82
+ /** Display width ignoring ANSI escapes and style tags, counting East-Asian
83
+ * wide chars as 2 columns. */
53
84
  export function displayWidth(text) {
54
- const clean = text.replace(ANSI_PATTERN, '');
85
+ const clean = text.replace(ANSI_PATTERN, '').replace(TAG_PATTERN, '');
55
86
  let width = 0;
56
87
  for (const ch of clean) {
57
88
  const code = ch.codePointAt(0);
@@ -135,6 +166,7 @@ function truncateToWidth(text, maxWidth) {
135
166
  }
136
167
  /** Render a parsed table as aligned monospace lines that fit within maxWidth columns. */
137
168
  export function renderGfmTable(table, maxWidth) {
169
+ const theme = getMarkdownTheme();
138
170
  const columns = table.header.length;
139
171
  const gap = ' │ ';
140
172
  const gapWidth = displayWidth(gap);
@@ -153,40 +185,43 @@ export function renderGfmTable(table, maxWidth) {
153
185
  overflow -= reduce;
154
186
  }
155
187
  }
156
- const padCell = (rendered, width, align) => {
157
- const padding = Math.max(0, width - displayWidth(rendered));
188
+ const padCell = (plain, width, align) => {
189
+ const padding = Math.max(0, width - displayWidth(plain));
158
190
  if (align === 'right')
159
- return ' '.repeat(padding) + rendered;
191
+ return ' '.repeat(padding) + plain;
160
192
  if (align === 'center') {
161
193
  const left = Math.floor(padding / 2);
162
- return ' '.repeat(left) + rendered + ' '.repeat(padding - left);
194
+ return ' '.repeat(left) + plain + ' '.repeat(padding - left);
163
195
  }
164
- return rendered + ' '.repeat(padding);
196
+ return plain + ' '.repeat(padding);
165
197
  };
198
+ // Pad the plain cell first, then style — so padding never measures tags.
166
199
  const renderRow = (cells, style) => cells
167
200
  .map((cell, index) => {
168
201
  const width = widths[index];
169
202
  const plain = displayWidth(cell) > width ? truncateToWidth(cell, width) : cell;
170
- return padCell(style(plain), width, table.aligns[index]);
203
+ return style(padCell(plain, width, table.aligns[index]));
171
204
  })
172
- .join(chalk.hex(MARKDOWN_THEME.muted)(gap));
173
- const header = renderRow(table.header, (cell) => chalk.bold.hex(MARKDOWN_THEME.accent)(inlineMarkdown(cell)));
205
+ .join(fg(theme.muted, gap));
206
+ const header = renderRow(table.header, (plain) => fg(theme.accent, `{bold}${inlineMarkdown(plain)}{/bold}`));
174
207
  // One uniform border color for every structural character (│, ─, ┼).
175
- const separator = chalk.hex(MARKDOWN_THEME.muted)(widths.map((width) => '─'.repeat(width)).join('─┼─'));
176
- const body = table.rows.map((row) => renderRow(row, (cell) => inlineMarkdown(cell)));
177
- return [header, chalk.hex(MARKDOWN_THEME.muted)(separator), ...body];
208
+ const separator = fg(theme.muted, widths.map((width) => '─'.repeat(width)).join('─┼─'));
209
+ const body = table.rows.map((row) => renderRow(row, (plain) => inlineMarkdown(plain)));
210
+ return [header, separator, ...body];
178
211
  }
179
212
  export function inlineMarkdown(text) {
180
- return text
181
- .replace(/\*\*\*(.+?)\*\*\*/g, (_m, t) => chalk.bold.italic(t))
182
- .replace(/\*\*(.+?)\*\*/g, (_m, t) => chalk.bold(t))
183
- .replace(/__(.+?)__/g, (_m, t) => chalk.bold(t))
184
- .replace(/\*(.+?)\*/g, (_m, t) => chalk.italic(t))
185
- .replace(/_(.+?)_/g, (_m, t) => chalk.italic(t))
186
- .replace(/`([^`]+)`/g, (_m, t) => chalk.bgHex(MARKDOWN_THEME.codeBg).hex(MARKDOWN_THEME.codeText)(` ${t} `))
187
- .replace(/~~(.+?)~~/g, (_m, t) => chalk.strikethrough(t));
213
+ const theme = getMarkdownTheme();
214
+ return escapeTags(text)
215
+ .replace(/\*\*\*(.+?)\*\*\*/g, (_m, t) => `{bold}${italic(t)}{/bold}`)
216
+ .replace(/\*\*(.+?)\*\*/g, (_m, t) => `{bold}${t}{/bold}`)
217
+ .replace(/__(.+?)__/g, (_m, t) => `{bold}${t}{/bold}`)
218
+ .replace(/\*(.+?)\*/g, (_m, t) => italic(t))
219
+ .replace(/_(.+?)_/g, (_m, t) => italic(t))
220
+ .replace(/`([^`]+)`/g, (_m, t) => `{${theme.codeBg}-bg}{${theme.codeText}-fg} ${t} {/${theme.codeText}-fg}{/${theme.codeBg}-bg}`)
221
+ .replace(/~~(.+?)~~/g, (_m, t) => strike(t));
188
222
  }
189
223
  export function renderMarkdown(text, cols = 80) {
224
+ const theme = getMarkdownTheme();
190
225
  const lines = text.split('\n');
191
226
  const out = [];
192
227
  let inCodeBlock = false;
@@ -203,12 +238,12 @@ export function renderMarkdown(text, cols = 80) {
203
238
  }
204
239
  else {
205
240
  inCodeBlock = false;
206
- const langLabel = codeLang ? chalk.hex(MARKDOWN_THEME.muted).italic(` ${codeLang}`) : '';
207
- out.push(chalk.hex(MARKDOWN_THEME.codeFence)('┌' + '─'.repeat(Math.max(2, cols - 2))) + langLabel);
241
+ const langLabel = codeLang ? fg(theme.muted, italic(` ${escapeTags(codeLang)}`)) : '';
242
+ out.push(fg(theme.codeFence, '┌' + '─'.repeat(Math.max(2, cols - 2))) + langLabel);
208
243
  for (const cl of codeLines) {
209
- out.push(chalk.hex(MARKDOWN_THEME.codeFence)('│ ') + renderCodeLine(codeLang, cl));
244
+ out.push(fg(theme.codeFence, '│ ') + renderCodeLine(codeLang, cl));
210
245
  }
211
- out.push(chalk.hex(MARKDOWN_THEME.codeFence)('└' + '─'.repeat(Math.max(2, cols - 2))));
246
+ out.push(fg(theme.codeFence, '└' + '─'.repeat(Math.max(2, cols - 2))));
212
247
  codeLang = '';
213
248
  codeLines = [];
214
249
  }
@@ -222,19 +257,19 @@ export function renderMarkdown(text, cols = 80) {
222
257
  const h2 = raw.match(/^## (.+)/);
223
258
  const h3 = raw.match(/^### (.+)/);
224
259
  if (h1) {
225
- out.push('\n' + chalk.bold.hex(MARKDOWN_THEME.accentStrong)(h1[1]));
260
+ out.push('\n' + fg(theme.heading, `{bold}${escapeTags(h1[1])}{/bold}`));
226
261
  continue;
227
262
  }
228
263
  if (h2) {
229
- out.push('\n' + chalk.bold.hex(MARKDOWN_THEME.text)(h2[1]));
264
+ out.push('\n' + fg(theme.headingStrong, `{bold}${escapeTags(h2[1])}{/bold}`));
230
265
  continue;
231
266
  }
232
267
  if (h3) {
233
- out.push(chalk.bold(h3[1]));
268
+ out.push(`{bold}${escapeTags(h3[1])}{/bold}`);
234
269
  continue;
235
270
  }
236
271
  if (/^---+$/.test(raw) || /^\*\*\*+$/.test(raw)) {
237
- out.push(chalk.hex(MARKDOWN_THEME.muted)('─'.repeat(cols)));
272
+ out.push(fg(theme.muted, '─'.repeat(cols)));
238
273
  continue;
239
274
  }
240
275
  const tableMatch = matchGfmTable(lines, index);
@@ -245,30 +280,30 @@ export function renderMarkdown(text, cols = 80) {
245
280
  }
246
281
  const bullet = raw.match(/^(\s*)[*\-+] (.+)/);
247
282
  if (bullet) {
248
- out.push((bullet[1] ?? '') + chalk.hex(MARKDOWN_THEME.accent)('•') + ' ' + inlineMarkdown(bullet[2] ?? ''));
283
+ out.push((bullet[1] ?? '') + fg(theme.accent, '•') + ' ' + inlineMarkdown(bullet[2] ?? ''));
249
284
  continue;
250
285
  }
251
286
  const numbered = raw.match(/^(\s*)(\d+)\. (.+)/);
252
287
  if (numbered) {
253
288
  out.push((numbered[1] ?? '') +
254
- chalk.hex(MARKDOWN_THEME.accent)(numbered[2] + '.') +
289
+ fg(theme.accent, escapeTags(numbered[2] + '.')) +
255
290
  ' ' +
256
291
  inlineMarkdown(numbered[3] ?? ''));
257
292
  continue;
258
293
  }
259
294
  const bq = raw.match(/^> (.+)/);
260
295
  if (bq) {
261
- out.push(chalk.hex(MARKDOWN_THEME.muted)('│ ') + chalk.italic.hex(MARKDOWN_THEME.muted)(bq[1]));
296
+ out.push(fg(theme.muted, '│ ') + fg(theme.muted, italic(escapeTags(bq[1]))));
262
297
  continue;
263
298
  }
264
299
  out.push(inlineMarkdown(raw));
265
300
  }
266
301
  if (inCodeBlock && codeLines.length > 0) {
267
- out.push(chalk.hex(MARKDOWN_THEME.codeFence)('┌─'));
302
+ out.push(fg(theme.codeFence, '┌─'));
268
303
  for (const cl of codeLines) {
269
- out.push(chalk.hex(MARKDOWN_THEME.codeFence)('│ ') + renderCodeLine(codeLang, cl));
304
+ out.push(fg(theme.codeFence, '│ ') + renderCodeLine(codeLang, cl));
270
305
  }
271
- out.push(chalk.hex(MARKDOWN_THEME.codeFence)('└─'));
306
+ out.push(fg(theme.codeFence, '└─'));
272
307
  }
273
308
  return out.join('\n');
274
309
  }