@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,833 @@
1
+ /**
2
+ * REPL rendering pipeline — event-facing side.
3
+ *
4
+ * Extracted from repl.mjs. Contains the pieces that write to the terminal
5
+ * once an event has been dispatched:
6
+ * - Block boundaries between different display sections
7
+ * - Tool head + result rendering (single-line vs two-line card shape)
8
+ * - Explore-run collapse (list/read/search/index burst → one animated line)
9
+ * - Content streaming (SSE partials → debounced markdown flush)
10
+ * - Spinner (single animated line, shared across concerns)
11
+ * - Stagnation banner
12
+ * - Detail expansion (/last, /expand N, F2)
13
+ *
14
+ * All mutable state lives in repl-state.mjs (`runtime`). Consumers of this
15
+ * module (renderEvent, handleCommand, keypress handlers) import the specific
16
+ * functions they need.
17
+ */
18
+
19
+ import { c, stripAnsi, renderMarkdown, inPlace } from './ansi.mjs';
20
+ import { paint } from '../ui/palette.mjs';
21
+ import { runtime, session } from './repl-state.mjs';
22
+ import { fitAnsiLine } from './repl-format.mjs';
23
+ import { exploreCategory, isExploreTool } from './repl-explore.mjs';
24
+ import {
25
+ clearPinnedStatus,
26
+ drawPinnedStatus,
27
+ isInputDockMounted,
28
+ moveToContent,
29
+ } from '../ui/input-dock.mjs';
30
+ import * as queue from '../ui/render-queue.mjs';
31
+
32
+ // Single seam for the transient spinner/status line. Rich mode (render
33
+ // queue active) → coalesced last-wins status that can never interleave
34
+ // with content. Legacy dock path and bare-TTY inPlace stay as fallbacks
35
+ // until their write-sites migrate onto the queue too.
36
+ // Max inner-tool lines shown under the spinner during a sub-agent run.
37
+ const SUB_AGENT_WINDOW_ROWS = 7;
38
+
39
+ function presentStatus(rendered) {
40
+ if (queue.isActive()) {
41
+ const win = runtime.subAgentWindow;
42
+ if (win?.active && win.lines.length) {
43
+ queue.statusBlock([
44
+ rendered,
45
+ ...win.lines.slice(-SUB_AGENT_WINDOW_ROWS).map(l => ` ${c.dim(l)}`),
46
+ ]);
47
+ return;
48
+ }
49
+ queue.status(rendered);
50
+ return;
51
+ }
52
+ if (isInputDockMounted()) { drawPinnedStatus(rendered); return; }
53
+ inPlace(rendered);
54
+ }
55
+
56
+ /** Push a line into the live sub-agent tool window (dedup consecutive). */
57
+ export function pushSubAgentWindowLine(line) {
58
+ const win = runtime.subAgentWindow;
59
+ if (!win?.active) return;
60
+ const text = String(line || '').trim();
61
+ if (!text || win.lines[win.lines.length - 1] === text) return;
62
+ win.lines.push(text);
63
+ if (win.lines.length > 24) win.lines.splice(0, win.lines.length - 24);
64
+ repaintSpinnerStatus();
65
+ }
66
+
67
+ export function setSubAgentWindowActive(active) {
68
+ runtime.subAgentWindow = { active: Boolean(active), lines: [] };
69
+ }
70
+
71
+ /**
72
+ * Replace the entire sub-agent live window with these lines. Called by
73
+ * repl.mjs's fold-* functions on every sub-agent tool_call and tool_result
74
+ * so the window shows RICH per-tool progress (same '• tool — outcome'
75
+ * format as the final summary) live during the sub-agent run instead of
76
+ * a bare '→ tool' spinner text or nothing at all.
77
+ */
78
+ export function rebuildSubAgentWindow(lines) {
79
+ const win = runtime.subAgentWindow;
80
+ if (!win?.active) return;
81
+ win.lines = Array.isArray(lines) ? lines.slice() : [];
82
+ repaintSpinnerStatus();
83
+ }
84
+
85
+ function erasePresentedStatus() {
86
+ if (queue.isActive()) { queue.clearStatus(); return; }
87
+ if (isInputDockMounted()) { clearPinnedStatus(); moveToContent(); return; }
88
+ inPlace('');
89
+ }
90
+ import {
91
+ formatCardHead,
92
+ formatCompactFileDiff,
93
+ summarizeResult,
94
+ recordCard,
95
+ lastCard,
96
+ getCard,
97
+ allCards,
98
+ clearCards,
99
+ } from '../ui/tool-card.mjs';
100
+ import { detailFor } from '../ui/tool-details.mjs';
101
+ import { subAgentIndent, inSubAgent as inSubAgentBlock } from '../ui/sub-agent.mjs';
102
+ import { safeCwd } from './repl-utils.mjs';
103
+ import { transcriptHeader, transcriptLine } from '../ui/transcript-block.mjs';
104
+
105
+ export function blockSeparatorMode() {
106
+ return String(process.env.KEPLER_BLOCK_SEPARATOR || 'space').toLowerCase();
107
+ }
108
+
109
+ export function renderBlockBoundary(nextBlock, { compactSame = false } = {}) {
110
+ if (!runtime.lastRenderedBlock) return;
111
+ if (compactSame && runtime.lastRenderedBlock === nextBlock) return;
112
+
113
+ const mode = blockSeparatorMode();
114
+ if (mode === 'off' || mode === 'none') return;
115
+ if (mode === 'dotted' || mode === 'dots') {
116
+ const cols = Math.max(24, process.stderr.columns || process.stdout.columns || 80);
117
+ process.stderr.write(` ${c.dim('·'.repeat(Math.min(44, cols - 4)))}\n`);
118
+ return;
119
+ }
120
+
121
+ process.stderr.write('\n');
122
+ }
123
+
124
+ export function flushPendingHead() {
125
+ if (!runtime.pendingHead) return;
126
+ process.stderr.write(`${runtime.pendingHead.head}\n`);
127
+ runtime.lastRenderedBlock = 'tool';
128
+ runtime.pendingHead = null;
129
+ }
130
+
131
+ export function clearPendingHead() {
132
+ // Called by interleaving handlers — flush as 2-line shape (because we are
133
+ // about to print something else) and continue.
134
+ flushPendingHead();
135
+ }
136
+
137
+ export function isInlineOutcomeTool(tool) {
138
+ return [
139
+ 'read_file', 'read_files', 'read_batch', 'get_file_info',
140
+ 'search_code', 'search_files', 'grep', 'list_files',
141
+ ].includes(String(tool || '').toLowerCase());
142
+ }
143
+
144
+ export function compactHeadForOutcome(head, outcome, cols) {
145
+ const reserve = stripAnsi(outcome).length + 4;
146
+ const maxHead = Math.max(28, cols - reserve);
147
+ return fitAnsiLine(head, maxHead);
148
+ }
149
+
150
+ export function readToolLabel(tool, data = {}) {
151
+ const args = data.args || {};
152
+ const filePath = args.file_path || args.path || data.file_path || data.path
153
+ || args.pattern || args.query || '';
154
+ if (filePath) return shortPath(String(filePath));
155
+ const output = String(data.output_preview || data.output || '').split('\n').find(Boolean) || '';
156
+ const match = output.match(/^([^:\s][^:\n]*):/);
157
+ return match ? shortPath(match[1]) : String(tool || 'file');
158
+ }
159
+
160
+ export function rememberExplore(label) {
161
+ const value = String(label || '').trim();
162
+ if (!value) return;
163
+ runtime.exploreRun.recent.push(value);
164
+ if (runtime.exploreRun.recent.length > 3) runtime.exploreRun.recent.shift();
165
+ }
166
+
167
+ export function exploreSummary() {
168
+ const { counts, recent } = runtime.exploreRun;
169
+ const bits = [];
170
+ if (counts.list) bits.push(`${counts.list} listed`);
171
+ if (counts.read) bits.push(`${counts.read} read`);
172
+ if (counts.search) bits.push(`${counts.search} searched`);
173
+ if (counts.index) bits.push(`${counts.index} indexed`);
174
+ const stats = bits.length ? bits.join(' · ') : 'starting…';
175
+ const latest = recent.length ? ` · ${recent[recent.length - 1]}` : '';
176
+ return `exploring · ${stats}${latest}`;
177
+ }
178
+
179
+ function exploreRunTotal() {
180
+ return Object.values(runtime.exploreRun.counts).reduce((a, b) => a + b, 0);
181
+ }
182
+
183
+ function exploreSnapshotEvery() {
184
+ const n = Number.parseInt(process.env.KEPLER_EXPLORE_SNAPSHOT_EVERY || '8', 10);
185
+ return Number.isFinite(n) ? Math.max(1, n) : 8;
186
+ }
187
+
188
+ function exploreSnapshotMs() {
189
+ const n = Number.parseInt(process.env.KEPLER_EXPLORE_SNAPSHOT_MS || '900', 10);
190
+ return Number.isFinite(n) ? Math.max(100, n) : 900;
191
+ }
192
+
193
+ function writeExploreSnapshot(summary = exploreSummary()) {
194
+ const cols = process.stderr.columns || 120;
195
+ const line = ` ${paint.text.dim(fitAnsiLine(summary, Math.max(32, cols - 2)))}`;
196
+ process.stderr.write(`${line}\n`);
197
+ runtime.exploreRun.lastPrintedSummary = summary;
198
+ runtime.exploreRun.lastPrintedTotal = exploreRunTotal();
199
+ runtime.exploreRun.lastPrintedAt = Date.now();
200
+ runtime.lastRenderedBlock = 'tool';
201
+ }
202
+
203
+ function shouldPrintExploreSnapshot() {
204
+ const summary = exploreSummary();
205
+ const total = exploreRunTotal();
206
+ if (!summary || total <= 0) return false;
207
+ if (!runtime.exploreRun.lastPrintedSummary) return true;
208
+ if (summary === runtime.exploreRun.lastPrintedSummary) return false;
209
+
210
+ const sinceTotal = total - (runtime.exploreRun.lastPrintedTotal || 0);
211
+ const sinceMs = Date.now() - (runtime.exploreRun.lastPrintedAt || 0);
212
+ return sinceTotal >= exploreSnapshotEvery() || sinceMs >= exploreSnapshotMs();
213
+ }
214
+
215
+ export function renderExploreRun() {
216
+ // Set the lock BEFORE touching the spinner so the interval's next tick
217
+ // picks up exploreSummary() text instead of any stale label.
218
+ runtime.exploreRun.lineActive = true;
219
+
220
+ if (!queue.isActive() && isInputDockMounted()) {
221
+ // Legacy docked path (queue not engaged): an animated bottom overlay
222
+ // created visible gaps, so emit bounded snapshots into the transcript
223
+ // instead. With the render queue active this branch is skipped — the
224
+ // coalesced status line can animate safely in dock mode.
225
+ if (runtime.spinInterval) {
226
+ clearInterval(runtime.spinInterval);
227
+ runtime.spinInterval = null;
228
+ inPlace('');
229
+ }
230
+ runtime.spinText = '';
231
+ if (shouldPrintExploreSnapshot()) writeExploreSnapshot();
232
+ return;
233
+ }
234
+
235
+ if (!runtime.spinInterval) {
236
+ // Bypass the lockout in startSpinner by seeding runtime.spinText directly.
237
+ runtime.spinText = exploreSummary();
238
+ runtime.spinFrame = 0;
239
+ runtime.spinInterval = setInterval(() => {
240
+ const isExploreActive = runtime.exploreRun && runtime.exploreRun.lineActive;
241
+ const label = isExploreActive ? exploreSummary() : runtime.spinText;
242
+ if (!label) return;
243
+ const frame = SPIN_FRAMES[runtime.spinFrame % SPIN_FRAMES.length];
244
+ runtime.spinFrame++;
245
+ const rendered = ` ${c.brand(frame)} ${c.dim(label)}`;
246
+ presentStatus(rendered);
247
+ }, 80);
248
+ }
249
+ runtime.lastRenderedBlock = 'tool';
250
+ }
251
+
252
+ export function flushExploreRun() {
253
+ const total = exploreRunTotal();
254
+ const summary = exploreSummary();
255
+ // Release the lock first so the real spinner teardown can run.
256
+ const wasActive = runtime.exploreRun.lineActive;
257
+ runtime.exploreRun.lineActive = false;
258
+ if (total > 0) {
259
+ if (wasActive) {
260
+ if (runtime.spinInterval) { clearInterval(runtime.spinInterval); runtime.spinInterval = null; }
261
+ runtime.spinText = '';
262
+ if (queue.isActive()) queue.clearStatus();
263
+ else if (!isInputDockMounted()) inPlace('');
264
+ }
265
+ if (queue.isActive() || !isInputDockMounted() || summary !== runtime.exploreRun.lastPrintedSummary) {
266
+ writeExploreSnapshot(summary);
267
+ }
268
+ }
269
+ runtime.exploreRun = { counts: {}, recent: [], lineActive: false, lastPrintedSummary: '', lastPrintedTotal: 0, lastPrintedAt: 0 };
270
+ }
271
+
272
+ // Legacy alias — several sites (resetContentStream, older event handlers)
273
+ // call this. Keep pointing at the new flush so nothing has to change.
274
+ const flushCompactReadRun = flushExploreRun;
275
+
276
+ export function renderToolCall(data) {
277
+ const tool = data?.tool || 'unknown';
278
+ const args = data?.args || {};
279
+ const indent = subAgentIndent();
280
+ const callId = data?.call_id || data?._callId || `${tool}:${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
281
+
282
+ // If a previous head is still pending (no result yet), flush it as a
283
+ // regular two-line shape before starting the next one.
284
+ flushPendingHead();
285
+
286
+ // ── Explore-run collapse ────────────────────────────────────────────────
287
+ // For list/read/search/index tools, skip the per-call head entirely and
288
+ // update a single animated summary spinner. The transcript stays clean;
289
+ // the user still sees live progress (12 read · 3 listed · latest: foo.py).
290
+ if (isExploreTool(tool)) {
291
+ runtime.exploreRun.counts[exploreCategory(tool)] =
292
+ (runtime.exploreRun.counts[exploreCategory(tool)] || 0) + 1;
293
+ session.toolCounts[tool] = (session.toolCounts[tool] || 0) + 1;
294
+ const label = readToolLabel(tool, { args });
295
+ if (label) rememberExplore(label);
296
+ recordCard({ id: callId, tool, args, startedAt: Date.now() });
297
+ renderExploreRun();
298
+ return;
299
+ }
300
+
301
+ // Sub-agent live window (queue mode): inner tool calls stream into the
302
+ // fixed-height status block instead of appending transcript lines. The
303
+ // card is still recorded so /expand, /last, and `d` show full detail.
304
+ if (queue.isActive() && runtime.subAgentWindow?.active && inSubAgentBlock()) {
305
+ recordCard({ id: callId, tool, args, startedAt: Date.now() });
306
+ session.toolCounts[tool] = (session.toolCounts[tool] || 0) + 1;
307
+ const label = readToolLabel(tool, { args });
308
+ pushSubAgentWindowLine(label ? `→ ${tool} · ${label}` : `→ ${tool}`);
309
+ return; // the spinner tick paints the window block
310
+ }
311
+
312
+ flushExploreRun();
313
+ renderBlockBoundary('tool', { compactSame: tool !== 'shell' });
314
+
315
+ const head = formatCardHead(tool, args, {
316
+ cwd: safeCwd(),
317
+ columns: process.stderr.columns || 120,
318
+ indent,
319
+ });
320
+
321
+ recordCard({ id: callId, tool, args, head, startedAt: Date.now() });
322
+ session.toolCounts[tool] = (session.toolCounts[tool] || 0) + 1;
323
+ runtime.pendingHead = { callId, head, indent };
324
+ runtime.lastRenderedBlock = 'tool';
325
+ // Spinner shows what's running until the result arrives. Per-call phase
326
+ // gives each tool its own elapsed clock — long shell runs count up live.
327
+ const spinLabel = tool === 'shell' && args.command
328
+ ? `shell: ${String(args.command).split('\n')[0].slice(0, 48)}`
329
+ : `${tool}…`;
330
+ startSpinner(spinLabel, { phase: `tool:${callId}` });
331
+ }
332
+
333
+ /**
334
+ * Render a tool result (success/failure, output snippet).
335
+ */
336
+ // (declaration moved to repl-state.mjs runtime.*)
337
+
338
+ export function formatToolDuration(data) {
339
+ const ms = data?.duration_ms ?? (data?.duration_s != null ? data.duration_s * 1000 : null);
340
+ if (ms == null) return '';
341
+ return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
342
+ }
343
+
344
+ export function renderToolResult(data, eventType = 'tool_result') {
345
+ if (!data) return;
346
+ const indent = subAgentIndent();
347
+ const gutter = `${indent}${paint.text.dim('⎿')} `;
348
+ const callId = data.call_id || data._callId;
349
+ // Either tool_result or tool_done is allowed to render — whichever wins
350
+ // the race. Subsequent events for the same callId are duplicates.
351
+ if (callId && runtime.renderedToolResults.has(callId)) return;
352
+ if (callId) runtime.renderedToolResults.add(callId);
353
+
354
+ const tool = data.tool || data._tool || '';
355
+ const durationMs = data?.duration_ms ?? (data?.duration_s != null ? data.duration_s * 1000 : null);
356
+ recordReadActivity(tool, data.args || {});
357
+ recordWriteActivity(tool, data.args || {}, data);
358
+
359
+ // Update the card buffer so /last and `d` can find it.
360
+ if (callId) recordCard({ id: callId, tool, args: data.args, result: data, durationMs });
361
+
362
+ if (data._blocked) session.blockedOps++;
363
+
364
+ const { text, tone: t } = summarizeResult(tool, data);
365
+ // Em dash reads more like prose than a system arrow.
366
+ const arrow = shellResultTool(tool)
367
+ ? `${paint.text.dim('result')} ${paint.text.dim('—')}`
368
+ : paint.text.dim('—');
369
+ const painter = t === 'success' ? paint.state.success
370
+ : t === 'warn' ? paint.state.warn
371
+ : t === 'danger' ? paint.state.danger
372
+ : paint.text.dim;
373
+ // Skip the duration tail when the tool was effectively instant (<200ms) —
374
+ // "1ms" / "0ms" was noise that hurt the prose feel.
375
+ const duration = (durationMs != null && durationMs < 200) ? '' : formatToolDuration(data);
376
+ const tail = duration ? paint.text.dim(` · ${duration}`) : '';
377
+ const outcome = `${arrow} ${painter(text || 'done')}${tail}`;
378
+ const hasLint = (tool === 'write_file' || tool === 'edit_file') && data.lint;
379
+ const diffPreview = formatCompactFileDiff(data, {
380
+ indent: gutter,
381
+ columns: process.stderr.columns || 120,
382
+ });
383
+
384
+ // Explore tools: the call already updated the summary spinner. Refresh
385
+ // the "latest" hint with the result's file if we have one, and skip the
386
+ // per-result render entirely.
387
+ if (isExploreTool(tool)) {
388
+ const label = readToolLabel(tool, data);
389
+ if (label) rememberExplore(label);
390
+ renderExploreRun();
391
+ return;
392
+ }
393
+
394
+ // Sub-agent live window: the call line is already streaming in the
395
+ // status block; the result stays card-only (close card summarizes).
396
+ if (queue.isActive() && runtime.subAgentWindow?.active && inSubAgentBlock()) {
397
+ return;
398
+ }
399
+
400
+ // ── Single-line combined emit ──
401
+ // If the head for this call is still buffered (no interleaving content
402
+ // landed), and the combined line fits the terminal width, emit ONE line
403
+ // and skip the gutter entirely. Multi-line result text (shell preview
404
+ // with rows + "+ N more" tail) skips this path — a wrapped multi-line
405
+ // block needs its own real estate.
406
+ const outcomeIsMultiLine = outcome.includes('\n');
407
+ if (runtime.pendingHead && runtime.pendingHead.callId === callId && !hasLint && !runtime.pendingHead.head.includes('\n') && !outcomeIsMultiLine) {
408
+ const cols = process.stderr.columns || 120;
409
+ const combined = `${runtime.pendingHead.head} ${outcome}`;
410
+ if (stripAnsi(combined).length <= cols) {
411
+ process.stderr.write(`${combined}\n`);
412
+ if (diffPreview) {
413
+ process.stderr.write(`${diffPreview}\n`);
414
+ rememberFileDiffPreview(data);
415
+ }
416
+ renderPlanBody(tool, data);
417
+ runtime.lastRenderedBlock = 'tool';
418
+ runtime.pendingHead = null;
419
+ return;
420
+ }
421
+ if (isInlineOutcomeTool(tool)) {
422
+ const compactHead = compactHeadForOutcome(runtime.pendingHead.head, outcome, cols);
423
+ process.stderr.write(`${compactHead} ${outcome}\n`);
424
+ if (diffPreview) {
425
+ process.stderr.write(`${diffPreview}\n`);
426
+ rememberFileDiffPreview(data);
427
+ }
428
+ renderPlanBody(tool, data);
429
+ runtime.lastRenderedBlock = 'tool';
430
+ runtime.pendingHead = null;
431
+ return;
432
+ }
433
+ // Combined too wide — flush the head as 2-line and fall through.
434
+ flushPendingHead();
435
+ } else if (runtime.pendingHead) {
436
+ // Stale pending head (different callId) — flush it before printing this
437
+ // result's gutter line below.
438
+ flushPendingHead();
439
+ }
440
+
441
+ // Two-line shape: gutter under the (already-printed or just-flushed) head.
442
+ // Multi-line outcome (shell preview with rows + "+ N more" tail): prepend
443
+ // the gutter to each line so the block stays aligned instead of ragged.
444
+ if (outcomeIsMultiLine) {
445
+ for (const line of outcome.split('\n')) {
446
+ process.stderr.write(`${gutter}${line}\n`);
447
+ }
448
+ } else {
449
+ process.stderr.write(`${gutter}${outcome}\n`);
450
+ }
451
+ if (diffPreview) {
452
+ process.stderr.write(`${diffPreview}\n`);
453
+ rememberFileDiffPreview(data);
454
+ }
455
+ renderPlanBody(tool, data);
456
+ runtime.lastRenderedBlock = 'tool';
457
+
458
+ // Lint warnings stay visible alongside writes.
459
+ if (hasLint) {
460
+ process.stderr.write(`${gutter}${paint.state.warn('⚠ ' + String(data.lint).split('\n')[0].slice(0, 80))}\n`);
461
+ }
462
+ }
463
+
464
+ // The plan sub-agent's output is the one peer result the USER needs to
465
+ // see, not just the model — it's the execution contract for the turn.
466
+ // Render the body as a bordered block (capped; full text stays on the
467
+ // card via /last). All other peer verbs keep the one-line summary.
468
+ const PLAN_BODY_MAX_LINES = 30;
469
+
470
+ function renderPlanBody(tool, data) {
471
+ if (String(tool || '').toLowerCase() !== 'plan') return;
472
+ const text = String(data?.output ?? data?.result ?? '').trim();
473
+ if (!text) return;
474
+ const indent = subAgentIndent();
475
+ const lines = transcriptRenderableLines(renderMarkdown(text));
476
+ if (!lines.length) return;
477
+ const shown = lines.slice(0, PLAN_BODY_MAX_LINES);
478
+ process.stderr.write(`${indent}${paint.text.dim('┌ plan')}\n`);
479
+ for (const line of shown) {
480
+ process.stderr.write(`${indent}${paint.text.dim('│')} ${line}\n`);
481
+ }
482
+ process.stderr.write(lines.length > shown.length
483
+ ? `${indent}${paint.text.dim(`└ … ${lines.length - shown.length} more lines · /last to expand`)}\n`
484
+ : `${indent}${paint.text.dim('└')}\n`);
485
+ }
486
+
487
+ function fileDiffKey(data = {}) {
488
+ return fileDiffKeys(data)[0] || '';
489
+ }
490
+
491
+ function fileDiffKeys(data = {}) {
492
+ const keys = [];
493
+ const callId = data.call_id || data._callId || data.request_id || data.id;
494
+ if (callId) keys.push(`call:${callId}`);
495
+ const diff = Array.isArray(data.file_diffs) ? data.file_diffs[0]
496
+ : data.file_diff ? data.file_diff
497
+ : data.type === 'file_diff' ? data
498
+ : null;
499
+ const file = diff?.relative_path || diff?.path || data.relative_path || data.path || '';
500
+ if (file) {
501
+ const added = diff?.lines_added ?? data.lines_added ?? '';
502
+ const removed = diff?.lines_removed ?? data.lines_removed ?? '';
503
+ keys.push(`file:${file}:${added}:${removed}`);
504
+ }
505
+ return keys;
506
+ }
507
+
508
+ function rememberFileDiffPreview(data = {}) {
509
+ for (const key of fileDiffKeys(data)) {
510
+ runtime.renderedFileDiffPreviews.add(key);
511
+ }
512
+ }
513
+
514
+ export function renderFileDiffEvent(data = {}) {
515
+ const keys = fileDiffKeys(data);
516
+ if (keys.some(key => runtime.renderedFileDiffPreviews.has(key))) return false;
517
+
518
+ const indent = subAgentIndent();
519
+ const gutter = `${indent}${paint.text.dim('⎿')} `;
520
+ const diffPreview = formatCompactFileDiff({
521
+ file_diff: data,
522
+ lines_added: data.lines_added,
523
+ lines_removed: data.lines_removed,
524
+ }, {
525
+ indent: gutter,
526
+ columns: process.stderr.columns || 120,
527
+ showFileHeader: true,
528
+ });
529
+ if (!diffPreview) return false;
530
+
531
+ renderBlockBoundary('tool', { compactSame: true });
532
+ process.stderr.write(`${diffPreview}\n`);
533
+ for (const key of keys) runtime.renderedFileDiffPreviews.add(key);
534
+ rememberChangedFile(data.relative_path || data.path);
535
+ runtime.lastRenderedBlock = 'tool';
536
+ return true;
537
+ }
538
+
539
+ function shellResultTool(tool) {
540
+ return [
541
+ 'shell', 'run_tests', 'validate_build', 'lint_check',
542
+ 'validate_file', 'validate_structure',
543
+ ].includes(String(tool || '').toLowerCase());
544
+ }
545
+
546
+ // ── Expand handler — `d`, `/last`, `/expand` ───────────────────────────
547
+ //
548
+ // All three call into the same renderer so output is consistent across
549
+ // keypress and slash-command paths. `expandLast` and `expandIndex` write
550
+ // directly to stderr.
551
+
552
+ export function expandLast() {
553
+ const card = lastCard();
554
+ if (!card) {
555
+ process.stderr.write(` ${paint.text.dim('(no tool to expand yet)')}\n`);
556
+ return;
557
+ }
558
+ process.stderr.write('\n' + detailFor(card) + '\n\n');
559
+ }
560
+
561
+ export function expandIndex(idxOrAll) {
562
+ if (idxOrAll === 'all') {
563
+ const cards = allCards();
564
+ if (!cards.length) {
565
+ process.stderr.write(` ${paint.text.dim('(no tools to expand yet)')}\n`);
566
+ return;
567
+ }
568
+ process.stderr.write('\n');
569
+ for (const c of cards) process.stderr.write(detailFor(c) + '\n');
570
+ process.stderr.write('\n');
571
+ return;
572
+ }
573
+ const card = getCard(idxOrAll);
574
+ if (!card) {
575
+ process.stderr.write(` ${paint.text.dim('(no card at index ' + idxOrAll + ')')}\n`);
576
+ return;
577
+ }
578
+ process.stderr.write('\n' + detailFor(card) + '\n\n');
579
+ }
580
+
581
+ /**
582
+ * Shorten a file path for display: /Users/sree/Sites/project/src/foo.mjs → src/foo.mjs
583
+ */
584
+ export function shortPath(p) {
585
+ if (!p) return '';
586
+ const cwd = safeCwd();
587
+ if (p.startsWith(cwd)) return p.slice(cwd.length + 1);
588
+ // Show last 2 segments
589
+ const parts = p.split('/');
590
+ return parts.length > 2 ? parts.slice(-2).join('/') : p;
591
+ }
592
+
593
+ export function rememberReadFile(filePath) {
594
+ const file = shortPath(String(filePath || '').trim());
595
+ if (file && !session.filesRead.includes(file)) session.filesRead.push(file);
596
+ }
597
+
598
+ export function rememberChangedFile(filePath) {
599
+ const file = shortPath(String(filePath || '').trim());
600
+ if (file && !session.filesChanged.includes(file)) session.filesChanged.push(file);
601
+ }
602
+
603
+ export function recordReadActivity(tool, args = {}) {
604
+ const normalized = String(tool || '').toLowerCase();
605
+ if (normalized === 'read_file' || normalized === 'read') {
606
+ rememberReadFile(args.file_path || args.path);
607
+ return;
608
+ }
609
+ if (normalized === 'read_files') {
610
+ const files = args.file_paths || args.paths || args.files || [];
611
+ for (const file of Array.isArray(files) ? files : []) {
612
+ rememberReadFile(typeof file === 'string' ? file : file?.file_path || file?.path);
613
+ }
614
+ }
615
+ }
616
+
617
+ export function recordWriteActivity(tool, args = {}, result = {}) {
618
+ const normalized = String(tool || '').toLowerCase();
619
+ if (!['write_file', 'edit_file', 'delete_file', 'write_project'].includes(normalized)) return;
620
+
621
+ if (normalized === 'write_project') {
622
+ const files = Array.isArray(args.files) ? args.files : [];
623
+ for (const file of files) {
624
+ rememberChangedFile(file?.file_path || file?.path);
625
+ }
626
+ }
627
+
628
+ rememberChangedFile(args.file_path || args.path || result.file_path || result.path);
629
+ const diffs = Array.isArray(result.file_diffs)
630
+ ? result.file_diffs
631
+ : result.file_diff ? [result.file_diff] : [];
632
+ for (const diff of diffs) {
633
+ rememberChangedFile(diff.relative_path || diff.path);
634
+ }
635
+ }
636
+
637
+ export function thinkingKind(text) {
638
+ return /\b(read|reading|inspect|scan|search|open|trace|look(?:ing)?\s+at)\b/i.test(text)
639
+ ? 'Reading'
640
+ : 'Thinking';
641
+ }
642
+
643
+ export function thinkingPrefix(text) {
644
+ const kind = thinkingKind(text);
645
+ return kind === 'Thinking' ? 'Thinking' : `Thinking · ${kind}`;
646
+ }
647
+
648
+ export function clippedThinking(text, limit = 200) {
649
+ const value = String(text || '');
650
+ return value.length > limit ? `${value.slice(0, limit - 2)} …` : value;
651
+ }
652
+
653
+ // ── Live Spinner ──
654
+ // A real animated spinner that ticks on an interval, not just per-call.
655
+ // Shows what's happening right now — thinking, tool executing, etc.
656
+
657
+ export const SPIN_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
658
+ // (declaration moved to repl-state.mjs runtime.*)
659
+ // (declaration moved to repl-state.mjs runtime.*)
660
+ // (declaration moved to repl-state.mjs runtime.*)
661
+
662
+ // Compose the status label with live phase telemetry: elapsed seconds
663
+ // (shown once a phase runs ≥3s — quick tools stay clean) and the
664
+ // per-phase tool-call counter (sub-agent progress). The 80ms tick calls
665
+ // this every frame, so elapsed counts up without any extra timer.
666
+ function composeStatusLabel(label) {
667
+ const parts = [label];
668
+ if (runtime.spinStartedAt) {
669
+ const elapsedS = Math.floor((Date.now() - runtime.spinStartedAt) / 1000);
670
+ if (runtime.spinToolCalls > 0) {
671
+ parts.push(`${runtime.spinToolCalls} call${runtime.spinToolCalls === 1 ? '' : 's'}`);
672
+ }
673
+ if (elapsedS >= 3) parts.push(`${elapsedS}s`);
674
+ }
675
+ return parts.join(' · ');
676
+ }
677
+
678
+ export function repaintSpinnerStatus(text = runtime.spinText, { advance = false } = {}) {
679
+ const isExploreActive = runtime.exploreRun && runtime.exploreRun.lineActive;
680
+ const label = isExploreActive ? exploreSummary() : (text || runtime.spinText);
681
+ if (!label) return;
682
+ const frame = SPIN_FRAMES[runtime.spinFrame % SPIN_FRAMES.length];
683
+ if (advance) runtime.spinFrame++;
684
+ const rendered = ` ${c.brand(frame)} ${c.dim(composeStatusLabel(label))}`;
685
+ if (!queue.isActive() && isExploreActive && isInputDockMounted()) return;
686
+ presentStatus(rendered);
687
+ }
688
+
689
+ /**
690
+ * Start (or re-label) the spinner. `phase` scopes the elapsed clock:
691
+ * a phase change resets it, same-phase updates keep it counting. Callers
692
+ * that don't pass a phase get a generic per-call reset (old behavior).
693
+ */
694
+ export function startSpinner(text, { phase = null } = {}) {
695
+ const nextPhase = phase || `generic:${text}`;
696
+ if (runtime.spinPhase !== nextPhase) {
697
+ runtime.spinPhase = nextPhase;
698
+ runtime.spinStartedAt = Date.now();
699
+ runtime.spinToolCalls = 0;
700
+ }
701
+ runtime.spinText = text;
702
+ runtime.spinFrame = 0;
703
+ if (!queue.isActive() && runtime.exploreRun && runtime.exploreRun.lineActive && isInputDockMounted()) return;
704
+ if (runtime.spinInterval) {
705
+ repaintSpinnerStatus(runtime.spinText);
706
+ return; // already running
707
+ }
708
+ runtime.spinInterval = setInterval(() => {
709
+ repaintSpinnerStatus(runtime.spinText, { advance: true });
710
+ }, 80);
711
+ repaintSpinnerStatus(runtime.spinText);
712
+ }
713
+
714
+ export function updateSpinner(text) {
715
+ runtime.spinText = text;
716
+ // Self-healing: a content flush stops the spinner (clears the
717
+ // interval), but long-running work — sub-agent runs especially —
718
+ // keeps sending updates afterwards. Without reviving the interval
719
+ // those updates write into a dead timer and the user sees a frozen
720
+ // "▸ running" with no progress at all. Same phase → clock continues.
721
+ if (!runtime.spinInterval && text) {
722
+ startSpinner(text, { phase: runtime.spinPhase || undefined });
723
+ return;
724
+ }
725
+ repaintSpinnerStatus(runtime.spinText);
726
+ }
727
+
728
+ /** Bump the per-phase progress counter (sub-agent tool calls). */
729
+ export function bumpSpinnerProgress() {
730
+ runtime.spinToolCalls++;
731
+ }
732
+
733
+ export function stopSpinner() {
734
+ // Explore owns the line while active — a stray stopSpinner from a
735
+ // transient handler must not blank the progress feedback.
736
+ // flushExploreRun() releases the lock and does the real teardown.
737
+ if (runtime.exploreRun && runtime.exploreRun.lineActive) return;
738
+ if (runtime.spinInterval) { clearInterval(runtime.spinInterval); runtime.spinInterval = null; }
739
+ runtime.spinText = '';
740
+ runtime.spinPhase = null;
741
+ runtime.spinStartedAt = 0;
742
+ runtime.spinToolCalls = 0;
743
+ erasePresentedStatus();
744
+ }
745
+
746
+ // ── Content Streaming Display ──
747
+
748
+ // (declaration moved to repl-state.mjs runtime.*)
749
+ // (declaration moved to repl-state.mjs runtime.*)
750
+ // (declaration moved to repl-state.mjs runtime.*)
751
+ // (declaration moved to repl-state.mjs runtime.*)
752
+ // (declaration moved to repl-state.mjs runtime.*)
753
+
754
+ export function startContentStream() {
755
+ runtime.streamBuffer = '';
756
+ runtime.streamedPartialText = '';
757
+ runtime.renderedToolResults.clear();
758
+ runtime.renderedFileDiffPreviews.clear();
759
+ runtime.exploreRun = { counts: {}, recent: [], lineActive: false, lastPrintedSummary: '', lastPrintedTotal: 0, lastPrintedAt: 0 };
760
+ runtime.renderedContentThisTurn = false;
761
+ runtime.contentHeaderPrinted = false;
762
+ runtime.lastRenderedBlock = null;
763
+ stopSpinner();
764
+ }
765
+
766
+ export function appendContent(text) {
767
+ if (!text) return;
768
+ // Any streamed content between renderToolCall and renderToolResult would
769
+ // scroll the head off "the line above", breaking the in-place collapse.
770
+ clearPendingHead();
771
+ runtime.streamBuffer += text;
772
+ runtime.streamedPartialText += text;
773
+
774
+ // Debounce rendering to avoid flicker on rapid partial updates
775
+ if (runtime.streamTimer) clearTimeout(runtime.streamTimer);
776
+ runtime.streamTimer = setTimeout(() => flushContent(), 50);
777
+ }
778
+
779
+ export function flushContent() {
780
+ if (runtime.streamTimer) { clearTimeout(runtime.streamTimer); runtime.streamTimer = null; }
781
+ if (!runtime.streamBuffer) return;
782
+
783
+ const rendered = renderMarkdown(runtime.streamBuffer);
784
+ const lines = transcriptRenderableLines(rendered);
785
+ runtime.streamBuffer = '';
786
+ if (!lines.length) return;
787
+
788
+ if (isInputDockMounted()) moveToContent();
789
+ stopSpinner();
790
+ // Any buffered tool head needs to land BEFORE this content so the order
791
+ // is preserved on screen.
792
+ flushPendingHead();
793
+ flushCompactReadRun();
794
+ renderBlockBoundary('content', { compactSame: true });
795
+ if (!runtime.contentHeaderPrinted) {
796
+ process.stdout.write(`${transcriptHeader('bahulam', { tone: 'assistant' })}\n`);
797
+ runtime.contentHeaderPrinted = true;
798
+ }
799
+ for (const line of lines) {
800
+ process.stdout.write(`${transcriptLine(line, { tone: 'assistant' })}\n`);
801
+ }
802
+ runtime.renderedContentThisTurn = true;
803
+ runtime.lastRenderedBlock = 'content';
804
+ if (typeof runtime.afterContentFlush === 'function') runtime.afterContentFlush();
805
+ }
806
+
807
+ export function transcriptRenderableLines(rendered) {
808
+ const lines = String(rendered ?? '').replace(/\r\n?/g, '\n').split('\n');
809
+ while (lines.length && lines[lines.length - 1] === '') lines.pop();
810
+ return lines;
811
+ }
812
+
813
+ export function renderStagnation(data = {}) {
814
+ const rawMessage = data?.message || '';
815
+ const reason = data?.reason || rawMessage.replace(/^Stagnation:\s*/i, '').trim();
816
+ const tool = data?.tool || data?.tool_name || '';
817
+ const suggestion = data?.suggestion || data?.recovery_strategy || data?.strategy || '';
818
+ const message = reason
819
+ ? `Stagnation${tool ? ` (${tool})` : ''}: ${reason}`
820
+ : `Stagnation${tool ? ` (${tool})` : ''} detected`;
821
+ const key = `${message}\n${suggestion}`;
822
+
823
+ if (session._lastStagnationWarning === key) return;
824
+ session._lastStagnationWarning = key;
825
+
826
+ stopSpinner();
827
+ flushContent();
828
+ flushPendingHead();
829
+ renderBlockBoundary('status', { compactSame: true });
830
+ process.stderr.write(` ${c.yellow('!')} ${c.yellow(message)}\n`);
831
+ if (suggestion) process.stderr.write(` ${c.dim(suggestion)}\n`);
832
+ runtime.lastRenderedBlock = 'status';
833
+ }