bonecode 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (270) hide show
  1. package/bin/bonecode +47 -42
  2. package/compat/opencode_adapter.ts +188 -17
  3. package/dist/bone/output/agent/src/algorithms.d.ts +1 -0
  4. package/dist/bone/output/agent/src/algorithms.js +3 -0
  5. package/dist/bone/output/agent/src/algorithms.js.map +1 -0
  6. package/dist/bone/output/agent/src/audit.d.ts +3 -0
  7. package/dist/bone/output/agent/src/audit.js +40 -0
  8. package/dist/bone/output/agent/src/audit.js.map +1 -0
  9. package/dist/bone/output/agent/src/auth.d.ts +8 -0
  10. package/dist/bone/output/agent/src/auth.js +56 -0
  11. package/dist/bone/output/agent/src/auth.js.map +1 -0
  12. package/dist/bone/output/agent/src/db.d.ts +6 -0
  13. package/dist/bone/output/agent/src/db.js +63 -0
  14. package/dist/bone/output/agent/src/db.js.map +1 -0
  15. package/dist/bone/output/agent/src/events.d.ts +25 -0
  16. package/dist/bone/output/agent/src/events.js +184 -0
  17. package/dist/bone/output/agent/src/events.js.map +1 -0
  18. package/dist/bone/output/agent/src/logger.d.ts +28 -0
  19. package/dist/bone/output/agent/src/logger.js +45 -0
  20. package/dist/bone/output/agent/src/logger.js.map +1 -0
  21. package/dist/bone/output/agent/src/metrics.d.ts +5 -0
  22. package/dist/bone/output/agent/src/metrics.js +60 -0
  23. package/dist/bone/output/agent/src/metrics.js.map +1 -0
  24. package/dist/bone/output/agent/src/routes/agent_instance.d.ts +1 -0
  25. package/dist/bone/output/agent/src/routes/agent_instance.js +253 -0
  26. package/dist/bone/output/agent/src/routes/agent_instance.js.map +1 -0
  27. package/dist/bone/output/agent/src/routes/build_step.d.ts +1 -0
  28. package/dist/bone/output/agent/src/routes/build_step.js +133 -0
  29. package/dist/bone/output/agent/src/routes/build_step.js.map +1 -0
  30. package/dist/bone/output/agent/src/routes/plan.d.ts +1 -0
  31. package/dist/bone/output/agent/src/routes/plan.js +119 -0
  32. package/dist/bone/output/agent/src/routes/plan.js.map +1 -0
  33. package/dist/bone/output/agent/src/routes/task.d.ts +1 -0
  34. package/dist/bone/output/agent/src/routes/task.js +133 -0
  35. package/dist/bone/output/agent/src/routes/task.js.map +1 -0
  36. package/dist/bone/output/agent/src/routes/tool_call.d.ts +1 -0
  37. package/dist/bone/output/agent/src/routes/tool_call.js +190 -0
  38. package/dist/bone/output/agent/src/routes/tool_call.js.map +1 -0
  39. package/dist/bone/output/agent/src/state_machines/agent_instance.d.ts +9 -0
  40. package/dist/bone/output/agent/src/state_machines/agent_instance.js +22 -0
  41. package/dist/bone/output/agent/src/state_machines/agent_instance.js.map +1 -0
  42. package/dist/bone/output/agent/src/state_machines/build_step.d.ts +9 -0
  43. package/dist/bone/output/agent/src/state_machines/build_step.js +20 -0
  44. package/dist/bone/output/agent/src/state_machines/build_step.js.map +1 -0
  45. package/dist/bone/output/agent/src/state_machines/plan.d.ts +9 -0
  46. package/dist/bone/output/agent/src/state_machines/plan.js +20 -0
  47. package/dist/bone/output/agent/src/state_machines/plan.js.map +1 -0
  48. package/dist/bone/output/agent/src/state_machines/task.d.ts +9 -0
  49. package/dist/bone/output/agent/src/state_machines/task.js +20 -0
  50. package/dist/bone/output/agent/src/state_machines/task.js.map +1 -0
  51. package/dist/bone/output/agent/src/state_machines/tool_call.d.ts +9 -0
  52. package/dist/bone/output/agent/src/state_machines/tool_call.js +20 -0
  53. package/dist/bone/output/agent/src/state_machines/tool_call.js.map +1 -0
  54. package/dist/bone/output/rag/src/algorithms.d.ts +1 -0
  55. package/dist/bone/output/rag/src/algorithms.js +3 -0
  56. package/dist/bone/output/rag/src/algorithms.js.map +1 -0
  57. package/dist/bone/output/rag/src/auth.d.ts +8 -0
  58. package/dist/bone/output/rag/src/auth.js +56 -0
  59. package/dist/bone/output/rag/src/auth.js.map +1 -0
  60. package/dist/bone/output/rag/src/db.d.ts +6 -0
  61. package/dist/bone/output/rag/src/db.js +63 -0
  62. package/dist/bone/output/rag/src/db.js.map +1 -0
  63. package/dist/bone/output/rag/src/events.d.ts +25 -0
  64. package/dist/bone/output/rag/src/events.js +184 -0
  65. package/dist/bone/output/rag/src/events.js.map +1 -0
  66. package/dist/bone/output/rag/src/extensions.d.ts +83 -0
  67. package/dist/bone/output/rag/src/extensions.js +329 -0
  68. package/dist/bone/output/rag/src/extensions.js.map +1 -0
  69. package/dist/bone/output/rag/src/flows.d.ts +24 -0
  70. package/dist/bone/output/rag/src/flows.js +236 -0
  71. package/dist/bone/output/rag/src/flows.js.map +1 -0
  72. package/dist/bone/output/rag/src/logger.d.ts +28 -0
  73. package/dist/bone/output/rag/src/logger.js +45 -0
  74. package/dist/bone/output/rag/src/logger.js.map +1 -0
  75. package/dist/bone/output/rag/src/metrics.d.ts +5 -0
  76. package/dist/bone/output/rag/src/metrics.js +60 -0
  77. package/dist/bone/output/rag/src/metrics.js.map +1 -0
  78. package/dist/bone/output/rag/src/routes/code_chunk.d.ts +1 -0
  79. package/dist/bone/output/rag/src/routes/code_chunk.js +100 -0
  80. package/dist/bone/output/rag/src/routes/code_chunk.js.map +1 -0
  81. package/dist/bone/output/rag/src/routes/code_file.d.ts +1 -0
  82. package/dist/bone/output/rag/src/routes/code_file.js +127 -0
  83. package/dist/bone/output/rag/src/routes/code_file.js.map +1 -0
  84. package/dist/bone/output/rag/src/routes/indexing_job.d.ts +1 -0
  85. package/dist/bone/output/rag/src/routes/indexing_job.js +113 -0
  86. package/dist/bone/output/rag/src/routes/indexing_job.js.map +1 -0
  87. package/dist/bone/output/rag/src/routes/knowledge_base.d.ts +1 -0
  88. package/dist/bone/output/rag/src/routes/knowledge_base.js +242 -0
  89. package/dist/bone/output/rag/src/routes/knowledge_base.js.map +1 -0
  90. package/dist/bone/output/rag/src/routes/memory_entry.d.ts +1 -0
  91. package/dist/bone/output/rag/src/routes/memory_entry.js +113 -0
  92. package/dist/bone/output/rag/src/routes/memory_entry.js.map +1 -0
  93. package/dist/bone/output/rag/src/state_machines/code_file.d.ts +9 -0
  94. package/dist/bone/output/rag/src/state_machines/code_file.js +21 -0
  95. package/dist/bone/output/rag/src/state_machines/code_file.js.map +1 -0
  96. package/dist/bone/output/rag/src/state_machines/indexing_job.d.ts +9 -0
  97. package/dist/bone/output/rag/src/state_machines/indexing_job.js +20 -0
  98. package/dist/bone/output/rag/src/state_machines/indexing_job.js.map +1 -0
  99. package/dist/bone/output/rag/src/state_machines/knowledge_base.d.ts +9 -0
  100. package/dist/bone/output/rag/src/state_machines/knowledge_base.js +21 -0
  101. package/dist/bone/output/rag/src/state_machines/knowledge_base.js.map +1 -0
  102. package/dist/bone/output/rag/src/state_machines/memory_entry.d.ts +9 -0
  103. package/dist/bone/output/rag/src/state_machines/memory_entry.js +18 -0
  104. package/dist/bone/output/rag/src/state_machines/memory_entry.js.map +1 -0
  105. package/dist/bone/output/session/src/algorithms.d.ts +1 -0
  106. package/dist/bone/output/session/src/algorithms.js +3 -0
  107. package/dist/bone/output/session/src/algorithms.js.map +1 -0
  108. package/dist/bone/output/session/src/audit.d.ts +3 -0
  109. package/dist/bone/output/session/src/audit.js +40 -0
  110. package/dist/bone/output/session/src/audit.js.map +1 -0
  111. package/dist/bone/output/session/src/auth.d.ts +8 -0
  112. package/dist/bone/output/session/src/auth.js +56 -0
  113. package/dist/bone/output/session/src/auth.js.map +1 -0
  114. package/dist/bone/output/session/src/db.d.ts +6 -0
  115. package/dist/bone/output/session/src/db.js +63 -0
  116. package/dist/bone/output/session/src/db.js.map +1 -0
  117. package/dist/bone/output/session/src/events.d.ts +26 -0
  118. package/dist/bone/output/session/src/events.js +212 -0
  119. package/dist/bone/output/session/src/events.js.map +1 -0
  120. package/dist/bone/output/session/src/extensions.d.ts +41 -0
  121. package/dist/bone/output/session/src/extensions.js +217 -0
  122. package/dist/bone/output/session/src/extensions.js.map +1 -0
  123. package/dist/bone/output/session/src/logger.d.ts +28 -0
  124. package/dist/bone/output/session/src/logger.js +44 -0
  125. package/dist/bone/output/session/src/logger.js.map +1 -0
  126. package/dist/bone/output/session/src/metrics.d.ts +5 -0
  127. package/dist/bone/output/session/src/metrics.js +60 -0
  128. package/dist/bone/output/session/src/metrics.js.map +1 -0
  129. package/dist/bone/output/session/src/routes/message.d.ts +1 -0
  130. package/dist/bone/output/session/src/routes/message.js +120 -0
  131. package/dist/bone/output/session/src/routes/message.js.map +1 -0
  132. package/dist/bone/output/session/src/routes/part.d.ts +1 -0
  133. package/dist/bone/output/session/src/routes/part.js +106 -0
  134. package/dist/bone/output/session/src/routes/part.js.map +1 -0
  135. package/dist/bone/output/session/src/routes/permission.d.ts +1 -0
  136. package/dist/bone/output/session/src/routes/permission.js +106 -0
  137. package/dist/bone/output/session/src/routes/permission.js.map +1 -0
  138. package/dist/bone/output/session/src/routes/project.d.ts +1 -0
  139. package/dist/bone/output/session/src/routes/project.js +106 -0
  140. package/dist/bone/output/session/src/routes/project.js.map +1 -0
  141. package/dist/bone/output/session/src/routes/session.d.ts +1 -0
  142. package/dist/bone/output/session/src/routes/session.js +308 -0
  143. package/dist/bone/output/session/src/routes/session.js.map +1 -0
  144. package/dist/bone/output/session/src/state_machines/session.d.ts +9 -0
  145. package/dist/bone/output/session/src/state_machines/session.js +21 -0
  146. package/dist/bone/output/session/src/state_machines/session.js.map +1 -0
  147. package/dist/bone/output/session/src/websocket.d.ts +15 -0
  148. package/dist/bone/output/session/src/websocket.js +215 -0
  149. package/dist/bone/output/session/src/websocket.js.map +1 -0
  150. package/dist/bone/output/workspace/src/algorithms.d.ts +1 -0
  151. package/dist/bone/output/workspace/src/algorithms.js +3 -0
  152. package/dist/bone/output/workspace/src/algorithms.js.map +1 -0
  153. package/dist/bone/output/workspace/src/auth.d.ts +8 -0
  154. package/dist/bone/output/workspace/src/auth.js +56 -0
  155. package/dist/bone/output/workspace/src/auth.js.map +1 -0
  156. package/dist/bone/output/workspace/src/db.d.ts +6 -0
  157. package/dist/bone/output/workspace/src/db.js +63 -0
  158. package/dist/bone/output/workspace/src/db.js.map +1 -0
  159. package/dist/bone/output/workspace/src/events.d.ts +25 -0
  160. package/dist/bone/output/workspace/src/events.js +184 -0
  161. package/dist/bone/output/workspace/src/events.js.map +1 -0
  162. package/dist/bone/output/workspace/src/logger.d.ts +28 -0
  163. package/dist/bone/output/workspace/src/logger.js +45 -0
  164. package/dist/bone/output/workspace/src/logger.js.map +1 -0
  165. package/dist/bone/output/workspace/src/metrics.d.ts +5 -0
  166. package/dist/bone/output/workspace/src/metrics.js +60 -0
  167. package/dist/bone/output/workspace/src/metrics.js.map +1 -0
  168. package/dist/bone/output/workspace/src/routes/codebase.d.ts +1 -0
  169. package/dist/bone/output/workspace/src/routes/codebase.js +113 -0
  170. package/dist/bone/output/workspace/src/routes/codebase.js.map +1 -0
  171. package/dist/bone/output/workspace/src/routes/snapshot.d.ts +1 -0
  172. package/dist/bone/output/workspace/src/routes/snapshot.js +151 -0
  173. package/dist/bone/output/workspace/src/routes/snapshot.js.map +1 -0
  174. package/dist/bone/output/workspace/src/routes/workspace.d.ts +1 -0
  175. package/dist/bone/output/workspace/src/routes/workspace.js +209 -0
  176. package/dist/bone/output/workspace/src/routes/workspace.js.map +1 -0
  177. package/dist/bone/output/workspace/src/state_machines/codebase.d.ts +9 -0
  178. package/dist/bone/output/workspace/src/state_machines/codebase.js +19 -0
  179. package/dist/bone/output/workspace/src/state_machines/codebase.js.map +1 -0
  180. package/dist/bone/output/workspace/src/state_machines/snapshot.d.ts +9 -0
  181. package/dist/bone/output/workspace/src/state_machines/snapshot.js +18 -0
  182. package/dist/bone/output/workspace/src/state_machines/snapshot.js.map +1 -0
  183. package/dist/bone/output/workspace/src/state_machines/workspace.d.ts +9 -0
  184. package/dist/bone/output/workspace/src/state_machines/workspace.js +19 -0
  185. package/dist/bone/output/workspace/src/state_machines/workspace.js.map +1 -0
  186. package/dist/compat/opencode_adapter.d.ts +25 -0
  187. package/dist/compat/opencode_adapter.js +599 -0
  188. package/dist/compat/opencode_adapter.js.map +1 -0
  189. package/dist/extensions/chunker.d.ts +24 -0
  190. package/dist/extensions/chunker.js +360 -0
  191. package/dist/extensions/chunker.js.map +1 -0
  192. package/dist/extensions/embedding_provider.d.ts +18 -0
  193. package/dist/extensions/embedding_provider.js +150 -0
  194. package/dist/extensions/embedding_provider.js.map +1 -0
  195. package/dist/extensions/llm_provider.d.ts +33 -0
  196. package/dist/extensions/llm_provider.js +338 -0
  197. package/dist/extensions/llm_provider.js.map +1 -0
  198. package/dist/extensions/mcp_bridge.d.ts +44 -0
  199. package/dist/extensions/mcp_bridge.js +151 -0
  200. package/dist/extensions/mcp_bridge.js.map +1 -0
  201. package/dist/extensions/rag_search.d.ts +38 -0
  202. package/dist/extensions/rag_search.js +242 -0
  203. package/dist/extensions/rag_search.js.map +1 -0
  204. package/dist/extensions/snapshot.d.ts +14 -0
  205. package/dist/extensions/snapshot.js +158 -0
  206. package/dist/extensions/snapshot.js.map +1 -0
  207. package/dist/extensions/tool_executor.d.ts +28 -0
  208. package/dist/extensions/tool_executor.js +268 -0
  209. package/dist/extensions/tool_executor.js.map +1 -0
  210. package/dist/src/cli.d.ts +15 -0
  211. package/dist/src/cli.js +687 -0
  212. package/dist/src/cli.js.map +1 -0
  213. package/dist/src/config.d.ts +44 -0
  214. package/dist/src/config.js +165 -0
  215. package/dist/src/config.js.map +1 -0
  216. package/dist/src/context_builder.d.ts +51 -0
  217. package/dist/src/context_builder.js +558 -0
  218. package/dist/src/context_builder.js.map +1 -0
  219. package/dist/src/db_adapter.d.ts +24 -0
  220. package/dist/src/db_adapter.js +341 -0
  221. package/dist/src/db_adapter.js.map +1 -0
  222. package/dist/src/engine/session/compaction_logic.d.ts +11 -0
  223. package/dist/src/engine/session/compaction_logic.js +113 -0
  224. package/dist/src/engine/session/compaction_logic.js.map +1 -0
  225. package/dist/src/engine/session/instruction_loader.d.ts +5 -0
  226. package/dist/src/engine/session/instruction_loader.js +78 -0
  227. package/dist/src/engine/session/instruction_loader.js.map +1 -0
  228. package/dist/src/engine/session/overflow_check.d.ts +14 -0
  229. package/dist/src/engine/session/overflow_check.js +45 -0
  230. package/dist/src/engine/session/overflow_check.js.map +1 -0
  231. package/dist/src/engine/session/prompt.d.ts +45 -0
  232. package/dist/src/engine/session/prompt.js +584 -0
  233. package/dist/src/engine/session/prompt.js.map +1 -0
  234. package/dist/src/engine/session/provider_transform.d.ts +59 -0
  235. package/dist/src/engine/session/provider_transform.js +193 -0
  236. package/dist/src/engine/session/provider_transform.js.map +1 -0
  237. package/dist/src/engine/session/retry_logic.d.ts +12 -0
  238. package/dist/src/engine/session/retry_logic.js +72 -0
  239. package/dist/src/engine/session/retry_logic.js.map +1 -0
  240. package/dist/src/engine/session/system_prompt.d.ts +9 -0
  241. package/dist/src/engine/session/system_prompt.js +96 -0
  242. package/dist/src/engine/session/system_prompt.js.map +1 -0
  243. package/dist/src/engine/session/tool_registry.d.ts +5 -0
  244. package/dist/src/engine/session/tool_registry.js +117 -0
  245. package/dist/src/engine/session/tool_registry.js.map +1 -0
  246. package/dist/src/export.d.ts +13 -0
  247. package/dist/src/export.js +103 -0
  248. package/dist/src/export.js.map +1 -0
  249. package/dist/src/mdns.d.ts +7 -0
  250. package/dist/src/mdns.js +60 -0
  251. package/dist/src/mdns.js.map +1 -0
  252. package/dist/src/rag_worker.d.ts +38 -0
  253. package/dist/src/rag_worker.js +435 -0
  254. package/dist/src/rag_worker.js.map +1 -0
  255. package/dist/src/server.d.ts +11 -0
  256. package/dist/src/server.js +214 -0
  257. package/dist/src/server.js.map +1 -0
  258. package/dist/src/stats.d.ts +45 -0
  259. package/dist/src/stats.js +233 -0
  260. package/dist/src/stats.js.map +1 -0
  261. package/dist/src/tui.d.ts +29 -0
  262. package/dist/src/tui.js +1053 -0
  263. package/dist/src/tui.js.map +1 -0
  264. package/package.json +7 -4
  265. package/src/cli.ts +247 -5
  266. package/src/export.ts +122 -0
  267. package/src/mdns.ts +53 -0
  268. package/src/server.ts +32 -0
  269. package/src/stats.ts +290 -0
  270. package/src/tui.ts +749 -248
