@bahulam/code 0.1.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 (278) hide show
  1. package/README.md +93 -0
  2. package/package.json +56 -0
  3. package/pulse/app/activity/page.tsx +190 -0
  4. package/pulse/app/api/activity/route.ts +138 -0
  5. package/pulse/app/api/benchmark/route.ts +113 -0
  6. package/pulse/app/api/benchmarks/route.ts +195 -0
  7. package/pulse/app/api/costs/route.ts +88 -0
  8. package/pulse/app/api/export/route.ts +77 -0
  9. package/pulse/app/api/history/route.ts +11 -0
  10. package/pulse/app/api/import/route.ts +31 -0
  11. package/pulse/app/api/memory/route.ts +50 -0
  12. package/pulse/app/api/plans/route.ts +9 -0
  13. package/pulse/app/api/projects/[slug]/route.ts +96 -0
  14. package/pulse/app/api/projects/route.ts +121 -0
  15. package/pulse/app/api/sessions/[id]/replay/route.ts +20 -0
  16. package/pulse/app/api/sessions/[id]/route.ts +31 -0
  17. package/pulse/app/api/sessions/route.ts +112 -0
  18. package/pulse/app/api/settings/route.ts +14 -0
  19. package/pulse/app/api/stats/route.ts +143 -0
  20. package/pulse/app/api/todos/route.ts +9 -0
  21. package/pulse/app/api/tools/route.ts +160 -0
  22. package/pulse/app/benchmarks/page.tsx +224 -0
  23. package/pulse/app/costs/page.tsx +179 -0
  24. package/pulse/app/export/page.tsx +465 -0
  25. package/pulse/app/favicon.ico +0 -0
  26. package/pulse/app/globals.css +263 -0
  27. package/pulse/app/help/page.tsx +143 -0
  28. package/pulse/app/history/page.tsx +157 -0
  29. package/pulse/app/layout.tsx +46 -0
  30. package/pulse/app/memory/page.tsx +365 -0
  31. package/pulse/app/overview-client.tsx +393 -0
  32. package/pulse/app/page.tsx +14 -0
  33. package/pulse/app/plans/page.tsx +308 -0
  34. package/pulse/app/projects/[slug]/page.tsx +390 -0
  35. package/pulse/app/projects/page.tsx +110 -0
  36. package/pulse/app/sessions/[id]/page.tsx +243 -0
  37. package/pulse/app/sessions/page.tsx +39 -0
  38. package/pulse/app/settings/page.tsx +188 -0
  39. package/pulse/app/todos/page.tsx +211 -0
  40. package/pulse/app/tools/page.tsx +249 -0
  41. package/pulse/cli.js +164 -0
  42. package/pulse/components/activity/day-of-week-chart.tsx +35 -0
  43. package/pulse/components/activity/streak-card.tsx +36 -0
  44. package/pulse/components/costs/cache-efficiency-panel.tsx +76 -0
  45. package/pulse/components/costs/cost-by-project-chart.tsx +48 -0
  46. package/pulse/components/costs/cost-over-time-chart.tsx +95 -0
  47. package/pulse/components/costs/model-token-table.tsx +60 -0
  48. package/pulse/components/global-search.tsx +193 -0
  49. package/pulse/components/keyboard-nav-provider.tsx +23 -0
  50. package/pulse/components/layout/bottom-nav.tsx +53 -0
  51. package/pulse/components/layout/client-layout.tsx +31 -0
  52. package/pulse/components/layout/sidebar-context.tsx +50 -0
  53. package/pulse/components/layout/sidebar.tsx +183 -0
  54. package/pulse/components/layout/top-bar.tsx +121 -0
  55. package/pulse/components/overview/activity-heatmap.tsx +107 -0
  56. package/pulse/components/overview/conversation-table.tsx +148 -0
  57. package/pulse/components/overview/model-breakdown-donut.tsx +95 -0
  58. package/pulse/components/overview/peak-hours-chart.tsx +87 -0
  59. package/pulse/components/overview/project-activity-donut.tsx +96 -0
  60. package/pulse/components/overview/stat-card.tsx +102 -0
  61. package/pulse/components/overview/usage-over-time-chart.tsx +166 -0
  62. package/pulse/components/projects/project-card.tsx +175 -0
  63. package/pulse/components/sessions/replay/assistant-markdown.tsx +94 -0
  64. package/pulse/components/sessions/replay/compaction-card.tsx +25 -0
  65. package/pulse/components/sessions/replay/session-sidebar.tsx +231 -0
  66. package/pulse/components/sessions/replay/token-accumulation-chart.tsx +98 -0
  67. package/pulse/components/sessions/replay/tool-call-badge.tsx +127 -0
  68. package/pulse/components/sessions/replay/turn-cards.tsx +220 -0
  69. package/pulse/components/sessions/replay/user-tool-result.tsx +158 -0
  70. package/pulse/components/sessions/session-badges.tsx +49 -0
  71. package/pulse/components/sessions/session-table.tsx +299 -0
  72. package/pulse/components/theme-provider.tsx +44 -0
  73. package/pulse/components/tools/feature-adoption-table.tsx +58 -0
  74. package/pulse/components/tools/mcp-server-panel.tsx +45 -0
  75. package/pulse/components/tools/tool-ranking-chart.tsx +57 -0
  76. package/pulse/components/tools/version-history-table.tsx +32 -0
  77. package/pulse/components/ui/alert.tsx +66 -0
  78. package/pulse/components/ui/badge.tsx +48 -0
  79. package/pulse/components/ui/breadcrumb.tsx +109 -0
  80. package/pulse/components/ui/button.tsx +64 -0
  81. package/pulse/components/ui/calendar.tsx +220 -0
  82. package/pulse/components/ui/card.tsx +92 -0
  83. package/pulse/components/ui/command.tsx +158 -0
  84. package/pulse/components/ui/dialog.tsx +158 -0
  85. package/pulse/components/ui/input.tsx +21 -0
  86. package/pulse/components/ui/popover.tsx +89 -0
  87. package/pulse/components/ui/progress.tsx +31 -0
  88. package/pulse/components/ui/select.tsx +190 -0
  89. package/pulse/components/ui/separator.tsx +28 -0
  90. package/pulse/components/ui/sheet.tsx +143 -0
  91. package/pulse/components/ui/skeleton.tsx +13 -0
  92. package/pulse/components/ui/table.tsx +116 -0
  93. package/pulse/components/ui/tabs.tsx +91 -0
  94. package/pulse/components/ui/tooltip.tsx +57 -0
  95. package/pulse/components/use-global-keyboard-nav.ts +79 -0
  96. package/pulse/components.json +23 -0
  97. package/pulse/eslint.config.mjs +18 -0
  98. package/pulse/lib/bahulam-paths.ts +23 -0
  99. package/pulse/lib/claude-reader.ts +592 -0
  100. package/pulse/lib/decode.ts +129 -0
  101. package/pulse/lib/pricing.ts +102 -0
  102. package/pulse/lib/replay-parser.ts +165 -0
  103. package/pulse/lib/tool-categories.ts +127 -0
  104. package/pulse/lib/utils.ts +6 -0
  105. package/pulse/next-env.d.ts +6 -0
  106. package/pulse/next.config.ts +16 -0
  107. package/pulse/package.json +45 -0
  108. package/pulse/postcss.config.mjs +7 -0
  109. package/pulse/public/activity.png +0 -0
  110. package/pulse/public/cc-lens.png +0 -0
  111. package/pulse/public/command-k.png +0 -0
  112. package/pulse/public/costs.png +0 -0
  113. package/pulse/public/dashboard-dark.png +0 -0
  114. package/pulse/public/dashboard-white.png +0 -0
  115. package/pulse/public/export.png +0 -0
  116. package/pulse/public/file.svg +1 -0
  117. package/pulse/public/globe.svg +1 -0
  118. package/pulse/public/next.svg +1 -0
  119. package/pulse/public/projects.png +0 -0
  120. package/pulse/public/session-chat.png +0 -0
  121. package/pulse/public/todos.png +0 -0
  122. package/pulse/public/tools.png +0 -0
  123. package/pulse/public/vercel.svg +1 -0
  124. package/pulse/public/window.svg +1 -0
  125. package/pulse/tsconfig.json +34 -0
  126. package/pulse/types/claude.ts +294 -0
  127. package/src/agents/loader.mjs +94 -0
  128. package/src/agents/multi_workflow_loader.mjs +330 -0
  129. package/src/agents/parser.mjs +205 -0
  130. package/src/agents/scaffold.mjs +223 -0
  131. package/src/agents/teams.mjs +123 -0
  132. package/src/agents/workflow_loader.mjs +122 -0
  133. package/src/agents/workflow_scaffold.mjs +249 -0
  134. package/src/auth/oauth.mjs +220 -0
  135. package/src/auth/tarang-auth.mjs +312 -0
  136. package/src/commands/agent.mjs +221 -0
  137. package/src/commands/workflow.mjs +581 -0
  138. package/src/config/cli-args.mjs +202 -0
  139. package/src/config/env.mjs +263 -0
  140. package/src/config/hook-runner.mjs +100 -0
  141. package/src/config/memory-loader.mjs +32 -0
  142. package/src/config/model-catalog.mjs +57 -0
  143. package/src/config/settings-loader.mjs +45 -0
  144. package/src/config/settings.mjs +132 -0
  145. package/src/context/ast-parser.mjs +298 -0
  146. package/src/context/bm25.mjs +85 -0
  147. package/src/context/prose-chunker.mjs +255 -0
  148. package/src/context/retriever.mjs +425 -0
  149. package/src/context/skeleton.mjs +134 -0
  150. package/src/context/symbol-indexer.mjs +375 -0
  151. package/src/core/agent-history.mjs +111 -0
  152. package/src/core/agent-loop.mjs +486 -0
  153. package/src/core/approval-log.mjs +145 -0
  154. package/src/core/approval.mjs +700 -0
  155. package/src/core/attachments.mjs +666 -0
  156. package/src/core/backend-url.mjs +68 -0
  157. package/src/core/bundled-runtime.mjs +418 -0
  158. package/src/core/cache-control.mjs +92 -0
  159. package/src/core/cache.mjs +105 -0
  160. package/src/core/callback-client.mjs +180 -0
  161. package/src/core/checkpoints.mjs +142 -0
  162. package/src/core/compact-history.mjs +127 -0
  163. package/src/core/context-envelope.mjs +54 -0
  164. package/src/core/context-manager.mjs +198 -0
  165. package/src/core/error-guidance.mjs +331 -0
  166. package/src/core/file-diff.mjs +217 -0
  167. package/src/core/headless.mjs +460 -0
  168. package/src/core/hooks-manager.mjs +87 -0
  169. package/src/core/jsonl-writer.mjs +449 -0
  170. package/src/core/local-agent.mjs +538 -0
  171. package/src/core/local-store.mjs +836 -0
  172. package/src/core/mode-selector.mjs +51 -0
  173. package/src/core/output-filter.mjs +177 -0
  174. package/src/core/paths.mjs +190 -0
  175. package/src/core/policy-resolver.mjs +156 -0
  176. package/src/core/pricing.mjs +336 -0
  177. package/src/core/project-artifacts.mjs +39 -0
  178. package/src/core/project-context-loader.mjs +139 -0
  179. package/src/core/providers.mjs +219 -0
  180. package/src/core/rate-limit-display.mjs +121 -0
  181. package/src/core/rate-limiter.mjs +119 -0
  182. package/src/core/resume-mode.mjs +192 -0
  183. package/src/core/risk-tier.mjs +388 -0
  184. package/src/core/safety.mjs +260 -0
  185. package/src/core/scheduler.mjs +173 -0
  186. package/src/core/session-manager.mjs +360 -0
  187. package/src/core/session.mjs +143 -0
  188. package/src/core/settings-sync.mjs +85 -0
  189. package/src/core/stagnation.mjs +57 -0
  190. package/src/core/stream-client.mjs +957 -0
  191. package/src/core/streaming.mjs +182 -0
  192. package/src/core/system-prompt.mjs +140 -0
  193. package/src/core/tasks.mjs +196 -0
  194. package/src/core/tool-executor.mjs +2231 -0
  195. package/src/core/trust.mjs +160 -0
  196. package/src/core/work-scope.mjs +248 -0
  197. package/src/hooks/engine.mjs +162 -0
  198. package/src/mcp/client.mjs +253 -0
  199. package/src/mcp/transport-shttp.mjs +130 -0
  200. package/src/mcp/transport-sse.mjs +131 -0
  201. package/src/mcp/transport-ws.mjs +134 -0
  202. package/src/onboarding/preflight.mjs +374 -0
  203. package/src/permissions/checker.mjs +57 -0
  204. package/src/permissions/command-classifier.mjs +700 -0
  205. package/src/permissions/injection-check.mjs +60 -0
  206. package/src/permissions/path-check.mjs +102 -0
  207. package/src/permissions/prompt.mjs +73 -0
  208. package/src/permissions/sandbox.mjs +112 -0
  209. package/src/plugins/loader.mjs +138 -0
  210. package/src/skills/installer.mjs +188 -0
  211. package/src/skills/loader.mjs +252 -0
  212. package/src/skills/runner.mjs +55 -0
  213. package/src/state/orbit.mjs +263 -0
  214. package/src/state/verbosity.mjs +99 -0
  215. package/src/telemetry/index.mjs +122 -0
  216. package/src/terminal/agents.mjs +353 -0
  217. package/src/terminal/analytics.mjs +292 -0
  218. package/src/terminal/ansi.mjs +695 -0
  219. package/src/terminal/init.mjs +145 -0
  220. package/src/terminal/main.mjs +310 -0
  221. package/src/terminal/repl-ask-form.mjs +120 -0
  222. package/src/terminal/repl-explore.mjs +44 -0
  223. package/src/terminal/repl-format.mjs +317 -0
  224. package/src/terminal/repl-model-form.mjs +132 -0
  225. package/src/terminal/repl-render.mjs +833 -0
  226. package/src/terminal/repl-resume.mjs +640 -0
  227. package/src/terminal/repl-state.mjs +120 -0
  228. package/src/terminal/repl-utils.mjs +34 -0
  229. package/src/terminal/repl.mjs +5032 -0
  230. package/src/terminal/skills.mjs +54 -0
  231. package/src/terminal/tool-display.mjs +392 -0
  232. package/src/tools/agent.mjs +137 -0
  233. package/src/tools/ask-user.mjs +61 -0
  234. package/src/tools/bash.mjs +231 -0
  235. package/src/tools/cron-create.mjs +120 -0
  236. package/src/tools/cron-delete.mjs +49 -0
  237. package/src/tools/cron-list.mjs +37 -0
  238. package/src/tools/edit.mjs +82 -0
  239. package/src/tools/enter-worktree.mjs +69 -0
  240. package/src/tools/exit-worktree.mjs +57 -0
  241. package/src/tools/glob.mjs +117 -0
  242. package/src/tools/grep.mjs +129 -0
  243. package/src/tools/lint.mjs +71 -0
  244. package/src/tools/ls.mjs +58 -0
  245. package/src/tools/lsp.mjs +115 -0
  246. package/src/tools/multi-edit.mjs +94 -0
  247. package/src/tools/notebook-edit.mjs +96 -0
  248. package/src/tools/project-overview.mjs +703 -0
  249. package/src/tools/read-mcp-resource.mjs +57 -0
  250. package/src/tools/read.mjs +138 -0
  251. package/src/tools/registry.mjs +116 -0
  252. package/src/tools/remote-trigger.mjs +84 -0
  253. package/src/tools/send-message.mjs +64 -0
  254. package/src/tools/skill.mjs +52 -0
  255. package/src/tools/test-runner.mjs +49 -0
  256. package/src/tools/todo-write.mjs +68 -0
  257. package/src/tools/tool-search.mjs +77 -0
  258. package/src/tools/web-fetch.mjs +65 -0
  259. package/src/tools/web-search.mjs +89 -0
  260. package/src/tools/write.mjs +55 -0
  261. package/src/ui/approval.mjs +510 -0
  262. package/src/ui/banner.mjs +232 -0
  263. package/src/ui/commands.mjs +537 -0
  264. package/src/ui/formatter.mjs +409 -0
  265. package/src/ui/icons.mjs +170 -0
  266. package/src/ui/input-dock.mjs +772 -0
  267. package/src/ui/markdown.mjs +278 -0
  268. package/src/ui/mission-report.mjs +296 -0
  269. package/src/ui/palette.mjs +189 -0
  270. package/src/ui/render-queue.mjs +500 -0
  271. package/src/ui/slash-commands.mjs +257 -0
  272. package/src/ui/spinner.mjs +116 -0
  273. package/src/ui/sub-agent.mjs +167 -0
  274. package/src/ui/term.mjs +174 -0
  275. package/src/ui/text-layout.mjs +127 -0
  276. package/src/ui/tool-card.mjs +740 -0
  277. package/src/ui/tool-details.mjs +504 -0
  278. package/src/ui/transcript-block.mjs +20 -0
