mixdog 0.8.0 → 0.9.0

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 (285) hide show
  1. package/README.md +47 -23
  2. package/package.json +33 -27
  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 +344 -0
  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 +1223 -95
  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 -7
  62. package/src/lib/rules-builder.cjs +240 -96
  63. package/src/lib/text-utils.cjs +1 -1
  64. package/src/mixdog-session-runtime.mjs +2325 -450
  65. package/src/output-styles/default.md +12 -28
  66. package/src/output-styles/extreme-simple.md +9 -6
  67. package/src/output-styles/simple.md +22 -9
  68. package/src/repl.mjs +118 -59
  69. package/src/rules/agent/00-common.md +15 -0
  70. package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
  71. package/src/rules/agent/30-explorer.md +22 -0
  72. package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
  73. package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
  74. package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
  75. package/src/rules/lead/01-general.md +9 -5
  76. package/src/rules/lead/04-workflow.md +51 -12
  77. package/src/rules/lead/lead-tool.md +6 -0
  78. package/src/rules/shared/01-tool.md +12 -1
  79. package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
  80. package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
  81. package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
  82. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
  83. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
  84. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
  85. package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
  86. package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
  87. package/src/runtime/agent/orchestrator/config.mjs +174 -55
  88. package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
  89. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
  90. package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
  91. package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
  92. package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
  93. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
  94. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +377 -243
  95. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +146 -93
  96. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
  97. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
  98. package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
  99. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
  100. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
  101. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
  102. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
  103. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
  104. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
  105. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
  106. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +221 -70
  107. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +477 -147
  108. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -496
  109. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
  110. package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
  111. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +31 -11
  112. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
  113. package/src/runtime/agent/orchestrator/session/cache/post-edit-marks.mjs +4 -4
  114. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +1 -1
  115. package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
  116. package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
  117. package/src/runtime/agent/orchestrator/session/loop.mjs +851 -674
  118. package/src/runtime/agent/orchestrator/session/manager.mjs +1593 -466
  119. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
  120. package/src/runtime/agent/orchestrator/session/store.mjs +291 -46
  121. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  122. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
  123. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
  124. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +34 -7
  125. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +1 -1
  126. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -0
  127. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +9 -9
  128. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
  129. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
  130. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
  131. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
  132. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
  133. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
  134. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
  135. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
  136. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
  137. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
  138. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
  139. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
  140. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
  141. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
  142. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
  143. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
  144. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +1 -1
  145. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
  146. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
  147. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
  148. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
  149. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
  150. package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
  151. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
  152. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
  153. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
  154. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
  155. package/src/runtime/channels/index.mjs +152 -24
  156. package/src/runtime/channels/lib/scheduler.mjs +18 -14
  157. package/src/runtime/channels/lib/session-discovery.mjs +3 -2
  158. package/src/runtime/channels/lib/tool-format.mjs +0 -2
  159. package/src/runtime/channels/lib/transcript-discovery.mjs +3 -2
  160. package/src/runtime/channels/lib/webhook.mjs +1 -1
  161. package/src/runtime/channels/tool-defs.mjs +29 -29
  162. package/src/runtime/memory/index.mjs +635 -107
  163. package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
  164. package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
  165. package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
  166. package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
  167. package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
  168. package/src/runtime/memory/lib/memory-cycle1.mjs +38 -17
  169. package/src/runtime/memory/lib/memory-cycle2.mjs +6 -7
  170. package/src/runtime/memory/lib/memory-cycle3.mjs +4 -4
  171. package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -1
  172. package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
  173. package/src/runtime/memory/lib/memory.mjs +88 -9
  174. package/src/runtime/memory/lib/model-profile.mjs +1 -1
  175. package/src/runtime/memory/lib/pg/adapter.mjs +15 -1
  176. package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
  177. package/src/runtime/memory/lib/runtime-fetcher.mjs +37 -3
  178. package/src/runtime/memory/lib/session-ingest.mjs +194 -0
  179. package/src/runtime/memory/lib/trace-store.mjs +96 -51
  180. package/src/runtime/memory/tool-defs.mjs +46 -37
  181. package/src/runtime/search/index.mjs +102 -466
  182. package/src/runtime/search/lib/web-tools.mjs +45 -25
  183. package/src/runtime/search/tool-defs.mjs +16 -23
  184. package/src/runtime/shared/abort-controller.mjs +1 -1
  185. package/src/runtime/shared/atomic-file.mjs +4 -3
  186. package/src/runtime/shared/background-tasks.mjs +122 -11
  187. package/src/runtime/shared/child-spawn-gate.mjs +145 -0
  188. package/src/runtime/shared/config.mjs +7 -4
  189. package/src/runtime/shared/err-text.mjs +131 -4
  190. package/src/runtime/shared/llm/cost.mjs +2 -2
  191. package/src/runtime/shared/llm/http-agent.mjs +23 -7
  192. package/src/runtime/shared/llm/index.mjs +34 -11
  193. package/src/runtime/shared/llm/usage-log.mjs +4 -4
  194. package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
  195. package/src/runtime/shared/singleton-owner.mjs +104 -0
  196. package/src/runtime/shared/tool-execution-contract.mjs +199 -20
  197. package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
  198. package/src/runtime/shared/tool-surface.mjs +624 -98
  199. package/src/runtime/shared/user-data-guard.mjs +0 -2
  200. package/src/standalone/agent-task-status.mjs +203 -0
  201. package/src/standalone/agent-task-status.test.mjs +76 -0
  202. package/src/standalone/agent-tool.mjs +1913 -0
  203. package/src/standalone/channel-worker.mjs +370 -14
  204. package/src/standalone/explore-tool.mjs +165 -70
  205. package/src/standalone/folder-dialog.mjs +314 -0
  206. package/src/standalone/hook-bus.mjs +898 -22
  207. package/src/standalone/memory-runtime-proxy.mjs +320 -0
  208. package/src/standalone/projects.mjs +226 -0
  209. package/src/standalone/provider-admin.mjs +41 -24
  210. package/src/standalone/seeds.mjs +2 -69
  211. package/src/standalone/usage-dashboard.mjs +96 -8
  212. package/src/tui/App.jsx +4798 -2153
  213. package/src/tui/components/AnsiText.jsx +39 -28
  214. package/src/tui/components/ContextPanel.jsx +87 -29
  215. package/src/tui/components/Markdown.jsx +43 -77
  216. package/src/tui/components/MarkdownTable.jsx +9 -184
  217. package/src/tui/components/Message.jsx +28 -11
  218. package/src/tui/components/Picker.jsx +95 -56
  219. package/src/tui/components/PromptInput.jsx +367 -239
  220. package/src/tui/components/QueuedCommands.jsx +1 -1
  221. package/src/tui/components/SlashCommandPalette.jsx +27 -21
  222. package/src/tui/components/Spinner.jsx +67 -38
  223. package/src/tui/components/StatusLine.jsx +606 -38
  224. package/src/tui/components/TextEntryPanel.jsx +128 -9
  225. package/src/tui/components/ToolExecution.jsx +617 -368
  226. package/src/tui/components/TurnDone.jsx +3 -3
  227. package/src/tui/components/UsagePanel.jsx +3 -5
  228. package/src/tui/components/tool-output-format.mjs +365 -0
  229. package/src/tui/components/tool-output-format.test.mjs +220 -0
  230. package/src/tui/dist/index.mjs +8915 -2418
  231. package/src/tui/engine-runtime-notification.test.mjs +115 -0
  232. package/src/tui/engine-tool-result-text.test.mjs +75 -0
  233. package/src/tui/engine.mjs +1455 -279
  234. package/src/tui/figures.mjs +21 -40
  235. package/src/tui/index.jsx +75 -31
  236. package/src/tui/input-editing.mjs +25 -0
  237. package/src/tui/markdown/format-token.mjs +511 -68
  238. package/src/tui/markdown/format-token.test.mjs +216 -0
  239. package/src/tui/markdown/render-ansi.mjs +94 -0
  240. package/src/tui/markdown/render-ansi.test.mjs +108 -0
  241. package/src/tui/markdown/stream-fence.mjs +34 -0
  242. package/src/tui/markdown/stream-fence.test.mjs +26 -0
  243. package/src/tui/markdown/table-layout.mjs +250 -0
  244. package/src/tui/paste-attachments.mjs +0 -7
  245. package/src/tui/spinner-verbs.mjs +1 -2
  246. package/src/tui/statusline-ansi-bridge.mjs +172 -0
  247. package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
  248. package/src/tui/theme.mjs +746 -24
  249. package/src/tui/time-format.mjs +1 -1
  250. package/src/tui/transcript-tool-failures.mjs +67 -0
  251. package/src/tui/transcript-tool-failures.test.mjs +111 -0
  252. package/src/ui/ansi.mjs +1 -2
  253. package/src/ui/markdown.mjs +85 -26
  254. package/src/ui/markdown.test.mjs +70 -0
  255. package/src/ui/model-display.mjs +121 -0
  256. package/src/ui/session-stats.mjs +44 -0
  257. package/src/ui/statusline-context-label.test.mjs +15 -0
  258. package/src/ui/statusline.mjs +386 -178
  259. package/src/ui/tool-card.mjs +3 -16
  260. package/src/vendor/statusline/bin/statusline-lib.mjs +8 -4
  261. package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
  262. package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
  263. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
  264. package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
  265. package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
  266. package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
  267. package/src/workflows/default/WORKFLOW.md +12 -5
  268. package/src/workflows/default/workflow.json +0 -1
  269. package/src/workflows/solo/WORKFLOW.md +15 -0
  270. package/src/workflows/solo/workflow.json +7 -0
  271. package/vendor/ink/build/output.js +6 -1
  272. package/src/agents/scheduler-task.md +0 -3
  273. package/src/agents/web-researcher/AGENT.md +0 -3
  274. package/src/agents/web-researcher/agent.json +0 -6
  275. package/src/agents/webhook-handler.md +0 -3
  276. package/src/rules/bridge/00-common.md +0 -5
  277. package/src/rules/bridge/30-explorer.md +0 -4
  278. package/src/rules/lead/00-tool-lead.md +0 -5
  279. package/src/rules/shared/00-language.md +0 -3
  280. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  281. package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
  282. package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
  283. package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
  284. package/src/standalone/bridge-tool.mjs +0 -1414
  285. package/src/tui/runtime/shared/process-shutdown.mjs +0 -1