package/src/tui.ts CHANGED
@@ -1,19 +1,16 @@
1
- /**
2
- * BoneCode TUI — styled after OpenCode's terminal interface
1
+ /**
2
+ * BoneCode TUI — terminal interface modeled after OpenCode
3
+ *
4
+ * Three core behaviours:
5
+ * 1. Tool activity is displayed as concise status lines
6
+ * (← Edit src/foo.ts, → Read package.json, $ npm test) — never raw code dumps.
7
+ * Assistant text is shown inline; tool calls and tool outputs are summarized.
8
+ *
9
+ * 2. Typing "/" shows an inline command menu above the prompt with
10
+ * arrow-key selection and tab/enter to insert.
3
11
  *
4
- * Layout:
5
- * ┌─────────────────────────────────────────────────┐
6
- * │ [message history — scrolls up] │
7
- * │ ┃ User message (left border, panel bg) │
8
- * │ ← tool $ shell → read ✱ glob │
9
- * │ response text │
10
- * │ ▣ Build · model · 2.1s │
11
- * ├─────────────────────────────────────────────────┤
12
- * │ ┃ [input textarea] │
13
- * │ ┃ Build · model │
14
- * │ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │
15
- * │ ~/project/dir esc interrupt │
16
- * └─────────────────────────────────────────────────┘
12
+ * 3. Ctrl+C aborts the in-flight LLM stream by aborting the fetch
13
+ * AND notifying the server to cancel the agent loop.
17
14
  */