@@ -0,0 +1,740 @@
1
+ /**
2
+ * Tool cards — Mission Control (PRD-055 §6).
3
+ *
4
+ * One-line summary per tool: icon + label + args + outcome.
5
+ *
6
+ * 🔭 search_code "JWT validation" → 4 matches in 2 files
7
+ * 🔭 read_file auth.py L42-L88 → 47 lines
8
+ * 🛠️ edit_file auth.py → +12 −4
9
+ * ⚙️ shell "npm test" → passed in 1.2s
10
+ *
11
+ * Two render points:
12
+ *
13
+ * formatCardHead(tool, args) — at tool invocation (no outcome)
14
+ * formatCard({ tool, args, result, … }) — once the result arrives
15
+ *
16
+ * Cards are recorded in a small ring buffer (`recordCard`, `lastCard`,
17
+ * `getCard`) so the expand handler in repl.mjs can re-render details on `d`
18
+ * / `/last` / `/expand <n>` without holding state in the REPL itself.
19
+ *
20
+ * No I/O — callers (repl, demo, headless adapter) are responsible for
21
+ * `process.stderr.write(...)`. This keeps the module pure and testable.
22
+ */
23
+
24
+ import { paint, width as visibleWidth } from './palette.mjs';
25
+ import { toolFamily } from './icons.mjs';
26
+ import { term } from './term.mjs';
27
+ import {
28
+ toolDisplayLabel,
29
+ toolDisplaySummary,
30
+ formatShellCommand,
31
+ shellCommandDisplay,
32
+ shellCommandProfile,
33
+ } from '../terminal/tool-display.mjs';
34
+
35
+ // ── Family → label colorizer ─────────────────────────────────────────────
36
+
37
+ function paintLabel(tool, label) {
38
+ switch (toolFamily(tool)) {
39
+ case 'subAgent': return paint.brand.data(label);
40
+ case 'search': return paint.text.primary(label);
41
+ case 'write': return paint.brand.primary(label);
42
+ case 'shell': return paint.state.warn(label);
43
+ case 'network': return paint.brand.accent(label);
44
+ default: return paint.text.primary(label);
45
+ }
46
+ }
47
+
48
+ // ── Args summary ─────────────────────────────────────────────────────────
49
+
50
+ function formatArgs(tool, args, cwd) {
51
+ const summary = toolDisplaySummary(tool, args || {}, { cwd });
52
+ if (!summary) return '';
53
+ if (tool === 'shell') {
54
+ const profile = shellCommandProfile(summary, { cwd });
55
+ if (profile.compact) return compactShellProfile(profile);
56
+ const display = shellCommandDisplay(summary, { cwd });
57
+ const command = `${paint.text.dim('$')} ${formatShellCommand(display.command, paintShellAdapter)}`;
58
+ return display.cwdLabel
59
+ ? `${command} ${paint.text.dim('in')} ${paint.brand.data(display.cwdLabel)}`
60
+ : command;
61
+ }
62
+ return paint.text.muted(summary);
63
+ }
64
+
65
+ // Adapter so formatShellCommand (from legacy tool-display.mjs) keeps working
66
+ // against the new palette. It expects an object with .red/.blue/.yellow/.white.
67
+ const paintShellAdapter = {
68
+ red: (s) => paint.state.danger(s),
69
+ blue: (s) => paint.brand.data(s),
70
+ yellow: (s) => paint.state.warn(s),
71
+ white: (s) => paint.text.primary(s),
72
+ };
73
+
74
+ // ── Result → outcome summary ─────────────────────────────────────────────
75
+
76
+ /**
77
+ * Summarize a tool result into a compact outcome label.
78
+ *
79
+ * @returns {{ text: string, tone: 'success'|'warn'|'danger'|'dim' }}
80
+ */
81
+ export function summarizeResult(tool, data) {
82
+ if (!data) return { text: '', tone: 'dim' };
83
+
84
+ if (data._blocked) {
85
+ return { text: firstOutputLine(data) || 'blocked', tone: 'danger' };
86
+ }
87
+ if (data._observation_timeout) {
88
+ const ms = data._observation_timeout_ms;
89
+ const duration = typeof ms === 'number' ? formatDuration(ms) : '';
90
+ return { text: duration ? `observed ${duration} tail` : 'observed output tail', tone: 'warn' };
91
+ }
92
+ if (data._timed_out) {
93
+ return { text: 'timed out', tone: 'danger' };
94
+ }
95
+ if (data.success === false) {
96
+ // For shell / test / build / lint / validate failures, the actual output
97
+ // usually holds the actionable line ("FAIL src/foo.test.js expected 3
98
+ // got 2") while data.error is a generic wrapper ("Test suite failed",
99
+ // "Command exited with code 1"). Prefer the first output line when it
100
+ // has meaningful content; fall back to error for other tools.
101
+ const shellFamily = new Set([
102
+ 'shell', 'run_tests', 'validate_build', 'lint_check',
103
+ 'validate_file', 'validate_structure',
104
+ ]);
105
+ const outputLine = firstOutputLine(data);
106
+ const msg = shellFamily.has(tool) && outputLine
107
+ ? String(outputLine).slice(0, 140)
108
+ : String(data.error || outputLine || 'failed').slice(0, 140);
109
+ return { text: msg, tone: 'danger' };
110
+ }
111
+
112
+ switch (tool) {
113
+ case 'read_file': {
114
+ const lines = data._total_lines || lineCount(data.output || data.output_preview);
115
+ return { text: `${lines} line${lines === 1 ? '' : 's'}`, tone: 'success' };
116
+ }
117
+ case 'read_files':
118
+ return { text: 'files read', tone: 'success' };
119
+
120
+ case 'search_code':
121
+ case 'search_files':
122
+ case 'grep': {
123
+ const matches = countMatches(data);
124
+ const files = countMatchFiles(data);
125
+ if (matches === 0) return { text: 'no matches', tone: 'warn' };
126
+ const filesPart = files > 0 ? ` in ${files} file${files === 1 ? '' : 's'}` : '';
127
+ return { text: `${matches} match${matches === 1 ? '' : 'es'}${filesPart}`, tone: 'success' };
128
+ }
129
+
130
+ case 'list_files': {
131
+ const n = lineCount(data.output);
132
+ return { text: n > 0 ? `${n} item${n === 1 ? '' : 's'}` : 'empty', tone: 'success' };
133
+ }
134
+
135
+ case 'edit_file':
136
+ case 'write_file':
137
+ case 'write_project': {
138
+ const delta = diffDelta(data);
139
+ if (delta) return { text: delta, tone: 'success' };
140
+ return { text: 'updated', tone: 'success' };
141
+ }
142
+
143
+ case 'delete_file':
144
+ return { text: 'deleted', tone: 'warn' };
145
+
146
+ case 'shell':
147
+ case 'run_tests':
148
+ case 'validate_build':
149
+ case 'lint_check':
150
+ case 'validate_file':
151
+ case 'validate_structure': {
152
+ const exit = data.exit_code ?? data.exitCode;
153
+ if (exit != null && exit !== 0) {
154
+ return { text: `exit ${exit}`, tone: 'danger' };
155
+ }
156
+ if (tool === 'shell') {
157
+ const structured = structuredOutputSummary(data.output_preview || data.output);
158
+ if (structured) return structured;
159
+ // Multi-row preview: first N rows + a "+ M more" tail so long
160
+ // outputs (e.g. `ls`) surface their scale instead of collapsing
161
+ // to a single first line with no hint that more exists.
162
+ const { preview, remaining } = outputPreviewRows(data, shellPreviewRows());
163
+ if (!preview) return { text: 'ok', tone: 'success' };
164
+ if (remaining === 0) return { text: preview, tone: 'success' };
165
+ const tail = paint.text.dim(`+ ${remaining} more row${remaining === 1 ? '' : 's'}`);
166
+ return { text: `${preview}\n${tail}`, tone: 'success' };
167
+ }
168
+ const head = firstOutputLine(data).slice(0, 100);
169
+ return { text: head || 'ok', tone: 'success' };
170
+ }
171
+
172
+ case 'analyze_code': {
173
+ // Backend returns "filename (N lines, ext)" — the filename already
174
+ // appears in the card head, so strip it and keep just the metadata.
175
+ const head = firstOutputLine(data);
176
+ const m = head.match(/\((\d+)\s+lines?,?\s+([^)]+)\)/);
177
+ if (m) return { text: `${m[1]} lines · ${m[2].trim()}`, tone: 'success' };
178
+ return { text: head.slice(0, 80) || 'done', tone: 'success' };
179
+ }
180
+
181
+ case 'plan':
182
+ case 'explore':
183
+ case 'verify':
184
+ case 'debug':
185
+ case 'refactor': {
186
+ const head = firstOutputLine(data).slice(0, 100);
187
+ return { text: head || 'done', tone: 'success' };
188
+ }
189
+
190
+ default: {
191
+ const head = firstOutputLine(data).slice(0, 100);
192
+ return { text: head || 'done', tone: 'success' };
193
+ }
194
+ }
195
+ }
196
+
197
+ function structuredOutputSummary(output) {
198
+ const raw = String(output || '').trim();
199
+ if (!raw || !/^[\[{]/.test(raw)) return null;
200
+ let value;
201
+ try {
202
+ value = JSON.parse(raw);
203
+ } catch {
204
+ const first = raw.split('\n').find(line => /^[\[{]/.test(line.trim()));
205
+ if (!first) return null;
206
+ try { value = JSON.parse(first.trim()); } catch { return null; }
207
+ }
208
+ return summarizeJsonOutput(value);
209
+ }
210
+
211
+ function summarizeJsonOutput(value) {
212
+ if (Array.isArray(value)) {
213
+ return { text: `json array · ${value.length} item${value.length === 1 ? '' : 's'}`, tone: 'success' };
214
+ }
215
+ if (!value || typeof value !== 'object') return null;
216
+
217
+ if ('service' in value && 'profile' in value && 'inSync' in value) {
218
+ const service = String(value.service || 'service');
219
+ const profile = value.profile ? ` · ${value.profile}` : '';
220
+ const status = value.inSync === true ? 'in sync'
221
+ : value.inSync === false ? 'out of sync'
222
+ : 'sync status unknown';
223
+ const diffs = Array.isArray(value.diff) ? value.diff
224
+ : Array.isArray(value.diffs) ? value.diffs
225
+ : [];
226
+ const diffText = diffs.length ? ` · ${diffs.length} diff${diffs.length === 1 ? '' : 's'}` : '';
227
+ return {
228
+ text: `${service} ${status}${profile}${diffText}`,
229
+ tone: value.inSync === false ? 'warn' : 'success',
230
+ };
231
+ }
232
+
233
+ const keys = Object.keys(value).slice(0, 4);
234
+ return {
235
+ text: keys.length ? `json · ${keys.join(', ')}` : 'json object',
236
+ tone: 'success',
237
+ };
238
+ }
239
+
240
+ export function formatCompactFileDiff(result, {
241
+ indent = ' ',
242
+ maxLines = Infinity,
243
+ maxFiles = Infinity,
244
+ columns = term().columns || 120,
245
+ showFileHeader = false,
246
+ } = {}) {
247
+ const diffs = fileDiffs(result)
248
+ .map(normalizeFileDiff)
249
+ .filter(diff => diff && (diff.redacted || diff?.hunks?.length));
250
+ if (!diffs.length) return '';
251
+
252
+ const out = [];
253
+ let shown = 0;
254
+ let truncated = false;
255
+ const lineLimit = Number.isFinite(maxLines) ? Math.max(0, Math.floor(maxLines)) : Infinity;
256
+ const fileLimit = Number.isFinite(maxFiles) ? Math.max(0, Math.floor(maxFiles)) : diffs.length;
257
+ const lineBudget = Math.max(40, columns - visibleWidth(indent) - 4);
258
+
259
+ for (const diff of diffs.slice(0, fileLimit)) {
260
+ if (showFileHeader || diffs.length > 1) {
261
+ if (shown >= lineLimit) { truncated = true; break; }
262
+ out.push(`${indent}${paint.brand.primary(diff.relative_path || diff.path || 'file')} ${paint.text.dim(diffDelta(diff))}`);
263
+ shown++;
264
+ }
265
+
266
+ if (diff.redacted) {
267
+ if (shown >= lineLimit) { truncated = true; break; }
268
+ const subject = (showFileHeader || diffs.length > 1)
269
+ ? ''
270
+ : `${[diff.relative_path || diff.path || 'file', diffDelta(diff)].filter(Boolean).join(' ')} · `;
271
+ out.push(`${indent}${paint.text.dim(`${subject}diff redacted for sensitive config`)}`);
272
+ shown++;
273
+ continue;
274
+ }
275
+
276
+ for (const hunk of diff.hunks || []) {
277
+ if (shown >= lineLimit) { truncated = true; break; }
278
+ out.push(`${indent}${paint.text.dim(`@@ -${hunk.old_start},${hunk.old_count} +${hunk.new_start},${hunk.new_count} @@`)}`);
279
+ shown++;
280
+
281
+ for (const line of hunk.lines || []) {
282
+ if (shown >= lineLimit) { truncated = true; break; }
283
+ out.push(`${indent}${paintDiffLine(line, lineBudget)}`);
284
+ shown++;
285
+ }
286
+ if (truncated) break;
287
+ }
288
+ if (truncated) break;
289
+ }
290
+
291
+ if (diffs.length > fileLimit) truncated = true;
292
+ if (truncated) out.push(`${indent}${paint.text.dim('… diff preview truncated; use /last to expand')}`);
293
+ return out.join('\n');
294
+ }
295
+
296
+ function fileDiffs(result) {
297
+ if (!result) return [];
298
+ if (Array.isArray(result.file_diffs)) return result.file_diffs;
299
+ if (result.file_diff) return [result.file_diff];
300
+ if (result.type === 'file_diff' || result.hunks || result.unified) return [result];
301
+ return [];
302
+ }
303
+
304
+ function normalizeFileDiff(diff) {
305
+ if (!diff) return null;
306
+ return {
307
+ ...diff,
308
+ hunks: normalizeHunks(diff),
309
+ };
310
+ }
311
+
312
+ function normalizeHunks(diff) {
313
+ if (Array.isArray(diff?.hunks) && diff.hunks.length) {
314
+ return diff.hunks.map(normalizeHunk).filter(hunk => hunk.lines.length);
315
+ }
316
+ if (diff?.unified) return parseUnifiedHunks(diff.unified);
317
+ return [];
318
+ }
319
+
320
+ function normalizeHunk(hunk = {}) {
321
+ const lines = Array.isArray(hunk.lines)
322
+ ? hunk.lines.map(normalizeDiffLine).filter(Boolean)
323
+ : typeof hunk.body === 'string'
324
+ ? parseDiffBody(hunk.body)
325
+ : [];
326
+ const oldCount = hunk.old_count ?? hunk.old_lines ?? hunk.oldCount ?? countDiffLines(lines, 'old');
327
+ const newCount = hunk.new_count ?? hunk.new_lines ?? hunk.newCount ?? countDiffLines(lines, 'new');
328
+ return {
329
+ ...hunk,
330
+ old_start: hunk.old_start ?? hunk.oldStart ?? 1,
331
+ old_count: oldCount,
332
+ new_start: hunk.new_start ?? hunk.newStart ?? 1,
333
+ new_count: newCount,
334
+ lines,
335
+ };
336
+ }
337
+
338
+ function normalizeDiffLine(line) {
339
+ if (typeof line === 'string') return parseUnifiedLine(line);
340
+ if (!line || typeof line !== 'object') return null;
341
+ const rawType = String(line.type || line.kind || '').toLowerCase();
342
+ const text = String(line.text ?? line.content ?? line.value ?? '');
343
+ if (rawType === 'add' || rawType === 'added' || rawType === '+') return { ...line, type: 'add', text };
344
+ if (rawType === 'remove' || rawType === 'removed' || rawType === 'delete' || rawType === '-') return { ...line, type: 'remove', text };
345
+ if (rawType === 'context' || rawType === 'same' || rawType === ' ') return { ...line, type: 'context', text };
346
+ return parseUnifiedLine(text);
347
+ }
348
+
349
+ function parseDiffBody(body) {
350
+ return String(body || '')
351
+ .replace(/\r\n?/g, '\n')
352
+ .split('\n')
353
+ .filter(line => line && !line.startsWith('@@'))
354
+ .map(parseUnifiedLine)
355
+ .filter(Boolean);
356
+ }
357
+
358
+ function parseUnifiedHunks(unified) {
359
+ const hunks = [];
360
+ let current = null;
361
+ for (const raw of String(unified || '').replace(/\r\n?/g, '\n').split('\n')) {
362
+ if (raw.startsWith('--- ') || raw.startsWith('+++ ')) continue;
363
+ const header = raw.match(/^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/);
364
+ if (header) {
365
+ current = {
366
+ old_start: Number(header[1]) || 1,
367
+ old_count: Number(header[2] || 1),
368
+ new_start: Number(header[3]) || 1,
369
+ new_count: Number(header[4] || 1),
370
+ lines: [],
371
+ };
372
+ hunks.push(current);
373
+ continue;
374
+ }
375
+ if (!current) {
376
+ if (!raw || (!raw.startsWith('+') && !raw.startsWith('-') && !raw.startsWith(' '))) continue;
377
+ current = { old_start: 1, old_count: 0, new_start: 1, new_count: 0, lines: [] };
378
+ hunks.push(current);
379
+ }
380
+ const line = parseUnifiedLine(raw);
381
+ if (line) current.lines.push(line);
382
+ }
383
+ for (const hunk of hunks) {
384
+ if (!hunk.old_count) hunk.old_count = countDiffLines(hunk.lines, 'old');
385
+ if (!hunk.new_count) hunk.new_count = countDiffLines(hunk.lines, 'new');
386
+ }
387
+ return hunks.filter(hunk => hunk.lines.length);
388
+ }
389
+
390
+ function parseUnifiedLine(raw) {
391
+ const line = String(raw ?? '');
392
+ if (!line && raw !== '') return null;
393
+ if (line.startsWith('+') && !line.startsWith('+++')) return { type: 'add', text: line.slice(1) };
394
+ if (line.startsWith('-') && !line.startsWith('---')) return { type: 'remove', text: line.slice(1) };
395
+ if (line.startsWith(' ')) return { type: 'context', text: line.slice(1) };
396
+ return { type: 'context', text: line };
397
+ }
398
+
399
+ function countDiffLines(lines, side) {
400
+ return lines.filter(line => {
401
+ if (side === 'old') return line.type !== 'add';
402
+ return line.type !== 'remove';
403
+ }).length;
404
+ }
405
+
406
+ function paintDiffLine(line, maxWidth) {
407
+ const text = truncatePlain(String(line?.text ?? ''), Math.max(20, maxWidth - 2));
408
+ if (line?.type === 'add') return paint.state.success(`+ ${text}`);
409
+ if (line?.type === 'remove') return paint.state.danger(`- ${text}`);
410
+ return paint.text.dim(` ${text}`);
411
+ }
412
+
413
+ function truncatePlain(text, max) {
414
+ if (text.length <= max) return text;
415
+ if (max <= 1) return '';
416
+ return text.slice(0, max - 1) + '…';
417
+ }
418
+
419
+ function firstOutputLine(data) {
420
+ const o = data?.output_preview || data?.output || data?.message || '';
421
+ return String(o).split('\n').map(l => l.trim()).find(Boolean) || '';
422
+ }
423
+
424
+ // Preview the first N non-empty output rows, joined by \n. Returns
425
+ // { preview, remaining, total } — remaining is how many non-empty rows
426
+ // were dropped past N. Long individual rows get clipped to `perRow` chars.
427
+ function outputPreviewRows(data, n, perRow = 200) {
428
+ const o = data?.output_preview ?? data?.output ?? data?.message ?? '';
429
+ const rows = String(o).split('\n').map(l => l.trim()).filter(Boolean);
430
+ const shown = rows.slice(0, n).map(l => l.length > perRow ? l.slice(0, perRow - 1) + '…' : l);
431
+ return { preview: shown.join('\n'), remaining: Math.max(0, rows.length - n), total: rows.length };
432
+ }
433
+
434
+ // Default rows shown in the shell result preview. Overridable via env for
435
+ // power users; keep it small — the result line rides above every command
436
+ // and eats vertical space in a long session.
437
+ function shellPreviewRows() {
438
+ const raw = parseInt(process.env.BAHULAM_SHELL_PREVIEW_ROWS ?? '', 10);
439
+ return Number.isFinite(raw) && raw >= 1 ? raw : 2;
440
+ }
441
+
442
+ function lineCount(s) {
443
+ if (!s) return 0;
444
+ return String(s).split('\n').filter(Boolean).length;
445
+ }
446
+
447
+ function countMatches(data) {
448
+ if (typeof data?.match_count === 'number') return data.match_count;
449
+ return lineCount(data?.output);
450
+ }
451
+
452
+ function countMatchFiles(data) {
453
+ if (typeof data?.file_count === 'number') return data.file_count;
454
+ const out = String(data?.output || '');
455
+ if (!out) return 0;
456
+ const files = new Set();
457
+ for (const line of out.split('\n')) {
458
+ const m = line.match(/^([^:]+):/);
459
+ if (m) files.add(m[1]);
460
+ }
461
+ return files.size;
462
+ }
463
+
464
+ function diffDelta(data) {
465
+ const add = data?.lines_added ?? data?.additions;
466
+ const rem = data?.lines_removed ?? data?.deletions;
467
+ if (add == null && rem == null) return '';
468
+ const a = add ?? 0;
469
+ const r = rem ?? 0;
470
+ return `+${a} −${r}`;
471
+ }
472
+
473
+ function tone(text, t) {
474
+ switch (t) {
475
+ case 'success': return paint.state.success(text);
476
+ case 'warn': return paint.state.warn(text);
477
+ case 'danger': return paint.state.danger(text);
478
+ case 'dim':
479
+ default: return paint.text.dim(text);
480
+ }
481
+ }
482
+
483
+ // ── Card head (printed at invocation) ────────────────────────────────────
484
+
485
+ /**
486
+ * Render the leading half of a card — colored verb + args.
487
+ *
488
+ * v2.0.3: dropped the leading tool icon (🔭/🛠️/⚙️). The label itself is a
489
+ * present-progressive verb so the line reads like prose:
490
+ * "Reading src/ui/banner.mjs · lines 31-65 — 36 lines"
491
+ * The icon was decorative noise that broke the conversational feel.
492
+ * Mission report and sub-agent renderers still use the icons in their
493
+ * own contexts.
494
+ *
495
+ * Width-aware: truncates args from the left when the line would overflow.
496
+ */
497
+ export function formatCardHead(tool, args, opts = {}) {
498
+ const cwd = opts.cwd || safeCwd();
499
+ const cols = opts.columns || term().columns || 120;
500
+ const indent = opts.indent ?? (tool === 'shell' ? '' : ' ');
501
+
502
+ const label = toolDisplayLabel(tool);
503
+ const argsText = formatArgs(tool, args, cwd);
504
+ const leadText = formatHeadLead(tool, label);
505
+
506
+ const leadVisible = visibleWidth(`${indent}${leadText}`);
507
+ const budget = Math.max(20, cols - leadVisible - 4);
508
+
509
+ if (tool === 'shell') {
510
+ const profile = shellCommandProfile(toolDisplaySummary(tool, args || {}, { cwd }), { cwd });
511
+ if (profile.compact) {
512
+ const head = `${indent}${leadText}`;
513
+ if (profile.preview) {
514
+ const fullArgs = compactShellProfile(profile);
515
+ if (visibleWidth(fullArgs) <= budget) return `${head} ${fullArgs}`;
516
+ const previewTail = `${paint.text.dim(' · preview:')} ${paint.text.primary(profile.preview)}`;
517
+ const baseArgs = compactShellProfile(profile, { includePreview: false, includeDetails: false });
518
+ const baseBudget = budget - visibleWidth(previewTail);
519
+ if (baseBudget >= 12) {
520
+ const baseTruncated = truncateEndVisible(baseArgs, baseBudget);
521
+ return `${head} ${baseTruncated}${previewTail}`;
522
+ }
523
+ }
524
+ const argsTruncated = truncateMiddle(argsText, budget);
525
+ return argsTruncated ? `${head} ${argsTruncated}` : head;
526
+ }
527
+ }
528
+
529
+ if (tool === 'shell' && visibleWidth(argsText) > budget) {
530
+ const wrapWidth = Math.max(32, cols - visibleWidth(indent) - 4);
531
+ const display = shellCommandDisplay(toolDisplaySummary(tool, args || {}, { cwd }), { cwd });
532
+ const commandLines = wrapCommand(display.command, wrapWidth)
533
+ .map((line, index) => `${indent}${paint.text.dim(index === 0 ? '$ ' : '> ')}${formatShellCommand(line, paintShellAdapter)}`);
534
+ const head = `${indent}${leadText}`;
535
+ const cwdLine = display.cwdLabel
536
+ ? `\n${indent}${paint.text.dim(' in ')}${paint.brand.data(display.cwdLabel)}`
537
+ : '';
538
+ return `${head}\n${commandLines.join('\n')}${cwdLine}`;
539
+ }
540
+
541
+ const argsTruncated = truncateMiddle(argsText, budget);
542
+
543
+ const head = `${indent}${leadText}`;
544
+ return argsTruncated ? `${head} ${argsTruncated}` : head;
545
+ }
546
+
547
+ function formatHeadLead(tool, label) {
548
+ if (tool !== 'shell') return paintLabel(tool, label);
549
+ return `${paint.text.dim('• shell ·')} ${paintLabel(tool, label)}`;
550
+ }
551
+
552
+ function compactShellProfile(profile, { includePreview = true, includeDetails = true } = {}) {
553
+ const previewSuffix = profile.preview ? ` · preview: ${profile.preview}` : '';
554
+ const summary = previewSuffix && profile.summary.endsWith(previewSuffix)
555
+ ? profile.summary.slice(0, -previewSuffix.length)
556
+ : profile.summary;
557
+ const parts = [`${paint.text.dim('$')} ${paint.text.primary(summary)}`];
558
+ if (profile.cwdLabel) parts.push(`${paint.text.dim('in')} ${paint.brand.data(profile.cwdLabel)}`);
559
+ if (includeDetails) parts.push(paint.text.dim(profile.detailHint || 'details: F2 or /last'));
560
+ if (includePreview && profile.preview) parts.push(`${paint.text.dim('preview:')} ${paint.text.primary(profile.preview)}`);
561
+ return parts.join(' · ');
562
+ }
563
+
564
+ /**
565
+ * Render a full card with outcome.
566
+ *
567
+ * 🔭 search_code "JWT" → 4 matches in 2 files · 120ms
568
+ *
569
+ * `result` is the tool_result data from the SSE stream (same shape as
570
+ * `renderToolResult` consumed). `durationMs` overrides what's on the result.
571
+ */
572
+ export function formatCard({ tool, args, result, durationMs, indent, columns, cwd } = {}) {
573
+ const cols = columns || term().columns || 120;
574
+ const head = formatCardHead(tool, args, { indent, columns: cols, cwd });
575
+
576
+ const summary = summarizeResult(tool, result);
577
+ const duration = formatDuration(durationMs ?? result?.duration_ms ?? (result?.duration_s != null ? result.duration_s * 1000 : null));
578
+
579
+ if (!summary.text && !duration) return head;
580
+
581
+ const arrow = outcomeLead(tool);
582
+ const body = summary.text ? tone(summary.text, summary.tone) : '';
583
+ // Hide the duration tail when the tool was effectively instant (<200ms).
584
+ // For fast reads, "1ms" / "0ms" was noise that broke the prose feel.
585
+ const showDuration = duration && (durationMs == null || durationMs >= 200);
586
+ const tail = showDuration ? paint.text.dim(` · ${duration}`) : '';
587
+
588
+ const candidate = `${head} ${arrow} ${body}${tail}`;
589
+ if (!head.includes('\n') && visibleWidth(candidate) <= cols) return candidate;
590
+ if (!head.includes('\n') && isInlineOutcomeTool(tool)) {
591
+ // Reserve = outcome width + 4 (2 gap + a couple padding). Compact the head
592
+ // to whatever remains. Floor at 12 so the head stays recognizable; on
593
+ // very narrow terminals (cols - reserve < 12) fall through to the
594
+ // two-line gutter shape below rather than emit an overflowing line.
595
+ const reserve = visibleWidth(`${arrow} ${body}${tail}`) + 4;
596
+ const headBudget = cols - reserve;
597
+ if (headBudget >= 12) {
598
+ const compactHead = truncateMiddle(head, headBudget);
599
+ const combined = `${compactHead} ${arrow} ${body}${tail}`;
600
+ if (visibleWidth(combined) <= cols) return combined;
601
+ }
602
+ }
603
+
604
+ // Doesn't fit on one line → push outcome to a separate gutter line.
605
+ const gutterIndent = (indent || ' ') + paint.text.dim('⎿ ');
606
+ return `${head}\n${gutterIndent}${arrow} ${body}${tail}`;
607
+ }
608
+
609
+ function outcomeLead(tool) {
610
+ return isShellOutcomeTool(tool)
611
+ ? `${paint.text.dim('result')} ${paint.text.dim('—')}`
612
+ : paint.text.dim('—');
613
+ }
614
+
615
+ function isShellOutcomeTool(tool) {
616
+ return [
617
+ 'shell', 'run_tests', 'validate_build', 'lint_check',
618
+ 'validate_file', 'validate_structure',
619
+ ].includes(String(tool || '').toLowerCase());
620
+ }
621
+
622
+ function isInlineOutcomeTool(tool) {
623
+ return [
624
+ 'read_file', 'read_files', 'read_batch', 'get_file_info',
625
+ 'search_code', 'search_files', 'grep', 'list_files',
626
+ ].includes(String(tool || '').toLowerCase());
627
+ }
628
+
629
+ function truncateMiddle(text, max) {
630
+ if (!text) return '';
631
+ if (visibleWidth(text) <= max) return text;
632
+ // Truncate the plain text and re-trust palette helpers to skip codes.
633
+ const plain = text.replace(/\x1b\[[0-9;]*m/g, '');
634
+ if (plain.length <= max) return text;
635
+ const keep = Math.max(8, max - 3);
636
+ const head = plain.slice(0, Math.floor(keep / 2));
637
+ const tail = plain.slice(plain.length - Math.ceil(keep / 2));
638
+ return paint.text.muted(`${head}…${tail}`);
639
+ }
640
+
641
+ function truncateEndVisible(text, max) {
642
+ if (!text) return '';
643
+ if (visibleWidth(text) <= max) return text;
644
+ const plain = text.replace(/\x1b\[[0-9;]*m/g, '');
645
+ const limit = Math.max(1, Math.floor(max));
646
+ if (limit <= 1) return '';
647
+ return paint.text.muted(`${plain.slice(0, limit - 1)}…`);
648
+ }
649
+
650
+ function wrapCommand(command, width) {
651
+ const text = String(command || '');
652
+ if (!text) return ['(empty command)'];
653
+ const lines = [];
654
+ for (const physicalLine of text.replace(/\r\n?/g, '\n').split('\n')) {
655
+ let line = '';
656
+ for (const token of physicalLine.match(/\S+\s*/g) || [physicalLine]) {
657
+ const next = line + token;
658
+ if (line && visibleWidth(next.trimEnd()) > width) {
659
+ lines.push(line.trimEnd());
660
+ line = token;
661
+ continue;
662
+ }
663
+ if (!line && visibleWidth(token.trimEnd()) > width) {
664
+ lines.push(...chunkLongToken(token.trimEnd(), width));
665
+ line = '';
666
+ continue;
667
+ }
668
+ line = next;
669
+ }
670
+ if (line.trimEnd()) lines.push(line.trimEnd());
671
+ else if (!physicalLine.trim()) lines.push('');
672
+ }
673
+ return lines.length ? lines : ['(empty command)'];
674
+ }
675
+
676
+ function chunkLongToken(token, width) {
677
+ const chunks = [];
678
+ const size = Math.max(8, width);
679
+ for (let i = 0; i < token.length; i += size) {
680
+ chunks.push(token.slice(i, i + size));
681
+ }
682
+ return chunks;
683
+ }
684
+
685
+ function formatDuration(ms) {
686
+ if (ms == null || !Number.isFinite(ms)) return '';
687
+ if (ms < 1000) return `${Math.round(ms)}ms`;
688
+ return `${(ms / 1000).toFixed(1)}s`;
689
+ }
690
+
691
+ function safeCwd() {
692
+ try { return process.cwd(); } catch { return ''; }
693
+ }
694
+
695
+ // ── Ring buffer of recent cards (for expand / /last) ─────────────────────
696
+
697
+ const MAX_CARDS = 50;
698
+ const _cards = [];
699
+
700
+ /**
701
+ * Record a card by its call_id (or generated id). Returns the stored entry.
702
+ * The entry is updated in place when the matching result arrives.
703
+ */
704
+ export function recordCard({ id, tool, args, head, result, durationMs, startedAt }) {
705
+ const entry = { id, tool, args, head, result: result || null, durationMs: durationMs ?? null, startedAt: startedAt ?? null };
706
+ // Replace if same id already exists (e.g. tool_call followed by tool_result)
707
+ const existing = _cards.findIndex(c => c.id != null && c.id === id);
708
+ if (existing >= 0) {
709
+ _cards[existing] = { ..._cards[existing], ...entry };
710
+ return _cards[existing];
711
+ }
712
+ _cards.push(entry);
713
+ if (_cards.length > MAX_CARDS) _cards.shift();
714
+ return entry;
715
+ }
716
+
717
+ /** Most recently recorded card (the one `d` / `/last` should expand). */
718
+ export function lastCard() {
719
+ return _cards[_cards.length - 1] || null;
720
+ }
721
+
722
+ /** Look up a card by id, or 1-based index from the tail (-1 == lastCard). */
723
+ export function getCard(idOrIndex) {
724
+ if (idOrIndex == null) return lastCard();
725
+ if (typeof idOrIndex === 'number') {
726
+ if (idOrIndex < 0) return _cards[_cards.length + idOrIndex] || null;
727
+ return _cards[idOrIndex] || null;
728
+ }
729
+ return _cards.find(c => c.id === idOrIndex) || null;
730
+ }
731
+
732
+ /** All recorded cards in order. */
733
+ export function allCards() {
734
+ return _cards.slice();
735
+ }
736
+
737
+ /** Drop all recorded cards (used by tests and `/clear`). */
738
+ export function clearCards() {
739
+ _cards.length = 0;
740
+ }