mixdog 0.8.1 → 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 (280) 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 +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 -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 +2212 -446
  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 +110 -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 +76 -28
  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/compact.mjs +1173 -267
  114. package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
  115. package/src/runtime/agent/orchestrator/session/loop.mjs +840 -612
  116. package/src/runtime/agent/orchestrator/session/manager.mjs +1574 -459
  117. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
  118. package/src/runtime/agent/orchestrator/session/store.mjs +67 -29
  119. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  120. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
  121. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
  122. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +34 -7
  123. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -0
  124. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +9 -9
  125. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
  126. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
  127. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
  128. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
  129. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
  130. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
  131. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
  132. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
  133. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
  134. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
  135. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
  136. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
  137. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
  138. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
  139. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
  140. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
  141. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
  142. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
  143. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
  144. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
  145. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
  146. package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
  147. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
  148. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
  149. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
  150. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
  151. package/src/runtime/channels/index.mjs +152 -24
  152. package/src/runtime/channels/lib/scheduler.mjs +18 -14
  153. package/src/runtime/channels/lib/session-discovery.mjs +3 -2
  154. package/src/runtime/channels/lib/transcript-discovery.mjs +3 -2
  155. package/src/runtime/channels/lib/webhook.mjs +1 -1
  156. package/src/runtime/channels/tool-defs.mjs +29 -29
  157. package/src/runtime/memory/index.mjs +520 -98
  158. package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
  159. package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
  160. package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
  161. package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
  162. package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
  163. package/src/runtime/memory/lib/memory-cycle1.mjs +28 -7
  164. package/src/runtime/memory/lib/memory-cycle2.mjs +4 -5
  165. package/src/runtime/memory/lib/memory-cycle3.mjs +2 -2
  166. package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -1
  167. package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
  168. package/src/runtime/memory/lib/memory.mjs +88 -9
  169. package/src/runtime/memory/lib/model-profile.mjs +1 -1
  170. package/src/runtime/memory/lib/pg/adapter.mjs +1 -1
  171. package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
  172. package/src/runtime/memory/lib/runtime-fetcher.mjs +37 -3
  173. package/src/runtime/memory/lib/session-ingest.mjs +194 -0
  174. package/src/runtime/memory/lib/trace-store.mjs +96 -51
  175. package/src/runtime/memory/tool-defs.mjs +46 -37
  176. package/src/runtime/search/index.mjs +102 -466
  177. package/src/runtime/search/lib/web-tools.mjs +45 -25
  178. package/src/runtime/search/tool-defs.mjs +16 -23
  179. package/src/runtime/shared/abort-controller.mjs +1 -1
  180. package/src/runtime/shared/atomic-file.mjs +4 -3
  181. package/src/runtime/shared/background-tasks.mjs +122 -11
  182. package/src/runtime/shared/child-spawn-gate.mjs +145 -0
  183. package/src/runtime/shared/config.mjs +7 -4
  184. package/src/runtime/shared/err-text.mjs +131 -4
  185. package/src/runtime/shared/llm/cost.mjs +2 -2
  186. package/src/runtime/shared/llm/http-agent.mjs +23 -7
  187. package/src/runtime/shared/llm/index.mjs +34 -11
  188. package/src/runtime/shared/llm/usage-log.mjs +4 -4
  189. package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
  190. package/src/runtime/shared/singleton-owner.mjs +104 -0
  191. package/src/runtime/shared/tool-execution-contract.mjs +199 -20
  192. package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
  193. package/src/runtime/shared/tool-surface.mjs +624 -95
  194. package/src/runtime/shared/user-data-guard.mjs +0 -2
  195. package/src/standalone/agent-task-status.mjs +203 -0
  196. package/src/standalone/agent-task-status.test.mjs +76 -0
  197. package/src/standalone/agent-tool.mjs +1913 -0
  198. package/src/standalone/channel-worker.mjs +370 -14
  199. package/src/standalone/explore-tool.mjs +165 -70
  200. package/src/standalone/folder-dialog.mjs +314 -0
  201. package/src/standalone/hook-bus.mjs +898 -22
  202. package/src/standalone/memory-runtime-proxy.mjs +320 -0
  203. package/src/standalone/projects.mjs +226 -0
  204. package/src/standalone/provider-admin.mjs +41 -24
  205. package/src/standalone/seeds.mjs +2 -69
  206. package/src/standalone/usage-dashboard.mjs +96 -8
  207. package/src/tui/App.jsx +4788 -2151
  208. package/src/tui/components/AnsiText.jsx +39 -28
  209. package/src/tui/components/ContextPanel.jsx +14 -4
  210. package/src/tui/components/Markdown.jsx +43 -77
  211. package/src/tui/components/MarkdownTable.jsx +9 -184
  212. package/src/tui/components/Message.jsx +28 -11
  213. package/src/tui/components/Picker.jsx +95 -56
  214. package/src/tui/components/PromptInput.jsx +367 -239
  215. package/src/tui/components/QueuedCommands.jsx +1 -1
  216. package/src/tui/components/SlashCommandPalette.jsx +27 -21
  217. package/src/tui/components/Spinner.jsx +67 -38
  218. package/src/tui/components/StatusLine.jsx +606 -38
  219. package/src/tui/components/TextEntryPanel.jsx +128 -9
  220. package/src/tui/components/ToolExecution.jsx +592 -362
  221. package/src/tui/components/TurnDone.jsx +3 -3
  222. package/src/tui/components/UsagePanel.jsx +3 -5
  223. package/src/tui/components/tool-output-format.mjs +365 -0
  224. package/src/tui/components/tool-output-format.test.mjs +220 -0
  225. package/src/tui/dist/index.mjs +8826 -2390
  226. package/src/tui/engine-runtime-notification.test.mjs +115 -0
  227. package/src/tui/engine-tool-result-text.test.mjs +75 -0
  228. package/src/tui/engine.mjs +1455 -279
  229. package/src/tui/figures.mjs +21 -40
  230. package/src/tui/index.jsx +75 -31
  231. package/src/tui/input-editing.mjs +25 -0
  232. package/src/tui/markdown/format-token.mjs +511 -68
  233. package/src/tui/markdown/format-token.test.mjs +216 -0
  234. package/src/tui/markdown/render-ansi.mjs +94 -0
  235. package/src/tui/markdown/render-ansi.test.mjs +108 -0
  236. package/src/tui/markdown/stream-fence.mjs +34 -0
  237. package/src/tui/markdown/stream-fence.test.mjs +26 -0
  238. package/src/tui/markdown/table-layout.mjs +250 -0
  239. package/src/tui/paste-attachments.mjs +0 -7
  240. package/src/tui/spinner-verbs.mjs +1 -2
  241. package/src/tui/statusline-ansi-bridge.mjs +172 -0
  242. package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
  243. package/src/tui/theme.mjs +746 -24
  244. package/src/tui/time-format.mjs +1 -1
  245. package/src/tui/transcript-tool-failures.mjs +67 -0
  246. package/src/tui/transcript-tool-failures.test.mjs +111 -0
  247. package/src/ui/ansi.mjs +1 -2
  248. package/src/ui/markdown.mjs +85 -26
  249. package/src/ui/markdown.test.mjs +70 -0
  250. package/src/ui/model-display.mjs +121 -0
  251. package/src/ui/session-stats.mjs +44 -0
  252. package/src/ui/statusline-context-label.test.mjs +15 -0
  253. package/src/ui/statusline.mjs +386 -178
  254. package/src/ui/tool-card.mjs +2 -13
  255. package/src/vendor/statusline/bin/statusline-lib.mjs +8 -4
  256. package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
  257. package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
  258. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
  259. package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
  260. package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
  261. package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
  262. package/src/workflows/default/WORKFLOW.md +12 -5
  263. package/src/workflows/default/workflow.json +0 -1
  264. package/src/workflows/solo/WORKFLOW.md +15 -0
  265. package/src/workflows/solo/workflow.json +7 -0
  266. package/vendor/ink/build/output.js +6 -1
  267. package/src/agents/scheduler-task.md +0 -3
  268. package/src/agents/web-researcher/AGENT.md +0 -3
  269. package/src/agents/web-researcher/agent.json +0 -6
  270. package/src/agents/webhook-handler.md +0 -3
  271. package/src/rules/bridge/00-common.md +0 -5
  272. package/src/rules/bridge/30-explorer.md +0 -4
  273. package/src/rules/lead/00-tool-lead.md +0 -5
  274. package/src/rules/shared/00-language.md +0 -3
  275. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  276. package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
  277. package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
  278. package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
  279. package/src/standalone/bridge-tool.mjs +0 -1414
  280. package/src/tui/runtime/shared/process-shutdown.mjs +0 -1