18
15
 
19
16
  import * as path from "path";
@@ -24,37 +21,36 @@ import * as http from "http";
24
21
  // ─── ANSI ─────────────────────────────────────────────────────────────────────
25
22
 
26
23
  const ESC = "\x1b";
27
- const R = `${ESC}[0m`; // reset
28
- const BOLD = `${ESC}[1m`;
29
- const DIM = `${ESC}[2m`;
30
- const CYAN = `${ESC}[96m`; // bright cyan — primary accent
31
- const GREEN= `${ESC}[92m`; // bright green — success
32
- const YELLOW=`${ESC}[93m`; // warning
33
- const RED = `${ESC}[91m`; // error
34
- const GRAY = `${ESC}[90m`; // textMuted
35
- const WHITE= `${ESC}[97m`; // text
36
- const BG_PANEL = `${ESC}[48;5;235m`; // backgroundPanel
37
- const BG_ELEM = `${ESC}[48;5;237m`; // backgroundElement (input area)
38
-
39
- // Box-drawing — OpenCode uses exactly these
40
- const VERT = ""; // U+2503 heavy vertical — left border on messages/prompt
41
- const CORN = ""; // U+2579 heavy up — bottom-left corner of prompt box
42
- const SHADE = ""; // upper half block — decorative shadow line under prompt
43
- const BLOCK = ""; // U+25A3 — end-of-turn marker
44
- const ARROW_R = ""; // read tool
45
- const ARROW_L = ""; // write/edit tool
46
- const DOLLAR = "$"; // shell tool
47
- const STAR = ""; // glob/grep tool
48
- const PCT = "%"; // webfetch tool
49
- const GEAR = ""; // generic tool
50
- const THINK = "▶"; // collapsed thinking
51
-
52
- function cols() { return process.stdout.columns || 80; }
24
+ const R = `${ESC}[0m`;
25
+ const BOLD = `${ESC}[1m`;
26
+ const DIM = `${ESC}[2m`;
27
+ const CYAN = `${ESC}[96m`;
28
+ const GREEN = `${ESC}[92m`;
29
+ const YELLOW= `${ESC}[93m`;
30
+ const RED = `${ESC}[91m`;
31
+ const GRAY = `${ESC}[90m`;
32
+ const WHITE = `${ESC}[97m`;
33
+ const BLUE = `${ESC}[94m`;
34
+
35
+ // Box drawing
36
+ const VERT = "┃";
37
+ const CORN = "";
38
+ const SHADE = "";
39
+ const BLOCK = "";
40
+ const ARROW_R = "";
41
+ const ARROW_L = "";
42
+ const DOLLAR = "$";
43
+ const STAR = "";
44
+ const PCT = "%";
45
+ const GEAR = "";
46
+ const HASH = "#";
47
+
48
+ function cols(): number { return process.stdout.columns || 80; }
53
49
  function out(s: string) { process.stdout.write(s); }
54
50
  function nl(s = "") { process.stdout.write(s + "\n"); }
51
+ function clearLine() { out(`\r${ESC}[2K`); }
55
52
 
56
53
  // ─── Logo ─────────────────────────────────────────────────────────────────────
57
- // "BONECODE" in block characters
58
54
 
