mixdog 0.8.1 → 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 (299) hide show
  1. package/README.md +47 -23
  2. package/package.json +34 -28
  3. package/scripts/_test-folder-dialog.mjs +30 -0
  4. package/scripts/agent-parallel-smoke.mjs +388 -0
  5. package/scripts/agent-tag-reuse-smoke.mjs +183 -0
  6. package/scripts/background-task-meta-smoke.mjs +38 -0
  7. package/scripts/boot-smoke.mjs +52 -9
  8. package/scripts/build-runtime-linux.sh +348 -0
  9. package/scripts/build-runtime-macos.sh +217 -0
  10. package/scripts/build-runtime-windows.ps1 +242 -0
  11. package/scripts/compact-active-turn-test.mjs +68 -0
  12. package/scripts/compact-smoke.mjs +859 -129
  13. package/scripts/compact-trigger-migration-smoke.mjs +187 -0
  14. package/scripts/fix-brief-fn.mjs +35 -0
  15. package/scripts/fix-format-tool-surface.mjs +24 -0
  16. package/scripts/fix-tool-exec-visible.mjs +42 -0
  17. package/scripts/generate-runtime-manifest.mjs +166 -0
  18. package/scripts/hook-bus-test.mjs +330 -0
  19. package/scripts/lead-workflow-smoke.mjs +33 -39
  20. package/scripts/live-worker-smoke.mjs +43 -37
  21. package/scripts/llm-trace-summary.mjs +315 -0
  22. package/scripts/memory-meta-concurrency-test.mjs +20 -0
  23. package/scripts/output-style-smoke.mjs +56 -15
  24. package/scripts/parent-abort-link-test.mjs +44 -0
  25. package/scripts/patch-agent-brief.mjs +48 -0
  26. package/scripts/patch-app.mjs +21 -0
  27. package/scripts/patch-app2.mjs +18 -0
  28. package/scripts/patch-dist-brief.mjs +96 -0
  29. package/scripts/patch-tool-exec.mjs +70 -0
  30. package/scripts/pretool-ask-runtime-test.mjs +54 -0
  31. package/scripts/provider-toolcall-test.mjs +376 -0
  32. package/scripts/reactive-compact-persist-smoke.mjs +124 -0
  33. package/scripts/sanitize-tool-pairs-test.mjs +260 -0
  34. package/scripts/session-context-bench.mjs +205 -33
  35. package/scripts/session-ingest-smoke.mjs +177 -0
  36. package/scripts/set-effort-config-test.mjs +41 -0
  37. package/scripts/smoke-runtime-negative.ps1 +106 -0
  38. package/scripts/smoke-runtime-negative.sh +97 -0
  39. package/scripts/smoke.mjs +25 -0
  40. package/scripts/tool-result-hook-test.mjs +48 -0
  41. package/scripts/tool-smoke.mjs +1222 -90
  42. package/scripts/toolcall-args-test.mjs +150 -0
  43. package/scripts/tui-background-failure-smoke.mjs +73 -0
  44. package/scripts/usage-metrics-epoch-smoke.mjs +114 -0
  45. package/src/agents/debugger/AGENT.md +8 -0
  46. package/src/agents/explore/AGENT.md +4 -0
  47. package/src/agents/heavy-worker/AGENT.md +9 -3
  48. package/src/agents/maintainer/AGENT.md +4 -0
  49. package/src/agents/reviewer/AGENT.md +8 -0
  50. package/src/agents/scheduler-task/AGENT.md +12 -0
  51. package/src/agents/scheduler-task/agent.json +6 -0
  52. package/src/agents/webhook-handler/AGENT.md +12 -0
  53. package/src/agents/webhook-handler/agent.json +6 -0
  54. package/src/agents/worker/AGENT.md +9 -3
  55. package/src/app.mjs +77 -3
  56. package/src/defaults/hidden-roles.json +17 -12
  57. package/src/headless-role.mjs +117 -0
  58. package/src/help.mjs +30 -0
  59. package/src/hooks/lib/permission-evaluator.cjs +11 -475
  60. package/src/lib/keychain-cjs.cjs +9 -1
  61. package/src/lib/mixdog-debug.cjs +0 -29
  62. package/src/lib/plugin-paths.cjs +1 -7
  63. package/src/lib/rules-builder.cjs +242 -98
  64. package/src/lib/text-utils.cjs +1 -1
  65. package/src/mixdog-session-runtime.mjs +2211 -446
  66. package/src/output-styles/default.md +12 -28
  67. package/src/output-styles/extreme-simple.md +9 -6
  68. package/src/output-styles/simple.md +22 -9
  69. package/src/repl.mjs +108 -59
  70. package/src/rules/agent/00-common.md +15 -0
  71. package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
  72. package/src/rules/agent/30-explorer.md +22 -0
  73. package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
  74. package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
  75. package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
  76. package/src/rules/lead/01-general.md +9 -5
  77. package/src/rules/lead/04-workflow.md +51 -12
  78. package/src/rules/lead/lead-tool.md +6 -0
  79. package/src/rules/shared/01-tool.md +12 -1
  80. package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
  81. package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
  82. package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
  83. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
  84. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
  85. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
  86. package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
  87. package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
  88. package/src/runtime/agent/orchestrator/config.mjs +174 -55
  89. package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
  90. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
  91. package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
  92. package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
  93. package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
  94. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
  95. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +380 -268
  96. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +131 -93
  97. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
  98. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
  99. package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
  100. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
  101. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
  102. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
  103. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
  104. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
  105. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
  106. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
  107. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +203 -71
  108. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +512 -312
  109. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -518
  110. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
  111. package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
  112. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +289 -11
  113. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
  114. package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
  115. package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
  116. package/src/runtime/agent/orchestrator/session/loop.mjs +840 -612
  117. package/src/runtime/agent/orchestrator/session/manager.mjs +1574 -459
  118. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
  119. package/src/runtime/agent/orchestrator/session/store.mjs +67 -29
  120. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  121. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
  122. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
  123. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +35 -8
  124. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -8
  125. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -53
  126. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
  127. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
  129. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
  130. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
  131. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
  132. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
  133. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
  134. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
  135. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
  136. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
  137. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
  138. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
  139. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
  140. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
  141. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
  142. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
  143. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
  146. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
  147. package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
  148. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
  149. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
  150. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
  151. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
  152. package/src/runtime/channels/index.mjs +149 -51
  153. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  154. package/src/runtime/channels/lib/config.mjs +0 -1
  155. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  156. package/src/runtime/channels/lib/executor.mjs +0 -3
  157. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  158. package/src/runtime/channels/lib/output-forwarder.mjs +1 -8
  159. package/src/runtime/channels/lib/runtime-paths.mjs +0 -6
  160. package/src/runtime/channels/lib/scheduler.mjs +18 -14
  161. package/src/runtime/channels/lib/session-discovery.mjs +3 -6
  162. package/src/runtime/channels/lib/tool-format.mjs +0 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -12
  164. package/src/runtime/channels/lib/webhook.mjs +1 -1
  165. package/src/runtime/channels/tool-defs.mjs +29 -29
  166. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  167. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  168. package/src/runtime/memory/index.mjs +519 -118
  169. package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
  170. package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
  171. package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
  172. package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
  173. package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
  174. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  175. package/src/runtime/memory/lib/memory-cycle1.mjs +28 -7
  176. package/src/runtime/memory/lib/memory-cycle2.mjs +4 -5
  177. package/src/runtime/memory/lib/memory-cycle3.mjs +2 -2
  178. package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -2
  179. package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
  180. package/src/runtime/memory/lib/memory.mjs +88 -9
  181. package/src/runtime/memory/lib/model-profile.mjs +1 -1
  182. package/src/runtime/memory/lib/pg/adapter.mjs +1 -1
  183. package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
  184. package/src/runtime/memory/lib/runtime-fetcher.mjs +80 -21
  185. package/src/runtime/memory/lib/session-ingest.mjs +196 -0
  186. package/src/runtime/memory/lib/trace-store.mjs +96 -51
  187. package/src/runtime/memory/tool-defs.mjs +46 -37
  188. package/src/runtime/search/index.mjs +104 -473
  189. package/src/runtime/search/lib/config.mjs +0 -4
  190. package/src/runtime/search/lib/state.mjs +1 -15
  191. package/src/runtime/search/lib/web-tools.mjs +44 -25
  192. package/src/runtime/search/tool-defs.mjs +16 -23
  193. package/src/runtime/shared/abort-controller.mjs +1 -1
  194. package/src/runtime/shared/atomic-file.mjs +4 -3
  195. package/src/runtime/shared/background-tasks.mjs +122 -11
  196. package/src/runtime/shared/child-spawn-gate.mjs +139 -0
  197. package/src/runtime/shared/config.mjs +7 -4
  198. package/src/runtime/shared/err-text.mjs +131 -4
  199. package/src/runtime/shared/llm/cost.mjs +2 -2
  200. package/src/runtime/shared/llm/http-agent.mjs +23 -7
  201. package/src/runtime/shared/llm/index.mjs +34 -11
  202. package/src/runtime/shared/llm/usage-log.mjs +4 -4
  203. package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
  204. package/src/runtime/shared/singleton-owner.mjs +104 -0
  205. package/src/runtime/shared/tool-execution-contract.mjs +199 -20
  206. package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
  207. package/src/runtime/shared/tool-surface.mjs +624 -95
  208. package/src/runtime/shared/user-data-guard.mjs +0 -2
  209. package/src/standalone/agent-task-status.mjs +203 -0
  210. package/src/standalone/agent-task-status.test.mjs +76 -0
  211. package/src/standalone/agent-tool.mjs +1913 -0
  212. package/src/standalone/channel-worker.mjs +370 -14
  213. package/src/standalone/explore-tool.mjs +165 -70
  214. package/src/standalone/folder-dialog.mjs +314 -0
  215. package/src/standalone/hook-bus.mjs +898 -22
  216. package/src/standalone/memory-runtime-proxy.mjs +320 -0
  217. package/src/standalone/projects.mjs +226 -0
  218. package/src/standalone/provider-admin.mjs +41 -24
  219. package/src/standalone/seeds.mjs +1 -78
  220. package/src/standalone/usage-dashboard.mjs +96 -8
  221. package/src/tui/App.jsx +4800 -2140
  222. package/src/tui/components/AnsiText.jsx +39 -28
  223. package/src/tui/components/ContextPanel.jsx +14 -4
  224. package/src/tui/components/Markdown.jsx +43 -77
  225. package/src/tui/components/MarkdownTable.jsx +9 -184
  226. package/src/tui/components/Message.jsx +28 -11
  227. package/src/tui/components/Picker.jsx +95 -56
  228. package/src/tui/components/PromptInput.jsx +428 -239
  229. package/src/tui/components/QueuedCommands.jsx +1 -1
  230. package/src/tui/components/SlashCommandPalette.jsx +27 -21
  231. package/src/tui/components/Spinner.jsx +67 -38
  232. package/src/tui/components/StatusLine.jsx +606 -38
  233. package/src/tui/components/TextEntryPanel.jsx +128 -9
  234. package/src/tui/components/ToolExecution.jsx +597 -362
  235. package/src/tui/components/TurnDone.jsx +3 -3
  236. package/src/tui/components/UsagePanel.jsx +3 -5
  237. package/src/tui/components/tool-output-format.mjs +499 -0
  238. package/src/tui/components/tool-output-format.test.mjs +312 -0
  239. package/src/tui/dist/index.mjs +9192 -2399
  240. package/src/tui/engine-runtime-notification.test.mjs +115 -0
  241. package/src/tui/engine-tool-result-text.test.mjs +75 -0
  242. package/src/tui/engine.mjs +1455 -279
  243. package/src/tui/figures.mjs +21 -40
  244. package/src/tui/index.jsx +75 -31
  245. package/src/tui/input-editing.mjs +25 -0
  246. package/src/tui/markdown/format-token.mjs +671 -69
  247. package/src/tui/markdown/format-token.test.mjs +312 -0
  248. package/src/tui/markdown/render-ansi.mjs +94 -0
  249. package/src/tui/markdown/render-ansi.test.mjs +108 -0
  250. package/src/tui/markdown/stream-fence.mjs +34 -0
  251. package/src/tui/markdown/stream-fence.test.mjs +26 -0
  252. package/src/tui/markdown/table-layout.mjs +250 -0
  253. package/src/tui/paste-attachments.mjs +0 -7
  254. package/src/tui/spinner-verbs.mjs +1 -2
  255. package/src/tui/statusline-ansi-bridge.mjs +172 -0
  256. package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
  257. package/src/tui/theme.mjs +756 -24
  258. package/src/tui/time-format.mjs +1 -1
  259. package/src/tui/transcript-tool-failures.mjs +67 -0
  260. package/src/tui/transcript-tool-failures.test.mjs +111 -0
  261. package/src/ui/ansi.mjs +1 -2
  262. package/src/ui/markdown.mjs +85 -26
  263. package/src/ui/markdown.test.mjs +70 -0
  264. package/src/ui/model-display.mjs +121 -0
  265. package/src/ui/session-stats.mjs +44 -0
  266. package/src/ui/statusline-context-label.test.mjs +15 -0
  267. package/src/ui/statusline.mjs +386 -178
  268. package/src/ui/tool-card.mjs +2 -13
  269. package/src/vendor/statusline/bin/statusline-lib.mjs +1 -620
  270. package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
  271. package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
  272. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
  273. package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
  274. package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
  275. package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
  276. package/src/workflows/default/WORKFLOW.md +12 -5
  277. package/src/workflows/default/workflow.json +0 -1
  278. package/src/workflows/solo/WORKFLOW.md +15 -0
  279. package/src/workflows/solo/workflow.json +7 -0
  280. package/vendor/ink/build/ink.js +54 -8
  281. package/vendor/ink/build/output.js +6 -1
  282. package/src/agents/scheduler-task.md +0 -3
  283. package/src/agents/web-researcher/AGENT.md +0 -3
  284. package/src/agents/web-researcher/agent.json +0 -6
  285. package/src/agents/webhook-handler.md +0 -3
  286. package/src/hooks/lib/permission-rules.cjs +0 -170
  287. package/src/hooks/lib/settings-loader.cjs +0 -112
  288. package/src/lib/hook-pipe-path.cjs +0 -10
  289. package/src/rules/bridge/00-common.md +0 -5
  290. package/src/rules/bridge/30-explorer.md +0 -4
  291. package/src/rules/lead/00-tool-lead.md +0 -5
  292. package/src/rules/shared/00-language.md +0 -3
  293. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  294. package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
  295. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  296. package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
  297. package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
  298. package/src/standalone/bridge-tool.mjs +0 -1414
  299. package/src/tui/runtime/shared/process-shutdown.mjs +0 -1
