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,250 @@
1
+ /**
2
+ * markdown/table-layout.mjs — pure GFM table layout calculation (no JSX/ink).
3
+ *
4
+ * This is the SINGLE SOURCE OF TRUTH for how a marked `table` token is laid out
5
+ * into terminal lines. Both the renderer (components/MarkdownTable.jsx) and the
6
+ * row-height estimator (App.jsx) call `buildTableRender` so the visible line
7
+ * count and the estimated line count can never drift (lockstep).
8
+ *
9
+ * `buildTableRender(token, terminalWidth)` returns the exact array of strings
10
+ * the component draws (`<Text>{lines.join('\n')}</Text>`); the component renders
11
+ * those verbatim (no visual change) and the estimator measures `lines.length`.
12
+ *
13
+ * Pure + deterministic: same (token, terminalWidth) → same output. No hooks,
14
+ * no time/random/global state — `terminalWidth` is always passed in by the
15
+ * caller (the component resolves it from useStdout()/forceWidth).
16
+ */
17
+ import stripAnsi from 'strip-ansi';
18
+ import stringWidth from 'string-width';
19
+ import wrapAnsi from 'wrap-ansi';
20
+ import { formatToken, padAligned } from './format-token.mjs';
21
+
22
+ export const SAFETY_MARGIN = 4;
23
+ export const MIN_COLUMN_WIDTH = 3;
24
+ export const MAX_ROW_LINES = 4;
25
+ const ANSI_BOLD_START = '\x1b[1m';
26
+ const ANSI_BOLD_END = '\x1b[22m';
27
+
28
+ /** Assistant markdown body width — lockstep with Message.jsx / forceWidth. */
29
+ export function assistantBodyWidth(columns) {
30
+ return Math.max(8, Number(columns || 80) - 3);
31
+ }
32
+
33
+ /** Wrap text to width, ANSI-aware, returning lines. */
34
+ export function wrapText(text, width, options) {
35
+ if (width <= 0) return [text];
36
+ const trimmedText = String(text).trimEnd();
37
+ const wrapped = wrapAnsi(trimmedText, width, {
38
+ hard: options?.hard ?? false,
39
+ trim: false,
40
+ wordWrap: true,
41
+ });
42
+ const lines = wrapped.split('\n').filter((line) => line.length > 0);
43
+ return lines.length > 0 ? lines : [''];
44
+ }
45
+
46
+ /** Hard-wrap so every line satisfies stringWidth(line) <= width (vertical tables). */
47
+ export function hardWrapLines(text, width) {
48
+ const max = Math.max(1, Math.floor(Number(width) || 1));
49
+ const input = String(text ?? '');
50
+ if (!input) return [''];
51
+ const out = [];
52
+ for (const softLine of wrapText(input, max, { hard: true })) {
53
+ let rest = softLine;
54
+ while (rest.length > 0 && stringWidth(rest) > max) {
55
+ let take = 1;
56
+ for (let i = 1; i <= rest.length; i++) {
57
+ if (stringWidth(rest.slice(0, i)) <= max) take = i;
58
+ else break;
59
+ }
60
+ out.push(rest.slice(0, take));
61
+ rest = rest.slice(take);
62
+ }
63
+ if (rest.length > 0) out.push(rest);
64
+ }
65
+ return out.length > 0 ? out : [''];
66
+ }
67
+
68
+ /**
69
+ * Compute the full table render as an ordered array of terminal lines plus the
70
+ * vertical-fallback flag. The logic mirrors the original MarkdownTable render
71
+ * exactly: column fit (ideal / proportional shrink / hard wrap), vertical
72
+ * fallback when a cell needs more than MAX_ROW_LINES, the bordered horizontal
73
+ * box, and the post-build overflow re-fallback to vertical.
74
+ */
75
+ export function buildTableRender(token, terminalWidth) {
76
+ const width = Number(terminalWidth) || 80;
77
+ const formatCell = (tokens) => tokens?.map((t) => formatToken(t)).join('') ?? '';
78
+ const getPlainText = (tokens) => stripAnsi(formatCell(tokens));
79
+
80
+ const getMinWidth = (tokens) => {
81
+ const text = getPlainText(tokens);
82
+ const words = text.split(/\s+/).filter((w) => w.length > 0);
83
+ if (words.length === 0) return MIN_COLUMN_WIDTH;
84
+ return Math.max(...words.map((w) => stringWidth(w)), MIN_COLUMN_WIDTH);
85
+ };
86
+ const getIdealWidth = (tokens) =>
87
+ Math.max(stringWidth(getPlainText(tokens)), MIN_COLUMN_WIDTH);
88
+
89
+ // Step 1: min (longest word) and ideal (full content) widths per column.
90
+ const minWidths = token.header.map((header, colIndex) => {
91
+ let maxMinWidth = getMinWidth(header.tokens);
92
+ for (const row of token.rows) maxMinWidth = Math.max(maxMinWidth, getMinWidth(row[colIndex]?.tokens));
93
+ return maxMinWidth;
94
+ });
95
+ const idealWidths = token.header.map((header, colIndex) => {
96
+ let maxIdeal = getIdealWidth(header.tokens);
97
+ for (const row of token.rows) maxIdeal = Math.max(maxIdeal, getIdealWidth(row[colIndex]?.tokens));
98
+ return maxIdeal;
99
+ });
100
+
101
+ // Step 2: available space.
102
+ const numCols = token.header.length;
103
+ const borderOverhead = 1 + numCols * 3;
104
+ const availableWidth = Math.max(width - borderOverhead - SAFETY_MARGIN, numCols * MIN_COLUMN_WIDTH);
105
+
106
+ // Step 3: fit column widths into available space.
107
+ const totalMin = minWidths.reduce((s, w) => s + w, 0);
108
+ const totalIdeal = idealWidths.reduce((s, w) => s + w, 0);
109
+ let needsHardWrap = false;
110
+ let columnWidths;
111
+ if (totalIdeal <= availableWidth) {
112
+ columnWidths = idealWidths;
113
+ } else if (totalMin <= availableWidth) {
114
+ const extraSpace = availableWidth - totalMin;
115
+ const overflows = idealWidths.map((ideal, i) => ideal - minWidths[i]);
116
+ const totalOverflow = overflows.reduce((s, o) => s + o, 0);
117
+ columnWidths = minWidths.map((min, i) => {
118
+ if (totalOverflow === 0) return min;
119
+ return min + Math.floor((overflows[i] / totalOverflow) * extraSpace);
120
+ });
121
+ } else {
122
+ needsHardWrap = true;
123
+ const scaleFactor = availableWidth / totalMin;
124
+ columnWidths = minWidths.map((w) => Math.max(Math.floor(w * scaleFactor), MIN_COLUMN_WIDTH));
125
+ }
126
+
127
+ // Step 4: max row lines → decide vertical fallback.
128
+ const calculateMaxRowLines = () => {
129
+ let maxLines = 1;
130
+ for (let i = 0; i < token.header.length; i++) {
131
+ const wrapped = wrapText(formatCell(token.header[i].tokens), columnWidths[i], { hard: needsHardWrap });
132
+ maxLines = Math.max(maxLines, wrapped.length);
133
+ }
134
+ for (const row of token.rows) {
135
+ for (let i = 0; i < row.length; i++) {
136
+ const wrapped = wrapText(formatCell(row[i]?.tokens), columnWidths[i], { hard: needsHardWrap });
137
+ maxLines = Math.max(maxLines, wrapped.length);
138
+ }
139
+ }
140
+ return maxLines;
141
+ };
142
+ const useVerticalFormat = calculateMaxRowLines() > MAX_ROW_LINES;
143
+
144
+ const renderRowLines = (cells, isHeader) => {
145
+ const cellLines = cells.map((cell, colIndex) =>
146
+ wrapText(formatCell(cell.tokens), columnWidths[colIndex], { hard: needsHardWrap }),
147
+ );
148
+ const maxLines = Math.max(...cellLines.map((l) => l.length), 1);
149
+ const verticalOffsets = cellLines.map((l) => Math.floor((maxLines - l.length) / 2));
150
+ const result = [];
151
+ for (let lineIdx = 0; lineIdx < maxLines; lineIdx++) {
152
+ let line = '│';
153
+ for (let colIndex = 0; colIndex < cells.length; colIndex++) {
154
+ const lines = cellLines[colIndex];
155
+ const offset = verticalOffsets[colIndex];
156
+ const contentLineIdx = lineIdx - offset;
157
+ const lineText = contentLineIdx >= 0 && contentLineIdx < lines.length ? lines[contentLineIdx] : '';
158
+ const colWidth = columnWidths[colIndex];
159
+ const align = isHeader ? 'center' : token.align?.[colIndex] ?? 'left';
160
+ line += ' ' + padAligned(lineText, stringWidth(lineText), colWidth, align) + ' │';
161
+ }
162
+ result.push(line);
163
+ }
164
+ return result;
165
+ };
166
+
167
+ const renderBorderLine = (type) => {
168
+ const [left, mid, cross, right] = {
169
+ top: ['┌', '─', '┬', '┐'],
170
+ middle: ['├', '─', '┼', '┤'],
171
+ bottom: ['└', '─', '┴', '┘'],
172
+ }[type];
173
+ let line = left;
174
+ columnWidths.forEach((colWidth, colIndex) => {
175
+ line += mid.repeat(colWidth + 2);
176
+ line += colIndex < columnWidths.length - 1 ? cross : right;
177
+ });
178
+ return line;
179
+ };
180
+
181
+ const renderVerticalLines = () => {
182
+ const lines = [];
183
+ const headers = token.header.map((h) => getPlainText(h.tokens));
184
+ const separatorWidth = Math.min(Math.max(0, width - 1), 40);
185
+ const separator = '─'.repeat(separatorWidth);
186
+ const wrapIndent = ' ';
187
+ const indentWidth = stringWidth(wrapIndent);
188
+ const pushFitted = (rawLine) => {
189
+ for (const part of hardWrapLines(rawLine, width)) lines.push(part);
190
+ };
191
+ token.rows.forEach((row, rowIndex) => {
192
+ if (rowIndex > 0) lines.push(separator);
193
+ row.forEach((cell, colIndex) => {
194
+ const label = headers[colIndex] || `Column ${colIndex + 1}`;
195
+ const rawValue = formatCell(cell.tokens).trimEnd();
196
+ const value = rawValue.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim();
197
+ const prefix = `${ANSI_BOLD_START}${label}:${ANSI_BOLD_END} `;
198
+ const prefixWidth = stringWidth(stripAnsi(prefix));
199
+ const firstValueWidth = Math.max(1, width - prefixWidth);
200
+ const contValueWidth = Math.max(1, width - indentWidth);
201
+ const firstValueLines = hardWrapLines(value, firstValueWidth);
202
+ if (firstValueLines.length === 0) {
203
+ pushFitted(prefix.trimEnd());
204
+ return;
205
+ }
206
+ pushFitted(prefix + firstValueLines[0]);
207
+ const tail = firstValueLines.slice(1).join(' ').trim();
208
+ if (tail) {
209
+ for (const cont of hardWrapLines(tail, contValueWidth)) {
210
+ if (!cont.trim()) continue;
211
+ pushFitted(wrapIndent + cont);
212
+ }
213
+ }
214
+ });
215
+ });
216
+ return lines;
217
+ };
218
+
219
+ if (useVerticalFormat) {
220
+ return { lines: renderVerticalLines(), useVerticalFormat: true };
221
+ }
222
+
223
+ const tableLines = [];
224
+ tableLines.push(renderBorderLine('top'));
225
+ tableLines.push(...renderRowLines(token.header, true));
226
+ tableLines.push(renderBorderLine('middle'));
227
+ token.rows.forEach((row, rowIndex) => {
228
+ tableLines.push(...renderRowLines(row, false));
229
+ if (rowIndex < token.rows.length - 1) tableLines.push(renderBorderLine('middle'));
230
+ });
231
+ tableLines.push(renderBorderLine('bottom'));
232
+
233
+ // Safety: if any line would overflow (resize race), fall back to vertical.
234
+ const maxLineWidth = Math.max(...tableLines.map((l) => stringWidth(stripAnsi(l))));
235
+ if (maxLineWidth > width - SAFETY_MARGIN) {
236
+ return { lines: renderVerticalLines(), useVerticalFormat: true };
237
+ }
238
+
239
+ return { lines: tableLines, useVerticalFormat: false };
240
+ }
241
+
242
+ /**
243
+ * Exact number of terminal lines a table token occupies once rendered at
244
+ * `terminalWidth`. This is what MarkdownTable actually draws, so the row
245
+ * estimator can reserve precisely that many rows (no top-clip, no slack).
246
+ */
247
+ export function measureMarkdownTableRows(token, terminalWidth) {
248
+ if (!token || !Array.isArray(token.header) || token.header.length === 0) return 0;
249
+ return buildTableRender(token, terminalWidth).lines.length;
250
+ }
@@ -62,13 +62,6 @@ export function formatImageRef(id) {
62
62
  return `[Image #${id}]`;
63
63
  }