59
55
  function printLogo() {
60
56
  nl();
@@ -64,16 +60,42 @@ function printLogo() {
64
60
  nl();
65
61
  }
66
62
 
67
- // ─── Config ───────────────────────────────────────────────────────────────────
63
+ // ─── Package version ──────────────────────────────────────────────────────────
68
64
 
69
- const PKG_ROOT = path.resolve(__dirname, "..");
65
+ const PKG_ROOT = (() => {
66
+ const fromSrc = path.resolve(__dirname, "..");
67
+ const fromDist = path.resolve(__dirname, "..", "..");
68
+ if (fs.existsSync(path.join(fromDist, "package.json"))) return fromDist;
69
+ return fromSrc;
70
+ })();
70
71
 
71
72
  function getVersion(): string {
72
73
  try { return JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf-8")).version; }
73
74
  catch { return "0.0.0"; }
74
75
  }
75
76
 
76
- // ─── @file autocomplete ───────────────────────────────────────────────────────
77
+ // ─── Slash command catalog ────────────────────────────────────────────────────
78
+
79
+ interface Command {
80
+ name: string;
81
+ description: string;
82
+ args?: string;
83
+ }
84
+
85
+ const COMMANDS: Command[] = [
86
+ { name: "/new", description: "Start a new session" },
87
+ { name: "/session", description: "Show current session ID" },
88
+ { name: "/sessions", description: "List recent sessions" },
89
+ { name: "/model", description: "Switch model", args: "<provider/model>" },
90
+ { name: "/provider", description: "Switch provider", args: "<id>" },
91
+ { name: "/providers", description: "List all providers" },
92
+ { name: "/clear", description: "Clear screen" },
93
+ { name: "/history", description: "Show last 10 prompts" },
94
+ { name: "/help", description: "Show this help" },
95
+ { name: "/exit", description: "Exit BoneCode" },
96
+ ];
97
+
98
+ // ─── @file autocomplete helpers ───────────────────────────────────────────────
77
99
 
78
100
  const IGNORED_DIRS = new Set([
79
101
  "node_modules", ".git", "dist", "build", ".next", "__pycache__",
@@ -109,29 +131,19 @@ function listFiles(worktree: string, prefix: string): string[] {
109
131
  return results.sort();
110
132
  }
111
133
 
112
- // ─── Completers ───────────────────────────────────────────────────────────────
113
-
114
- const COMMANDS = [
115
- "/new", "/session", "/model", "/provider", "/providers",
116
- "/clear", "/history", "/help", "/exit", "/quit",
117
- ];
118
-
119
134
  function buildCompleter(worktree: string) {
120
135
  return (line: string): [string[], string] => {
121
- // /command autocomplete
122
136
  if (line.startsWith("/")) {
123
- const matches = COMMANDS.filter(c => c.startsWith(line));
137
+ // Tab-complete commands too (in addition to the inline menu)
138
+ const matches = COMMANDS.map(c => c.name).filter(c => c.startsWith(line));
124
139
  return [matches, line];
125
140
  }
126
-
127
- // @file autocomplete
128
141
  const atIdx = line.lastIndexOf("@");
129
142
  if (atIdx !== -1) {
130
143
  const prefix = line.slice(atIdx + 1);
131
144
  const completions = listFiles(worktree, prefix).map(f => line.slice(0, atIdx + 1) + f);
132
145
  return [completions, line];
133
146
  }
134
-
135
147
  return [[], line];
136
148
  };
137
149
  }
@@ -156,6 +168,12 @@ async function waitForServer(port: number, maxMs = 30_000): Promise<boolean> {
156
168
 
157
169
  // ─── API ──────────────────────────────────────────────────────────────────────
158
170
 
171
+ async function apiGet<T>(url: string, token: string): Promise<T> {
172
+ const r = await fetch(url, { headers: { "Authorization": `Bearer ${token}` } });
173
+ if (!r.ok) throw new Error(`API ${r.status}`);
174
+ return r.json() as Promise<T>;
175
+ }
176
+
159
177
  async function apiPost(url: string, body: object, token: string): Promise<Response> {
160
178
  return fetch(url, {
161
179
  method: "POST",
@@ -164,10 +182,8 @@ async function apiPost(url: string, body: object, token: string): Promise<Respon
164
182
  });
165
183
  }
166
184
 
167
- async function apiGet<T>(url: string, token: string): Promise<T> {
168
- const r = await fetch(url, { headers: { "Authorization": `Bearer ${token}` } });
169
- if (!r.ok) throw new Error(`API ${r.status}`);
170
- return r.json() as Promise<T>;
185
+ async function apiDelete(url: string, token: string): Promise<void> {
186
+ await fetch(url, { method: "DELETE", headers: { "Authorization": `Bearer ${token}` } });
171
187
  }
172
188
 
173
189
  async function createSession(port: number, token: string, worktree: string, title: string): Promise<string> {
@@ -178,6 +194,172 @@ async function createSession(port: number, token: string, worktree: string, titl
178
194
  return sess.id;
179
195
  }
180
196
 
197
+ // ─── Tool display rules — modeled after opencode's tool.ts ────────────────────
198
+
199
+ interface ToolDisplay {
200
+ icon: string;
201
+ color: string;
202
+ title: string;
203
+ detail?: string;
204
+ }
205
+
206
+ function relPath(p: string | undefined, worktree: string): string {
207
+ if (!p) return "";
208
+ try {
209
+ if (path.isAbsolute(p)) {
210
+ const rel = path.relative(worktree, p);
211
+ if (!rel.startsWith("..")) return rel.replace(/\\/g, "/");
212
+ }
213
+ return p.replace(/\\/g, "/");
214
+ } catch { return p; }
215
+ }
216
+
217
+ function describeTool(toolName: string, input: any, worktree: string): ToolDisplay {
218
+ const n = (toolName || "").toLowerCase();
219
+ const inp = input || {};
220
+
221
+ // Map BoneCode's tool names AND opencode's names
222
+ const isWrite = n === "write" || n === "write_file";
223
+ const isEdit = n === "edit" || n === "edit_file";
224
+ const isRead = n === "read" || n === "read_file";
225
+ const isShell = n === "bash" || n === "shell" || n === "run_command";
226
+ const isGlob = n === "glob";
227
+ const isGrep = n === "grep" || n === "search_files";
228
+ const isList = n === "list_directory" || n === "list" || n === "ls";
229
+ const isWebfetch = n === "webfetch";
230
+ const isWebsearch = n === "websearch";
231
+ const isPatch = n === "apply_patch" || n === "patch";
232
+ const isTodo = n === "todo_write" || n === "todowrite" || n === "todo";
233
+ const isTask = n === "task" || n === "subagent";
234
+
235
+ if (isWrite) {
236
+ return { icon: ARROW_L, color: CYAN, title: "Write", detail: relPath(inp.path || inp.filePath, worktree) };
237
+ }
238
+ if (isEdit) {
239
+ return { icon: ARROW_L, color: CYAN, title: "Edit", detail: relPath(inp.path || inp.filePath, worktree) };
240
+ }
241
+ if (isPatch) {
242
+ return { icon: PCT, color: CYAN, title: "Patch", detail: inp.patch ? `${String(inp.patch).split("\n").length} lines` : "" };
243
+ }
244
+ if (isRead) {
245
+ let detail = relPath(inp.path || inp.filePath, worktree);
246
+ if (inp.start_line || inp.end_line) {
247
+ detail += ` ${GRAY}[${inp.start_line || 1}-${inp.end_line || ""}]${R}`;
248
+ }
249
+ return { icon: ARROW_R, color: GRAY, title: "Read", detail };
250
+ }
251
+ if (isShell) {
252
+ const cmd = String(inp.command || "").trim();
253
+ const short = cmd.length > 60 ? cmd.slice(0, 57) + "..." : cmd;
254
+ return { icon: DOLLAR, color: YELLOW, title: short || "Shell" };
255
+ }
256
+ if (isGlob) {
257
+ return { icon: STAR, color: GRAY, title: "Glob", detail: inp.pattern || "" };
258
+ }
259
+ if (isGrep) {
260
+ const pattern = inp.pattern || "";
261
+ const glob = inp.glob ? ` in ${inp.glob}` : "";
262
+ return { icon: STAR, color: GRAY, title: "Grep", detail: `"${pattern}"${glob}` };
263
+ }
264
+ if (isList) {
265
+ return { icon: ARROW_R, color: GRAY, title: "List", detail: relPath(inp.path, worktree) };
266
+ }
267
+ if (isWebfetch) {
268
+ return { icon: PCT, color: BLUE, title: "WebFetch", detail: inp.url || "" };
269
+ }
270
+ if (isWebsearch) {
271
+ return { icon: PCT, color: BLUE, title: "WebSearch", detail: inp.query ? `"${inp.query}"` : "" };
272
+ }
273
+ if (isTodo) {
274
+ const todos = Array.isArray(inp.todos) ? inp.todos : [];
275
+ const done = todos.filter((t: any) => t.status === "completed").length;
276
+ return { icon: HASH, color: GRAY, title: "Todos", detail: `${done}/${todos.length} done` };
277
+ }
278
+ if (isTask) {
279
+ return { icon: HASH, color: CYAN, title: "Task", detail: inp.description || "" };
280
+ }
281
+
282
+ // Fallback
283
+ return { icon: GEAR, color: GRAY, title: toolName };
284
+ }
285
+
286
+ function renderToolStart(d: ToolDisplay) {
287
+ const detail = d.detail ? ` ${GRAY}${d.detail}${R}` : "";
288
+ nl(` ${d.color}${d.icon}${R} ${WHITE}${d.title}${R}${detail}`);
289
+ }
290
+
291
+ function renderToolDone(d: ToolDisplay, ms?: number) {
292
+ const detail = d.detail ? ` ${GRAY}${d.detail}${R}` : "";
293
+ const time = ms !== undefined ? ` ${GRAY}${(ms / 1000).toFixed(1)}s${R}` : "";
294
+ // Replace the "..." with a checkmark (or just write a new line if not interactive)
295
+ nl(` ${GREEN}✓${R} ${GRAY}${d.title}${R}${detail}${time}`);
296
+ }
297
+
298
+ function renderToolError(d: ToolDisplay, err: string) {
299
+ const detail = d.detail ? ` ${GRAY}${d.detail}${R}` : "";
300
+ nl(` ${RED}✗${R} ${GRAY}${d.title}${R}${detail} ${RED}${err}${R}`);
301
+ }
302
+
303
+ // ─── User message rendering ───────────────────────────────────────────────────
304
+
305
+ function renderUserMessage(text: string) {
306
+ nl();
307
+ for (const line of text.split("\n")) {
308
+ nl(`${GRAY}${VERT}${R} ${WHITE}${line}${R}`);
309
+ }
310
+ nl();
311
+ }
312
+
313
+ function renderTurnEnd(model: string, elapsedMs: number, interrupted: boolean) {
314
+ const elapsed = (elapsedMs / 1000).toFixed(1);
315
+ const dur = interrupted ? `${YELLOW}interrupted${R}` : `${GRAY}${elapsed}s${R}`;
316
+ nl();
317
+ nl(` ${CYAN}${BLOCK}${R} ${WHITE}Build${R}${GRAY} · ${model} · ${R}${dur}`);
318
+ }
319
+
320
+ // ─── Help / providers / commands menu ─────────────────────────────────────────
321
+
322
+ function printHelp() {
323
+ nl();
324
+ nl(`${CYAN}${BOLD}BoneCode${R} ${GRAY}commands${R}`);
325
+ nl();
326
+ for (const c of COMMANDS) {
327
+ const args = c.args ? ` ${GRAY}${c.args}${R}` : "";
328
+ nl(` ${CYAN}${c.name.padEnd(12)}${R}${args.padEnd(20)} ${GRAY}${c.description}${R}`);
329
+ }
330
+ nl();
331
+ nl(` ${CYAN}Ctrl+C${R} ${GRAY}Interrupt current request${R}`);
332
+ nl(` ${CYAN}Ctrl+D${R} ${GRAY}Exit BoneCode${R}`);
333
+ nl(` ${CYAN}↑ / ↓${R} ${GRAY}Prompt history${R}`);
334
+ nl(` ${CYAN}@<path>${R} ${GRAY}Attach a file (Tab completes)${R}`);
335
+ nl();
336
+ }
337
+
338
+ async function fetchProviders(port: number, token: string): Promise<any[]> {
339
+ try { return await apiGet<any[]>(`http://localhost:${port}/v2/provider`, token); }
340
+ catch { return []; }
341
+ }
342
+
343
+ function printProviders(providers: any[], currentProvider: string) {
344
+ const width = cols();
345
+ nl();
346
+ nl(`${CYAN}${BOLD}Providers${R}`);
347
+ nl(`${GRAY}${"─".repeat(Math.min(width, 56))}${R}`);
348
+ for (const p of providers) {
349
+ const active = p.id === currentProvider;
350
+ const dot = active ? `${GREEN}●${R}` : `${GRAY}○${R}`;
351
+ const free = p.free ? ` ${GREEN}free${R}` : "";
352
+ const local = p.id === "local" ? ` ${CYAN}local config${R}` : "";
353
+ nl(` ${dot} ${active ? CYAN + BOLD : WHITE}${p.id}${R}${free}${local} ${GRAY}${p.name}${R}`);
354
+ if (p.freeNote) nl(` ${GRAY}${p.freeNote}${R}`);
355
+ if (p.models?.length) nl(` ${GRAY}${p.models.slice(0, 3).join(" ")}${R}`);
356
+ if (p.keyEnv) nl(` ${GRAY}${p.keyEnv}${R}`);
357
+ }
358
+ nl(`${GRAY}${"─".repeat(Math.min(width, 56))}${R}`);
359
+ nl(` ${GRAY}/provider <id> /model <id>${R}`);
360
+ nl();
361
+ }
362
+
181
363
  // ─── Streaming ────────────────────────────────────────────────────────────────
182
364
 
183
365
  interface StreamResult {
@@ -188,15 +370,155 @@ interface StreamResult {
188
370
  interrupted: boolean;
189
371
  }
190
372
 
373
+ interface ToolTrack {
374
+ callId: string;
375
+ display: ToolDisplay;
376
+ startedAt: number;
377
+ }
378
+
379
+ /**
380
+ * Stateful code-fence collapser.
381
+ *
382
+ * The LLM streams text token-by-token, including ```python\n...\n``` blocks.
383
+ * We don't want to dump that raw to the terminal because:
384
+ * (a) The code is going to be saved via a tool call anyway, so showing it
385
+ * twice (raw stream + saved file path) is redundant.
386
+ * (b) It can be 100s of lines long and overwhelms the TUI.
387
+ *
388
+ * Strategy: buffer everything between ``` and ```. When the closing fence
389
+ * arrives, emit a one-line marker like [code: python, 42 lines]
390
+ * instead of the buffered content.
391
+ *
392
+ * State machine:
393
+ * "text" — pass deltas through verbatim (with partial-fence detection)
394
+ * "fence" — buffer everything; emit marker on close fence
395
+ */
396
+ function makeCodeFenceCollapser() {
397
+ let mode: "text" | "fence" = "text";
398
+ let lang = "";
399
+ let buffered = ""; // bytes accumulated inside a fence
400
+ let pending = ""; // partial-fence buffer (when we see backticks but don't know yet)
401
+
402
+ const lines = (s: string) => s.split("\n").length;
403
+
404
+ function flushPending(): string {
405
+ const out = pending;
406
+ pending = "";
407
+ return out;
408
+ }
409
+
410
+ return {
411
+ feed(chunk: string): string {
412
+ let result = "";
413
+ let i = 0;
414
+ const buf = pending + chunk;
415
+ pending = "";
416
+
417
+ while (i < buf.length) {
418
+ if (mode === "text") {
419
+ // Look for ``` to enter fence mode
420
+ const fenceIdx = buf.indexOf("```", i);
421
+ if (fenceIdx === -1) {
422
+ // No fence in remaining text — emit it all, but hold the last 2 chars
423
+ // in case they're part of an upcoming fence.
424
+ const safeEnd = Math.max(i, buf.length - 2);
425
+ result += buf.slice(i, safeEnd);
426
+ pending = buf.slice(safeEnd);
427
+ i = buf.length;
428
+ break;
429
+ }
430
+ // Emit text up to the fence
431
+ result += buf.slice(i, fenceIdx);
432
+ // Read the language (everything until newline)
433
+ const nlIdx = buf.indexOf("\n", fenceIdx + 3);
434
+ if (nlIdx === -1) {
435
+ // Don't have the full opening line yet — buffer
436
+ pending = buf.slice(fenceIdx);
437
+ i = buf.length;
438
+ break;
439
+ }
440
+ lang = buf.slice(fenceIdx + 3, nlIdx).trim() || "code";
441
+ i = nlIdx + 1;
442
+ mode = "fence";
443
+ buffered = "";
444
+ // Print a leading marker (will be amended when we close)
445
+ result += `${GRAY}┃ code: ${lang}…${R}`;
446
+ continue;
447
+ }
448
+
449
+ // mode === "fence" — look for closing ```
450
+ const closeIdx = buf.indexOf("```", i);
451
+ if (closeIdx === -1) {
452
+ buffered += buf.slice(i);
453
+ // Hold last 2 chars in case they're part of an upcoming close fence
454
+ const safeEnd = Math.max(i, buf.length - 2);
455
+ buffered = buffered.slice(0, buffered.length - (buf.length - safeEnd));
456
+ pending = buf.slice(safeEnd);
457
+ i = buf.length;
458
+ break;
459
+ }
460
+ // Closing fence found
461
+ buffered += buf.slice(i, closeIdx);
462
+ const lineCount = lines(buffered.replace(/\n+$/, ""));
463
+ // Replace the placeholder we already wrote with the final marker.
464
+ // Carriage return + clear line + reprint marker.
465
+ result += `\r${ESC}[2K ${GRAY}┃ code: ${lang}, ${lineCount} line${lineCount === 1 ? "" : "s"}${R}\n`;
466
+ i = closeIdx + 3;
467
+ // Skip optional newline after closing fence
468
+ if (buf[i] === "\n") i++;
469
+ mode = "text";
470
+ lang = "";
471
+ buffered = "";
472
+ }
473
+
474
+ return result;
475
+ },
476
+ flush(): string {
477
+ const tail = flushPending();
478
+ if (mode === "fence") {
479
+ // Stream ended mid-fence — close it with an approximate count
480
+ const lineCount = lines(buffered.replace(/\n+$/, ""));
481
+ mode = "text";
482
+ return tail + `\r${ESC}[2K ${GRAY}┃ code: ${lang}, ${lineCount}+ lines${R}\n`;
483
+ }
484
+ return tail;
485
+ },
486
+ };
487
+ }
488
+
191
489
  async function streamPrompt(opts: {
192
490
  port: number; token: string; sessionId: string;
193
491
  model: string; provider: string; message: string;
194
- abortSignal: AbortSignal; onDelta: (t: string) => void;
492
+ worktree: string;
493
+ abortSignal: AbortSignal;
195
494
  }): Promise<StreamResult> {
196
- const { port, token, sessionId, model, provider, message, abortSignal, onDelta } = opts;
495
+ const { port, token, sessionId, model, provider, message, worktree, abortSignal } = opts;
197
496
  const t0 = Date.now();
198
497
  let fullText = "";
199
498
  let tokens = 0;
499
+ let inAssistantText = false;
500
+ let tools = new Map<string, ToolTrack>();
501
+
502
+ // Code-fence collapser — replaces ```...``` blocks with [code: lang, N lines]
503
+ // markers across delta boundaries.
504
+ const fence = makeCodeFenceCollapser();
505
+ const collapseCodeFences = (chunk: string): string => fence.feed(chunk);
506
+
507
+ // Track which message the deltas belong to so we can detect tool boundaries
508
+ // The compat adapter emits these event types:
509
+ // part.delta — text chunk
510
+ // tool.requested — tool call started
511
+ // message.updated — message saved
512
+ // session.updated — session state changed
513
+ // error — error event
514
+
515
+ const flushTextLine = () => {
516
+ if (inAssistantText) {
517
+ // Make sure assistant text ends with a newline before the next thing
518
+ if (fullText && !fullText.endsWith("\n")) out("\n");
519
+ inAssistantText = false;
520
+ }
521
+ };
200
522
 
201
523
  try {
202
524
  const r = await fetch(`http://localhost:${port}/v2/session/${sessionId}/prompt`, {
@@ -205,148 +527,201 @@ async function streamPrompt(opts: {
205
527
  body: JSON.stringify({ content: message, modelID: model, providerID: provider }),
206
528
  signal: abortSignal,
207
529
  });
208
- if (!r.ok) return { text: "", tokens: 0, elapsedMs: Date.now() - t0, error: `HTTP ${r.status}: ${await r.text()}`, interrupted: false };
530
+ if (!r.ok) {
531
+ return { text: "", tokens: 0, elapsedMs: Date.now() - t0, error: `HTTP ${r.status}: ${await r.text()}`, interrupted: false };
532
+ }
209
533
 
210
534
  const reader = r.body!.getReader();
211
535
  const dec = new TextDecoder();
212
536
  let buf = "";
537
+
213
538
  while (true) {
214
539
  const { value, done } = await reader.read();
215
540
  if (done) break;
216
541
  buf += dec.decode(value, { stream: true });
217
542
  const lines = buf.split("\n");
218
543
  buf = lines.pop() || "";
544
+
219
545
  for (const raw of lines) {
220
546
  if (!raw.startsWith("data: ")) continue;
221
547
  const json = raw.slice(6).trim();
222
548
  if (!json || json === "[DONE]") continue;
549
+
223
550
  try {
224
551
  const ev = JSON.parse(json);
552
+
553
+ // Text delta — assistant is generating prose
225
554
  if (ev.type === "part.delta" && ev.delta?.type === "text") {
226
- fullText += ev.delta.text;
227
- onDelta(ev.delta.text);
555
+ const text = ev.delta.text || "";
556
+ if (!text) continue;
557
+ if (!inAssistantText) {
558
+ // Start of assistant text — print the indent prefix once
559
+ out(` `);
560
+ inAssistantText = true;
561
+ }
562
+ // Process the delta through the code-fence collapser so streaming
563
+ // code blocks appear as "[code: lang]" placeholders instead of
564
+ // dumping raw source.
565
+ const piece = collapseCodeFences(text);
566
+ // Print with leading-newline indenting (so each new line gets the 3-space prefix)
567
+ const indented = piece.replace(/\n/g, `\n `);
568
+ out(indented);
569
+ fullText += text;
570
+ continue;
571
+ }
572
+
573
+ // Tool requested — show concise activity line
574
+ if (ev.type === "tool.requested") {
575
+ flushTextLine();
576
+ const callId = ev.tool_call_id || ev.id || `${ev.tool_name}-${Date.now()}`;
577
+ const display = describeTool(ev.tool_name || "tool", ev.tool_input || {}, worktree);
578
+ tools.set(callId, { callId, display, startedAt: Date.now() });
579
+ renderToolStart(display);
580
+ continue;
228
581
  }
229
- if (ev.type === "error") return { text: fullText, tokens, elapsedMs: Date.now() - t0, error: ev.properties?.message || "error", interrupted: false };
230
- } catch {}
582
+
583
+ // Tool completed
584
+ if (ev.type === "tool.completed" || ev.type === "tool.success") {
585
+ flushTextLine();
586
+ const callId = ev.tool_call_id || ev.id || "";
587
+ const tracked = tools.get(callId);
588
+ if (tracked) {
589
+ const ms = Date.now() - tracked.startedAt;
590
+ renderToolDone(tracked.display, ms);
591
+ tools.delete(callId);
592
+ }
593
+ continue;
594
+ }
595
+
596
+ // Tool failed
597
+ if (ev.type === "tool.failed" || ev.type === "tool.error") {
598
+ flushTextLine();
599
+ const callId = ev.tool_call_id || ev.id || "";
600
+ const tracked = tools.get(callId);
601
+ const err = ev.error || ev.message || "failed";
602
+ if (tracked) {
603
+ renderToolError(tracked.display, err);
604
+ tools.delete(callId);
605
+ } else {
606
+ nl(` ${RED}✗ ${err}${R}`);
607
+ }
608
+ continue;
609
+ }
610
+
611
+ // Retry notification
612
+ if (ev.type === "session.retry") {
613
+ flushTextLine();
614
+ nl(` ${YELLOW}⟳ Retry ${ev.attempt || ""}: ${ev.message || ""}${R}`);
615
+ continue;
616
+ }
617
+
618
+ // Server-side error
619
+ if (ev.type === "error") {
620
+ flushTextLine();
621
+ return {
622
+ text: fullText,
623
+ tokens,
624
+ elapsedMs: Date.now() - t0,
625
+ error: ev.properties?.message || "error",
626
+ interrupted: false,
627
+ };
628
+ }
629
+
630
+ // Compaction
631
+ if (ev.type === "session.compacted") {
632
+ flushTextLine();
633
+ nl(` ${BLUE}⊕ Context compacted${R}`);
634
+ continue;
635
+ }
636
+ } catch {
637
+ // Ignore malformed events
638
+ }
231
639
  }
232
640
  }
233
641
 
642
+ flushTextLine();
643
+
644
+ // Drain any tools that didn't get a completion event
645
+ for (const tracked of tools.values()) {
646
+ const ms = Date.now() - tracked.startedAt;
647
+ renderToolDone(tracked.display, ms);
648
+ }
649
+ tools.clear();
650
+
651
+ // Flush any unclosed code fence
652
+ const tail = fence.flush();
653
+ if (tail) out(tail);
654
+
655
+ // Fetch the final message to get token totals
234
656
  try {
235
657
  const msgs = await apiGet<any[]>(`http://localhost:${port}/v2/session/${sessionId}/message`, token);
236
658
  const last = msgs.filter((m: any) => m.role === "assistant").slice(-1)[0];
237
659
  tokens = (last?.tokens?.input || 0) + (last?.tokens?.output || 0);
660
+ // If we didn't get any text deltas but the stored message has text, render it now
661
+ if (!fullText && last?.parts) {
662
+ for (const p of last.parts) {
663
+ if (p.type === "text" && p.text) {
664
+ out(` ${p.text.replace(/\n/g, `\n `)}\n`);
665
+ fullText = p.text;
666
+ break;
667
+ }
668
+ }
669
+ }
238
670
  } catch {}
239
671
 
240
672
  return { text: fullText, tokens, elapsedMs: Date.now() - t0, interrupted: false };
241
673
  } catch (e: any) {
242
- if (e.name === "AbortError") return { text: fullText, tokens, elapsedMs: Date.now() - t0, interrupted: true };
674
+ flushTextLine();
675
+ if (e.name === "AbortError") {
676
+ return { text: fullText, tokens, elapsedMs: Date.now() - t0, interrupted: true };
677
+ }
243
678
  return { text: fullText, tokens, elapsedMs: Date.now() - t0, error: e.message, interrupted: false };
244
679
  }
245
680
  }
246
681
 
247
- // ─── Rendering helpers ────────────────────────────────────────────────────────
248
-
249
- // User message: ┃ left border, panel background
250
- function renderUserMessage(text: string) {
251
- nl();
252
- const lines = text.split("\n");
253
- for (const l of lines) {
254
- nl(`${GRAY}${VERT}${R} ${WHITE}${l}${R}`);
255
- }
256
- nl();
257
- }
258
-
259
- // Tool call line — inline style matching OpenCode
260
- function renderToolCall(toolName: string, summary: string, done: boolean) {
261
- const icon = toolIcon(toolName);
262
- const color = done ? GRAY : WHITE;
263
- nl(` ${color}${icon} ${summary}${R}`);
264
- }
265
-
266
- function toolIcon(name: string): string {
267
- const n = name.toLowerCase();
268
- if (n === "write" || n === "edit" || n === "apply_patch") return ARROW_L;
269
- if (n === "read") return ARROW_R;
270
- if (n === "shell" || n === "bash") return DOLLAR;
271
- if (n === "glob" || n === "grep") return STAR;
272
- if (n === "webfetch") return PCT;
273
- return GEAR;
274
- }
275
-
276
- // End-of-turn marker: ▣ Build · model · 2.1s
277
- function renderTurnEnd(model: string, elapsedMs: number, interrupted: boolean) {
278
- const elapsed = (elapsedMs / 1000).toFixed(1);
279
- const dur = interrupted ? `${YELLOW}interrupted${R}` : `${GRAY}${elapsed}s${R}`;
280
- nl();
281
- nl(` ${CYAN}${BLOCK}${R} ${WHITE}Build${R}${GRAY} · ${model} · ${R}${dur}`);
282
- }
283
-
284
- // Prompt box — matches OpenCode's input area design
285
- function renderPromptBox(model: string, provider: string, sessionId: string, worktree: string) {
286
- const width = cols();
287
- const dir = worktree.replace(/\\/g, "/").replace(/^.*\/([^/]+\/[^/]+)$/, "$1"); // last 2 path segments
288
- const modelLabel = `${WHITE}Build${R}${GRAY} · ${model}${R}`;
289
- const shadow = SHADE.repeat(Math.min(width - 2, 60));
682
+ // ─── Slash command menu — inline, above the prompt ────────────────────────────
290
683
 
291
- nl();
292
- nl(`${GRAY}${VERT}${R}`);
293
- nl(`${GRAY}${VERT}${R} ${GRAY}${DIM}Type a message...${R}`);
294
- nl(`${GRAY}${VERT}${R}`);
295
- nl(`${GRAY}${VERT}${R} ${modelLabel}`);
296
- nl(`${GRAY}${CORN}${shadow}${R}`);
297
- nl(`${GRAY}${dir}${R} ${GRAY}esc ${DIM}interrupt${R}`);
684
+ interface MenuState {
685
+ visible: boolean;
686
+ options: Command[];
687
+ selected: number;
688
+ rowsRendered: number;
298
689
  }
299
690
 
300
- // Help text
301
- function printHelp() {
302
- nl();
303
- nl(`${CYAN}${BOLD}BoneCode${R} ${GRAY}commands${R}`);
304
- nl();
305
- nl(` ${CYAN}/new${R} New session`);
306
- nl(` ${CYAN}/session${R} Show session ID`);
307
- nl(` ${CYAN}/model <id>${R} Switch model`);
308
- nl(` ${CYAN}/provider <id>${R} Switch provider`);
309
- nl(` ${CYAN}/providers${R} List all providers`);
310
- nl(` ${CYAN}/clear${R} Clear screen`);
311
- nl(` ${CYAN}/history${R} Last 10 prompts`);
312
- nl(` ${CYAN}/exit${R} Exit`);
313
- nl();
314
- nl(` ${CYAN}Ctrl+C${R} Interrupt current request`);
315
- nl(` ${CYAN}Ctrl+D${R} Exit`);
316
- nl(` ${CYAN}↑ / ↓${R} Prompt history`);
317
- nl(` ${CYAN}@<path>${R} Attach file — Tab to autocomplete`);
318
- nl();
319
- nl(` ${GRAY}Model shorthand: /model groq/llama-3.3-70b-versatile${R}`);
320
- nl(` ${GRAY}Local config: /model local${R}`);
321
- nl();
691
+ function filterCommands(query: string): Command[] {
692
+ if (!query || query === "/") return COMMANDS;
693
+ const q = query.toLowerCase();
694
+ return COMMANDS.filter(c => c.name.toLowerCase().startsWith(q));
322
695
  }
323
696
 
324
- async function fetchProviders(port: number, token: string): Promise<any[]> {
325
- try { return await apiGet<any[]>(`http://localhost:${port}/v2/provider`, token); }
326
- catch { return []; }
697
+ function renderCommandMenu(state: MenuState): number {
698
+ if (!state.visible || state.options.length === 0) return 0;
699
+ const max = Math.min(state.options.length, 8);
700
+ for (let i = 0; i < max; i++) {
701
+ const c = state.options[i]!;
702
+ const selected = i === state.selected;
703
+ const prefix = selected ? `${CYAN}▌${R} ` : ` `;
704
+ const name = selected ? `${CYAN}${BOLD}${c.name}${R}` : `${WHITE}${c.name}${R}`;
705
+ const args = c.args ? ` ${GRAY}${c.args}${R}` : "";
706
+ const desc = `${GRAY}${c.description}${R}`;
707
+ nl(`${prefix}${name}${args} ${desc}`);
708
+ }
709
+ if (state.options.length > max) {
710
+ nl(` ${GRAY}... ${state.options.length - max} more (keep typing)${R}`);
711
+ return max + 1;
712
+ }
713
+ return max;
327
714
  }
328
715
 
329
- function printProviders(providers: any[], currentProvider: string) {
330
- const width = cols();
331
- nl();
332
- nl(`${CYAN}${BOLD}Providers${R}`);
333
- nl(`${GRAY}${"─".repeat(Math.min(width, 56))}${R}`);
334
- for (const p of providers) {
335
- const active = p.id === currentProvider;
336
- const dot = active ? `${GREEN}●${R}` : `${GRAY}○${R}`;
337
- const free = p.free ? ` ${GREEN}free${R}` : "";
338
- const local = p.id === "local" ? ` ${CYAN}local config${R}` : "";
339
- nl(` ${dot} ${active ? CYAN + BOLD : WHITE}${p.id}${R}${free}${local} ${GRAY}${p.name}${R}`);
340
- if (p.freeNote) nl(` ${GRAY}${p.freeNote}${R}`);
341
- if (p.models?.length) nl(` ${GRAY}${p.models.slice(0, 3).join(" ")}${R}`);
342
- if (p.keyEnv) nl(` ${GRAY}${p.keyEnv}${R}`);
716
+ function clearMenu(rows: number) {
717
+ if (rows <= 0) return;
718
+ // Move up `rows` lines and clear each one
719
+ for (let i = 0; i < rows; i++) {
720
+ out(`${ESC}[1A${ESC}[2K`);
343
721
  }
344
- nl(`${GRAY}${"─".repeat(Math.min(width, 56))}${R}`);
345
- nl(` ${GRAY}/provider <id> /model <id>${R}`);
346
- nl();
347
722
  }
348
723
 
349
- // ─── Main TUI ─────────────────────────────────────────────────────────────────
724
+ // ─── Main TUI loop ────────────────────────────────────────────────────────────
350
725
 
351
726
  export async function runTUI(opts: {
352
727
  port: number; token: string; model: string;
@@ -355,98 +730,217 @@ export async function runTUI(opts: {
355
730
  let { model, provider } = opts;
356
731
  const { port, token, worktree } = opts;
357
732
  let sessionId = opts.sessionId || null;
358
- let turnCount = 0;
359
733
  const history: string[] = [];
360
734
  let abort: AbortController | null = null;
361
735
  let streaming = false;
362
736
 
363
- // Create initial session
737
+ // Initial session
364
738
  if (!sessionId) {
365
739
  try { sessionId = await createSession(port, token, worktree, "BoneCode Session"); }
366
- catch (e: any) { process.stderr.write(`${RED}Failed to create session: ${e.message}${R}\n`); process.exit(1); }
740
+ catch (e: any) {
741
+ process.stderr.write(`${RED}Failed to create session: ${e.message}${R}\n`);
742
+ process.exit(1);
743
+ }
367
744
  }
368
745
 
369
746
  // Header
370
747
  printLogo();
371
- const VERSION = getVersion();
372
- nl(` ${GRAY}v${VERSION} · ${model} · ${path.basename(worktree)}${R}`);
373
- nl(` ${GRAY}session ${sessionId!.slice(0, 8)}${R}`);
748
+ nl(` ${GRAY}v${getVersion()} · ${model} · ${path.basename(worktree)}${R}`);
749
+ nl(` ${GRAY}session ${sessionId.slice(0, 8)}${R}`);
374
750
  nl();
375
- nl(` ${GRAY}/help for commands · Ctrl+C interrupt · Ctrl+D exit${R}`);
751
+ nl(` ${GRAY}Type ${R}${CYAN}/${R}${GRAY} to see commands · Ctrl+C interrupt · Ctrl+D exit${R}`);
376
752
  nl();
377
- nl(); // extra blank line — ghost hint uses the line above the prompt
378
753
 
754
+ // ─── Slash command menu state ──────────────────────────────────────────────
755
+ const menu: MenuState = {
756
+ visible: false,
757
+ options: [],
758
+ selected: 0,
759
+ rowsRendered: 0,
760
+ };
761
+
762
+ // Set up readline AFTER setting up SIGINT so we control it
379
763
  const rl = readline.createInterface({
380
- input: process.stdin, output: process.stdout,
381
- terminal: true, historySize: 200,
764
+ input: process.stdin,
765
+ output: process.stdout,
766
+ terminal: true,
767
+ historySize: 200,
382
768
  completer: buildCompleter(worktree),
383
769
  });
384
770
 
385
771
  const promptStr = () => `${CYAN}${BOLD}>${R} `;
386
772
 
387
- const sigint = () => {
773
+ // ─── Ctrl+C handling ──────────────────────────────────────────────────────
774
+ // When streaming: abort the request AND notify server
775
+ // When idle: clear menu/input or hint to use /exit
776
+ const onSigint = async () => {
388
777
  if (streaming && abort) {
389
778
  abort.abort();
390
- nl(`\n${YELLOW}interrupted${R}`);
391
- } else {
392
- nl(`\n${GRAY}(Ctrl+D or /exit to quit)${R}`);
779
+ // Also tell the server to cancel the agent loop
780
+ try {
781
+ await fetch(`http://localhost:${port}/v2/session/${sessionId}/cancel`, {
782
+ method: "POST",
783
+ headers: { "Authorization": `Bearer ${token}` },
784
+ });
785
+ } catch { /* server may not have the endpoint, abort is enough */ }
786
+ // Don't reprompt here — the streamPrompt finally block will handle UI
787
+ return;
393
788
  }
789
+
790
+ // Idle: clear menu if visible, else hint
791
+ if (menu.visible) {
792
+ clearMenu(menu.rowsRendered);
793
+ menu.visible = false;
794
+ menu.rowsRendered = 0;
795
+ menu.selected = 0;
796
+ }
797
+ out(`\n${GRAY}(Ctrl+D or /exit to quit)${R}\n`);
394
798
  rl.setPrompt(promptStr());
395
799
  rl.prompt();
396
800
  };
397
- process.on("SIGINT", sigint);
801
+
802
+ // Detach readline's default SIGINT (which closes the line buffer)
803
+ // and route it to our handler.
804
+ rl.on("SIGINT", onSigint);
398
805
 
399
806
  rl.on("close", () => {
400
- process.removeListener("SIGINT", sigint);
807
+ if (streaming && abort) abort.abort();
401
808
  nl(`\n${GRAY}Goodbye.${R}`);
402
809
  process.exit(0);
403
810
  });
404
811
 
405
- rl.setPrompt(promptStr());
406
- rl.prompt();
812
+ // ─── Live menu update on every keystroke ──────────────────────────────────
813
+ // We hook into the keypress events of stdin to redraw the menu.
814
+ // The menu sits ABOVE the prompt line.
815
+ const stdin: any = (rl as any).input;
816
+
817
+ const updateMenu = () => {
818
+ if (streaming) return;
819
+
820
+ const line: string = (rl as any).line || "";
821
+ const startsWithSlash = line.startsWith("/");
822
+ const shouldShow = startsWithSlash && !line.includes(" ");
823
+
824
+ if (!shouldShow) {
825
+ if (menu.visible) {
826
+ // Need to clear the menu — but we need to preserve the current input line.
827
+ // Strategy: save current line content & cursor, clear menu lines above,
828
+ // then restore the prompt and content.
829
+ const cursor = (rl as any).cursor || 0;
830
+ out(`\r${ESC}[2K`); // clear current prompt line
831
+ clearMenu(menu.rowsRendered); // clear menu lines above
832
+ menu.visible = false;
833
+ menu.rowsRendered = 0;
834
+ menu.selected = 0;
835
+ // Redraw prompt + line
836
+ out(promptStr() + line);
837
+ // Restore cursor position
838
+ const drawn = line.length;
839
+ if (cursor < drawn) {
840
+ out(`${ESC}[${drawn - cursor}D`);
841
+ }
842
+ }
843
+ return;
844
+ }
407
845
 
408
- // Ghost-text hint: when user types /x show matching commands above prompt
409
- let lastHint = "";
410
- (rl as any).input?.on("keypress", (_: any, key: any) => {
846
+ // Filter and reset selection if list changed
847
+ const newOptions = filterCommands(line);
848
+ if (newOptions.length === 0) {
849
+ // Clear menu if visible
850
+ if (menu.visible) {
851
+ const cursor = (rl as any).cursor || 0;
852
+ out(`\r${ESC}[2K`);
853
+ clearMenu(menu.rowsRendered);
854
+ menu.visible = false;
855
+ menu.rowsRendered = 0;
856
+ out(promptStr() + line);
857
+ if (cursor < line.length) out(`${ESC}[${line.length - cursor}D`);
858
+ }
859
+ return;
860
+ }
861
+
862
+ // Save current input line
863
+ const cursor = (rl as any).cursor || 0;
864
+ out(`\r${ESC}[2K`); // clear prompt line
865
+ clearMenu(menu.rowsRendered); // clear old menu
866
+
867
+ menu.options = newOptions;
868
+ menu.selected = Math.min(menu.selected, newOptions.length - 1);
869
+ if (menu.selected < 0) menu.selected = 0;
870
+ menu.visible = true;
871
+
872
+ // Render the menu (each item ends in newline)
873
+ menu.rowsRendered = renderCommandMenu(menu);
874
+
875
+ // Redraw prompt + line
876
+ out(promptStr() + line);
877
+ if (cursor < line.length) {
878
+ out(`${ESC}[${line.length - cursor}D`);
879
+ }
880
+ };
881
+
882
+ // Capture special keys for menu navigation
883
+ const onKeypress = (_chunk: any, key: any) => {
411
884
  if (!key) return;
412
- // Small delay so readline has updated its line buffer
413
- setImmediate(() => {
414
- const current: string = (rl as any).line || "";
415
- if (!current.startsWith("/") || current.includes(" ")) {
416
- if (lastHint) {
417
- // Clear the hint line
418
- process.stdout.write(`${ESC}[s`); // save cursor
419
- process.stdout.write(`${ESC}[1A${ESC}[2K`); // up 1, clear line
420
- process.stdout.write(`${ESC}[u`); // restore cursor
421
- lastHint = "";
422
- }
885
+ if (streaming) return;
886
+
887
+ if (menu.visible && menu.options.length > 0) {
888
+ // Arrow up/down navigate the menu
889
+ if (key.name === "up") {
890
+ menu.selected = (menu.selected - 1 + menu.options.length) % menu.options.length;
891
+ updateMenu();
423
892
  return;
424
893
  }
425
- const matches = COMMANDS.filter(c => c.startsWith(current) && c !== current);
426
- const hint = matches.length ? matches[0] : "";
427
- if (hint === lastHint) return;
428
- lastHint = hint;
429
- // Print hint above the prompt line
430
- process.stdout.write(`${ESC}[s`); // save cursor
431
- process.stdout.write(`${ESC}[1A${ESC}[2K`); // up 1, clear line
432
- if (hint) {
433
- // Show current typed part normal, rest in grey
434
- const ghost = hint.slice(current.length);
435
- process.stdout.write(`${promptStr()}${current}${GRAY}${ghost}${R}`);
894
+ if (key.name === "down") {
895
+ menu.selected = (menu.selected + 1) % menu.options.length;
896
+ updateMenu();
897
+ return;
436
898
  }
437
- process.stdout.write(`${ESC}[u`); // restore cursor
438
- });
439
- });
899
+ // Tab: replace input with the selected command
900
+ if (key.name === "tab") {
901
+ const sel = menu.options[menu.selected];
902
+ if (sel) {
903
+ // Replace the line buffer with the command + space
904
+ (rl as any).line = sel.name + " ";
905
+ (rl as any).cursor = sel.name.length + 1;
906
+ updateMenu();
907
+ out(`\r${ESC}[2K`);
908
+ out(promptStr() + (rl as any).line);
909
+ }
910
+ return;
911
+ }
912
+ }
913
+
914
+ // Default: trigger menu update on the next tick (after readline updates the buffer)
915
+ setImmediate(updateMenu);
916
+ };
917
+
918
+ stdin.on("keypress", onKeypress);
919
+
920
+ rl.setPrompt(promptStr());
921
+ rl.prompt();
440
922
 
923
+ // ─── Main input loop ──────────────────────────────────────────────────────
441
924
  for await (const rawLine of rl) {
442
925
  const text = rawLine.trim();
443
926
 
444
- if (!text) { rl.setPrompt(promptStr()); rl.prompt(); continue; }
927
+ // Clear any visible menu before processing
928
+ if (menu.visible) {
929
+ menu.visible = false;
930
+ menu.rowsRendered = 0;
931
+ menu.selected = 0;
932
+ }
933
+
934
+ if (!text) {
935
+ rl.setPrompt(promptStr());
936
+ rl.prompt();
937
+ continue;
938
+ }
445
939
 
446
940
  history.push(text);
447
941
  if (history.length > 200) history.shift();
448
942
 
449
- // ── Commands ──────────────────────────────────────────────────────────────
943
+ // ── Slash commands ──────────────────────────────────────────────────────
450
944
  if (text.startsWith("/")) {
451
945
  const parts = text.slice(1).trim().split(/\s+/);
452
946
  const cmd = parts[0]?.toLowerCase() || "";
@@ -460,7 +954,6 @@ export async function runTUI(opts: {
460
954
  case "new":
461
955
  try {
462
956
  sessionId = await createSession(port, token, worktree, "New Session");
463
- turnCount = 0;
464
957
  nl(`${GREEN}✓${R} ${GRAY}session ${sessionId.slice(0, 8)}${R}`);
465
958
  } catch (e: any) { nl(`${RED}✗ ${e.message}${R}`); }
466
959
  break;
@@ -469,21 +962,35 @@ export async function runTUI(opts: {
469
962
  nl(`${GRAY}${sessionId}${R}`);
470
963
  break;
471
964
 
965
+ case "sessions": {
966
+ try {
967
+ const list = await apiGet<any[]>(`http://localhost:${port}/v2/session?limit=10`, token);
968
+ if (!list.length) { nl(`${GRAY}No sessions${R}`); break; }
969
+ nl();
970
+ for (const s of list) {
971
+ const active = s.id === sessionId;
972
+ const dot = active ? `${GREEN}●${R}` : `${GRAY}○${R}`;
973
+ const t = new Date(s.time?.updated || Date.now()).toLocaleString();
974
+ nl(` ${dot} ${WHITE}${(s.title || "untitled").slice(0, 50).padEnd(50)}${R} ${GRAY}${t}${R}`);
975
+ }
976
+ nl();
977
+ } catch (e: any) { nl(`${RED}✗ ${e.message}${R}`); }
978
+ break;
979
+ }
980
+
472
981
  case "model":
473
982
  if (args[0]) {
474
983
  if (args[0] === "local") {
475
984
  provider = process.env.DEFAULT_PROVIDER || "openai_compatible";
476
985
  model = process.env.DEFAULT_MODEL || "local-model";
477
- nl(`${GREEN}✓${R} ${WHITE}${provider}/${model}${R}`);
478
986
  } else if (args[0].includes("/")) {
479
987
  const i = args[0].indexOf("/");
480
988
  provider = args[0].slice(0, i);
481
989
  model = args[0].slice(i + 1);
482
- nl(`${GREEN}✓${R} ${WHITE}${provider}/${model}${R}`);
483
990
  } else {
484
991
  model = args[0];
485
- nl(`${GREEN}✓${R} ${WHITE}${provider}/${model}${R}`);
486
992
  }
993
+ nl(`${GREEN}✓${R} ${WHITE}${provider}/${model}${R}`);
487
994
  } else {
488
995
  nl(`${GRAY}${provider}/${model}${R}`);
489
996
  }
@@ -517,7 +1024,7 @@ export async function runTUI(opts: {
517
1024
  break;
518
1025
 
519
1026
  case "history":
520
- if (!history.length) { nl(`${GRAY}No history${R}`); }
1027
+ if (!history.length) nl(`${GRAY}No history${R}`);
521
1028
  else history.slice(-10).forEach((h, i) => nl(` ${GRAY}${i + 1}.${R} ${h.slice(0, 80)}`));
522
1029
  break;
523
1030
 
@@ -527,7 +1034,7 @@ export async function runTUI(opts: {
527
1034
  break;
528
1035
 
529
1036
  default:
530
- nl(`${YELLOW}Unknown: /${cmd}${R} ${GRAY}/help${R}`);
1037
+ nl(`${YELLOW}Unknown: /${cmd}${R} ${GRAY}Type /help for available commands${R}`);
531
1038
  }
532
1039
 
533
1040
  rl.setPrompt(promptStr());
@@ -535,60 +1042,53 @@ export async function runTUI(opts: {
535
1042
  continue;
536
1043
  }
537
1044
 
538
- // ── Prompt ────────────────────────────────────────────────────────────────
539
-
1045
+ // ── Prompt to the agent ─────────────────────────────────────────────────
540
1046
  if (!sessionId) {
541
1047
  try { sessionId = await createSession(port, token, worktree, text.slice(0, 50)); }
542
- catch (e: any) { nl(`${RED}✗ ${e.message}${R}`); rl.setPrompt(promptStr()); rl.prompt(); continue; }
1048
+ catch (e: any) {
1049
+ nl(`${RED}✗ ${e.message}${R}`);
1050
+ rl.setPrompt(promptStr());
1051
+ rl.prompt();
1052
+ continue;
1053
+ }
543
1054
  }
544
1055
 
545
- // Resolve @file mentions
1056
+ // Resolve @file mentions to absolute paths
546
1057
  const resolved = text.replace(/@([\w./\-]+)/g, (match, fp) => {
547
1058
  const abs = path.isAbsolute(fp) ? fp : path.resolve(worktree, fp);
548
1059
  return fs.existsSync(abs) ? `@${abs}` : match;
549
1060
  });
550
1061
 
551
- // Render user message with ┃ border
552
- renderUserMessage(resolved !== text ? `${text} ${GRAY}(files resolved)${R}` : text);
1062
+ renderUserMessage(text);
553
1063
 
554
- // Thinking indicator
555
- out(` ${GRAY}⋯${R}`);
1064
+ // Show "..." spinner so the user knows something is happening
1065
+ out(` ${GRAY}thinking...${R}`);
556
1066
 
557
1067
  rl.pause();
558
1068
  streaming = true;
559
1069
  abort = new AbortController();
560
- let firstDelta = true;
561
1070
 
1071
+ const t0 = Date.now();
562
1072
  const result = await streamPrompt({
563
- port, token, sessionId: sessionId!, model, provider,
564
- message: resolved, abortSignal: abort.signal,
565
- onDelta: (delta) => {
566
- if (firstDelta) {
567
- // Clear the thinking indicator, print indent once
568
- out(`\r${ESC}[2K`);
569
- nl();
570
- out(` `);
571
- firstDelta = false;
572
- }
573
- // Strip markdown fences for terminal rendering
574
- const clean = delta.replace(/^```\w*\n?/gm, "").replace(/^```\n?/gm, "");
575
- if (clean) out(clean);
576
- },
1073
+ port, token, sessionId,
1074
+ model, provider,
1075
+ message: resolved,
1076
+ worktree,
1077
+ abortSignal: abort.signal,
577
1078
  });
578
1079
 
579
1080
  streaming = false;
580
1081
  abort = null;
581
1082
 
582
- if (firstDelta) {
583
- // No deltas arrived — clear thinking indicator
584
- out(`\r${ESC}[2K`);
1083
+ // Clear the thinking spinner if no output happened
1084
+ if (!result.text && !result.error) {
1085
+ clearLine();
1086
+ } else {
1087
+ // Make sure the next render starts on a clean line
1088
+ const cur = (process.stdout as any).cursor;
1089
+ if (cur && cur.col !== 0) out("\n");
585
1090
  }
586
1091
 
587
- if (result.text && !result.text.endsWith("\n")) nl();
588
-
589
- turnCount++;
590
-
591
- // End-of-turn marker: ▣ Build · model · 2.1s
592
1092
  renderTurnEnd(model, result.elapsedMs, result.interrupted);
593
1093
 
594
1094
  if (result.error && !result.interrupted) {
@@ -599,9 +1099,10 @@ export async function runTUI(opts: {
599
1099
 
600
1100
  rl.resume();
601
1101
  rl.setPrompt(promptStr());
602
- nl(); // blank line for ghost hint
603
1102
  rl.prompt();
604
1103
  }
1104
+
1105
+ stdin.removeListener("keypress", onKeypress);
605
1106
  }
606
1107
 
607
1108
  // ─── Entry point ──────────────────────────────────────────────────────────────