@@ -0,0 +1,312 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { marked } from 'marked';
4
+ import stripAnsi from 'strip-ansi';
5
+ import stringWidth from 'string-width';
6
+ import {
7
+ formatToken,
8
+ highlightCodeBlockToLines,
9
+ extraColorizers,
10
+ _highlightCacheSizeForTests,
11
+ } from './format-token.mjs';
12
+ import { setThemeSetting, theme } from '../theme.mjs';
13
+
14
+ setThemeSetting('mixdog', { persist: false });
15
+
16
+ function rgbSgr(value) {
17
+ const m = /^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/.exec(String(value || ''));
18
+ return m ? `38;2;${m[1]};${m[2]};${m[3]}` : null;
19
+ }
20
+
21
+ function lexFirst(md, type) {
22
+ const tokens = marked.lexer(md);
23
+ return tokens.find((t) => t.type === type) ?? tokens[0];
24
+ }
25
+
26
+ test('fenced code block has no ``` fences and flush-left body', () => {
27
+ const token = lexFirst('```js\nconst x = 1;\n```\n', 'code');
28
+ const out = formatToken(token);
29
+ const plain = stripAnsi(out);
30
+ assert.ok(!plain.includes('```'), 'no literal fence markers');
31
+ assert.ok(plain.includes('const x = 1;'), 'body is flush-left');
32
+ assert.ok(!plain.trimStart().startsWith('js'), 'no language label row');
33
+ });
34
+
35
+ test('short code line has no terminal-width trailing padding band', () => {
36
+ const token = lexFirst('```js\nshort\n```\n', 'code');
37
+ const out = formatToken(token, 0, null, null, 60);
38
+ const bodyLine = stripAnsi(out).split('\n').find((l) => l.includes('short'));
39
+ assert.ok(bodyLine, 'body line present');
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');
51
+ });
52
+
53
+ test('long code line wraps within requested body width', () => {
54
+ const long = 'a'.repeat(40);
55
+ const token = lexFirst(`\`\`\`js\n${long}\n\`\`\`\n`, 'code');
56
+ const width = 20;
57
+ const out = formatToken(token, 0, null, null, width);
58
+ const bodyLines = stripAnsi(out).split('\n').filter((l) => l.includes('a'));
59
+ assert.ok(bodyLines.length > 1, 'long line is wrapped into multiple rows');
60
+ for (const line of bodyLines) {
61
+ assert.ok(line.length <= width, `wrapped line "${line}" fits width ${width}`);
62
+ }
63
+ assert.equal(bodyLines.join('').replace(/\s/g, ''), long, 'wrapped segments preserve content');
64
+ });
65
+
66
+ test('list item fenced code block respects narrowed width and has no fences', () => {
67
+ const long = 'b'.repeat(36);
68
+ const codeTok = lexFirst(`\`\`\`js\n${long}\n\`\`\`\n`, 'code');
69
+ const listTok = {
70
+ type: 'list',
71
+ ordered: false,
72
+ items: [{ type: 'list_item', tokens: [codeTok] }],
73
+ };
74
+ const outerWidth = 22;
75
+ const out = formatToken(listTok, 0, null, null, outerWidth);
76
+ const plain = stripAnsi(out);
77
+ assert.ok(!plain.includes('```'), 'no literal fence markers');
78
+ const prefixed = plain.split('\n').filter((l) => l.includes('b'));
79
+ assert.ok(prefixed.length > 1, 'nested list code wraps under narrow width');
80
+ for (const line of prefixed) {
81
+ assert.ok(stringWidth(line) <= outerWidth, `line fits outer width: "${line}"`);
82
+ }
83
+ });
84
+
85
+ test('blockquote fenced code block respects narrowed width and has no fences', () => {
86
+ const long = 'c'.repeat(36);
87
+ const codeTok = lexFirst(`\`\`\`js\n${long}\n\`\`\`\n`, 'code');
88
+ const quoteTok = { type: 'blockquote', tokens: [codeTok] };
89
+ const outerWidth = 24;
90
+ const out = formatToken(quoteTok, 0, null, null, outerWidth);
91
+ const plain = stripAnsi(out);
92
+ assert.ok(!plain.includes('```'), 'no literal fence markers');
93
+ const lines = plain.split('\n').filter((l) => l.includes('c'));
94
+ assert.ok(lines.length > 1, 'blockquote code wraps under narrow width');
95
+ for (const line of lines) {
96
+ assert.ok(stringWidth(line) <= outerWidth, `line fits outer width: "${line}"`);
97
+ }
98
+ });
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
+
146
+ test('js highlighting colors keyword, string, number distinctly', () => {
147
+ const token = lexFirst('```js\nconst n = 42;\nlet s = "hi";\n```\n', 'code');
148
+ const out = formatToken(token);
149
+ assert.ok(out.includes(rgbSgr(theme.syntaxKeyword)), 'keyword colored');
150
+ assert.ok(out.includes(rgbSgr(theme.syntaxNumber)), 'number colored');
151
+ assert.ok(out.includes(rgbSgr(theme.syntaxString)), 'string colored');
152
+ });
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
+
190
+ test('diff fenced block colors add/remove/hunk/header separately', () => {
191
+ const diff = [
192
+ '```diff',
193
+ 'diff --git a/x b/x',
194
+ '--- a/x',
195
+ '+++ b/x',
196
+ '@@ -1,2 +1,2 @@',
197
+ '-old line',
198
+ '+new line',
199
+ ' context line',
200
+ '```',
201
+ '',
202
+ ].join('\n');
203
+ const token = lexFirst(diff, 'code');
204
+ const out = formatToken(token);
205
+ const added = rgbSgr(theme.mdDiffAdded);
206
+ const removed = rgbSgr(theme.mdDiffRemoved);
207
+ const hunk = rgbSgr(theme.mdDiffHunk);
208
+ const header = rgbSgr(theme.mdDiffHeader);
209
+ assert.ok(out.includes(added), 'added line color present');
210
+ assert.ok(out.includes(removed), 'removed line color present');
211
+ assert.ok(out.includes(hunk), 'hunk line color present');
212
+ assert.ok(out.includes(header), 'header line color present');
213
+ assert.equal(new Set([added, removed, hunk, header]).size, 4);
214
+ assert.ok(out.indexOf('+++ b/x') !== -1);
215
+ });
216
+
217
+ test('plus/minus file headers are not treated as add/remove', () => {
218
+ const diff = '```patch\n--- a/f\n+++ b/f\n@@ -0,0 +1 @@\n+x\n```\n';
219
+ const token = lexFirst(diff, 'code');
220
+ const out = formatToken(token);
221
+ const headerSgr = rgbSgr(theme.mdDiffHeader);
222
+ const addedSgr = rgbSgr(theme.mdDiffAdded);
223
+ const idxHeader = out.indexOf('+++ b/f');
224
+ const before = out.slice(0, idxHeader);
225
+ const lastHeader = before.lastIndexOf(headerSgr);
226
+ const lastAdded = before.lastIndexOf(addedSgr);
227
+ assert.ok(lastHeader > lastAdded, 'plus-plus-plus header uses header color, not added');
228
+ });
229
+
230
+ test('bare unified diff (no lang) is detected and colored', () => {
231
+ const token = lexFirst('```\n@@ -1 +1 @@\n-a\n+b\n```\n', 'code');
232
+ const out = formatToken(token);
233
+ assert.ok(out.includes(rgbSgr(theme.mdDiffHunk)), 'hunk colored on bare diff');
234
+ assert.ok(out.includes(rgbSgr(theme.mdDiffAdded)), 'add colored on bare diff');
235
+ });
236
+
237
+ test('unknown language falls back to flat code block color', () => {
238
+ const token = lexFirst('```foobarlang\nsome text\n```\n', 'code');
239
+ const out = formatToken(token);
240
+ assert.ok(out.includes(rgbSgr(theme.mdCodeBlock)), 'flat code-block color used');
241
+ assert.ok(stripAnsi(out).includes('some text'), 'body is flush-left');
242
+ });
243
+
244
+ test('inline link emits OSC 8 hyperlink with styled label (URL hidden)', () => {
245
+ const token = lexFirst('[label](https://example.com)', 'paragraph');
246
+ const out = formatToken(token);
247
+ // OSC 8 wraps the visible label; the raw URL lives in the escape, not as
248
+ // on-screen text, so stripAnsi keeps only the label.
249
+ assert.ok(out.includes('\x1b]8;;https://example.com\x07'), 'OSC 8 link target present');
250
+ assert.ok(out.includes('\x1b]8;;\x07'), 'OSC 8 close present');
251
+ assert.ok(stripAnsi(out).includes('label'), 'link label text visible');
252
+ assert.ok(out.includes(rgbSgr(theme.mdLinkText)), 'link label colored');
253
+ });
254
+
255
+ test('bare-url link emits OSC 8 with the URL as visible clickable text', () => {
256
+ const token = lexFirst('<https://example.com>', 'paragraph');
257
+ const out = formatToken(token);
258
+ assert.ok(out.includes('\x1b]8;;https://example.com\x07'), 'OSC 8 link target present');
259
+ assert.ok(stripAnsi(out).includes('https://example.com'), 'URL visible as text');
260
+ assert.ok(out.includes(rgbSgr(theme.mdLink)), 'link URL colored');
261
+ });
262
+
263
+ test('strong and em use distinct mdStrong / mdEmph colors', () => {
264
+ const strongTok = lexFirst('**bold**', 'paragraph');
265
+ const emTok = lexFirst('*ital*', 'paragraph');
266
+ const strongOut = formatToken(strongTok);
267
+ const emOut = formatToken(emTok);
268
+ assert.ok(strongOut.includes(rgbSgr(theme.mdStrong)), 'strong colored');
269
+ assert.ok(emOut.includes(rgbSgr(theme.mdEmph)), 'em colored');
270
+ assert.ok(strongOut.includes('\x1b[1m'), 'strong is bold');
271
+ assert.ok(emOut.includes('\x1b[3m'), 'em is italic');
272
+ });
273
+
274
+ test('h1 heading is bold, italic, and underlined', () => {
275
+ const token = lexFirst('# Title', 'heading');
276
+ const out = formatToken(token);
277
+ assert.ok(out.includes('\x1b[1m'), 'h1 is bold');
278
+ assert.ok(out.includes('\x1b[3m'), 'h1 is italic');
279
+ assert.ok(out.includes('\x1b[4m'), 'h1 is underlined');
280
+ });
281
+
282
+ test('ordered list markers follow nesting depth (1. / a. / i.)', () => {
283
+ const md = [
284
+ '1. one',
285
+ ' 1. two',
286
+ ' 1. three',
287
+ ' 1. four',
288
+ ].join('\n');
289
+ const tokens = marked.lexer(md);
290
+ const plain = stripAnsi(tokens.map((t) => formatToken(t)).join(''));
291
+ assert.ok(plain.includes('1. one'), 'depth 0 arabic');
292
+ assert.ok(plain.includes('1. two'), 'depth 1 arabic');
293
+ assert.ok(plain.includes('a. three'), 'depth 2 lowercase letter');
294
+ assert.ok(plain.includes('i. four'), 'depth 3 lowercase roman');
295
+ });
296
+
297
+ test('every palette defines the full extended key set', () => {
298
+ const required = [
299
+ 'mdCodeBlockBorder', 'mdCodeBlockBg', 'mdCodeSpanBg', 'mdLink', 'mdLinkText', 'mdStrong', 'mdEmph',
300
+ 'mdDiffAdded', 'mdDiffRemoved', 'mdDiffHunk', 'mdDiffHeader', 'mdDiffContext',
301
+ 'mdDiffAddedBg', 'mdDiffRemovedBg',
302
+ 'syntaxComment', 'syntaxKeyword', 'syntaxFunction', 'syntaxVariable',
303
+ 'syntaxString', 'syntaxNumber', 'syntaxType', 'syntaxOperator', 'syntaxPunctuation',
304
+ ];
305
+ for (const id of ['mixdog', 'pi-dark', 'claude-dark', 'dracula', 'tokyonight', 'nord', 'gruvbox', 'catppuccin', 'everforest']) {
306
+ setThemeSetting(id, { persist: false });
307
+ for (const key of required) {
308
+ assert.ok(/^rgb\(\d+,\s*\d+,\s*\d+\)$/.test(String(theme[key])), `${id}.${key} is an rgb() string`);
309
+ }
310
+ }
311
+ setThemeSetting('mixdog', { persist: false });
312
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * markdown/render-ansi.mjs — markdown → token + ANSI helpers (no JSX).
3
+ *
4
+ * The lexing/token-cache and the partial-fence trim decision live here, free of
5
+ * the ink/JSX render stack, so the exact streaming-vs-stable token path the
6
+ * <Markdown> component renders is unit-testable without an ink renderer.
7
+ *
8
+ * - lexMarkdown(content, { trimPartialFences }) → marked tokens. Stable
9
+ * (non-streaming) content is cached; the streaming suffix is lexed fresh and
10
+ * trimmed (a partial closing fence is dropped) and never cached, so trimming
11
+ * can never corrupt the shared cache used by stable text.
12
+ * - renderTokenAnsiSegments(content, opts) → array of { type, ansi?, token }
13
+ * describing what the component emits: 'table' segments carry the token for
14
+ * MarkdownTable; 'ansi' segments carry the formatToken() string.
15
+ */
16
+ import { marked } from 'marked';
17
+ import { formatToken } from './format-token.mjs';
18
+ import { trimPartialClosingFences } from './stream-fence.mjs';
19
+
20
+ const TOKEN_CACHE_MAX = 500;
21
+ const tokenCache = new Map();
22
+ const MD_SYNTAX_RE = /[#*`|[>\-_~]|\n\n|^\d+\. |\n\d+\. /;
23
+
24
+ let _configured = false;
25
+ export function configureMarked() {
26
+ if (_configured) return;
27
+ _configured = true;
28
+ // Disable strikethrough: models use ~ for "approximate" (~100), not <del>.
29
+ marked.use({ tokenizer: { del() { return undefined; } } });
30
+ }
31
+
32
+ export function hasMarkdownSyntax(text) {
33
+ const value = String(text ?? '');
34
+ return MD_SYNTAX_RE.test(value);
35
+ }
36
+
37
+ export function lexMarkdown(content, { trimPartialFences = false } = {}) {
38
+ configureMarked();
39
+ const text = String(content ?? '');
40
+ if (!hasMarkdownSyntax(text)) {
41
+ return [{
42
+ type: 'paragraph',
43
+ raw: text,
44
+ text,
45
+ tokens: [{ type: 'text', raw: text, text }],
46
+ }];
47
+ }
48
+ // Streaming suffix: lex fresh and trim a partial closing fence so an open code
49
+ // block does not include a lone trailing backtick as a body line. These tokens
50
+ // are transient per-delta and trimming mutates them in place, so they are NOT
51
+ // cached — caching would corrupt the shared cache for stable text.
52
+ if (trimPartialFences) {
53
+ const tokens = marked.lexer(text);
54
+ trimPartialClosingFences(tokens);
55
+ return tokens;
56
+ }
57
+ const hit = tokenCache.get(text);
58
+ if (hit) {
59
+ tokenCache.delete(text);
60
+ tokenCache.set(text, hit);
61
+ return hit;
62
+ }
63
+ const tokens = marked.lexer(text);
64
+ if (tokenCache.size >= TOKEN_CACHE_MAX) {
65
+ const first = tokenCache.keys().next().value;
66
+ if (first !== undefined) tokenCache.delete(first);
67
+ }
68
+ tokenCache.set(text, tokens);
69
+ return tokens;
70
+ }
71
+
72
+ /**
73
+ * Render markdown to an ordered list of segments the component maps to ink:
74
+ * { type: 'table', token } → <MarkdownTable token={token} />
75
+ * { type: 'ansi', ansi: string } → <AnsiText>{ansi}</AnsiText>
76
+ * Blank-edge-only ansi segments are dropped (mirrors the component's pushAnsi).
77
+ */
78
+ export function renderTokenAnsiSegments(content, opts = {}) {
79
+ const tokens = lexMarkdown(content, opts);
80
+ const width = Number(opts.width) || 0;
81
+ const segments = [];
82
+ for (const token of tokens) {
83
+ if (token.type === 'table') {
84
+ segments.push({ type: 'table', token });
85
+ } else if (token.type === 'space') {
86
+ continue;
87
+ } else {
88
+ const ansi = String(formatToken(token, 0, null, null, width) ?? '').replace(/^\n+|\n+$/g, '');
89
+ if (!ansi) continue;
90
+ segments.push({ type: 'ansi', ansi, token });
91
+ }
92
+ }
93
+ return segments;
94
+ }
@@ -0,0 +1,108 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import stripAnsi from 'strip-ansi';
4
+ import { assistantBodyWidth } from './table-layout.mjs';
5
+ import { renderTokenAnsiSegments, lexMarkdown, hasMarkdownSyntax } from './render-ansi.mjs';
6
+ import { setThemeSetting } from '../theme.mjs';
7
+
8
+ setThemeSetting('mixdog', { persist: false });
9
+
10
+ // Mirrors the streaming suffix the <Markdown trimPartialFences> path renders.
11
+ const STREAMING = '```js\nconst x = 1;\n`';
12
+
13
+ function codeBlockVisibleLines(segments) {
14
+ const codeSeg = segments.find((s) => s.type === 'ansi' && s.token?.type === 'code');
15
+ assert.ok(codeSeg, 'a fenced code segment is rendered');
16
+ return stripAnsi(codeSeg.ansi).split('\n');
17
+ }
18
+
19
+ test('streaming render path drops the partial trailing backtick body line', () => {
20
+ const segments = renderTokenAnsiSegments(STREAMING, { trimPartialFences: true });
21
+ const lines = codeBlockVisibleLines(segments);
22
+ const bodyLines = lines.filter((l) => !/^js$/.test(l.trim()));
23
+ for (const l of bodyLines) {
24
+ assert.notEqual(l.trim(), '`', 'no body line is a lone partial fence');
25
+ }
26
+ // The real code body is still present.
27
+ assert.ok(lines.some((l) => l.includes('const x = 1;')), 'code body preserved');
28
+ });
29
+
30
+ test('without trimPartialFences the partial backtick survives as a body line', () => {
31
+ // Proves the trim flag is what removes it on the render path (not formatToken).
32
+ const segments = renderTokenAnsiSegments(STREAMING, { trimPartialFences: false });
33
+ const lines = codeBlockVisibleLines(segments);
34
+ const bodyLines = lines.filter((l) => !/^js$/.test(l.trim()));
35
+ assert.ok(
36
+ bodyLines.some((l) => l.trim() === '`'),
37
+ 'untrimmed path keeps the partial fence as a body line',
38
+ );
39
+ });
40
+
41
+ test('trimPartialFences tokens are not cached (stable text unaffected)', () => {
42
+ const stable = '```js\nconst y = 2;\n```\n';
43
+ const a = lexMarkdown(stable, { trimPartialFences: false });
44
+ const b = lexMarkdown(stable, { trimPartialFences: false });
45
+ assert.strictEqual(a, b, 'stable content returns the cached token array');
46
+ // Streaming lex must be a fresh (non-cached) array.
47
+ const c = lexMarkdown(STREAMING, { trimPartialFences: true });
48
+ const d = lexMarkdown(STREAMING, { trimPartialFences: true });
49
+ assert.notStrictEqual(c, d, 'streaming lex is never cached');
50
+ });
51
+
52
+ test('closed code block renders lang label and body without fence markers', () => {
53
+ const segments = renderTokenAnsiSegments('```js\nconst z = 3;\n```\n', { trimPartialFences: true });
54
+ const lines = codeBlockVisibleLines(segments);
55
+ assert.ok(lines.some((l) => l.includes('const z = 3;')), 'body present');
56
+ assert.ok(lines.some((l) => l.trim() === 'js'), 'language label present');
57
+ assert.ok(!lines.some((l) => l.includes('```')), 'no literal fence markers');
58
+ });
59
+
60
+ test('renderTokenAnsiSegments width matches assistant body width for code pre-wrap', () => {
61
+ const long = 'z'.repeat(60);
62
+ const md = `\`\`\`js\n${long}\n\`\`\``;
63
+ const bodyWidth = assistantBodyWidth(40);
64
+ const atBody = renderTokenAnsiSegments(md, { width: bodyWidth });
65
+ const atDefault = renderTokenAnsiSegments(md, { width: 80 });
66
+ const bodyLines = (seg) => stripAnsi(seg.find((s) => s.token?.type === 'code').ansi)
67
+ .split('\n')
68
+ .filter((l) => l.startsWith(' '));
69
+ assert.ok(bodyLines(atBody).length > bodyLines(atDefault).length,
70
+ 'narrow body width produces more wrapped code rows than width 80');
71
+ });
72
+
73
+ const PLAIN_PREFIX = 'x'.repeat(501);
74
+
75
+ test('hasMarkdownSyntax detects strong markers after a 500+ char plain prefix', () => {
76
+ assert.ok(hasMarkdownSyntax(`${PLAIN_PREFIX}**late bold**`));
77
+ });
78
+ test('hasMarkdownSyntax detects inline code after a 500+ char plain prefix', () => {
79
+ assert.ok(hasMarkdownSyntax(`${PLAIN_PREFIX}\`late code\``));
80
+ });
81
+
82
+ test('render path parses fenced code after a 500+ char plain prefix', () => {
83
+ const input = `${PLAIN_PREFIX}\n\`\`\`js\nconst late = true;\n\`\`\``;
84
+ const tokens = lexMarkdown(input);
85
+ assert.ok(
86
+ tokens.some((t) => t.type === 'code' && String(t.text).includes('const late = true;')),
87
+ 'lexer emits a code token with the fenced body',
88
+ );
89
+ const segments = renderTokenAnsiSegments(input);
90
+ const lines = codeBlockVisibleLines(segments);
91
+ assert.ok(lines.some((l) => l.includes('const late = true;')), 'code block body is rendered');
92
+ const visible = segments.map((s) => stripAnsi(s.ansi ?? '')).join('\n');
93
+ assert.ok(!visible.includes(`${PLAIN_PREFIX}\`\`\`js`), 'prefix is not glued to raw fence markers');
94
+ });
95
+
96
+ test('render path parses strong after a 500+ char plain prefix (no raw **)', () => {
97
+ const segments = renderTokenAnsiSegments(`${PLAIN_PREFIX}**rendered**`);
98
+ const visible = segments.map((s) => stripAnsi(s.ansi ?? '')).join('');
99
+ assert.ok(visible.includes('rendered'), 'bold text is present');
100
+ assert.ok(!visible.includes('**'), 'markdown strong markers are not leaked');
101
+ });
102
+
103
+ test('render path parses inline code after a 500+ char plain prefix (no raw backticks)', () => {
104
+ const segments = renderTokenAnsiSegments(`${PLAIN_PREFIX}\`snippet\``);
105
+ const visible = segments.map((s) => stripAnsi(s.ansi ?? '')).join('');
106
+ assert.ok(visible.includes('snippet'), 'codespan text is present');
107
+ assert.ok(!visible.includes('`'), 'inline code backticks are not leaked');
108
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * markdown/stream-fence.mjs — streamed partial closing-fence trimming.
3
+ *
4
+ * While a fenced code block is still streaming in, marked may briefly emit the
5
+ * partial closing fence (a lone ` or ``) as a final code-block line, which makes
6
+ * the rendered block grow then shrink as the remaining backticks arrive — a
7
+ * visible flicker. `trimPartialClosingFences` strips that trailing partial fence
8
+ * from the LAST code token (recursing through trailing list/blockquote nesting)
9
+ * so the block height stays stable across deltas.
10
+ *
11
+ * Kept in its own dependency-free module so it can be unit-tested without the
12
+ * ink/JSX render stack. Ref: earendil-works/pi packages/tui/src/components/markdown.ts.
13
+ */
14
+ export function trimPartialClosingFences(tokens) {
15
+ const token = tokens?.[tokens.length - 1];
16
+ if (!token) return;
17
+ if (token.type === 'list') {
18
+ const items = token.items ?? [];
19
+ trimPartialClosingFences(items[items.length - 1]?.tokens ?? []);
20
+ return;
21
+ }
22
+ if (token.type === 'blockquote') {
23
+ trimPartialClosingFences(token.tokens ?? []);
24
+ return;
25
+ }
26
+ if (token.type !== 'code') return;
27
+ const marker = /^(`{3,}|~{3,})/.exec(token.raw ?? '')?.[1];
28
+ const lastLine = String(token.raw ?? '').split('\n').pop();
29
+ if (!marker || !lastLine) return;
30
+ // Only trim a partial fence: shorter than the opening marker and made up
31
+ // solely of the same fence char. A complete closing fence is left intact.
32
+ if (lastLine.length >= marker.length || lastLine !== marker[0].repeat(lastLine.length)) return;
33
+ token.text = String(token.text ?? '').slice(0, -lastLine.length).replace(/\n$/, '');
34
+ }
@@ -0,0 +1,26 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { marked } from 'marked';
4
+ import { trimPartialClosingFences } from './stream-fence.mjs';
5
+
6
+ test('trims a partial closing fence (single backtick) from open code block', () => {
7
+ const tokens = marked.lexer('```js\nconst x = 1;\n`');
8
+ trimPartialClosingFences(tokens);
9
+ const code = tokens.find((t) => t.type === 'code');
10
+ assert.ok(code, 'code token exists');
11
+ assert.ok(!code.text.endsWith('`'), 'partial fence trimmed from text');
12
+ assert.ok(code.text.includes('const x = 1;'), 'real body preserved');
13
+ });
14
+
15
+ test('leaves a complete closing fence intact', () => {
16
+ const tokens = marked.lexer('```js\nconst x = 1;\n```\n');
17
+ const code = tokens.find((t) => t.type === 'code');
18
+ const before = code.text;
19
+ trimPartialClosingFences(tokens);
20
+ assert.equal(code.text, before, 'closed block unchanged');
21
+ });
22
+
23
+ test('no-op on non-code trailing token', () => {
24
+ const tokens = marked.lexer('hello world\n');
25
+ assert.doesNotThrow(() => trimPartialClosingFences(tokens));
26
+ });