64
64
 
65
- export function parsePasteReferences(input) {
66
- const re = /\[(?:Image|Pasted text|\.\.\.Truncated text) #(\d+)(?: \+\d+ lines)?\]/g;
67
- return [...String(input || '').matchAll(re)]
68
- .map((m) => ({ id: Number(m[1]) || 0, match: m[0], index: m.index || 0 }))
69
- .filter((m) => m.id > 0);
70
- }
71
-
72
65
  export function imageReferenceIds(input) {
73
66
  const re = /\[Image #(\d+)\]/g;
74
67
  return new Set([...String(input || '').matchAll(re)].map((m) => Number(m[1]) || 0).filter(Boolean));
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * src/tui/spinner-verbs.mjs — playful "thinking" verbs for the spinner.
3
3
  *
4
- * Ported verbatim from Claude Code (refs/claude-code/src/constants/spinnerVerbs.ts
5
- * SPINNER_VERBS). Shown as `<Verb>… (Ns · ↑ N tokens)` while a turn runs.
4
+ * Playful spinner verbs. Shown as `<Verb>… (Ns · ↑ N tokens)` while a turn runs.
6
5
  */
7
6
  export const SPINNER_VERBS = [
8
7
  'Accomplishing', 'Actioning', 'Actualizing', 'Architecting', 'Baking', 'Beaming',
@@ -0,0 +1,172 @@
1
+ /**
2
+ * TUI-only bridge: remap hardcoded footer ANSI from `src/ui/statusline.mjs` and
3
+ * vendored `statusline-lib` into the active React TUI theme.
4
+ *
5
+ * CLI/REPL keeps emitting the fixed Mixdog statusline palette; the Ink footer
6
+ * normalizes that at display time so `/theme` can re-tone without touching ui/.
7
+ */
8
+
9
+ const RESET = '\x1b[0m';
10
+
11
+ /** Truecolor SGR sequences emitted by the shared statusline stack (not theme). */
12
+ export const STATUSLINE_CANONICAL_TRUECOLOR = Object.freeze({
13
+ statusText: [198, 198, 198],
14
+ subtle: [136, 136, 136],
15
+ success: [0, 170, 75],
16
+ successBright: [0, 185, 88],
17
+ warning: [255, 193, 7],
18
+ warningBright: [255, 210, 80],
19
+ error: [220, 70, 88],
20
+ });
21
+
22
+ function truecolorSgr(r, g, b) {
23
+ return `\x1b[38;2;${r};${g};${b}m`;
24
+ }
25
+
26
+ function footerLocalNum(value) {
27
+ const n = Number(value);
28
+ return Number.isFinite(n) ? n : 0;
29
+ }
30
+
31
+ /** Mirrors StatusLine `isResetStatsState` — stats-only identity for footer cache. */
32
+ export function isResetStatsState(stats) {
33
+ const s = stats && typeof stats === 'object' ? stats : {};
34
+ return footerLocalNum(s.currentContextTokens) === 0
35
+ && footerLocalNum(s.currentEstimatedContextTokens) === 0
36
+ && footerLocalNum(s.inputTokens) === 0
37
+ && footerLocalNum(s.latestInputTokens) === 0
38
+ && footerLocalNum(s.promptTokens) === 0
39
+ && footerLocalNum(s.turns) === 0;
40
+ }
41
+
42
+ /**
43
+ * True when the footer should snap local / invalidate cached async output.
44
+ * Keep in sync with `shouldSnapLocalStatusline` in StatusLine.jsx.
45
+ */
46
+ export function statuslineFooterIdentityChanged(args, lastArgs) {
47
+ if (!args) return false;
48
+ if (!lastArgs) return true;
49
+ if (args.agentRevision !== lastArgs.agentRevision) return true;
50
+ if (args.sessionId !== lastArgs.sessionId) return true;
51
+ if (args.provider !== lastArgs.provider || args.model !== lastArgs.model) return true;
52
+ if (args.effort !== lastArgs.effort || args.fast !== lastArgs.fast) return true;
53
+ if (args.contextWindow !== lastArgs.contextWindow || args.rawContextWindow !== lastArgs.rawContextWindow) return true;
54
+ if (args.displayContextWindow !== lastArgs.displayContextWindow) return true;
55
+ if (args.compactBoundaryTokens !== lastArgs.compactBoundaryTokens) return true;
56
+ if (args.autoCompactTokenLimit !== lastArgs.autoCompactTokenLimit) return true;
57
+ if (isResetStatsState(args.stats) && !isResetStatsState(lastArgs.stats)) return true;
58
+ return false;
59
+ }
60
+
61
+ /**
62
+ * Cache key for the last async full statusline (identity-changing props only).
63
+ * Must stay aligned with `shouldSnapLocalStatusline` in StatusLine.jsx.
64
+ * Stats reset is not keyed here; `statuslineFooterIdentityChanged` clears cache instead.
65
+ */
66
+ export function statuslineFooterCacheKey({
67
+ agentRevision = '',
68
+ sessionId = '',
69
+ clientHostPid = '',
70
+ provider = '',
71
+ model = '',
72
+ effort = '',
73
+ fast = false,
74
+ contextWindow = 0,
75
+ displayContextWindow = 0,
76
+ rawContextWindow = 0,
77
+ compactBoundaryTokens = 0,
78
+ autoCompactTokenLimit = 0,
79
+ } = {}) {
80
+ return [
81
+ String(agentRevision),
82
+ String(sessionId),
83
+ String(clientHostPid ?? ''),
84
+ String(provider),
85
+ String(model),
86
+ String(effort),
87
+ fast === true ? '1' : '0',
88
+ String(contextWindow),
89
+ String(displayContextWindow),
90
+ String(rawContextWindow),
91
+ String(compactBoundaryTokens),
92
+ String(autoCompactTokenLimit),
93
+ ].join('\0');
94
+ }
95
+
96
+ /**
97
+ * Gateway quota segments often end a dim label with reset then bare values
98
+ * (`Credit $1.23`, `used/limit`). Ink defaults reset to terminal fg; apply
99
+ * themed status text until the next explicit SGR.
100
+ */
101
+ export function applyDefaultStatusForegroundAfterReset(text, STATUS, reset = RESET) {
102
+ const src = String(text || '');
103
+ if (!STATUS || !reset) return src;
104
+ let out = '';
105
+ let i = 0;
106
+ while (i < src.length) {
107
+ const at = src.indexOf(reset, i);
108
+ if (at === -1) {
109
+ out += src.slice(i);
110
+ break;
111
+ }
112
+ out += src.slice(i, at + reset.length);
113
+ i = at + reset.length;
114
+ let j = i;
115
+ while (j < src.length && src[j] === ' ') {
116
+ out += src[j];
117
+ j += 1;
118
+ }
119
+ if (j < src.length && src[j] !== '\x1b') {
120
+ out += STATUS;
121
+ }
122
+ i = j;
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /**
128
+ * @param {string} text
129
+ * @param {{ STATUS: string, SUBTLE: string, SUCCESS: string, WARNING: string, ERROR: string }} colors
130
+ */
131
+ export function remapCanonicalStatuslineTruecolor(text, colors) {
132
+ const c = STATUSLINE_CANONICAL_TRUECOLOR;
133
+ const pairs = [
134
+ [truecolorSgr(...c.statusText), colors.STATUS],
135
+ [truecolorSgr(...c.subtle), colors.SUBTLE],
136
+ [truecolorSgr(...c.success), colors.SUCCESS],
137
+ [truecolorSgr(...c.successBright), colors.SUCCESS],
138
+ [truecolorSgr(...c.warning), colors.WARNING],
139
+ [truecolorSgr(...c.warningBright), colors.WARNING],
140
+ [truecolorSgr(...c.error), colors.ERROR],
141
+ ];
142
+ let out = String(text || '');
143
+ for (const [from, to] of pairs) {
144
+ if (from && to) out = out.split(from).join(to);
145
+ }
146
+ return out;
147
+ }
148
+
149
+ /**
150
+ * Normalize vendored/native statusline ANSI for Ink `<Text>` using theme SGR.
151
+ *
152
+ * @param {string} text
153
+ * @param {{ STATUS: string, SUBTLE: string, SUCCESS: string, WARNING: string, ERROR: string }} colors
154
+ * @param {{ reset?: string }} [opts]
155
+ */
156
+ export function normalizeStatuslineAnsi(text, colors, { reset = RESET } = {}) {
157
+ const { STATUS, SUBTLE, SUCCESS, WARNING, ERROR } = colors;
158
+ let out = remapCanonicalStatuslineTruecolor(text, colors)
159
+ .replace(/\n+$/, '')
160
+ .replace(/\x1b\[1m/g, STATUS)
161
+ .replace(/\x1b\[2m/g, SUBTLE)
162
+ .replace(/\x1b\[31m/g, ERROR)
163
+ .replace(/\x1b\[32m/g, SUCCESS)
164
+ .replace(/\x1b\[33m/g, WARNING)
165
+ .replace(/\x1b\[36m/g, SUBTLE)
166
+ .replace(/\x1b\[90m/g, SUBTLE)
167
+ .replace(/^((?:\x1b\[[0-9;]*m)*)◆((?:\x1b\[[0-9;]*m)*)(\s?)/, `${STATUS}◆$2$3`)
168
+ .replace(/(\x1b\[0m )(\d+(?:\.\d+)?%)(?= |$)/g, `$1${STATUS}$2${reset}`)
169
+ .replaceAll(`${reset} ${SUBTLE}│${reset} `, ` ${SUBTLE}│${reset} `);
170
+ out = applyDefaultStatusForegroundAfterReset(out, STATUS, reset);
171
+ return out;
172
+ }
@@ -0,0 +1,159 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ remapCanonicalStatuslineTruecolor,
5
+ normalizeStatuslineAnsi,
6
+ statuslineFooterCacheKey,
7
+ statuslineFooterIdentityChanged,
8
+ isResetStatsState,
9
+ STATUSLINE_CANONICAL_TRUECOLOR,
10
+ } from './statusline-ansi-bridge.mjs';
11
+ import { theme, setThemeSetting } from './theme.mjs';
12
+
13
+ function sgr38(r, g, b) {
14
+ return `\x1b[38;2;${r};${g};${b}m`;
15
+ }
16
+
17
+ function statusColorsFromTheme() {
18
+ const ansiRgb = (value, fallback) => {
19
+ const match = /^rgb\((\d+),(\d+),(\d+)\)$/.exec(String(value || '').replace(/\s+/g, ''));
20
+ if (!match) return fallback;
21
+ return sgr38(match[1], match[2], match[3]);
22
+ };
23
+ return {
24
+ STATUS: ansiRgb(theme.statusText, sgr38(198, 198, 198)),
25
+ SUBTLE: ansiRgb(theme.statusSubtle, sgr38(136, 136, 136)),
26
+ SUCCESS: ansiRgb(theme.success, sgr38(0, 170, 75)),
27
+ WARNING: ansiRgb(theme.warning, sgr38(255, 193, 7)),
28
+ ERROR: ansiRgb(theme.error, sgr38(220, 70, 88)),
29
+ };
30
+ }
31
+
32
+ test('remaps canonical truecolor sequences from ui/statusline.mjs', () => {
33
+ const colors = statusColorsFromTheme();
34
+ const subtle = sgr38(...STATUSLINE_CANONICAL_TRUECOLOR.subtle);
35
+ const success = sgr38(...STATUSLINE_CANONICAL_TRUECOLOR.success);
36
+ const raw = `${subtle}│\x1b[0m ${success}42%`;
37
+ const out = remapCanonicalStatuslineTruecolor(raw, colors);
38
+ assert.ok(!out.includes('38;2;136;136;136'), 'subtle canonical RGB should be replaced');
39
+ assert.ok(!out.includes('38;2;0;170;75'), 'success canonical RGB should be replaced');
40
+ assert.ok(out.includes(colors.SUBTLE));
41
+ assert.ok(out.includes(colors.SUCCESS));
42
+ });
43
+
44
+ test('normalizeStatuslineAnsi maps bold and legacy SGR plus truecolor', () => {
45
+ const colors = statusColorsFromTheme();
46
+ const raw = `\x1b[1mmodel\x1b[0m \x1b[33m5H\x1b[0m ${sgr38(...STATUSLINE_CANONICAL_TRUECOLOR.error)}quota`;
47
+ const out = normalizeStatuslineAnsi(raw, colors);
48
+ assert.ok(out.includes(colors.STATUS));
49
+ assert.ok(out.includes(colors.WARNING));
50
+ assert.ok(out.includes(colors.ERROR));
51
+ assert.ok(!out.includes('\x1b[33m'));
52
+ assert.ok(!out.includes('38;2;220;70;88'));
53
+ });
54
+
55
+ test('normalizeStatuslineAnsi preserves leading diamond glyph', () => {
56
+ const colors = statusColorsFromTheme();
57
+ const subtle = sgr38(...STATUSLINE_CANONICAL_TRUECOLOR.subtle);
58
+ const raw = `${subtle}◆\x1b[0m \x1b[1mgpt-4o\x1b[0m`;
59
+ const out = normalizeStatuslineAnsi(raw, colors);
60
+ assert.ok(out.includes('◆'), 'visible ◆ must remain');
61
+ assert.ok(out.includes('gpt-4o'));
62
+ assert.ok(out.indexOf('◆') < out.indexOf('gpt-4o'));
63
+ });
64
+
65
+ test('retimes bare values after reset without clobbering explicit segment colors', () => {
66
+ const colors = statusColorsFromTheme();
67
+ const subtle = colors.SUBTLE;
68
+ const success = colors.SUCCESS;
69
+ const warning = colors.WARNING;
70
+ const reset = '\x1b[0m';
71
+ const creditSeg = `${subtle}Credit${reset} $12.34`;
72
+ const outCredit = normalizeStatuslineAnsi(creditSeg, colors, { reset });
73
+ assert.ok(outCredit.includes('$12.34'));
74
+ const moneyIdx = outCredit.indexOf('$12.34');
75
+ assert.ok(moneyIdx > 0);
76
+ assert.ok(outCredit.slice(0, moneyIdx).includes(colors.STATUS), 'bare credit amount uses status text');
77
+
78
+ const usedLimit = `${subtle}5H${reset} $3.00/$10.00`;
79
+ const outUsed = normalizeStatuslineAnsi(usedLimit, colors, { reset });
80
+ assert.ok(outUsed.includes('$3.00/$10.00'));
81
+ assert.ok(outUsed.slice(0, outUsed.indexOf('$3.00')).includes(colors.STATUS));
82
+
83
+ const colored = `${subtle}5H${reset} ${warning}est $5.00${reset}`;
84
+ const outColored = normalizeStatuslineAnsi(colored, colors, { reset });
85
+ assert.ok(outColored.includes(warning), 'warning color on value must remain');
86
+ const estIdx = outColored.indexOf('est');
87
+ assert.ok(estIdx > 0);
88
+ assert.ok(outColored.slice(0, estIdx).includes(warning));
89
+ });
90
+
91
+ test('statuslineFooterCacheKey changes when session or route identity changes', () => {
92
+ const a = statuslineFooterCacheKey({ sessionId: 's1', provider: 'p', model: 'm1' });
93
+ const b = statuslineFooterCacheKey({ sessionId: 's2', provider: 'p', model: 'm1' });
94
+ const c = statuslineFooterCacheKey({ sessionId: 's1', provider: 'p', model: 'm2' });
95
+ assert.notEqual(a, b);
96
+ assert.notEqual(a, c);
97
+ });
98
+
99
+ test('statusline footer identity tracks context display boundary fields', () => {
100
+ const base = {
101
+ sessionId: 's1',
102
+ provider: 'p',
103
+ model: 'm',
104
+ contextWindow: 1_000_000,
105
+ displayContextWindow: 900_000,
106
+ rawContextWindow: 1_000_000,
107
+ compactBoundaryTokens: 900_000,
108
+ autoCompactTokenLimit: 810_000,
109
+ stats: { currentEstimatedContextTokens: 10_000 },
110
+ };
111
+ const changedDisplay = { ...base, displayContextWindow: 800_000 };
112
+ const changedBoundary = { ...base, compactBoundaryTokens: 800_000 };
113
+ const changedTrigger = { ...base, autoCompactTokenLimit: 720_000 };
114
+
115
+ assert.notEqual(statuslineFooterCacheKey(base), statuslineFooterCacheKey(changedDisplay));
116
+ assert.notEqual(statuslineFooterCacheKey(base), statuslineFooterCacheKey(changedBoundary));
117
+ assert.notEqual(statuslineFooterCacheKey(base), statuslineFooterCacheKey(changedTrigger));
118
+ assert.equal(statuslineFooterIdentityChanged(changedDisplay, base), true);
119
+ assert.equal(statuslineFooterIdentityChanged(changedBoundary, base), true);
120
+ assert.equal(statuslineFooterIdentityChanged(changedTrigger, base), true);
121
+ });
122
+
123
+ test('stats reset transition is footer identity change (cache must not reuse on /theme)', () => {
124
+ const activeStats = {
125
+ currentContextTokens: 1200,
126
+ inputTokens: 800,
127
+ turns: 2,
128
+ };
129
+ const resetStats = {
130
+ currentContextTokens: 0,
131
+ currentEstimatedContextTokens: 0,
132
+ inputTokens: 0,
133
+ latestInputTokens: 0,
134
+ promptTokens: 0,
135
+ turns: 0,
136
+ };
137
+ assert.equal(isResetStatsState(resetStats), true);
138
+ assert.equal(isResetStatsState(activeStats), false);
139
+ const sameRoute = { sessionId: 's1', provider: 'p', model: 'm', stats: resetStats };
140
+ const lastRoute = { sessionId: 's1', provider: 'p', model: 'm', stats: activeStats };
141
+ assert.equal(statuslineFooterCacheKey(sameRoute), statuslineFooterCacheKey(lastRoute));
142
+ assert.equal(statuslineFooterIdentityChanged(sameRoute, lastRoute), true);
143
+ });
144
+
145
+ test('theme switch changes normalized output when raw line is canonical', () => {
146
+ setThemeSetting('mixdog', { persist: false });
147
+ const colorsA = statusColorsFromTheme();
148
+ const raw = `${sgr38(...STATUSLINE_CANONICAL_TRUECOLOR.success)}▓▓\x1b[0m ${sgr38(...STATUSLINE_CANONICAL_TRUECOLOR.subtle)}░░`;
149
+ const tonedA = normalizeStatuslineAnsi(raw, colorsA);
150
+
151
+ setThemeSetting('pi-dark', { persist: false });
152
+ const colorsB = statusColorsFromTheme();
153
+ const tonedB = normalizeStatuslineAnsi(raw, colorsB);
154
+
155
+ assert.notEqual(tonedA, tonedB);
156
+ assert.ok(tonedB.includes(colorsB.SUCCESS));
157
+ assert.ok(tonedB.includes(colorsB.SUBTLE));
158
+ setThemeSetting('mixdog', { persist: false });
159
+ });