@@ -1,34 +1,39 @@
1
1
  /**
2
2
  * components/AnsiText.jsx — render ANSI SGR text as ink <Text> spans.
3
3
  *
4
- * Claude Code parses ANSI into styled Text nodes instead of relying on raw
4
+ * Parses ANSI into styled Text nodes instead of relying on raw
5
5
  * escape passthrough. This keeps markdown emphasis/code colors stable when an
6
6
  * inner span resets color back to the default.
7
7
  */
8
8
  import React from 'react';
9
9
  import { Text } from 'ink';
10
- import { theme } from '../theme.mjs';
10
+ import { theme, getThemeVersion } from '../theme.mjs';
11
11
 
12
12
  const ANSI_RE = /\x1b\[([0-9;]*)m/g;
13
13
 
14
- const ANSI_COLORS = {
15
- 30: theme.subtle,
16
- 31: theme.error,
17
- 32: theme.success,
18
- 33: theme.warning,
19
- 34: theme.code,
20
- 35: 'ansi:magenta',
21
- 36: 'ansi:cyan',
22
- 37: theme.text,
23
- 90: theme.subtle,
24
- 91: theme.error,
25
- 92: theme.success,
26
- 93: theme.warning,
27
- 94: theme.code,
28
- 95: 'ansi:magentaBright',
29
- 96: 'ansi:cyanBright',
30
- 97: theme.statusText,
31
- };
14
+ // Resolve the named SGR (30-37/90-97) → theme color map at call time so a
15
+ // live `/theme` switch is honored. `theme` is mutated in-place on switch, so
16
+ // reading the keys per parse keeps colors in sync without a module reload.
17
+ function ansiColorMap() {
18
+ return {
19
+ 30: theme.subtle,
20
+ 31: theme.error,
21
+ 32: theme.success,
22
+ 33: theme.warning,
23
+ 34: theme.code,
24
+ 35: 'ansi:magenta',
25
+ 36: 'ansi:cyan',
26
+ 37: theme.text,
27
+ 90: theme.subtle,
28
+ 91: theme.error,
29
+ 92: theme.success,
30
+ 93: theme.warning,
31
+ 94: theme.code,
32
+ 95: 'ansi:magentaBright',
33
+ 96: 'ansi:cyanBright',
34
+ 97: theme.statusText,
35
+ };
36
+ }
32
37
 
33
38
  const ANSI_BG_COLORS = {
34
39
  40: 'ansi:black',
@@ -74,7 +79,7 @@ function rgbCode(codes, index) {
74
79
  return `rgb(${r},${g},${b})`;
75
80
  }
76
81
 
77
- function applySgr(state, codes, defaultColor) {
82
+ function applySgr(state, codes, defaultColor, ansiColors) {
78
83
  if (!codes.length) codes = [0];
79
84
  for (let i = 0; i < codes.length; i++) {
80
85
  const code = codes[i];
@@ -133,8 +138,8 @@ function applySgr(state, codes, defaultColor) {
133
138
  state.backgroundColor = undefined;
134
139
  break;
135
140
  default:
136
- if (ANSI_COLORS[code]) {
137
- state.color = ANSI_COLORS[code];
141
+ if (ansiColors[code]) {
142
+ state.color = ansiColors[code];
138
143
  } else if (ANSI_BG_COLORS[code]) {
139
144
  state.backgroundColor = ANSI_BG_COLORS[code];
140
145
  }
@@ -147,6 +152,7 @@ function parseAnsi(text, defaultColor) {
147
152
  const source = String(text ?? '');
148
153
  const spans = [];
149
154
  const state = defaultState(defaultColor);
155
+ const ansiColors = ansiColorMap();
150
156
  let lastIndex = 0;
151
157
  let match;
152
158
 
@@ -159,7 +165,7 @@ function parseAnsi(text, defaultColor) {
159
165
  .split(';')
160
166
  .filter(Boolean)
161
167
  .map((n) => Number(n));
162
- applySgr(state, codes, defaultColor);
168
+ applySgr(state, codes, defaultColor, ansiColors);
163
169
  lastIndex = ANSI_RE.lastIndex;
164
170
  }
165
171
 
@@ -171,9 +177,13 @@ function parseAnsi(text, defaultColor) {
171
177
  }
172
178
 
173
179
  export function AnsiText({ children, defaultColor, wrap }) {
180
+ // ansiColorMap() reads live theme.* keys, so the parsed spans depend on the
181
+ // active theme. Include the theme version in the memo deps so a /theme switch
182
+ // re-parses with the new palette instead of reusing stale span colors.
183
+ const themeVersion = getThemeVersion();
174
184
  const spans = React.useMemo(
175
185
  () => parseAnsi(children, defaultColor),
176
- [children, defaultColor],
186
+ [children, defaultColor, themeVersion],
177
187
  );
178
188
 
179
189
  return (
@@ -183,9 +193,10 @@ export function AnsiText({ children, defaultColor, wrap }) {
183
193
  key={index}
184
194
  color={span.style.color}
185
195
  backgroundColor={span.style.backgroundColor}
186
- // Windows Terminal's synthetic bold makes Korean markdown text look
187
- // slightly fuzzy. Keep the color emphasis, but avoid the font weight.
188
- bold={false}
196
+ // Honor SGR bold only on spans that chalk/markdown set (e.g. **strong**,
197
+ // headings). Do not force bold globally avoids fuzzy Korean body text
198
+ // when models emit no bold codes.
199
+ bold={span.style.bold}
189
200
  dimColor={span.style.dimColor}
190
201
  italic={span.style.italic}
191
202
  underline={span.style.underline}
@@ -171,11 +171,13 @@ function ContextUsageView({ detail, columns }) {
171
171
  const barWidth = Math.max(12, Math.min(34, innerWidth - stringWidth(summaryText) - stringWidth(pctText) - 5));
172
172
  const builtInToolTokens = bucketTokens(schema, ['code', 'web', 'mutation', 'channels', 'setup', 'other']);
173
173
  const builtInToolCount = bucketCount(schema, ['code', 'web', 'mutation', 'channels', 'setup', 'other']);
174
- const projectTokens = semanticTokens(semantic, ['project', 'workspace', 'environment', 'other']);
174
+ const sessionTokens = semanticTokens(semantic, ['workspace', 'environment', 'other']);
175
175
  const compactionLine = metricValue([
176
176
  compaction.stage && compaction.stage !== 'pending' ? compaction.stage : '',
177
177
  compaction.state,
178
+ compaction.type ? `type ${compaction.type}` : '',
178
179
  compaction.triggerTokens ? `trigger ${formatTokens(compaction.triggerTokens)}` : '',
180
+ compaction.boundaryTokens ? `boundary ${formatTokens(compaction.boundaryTokens)}` : '',
179
181
  ]);
180
182
  const sourceLine = metricValue([
181
183
  usage.effective ? `effective ${formatTokens(windowTokens)}` : `window ${formatTokens(windowTokens)}`,
@@ -192,7 +194,7 @@ function ContextUsageView({ detail, columns }) {
192
194
  { label: 'MCP', tokens: bucketTokens(schema, ['mcp']), meta: `${mcp.connected || 0}/${mcp.configured || 0} servers` },
193
195
  { label: 'Skills', tokens: bucketTokens(schema, ['skills']), meta: `${extensions.skills || 0} skills` },
194
196
  { label: 'Memory', tokens: semanticTokens(semantic, ['memory']) + bucketTokens(schema, ['memory']), meta: 'core + recall tools' },
195
- { label: 'Project', tokens: projectTokens, meta: 'mixdog.md · workspace' },
197
+ { label: 'Session', tokens: sessionTokens, meta: 'workspace · environment' },
196
198
  { label: 'Workflow', tokens: semanticTokens(semantic, ['workflow']) + bucketTokens(schema, ['agents']), meta: 'workflow · agents' },
197
199
  { label: 'System', tokens: semanticTokens(semantic, ['system']), meta: 'rules · role catalog' },
198
200
  { label: 'Overhead', tokens: finiteNumber(request.overheadTokens), meta: 'request frame' },
@@ -223,7 +225,7 @@ function ContextUsageView({ detail, columns }) {
223
225
  );
224
226
  }
225
227
 
226
- export function ContextPanel({ rows, title = 'Context Usage', columns = 80, fillHeight = false, detail = null }) {
228
+ export function ContextPanel({ rows, title = 'Context Usage', columns = 80, fillHeight = false, detail = null, description = '' }) {
227
229
  const safeRows = Array.isArray(rows) ? rows : [];
228
230
  const labelWidth = Math.min(
229
231
  safeRows.reduce((w, row) => Math.max(w, String(row.label || '').length), 0),
@@ -231,6 +233,11 @@ export function ContextPanel({ rows, title = 'Context Usage', columns = 80, fill
231
233
  );
232
234
  const valueWidth = Math.max(0, columns - labelWidth - 8);
233
235
  const isContextUsage = detail?.type === 'context';
236
+ // Standard panel rhythm: title row, blank, description/hint row, blank, content.
237
+ const panelDescription = truncateText(
238
+ String(description || (isContextUsage ? 'Live context window usage by category.' : '')).replace(/\s+/g, ' ').trim(),
239
+ Math.max(0, columns - 4),
240
+ );
234
241
 
235
242
  return (
236
243
  <Box flexDirection="column" flexShrink={0} width="100%" height={fillHeight ? '100%' : undefined}>
@@ -242,10 +249,13 @@ export function ContextPanel({ rows, title = 'Context Usage', columns = 80, fill
242
249
  width="100%"
243
250
  height={fillHeight ? '100%' : undefined}
244
251
  >
245
- <Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
252
+ <Box flexDirection="row" justifyContent="space-between">
246
253
  <Text color={theme.panelTitle}>{title}</Text>
247
254
  <Text color={theme.subtle}>Esc back</Text>
248
255
  </Box>
256
+ <Text> </Text>
257
+ <Text color={theme.subtle}>{panelDescription || ' '}</Text>
258
+ <Text> </Text>
249
259
  {isContextUsage ? (
250
260
  <ContextUsageView detail={detail} columns={columns} />
251
261
  ) : (
@@ -1,93 +1,52 @@
1
1
  /**
2
2
  * components/Markdown.jsx — markdown → ink, hybrid renderer.
3
3
  *
4
- * Ported from Claude Code (refs/claude-code/src/components/Markdown.tsx):
5
- * - marked.lexer() produces the token stream (same lib + config as CC).
4
+ * Markdown ink hybrid renderer:
5
+ * - marked.lexer() produces the token stream.
6
6
  * - Non-table tokens are rendered to ANSI strings via formatToken and emitted
7
- * through <AnsiText>, matching CC's span-based <Ansi> behavior.
7
+ * through <AnsiText> as styled spans.
8
8
  * - Tables are rendered by the MarkdownTable component (proper Box layout).
9
- * - Adjacent non-table tokens are coalesced into one <Text> (CC's
10
- * nonTableContent buffer) so block spacing matches.
9
+ * - Block tokens are emitted as separate Ink children so lists, code fences,
10
+ * and tables keep their markdown block boundaries.
11
11
  *
12
- * Syntax highlighting from CC is dropped, but token cache + streaming-split are
12
+ * Syntax highlighting is omitted, but token cache + streaming-split are
13
13
  * kept so partial markdown does not repaint stable text on every delta.
14
14
  */
15
15
  import React, { useRef } from 'react';
16
16
  import { Box, Text } from 'ink';
17
17
  import { marked } from 'marked';
18
- import { formatToken } from '../markdown/format-token.mjs';
18
+ import {
19
+ configureMarked,
20
+ hasMarkdownSyntax,
21
+ renderTokenAnsiSegments,
22
+ } from '../markdown/render-ansi.mjs';
23
+ import { trimPartialClosingFences } from '../markdown/stream-fence.mjs';
19
24
  import { AnsiText } from './AnsiText.jsx';
20
25
  import { MarkdownTable } from './MarkdownTable.jsx';
21
26
  import { theme } from '../theme.mjs';
22
27
 
23
- let _configured = false;
24
- function configureMarked() {
25
- if (_configured) return;
26
- _configured = true;
27
- // Disable strikethrough: models use ~ for "approximate" (~100), not <del>.
28
- marked.use({ tokenizer: { del() { return undefined; } } });
29
- }
30
-
31
- const TOKEN_CACHE_MAX = 500;
32
- const tokenCache = new Map();
33
- const MD_SYNTAX_RE = /[#*`|[>\-_~]|\n\n|^\d+\. |\n\d+\. /;
34
-
35
- function hasMarkdownSyntax(text) {
36
- const sample = text.length > 500 ? text.slice(0, 500) : text;
37
- return MD_SYNTAX_RE.test(sample);
38
- }
39
-
40
- function cachedLexer(content) {
41
- const text = String(content ?? '');
42
- if (!hasMarkdownSyntax(text)) {
43
- return [{
44
- type: 'paragraph',
45
- raw: text,
46
- text,
47
- tokens: [{ type: 'text', raw: text, text }],
48
- }];
49
- }
50
- const hit = tokenCache.get(text);
51
- if (hit) {
52
- tokenCache.delete(text);
53
- tokenCache.set(text, hit);
54
- return hit;
55
- }
56
- const tokens = marked.lexer(text);
57
- if (tokenCache.size >= TOKEN_CACHE_MAX) {
58
- const first = tokenCache.keys().next().value;
59
- if (first !== undefined) tokenCache.delete(first);
60
- }
61
- tokenCache.set(text, tokens);
62
- return tokens;
63
- }
64
-
65
- function renderMarkdownElements(content) {
66
- configureMarked();
67
- const tokens = cachedLexer(String(content ?? ''));
28
+ function renderMarkdownElements(content, trimPartialFences = false, tableWidth) {
29
+ // `tableWidth` is the App's body/content width; it doubles as the hr fill
30
+ // width. Fall back to 80 when not provided so an hr still spans a sane rule.
31
+ const segments = renderTokenAnsiSegments(content, { trimPartialFences, width: tableWidth || 80 });
68
32
  const result = [];
69
- let buffer = '';
70
33
  let idx = 0;
71
- const flush = () => {
72
- if (buffer) {
73
- // CC trims the coalesced non-table block (MarkdownBody: nonTableContent
74
- // .trim()) so leading/trailing blank lines from token EOLs don't bleed
75
- // into the surrounding gap={1} spacing. defaultColor={theme.text}
76
- // keeps ANSI resets on the same dark-theme foreground instead of the
77
- // terminal profile's default foreground.
78
- result.push(<AnsiText key={`md_${idx++}`} defaultColor={theme.text}>{buffer.trim()}</AnsiText>);
79
- buffer = '';
80
- }
81
- };
82
- for (const token of tokens) {
83
- if (token.type === 'table') {
84
- flush();
85
- result.push(<MarkdownTable key={`md_${idx++}`} token={token} />);
34
+ for (const segment of segments) {
35
+ if (segment.type === 'table') {
36
+ // Pass the App's body width as forceWidth so the table is laid out at the
37
+ // SAME width the row-height estimator measures (measureMarkdownTableRows).
38
+ // Without it MarkdownTable falls back to useStdout().columns, which on
39
+ // win32 is frameColumns+1 a 1-col gap that can flip the table between
40
+ // horizontal and vertical layout and top-clip streaming output.
41
+ result.push(<MarkdownTable key={`md_${idx++}`} token={segment.token} forceWidth={tableWidth} />);
86
42
  } else {
87
- buffer += formatToken(token);
43
+ // defaultColor={theme.text} keeps ANSI resets on the same dark-theme
44
+ // foreground instead of the terminal profile's default foreground.
45
+ result.push(
46
+ <AnsiText key={`md_${idx++}`} defaultColor={theme.text}>{segment.ansi}</AnsiText>,
47
+ );
88
48
  }
89
49
  }
90
- flush();
91
50
  return result;
92
51
  }
93
52
 
@@ -145,15 +104,18 @@ function balanceStreamingMarkdown(text) {
145
104
  return rendered;
146
105
  }
147
106
 
148
- export function Markdown({ children }) {
107
+ export function Markdown({ children, themeEpoch = 0, trimPartialFences = false, columns }) {
149
108
  const elements = React.useMemo(() => {
150
109
  try {
151
- return renderMarkdownElements(children);
110
+ return renderMarkdownElements(children, trimPartialFences, columns);
152
111
  } catch {
153
112
  // Never throw into the render tree — fall back to raw text.
154
113
  return [<Text key="md_0" color={theme.text}>{String(children ?? '')}</Text>];
155
114
  }
156
- }, [children]);
115
+ // themeEpoch is a memo dep so a /theme switch re-renders to ANSI with the new
116
+ // md* colors (formatToken re-resolves its colorizers on the active theme).
117
+ // columns is a dep so a resize re-lays-out tables at the new forceWidth.
118
+ }, [children, themeEpoch, trimPartialFences, columns]);
157
119
 
158
120
  return (
159
121
  <Box flexDirection="column" gap={1}>
@@ -162,13 +124,13 @@ export function Markdown({ children }) {
162
124
  );
163
125
  }
164
126
 
165
- export function StreamingMarkdown({ children }) {
127
+ export function StreamingMarkdown({ children, themeEpoch = 0, columns }) {
166
128
  const stablePrefixRef = useRef('');
167
129
  const text = String(children ?? '');
168
130
 
169
131
  if (!hasMarkdownSyntax(text)) {
170
132
  stablePrefixRef.current = '';
171
- return <Markdown>{text}</Markdown>;
133
+ return <Markdown themeEpoch={themeEpoch} columns={columns}>{text}</Markdown>;
172
134
  }
173
135
 
174
136
  if (!text.startsWith(stablePrefixRef.current)) {
@@ -179,7 +141,11 @@ export function StreamingMarkdown({ children }) {
179
141
  try {
180
142
  configureMarked();
181
143
  const boundary = stablePrefix.length;
144
+ // Lex the still-streaming suffix and trim any partial closing fence so an
145
+ // open code block does not grow-then-shrink as the final backtick(s)
146
+ // stream in. Operates on a fresh lex so the shared tokenCache is untouched.
182
147
  const tokens = marked.lexer(text.substring(boundary));
148
+ trimPartialClosingFences(tokens);
183
149
  let lastContentIdx = tokens.length - 1;
184
150
  while (lastContentIdx >= 0 && tokens[lastContentIdx]?.type === 'space') lastContentIdx--;
185
151
  let advance = 0;
@@ -198,8 +164,8 @@ export function StreamingMarkdown({ children }) {
198
164
  const unstableSuffix = text.substring(stablePrefix.length);
199
165
  return (
200
166
  <Box flexDirection="column" gap={1}>
201
- {stablePrefix ? <Markdown>{stablePrefix}</Markdown> : null}
202
- {unstableSuffix ? <Markdown>{balanceStreamingMarkdown(unstableSuffix)}</Markdown> : null}
167
+ {stablePrefix ? <Markdown themeEpoch={themeEpoch} columns={columns}>{stablePrefix}</Markdown> : null}
168
+ {unstableSuffix ? <Markdown themeEpoch={themeEpoch} columns={columns} trimPartialFences>{balanceStreamingMarkdown(unstableSuffix)}</Markdown> : null}
203
169
  </Box>
204
170
  );
205
171
  }
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * components/MarkdownTable.jsx — GFM table → ink Box layout.
3
3
  *
4
- * Ported from Claude Code (refs/claude-code/src/components/MarkdownTable.tsx),
5
- * adapted for this CLI:
4
+ * GFM table layout for ink:
6
5
  * - <Ansi> → <Text> (ink 7 has no <Ansi>; <Text> passes ANSI escapes through).
7
6
  * - useTerminalSize() → ink useStdout().stdout.columns.
8
- * - stringWidth / wrapAnsi from the npm packages (same libs CC vendors).
7
+ * - stringWidth / wrapAnsi from npm packages.
9
8
  * - formatCell uses our format-token.formatToken (no highlight arg).
10
9
  *
11
10
  * The width-fitting algorithm (ideal vs min widths, proportional shrink, hard
@@ -13,29 +12,7 @@
13
12
  */
14
13
  import React from 'react';
15
14
  import { Text, useStdout } from 'ink';
16
- import stripAnsi from 'strip-ansi';
17
- import stringWidth from 'string-width';
18
- import wrapAnsi from 'wrap-ansi';
19
- import { formatToken, padAligned } from '../markdown/format-token.mjs';
20
-
21
- const SAFETY_MARGIN = 4;
22
- const MIN_COLUMN_WIDTH = 3;
23
- const MAX_ROW_LINES = 4;
24
- const ANSI_BOLD_START = '\x1b[1m';
25
- const ANSI_BOLD_END = '\x1b[22m';
26
-
27
- /** Wrap text to width, ANSI-aware, returning lines (CC wrapText). */
28
- function wrapText(text, width, options) {
29
- if (width <= 0) return [text];
30
- const trimmedText = String(text).trimEnd();
31
- const wrapped = wrapAnsi(trimmedText, width, {
32
- hard: options?.hard ?? false,
33
- trim: false,
34
- wordWrap: true,
35
- });
36
- const lines = wrapped.split('\n').filter((line) => line.length > 0);
37
- return lines.length > 0 ? lines : [''];
38
- }
15
+ import { buildTableRender } from '../markdown/table-layout.mjs';
39
16
 
40
17
  export function MarkdownTable({ token, forceWidth }) {
41
18
  // App owns resize reflow and there is no <Static> transcript anymore, so table
@@ -43,162 +20,10 @@ export function MarkdownTable({ token, forceWidth }) {
43
20
  const actualTerminalWidth = useStdout()?.stdout?.columns ?? 80;
44
21
  const terminalWidth = forceWidth ?? actualTerminalWidth;
45
22
 
46
- const formatCell = (tokens) => tokens?.map((t) => formatToken(t)).join('') ?? '';
47
- const getPlainText = (tokens) => stripAnsi(formatCell(tokens));
48
-
49
- const getMinWidth = (tokens) => {
50
- const text = getPlainText(tokens);
51
- const words = text.split(/\s+/).filter((w) => w.length > 0);
52
- if (words.length === 0) return MIN_COLUMN_WIDTH;
53
- return Math.max(...words.map((w) => stringWidth(w)), MIN_COLUMN_WIDTH);
54
- };
55
- const getIdealWidth = (tokens) =>
56
- Math.max(stringWidth(getPlainText(tokens)), MIN_COLUMN_WIDTH);
57
-
58
- // Step 1: min (longest word) and ideal (full content) widths per column.
59
- const minWidths = token.header.map((header, colIndex) => {
60
- let maxMinWidth = getMinWidth(header.tokens);
61
- for (const row of token.rows) maxMinWidth = Math.max(maxMinWidth, getMinWidth(row[colIndex]?.tokens));
62
- return maxMinWidth;
63
- });
64
- const idealWidths = token.header.map((header, colIndex) => {
65
- let maxIdeal = getIdealWidth(header.tokens);
66
- for (const row of token.rows) maxIdeal = Math.max(maxIdeal, getIdealWidth(row[colIndex]?.tokens));
67
- return maxIdeal;
68
- });
69
-
70
- // Step 2: available space.
71
- const numCols = token.header.length;
72
- const borderOverhead = 1 + numCols * 3;
73
- const availableWidth = Math.max(terminalWidth - borderOverhead - SAFETY_MARGIN, numCols * MIN_COLUMN_WIDTH);
74
-
75
- // Step 3: fit column widths into available space.
76
- const totalMin = minWidths.reduce((s, w) => s + w, 0);
77
- const totalIdeal = idealWidths.reduce((s, w) => s + w, 0);
78
- let needsHardWrap = false;
79
- let columnWidths;
80
- if (totalIdeal <= availableWidth) {
81
- columnWidths = idealWidths;
82
- } else if (totalMin <= availableWidth) {
83
- const extraSpace = availableWidth - totalMin;
84
- const overflows = idealWidths.map((ideal, i) => ideal - minWidths[i]);
85
- const totalOverflow = overflows.reduce((s, o) => s + o, 0);
86
- columnWidths = minWidths.map((min, i) => {
87
- if (totalOverflow === 0) return min;
88
- return min + Math.floor((overflows[i] / totalOverflow) * extraSpace);
89
- });
90
- } else {
91
- needsHardWrap = true;
92
- const scaleFactor = availableWidth / totalMin;
93
- columnWidths = minWidths.map((w) => Math.max(Math.floor(w * scaleFactor), MIN_COLUMN_WIDTH));
94
- }
95
-
96
- // Step 4: max row lines → decide vertical fallback.
97
- const calculateMaxRowLines = () => {
98
- let maxLines = 1;
99
- for (let i = 0; i < token.header.length; i++) {
100
- const wrapped = wrapText(formatCell(token.header[i].tokens), columnWidths[i], { hard: needsHardWrap });
101
- maxLines = Math.max(maxLines, wrapped.length);
102
- }
103
- for (const row of token.rows) {
104
- for (let i = 0; i < row.length; i++) {
105
- const wrapped = wrapText(formatCell(row[i]?.tokens), columnWidths[i], { hard: needsHardWrap });
106
- maxLines = Math.max(maxLines, wrapped.length);
107
- }
108
- }
109
- return maxLines;
110
- };
111
- const useVerticalFormat = calculateMaxRowLines() > MAX_ROW_LINES;
112
-
113
- const renderRowLines = (cells, isHeader) => {
114
- const cellLines = cells.map((cell, colIndex) =>
115
- wrapText(formatCell(cell.tokens), columnWidths[colIndex], { hard: needsHardWrap }),
116
- );
117
- const maxLines = Math.max(...cellLines.map((l) => l.length), 1);
118
- const verticalOffsets = cellLines.map((l) => Math.floor((maxLines - l.length) / 2));
119
- const result = [];
120
- for (let lineIdx = 0; lineIdx < maxLines; lineIdx++) {
121
- let line = '│';
122
- for (let colIndex = 0; colIndex < cells.length; colIndex++) {
123
- const lines = cellLines[colIndex];
124
- const offset = verticalOffsets[colIndex];
125
- const contentLineIdx = lineIdx - offset;
126
- const lineText = contentLineIdx >= 0 && contentLineIdx < lines.length ? lines[contentLineIdx] : '';
127
- const width = columnWidths[colIndex];
128
- const align = isHeader ? 'center' : token.align?.[colIndex] ?? 'left';
129
- line += ' ' + padAligned(lineText, stringWidth(lineText), width, align) + ' │';
130
- }
131
- result.push(line);
132
- }
133
- return result;
134
- };
135
-
136
- const renderBorderLine = (type) => {
137
- const [left, mid, cross, right] = {
138
- top: ['┌', '─', '┬', '┐'],
139
- middle: ['├', '─', '┼', '┤'],
140
- bottom: ['└', '─', '┴', '┘'],
141
- }[type];
142
- let line = left;
143
- columnWidths.forEach((width, colIndex) => {
144
- line += mid.repeat(width + 2);
145
- line += colIndex < columnWidths.length - 1 ? cross : right;
146
- });
147
- return line;
148
- };
149
-
150
- const renderVerticalFormat = () => {
151
- const lines = [];
152
- const headers = token.header.map((h) => getPlainText(h.tokens));
153
- const separatorWidth = Math.min(terminalWidth - 1, 40);
154
- const separator = '─'.repeat(separatorWidth);
155
- const wrapIndent = ' ';
156
- token.rows.forEach((row, rowIndex) => {
157
- if (rowIndex > 0) lines.push(separator);
158
- row.forEach((cell, colIndex) => {
159
- const label = headers[colIndex] || `Column ${colIndex + 1}`;
160
- const rawValue = formatCell(cell.tokens).trimEnd();
161
- const value = rawValue.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim();
162
- const firstLineWidth = terminalWidth - stringWidth(label) - 3;
163
- const subsequentLineWidth = terminalWidth - wrapIndent.length - 1;
164
- const firstPassLines = wrapText(value, Math.max(firstLineWidth, 10));
165
- const firstLine = firstPassLines[0] || '';
166
- let wrappedValue;
167
- if (firstPassLines.length <= 1 || subsequentLineWidth <= firstLineWidth) {
168
- wrappedValue = firstPassLines;
169
- } else {
170
- const remainingText = firstPassLines.slice(1).map((l) => l.trim()).join(' ');
171
- wrappedValue = [firstLine, ...wrapText(remainingText, subsequentLineWidth)];
172
- }
173
- lines.push(`${ANSI_BOLD_START}${label}:${ANSI_BOLD_END} ${wrappedValue[0] || ''}`);
174
- for (let i = 1; i < wrappedValue.length; i++) {
175
- if (!wrappedValue[i].trim()) continue;
176
- lines.push(`${wrapIndent}${wrappedValue[i]}`);
177
- }
178
- });
179
- });
180
- return lines.join('\n');
181
- };
182
-
183
- if (useVerticalFormat) {
184
- return <Text>{renderVerticalFormat()}</Text>;
185
- }
186
-
187
- const tableLines = [];
188
- tableLines.push(renderBorderLine('top'));
189
- tableLines.push(...renderRowLines(token.header, true));
190
- tableLines.push(renderBorderLine('middle'));
191
- token.rows.forEach((row, rowIndex) => {
192
- tableLines.push(...renderRowLines(row, false));
193
- if (rowIndex < token.rows.length - 1) tableLines.push(renderBorderLine('middle'));
194
- });
195
- tableLines.push(renderBorderLine('bottom'));
196
-
197
- // Safety: if any line would overflow (resize race), fall back to vertical.
198
- const maxLineWidth = Math.max(...tableLines.map((l) => stringWidth(stripAnsi(l))));
199
- if (maxLineWidth > terminalWidth - SAFETY_MARGIN) {
200
- return <Text>{renderVerticalFormat()}</Text>;
201
- }
202
-
203
- return <Text>{tableLines.join('\n')}</Text>;
23
+ // The entire layout (column fit, vertical fallback, bordered box, overflow
24
+ // re-fallback) lives in the pure markdown/table-layout.mjs module so the
25
+ // renderer and the App.jsx row-height estimator share one source of truth and
26
+ // can never drift. The component just draws the lines it returns verbatim.
27
+ const { lines } = buildTableRender(token, terminalWidth);
28
+ return <Text>{lines.join('\n')}</Text>;
204
29
  }
@@ -1,42 +1,59 @@
1
1
  /**
2
2
  * components/Message.jsx — transcript message rows.
3
3
  *
4
- * Ported from Claude Code:
5
- * - AssistantTextMessage.tsx: `● <markdown>` where the dot is `BLACK_CIRCLE`
6
- * in the `text` color (white, NOT claude-orange), in a 2-wide gutter, and
4
+ * Transcript message rows:
5
+ * - Assistant: `● <markdown>` where the dot is `BLACK_CIRCLE`
6
+ * in the `text` color (not the orange title accent), in a 2-wide gutter, and
7
7
  * the markdown sits in a column box beside it (flexDirection row).
8
- * - UserPromptMessage.tsx: text on a `userMessageBackground` background with
9
- * `paddingRight={1}` (no prompt glyph — CC uses the bg to distinguish input
8
+ * - User: text on a `userMessageBackground` background with
9
+ * `paddingRight={1}` (no prompt glyph — the bg distinguishes input
10
10
  * from assistant/tool rows).
11
11
  *
12
12
  * The dot uses `minWidth={2}` so wrapped markdown lines align under the text,
13
- * not under the dot (CC's NoSelect minWidth={2} behavior).
13
+ * not under the dot (2-wide gutter alignment).
14
14
  */
15
15
  import React from 'react';
16
16
  import { Box, Text, useAnimation } from 'ink';
17
17
  import { theme, TURN_MARKER } from '../theme.mjs';
18
18
  import { Markdown, StreamingMarkdown } from './Markdown.jsx';
19
+ import { assistantBodyWidth } from '../markdown/table-layout.mjs';
19
20
  import { THEREFORE } from '../figures.mjs';
20
21
  import { formatDuration } from '../time-format.mjs';
21
22
 
22
- export const AssistantMessage = React.memo(function AssistantMessage({ text, streaming = false }) {
23
+ // `themeEpoch` is a memo-busting prop (threaded from App): the active theme
24
+ // mutates `theme` in-place, so a /theme switch must re-render this memoized row
25
+ // and recompute its markdown colors. It is forwarded into Markdown so the
26
+ // token/AnsiText caches include it as a dep.
27
+ export const AssistantMessage = React.memo(function AssistantMessage({ text, streaming = false, columns = 80, themeEpoch = 0 }) {
28
+ // The body column needs an EXPLICIT numeric width. Without it, ink/Yoga
29
+ // measures the wrapped markdown body before the row's available width is
30
+ // resolved and caches its height as a single row — so a multi-line assistant
31
+ // message renders only its LAST wrapped line (the head lines are clipped) and
32
+ // streaming height jitters as the measure flips. Reserve the 2-col gutter
33
+ // (BLACK CIRCLE in minWidth={2}) plus one right-edge safety cell. Letting an
34
+ // assistant line reach the terminal's last column can auto-wrap/scroll on
35
+ // Windows Terminal/conhost and make the next redraw appear to lose leading
36
+ // CJK characters even though the backing transcript is intact.
37
+ const bodyWidth = assistantBodyWidth(columns);
23
38
  return (
24
39
  <Box flexDirection="row" marginTop={1}>
25
40
  <Box flexShrink={0} minWidth={2}>
26
41
  <Text color={theme.text}>{TURN_MARKER}</Text>
27
42
  </Box>
28
- <Box flexDirection="column" flexGrow={1}>
29
- {streaming ? <StreamingMarkdown>{text}</StreamingMarkdown> : <Markdown>{text}</Markdown>}
43
+ <Box flexDirection="column" flexShrink={0} width={bodyWidth}>
44
+ {streaming
45
+ ? <StreamingMarkdown themeEpoch={themeEpoch} columns={bodyWidth}>{text}</StreamingMarkdown>
46
+ : <Markdown themeEpoch={themeEpoch} columns={bodyWidth}>{text}</Markdown>}
30
47
  </Box>
31
48
  </Box>
32
49
  );
33
50
  });
34
51
 
35
- export const UserMessage = React.memo(function UserMessage({ text, attached = false, columns }) {
52
+ export const UserMessage = React.memo(function UserMessage({ text, attached = false, columns, themeEpoch = 0 }) {
36
53
  // `attached` = the previous transcript row is also a user message (consecutive
37
54
  // steering prompts). Those stack flush together; a user message that follows
38
55
  // an assistant/tool row gets a one-row gap above it.
39
- // The background band fills the row edge-to-edge (CC's user bubble reads as a
56
+ // The background band fills the row edge-to-edge (user bubble reads as a
40
57
  // full-width band, not a text-hugging tag). An explicit numeric width is more
41
58
  // robust than "100%" here — it guarantees the band even if a parent's width
42
59
  // context is ambiguous. paddingLeft aligns the body under the 2-col gutter.