mixdog 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (285) hide show
  1. package/README.md +47 -23
  2. package/package.json +33 -27
  3. package/scripts/_test-folder-dialog.mjs +30 -0
  4. package/scripts/agent-parallel-smoke.mjs +388 -0
  5. package/scripts/agent-tag-reuse-smoke.mjs +183 -0
  6. package/scripts/background-task-meta-smoke.mjs +38 -0
  7. package/scripts/boot-smoke.mjs +52 -9
  8. package/scripts/build-runtime-linux.sh +348 -0
  9. package/scripts/build-runtime-macos.sh +217 -0
  10. package/scripts/build-runtime-windows.ps1 +242 -0
  11. package/scripts/compact-active-turn-test.mjs +68 -0
  12. package/scripts/compact-smoke.mjs +859 -129
  13. package/scripts/compact-trigger-migration-smoke.mjs +187 -0
  14. package/scripts/fix-brief-fn.mjs +35 -0
  15. package/scripts/fix-format-tool-surface.mjs +24 -0
  16. package/scripts/fix-tool-exec-visible.mjs +42 -0
  17. package/scripts/generate-runtime-manifest.mjs +166 -0
  18. package/scripts/hook-bus-test.mjs +330 -0
  19. package/scripts/lead-workflow-smoke.mjs +33 -39
  20. package/scripts/live-worker-smoke.mjs +43 -37
  21. package/scripts/llm-trace-summary.mjs +315 -0
  22. package/scripts/memory-meta-concurrency-test.mjs +20 -0
  23. package/scripts/output-style-smoke.mjs +56 -15
  24. package/scripts/parent-abort-link-test.mjs +44 -0
  25. package/scripts/patch-agent-brief.mjs +48 -0
  26. package/scripts/patch-app.mjs +21 -0
  27. package/scripts/patch-app2.mjs +18 -0
  28. package/scripts/patch-dist-brief.mjs +96 -0
  29. package/scripts/patch-tool-exec.mjs +70 -0
  30. package/scripts/pretool-ask-runtime-test.mjs +54 -0
  31. package/scripts/provider-toolcall-test.mjs +376 -0
  32. package/scripts/reactive-compact-persist-smoke.mjs +124 -0
  33. package/scripts/sanitize-tool-pairs-test.mjs +260 -0
  34. package/scripts/session-context-bench.mjs +344 -0
  35. package/scripts/session-ingest-smoke.mjs +177 -0
  36. package/scripts/set-effort-config-test.mjs +41 -0
  37. package/scripts/smoke-runtime-negative.ps1 +106 -0
  38. package/scripts/smoke-runtime-negative.sh +97 -0
  39. package/scripts/smoke.mjs +25 -0
  40. package/scripts/tool-result-hook-test.mjs +48 -0
  41. package/scripts/tool-smoke.mjs +1223 -95
  42. package/scripts/toolcall-args-test.mjs +150 -0
  43. package/scripts/tui-background-failure-smoke.mjs +73 -0
  44. package/scripts/usage-metrics-epoch-smoke.mjs +114 -0
  45. package/src/agents/debugger/AGENT.md +8 -0
  46. package/src/agents/explore/AGENT.md +4 -0
  47. package/src/agents/heavy-worker/AGENT.md +9 -3
  48. package/src/agents/maintainer/AGENT.md +4 -0
  49. package/src/agents/reviewer/AGENT.md +8 -0
  50. package/src/agents/scheduler-task/AGENT.md +12 -0
  51. package/src/agents/scheduler-task/agent.json +6 -0
  52. package/src/agents/webhook-handler/AGENT.md +12 -0
  53. package/src/agents/webhook-handler/agent.json +6 -0
  54. package/src/agents/worker/AGENT.md +9 -3
  55. package/src/app.mjs +77 -3
  56. package/src/defaults/hidden-roles.json +17 -12
  57. package/src/headless-role.mjs +117 -0
  58. package/src/help.mjs +30 -0
  59. package/src/hooks/lib/permission-evaluator.cjs +11 -475
  60. package/src/lib/keychain-cjs.cjs +9 -1
  61. package/src/lib/mixdog-debug.cjs +0 -7
  62. package/src/lib/rules-builder.cjs +240 -96
  63. package/src/lib/text-utils.cjs +1 -1
  64. package/src/mixdog-session-runtime.mjs +2325 -450
  65. package/src/output-styles/default.md +12 -28
  66. package/src/output-styles/extreme-simple.md +9 -6
  67. package/src/output-styles/simple.md +22 -9
  68. package/src/repl.mjs +118 -59
  69. package/src/rules/agent/00-common.md +15 -0
  70. package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
  71. package/src/rules/agent/30-explorer.md +22 -0
  72. package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
  73. package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
  74. package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
  75. package/src/rules/lead/01-general.md +9 -5
  76. package/src/rules/lead/04-workflow.md +51 -12
  77. package/src/rules/lead/lead-tool.md +6 -0
  78. package/src/rules/shared/01-tool.md +12 -1
  79. package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
  80. package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
  81. package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
  82. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
  83. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
  84. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
  85. package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
  86. package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
  87. package/src/runtime/agent/orchestrator/config.mjs +174 -55
  88. package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
  89. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
  90. package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
  91. package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
  92. package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
  93. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
  94. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +377 -243
  95. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +146 -93
  96. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
  97. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
  98. package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
  99. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
  100. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
  101. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
  102. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
  103. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
  104. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
  105. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
  106. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +221 -70
  107. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +477 -147
  108. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -496
  109. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
  110. package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
  111. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +31 -11
  112. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
  113. package/src/runtime/agent/orchestrator/session/cache/post-edit-marks.mjs +4 -4
  114. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +1 -1
  115. package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
  116. package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
  117. package/src/runtime/agent/orchestrator/session/loop.mjs +851 -674
  118. package/src/runtime/agent/orchestrator/session/manager.mjs +1593 -466
  119. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
  120. package/src/runtime/agent/orchestrator/session/store.mjs +291 -46
  121. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  122. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
  123. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
  124. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +34 -7
  125. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +1 -1
  126. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -0
  127. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +9 -9
  128. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
  129. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
  130. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
  131. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
  132. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
  133. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
  134. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
  135. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
  136. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
  137. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
  138. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
  139. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
  140. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
  141. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
  142. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
  143. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
  144. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +1 -1
  145. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
  146. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
  147. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
  148. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
  149. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
  150. package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
  151. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
  152. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
  153. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
  154. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
  155. package/src/runtime/channels/index.mjs +152 -24
  156. package/src/runtime/channels/lib/scheduler.mjs +18 -14
  157. package/src/runtime/channels/lib/session-discovery.mjs +3 -2
  158. package/src/runtime/channels/lib/tool-format.mjs +0 -2
  159. package/src/runtime/channels/lib/transcript-discovery.mjs +3 -2
  160. package/src/runtime/channels/lib/webhook.mjs +1 -1
  161. package/src/runtime/channels/tool-defs.mjs +29 -29
  162. package/src/runtime/memory/index.mjs +635 -107
  163. package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
  164. package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
  165. package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
  166. package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
  167. package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
  168. package/src/runtime/memory/lib/memory-cycle1.mjs +38 -17
  169. package/src/runtime/memory/lib/memory-cycle2.mjs +6 -7
  170. package/src/runtime/memory/lib/memory-cycle3.mjs +4 -4
  171. package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -1
  172. package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
  173. package/src/runtime/memory/lib/memory.mjs +88 -9
  174. package/src/runtime/memory/lib/model-profile.mjs +1 -1
  175. package/src/runtime/memory/lib/pg/adapter.mjs +15 -1
  176. package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
  177. package/src/runtime/memory/lib/runtime-fetcher.mjs +37 -3
  178. package/src/runtime/memory/lib/session-ingest.mjs +194 -0
  179. package/src/runtime/memory/lib/trace-store.mjs +96 -51
  180. package/src/runtime/memory/tool-defs.mjs +46 -37
  181. package/src/runtime/search/index.mjs +102 -466
  182. package/src/runtime/search/lib/web-tools.mjs +45 -25
  183. package/src/runtime/search/tool-defs.mjs +16 -23
  184. package/src/runtime/shared/abort-controller.mjs +1 -1
  185. package/src/runtime/shared/atomic-file.mjs +4 -3
  186. package/src/runtime/shared/background-tasks.mjs +122 -11
  187. package/src/runtime/shared/child-spawn-gate.mjs +145 -0
  188. package/src/runtime/shared/config.mjs +7 -4
  189. package/src/runtime/shared/err-text.mjs +131 -4
  190. package/src/runtime/shared/llm/cost.mjs +2 -2
  191. package/src/runtime/shared/llm/http-agent.mjs +23 -7
  192. package/src/runtime/shared/llm/index.mjs +34 -11
  193. package/src/runtime/shared/llm/usage-log.mjs +4 -4
  194. package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
  195. package/src/runtime/shared/singleton-owner.mjs +104 -0
  196. package/src/runtime/shared/tool-execution-contract.mjs +199 -20
  197. package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
  198. package/src/runtime/shared/tool-surface.mjs +624 -98
  199. package/src/runtime/shared/user-data-guard.mjs +0 -2
  200. package/src/standalone/agent-task-status.mjs +203 -0
  201. package/src/standalone/agent-task-status.test.mjs +76 -0
  202. package/src/standalone/agent-tool.mjs +1913 -0
  203. package/src/standalone/channel-worker.mjs +370 -14
  204. package/src/standalone/explore-tool.mjs +165 -70
  205. package/src/standalone/folder-dialog.mjs +314 -0
  206. package/src/standalone/hook-bus.mjs +898 -22
  207. package/src/standalone/memory-runtime-proxy.mjs +320 -0
  208. package/src/standalone/projects.mjs +226 -0
  209. package/src/standalone/provider-admin.mjs +41 -24
  210. package/src/standalone/seeds.mjs +2 -69
  211. package/src/standalone/usage-dashboard.mjs +96 -8
  212. package/src/tui/App.jsx +4798 -2153
  213. package/src/tui/components/AnsiText.jsx +39 -28
  214. package/src/tui/components/ContextPanel.jsx +87 -29
  215. package/src/tui/components/Markdown.jsx +43 -77
  216. package/src/tui/components/MarkdownTable.jsx +9 -184
  217. package/src/tui/components/Message.jsx +28 -11
  218. package/src/tui/components/Picker.jsx +95 -56
  219. package/src/tui/components/PromptInput.jsx +367 -239
  220. package/src/tui/components/QueuedCommands.jsx +1 -1
  221. package/src/tui/components/SlashCommandPalette.jsx +27 -21
  222. package/src/tui/components/Spinner.jsx +67 -38
  223. package/src/tui/components/StatusLine.jsx +606 -38
  224. package/src/tui/components/TextEntryPanel.jsx +128 -9
  225. package/src/tui/components/ToolExecution.jsx +617 -368
  226. package/src/tui/components/TurnDone.jsx +3 -3
  227. package/src/tui/components/UsagePanel.jsx +3 -5
  228. package/src/tui/components/tool-output-format.mjs +365 -0
  229. package/src/tui/components/tool-output-format.test.mjs +220 -0
  230. package/src/tui/dist/index.mjs +8915 -2418
  231. package/src/tui/engine-runtime-notification.test.mjs +115 -0
  232. package/src/tui/engine-tool-result-text.test.mjs +75 -0
  233. package/src/tui/engine.mjs +1455 -279
  234. package/src/tui/figures.mjs +21 -40
  235. package/src/tui/index.jsx +75 -31
  236. package/src/tui/input-editing.mjs +25 -0
  237. package/src/tui/markdown/format-token.mjs +511 -68
  238. package/src/tui/markdown/format-token.test.mjs +216 -0
  239. package/src/tui/markdown/render-ansi.mjs +94 -0
  240. package/src/tui/markdown/render-ansi.test.mjs +108 -0
  241. package/src/tui/markdown/stream-fence.mjs +34 -0
  242. package/src/tui/markdown/stream-fence.test.mjs +26 -0
  243. package/src/tui/markdown/table-layout.mjs +250 -0
  244. package/src/tui/paste-attachments.mjs +0 -7
  245. package/src/tui/spinner-verbs.mjs +1 -2
  246. package/src/tui/statusline-ansi-bridge.mjs +172 -0
  247. package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
  248. package/src/tui/theme.mjs +746 -24
  249. package/src/tui/time-format.mjs +1 -1
  250. package/src/tui/transcript-tool-failures.mjs +67 -0
  251. package/src/tui/transcript-tool-failures.test.mjs +111 -0
  252. package/src/ui/ansi.mjs +1 -2
  253. package/src/ui/markdown.mjs +85 -26
  254. package/src/ui/markdown.test.mjs +70 -0
  255. package/src/ui/model-display.mjs +121 -0
  256. package/src/ui/session-stats.mjs +44 -0
  257. package/src/ui/statusline-context-label.test.mjs +15 -0
  258. package/src/ui/statusline.mjs +386 -178
  259. package/src/ui/tool-card.mjs +3 -16
  260. package/src/vendor/statusline/bin/statusline-lib.mjs +8 -4
  261. package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
  262. package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
  263. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
  264. package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
  265. package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
  266. package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
  267. package/src/workflows/default/WORKFLOW.md +12 -5
  268. package/src/workflows/default/workflow.json +0 -1
  269. package/src/workflows/solo/WORKFLOW.md +15 -0
  270. package/src/workflows/solo/workflow.json +7 -0
  271. package/vendor/ink/build/output.js +6 -1
  272. package/src/agents/scheduler-task.md +0 -3
  273. package/src/agents/web-researcher/AGENT.md +0 -3
  274. package/src/agents/web-researcher/agent.json +0 -6
  275. package/src/agents/webhook-handler.md +0 -3
  276. package/src/rules/bridge/00-common.md +0 -5
  277. package/src/rules/bridge/30-explorer.md +0 -4
  278. package/src/rules/lead/00-tool-lead.md +0 -5
  279. package/src/rules/shared/00-language.md +0 -3
  280. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  281. package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
  282. package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
  283. package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
  284. package/src/standalone/bridge-tool.mjs +0 -1414
  285. package/src/tui/runtime/shared/process-shutdown.mjs +0 -1
