mixdog 0.9.0 → 0.9.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.
Files changed (57) hide show
  1. package/package.json +3 -3
  2. package/scripts/session-ingest-smoke.mjs +2 -2
  3. package/src/headless-role.mjs +1 -1
  4. package/src/lib/mixdog-debug.cjs +0 -22
  5. package/src/lib/plugin-paths.cjs +1 -7
  6. package/src/lib/rules-builder.cjs +2 -2
  7. package/src/mixdog-session-runtime.mjs +0 -1
  8. package/src/repl.mjs +0 -2
  9. package/src/runtime/agent/orchestrator/internal-roles.mjs +1 -1
  10. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +27 -49
  11. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +5 -20
  12. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +8 -27
  13. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +52 -182
  14. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +8 -30
  15. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +258 -0
  16. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +0 -8
  18. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
  19. package/src/runtime/channels/index.mjs +0 -30
  20. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  21. package/src/runtime/channels/lib/config.mjs +0 -1
  22. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  23. package/src/runtime/channels/lib/executor.mjs +0 -3
  24. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  25. package/src/runtime/channels/lib/output-forwarder.mjs +1 -8
  26. package/src/runtime/channels/lib/runtime-paths.mjs +0 -6
  27. package/src/runtime/channels/lib/session-discovery.mjs +0 -4
  28. package/src/runtime/channels/lib/tool-format.mjs +0 -1
  29. package/src/runtime/channels/lib/transcript-discovery.mjs +1 -10
  30. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  31. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  32. package/src/runtime/memory/index.mjs +3 -24
  33. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  34. package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
  35. package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
  36. package/src/runtime/memory/lib/session-ingest.mjs +9 -7
  37. package/src/runtime/search/index.mjs +2 -7
  38. package/src/runtime/search/lib/config.mjs +0 -4
  39. package/src/runtime/search/lib/state.mjs +1 -15
  40. package/src/runtime/search/lib/web-tools.mjs +0 -1
  41. package/src/runtime/shared/child-spawn-gate.mjs +0 -6
  42. package/src/standalone/seeds.mjs +1 -11
  43. package/src/tui/App.jsx +35 -12
  44. package/src/tui/components/PromptInput.jsx +63 -2
  45. package/src/tui/components/ToolExecution.jsx +7 -2
  46. package/src/tui/components/tool-output-format.mjs +156 -22
  47. package/src/tui/components/tool-output-format.test.mjs +93 -1
  48. package/src/tui/dist/index.mjs +473 -116
  49. package/src/tui/markdown/format-token.mjs +267 -108
  50. package/src/tui/markdown/format-token.test.mjs +105 -9
  51. package/src/tui/theme.mjs +10 -0
  52. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
  53. package/vendor/ink/build/ink.js +54 -8
  54. package/src/hooks/lib/permission-rules.cjs +0 -170
  55. package/src/hooks/lib/settings-loader.cjs +0 -112
  56. package/src/lib/hook-pipe-path.cjs +0 -10
  57. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
@@ -5,14 +5,15 @@
5
5
  * - chalk is forced to truecolor (level 3) so colors render regardless of the
6
6
  * ambient TTY detection (we control the surface).
7
7
  * - The `permission`/code accent and blockquote bar come from our theme.mjs.
8
- * - `code` (fenced) is emitted as a block-colored plain text + EOL (no
9
- * syntax highlighter dependency); `codespan` (inline) gets the accent color.
8
+ * - `code` (fenced) uses cli-highlight + theme syntax palette; `codespan`
9
+ * (inline) gets the accent color.
10
10
  * - `table` is NOT handled here — the React component (MarkdownTable.jsx)
11
11
  * renders tables with proper ink Box layout (hybrid split).
12
12
  * - Hyperlinks/issue-ref linkify are dropped (no OSC-8 dependency); link text
13
13
  * is shown plainly with its URL.
14
14
  */
15
15
  import { Chalk } from 'chalk';
16
+ import { highlight, supportsLanguage } from 'cli-highlight';
16
17
  import stripAnsi from 'strip-ansi';
17
18
  import stringWidth from 'string-width';
18
19
  import wrapAnsi from 'wrap-ansi';
@@ -101,6 +102,7 @@ export function extraColorizers() {
101
102
  body: fallbackBody,
102
103
  // Truecolor background band for fenced code blocks (per-line wrap).
103
104
  codeBg: rgbBg(theme.mdCodeBlockBg ?? theme.background),
105
+ codeSpanBg: rgbBg(theme.mdCodeSpanBg ?? theme.mdCodeBlockBg ?? theme.background),
104
106
  };
105
107
  return _extra;
106
108
  }
@@ -124,12 +126,10 @@ function decodeEntities(s) {
124
126
  }
125
127
 
126
128
  // ── Fenced code block rendering ─────────────────────────────────────────────