@@ -0,0 +1,216 @@
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 { formatToken } from './format-token.mjs';
7
+ import { setThemeSetting, theme } from '../theme.mjs';
8
+
9
+ setThemeSetting('mixdog', { persist: false });
10
+
11
+ function rgbSgr(value) {
12
+ const m = /^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/.exec(String(value || ''));
13
+ return m ? `38;2;${m[1]};${m[2]};${m[3]}` : null;
14
+ }
15
+
16
+ function lexFirst(md, type) {
17
+ const tokens = marked.lexer(md);
18
+ return tokens.find((t) => t.type === type) ?? tokens[0];
19
+ }
20
+
21
+ test('fenced code block renders lang label without ``` fences and indented body', () => {
22
+ const token = lexFirst('```js\nconst x = 1;\n```\n', 'code');
23
+ const out = formatToken(token);
24
+ const plain = stripAnsi(out);
25
+ assert.ok(plain.includes('js'), 'language label visible');
26
+ 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');
29
+ });
30
+
31
+ test('short code line has no terminal-width trailing padding band', () => {
32
+ const token = lexFirst('```js\nshort\n```\n', 'code');
33
+ const out = formatToken(token, 0, null, null, 60);
34
+ const bodyLine = stripAnsi(out).split('\n').find((l) => l.includes('short'));
35
+ assert.ok(bodyLine, 'body line present');
36
+ assert.equal(bodyLine, ' short', 'body line is only indent + text');
37
+ });
38
+
39
+ test('long code line wraps within requested body width', () => {
40
+ const long = 'a'.repeat(40);
41
+ const token = lexFirst(`\`\`\`js\n${long}\n\`\`\`\n`, 'code');
42
+ const width = 20;
43
+ const out = formatToken(token, 0, null, null, width);
44
+ const bodyLines = stripAnsi(out).split('\n').filter((l) => l.startsWith(' '));
45
+ assert.ok(bodyLines.length > 1, 'long line is wrapped into multiple rows');
46
+ for (const line of bodyLines) {
47
+ assert.ok(line.length <= width, `wrapped line "${line}" fits width ${width}`);
48
+ }
49
+ assert.equal(bodyLines.join('').replace(/\s/g, ''), long, 'wrapped segments preserve content');
50
+ });
51
+
52
+ test('list item fenced code block respects narrowed width and has no fences', () => {
53
+ const long = 'b'.repeat(36);
54
+ const codeTok = lexFirst(`\`\`\`js\n${long}\n\`\`\`\n`, 'code');
55
+ const listTok = {
56
+ type: 'list',
57
+ ordered: false,
58
+ items: [{ type: 'list_item', tokens: [codeTok] }],
59
+ };
60
+ const outerWidth = 22;
61
+ const out = formatToken(listTok, 0, null, null, outerWidth);
62
+ const plain = stripAnsi(out);
63
+ assert.ok(!plain.includes('```'), 'no literal fence markers');
64
+ const prefixed = plain.split('\n').filter((l) => l.includes('b'));
65
+ assert.ok(prefixed.length > 1, 'nested list code wraps under narrow width');
66
+ for (const line of prefixed) {
67
+ assert.ok(stringWidth(line) <= outerWidth, `line fits outer width: "${line}"`);
68
+ }
69
+ });
70
+
71
+ test('blockquote fenced code block respects narrowed width and has no fences', () => {
72
+ const long = 'c'.repeat(36);
73
+ const codeTok = lexFirst(`\`\`\`js\n${long}\n\`\`\`\n`, 'code');
74
+ const quoteTok = { type: 'blockquote', tokens: [codeTok] };
75
+ const outerWidth = 24;
76
+ const out = formatToken(quoteTok, 0, null, null, outerWidth);
77
+ const plain = stripAnsi(out);
78
+ assert.ok(!plain.includes('```'), 'no literal fence markers');
79
+ const lines = plain.split('\n').filter((l) => l.includes('c'));
80
+ assert.ok(lines.length > 1, 'blockquote code wraps under narrow width');
81
+ for (const line of lines) {
82
+ assert.ok(stringWidth(line) <= outerWidth, `line fits outer width: "${line}"`);
83
+ }
84
+ });
85
+
86
+ test('js highlighting colors keyword, string, number distinctly', () => {
87
+ const token = lexFirst('```js\nconst n = 42;\nlet s = "hi";\n```\n', 'code');
88
+ const out = formatToken(token);
89
+ assert.ok(out.includes(rgbSgr(theme.syntaxKeyword)), 'keyword colored');
90
+ assert.ok(out.includes(rgbSgr(theme.syntaxNumber)), 'number colored');
91
+ assert.ok(out.includes(rgbSgr(theme.syntaxString)), 'string colored');
92
+ });
93
+
94
+ test('diff fenced block colors add/remove/hunk/header separately', () => {
95
+ const diff = [
96
+ '```diff',
97
+ 'diff --git a/x b/x',
98
+ '--- a/x',
99
+ '+++ b/x',
100
+ '@@ -1,2 +1,2 @@',
101
+ '-old line',
102
+ '+new line',
103
+ ' context line',
104
+ '```',
105
+ '',
106
+ ].join('\n');
107
+ const token = lexFirst(diff, 'code');
108
+ const out = formatToken(token);
109
+ const added = rgbSgr(theme.mdDiffAdded);
110
+ const removed = rgbSgr(theme.mdDiffRemoved);
111
+ const hunk = rgbSgr(theme.mdDiffHunk);
112
+ const header = rgbSgr(theme.mdDiffHeader);
113
+ assert.ok(out.includes(added), 'added line color present');
114
+ assert.ok(out.includes(removed), 'removed line color present');
115
+ assert.ok(out.includes(hunk), 'hunk line color present');
116
+ assert.ok(out.includes(header), 'header line color present');
117
+ assert.equal(new Set([added, removed, hunk, header]).size, 4);
118
+ assert.ok(out.indexOf('+++ b/x') !== -1);
119
+ });
120
+
121
+ test('plus/minus file headers are not treated as add/remove', () => {
122
+ const diff = '```patch\n--- a/f\n+++ b/f\n@@ -0,0 +1 @@\n+x\n```\n';
123
+ const token = lexFirst(diff, 'code');
124
+ const out = formatToken(token);
125
+ const headerSgr = rgbSgr(theme.mdDiffHeader);
126
+ const addedSgr = rgbSgr(theme.mdDiffAdded);
127
+ const idxHeader = out.indexOf('+++ b/f');
128
+ const before = out.slice(0, idxHeader);
129
+ const lastHeader = before.lastIndexOf(headerSgr);
130
+ const lastAdded = before.lastIndexOf(addedSgr);
131
+ assert.ok(lastHeader > lastAdded, 'plus-plus-plus header uses header color, not added');
132
+ });
133
+
134
+ test('bare unified diff (no lang) is detected and colored', () => {
135
+ const token = lexFirst('```\n@@ -1 +1 @@\n-a\n+b\n```\n', 'code');
136
+ const out = formatToken(token);
137
+ assert.ok(out.includes(rgbSgr(theme.mdDiffHunk)), 'hunk colored on bare diff');
138
+ assert.ok(out.includes(rgbSgr(theme.mdDiffAdded)), 'add colored on bare diff');
139
+ });
140
+
141
+ test('unknown language falls back to flat code block color', () => {
142
+ const token = lexFirst('```foobarlang\nsome text\n```\n', 'code');
143
+ const out = formatToken(token);
144
+ assert.ok(out.includes(rgbSgr(theme.mdCodeBlock)), 'flat code-block color used');
145
+ assert.ok(stripAnsi(out).includes(' some text'), 'body still indented');
146
+ });
147
+
148
+ test('inline link emits OSC 8 hyperlink with styled label (URL hidden)', () => {
149
+ const token = lexFirst('[label](https://example.com)', 'paragraph');
150
+ const out = formatToken(token);
151
+ // OSC 8 wraps the visible label; the raw URL lives in the escape, not as
152
+ // on-screen text, so stripAnsi keeps only the label.
153
+ assert.ok(out.includes('\x1b]8;;https://example.com\x07'), 'OSC 8 link target present');
154
+ assert.ok(out.includes('\x1b]8;;\x07'), 'OSC 8 close present');
155
+ assert.ok(stripAnsi(out).includes('label'), 'link label text visible');
156
+ assert.ok(out.includes(rgbSgr(theme.mdLinkText)), 'link label colored');
157
+ });
158
+
159
+ test('bare-url link emits OSC 8 with the URL as visible clickable text', () => {
160
+ const token = lexFirst('<https://example.com>', 'paragraph');
161
+ const out = formatToken(token);
162
+ assert.ok(out.includes('\x1b]8;;https://example.com\x07'), 'OSC 8 link target present');
163
+ assert.ok(stripAnsi(out).includes('https://example.com'), 'URL visible as text');
164
+ assert.ok(out.includes(rgbSgr(theme.mdLink)), 'link URL colored');
165
+ });
166
+
167
+ test('strong and em use distinct mdStrong / mdEmph colors', () => {
168
+ const strongTok = lexFirst('**bold**', 'paragraph');
169
+ const emTok = lexFirst('*ital*', 'paragraph');
170
+ const strongOut = formatToken(strongTok);
171
+ const emOut = formatToken(emTok);
172
+ assert.ok(strongOut.includes(rgbSgr(theme.mdStrong)), 'strong colored');
173
+ assert.ok(emOut.includes(rgbSgr(theme.mdEmph)), 'em colored');
174
+ assert.ok(strongOut.includes('\x1b[1m'), 'strong is bold');
175
+ assert.ok(emOut.includes('\x1b[3m'), 'em is italic');
176
+ });
177
+
178
+ test('h1 heading is bold, italic, and underlined', () => {
179
+ const token = lexFirst('# Title', 'heading');
180
+ const out = formatToken(token);
181
+ assert.ok(out.includes('\x1b[1m'), 'h1 is bold');
182
+ assert.ok(out.includes('\x1b[3m'), 'h1 is italic');
183
+ assert.ok(out.includes('\x1b[4m'), 'h1 is underlined');
184
+ });
185
+
186
+ test('ordered list markers follow nesting depth (1. / a. / i.)', () => {
187
+ const md = [
188
+ '1. one',
189
+ ' 1. two',
190
+ ' 1. three',
191
+ ' 1. four',
192
+ ].join('\n');
193
+ const tokens = marked.lexer(md);
194
+ const plain = stripAnsi(tokens.map((t) => formatToken(t)).join(''));
195
+ assert.ok(plain.includes('1. one'), 'depth 0 arabic');
196
+ assert.ok(plain.includes('1. two'), 'depth 1 arabic');
197
+ assert.ok(plain.includes('a. three'), 'depth 2 lowercase letter');
198
+ assert.ok(plain.includes('i. four'), 'depth 3 lowercase roman');
199
+ });
200
+
201
+ test('every palette defines the full extended key set', () => {
202
+ const required = [
203
+ 'mdCodeBlockBorder', 'mdCodeBlockBg', 'mdLink', 'mdLinkText', 'mdStrong', 'mdEmph',
204
+ 'mdDiffAdded', 'mdDiffRemoved', 'mdDiffHunk', 'mdDiffHeader', 'mdDiffContext',
205
+ 'mdDiffAddedBg', 'mdDiffRemovedBg',
206
+ 'syntaxComment', 'syntaxKeyword', 'syntaxFunction', 'syntaxVariable',
207
+ 'syntaxString', 'syntaxNumber', 'syntaxType', 'syntaxOperator', 'syntaxPunctuation',
208
+ ];
209
+ for (const id of ['mixdog', 'pi-dark', 'claude-dark', 'dracula', 'tokyonight', 'nord', 'gruvbox', 'catppuccin', 'everforest']) {
210
+ setThemeSetting(id, { persist: false });
211
+ for (const key of required) {
212
+ assert.ok(/^rgb\(\d+,\s*\d+,\s*\d+\)$/.test(String(theme[key])), `${id}.${key} is an rgb() string`);
213
+ }
214
+ }
215
+ setThemeSetting('mixdog', { persist: false });
216
+ });
@@ -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
+ });