@@ -1,5 +1,5 @@
1
1
  /**
2
- * TUI duration formatting, matching Claude Code's formatDuration shape.
2
+ * TUI duration formatting (compact elapsed-time labels).
3
3
  *
4
4
  * Examples:
5
5
  * 42s
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Transcript visibility rules for fully-failed tool batches.
3
+ * Hook / approval denials are user-actionable and must stay visible in the TUI.
4
+ */
5
+
6
+ import { parseToolArgs } from '../runtime/shared/tool-surface.mjs';
7
+
8
+ const HOOK_DENIAL_RE = /denied by hook/i;
9
+ const APPROVAL_UNAVAILABLE_RE = /approval required but no approval UI is available/i;
10
+
11
+ export function toolItemResultText(item) {
12
+ if (!item) return '';
13
+ const chunks = [];
14
+ if (item.result != null) chunks.push(String(item.result));
15
+ if (item.rawResult != null && item.rawResult !== item.result) chunks.push(String(item.rawResult));
16
+ return chunks.join('\n').trim();
17
+ }
18
+
19
+ export function isHookApprovalDenialToolResult(text) {
20
+ const value = String(text ?? '').trim();
21
+ if (!value) return false;
22
+ if (HOOK_DENIAL_RE.test(value)) return true;
23
+ if (APPROVAL_UNAVAILABLE_RE.test(value)) return true;
24
+ return false;
25
+ }
26
+
27
+ export function isHookApprovalDenialToolItem(item) {
28
+ if (!item || item.kind !== 'tool') return false;
29
+ return isHookApprovalDenialToolResult(toolItemResultText(item));
30
+ }
31
+
32
+ /** Strip the orchestrator's `Error: tool "name"` prefix for a compact TUI line. */
33
+ export function formatHookDenialDetail(text) {
34
+ const raw = String(text ?? '').trim();
35
+ if (!raw) return '';
36
+ const stripped = raw.replace(/^Error:\s*tool\s*"[^"]*"\s*/i, '').trim();
37
+ return stripped || raw;
38
+ }
39
+
40
+ export function isFullyFailedToolBatch(item) {
41
+ if (!item || item.kind !== 'tool') return false;
42
+ const args = parseToolArgs(item.args);
43
+ const hasTaskId = Boolean(args.task_id || args.taskId);
44
+ const status = String(args.status || '').toLowerCase();
45
+ if (hasTaskId && /^(failed|error|timeout|cancelled|canceled|killed)$/.test(status)) {
46
+ return false;
47
+ }
48
+ const count = Math.max(1, Number(item.count || 1));
49
+ const done = Math.max(0, Math.min(count, Number(item.completedCount || (item.result == null ? 0 : count))));
50
+ const explicit = Number(item.errorCount);
51
+ const failed = Number.isFinite(explicit)
52
+ ? Math.max(0, Math.min(count, Math.floor(explicit)))
53
+ : item.isError ? count : 0;
54
+ return done >= count && failed >= count;
55
+ }
56
+
57
+ /** Hide noisy all-failed internal tool cards, except hook/approval denials. */
58
+ export function shouldSuppressFullyFailedToolItem(item) {
59
+ if (!isFullyFailedToolBatch(item)) return false;
60
+ if (isHookApprovalDenialToolItem(item)) return false;
61
+ return !hasUsefulFailedToolResultBody(item);
62
+ }
63
+
64
+ /** Non-empty result/rawResult text the user can expand and inspect in the transcript. */
65
+ export function hasUsefulFailedToolResultBody(item) {
66
+ return Boolean(toolItemResultText(item));
67
+ }
@@ -0,0 +1,111 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ formatHookDenialDetail,
5
+ hasUsefulFailedToolResultBody,
6
+ isFullyFailedToolBatch,
7
+ isHookApprovalDenialToolItem,
8
+ isHookApprovalDenialToolResult,
9
+ shouldSuppressFullyFailedToolItem,
10
+ } from './transcript-tool-failures.mjs';
11
+
12
+ const hookDeniedItem = {
13
+ kind: 'tool',
14
+ name: 'shell',
15
+ args: { command: 'rm -rf /' },
16
+ result: 'Error: tool "shell" denied by hook: approval required but no approval UI is available (policy)',
17
+ isError: true,
18
+ count: 1,
19
+ completedCount: 1,
20
+ };
21
+
22
+ const noisyFailedItem = {
23
+ kind: 'tool',
24
+ name: 'grep',
25
+ args: { pattern: 'foo' },
26
+ result: 'no matches',
27
+ isError: true,
28
+ count: 1,
29
+ completedCount: 1,
30
+ };
31
+
32
+ const emptyFailedItem = {
33
+ kind: 'tool',
34
+ name: 'grep',
35
+ args: { pattern: 'foo' },
36
+ result: null,
37
+ rawResult: null,
38
+ isError: true,
39
+ count: 1,
40
+ completedCount: 1,
41
+ };
42
+
43
+ const failedTaskJsonStringArgs = {
44
+ kind: 'tool',
45
+ name: 'shell',
46
+ args: '{"task_id":"t1","status":"failed","error":"boom"}',
47
+ result: null,
48
+ rawResult: null,
49
+ isError: true,
50
+ count: 1,
51
+ completedCount: 1,
52
+ };
53
+
54
+ const failedTaskInputWrapperArgs = {
55
+ kind: 'tool',
56
+ name: 'shell',
57
+ args: { input: { task_id: 't2', status: 'timeout' } },
58
+ result: null,
59
+ rawResult: null,
60
+ isError: true,
61
+ count: 1,
62
+ completedCount: 1,
63
+ };
64
+
65
+ test('detects hook denial result text', () => {
66
+ assert.equal(
67
+ isHookApprovalDenialToolResult('Error: tool "read" denied by hook: blocked'),
68
+ true,
69
+ );
70
+ assert.equal(
71
+ isHookApprovalDenialToolResult('Error: tool "shell" denied by hook: approval required but no approval UI is available'),
72
+ true,
73
+ );
74
+ assert.equal(isHookApprovalDenialToolResult('grep: no matches'), false);
75
+ });
76
+
77
+ test('fully-failed hook denials are not suppressed', () => {
78
+ assert.equal(isFullyFailedToolBatch(hookDeniedItem), true);
79
+ assert.equal(isHookApprovalDenialToolItem(hookDeniedItem), true);
80
+ assert.equal(shouldSuppressFullyFailedToolItem(hookDeniedItem), false);
81
+ });
82
+
83
+ test('fully-failed non-hook errors with visible body stay visible', () => {
84
+ assert.equal(isFullyFailedToolBatch(noisyFailedItem), true);
85
+ assert.equal(hasUsefulFailedToolResultBody(noisyFailedItem), true);
86
+ assert.equal(shouldSuppressFullyFailedToolItem(noisyFailedItem), false);
87
+ });
88
+
89
+ test('fully-failed non-hook errors without useful body stay suppressed', () => {
90
+ assert.equal(isFullyFailedToolBatch(emptyFailedItem), true);
91
+ assert.equal(hasUsefulFailedToolResultBody(emptyFailedItem), false);
92
+ assert.equal(shouldSuppressFullyFailedToolItem(emptyFailedItem), true);
93
+ });
94
+
95
+ test('failed background task with JSON-string args is not suppressed', () => {
96
+ assert.equal(isFullyFailedToolBatch(failedTaskJsonStringArgs), false);
97
+ assert.equal(shouldSuppressFullyFailedToolItem(failedTaskJsonStringArgs), false);
98
+ });
99
+
100
+ test('failed background task with input-wrapped args is not suppressed', () => {
101
+ assert.equal(isFullyFailedToolBatch(failedTaskInputWrapperArgs), false);
102
+ assert.equal(shouldSuppressFullyFailedToolItem(failedTaskInputWrapperArgs), false);
103
+ });
104
+
105
+ test('formatHookDenialDetail strips tool prefix', () => {
106
+ assert.equal(
107
+ formatHookDenialDetail(hookDeniedItem.result),
108
+ 'denied by hook: approval required but no approval UI is available (policy)',
109
+ );
110
+ });
111
+
package/src/ui/ansi.mjs CHANGED
@@ -85,8 +85,7 @@ export const bgBlack = sgr('40');
85
85
  export const bgBlue = sgr('44');