127
- // Compact language label (no ``` fences), two-space-indented wrapped body.
128
- // Diff/patch languages
129
- // (and code text that clearly looks like a unified diff) get colored +/-/@@
130
- // lines; other known languages get a conservative regex highlighter; unknown
131
- // languages fall back to the flat `mdCodeBlock` body color.
132
- const CODE_BLOCK_INDENT = ' ';
129
+ // Flush-left wrapped body with per-line background band (no ``` fences / lang row).
130
+ // Diff/patch languages (and bodies that look like unified diffs) keep the diff
131
+ // highlighter; other languages use cli-highlight (highlight.js) themed from our
132
+ // syntax* palette; unknown languages fall back to flat `mdCodeBlock` body color.
133
133
  const DIFF_LANGS = new Set(['diff', 'patch', 'udiff', 'git-diff', 'gitdiff']);
134
134
 
135
135
  /** Wrap text to width, ANSI-aware (lockstep with table-layout hard wrap). */
@@ -167,17 +167,36 @@ function hardWrapAnsiLines(text, width) {
167
167
  return out.length > 0 ? out : [''];
168
168
  }
169
169
 
170
- /** Wrap one logical code line (ANSI content, no indent) and prefix each segment. */
171
- function wrapIndentedCodeLine(ansiContent, maxLineWidth) {
172
- const indentW = stringWidth(CODE_BLOCK_INDENT);
173
- const contentMax = Math.max(1, maxLineWidth - indentW);
174
- const segments = hardWrapAnsiLines(ansiContent, contentMax);
175
- return segments.map((seg) => `${CODE_BLOCK_INDENT}${seg}`);
170
+ /** Wrap one logical code line (ANSI content) to max visible width. */
171
+ function wrapCodeLine(ansiContent, maxLineWidth) {
172
+ const contentMax = Math.max(1, maxLineWidth);
173
+ return hardWrapAnsiLines(ansiContent, contentMax);
176
174
  }
177
175
 
178
- /** Tint code lines without padding to terminal width (no trailing bg band). */
179
- function tintCodeLines(lines, codeBg) {
180
- return lines.map((line) => codeBg(line)).join(EOL);
176
+ /** Visible terminal width of an ANSI-colored line (codes excluded). */
177
+ function visibleLineWidth(line) {
178
+ return stringWidth(stripAnsi(String(line ?? '')));
179
+ }
180
+
181
+ /** Max visible width across lang label + body lines (content-based, not terminal). */
182
+ function codeBlockContentWidth(langLine, bodyLines) {
183
+ const all = [...(langLine ? [langLine] : []), ...bodyLines];
184
+ if (all.length === 0) return 0;
185
+ return Math.max(1, ...all.map(visibleLineWidth));
186
+ }
187
+
188
+ /** Apply code-block bg to one line, padded to a shared compact block width. */
189
+ function tintCodeLineToBlockWidth(line, codeBg, blockWidth) {
190
+ const w = visibleLineWidth(line);
191
+ const pad = Math.max(0, blockWidth - w);
192
+ return codeBg(`${line ?? ''}${' '.repeat(pad)}`);
193
+ }
194
+
195
+ function tintCodeBlockBody(langLine, bodyLines, codeBg) {
196
+ const blockWidth = codeBlockContentWidth(langLine, bodyLines);
197
+ const langOut = langLine ? tintCodeLineToBlockWidth(langLine, codeBg, blockWidth) : '';
198
+ const bodyOut = bodyLines.map((line) => tintCodeLineToBlockWidth(line, codeBg, blockWidth)).join(EOL);
199
+ return { langOut, bodyOut };
181
200
  }
182
201
 
183
202
  /** Reduce render width by a visible prefix (list marker, blockquote bar, etc.). */
@@ -266,34 +285,60 @@ function colorizeDiffStatTrailer(line, c) {
266
285
  .replace(/(\d+)(\s+deletions?\(-\))/g, (_, n, rest) => `${c.diffRemoved(n)}${c.diffContext(rest)}`);
267
286
  }
268
287
 
269
- function renderDiffBody(text, c, bandWidth) {
288
+ function collectDiffBodyLines(text, c, bandWidth) {
270
289
  const lines = [];
271
290
  for (const line of String(text ?? '').split(EOL)) {
272
291
  const colored = colorizeDiffLine(line, c);
273
- lines.push(...wrapIndentedCodeLine(colored, bandWidth));
292
+ lines.push(...wrapCodeLine(colored, bandWidth));
274
293
  }
275
- return tintCodeLines(lines, c.codeBg);
276
- }
277
-
278
- // ── Lightweight syntax highlighting (regex token classes, no parser) ────────
279
- const KEYWORDS = {
280
- js: ['const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break', 'continue', 'new', 'class', 'extends', 'super', 'this', 'import', 'export', 'from', 'default', 'async', 'await', 'yield', 'try', 'catch', 'finally', 'throw', 'typeof', 'instanceof', 'in', 'of', 'void', 'delete', 'null', 'undefined', 'true', 'false'],
281
- py: ['def', 'class', 'return', 'if', 'elif', 'else', 'for', 'while', 'import', 'from', 'as', 'with', 'try', 'except', 'finally', 'raise', 'pass', 'break', 'continue', 'lambda', 'yield', 'global', 'nonlocal', 'async', 'await', 'and', 'or', 'not', 'in', 'is', 'None', 'True', 'False', 'self'],
282
- sh: ['if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'do', 'done', 'case', 'esac', 'function', 'return', 'in', 'export', 'local', 'echo', 'cd', 'set', 'unset', 'read', 'source'],
283
- css: [],
284
- go: ['package', 'import', 'func', 'return', 'if', 'else', 'for', 'range', 'switch', 'case', 'default', 'break', 'continue', 'fallthrough', 'goto', 'defer', 'go', 'select', 'chan', 'map', 'struct', 'interface', 'type', 'var', 'const', 'nil', 'true', 'false', 'iota', 'string', 'int', 'int64', 'float64', 'bool', 'byte', 'rune', 'error'],
285
- rust: ['fn', 'let', 'mut', 'const', 'static', 'return', 'if', 'else', 'match', 'for', 'while', 'loop', 'break', 'continue', 'struct', 'enum', 'trait', 'impl', 'mod', 'use', 'pub', 'crate', 'self', 'super', 'where', 'as', 'dyn', 'move', 'ref', 'unsafe', 'async', 'await', 'type', 'true', 'false', 'Some', 'None', 'Ok', 'Err', 'Box', 'Vec', 'String', 'usize', 'isize', 'i32', 'u32', 'i64', 'u64', 'f64', 'bool'],
286
- java: ['public', 'private', 'protected', 'class', 'interface', 'enum', 'extends', 'implements', 'abstract', 'final', 'static', 'void', 'new', 'return', 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'default', 'break', 'continue', 'try', 'catch', 'finally', 'throw', 'throws', 'import', 'package', 'this', 'super', 'instanceof', 'synchronized', 'volatile', 'transient', 'native', 'int', 'long', 'short', 'byte', 'char', 'float', 'double', 'boolean', 'true', 'false', 'null'],
287
- c: ['auto', 'break', 'case', 'char', 'const', 'continue', 'default', 'do', 'double', 'else', 'enum', 'extern', 'float', 'for', 'goto', 'if', 'inline', 'int', 'long', 'register', 'return', 'short', 'signed', 'sizeof', 'static', 'struct', 'switch', 'typedef', 'union', 'unsigned', 'void', 'volatile', 'while', 'class', 'namespace', 'template', 'public', 'private', 'protected', 'virtual', 'new', 'delete', 'using', 'nullptr', 'true', 'false', 'bool'],
288
- ruby: ['def', 'end', 'class', 'module', 'if', 'elsif', 'else', 'unless', 'case', 'when', 'then', 'while', 'until', 'for', 'in', 'do', 'begin', 'rescue', 'ensure', 'raise', 'return', 'yield', 'break', 'next', 'redo', 'retry', 'self', 'nil', 'true', 'false', 'and', 'or', 'not', 'require', 'require_relative', 'attr_accessor', 'attr_reader', 'attr_writer', 'puts', 'lambda', 'proc'],
289
- sql: ['select', 'from', 'where', 'insert', 'into', 'values', 'update', 'set', 'delete', 'create', 'alter', 'drop', 'table', 'view', 'index', 'join', 'inner', 'left', 'right', 'outer', 'full', 'on', 'group', 'by', 'order', 'having', 'limit', 'offset', 'distinct', 'as', 'and', 'or', 'not', 'null', 'is', 'in', 'between', 'like', 'union', 'all', 'primary', 'key', 'foreign', 'references', 'default', 'constraint', 'unique', 'count', 'sum', 'avg', 'min', 'max'],
290
- yaml: [],
291
- toml: [],
294
+ return lines;
295
+ }
296
+
297
+ // ── cli-highlight (highlight.js) ────────────────────────────────────────────
298
+ /** Internal family → highlight.js language id (when alias alone is insufficient). */
299
+ const FAMILY_TO_HLJS = {
300
+ js: 'javascript',
301
+ ts: 'typescript',
302
+ tsx: 'tsx',
303
+ jsx: 'jsx',
304
+ json: 'json',
305
+ sh: 'bash',
306
+ py: 'python',
307
+ css: 'css',
308
+ html: 'xml',
309
+ go: 'go',
310
+ rust: 'rust',
311
+ java: 'java',
312
+ c: 'cpp',
313
+ ruby: 'ruby',
314
+ sql: 'sql',
315
+ yaml: 'yaml',
316
+ toml: 'ini',
317
+ kotlin: 'kotlin',
318
+ swift: 'swift',
319
+ php: 'php',
320
+ csharp: 'csharp',
321
+ dockerfile: 'dockerfile',
322
+ protobuf: 'protobuf',
323
+ scala: 'scala',
324
+ dart: 'dart',
325
+ lua: 'lua',
326
+ perl: 'perl',
327
+ r: 'r',
328
+ objc: 'objectivec',
329
+ powershell: 'powershell',
330
+ makefile: 'makefile',
331
+ nginx: 'nginx',
332
+ ini: 'ini',
333
+ vim: 'vim',
334
+ haskell: 'haskell',
335
+ elixir: 'elixir',
336
+ clojure: 'clojure',
292
337
  };
293
338
 
294
339
  export const LANG_FAMILY = {
295
340
  js: 'js', javascript: 'js', mjs: 'js', cjs: 'js',
296
- ts: 'js', typescript: 'js', jsx: 'js', tsx: 'js',
341
+ ts: 'ts', typescript: 'ts', jsx: 'jsx', tsx: 'tsx',
297
342
  json: 'json', json5: 'json',
298
343
  bash: 'sh', sh: 'sh', shell: 'sh', zsh: 'sh',
299
344
  python: 'py', py: 'py',
@@ -308,64 +353,187 @@ export const LANG_FAMILY = {
308
353
  sql: 'sql',
309
354
  yaml: 'yaml', yml: 'yaml',
310
355
  toml: 'toml',
356
+ kotlin: 'kotlin', kt: 'kotlin', kts: 'kotlin',
357
+ swift: 'swift',
358
+ php: 'php',
359
+ csharp: 'csharp', cs: 'csharp', 'c#': 'csharp',
360
+ dockerfile: 'dockerfile', docker: 'dockerfile',
361
+ graphql: 'graphql', gql: 'graphql',
362
+ protobuf: 'protobuf', proto: 'protobuf',
363
+ scala: 'scala',
364
+ dart: 'dart',
365
+ lua: 'lua',
366
+ perl: 'perl', pl: 'perl',
367
+ r: 'r', rl: 'r',
368
+ objc: 'objc', 'objective-c': 'objc', 'obj-c': 'objc',
369
+ powershell: 'powershell', ps1: 'powershell', ps: 'powershell', pwsh: 'powershell',
370
+ makefile: 'makefile', make: 'makefile',
371
+ nginx: 'nginx',
372
+ ini: 'ini',
373
+ vim: 'vim',
374
+ haskell: 'haskell', hs: 'haskell',
375
+ elixir: 'elixir', ex: 'elixir', exs: 'elixir',
376
+ clojure: 'clojure', clj: 'clojure',
311
377
  };
312
378
 
313
- // Families whose comment lines / inline comments start with `#`.
314
- const HASH_COMMENT_FAMILIES = new Set(['sh', 'py', 'yaml', 'toml']);
379
+ const HIGHLIGHT_CACHE_MAX = 300;
380
+ const highlightCache = new Map();
315
381
 
316
- /** Highlight a single line for a c-like / scripting family (token scan). */
317
- export function highlightCodeLine(line, family, c) {
318
- const kw = new Set(KEYWORDS[family === 'json' ? 'js' : family] ?? []);
319
- // Comment lines (whole-line) for the common families.
320
- if (family === 'js' && /^\s*\/\//.test(line)) return c.synComment(line);
321
- if (HASH_COMMENT_FAMILIES.has(family) && /^\s*#/.test(line)) return c.synComment(line);
322
- if (family === 'html' && /^\s*<!--/.test(line)) return c.synComment(line);
323
-
324
- // Token regex: strings, numbers, comments, identifiers, punctuation.
325
- const TOKEN_RE = /(`(?:\\.|[^`\\])*`|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|(\/\/[^\n]*|#[^\n]*)|(\b\d+(?:\.\d+)?\b)|([A-Za-z_$][A-Za-z0-9_$]*)|([{}()[\].,;:=+\-*/%<>!&|^~?]+)/g;
326
- let out = '';
327
- let last = 0;
328
- let m;
329
- TOKEN_RE.lastIndex = 0;
330
- while ((m = TOKEN_RE.exec(line)) !== null) {
331
- if (m.index > last) out += c.body(line.slice(last, m.index));
332
- const [, str, comment, num, ident, punct] = m;
333
- if (str !== undefined) {
334
- out += c.synString(str);
335
- } else if (comment !== undefined) {
336
- // `#` is only a comment for sh/py; for js treat as body.
337
- if (comment.startsWith('//') ? family === 'js' : HASH_COMMENT_FAMILIES.has(family)) {
338
- out += c.synComment(comment);
339
- } else {
340
- out += c.body(comment);
341
- }
342
- } else if (num !== undefined) {
343
- out += c.synNumber(num);
344
- } else if (ident !== undefined) {
345
- if (kw.has(ident)) out += c.synKeyword(ident);
346
- else if (line[TOKEN_RE.lastIndex] === '(') out += c.synFunction(ident);
347
- else out += c.body(ident);
348
- } else if (punct !== undefined) {
349
- out += c.synOperator(punct);
382
+ /** @internal Test-only introspection for highlight LRU cache. */
383
+ export function _highlightCacheSizeForTests() {
384
+ return highlightCache.size;
385
+ }
386
+
387
+ let _hljsThemeVersion = -1;
388
+ let _hljsTheme = null;
389
+
390
+ /** Map highlight.js token classes to chalk truecolor fns from the active theme. */
391
+ function buildCliHighlightTheme(c) {
392
+ const plain = (s) => c.body(s);
393
+ return {
394
+ default: plain,
395
+ keyword: c.synKeyword,
396
+ built_in: c.synType,
397
+ type: c.synType,
398
+ literal: c.synKeyword,
399
+ number: c.synNumber,
400
+ regexp: c.synString,
401
+ string: c.synString,
402
+ subst: plain,
403
+ symbol: plain,
404
+ class: c.synType,
405
+ function: c.synFunction,
406
+ title: c.synFunction,
407
+ params: c.synVariable,
408
+ comment: c.synComment,
409
+ doctag: c.synComment,
410
+ meta: c.synComment,
411
+ section: c.synType,
412
+ tag: c.synPunct,
413
+ name: c.synFunction,
414
+ builtin: c.synType,
415
+ attr: c.synType,
416
+ attribute: c.synType,
417
+ variable: c.synVariable,
418
+ selector: c.synKeyword,
419
+ template: c.synVariable,
420
+ bullet: plain,
421
+ code: plain,
422
+ emphasis: plain,
423
+ strong: plain,
424
+ formula: plain,
425
+ link: plain,
426
+ quote: plain,
427
+ addition: c.synString,
428
+ deletion: c.synString,
429
+ };
430
+ }
431
+
432
+ function getCliHighlightTheme() {
433
+ const version = getThemeVersion();
434
+ if (_hljsTheme && _hljsThemeVersion === version) return _hljsTheme;
435
+ _hljsThemeVersion = version;
436
+ _hljsTheme = buildCliHighlightTheme(extraColorizers());
437
+ return _hljsTheme;
438
+ }
439
+
440
+ function hljsLanguageFromFamily(family) {
441
+ if (!family || family === 'md') return null;
442
+ const mapped = FAMILY_TO_HLJS[family];
443
+ if (mapped && supportsLanguage(mapped)) return mapped;
444
+ if (supportsLanguage(family)) return family;
445
+ return null;
446
+ }
447
+
448
+ /** Resolve a fenced info-string to a highlight.js language id. */
449
+ function resolveHljsLanguage(lang) {
450
+ const normalized = normalizeLang(lang);
451
+ if (!normalized) return null;
452
+ const viaFamily = hljsLanguageFromFamily(LANG_FAMILY[normalized]);
453
+ if (viaFamily) return viaFamily;
454
+ return supportsLanguage(normalized) ? normalized : null;
455
+ }
456
+
457
+ /**
458
+ * Highlight source with cli-highlight; returns null when language is unsupported
459
+ * or highlighting fails (caller falls back to flat body color).
460
+ */
461
+ function highlightCodeText(text, hljsLang) {
462
+ if (!hljsLang || !supportsLanguage(hljsLang)) return null;
463
+ const src = String(text ?? '');
464
+ if (!src.trim()) return null;
465
+ if (!/[A-Za-z0-9]/.test(src)) return null;
466
+ const cacheKey = `${hljsLang}|${getThemeVersion()}|${src}`;
467
+ const cached = highlightCache.get(cacheKey);
468
+ if (cached !== undefined) {
469
+ highlightCache.delete(cacheKey);
470
+ highlightCache.set(cacheKey, cached);
471
+ return cached;
472
+ }
473
+ try {
474
+ const out = highlight(src, {
475
+ language: hljsLang,
476
+ theme: getCliHighlightTheme(),
477
+ ignoreIllegals: true,
478
+ });
479
+ if (highlightCache.size >= HIGHLIGHT_CACHE_MAX) {
480
+ const first = highlightCache.keys().next().value;
481
+ if (first !== undefined) highlightCache.delete(first);
350
482
  }
351
- last = TOKEN_RE.lastIndex;
483
+ highlightCache.set(cacheKey, out);
484
+ return out;
485
+ } catch {
486
+ return null;
352
487
  }
353
- if (last < line.length) out += c.body(line.slice(last));
354
- return out;
355
488
  }
356
489
 
357
- function renderHighlightedBody(text, family, c, bandWidth) {
490
+ /** Highlight a single line (tool expanded output); uses the same theme map. */
491
+ export function highlightCodeLine(line, family, c) {
492
+ const hljsLang = hljsLanguageFromFamily(family);
493
+ const highlighted = highlightCodeText(line, hljsLang);
494
+ if (highlighted != null) return highlighted;
495
+ return line ? c.body(line) : '';
496
+ }
497
+
498
+ /**
499
+ * Highlight a multi-line block in one cli-highlight call; returns one ANSI string per line.
500
+ * On miss, each line is flat-colored with `c.body`.
501
+ */
502
+ export function highlightCodeBlockToLines(text, family, c) {
503
+ const hljsLang = hljsLanguageFromFamily(family);
504
+ const raw = String(text ?? '');
505
+ const highlighted = hljsLang ? highlightCodeText(raw, hljsLang) : null;
506
+ if (highlighted != null) {
507
+ return highlighted.split(EOL);
508
+ }
509
+ return raw.split(EOL).map((line) => (line ? c.body(line) : ''));
510
+ }
511
+
512
+ function collectFlatBodyLines(text, codeBlock, bandWidth) {
358
513
  const lines = [];
359
514
  for (const line of String(text ?? '').split(EOL)) {
360
- const colored = line ? highlightCodeLine(line, family, c) : '';
361
- lines.push(...wrapIndentedCodeLine(colored, bandWidth));
515
+ const colored = codeBlock(line);
516
+ lines.push(...wrapCodeLine(colored, bandWidth));
362
517
  }
363
- return tintCodeLines(lines, c.codeBg);
518
+ return lines;
519
+ }
520
+
521
+ function collectHighlightedBodyLines(text, hljsLang, bandWidth) {
522
+ const highlighted = highlightCodeText(text, hljsLang);
523
+ const { codeBlock } = colorizers();
524
+ const source = highlighted != null
525
+ ? highlighted
526
+ : String(text ?? '').split(EOL).map(codeBlock).join(EOL);
527
+ const lines = [];
528
+ for (const line of source.split(EOL)) {
529
+ lines.push(...wrapCodeLine(line, bandWidth));
530
+ }
531
+ return lines;
364
532
  }
365
533
 
366
534
  /**
367
- * Render a fenced `code` token: compact language label, wrapped indented body,
368
- * and language-aware coloring (no visible ``` fence lines).
535
+ * Render a fenced `code` token: flush-left wrapped body with background band
536
+ * and language-aware coloring (no visible ``` fence lines or lang label).
369
537
  */
370
538
  function renderCodeBlock(token, width = 0) {
371
539
  const { codeBlock } = colorizers();
@@ -374,30 +542,20 @@ function renderCodeBlock(token, width = 0) {
374
542
  const text = decodeEntities(token.text ?? '');
375
543
  const bandWidth = Math.max(8, Number(width) || 80);
376
544
 
377
- let body;
545
+ let bodyLines;
378
546
  if (DIFF_LANGS.has(lang) || (!lang && looksLikeUnifiedDiff(text))) {
379
- body = renderDiffBody(text, c, bandWidth);
547
+ bodyLines = collectDiffBodyLines(text, c, bandWidth);
380
548
  } else {
549
+ const hljsLang = resolveHljsLanguage(lang);
381
550
  const family = LANG_FAMILY[lang];
382
- // Any known family except markdown-in-markdown routes through the regex
383
- // highlighter (html/json/yaml/toml included). 'md' stays flat (no nested
384
- // markdown highlighter); unknown langs fall back to the flat body color.
385
- if (family && family !== 'md') {
386
- body = renderHighlightedBody(text, family, c, bandWidth);
551
+ if (hljsLang && family !== 'md') {
552
+ bodyLines = collectHighlightedBodyLines(text, hljsLang, bandWidth);
387
553
  } else {
388
- // Unknown/plain language: flat code-block body color, still indented.
389
- const lines = [];
390
- for (const line of text.split(EOL)) {
391
- const colored = codeBlock(line);
392
- lines.push(...wrapIndentedCodeLine(colored, bandWidth));
393
- }
394
- body = tintCodeLines(lines, c.codeBg);
554
+ bodyLines = collectFlatBodyLines(text, codeBlock, bandWidth);
395
555
  }
396
556
  }
397
- const langLine = lang
398
- ? `${c.codeBg(c.fenceBorder(lang))}${EOL}`
399
- : '';
400
- return `${langLine}${body}${body ? EOL : ''}`;
557
+ const { bodyOut } = tintCodeBlockBody(null, bodyLines, c.codeBg);
558
+ return `${bodyOut}${bodyOut ? EOL : ''}`;
401
559
  }
402
560
 
403
561
  function numberToLetter(n) {
@@ -455,19 +613,19 @@ export function formatToken(token, listBaseIndent = 0, orderedListNumber = null,
455
613
  const inner = (token.tokens ?? []).map((t) => formatToken(t, 0, null, null, innerWidth)).join('');
456
614
  return inner
457
615
  .split(EOL)
458
- .map((line) =>
459
- stripAnsi(line).trim()
460
- ? `${bar} ${quoteText(chalk.italic(line))}`
461
- : line,
462
- )
616
+ .map((line) => {
617
+ // Padded fenced-code blank rows are space-only; trim() would drop the quote bar.
618
+ if (stringWidth(stripAnsi(line)) === 0) return line;
619
+ return `${bar} ${quoteText(chalk.italic(line))}`;
620
+ })
463
621
  .join(EOL);
464
622
  }
465
623
  case 'code':
466
- // Fenced block: compact lang label + wrapped indented, colored body.
624
+ // Fenced block: flush-left wrapped body with syntax highlighting.
467
625
  return renderCodeBlock(token, width);
468
626
  case 'codespan':
469
627
  // inline code
470
- return accent(decodeEntities(token.text));
628
+ return ex.codeSpanBg(accent(decodeEntities(token.text)));
471
629
  case 'em':
472
630
  return ex.emph(chalk.italic((token.tokens ?? []).map((t) => formatToken(t, 0, null, parent)).join('')));
473
631
  case 'strong':
@@ -598,7 +756,8 @@ function formatListItem(token, listBaseIndent, orderedListNumber, parent, depth
598
756
  out += `${markerPrefix.trimEnd()}${EOL}`;
599
757
  firstBlock = false;
600
758
  }
601
- out += formatToken(child, nestedListIndent, null, token, childWidth, depth + 1);
759
+ // Pass outer `width` so the nested list subtracts only its own marker prefix once.
760
+ out += formatToken(child, nestedListIndent, null, token, width, depth + 1);
602
761
  continue;
603
762
  }
604
763
 
@@ -3,7 +3,12 @@ import assert from 'node:assert/strict';
3
3
  import { marked } from 'marked';
4
4
  import stripAnsi from 'strip-ansi';
5
5
  import stringWidth from 'string-width';
6
- import { formatToken } from './format-token.mjs';
6
+ import {
7
+ formatToken,
8
+ highlightCodeBlockToLines,
9
+ extraColorizers,
10
+ _highlightCacheSizeForTests,
11
+ } from './format-token.mjs';
7
12
  import { setThemeSetting, theme } from '../theme.mjs';
8
13
 
9
14
  setThemeSetting('mixdog', { persist: false });
@@ -18,14 +23,13 @@ function lexFirst(md, type) {
18
23
  return tokens.find((t) => t.type === type) ?? tokens[0];
19
24
  }
20
25
 
21
- test('fenced code block renders lang label without ``` fences and indented body', () => {
26
+ test('fenced code block has no ``` fences and flush-left body', () => {
22
27
  const token = lexFirst('```js\nconst x = 1;\n```\n', 'code');
23
28
  const out = formatToken(token);
24
29
  const plain = stripAnsi(out);
25
- assert.ok(plain.includes('js'), 'language label visible');
26
30
  assert.ok(!plain.includes('```'), 'no literal fence markers');
27
- assert.ok(plain.includes(' const x = 1;'), 'body is two-space indented');
28
- assert.ok(out.includes(rgbSgr(theme.mdCodeBlockBorder)), 'lang label uses border color');
31
+ assert.ok(plain.includes('const x = 1;'), 'body is flush-left');
32
+ assert.ok(!plain.trimStart().startsWith('js'), 'no language label row');
29
33
  });
30
34
 
31
35
  test('short code line has no terminal-width trailing padding band', () => {
@@ -33,7 +37,17 @@ test('short code line has no terminal-width trailing padding band', () => {
33
37
  const out = formatToken(token, 0, null, null, 60);
34
38
  const bodyLine = stripAnsi(out).split('\n').find((l) => l.includes('short'));
35
39
  assert.ok(bodyLine, 'body line present');
36
- assert.equal(bodyLine, ' short', 'body line is only indent + text');
40
+ assert.equal(bodyLine, 'short', 'body line is only text');
41
+ });
42
+
43
+ test('code block body lines share one compact background width', () => {
44
+ const token = lexFirst('```js\na\n\nlonger line\n```\n', 'code');
45
+ const out = formatToken(token, 0, null, null, 80);
46
+ const plainLines = stripAnsi(out).split('\n').filter((l) => l.length > 0);
47
+ const widths = plainLines.map((l) => stringWidth(l));
48
+ assert.ok(widths.length >= 3, 'body lines rendered');
49
+ assert.equal(new Set(widths).size, 1, 'every row uses the same content width');
50
+ assert.equal(Math.max(...widths), stringWidth('longer line'), 'width follows longest rendered line');
37
51
  });
38
52
 
39
53
  test('long code line wraps within requested body width', () => {
@@ -41,7 +55,7 @@ test('long code line wraps within requested body width', () => {
41
55
  const token = lexFirst(`\`\`\`js\n${long}\n\`\`\`\n`, 'code');
42
56
  const width = 20;
43
57
  const out = formatToken(token, 0, null, null, width);
44
- const bodyLines = stripAnsi(out).split('\n').filter((l) => l.startsWith(' '));
58
+ const bodyLines = stripAnsi(out).split('\n').filter((l) => l.includes('a'));
45
59
  assert.ok(bodyLines.length > 1, 'long line is wrapped into multiple rows');
46
60
  for (const line of bodyLines) {
47
61
  assert.ok(line.length <= width, `wrapped line "${line}" fits width ${width}`);
@@ -83,6 +97,52 @@ test('blockquote fenced code block respects narrowed width and has no fences', (
83
97
  }
84
98
  });
85
99
 
100
+ test('blockquote fenced code with blank line keeps quote prefix on every code row', () => {
101
+ const codeTok = lexFirst('```js\na\n\nb\n```\n', 'code');
102
+ const quoteTok = { type: 'blockquote', tokens: [codeTok] };
103
+ const outerWidth = 40;
104
+ const out = formatToken(quoteTok, 0, null, null, outerWidth);
105
+ const plain = stripAnsi(out);
106
+ assert.ok(!plain.includes('```'), 'no literal fence markers');
107
+ const bar = '\u258e';
108
+ const visibleLines = plain.split('\n').filter((l) => stringWidth(l) > 0);
109
+ assert.ok(visibleLines.length >= 3, 'two body lines + blank padded row');
110
+ for (const line of visibleLines) {
111
+ assert.ok(line.startsWith(bar), `quote bar on every visible row: "${line}"`);
112
+ assert.ok(stringWidth(line) <= outerWidth, `line fits outer width: "${line}"`);
113
+ }
114
+ const blankPadded = visibleLines.find((l) => !l.includes('a') && !l.includes('b'));
115
+ assert.ok(blankPadded, 'background-padded blank code row is rendered');
116
+ assert.ok(blankPadded.startsWith(bar), 'blank padded code row keeps quote prefix');
117
+ });
118
+
119
+ test('two-level nested list code block does not double-subtract list prefix width', () => {
120
+ const long = 'd'.repeat(40);
121
+ const codeTok = lexFirst(`\`\`\`js\n${long}\n\`\`\`\n`, 'code');
122
+ const innerList = {
123
+ type: 'list',
124
+ ordered: false,
125
+ items: [{ type: 'list_item', tokens: [codeTok] }],
126
+ };
127
+ const outerList = {
128
+ type: 'list',
129
+ ordered: false,
130
+ items: [{ type: 'list_item', tokens: [innerList] }],
131
+ };
132
+ const outerWidth = 30;
133
+ const singleLevelNested = formatToken(innerList, 2, null, null, outerWidth);
134
+ const twoLevelNested = formatToken(outerList, 0, null, null, outerWidth);
135
+ const codeLines = (plain) => plain.split('\n').filter((l) => l.includes('d'));
136
+ const singleLines = codeLines(stripAnsi(singleLevelNested));
137
+ const nestedLines = codeLines(stripAnsi(twoLevelNested));
138
+ assert.equal(
139
+ nestedLines.length,
140
+ singleLines.length,
141
+ 'nested list code wraps like one-level nested list at the same outer width',
142
+ );
143
+ assert.ok(nestedLines.length > 1, 'code is wrapped under narrow width');
144
+ });
145
+
86
146
  test('js highlighting colors keyword, string, number distinctly', () => {
87
147
  const token = lexFirst('```js\nconst n = 42;\nlet s = "hi";\n```\n', 'code');
88
148
  const out = formatToken(token);
@@ -91,6 +151,42 @@ test('js highlighting colors keyword, string, number distinctly', () => {
91
151
  assert.ok(out.includes(rgbSgr(theme.syntaxString)), 'string colored');
92
152
  });
93
153
 
154
+ test('highlight cache serves repeat block highlight without changing output', () => {
155
+ const c = extraColorizers();
156
+ const text = `const cacheProbe_${Date.now()} = 1;\nlet y = 2;\n`;
157
+ const sizeBefore = _highlightCacheSizeForTests();
158
+ const first = highlightCodeBlockToLines(text, 'js', c);
159
+ assert.equal(_highlightCacheSizeForTests(), sizeBefore + 1, 'first highlight populates cache');
160
+ const second = highlightCodeBlockToLines(text, 'js', c);
161
+ assert.deepEqual(second, first);
162
+ assert.equal(_highlightCacheSizeForTests(), sizeBefore + 1, 'cache hit does not grow entries');
163
+ assert.ok(first[0].includes('38;2;'), 'highlighted ANSI present');
164
+ });
165
+
166
+ test('inline codespan emits truecolor background band', () => {
167
+ const tokens = marked.lexer('`hello`');
168
+ const codespan = tokens[0].tokens[0];
169
+ assert.equal(codespan.type, 'codespan');
170
+ const out = formatToken(codespan);
171
+ assert.ok(out.includes('48;2;'), 'background SGR present');
172
+ assert.ok(stripAnsi(out).includes('hello'));
173
+ assert.ok(out.includes(rgbSgr(theme.mdCode)), 'inline code accent');
174
+ });
175
+
176
+ test('kotlin fence resolves and highlights', () => {
177
+ const token = lexFirst('```kotlin\nfun main() {}\n```\n', 'code');
178
+ const out = formatToken(token);
179
+ assert.ok(out.includes(rgbSgr(theme.syntaxKeyword)) || out.includes(rgbSgr(theme.mdCodeBlock)));
180
+ assert.ok(stripAnsi(out).includes('fun main'));
181
+ });
182
+
183
+ test('typescript fence uses typescript highlighter', () => {
184
+ const token = lexFirst('```ts\nconst x: number = 1;\n```\n', 'code');
185
+ const out = formatToken(token);
186
+ assert.ok(out.includes('38;2;'));
187
+ assert.ok(stripAnsi(out).includes('const x'));
188
+ });
189
+
94
190
  test('diff fenced block colors add/remove/hunk/header separately', () => {
95
191
  const diff = [
96
192
  '```diff',
@@ -142,7 +238,7 @@ test('unknown language falls back to flat code block color', () => {
142
238
  const token = lexFirst('```foobarlang\nsome text\n```\n', 'code');
143
239
  const out = formatToken(token);
144
240
  assert.ok(out.includes(rgbSgr(theme.mdCodeBlock)), 'flat code-block color used');
145
- assert.ok(stripAnsi(out).includes(' some text'), 'body still indented');
241
+ assert.ok(stripAnsi(out).includes('some text'), 'body is flush-left');
146
242
  });
147
243
 
148
244
  test('inline link emits OSC 8 hyperlink with styled label (URL hidden)', () => {
@@ -200,7 +296,7 @@ test('ordered list markers follow nesting depth (1. / a. / i.)', () => {
200
296
 
201
297
  test('every palette defines the full extended key set', () => {
202
298
  const required = [
203
- 'mdCodeBlockBorder', 'mdCodeBlockBg', 'mdLink', 'mdLinkText', 'mdStrong', 'mdEmph',
299
+ 'mdCodeBlockBorder', 'mdCodeBlockBg', 'mdCodeSpanBg', 'mdLink', 'mdLinkText', 'mdStrong', 'mdEmph',
204
300
  'mdDiffAdded', 'mdDiffRemoved', 'mdDiffHunk', 'mdDiffHeader', 'mdDiffContext',
205
301
  'mdDiffAddedBg', 'mdDiffRemovedBg',
206
302
  'syntaxComment', 'syntaxKeyword', 'syntaxFunction', 'syntaxVariable',