86
86
 
87
87
  // --- Truecolor (24-bit) -----------------------------------------------------
88
- // Claude Code defines its palette as explicit rgb() values (refs/claude-code
89
- // src/utils/theme.ts darkTheme). 16-color SGR can't reproduce them, so we emit
88
+ // The TUI palette uses explicit rgb() values. 16-color SGR can't reproduce them, so we emit
90
89
  // 24-bit SGR. Honors NO_COLOR / TTY exactly like the named helpers above.
91
90
 
92
91
  /** Foreground truecolor wrapper: `rgb(215,119,87)('x')`. */
@@ -20,17 +20,29 @@ import {
20
20
  bold,
21
21
  italic,
22
22
  dim,
23
- cyan,
24
- gray,
25
- yellow,
26
- green,
27
23
  underline,
28
24
  strike as strikeStyle,
29
- brightWhite,
30
25
  colorEnabled,
31
26
  visibleWidth,
27
+ rgb,
28
+ compose,
32
29
  } from './ansi.mjs';
33
30
 
31
+ // Default Mixdog dark markdown semantics (mirrors src/tui/theme.mjs mixdogPalette md* keys).
32
+ const PALETTE = {
33
+ heading1: rgb(215, 119, 87),
34
+ heading: rgb(240, 198, 116),
35
+ inlineCode: rgb(138, 190, 183),
36
+ link: rgb(47, 127, 255),
37
+ codeBlock: rgb(181, 189, 104),
38
+ fenceLabel: rgb(138, 190, 183),
39
+ listBullet: rgb(138, 190, 183),
40
+ quoteText: rgb(128, 128, 128),
41
+ diffAdd: rgb(0, 170, 75),
42
+ diffDel: rgb(220, 70, 88),
43
+ diffHunk: rgb(204, 157, 44),
44
+ };
45
+
34
46
  /**
35
47
  * Render a markdown string to a terminal-styled string.
36
48
  * @param {string} src
@@ -54,6 +66,8 @@ function renderUnsafe(src, opts) {
54
66
  let inFence = false;
55
67
  let fenceLang = '';
56
68
  let fenceBuf = [];
69
+ let fenceMarker = '';
70
+ let fenceMarkerLen = 0;
57
71
 
58
72
  for (let i = 0; i < lines.length; i++) {
59
73
  const line = lines[i];
@@ -62,16 +76,24 @@ function renderUnsafe(src, opts) {
62
76
  if (fenceMatch && !inFence) {
63
77
  inFence = true;
64
78
  fenceLang = fenceMatch[3].trim();
79
+ fenceMarker = fenceMatch[2][0];
80
+ fenceMarkerLen = fenceMatch[2].length;
65
81
  fenceBuf = [];
66
82
  continue;
67
83
  }
68
84
  if (inFence) {
69
- // A line of only backticks/tildes closes the fence.
70
- if (/^(\s*)(`{3,}|~{3,})\s*$/.test(line)) {
85
+ const closeMatch = /^(\s*)([`~]+)\s*$/.exec(line);
86
+ const markerRun = closeMatch?.[2] ?? '';
87
+ const closes =
88
+ markerRun.length >= fenceMarkerLen &&
89
+ [...markerRun].every((ch) => ch === fenceMarker);
90
+ if (closes) {
71
91
  out.push(renderCodeBlock(fenceBuf, fenceLang, width));
72
92
  inFence = false;
73
93
  fenceLang = '';
74
94
  fenceBuf = [];
95
+ fenceMarker = '';
96
+ fenceMarkerLen = 0;
75
97
  } else {
76
98
  fenceBuf.push(line);
77
99
  }
@@ -98,7 +120,7 @@ function clampWidth(w) {
98
120
  function renderLine(line, width) {
99
121
  // Horizontal rule.
100
122
  if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
101
- return dim('─'.repeat(Math.min(width, 60)));
123
+ return dim(rgb(128, 128, 128)('─'.repeat(Math.min(width, 60))));
102
124
  }
103
125
 
104
126
  // ATX heading.
@@ -106,30 +128,30 @@ function renderLine(line, width) {
106
128
  if (h) {
107
129
  const level = h[1].length;
108
130
  const text = renderInline(h[2]);
109
- if (level === 1) return '\n' + bold(brightWhite('▌ ' + text));
110
- if (level === 2) return '\n' + bold(cyan(text));
111
- if (level === 3) return bold(text);
112
- return bold(dim(text));
131
+ if (level === 1) return '\n' + compose(bold, PALETTE.heading1)('▌ ' + text);
132
+ if (level === 2) return '\n' + compose(bold, PALETTE.heading)(text);
133
+ if (level === 3) return compose(bold, PALETTE.heading)(text);
134
+ return compose(bold, dim)(text);
113
135
  }
114
136
 
115
137
  // Blockquote (possibly nested).
116
138
  const q = /^(\s*>+)\s?(.*)$/.exec(line);
117
139
  if (q) {
118
- return dim('│ ') + dim(renderInline(q[2]));
140
+ return dim(PALETTE.quoteText('│ ')) + italic(PALETTE.quoteText(renderInline(q[2])));
119
141
  }
120
142
 
121
143
  // Bullet list.
122
144
  const b = /^(\s*)([-*+])\s+(.*)$/.exec(line);
123
145
  if (b) {
124
146
  const indent = b[1].replace(/\t/g, ' ');
125
- return indent + yellow('•') + ' ' + renderInline(b[3]);
147
+ return indent + PALETTE.listBullet('•') + ' ' + renderInline(b[3]);
126
148
  }
127
149
 
128
150
  // Numbered list.
129
151
  const n = /^(\s*)(\d+)([.)])\s+(.*)$/.exec(line);
130
152
  if (n) {
131
153
  const indent = n[1].replace(/\t/g, ' ');
132
- return indent + yellow(n[2] + '.') + ' ' + renderInline(n[4]);
154
+ return indent + PALETTE.listBullet(n[2] + '.') + ' ' + renderInline(n[4]);
133
155
  }
134
156
 
135
157
  // Plain paragraph line.
@@ -138,40 +160,77 @@ function renderLine(line, width) {
138
160
 
139
161
  function renderCodeBlock(bufLines, lang, width) {
140
162
  const inner = bufLines.length ? bufLines : [''];
163
+ const isDiff = isDiffFence(lang, inner);
141
164
  const contentWidth = Math.max(
142
165
  20,
143
166
  Math.min(width, inner.reduce((m, l) => Math.max(m, l.length), 0) + 2),
144
167
  );
145
- const label = lang ? ` ${lang} ` : '';
146
- const top = dim('┌' + (label ? gray(label) : '') + '─'.repeat(Math.max(0, contentWidth - visibleWidth(label) - 1)) + '┐');
168
+ const labelPlain = lang ? ` ${lang} ` : '';
169
+ const labelStyled = labelPlain ? PALETTE.fenceLabel(labelPlain) : '';
170
+ const ruleLen = Math.max(0, contentWidth - visibleWidth(labelPlain));
171
+ const top = dim('┌') + labelStyled + dim('─'.repeat(ruleLen) + '┐');
147
172
  const bottom = dim('└' + '─'.repeat(contentWidth) + '┘');
148
173
  const body = inner.map((l) => {
149
- const text = colorEnabled() ? green(l) : l;
150
- return dim('│ ') + text;
174
+ const text = colorFenceLine(l, isDiff);
175
+ const padTarget = Math.max(0, contentWidth - 1);
176
+ const padded = padVisible(text, padTarget);
177
+ return dim('│ ') + padded + dim('│');
151
178
  });
152
179
  return [top, ...body, bottom].join('\n');
153
180
  }
154
181
 
182
+ function padVisible(text, targetWidth) {
183
+ const w = visibleWidth(text);
184
+ if (w >= targetWidth) return text;
185
+ return String(text ?? '') + ' '.repeat(targetWidth - w);
186
+ }
187
+
188
+ function isDiffFence(lang, lines) {
189
+ const tag = String(lang ?? '').trim().toLowerCase();
190
+ if (/^(diff|patch|udiff)$/.test(tag)) return true;
191
+ let hunk = false;
192
+ let delta = false;
193
+ for (const line of lines) {
194
+ const s = String(line ?? '');
195
+ if (/^@@/.test(s)) hunk = true;
196
+ if (/^\+/.test(s) && !/^\+\+\+/.test(s)) delta = true;
197
+ if (/^-/.test(s) && !/^---/.test(s)) delta = true;
198
+ }
199
+ return hunk && delta;
200
+ }
201
+
202
+ function colorFenceLine(line, isDiff) {
203
+ const s = String(line ?? '');
204
+ if (!colorEnabled()) return s;
205
+ if (!isDiff) return PALETTE.codeBlock(s);
206
+ if (/^@@/.test(s)) return PALETTE.diffHunk(s);
207
+ if (/^(\+\+\+|---)/.test(s)) return PALETTE.diffHunk(s);
208
+ if (/^\+/.test(s)) return PALETTE.diffAdd(s);
209
+ if (/^-/.test(s)) return PALETTE.diffDel(s);
210
+ return PALETTE.codeBlock(s);
211
+ }
212
+
155
213
  // --- Inline ------------------------------------------------------------------
156
214
 
157
215
  /**
158
216
  * Render inline markdown spans. Order matters: we extract code spans first
159
217
  * (their contents are literal), then apply emphasis/link transforms to the rest.
160
218
  */
161
- function renderInline(text) {
219
+ function renderInline(text, state) {
220
+ const st = state ?? { codeSpans: [] };
162
221
  let s = String(text ?? '');
163
222
 
164
223
  // Protect inline code spans from further formatting.
165
- const codeSpans = [];
166
224
  s = s.replace(/`([^`]+)`/g, (_m, code) => {
167
- const token = `\u0000C${codeSpans.length}\u0000`;
168
- codeSpans.push(code);
225
+ const token = `\u0000C${st.codeSpans.length}\u0000`;
226
+ st.codeSpans.push(code);
169
227
  return token;
170
228
  });
171
229
 
172
230
  // Links: [text](url) -> text (url)
173
231
  s = s.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_m, label, url) => {
174
- return underline(label) + ' ' + dim('(' + url + ')');
232
+ const linkLabel = compose(underline, PALETTE.link)(renderInline(label, st));
233
+ return linkLabel + ' ' + dim('(' + url + ')');
175
234
  });
176
235
 
177
236
  // Bold: **x** or __x__
@@ -187,8 +246,8 @@ function renderInline(text) {
187
246
 
188
247
  // Restore code spans, styled.
189
248
  s = s.replace(/\u0000C(\d+)\u0000/g, (_m, idx) => {
190
- const code = codeSpans[Number(idx)] ?? '';
191
- return colorEnabled() ? cyan(code) : '`' + code + '`';
249
+ const code = st.codeSpans[Number(idx)] ?? '';
250
+ return colorEnabled() ? PALETTE.inlineCode(code) : '`' + code + '`';
192
251
  });
193
252
 
194
253
  return s;
@@ -0,0 +1,70 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { env } from 'node:process';
4
+ import { renderMarkdown } from './markdown.mjs';
5
+ import { refreshColorSupport, stripAnsi } from './ansi.mjs';
6
+
7
+ test.before(() => {
8
+ env.FORCE_COLOR = '1';
9
+ refreshColorSupport();
10
+ });
11
+
12
+ test('renderMarkdown never throws on garbage input', () => {
13
+ assert.doesNotThrow(() => renderMarkdown('**unclosed `fence\n> quote\n'));
14
+ assert.equal(renderMarkdown(null), '');
15
+ });
16
+
17
+ test('diff fenced blocks color hunks and +/- lines', () => {
18
+ const src = [
19
+ '```diff',
20
+ '--- a/file.mjs',
21
+ '+++ b/file.mjs',
22
+ '@@ -1,2 +1,2 @@',
23
+ '-old',
24
+ '+new',
25
+ '```',
26
+ ].join('\n');
27
+ const out = renderMarkdown(src);
28
+ assert.match(out, /\x1b\[38;2;204;157;44/);
29
+ assert.match(out, /\x1b\[38;2;0;170;75/);
30
+ assert.match(out, /\x1b\[38;2;220;70;88/);
31
+ assert.ok(stripAnsi(out).includes('diff'));
32
+ });
33
+
34
+ test('inline code and headings use Mixdog accent colors', () => {
35
+ const out = renderMarkdown('# Title\nUse `code` here.');
36
+ assert.match(out, /\x1b\[38;2;215;119;87/);
37
+ assert.match(out, /\x1b\[38;2;138;190;183/);
38
+ });
39
+
40
+ test('longer fence opener does not close on shorter backtick line inside block', () => {
41
+ const src = ['````', '```', 'still inside', '````'].join('\n');
42
+ const plain = stripAnsi(renderMarkdown(src));
43
+ assert.ok(plain.includes('still inside'));
44
+ assert.ok(!plain.includes('```\nstill'));
45
+ });
46
+
47
+ test('mixed backtick-tilde closer does not end backtick fence', () => {
48
+ const src = ['```', '``~', 'still inside', '```'].join('\n');
49
+ const plain = stripAnsi(renderMarkdown(src));
50
+ assert.ok(plain.includes('still inside'));
51
+ assert.ok(plain.includes('``~'));
52
+ });
53
+
54
+ test('fenced code block body rows include right border', () => {
55
+ const out = stripAnsi(renderMarkdown('```js\nx\n```'));
56
+ const rows = out.split('\n').filter((line) => line.startsWith('│ '));
57
+ assert.ok(rows.length >= 1);
58
+ for (const row of rows) {
59
+ assert.match(row, /│\s*$/);
60
+ }
61
+ });
62
+
63
+ test('inline code inside link labels is preserved', () => {
64
+ const out = renderMarkdown('[see `foo`](https://example.com)');
65
+ const plain = stripAnsi(out);
66
+ assert.ok(plain.includes('foo'));
67
+ assert.ok(plain.includes('https://example.com'));
68
+ assert.match(out, /\x1b\[38;2;138;190;183/);
69
+ });
70
+
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Pure model display-name helpers shared by the TUI snap path and statusline.
3
+ * No I/O, catalog, or gateway imports — safe for static TUI bundling.
4
+ */
5
+
6
+ export function titleModelPart(part) {
7
+ const text = String(part || '').trim();
8
+ if (!text) return '';
9
+ const lower = text.toLowerCase();
10
+ if (lower === 'gpt') return 'GPT';
11
+ if (lower === 'api') return 'API';
12
+ if (lower === 'v4') return 'V4';
13
+ return lower.charAt(0).toUpperCase() + lower.slice(1);
14
+ }
15
+
16
+ function stripModelId(model) {
17
+ const text = String(model || '').trim();
18
+ if (!text) return '';
19
+ return text.includes('/') ? (text.split('/').filter(Boolean).at(-1) || text) : text;
20
+ }
21
+
22
+ function normalizeDisplayHint(displayHint) {
23
+ if (displayHint == null || displayHint === '') return '';
24
+ if (typeof displayHint === 'object') {
25
+ return String(displayHint.displayName || displayHint.display || displayHint.name || '').trim();
26
+ }
27
+ return String(displayHint).trim();
28
+ }
29
+
30
+ export function canonicalModelDisplay(model, provider) {
31
+ void provider;
32
+ const raw = String(model || '')
33
+ .trim()
34
+ .replace(/-\d{4}-\d{2}-\d{2}$/, '')
35
+ .replace(/-\d{8}$/, '');
36
+ if (!raw) return '';
37
+
38
+ const gpt = raw.match(/^gpt-(\d+(?:\.\d+)?)(?:-(.+))?$/i);
39
+ if (gpt) {
40
+ const suffix = gpt[2]
41
+ ? '-' + gpt[2].split('-').map(titleModelPart).filter(Boolean).join('-')
42
+ : '';
43
+ return `GPT-${gpt[1]}${suffix}`;
44
+ }
45
+
46
+ if (/^gpt-/i.test(raw)) {
47
+ return raw
48
+ .split('-')
49
+ .map((part, index) => (index === 0 ? part.toUpperCase() : titleModelPart(part)))
50
+ .filter(Boolean)
51
+ .join('-');
52
+ }
53
+
54
+ const openaiO = raw.match(/^o(\d+(?:\.\d+)?)(?:-(.+))?$/i);
55
+ if (openaiO) {
56
+ const tail = openaiO[2]
57
+ ? ' ' + openaiO[2].split('-').map(titleModelPart).filter(Boolean).join(' ')
58
+ : '';
59
+ return `O${openaiO[1]}${tail}`;
60
+ }
61
+
62
+ const codex = raw.match(/^codex-(.+)$/i);
63
+ if (codex) {
64
+ return `Codex ${codex[1].split('-').map(titleModelPart).filter(Boolean).join(' ')}`;
65
+ }
66
+
67
+ const deepseek = raw.match(/^deepseek-(.+)$/i);
68
+ if (deepseek) {
69
+ return `DeepSeek ${deepseek[1].split('-').map(titleModelPart).filter(Boolean).join(' ')}`;
70
+ }
71
+
72
+ const grok = raw.match(/^grok-(.+)$/i);
73
+ if (grok) {
74
+ return `Grok ${grok[1].split('-').map(titleModelPart).filter(Boolean).join(' ')}`;
75
+ }
76
+
77
+ const claudeLegacy = raw.match(/^claude-(\d+)(?:-(\d+))?-(opus|sonnet|haiku)(?:-|$)/i);
78
+ if (claudeLegacy) {
79
+ const version = `${claudeLegacy[1]}${claudeLegacy[2] ? `.${claudeLegacy[2]}` : ''}`;
80
+ return `Claude ${titleModelPart(claudeLegacy[3])} ${version}`;
81
+ }
82
+
83
+ const claude = raw.match(/^claude-(opus|sonnet|haiku)-(.+)$/i);
84
+ if (claude) {
85
+ return `Claude ${titleModelPart(claude[1])} ${claude[2].replace(/-/g, '.')}`;
86
+ }
87
+
88
+ const gemini = raw.match(/^gemini-(\d+(?:\.\d+)?)-(.+)$/i);
89
+ if (gemini) {
90
+ return `Gemini ${gemini[1]} ${gemini[2].split('-').map(titleModelPart).filter(Boolean).join(' ')}`;
91
+ }
92
+
93
+ const geminiLoose = raw.match(/^gemini-(.+)$/i);
94
+ if (geminiLoose) {
95
+ return `Gemini ${geminiLoose[1].split('-').map(titleModelPart).filter(Boolean).join(' ')}`;
96
+ }
97
+
98
+ return raw;
99
+ }
100
+
101
+ export function displayModelName(model, provider = '', displayHint = '') {
102
+ const id = stripModelId(model);
103
+ const hint = normalizeDisplayHint(displayHint);
104
+
105
+ if (id) {
106
+ const canonical = canonicalModelDisplay(id, provider);
107
+ if (canonical && canonical !== id) return canonical;
108
+ }
109
+ if (hint) return hint;
110
+ if (id) return canonicalModelDisplay(id, provider) || id;
111
+ return '';
112
+ }
113
+
114
+ export function shortenModelName(name, cols) {
115
+ let out = String(name || 'model').replace(/\s*\(1M context\)/i, ' (1M)');
116
+ out = out.replace(/^Claude\s+/i, '');
117
+ out = out.replace(/^OpenAI\s+/i, '');
118
+ if (cols < 80 && out.length > 18) return out.slice(0, 17) + '…';
119
+ if (cols < 120 && out.length > 28) return out.slice(0, 27) + '…';
120
+ return out;
121
+ }
@@ -0,0 +1,44 @@
1
+ /** Create a mutable session-usage accumulator. */
2
+ export function createSessionStats() {
3
+ return {
4
+ inputTokens: 0,
5
+ outputTokens: 0,
6
+ cachedTokens: 0,
7
+ cacheWriteTokens: 0,
8
+ promptTokens: 0,
9
+ latestInputTokens: 0,
10
+ latestOutputTokens: 0,
11
+ latestCachedTokens: 0,
12
+ latestCacheWriteTokens: 0,
13
+ latestPromptTokens: 0,
14
+ costUsd: 0,
15
+ turns: 0,
16
+ };
17
+ }
18
+
19
+ /**
20
+ * Fold one `onUsageDelta` event into the accumulator.
21
+ * @param {object} stats — from createSessionStats()
22
+ * @param {object} delta — { deltaInput, deltaOutput, deltaCachedRead, deltaCacheWrite, costUsd }
23
+ */
24
+ export function applyUsageDelta(stats, delta = {}) {
25
+ if (!stats || !delta) return stats;
26
+ stats.inputTokens += num(delta.deltaInput);
27
+ stats.outputTokens += num(delta.deltaOutput);
28
+ stats.cachedTokens += num(delta.deltaCachedRead);
29
+ stats.cacheWriteTokens += num(delta.deltaCacheWrite);
30
+ stats.promptTokens += num(delta.deltaPrompt);
31
+ stats.latestInputTokens = num(delta.deltaInput);
32
+ stats.latestOutputTokens = num(delta.deltaOutput);
33
+ stats.latestCachedTokens = num(delta.deltaCachedRead);
34
+ stats.latestCacheWriteTokens = num(delta.deltaCacheWrite);
35
+ stats.latestPromptTokens = num(delta.deltaPrompt);
36
+ // costUsd from the engine is cumulative-per-call; we sum the per-turn deltas.
37
+ stats.costUsd += num(delta.costUsd);
38
+ return stats;
39
+ }
40
+
41
+ function num(v) {
42
+ const n = Number(v);
43
+ return Number.isFinite(n) ? n : 0;
44
+ }
@@ -0,0 +1,15 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { contextPctDisplayLabel } from './statusline.mjs';
4
+
5
+ test('contextPctDisplayLabel clamps displayed percent at 100', () => {
6
+ assert.equal(contextPctDisplayLabel(105), '100');
7
+ assert.equal(contextPctDisplayLabel(100.9), '100');
8
+ assert.equal(contextPctDisplayLabel(99.2), '99');
9
+ });
10
+
11
+ test('contextPctDisplayLabel keeps sub-1% decimal display', () => {
12
+ assert.equal(contextPctDisplayLabel(0.45), '0.5');
13
+ assert.equal(contextPctDisplayLabel(0.04), '0');
14
+ });
15
+