jonsoc 1.1.43

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 (420) hide show
  1. package/AGENTS.md +27 -0
  2. package/Dockerfile +18 -0
  3. package/PUBLISHING_GUIDE.md +151 -0
  4. package/README.md +58 -0
  5. package/bin/jonsoc +279 -0
  6. package/bunfig.toml +7 -0
  7. package/package.json +147 -0
  8. package/package.json.placeholder +11 -0
  9. package/parsers-config.ts +253 -0
  10. package/script/build.ts +115 -0
  11. package/script/publish-registries.ts +197 -0
  12. package/script/publish.ts +110 -0
  13. package/script/schema.ts +47 -0
  14. package/script/seed-e2e.ts +50 -0
  15. package/src/acp/README.md +164 -0
  16. package/src/acp/agent.ts +1437 -0
  17. package/src/acp/session.ts +105 -0
  18. package/src/acp/types.ts +22 -0
  19. package/src/agent/agent.ts +347 -0
  20. package/src/agent/generate.txt +75 -0
  21. package/src/agent/prompt/compaction.txt +12 -0
  22. package/src/agent/prompt/explore.txt +18 -0
  23. package/src/agent/prompt/summary.txt +11 -0
  24. package/src/agent/prompt/title.txt +44 -0
  25. package/src/auth/index.ts +73 -0
  26. package/src/brand/index.ts +73 -0
  27. package/src/bun/index.ts +139 -0
  28. package/src/bus/bus-event.ts +43 -0
  29. package/src/bus/global.ts +10 -0
  30. package/src/bus/index.ts +105 -0
  31. package/src/cli/bootstrap.ts +17 -0
  32. package/src/cli/cmd/acp.ts +69 -0
  33. package/src/cli/cmd/agent.ts +257 -0
  34. package/src/cli/cmd/auth.ts +405 -0
  35. package/src/cli/cmd/cmd.ts +7 -0
  36. package/src/cli/cmd/debug/agent.ts +166 -0
  37. package/src/cli/cmd/debug/config.ts +16 -0
  38. package/src/cli/cmd/debug/file.ts +97 -0
  39. package/src/cli/cmd/debug/index.ts +48 -0
  40. package/src/cli/cmd/debug/lsp.ts +52 -0
  41. package/src/cli/cmd/debug/ripgrep.ts +87 -0
  42. package/src/cli/cmd/debug/scrap.ts +16 -0
  43. package/src/cli/cmd/debug/skill.ts +16 -0
  44. package/src/cli/cmd/debug/snapshot.ts +52 -0
  45. package/src/cli/cmd/export.ts +88 -0
  46. package/src/cli/cmd/generate.ts +38 -0
  47. package/src/cli/cmd/github.ts +1548 -0
  48. package/src/cli/cmd/import.ts +99 -0
  49. package/src/cli/cmd/mcp.ts +765 -0
  50. package/src/cli/cmd/models.ts +77 -0
  51. package/src/cli/cmd/pr.ts +112 -0
  52. package/src/cli/cmd/run.ts +395 -0
  53. package/src/cli/cmd/serve.ts +20 -0
  54. package/src/cli/cmd/session.ts +135 -0
  55. package/src/cli/cmd/stats.ts +402 -0
  56. package/src/cli/cmd/tui/app.tsx +923 -0
  57. package/src/cli/cmd/tui/attach.ts +39 -0
  58. package/src/cli/cmd/tui/component/border.tsx +21 -0
  59. package/src/cli/cmd/tui/component/dialog-agent.tsx +31 -0
  60. package/src/cli/cmd/tui/component/dialog-command.tsx +162 -0
  61. package/src/cli/cmd/tui/component/dialog-error-log.tsx +155 -0
  62. package/src/cli/cmd/tui/component/dialog-mcp.tsx +86 -0
  63. package/src/cli/cmd/tui/component/dialog-model.tsx +234 -0
  64. package/src/cli/cmd/tui/component/dialog-provider.tsx +256 -0
  65. package/src/cli/cmd/tui/component/dialog-session-list.tsx +114 -0
  66. package/src/cli/cmd/tui/component/dialog-session-rename.tsx +31 -0
  67. package/src/cli/cmd/tui/component/dialog-stash.tsx +87 -0
  68. package/src/cli/cmd/tui/component/dialog-status.tsx +164 -0
  69. package/src/cli/cmd/tui/component/dialog-tag.tsx +44 -0
  70. package/src/cli/cmd/tui/component/dialog-theme-list.tsx +50 -0
  71. package/src/cli/cmd/tui/component/dynamic-layout.tsx +86 -0
  72. package/src/cli/cmd/tui/component/inspector-overlay.tsx +247 -0
  73. package/src/cli/cmd/tui/component/logo.tsx +88 -0
  74. package/src/cli/cmd/tui/component/prompt/autocomplete.tsx +653 -0
  75. package/src/cli/cmd/tui/component/prompt/frecency.tsx +89 -0
  76. package/src/cli/cmd/tui/component/prompt/history.tsx +108 -0
  77. package/src/cli/cmd/tui/component/prompt/index.tsx +1347 -0
  78. package/src/cli/cmd/tui/component/prompt/stash.tsx +101 -0
  79. package/src/cli/cmd/tui/component/textarea-keybindings.ts +73 -0
  80. package/src/cli/cmd/tui/component/tips.tsx +153 -0
  81. package/src/cli/cmd/tui/component/todo-item.tsx +32 -0
  82. package/src/cli/cmd/tui/context/args.tsx +14 -0
  83. package/src/cli/cmd/tui/context/directory.ts +13 -0
  84. package/src/cli/cmd/tui/context/error-log.tsx +56 -0
  85. package/src/cli/cmd/tui/context/exit.tsx +26 -0
  86. package/src/cli/cmd/tui/context/helper.tsx +25 -0
  87. package/src/cli/cmd/tui/context/inspector.tsx +57 -0
  88. package/src/cli/cmd/tui/context/keybind.tsx +108 -0
  89. package/src/cli/cmd/tui/context/kv.tsx +53 -0
  90. package/src/cli/cmd/tui/context/layout.tsx +240 -0
  91. package/src/cli/cmd/tui/context/local.tsx +402 -0
  92. package/src/cli/cmd/tui/context/prompt.tsx +18 -0
  93. package/src/cli/cmd/tui/context/route.tsx +51 -0
  94. package/src/cli/cmd/tui/context/sdk.tsx +94 -0
  95. package/src/cli/cmd/tui/context/sync.tsx +449 -0
  96. package/src/cli/cmd/tui/context/theme/aura.json +69 -0
  97. package/src/cli/cmd/tui/context/theme/ayu.json +80 -0
  98. package/src/cli/cmd/tui/context/theme/carbonfox.json +248 -0
  99. package/src/cli/cmd/tui/context/theme/catppuccin-frappe.json +233 -0
  100. package/src/cli/cmd/tui/context/theme/catppuccin-macchiato.json +233 -0
  101. package/src/cli/cmd/tui/context/theme/catppuccin.json +112 -0
  102. package/src/cli/cmd/tui/context/theme/cobalt2.json +228 -0
  103. package/src/cli/cmd/tui/context/theme/cursor.json +249 -0
  104. package/src/cli/cmd/tui/context/theme/dracula.json +219 -0
  105. package/src/cli/cmd/tui/context/theme/everforest.json +241 -0
  106. package/src/cli/cmd/tui/context/theme/flexoki.json +237 -0
  107. package/src/cli/cmd/tui/context/theme/github.json +233 -0
  108. package/src/cli/cmd/tui/context/theme/gruvbox.json +242 -0
  109. package/src/cli/cmd/tui/context/theme/jonsoc.json +245 -0
  110. package/src/cli/cmd/tui/context/theme/kanagawa.json +77 -0
  111. package/src/cli/cmd/tui/context/theme/lucent-orng.json +237 -0
  112. package/src/cli/cmd/tui/context/theme/material.json +235 -0
  113. package/src/cli/cmd/tui/context/theme/matrix.json +77 -0
  114. package/src/cli/cmd/tui/context/theme/mercury.json +252 -0
  115. package/src/cli/cmd/tui/context/theme/monokai.json +221 -0
  116. package/src/cli/cmd/tui/context/theme/nightowl.json +221 -0
  117. package/src/cli/cmd/tui/context/theme/nord.json +223 -0
  118. package/src/cli/cmd/tui/context/theme/one-dark.json +84 -0
  119. package/src/cli/cmd/tui/context/theme/orng.json +249 -0
  120. package/src/cli/cmd/tui/context/theme/osaka-jade.json +93 -0
  121. package/src/cli/cmd/tui/context/theme/palenight.json +222 -0
  122. package/src/cli/cmd/tui/context/theme/rosepine.json +234 -0
  123. package/src/cli/cmd/tui/context/theme/solarized.json +223 -0
  124. package/src/cli/cmd/tui/context/theme/synthwave84.json +226 -0
  125. package/src/cli/cmd/tui/context/theme/tokyonight.json +243 -0
  126. package/src/cli/cmd/tui/context/theme/vercel.json +245 -0
  127. package/src/cli/cmd/tui/context/theme/vesper.json +218 -0
  128. package/src/cli/cmd/tui/context/theme/zenburn.json +223 -0
  129. package/src/cli/cmd/tui/context/theme.tsx +1153 -0
  130. package/src/cli/cmd/tui/event.ts +48 -0
  131. package/src/cli/cmd/tui/hooks/use-command-registry.tsx +184 -0
  132. package/src/cli/cmd/tui/routes/home.tsx +198 -0
  133. package/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx +64 -0
  134. package/src/cli/cmd/tui/routes/session/dialog-message.tsx +109 -0
  135. package/src/cli/cmd/tui/routes/session/dialog-subagent.tsx +26 -0
  136. package/src/cli/cmd/tui/routes/session/dialog-timeline.tsx +47 -0
  137. package/src/cli/cmd/tui/routes/session/footer.tsx +91 -0
  138. package/src/cli/cmd/tui/routes/session/git-commit.tsx +59 -0
  139. package/src/cli/cmd/tui/routes/session/git-history.tsx +122 -0
  140. package/src/cli/cmd/tui/routes/session/header.tsx +185 -0
  141. package/src/cli/cmd/tui/routes/session/index.tsx +2363 -0
  142. package/src/cli/cmd/tui/routes/session/navigator-ui.tsx +214 -0
  143. package/src/cli/cmd/tui/routes/session/navigator.tsx +1124 -0
  144. package/src/cli/cmd/tui/routes/session/panel-explorer.tsx +553 -0
  145. package/src/cli/cmd/tui/routes/session/panel-viewer.tsx +386 -0
  146. package/src/cli/cmd/tui/routes/session/permission.tsx +501 -0
  147. package/src/cli/cmd/tui/routes/session/question.tsx +507 -0
  148. package/src/cli/cmd/tui/routes/session/sidebar.tsx +365 -0
  149. package/src/cli/cmd/tui/routes/session/vcs-diff-viewer.tsx +37 -0
  150. package/src/cli/cmd/tui/routes/ui-settings.tsx +449 -0
  151. package/src/cli/cmd/tui/thread.ts +172 -0
  152. package/src/cli/cmd/tui/ui/dialog-alert.tsx +90 -0
  153. package/src/cli/cmd/tui/ui/dialog-confirm.tsx +83 -0
  154. package/src/cli/cmd/tui/ui/dialog-export-options.tsx +204 -0
  155. package/src/cli/cmd/tui/ui/dialog-help.tsx +38 -0
  156. package/src/cli/cmd/tui/ui/dialog-prompt.tsx +77 -0
  157. package/src/cli/cmd/tui/ui/dialog-select.tsx +384 -0
  158. package/src/cli/cmd/tui/ui/dialog.tsx +170 -0
  159. package/src/cli/cmd/tui/ui/link.tsx +28 -0
  160. package/src/cli/cmd/tui/ui/spinner.ts +375 -0
  161. package/src/cli/cmd/tui/ui/toast.tsx +100 -0
  162. package/src/cli/cmd/tui/util/clipboard.ts +255 -0
  163. package/src/cli/cmd/tui/util/editor.ts +32 -0
  164. package/src/cli/cmd/tui/util/signal.ts +7 -0
  165. package/src/cli/cmd/tui/util/terminal.ts +114 -0
  166. package/src/cli/cmd/tui/util/transcript.ts +98 -0
  167. package/src/cli/cmd/tui/worker.ts +152 -0
  168. package/src/cli/cmd/uninstall.ts +362 -0
  169. package/src/cli/cmd/upgrade.ts +73 -0
  170. package/src/cli/cmd/web.ts +81 -0
  171. package/src/cli/error.ts +57 -0
  172. package/src/cli/network.ts +53 -0
  173. package/src/cli/ui.ts +119 -0
  174. package/src/cli/upgrade.ts +25 -0
  175. package/src/command/index.ts +131 -0
  176. package/src/command/template/initialize.txt +10 -0
  177. package/src/command/template/review.txt +99 -0
  178. package/src/config/config.ts +1404 -0
  179. package/src/config/markdown.ts +93 -0
  180. package/src/env/index.ts +26 -0
  181. package/src/file/ignore.ts +83 -0
  182. package/src/file/index.ts +432 -0
  183. package/src/file/ripgrep.ts +407 -0
  184. package/src/file/time.ts +69 -0
  185. package/src/file/watcher.ts +127 -0
  186. package/src/flag/flag.ts +80 -0
  187. package/src/format/formatter.ts +357 -0
  188. package/src/format/index.ts +137 -0
  189. package/src/global/index.ts +58 -0
  190. package/src/id/id.ts +83 -0
  191. package/src/ide/index.ts +76 -0
  192. package/src/index.ts +208 -0
  193. package/src/installation/index.ts +258 -0
  194. package/src/lsp/client.ts +252 -0
  195. package/src/lsp/index.ts +485 -0
  196. package/src/lsp/language.ts +119 -0
  197. package/src/lsp/server.ts +2046 -0
  198. package/src/mcp/auth.ts +135 -0
  199. package/src/mcp/index.ts +934 -0
  200. package/src/mcp/oauth-callback.ts +200 -0
  201. package/src/mcp/oauth-provider.ts +155 -0
  202. package/src/patch/index.ts +680 -0
  203. package/src/permission/arity.ts +163 -0
  204. package/src/permission/index.ts +210 -0
  205. package/src/permission/next.ts +280 -0
  206. package/src/plugin/codex.ts +500 -0
  207. package/src/plugin/copilot.ts +283 -0
  208. package/src/plugin/index.ts +135 -0
  209. package/src/project/bootstrap.ts +35 -0
  210. package/src/project/instance.ts +91 -0
  211. package/src/project/project.ts +371 -0
  212. package/src/project/state.ts +66 -0
  213. package/src/project/vcs.ts +151 -0
  214. package/src/provider/auth.ts +147 -0
  215. package/src/provider/models-macro.ts +14 -0
  216. package/src/provider/models.ts +114 -0
  217. package/src/provider/provider.ts +1220 -0
  218. package/src/provider/sdk/openai-compatible/src/README.md +5 -0
  219. package/src/provider/sdk/openai-compatible/src/index.ts +2 -0
  220. package/src/provider/sdk/openai-compatible/src/openai-compatible-provider.ts +100 -0
  221. package/src/provider/sdk/openai-compatible/src/responses/convert-to-openai-responses-input.ts +303 -0
  222. package/src/provider/sdk/openai-compatible/src/responses/map-openai-responses-finish-reason.ts +22 -0
  223. package/src/provider/sdk/openai-compatible/src/responses/openai-config.ts +18 -0
  224. package/src/provider/sdk/openai-compatible/src/responses/openai-error.ts +22 -0
  225. package/src/provider/sdk/openai-compatible/src/responses/openai-responses-api-types.ts +207 -0
  226. package/src/provider/sdk/openai-compatible/src/responses/openai-responses-language-model.ts +1732 -0
  227. package/src/provider/sdk/openai-compatible/src/responses/openai-responses-prepare-tools.ts +177 -0
  228. package/src/provider/sdk/openai-compatible/src/responses/openai-responses-settings.ts +1 -0
  229. package/src/provider/sdk/openai-compatible/src/responses/tool/code-interpreter.ts +88 -0
  230. package/src/provider/sdk/openai-compatible/src/responses/tool/file-search.ts +128 -0
  231. package/src/provider/sdk/openai-compatible/src/responses/tool/image-generation.ts +115 -0
  232. package/src/provider/sdk/openai-compatible/src/responses/tool/local-shell.ts +65 -0
  233. package/src/provider/sdk/openai-compatible/src/responses/tool/web-search-preview.ts +104 -0
  234. package/src/provider/sdk/openai-compatible/src/responses/tool/web-search.ts +103 -0
  235. package/src/provider/transform.ts +742 -0
  236. package/src/pty/index.ts +241 -0
  237. package/src/question/index.ts +176 -0
  238. package/src/scheduler/index.ts +61 -0
  239. package/src/server/error.ts +36 -0
  240. package/src/server/event.ts +7 -0
  241. package/src/server/mdns.ts +59 -0
  242. package/src/server/routes/config.ts +92 -0
  243. package/src/server/routes/experimental.ts +208 -0
  244. package/src/server/routes/file.ts +227 -0
  245. package/src/server/routes/global.ts +135 -0
  246. package/src/server/routes/mcp.ts +225 -0
  247. package/src/server/routes/permission.ts +68 -0
  248. package/src/server/routes/project.ts +82 -0
  249. package/src/server/routes/provider.ts +165 -0
  250. package/src/server/routes/pty.ts +169 -0
  251. package/src/server/routes/question.ts +98 -0
  252. package/src/server/routes/session.ts +939 -0
  253. package/src/server/routes/tui.ts +379 -0
  254. package/src/server/server.ts +663 -0
  255. package/src/session/compaction.ts +225 -0
  256. package/src/session/index.ts +498 -0
  257. package/src/session/llm.ts +288 -0
  258. package/src/session/message-v2.ts +740 -0
  259. package/src/session/message.ts +189 -0
  260. package/src/session/processor.ts +406 -0
  261. package/src/session/prompt/anthropic-20250930.txt +168 -0
  262. package/src/session/prompt/anthropic.txt +172 -0
  263. package/src/session/prompt/anthropic_spoof.txt +1 -0
  264. package/src/session/prompt/beast.txt +149 -0
  265. package/src/session/prompt/build-switch.txt +5 -0
  266. package/src/session/prompt/codex_header.txt +81 -0
  267. package/src/session/prompt/copilot-gpt-5.txt +145 -0
  268. package/src/session/prompt/gemini.txt +157 -0
  269. package/src/session/prompt/max-steps.txt +16 -0
  270. package/src/session/prompt/plan-reminder-anthropic.txt +67 -0
  271. package/src/session/prompt/plan.txt +26 -0
  272. package/src/session/prompt/qwen.txt +111 -0
  273. package/src/session/prompt.ts +1815 -0
  274. package/src/session/retry.ts +90 -0
  275. package/src/session/revert.ts +121 -0
  276. package/src/session/status.ts +76 -0
  277. package/src/session/summary.ts +150 -0
  278. package/src/session/system.ts +156 -0
  279. package/src/session/todo.ts +37 -0
  280. package/src/share/share-next.ts +204 -0
  281. package/src/share/share.ts +95 -0
  282. package/src/shell/shell.ts +67 -0
  283. package/src/skill/index.ts +1 -0
  284. package/src/skill/skill.ts +135 -0
  285. package/src/snapshot/index.ts +236 -0
  286. package/src/storage/storage.ts +227 -0
  287. package/src/tool/apply_patch.ts +279 -0
  288. package/src/tool/apply_patch.txt +33 -0
  289. package/src/tool/bash.ts +258 -0
  290. package/src/tool/bash.txt +115 -0
  291. package/src/tool/batch.ts +175 -0
  292. package/src/tool/batch.txt +24 -0
  293. package/src/tool/codesearch.ts +132 -0
  294. package/src/tool/codesearch.txt +12 -0
  295. package/src/tool/edit.ts +645 -0
  296. package/src/tool/edit.txt +10 -0
  297. package/src/tool/external-directory.ts +32 -0
  298. package/src/tool/glob.ts +77 -0
  299. package/src/tool/glob.txt +6 -0
  300. package/src/tool/grep.ts +154 -0
  301. package/src/tool/grep.txt +8 -0
  302. package/src/tool/invalid.ts +17 -0
  303. package/src/tool/ls.ts +121 -0
  304. package/src/tool/ls.txt +1 -0
  305. package/src/tool/lsp.ts +96 -0
  306. package/src/tool/lsp.txt +19 -0
  307. package/src/tool/multiedit.ts +46 -0
  308. package/src/tool/multiedit.txt +41 -0
  309. package/src/tool/plan-enter.txt +14 -0
  310. package/src/tool/plan-exit.txt +13 -0
  311. package/src/tool/plan.ts +130 -0
  312. package/src/tool/question.ts +33 -0
  313. package/src/tool/question.txt +10 -0
  314. package/src/tool/read.ts +202 -0
  315. package/src/tool/read.txt +12 -0
  316. package/src/tool/registry.ts +162 -0
  317. package/src/tool/skill.ts +82 -0
  318. package/src/tool/task.ts +188 -0
  319. package/src/tool/task.txt +60 -0
  320. package/src/tool/todo.ts +53 -0
  321. package/src/tool/todoread.txt +14 -0
  322. package/src/tool/todowrite.txt +167 -0
  323. package/src/tool/tool.ts +88 -0
  324. package/src/tool/truncation.ts +106 -0
  325. package/src/tool/webfetch.ts +182 -0
  326. package/src/tool/webfetch.txt +13 -0
  327. package/src/tool/websearch.ts +150 -0
  328. package/src/tool/websearch.txt +14 -0
  329. package/src/tool/write.ts +80 -0
  330. package/src/tool/write.txt +8 -0
  331. package/src/util/archive.ts +16 -0
  332. package/src/util/color.ts +19 -0
  333. package/src/util/context.ts +25 -0
  334. package/src/util/defer.ts +12 -0
  335. package/src/util/eventloop.ts +20 -0
  336. package/src/util/filesystem.ts +93 -0
  337. package/src/util/fn.ts +11 -0
  338. package/src/util/format.ts +20 -0
  339. package/src/util/iife.ts +3 -0
  340. package/src/util/keybind.ts +103 -0
  341. package/src/util/lazy.ts +18 -0
  342. package/src/util/locale.ts +81 -0
  343. package/src/util/lock.ts +98 -0
  344. package/src/util/log.ts +180 -0
  345. package/src/util/queue.ts +32 -0
  346. package/src/util/rpc.ts +66 -0
  347. package/src/util/scrap.ts +10 -0
  348. package/src/util/signal.ts +12 -0
  349. package/src/util/timeout.ts +14 -0
  350. package/src/util/token.ts +7 -0
  351. package/src/util/wildcard.ts +56 -0
  352. package/src/worktree/index.ts +524 -0
  353. package/sst-env.d.ts +9 -0
  354. package/test/acp/agent-interface.test.ts +51 -0
  355. package/test/acp/event-subscription.test.ts +436 -0
  356. package/test/agent/agent.test.ts +638 -0
  357. package/test/bun.test.ts +53 -0
  358. package/test/cli/cmd/tui/fileref.test.ts +30 -0
  359. package/test/cli/github-action.test.ts +129 -0
  360. package/test/cli/github-remote.test.ts +80 -0
  361. package/test/cli/tui/navigator_logic.test.ts +99 -0
  362. package/test/cli/tui/transcript.test.ts +297 -0
  363. package/test/cli/ui.test.ts +80 -0
  364. package/test/config/agent-color.test.ts +66 -0
  365. package/test/config/config.test.ts +1613 -0
  366. package/test/config/fixtures/empty-frontmatter.md +4 -0
  367. package/test/config/fixtures/frontmatter.md +28 -0
  368. package/test/config/fixtures/no-frontmatter.md +1 -0
  369. package/test/config/markdown.test.ts +192 -0
  370. package/test/file/ignore.test.ts +10 -0
  371. package/test/file/path-traversal.test.ts +198 -0
  372. package/test/fixture/fixture.ts +45 -0
  373. package/test/fixture/lsp/fake-lsp-server.js +77 -0
  374. package/test/ide/ide.test.ts +82 -0
  375. package/test/keybind.test.ts +421 -0
  376. package/test/lsp/client.test.ts +95 -0
  377. package/test/mcp/headers.test.ts +153 -0
  378. package/test/mcp/oauth-browser.test.ts +261 -0
  379. package/test/patch/patch.test.ts +348 -0
  380. package/test/permission/arity.test.ts +33 -0
  381. package/test/permission/next.test.ts +690 -0
  382. package/test/permission-task.test.ts +319 -0
  383. package/test/plugin/codex.test.ts +123 -0
  384. package/test/preload.ts +67 -0
  385. package/test/project/project.test.ts +120 -0
  386. package/test/provider/amazon-bedrock.test.ts +268 -0
  387. package/test/provider/gitlab-duo.test.ts +286 -0
  388. package/test/provider/provider.test.ts +2149 -0
  389. package/test/provider/transform.test.ts +1631 -0
  390. package/test/question/question.test.ts +300 -0
  391. package/test/scheduler.test.ts +73 -0
  392. package/test/server/session-list.test.ts +39 -0
  393. package/test/server/session-select.test.ts +78 -0
  394. package/test/session/compaction.test.ts +293 -0
  395. package/test/session/llm.test.ts +90 -0
  396. package/test/session/message-v2.test.ts +786 -0
  397. package/test/session/retry.test.ts +131 -0
  398. package/test/session/revert-compact.test.ts +285 -0
  399. package/test/session/session.test.ts +71 -0
  400. package/test/skill/skill.test.ts +185 -0
  401. package/test/snapshot/snapshot.test.ts +939 -0
  402. package/test/tool/__snapshots__/tool.test.ts.snap +9 -0
  403. package/test/tool/apply_patch.test.ts +499 -0
  404. package/test/tool/bash.test.ts +320 -0
  405. package/test/tool/external-directory.test.ts +126 -0
  406. package/test/tool/fixtures/large-image.png +0 -0
  407. package/test/tool/fixtures/models-api.json +33453 -0
  408. package/test/tool/grep.test.ts +109 -0
  409. package/test/tool/question.test.ts +105 -0
  410. package/test/tool/read.test.ts +332 -0
  411. package/test/tool/registry.test.ts +76 -0
  412. package/test/tool/truncation.test.ts +159 -0
  413. package/test/util/filesystem.test.ts +39 -0
  414. package/test/util/format.test.ts +59 -0
  415. package/test/util/iife.test.ts +36 -0
  416. package/test/util/lazy.test.ts +50 -0
  417. package/test/util/lock.test.ts +72 -0
  418. package/test/util/timeout.test.ts +21 -0
  419. package/test/util/wildcard.test.ts +75 -0
  420. package/tsconfig.json +16 -0
@@ -0,0 +1,2363 @@
1
+ import {
2
+ batch,
3
+ createContext,
4
+ createEffect,
5
+ createMemo,
6
+ createSignal,
7
+ For,
8
+ Match,
9
+ on,
10
+ onCleanup,
11
+ onMount,
12
+ Show,
13
+ Switch,
14
+ useContext,
15
+ } from "solid-js"
16
+ import { Dynamic } from "solid-js/web"
17
+ import "opentui-spinner/solid"
18
+ import path from "path"
19
+ import { useRoute, useRouteData } from "@tui/context/route"
20
+ import { useSync } from "@tui/context/sync"
21
+ import { SplitBorder } from "@tui/component/border"
22
+ import { useTheme } from "@tui/context/theme"
23
+ import {
24
+ BoxRenderable,
25
+ type MouseEvent,
26
+ ScrollBoxRenderable,
27
+ addDefaultParsers,
28
+ MacOSScrollAccel,
29
+ type ScrollAcceleration,
30
+ TextAttributes,
31
+ RGBA,
32
+ } from "@opentui/core"
33
+ import { Prompt, type PromptRef } from "@tui/component/prompt"
34
+ import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@jonsoc/sdk/v2"
35
+ import { useLocal } from "@tui/context/local"
36
+ import { Locale } from "@/util/locale"
37
+ import type { Tool } from "@/tool/tool"
38
+ import type { ReadTool } from "@/tool/read"
39
+ import type { WriteTool } from "@/tool/write"
40
+ import { BashTool } from "@/tool/bash"
41
+ import type { GlobTool } from "@/tool/glob"
42
+ import { TodoWriteTool } from "@/tool/todo"
43
+ import type { GrepTool } from "@/tool/grep"
44
+ import type { ListTool } from "@/tool/ls"
45
+ import type { EditTool } from "@/tool/edit"
46
+ import type { ApplyPatchTool } from "@/tool/apply_patch"
47
+ import type { WebFetchTool } from "@/tool/webfetch"
48
+ import type { TaskTool } from "@/tool/task"
49
+ import type { QuestionTool } from "@/tool/question"
50
+ import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
51
+ import { useSDK } from "@tui/context/sdk"
52
+ import { useCommandDialog } from "@tui/component/dialog-command"
53
+ import { useKeybind } from "@tui/context/keybind"
54
+ import { Header } from "./header"
55
+ import { parsePatch } from "diff"
56
+ import { useDialog } from "../../ui/dialog"
57
+ import { TodoItem } from "../../component/todo-item"
58
+ import { DialogMessage } from "./dialog-message"
59
+ import type { PromptInfo } from "../../component/prompt/history"
60
+ import { DialogConfirm } from "@tui/ui/dialog-confirm"
61
+ import { DialogTimeline } from "./dialog-timeline"
62
+ import { DialogForkFromTimeline } from "./dialog-fork-from-timeline"
63
+ import { DialogSessionRename } from "../../component/dialog-session-rename"
64
+ import { ExplorerPanel } from "./panel-explorer"
65
+ import { FileViewerPanel } from "./panel-viewer"
66
+ import { DynamicLayout } from "@tui/component/dynamic-layout"
67
+ import { useLayout } from "@tui/context/layout"
68
+ import { useCommandRegistry } from "../../hooks/use-command-registry"
69
+ import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
70
+ import parsers from "../../../../../../parsers-config.ts"
71
+ import { Clipboard } from "../../util/clipboard"
72
+ import { Toast, useToast } from "../../ui/toast"
73
+ import { useKV } from "../../context/kv.tsx"
74
+ import { Editor } from "../../util/editor"
75
+ import stripAnsi from "strip-ansi"
76
+ import { Footer } from "./footer.tsx"
77
+ import { usePromptRef } from "../../context/prompt"
78
+ import { useExit } from "../../context/exit"
79
+ import { Filesystem } from "@/util/filesystem"
80
+ import { Global } from "@/global"
81
+ import { PermissionPrompt } from "./permission"
82
+ import { QuestionPrompt } from "./question"
83
+ import { DialogExportOptions } from "../../ui/dialog-export-options"
84
+ import { formatTranscript } from "../../util/transcript"
85
+ import { createColors, createFrames } from "../../ui/spinner.ts"
86
+
87
+ addDefaultParsers(parsers.parsers)
88
+
89
+ class CustomSpeedScroll implements ScrollAcceleration {
90
+ constructor(private speed: number) {}
91
+
92
+ tick(_now?: number): number {
93
+ return this.speed
94
+ }
95
+
96
+ reset(): void {}
97
+ }
98
+
99
+ const context = createContext<{
100
+ width: number
101
+ sessionID: string
102
+ conceal: () => boolean
103
+ showThinking: () => boolean
104
+ showTimestamps: () => boolean
105
+ showDetails: () => boolean
106
+ diffWrapMode: () => "word" | "none"
107
+ sync: ReturnType<typeof useSync>
108
+ }>()
109
+
110
+ function use() {
111
+ const ctx = useContext(context)
112
+ if (!ctx) throw new Error("useContext must be used within a Session component")
113
+ return ctx
114
+ }
115
+
116
+ export function Session() {
117
+ const route = useRouteData("session")
118
+ const { navigate } = useRoute()
119
+ const sync = useSync()
120
+ const kv = useKV()
121
+ const { theme } = useTheme()
122
+ const promptRef = usePromptRef()
123
+ const layout = useLayout()
124
+ const [selectedFilePath, setSelectedFilePath] = createSignal<string | null>(null)
125
+
126
+ // Declare scroll ref as signal for reactivity
127
+ const [scroll, setScroll] = createSignal<ScrollBoxRenderable | undefined>(undefined)
128
+
129
+ // Initialize dimensions hook BEFORE any memos that depend on it
130
+ const dimensions = useTerminalDimensions()
131
+
132
+ // Reactive panel widths that update when layout store changes
133
+ const explorerWidth = createMemo(() => {
134
+ const panel = layout.getPanelByType("explorer")
135
+ if (!panel?.visible) return 0
136
+ const dims = dimensions()
137
+ if (!dims?.width) return 0
138
+ return Math.floor(dims.width * ((panel.width || 20) / 100))
139
+ })
140
+
141
+ const viewerWidth = createMemo(() => {
142
+ const panel = layout.getPanelByType("viewer")
143
+ if (!panel?.visible) return 0
144
+ const dims = dimensions()
145
+ if (!dims?.width) return 0
146
+ return Math.floor(dims.width * ((panel.width || 30) / 100))
147
+ })
148
+ const session = createMemo(() => sync.session.get(route.sessionID))
149
+ const children = createMemo(() => {
150
+ const parentID = session()?.parentID ?? session()?.id
151
+ return sync.data.session
152
+ .filter((x) => x.parentID === parentID || x.id === parentID)
153
+ .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
154
+ })
155
+ const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
156
+ const permissions = createMemo(() => {
157
+ if (session()?.parentID) return []
158
+ return children().flatMap((x) => sync.data.permission[x.id] ?? [])
159
+ })
160
+ const questions = createMemo(() => {
161
+ if (session()?.parentID) return []
162
+ return children().flatMap((x) => sync.data.question[x.id] ?? [])
163
+ })
164
+
165
+ const pending = createMemo(() => {
166
+ return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
167
+ })
168
+
169
+ const lastAssistant = createMemo(() => {
170
+ return messages().findLast((x) => x.role === "assistant")
171
+ })
172
+ const [navigatorTab, setNavigatorTab] = kv.signal<"explorer" | "git">("navigator_tab", "explorer")
173
+ const [conceal, setConceal] = createSignal(true)
174
+ const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true)
175
+ const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
176
+ const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
177
+ const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
178
+ const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_enabled", true)
179
+ const [diffWrapMode, setDiffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "none")
180
+ const [navigatorWrapMode, setNavigatorWrapMode] = kv.signal<"word" | "none">("navigator_wrap_mode", "none")
181
+ const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
182
+
183
+ const wide = createMemo(() => {
184
+ const dims = dimensions()
185
+ return dims?.width > 120
186
+ })
187
+ const showTimestamps = createMemo(() => timestamps() === "show")
188
+
189
+ const scrollAcceleration = createMemo(() => {
190
+ const tui = sync.data.config.tui
191
+ if (tui?.scroll_acceleration?.enabled) {
192
+ return new MacOSScrollAccel()
193
+ }
194
+ if (tui?.scroll_speed) {
195
+ return new CustomSpeedScroll(tui.scroll_speed)
196
+ }
197
+
198
+ return new CustomSpeedScroll(3)
199
+ })
200
+
201
+ createEffect(async () => {
202
+ await sync.session
203
+ .sync(route.sessionID)
204
+ .then(() => {
205
+ if (scroll) scroll()!.scrollBy(100_000)
206
+ })
207
+ .catch((e) => {
208
+ console.error(e)
209
+ toast.show({
210
+ message: `Session not found: ${route.sessionID}`,
211
+ variant: "error",
212
+ })
213
+ return navigate({ type: "home" })
214
+ })
215
+ })
216
+
217
+ const toast = useToast()
218
+ const sdk = useSDK()
219
+
220
+ // Handle initial prompt from fork
221
+ createEffect(() => {
222
+ if (route.initialPrompt && prompt) {
223
+ prompt.set(route.initialPrompt)
224
+ }
225
+ })
226
+
227
+ let lastSwitch: string | undefined = undefined
228
+ sdk.event.on("message.part.updated", (evt) => {
229
+ const part = evt.properties.part
230
+ if (part.type !== "tool") return
231
+ if (part.sessionID !== route.sessionID) return
232
+ if (part.state.status !== "completed") return
233
+ if (part.id === lastSwitch) return
234
+
235
+ if (part.tool === "plan_exit") {
236
+ local.agent.set("build")
237
+ lastSwitch = part.id
238
+ } else if (part.tool === "plan_enter") {
239
+ local.agent.set("plan")
240
+ lastSwitch = part.id
241
+ }
242
+ })
243
+
244
+ let prompt: PromptRef
245
+ const keybind = useKeybind()
246
+
247
+ // Allow exit when in child session (prompt is hidden)
248
+ const exit = useExit()
249
+ const dialog = useDialog()
250
+ useKeyboard((evt) => {
251
+ if (dialog.isOpen()) return
252
+ if (!session()?.parentID) return
253
+ if (keybind.match("app_exit", evt)) {
254
+ exit()
255
+ }
256
+ })
257
+
258
+ // Helper: Find next visible message boundary in direction
259
+ const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
260
+ const children = scroll()!.getChildren()
261
+ const messagesList = messages()
262
+ const scrollTop = scroll()!.y
263
+
264
+ // Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content
265
+ const visibleMessages = children
266
+ .filter((c) => {
267
+ if (!c.id) return false
268
+ const message = messagesList.find((m) => m.id === c.id)
269
+ if (!message) return false
270
+
271
+ // Check if message has valid non-synthetic, non-ignored text parts
272
+ const parts = sync.data.part[message.id]
273
+ if (!parts || !Array.isArray(parts)) return false
274
+
275
+ return parts.some((part) => part && part.type === "text" && !part.synthetic && !part.ignored)
276
+ })
277
+ .sort((a, b) => a.y - b.y)
278
+
279
+ if (visibleMessages.length === 0) return null
280
+
281
+ if (direction === "next") {
282
+ // Find first message below current position
283
+ return visibleMessages.find((c) => c.y > scrollTop + 10)?.id ?? null
284
+ }
285
+ // Find last message above current position
286
+ return [...visibleMessages].reverse().find((c) => c.y < scrollTop - 10)?.id ?? null
287
+ }
288
+
289
+ // Helper: Scroll to message in direction or fallback to page scroll
290
+ const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>) => {
291
+ const targetID = findNextVisibleMessage(direction)
292
+
293
+ if (!targetID) {
294
+ scroll()!.scrollBy(direction === "next" ? scroll()!.height : -scroll()!.height)
295
+ dialog.clear()
296
+ return
297
+ }
298
+
299
+ const child = scroll()!
300
+ .getChildren()
301
+ .find((c) => c.id === targetID)
302
+ if (child) scroll()!.scrollBy(child.y - scroll()!.y - 1)
303
+ dialog.clear()
304
+ }
305
+
306
+ function toBottom() {
307
+ setTimeout(() => {
308
+ const s = scroll()
309
+ if (s) s.scrollTo(s.scrollHeight)
310
+ }, 50)
311
+ }
312
+
313
+ const local = useLocal()
314
+
315
+ function moveChild(direction: number) {
316
+ if (children().length === 1) return
317
+ let next = children().findIndex((x) => x.id === session()?.id) + direction
318
+ if (next >= children().length) next = 0
319
+ if (next < 0) next = children().length - 1
320
+ if (children()[next]) {
321
+ navigate({
322
+ type: "session",
323
+ sessionID: children()[next].id,
324
+ })
325
+ }
326
+ }
327
+
328
+ const command = useCommandDialog()
329
+
330
+ const closeNavigator = () => {
331
+ // Just focus the prompt, layout controls visibility now
332
+ promptRef.current?.focus()
333
+ }
334
+
335
+ // Register layout commands through centralized registry
336
+ const layoutHelpers = useCommandRegistry({
337
+ groups: ["layout", "system"],
338
+ returnTo: { type: "session", sessionID: route.sessionID },
339
+ })
340
+
341
+ // Session-specific commands (should eventually be moved to registry)
342
+ command.register(() => [
343
+ {
344
+ title: "Share session",
345
+ value: "session.share",
346
+ suggested: route.type === "session",
347
+ keybind: "session_share",
348
+ category: "Session",
349
+ enabled: sync.data.config.share !== "disabled" && !session()?.share?.url,
350
+ slash: {
351
+ name: "share",
352
+ },
353
+ onSelect: async (dialog) => {
354
+ await sdk.client.session
355
+ .share({
356
+ sessionID: route.sessionID,
357
+ })
358
+ .then((res) =>
359
+ Clipboard.copy(res.data!.share!.url).catch(() =>
360
+ toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }),
361
+ ),
362
+ )
363
+ .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
364
+ .catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
365
+ dialog.clear()
366
+ },
367
+ },
368
+ {
369
+ title: "Rename session",
370
+ value: "session.rename",
371
+ keybind: "session_rename",
372
+ category: "Session",
373
+ slash: {
374
+ name: "rename",
375
+ },
376
+ onSelect: (dialog) => {
377
+ dialog.replace(() => <DialogSessionRename session={route.sessionID} />)
378
+ },
379
+ },
380
+ {
381
+ title: "Jump to message",
382
+ value: "session.timeline",
383
+ keybind: "session_timeline",
384
+ category: "Session",
385
+ slash: {
386
+ name: "timeline",
387
+ },
388
+ onSelect: (dialog) => {
389
+ dialog.replace(() => (
390
+ <DialogTimeline
391
+ onMove={(messageID) => {
392
+ const child = scroll()!
393
+ .getChildren()
394
+ .find((child) => {
395
+ return child.id === messageID
396
+ })
397
+ if (child) scroll()!.scrollBy(child.y - scroll()!.y - 1)
398
+ }}
399
+ sessionID={route.sessionID}
400
+ setPrompt={(promptInfo) => prompt.set(promptInfo)}
401
+ />
402
+ ))
403
+ },
404
+ },
405
+ {
406
+ title: "Fork from message",
407
+ value: "session.fork",
408
+ keybind: "session_fork",
409
+ category: "Session",
410
+ slash: {
411
+ name: "fork",
412
+ },
413
+ onSelect: (dialog) => {
414
+ dialog.replace(() => (
415
+ <DialogForkFromTimeline
416
+ onMove={(messageID) => {
417
+ const child = scroll()!
418
+ .getChildren()
419
+ .find((child) => {
420
+ return child.id === messageID
421
+ })
422
+ if (child) scroll()!.scrollBy(child.y - scroll()!.y - 1)
423
+ }}
424
+ sessionID={route.sessionID}
425
+ />
426
+ ))
427
+ },
428
+ },
429
+ {
430
+ title: "Compact session",
431
+ value: "session.compact",
432
+ keybind: "session_compact",
433
+ category: "Session",
434
+ slash: {
435
+ name: "compact",
436
+ aliases: ["summarize"],
437
+ },
438
+ onSelect: (dialog) => {
439
+ const selectedModel = local.model.current()
440
+ if (!selectedModel) {
441
+ toast.show({
442
+ variant: "warning",
443
+ message: "Connect a provider to summarize this session",
444
+ duration: 3000,
445
+ })
446
+ return
447
+ }
448
+ sdk.client.session.summarize({
449
+ sessionID: route.sessionID,
450
+ modelID: selectedModel.modelID,
451
+ providerID: selectedModel.providerID,
452
+ })
453
+ dialog.clear()
454
+ },
455
+ },
456
+ {
457
+ title: "Unshare session",
458
+ value: "session.unshare",
459
+ keybind: "session_unshare",
460
+ category: "Session",
461
+ enabled: !!session()?.share?.url,
462
+ slash: {
463
+ name: "unshare",
464
+ },
465
+ onSelect: async (dialog) => {
466
+ await sdk.client.session
467
+ .unshare({
468
+ sessionID: route.sessionID,
469
+ })
470
+ .then(() => toast.show({ message: "Session unshared successfully", variant: "success" }))
471
+ .catch(() => toast.show({ message: "Failed to unshare session", variant: "error" }))
472
+ dialog.clear()
473
+ },
474
+ },
475
+ {
476
+ title: "Undo previous message",
477
+ value: "session.undo",
478
+ keybind: "messages_undo",
479
+ category: "Session",
480
+ slash: {
481
+ name: "undo",
482
+ },
483
+ onSelect: async (dialog) => {
484
+ const status = sync.data.session_status?.[route.sessionID]
485
+ if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {})
486
+ const revert = session()?.revert?.messageID
487
+ const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user")
488
+ if (!message) return
489
+ sdk.client.session
490
+ .revert({
491
+ sessionID: route.sessionID,
492
+ messageID: message.id,
493
+ })
494
+ .then(() => {
495
+ toBottom()
496
+ })
497
+ const parts = sync.data.part[message.id]
498
+ prompt.set(
499
+ parts.reduce(
500
+ (agg, part) => {
501
+ if (part.type === "text") {
502
+ if (!part.synthetic) agg.input += part.text
503
+ }
504
+ if (part.type === "file") agg.parts.push(part)
505
+ return agg
506
+ },
507
+ { input: "", parts: [] as PromptInfo["parts"] },
508
+ ),
509
+ )
510
+ dialog.clear()
511
+ },
512
+ },
513
+ {
514
+ title: "Redo",
515
+ value: "session.redo",
516
+ keybind: "messages_redo",
517
+ category: "Session",
518
+ enabled: !!session()?.revert?.messageID,
519
+ slash: {
520
+ name: "redo",
521
+ },
522
+ onSelect: (dialog) => {
523
+ dialog.clear()
524
+ const messageID = session()?.revert?.messageID
525
+ if (!messageID) return
526
+ const message = messages().find((x) => x.role === "user" && x.id > messageID)
527
+ if (!message) {
528
+ sdk.client.session.unrevert({
529
+ sessionID: route.sessionID,
530
+ })
531
+ prompt.set({ input: "", parts: [] })
532
+ return
533
+ }
534
+ sdk.client.session.revert({
535
+ sessionID: route.sessionID,
536
+ messageID: message.id,
537
+ })
538
+ },
539
+ },
540
+ {
541
+ title: "Toggle code concealment",
542
+ value: "session.toggle.conceal",
543
+ keybind: "messages_toggle_conceal" as any,
544
+ category: "Session",
545
+ onSelect: (dialog) => {
546
+ setConceal((prev) => !prev)
547
+ dialog.clear()
548
+ },
549
+ },
550
+ {
551
+ title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
552
+ value: "session.toggle.timestamps",
553
+ category: "Session",
554
+ slash: {
555
+ name: "timestamps",
556
+ aliases: ["toggle-timestamps"],
557
+ },
558
+ onSelect: (dialog) => {
559
+ setTimestamps((prev) => (prev === "show" ? "hide" : "show"))
560
+ dialog.clear()
561
+ },
562
+ },
563
+ {
564
+ title: showThinking() ? "Hide thinking" : "Show thinking",
565
+ value: "session.toggle.thinking",
566
+ category: "Session",
567
+ slash: {
568
+ name: "thinking",
569
+ aliases: ["toggle-thinking"],
570
+ },
571
+ onSelect: (dialog) => {
572
+ setShowThinking((prev) => !prev)
573
+ dialog.clear()
574
+ },
575
+ },
576
+ {
577
+ title: diffWrapMode() === "word" ? "Diff: Disable line wrap" : "Diff: Enable line wrap",
578
+ value: "session.toggle.diffwrap",
579
+ category: "Session",
580
+ slash: {
581
+ name: "diffwrap",
582
+ },
583
+ onSelect: (dialog) => {
584
+ setDiffWrapMode((prev) => (prev === "word" ? "none" : "word"))
585
+ dialog.clear()
586
+ },
587
+ },
588
+ {
589
+ title: navigatorWrapMode() === "word" ? "Navigator: Disable line wrap" : "Navigator: Enable line wrap",
590
+ value: "session.toggle.navigatorwrap",
591
+ category: "Navigator",
592
+ slash: {
593
+ name: "navwrap",
594
+ },
595
+ onSelect: (dialog) => {
596
+ setNavigatorWrapMode(() => (navigatorWrapMode() === "word" ? "none" : "word"))
597
+ dialog.clear()
598
+ },
599
+ },
600
+ {
601
+ title: showDetails() ? "Hide tool details" : "Show tool details",
602
+ value: "session.toggle.actions",
603
+ keybind: "tool_details",
604
+ category: "Session",
605
+ onSelect: (dialog) => {
606
+ setShowDetails((prev) => !prev)
607
+ dialog.clear()
608
+ },
609
+ },
610
+ {
611
+ title: "Toggle session scrollbar",
612
+ value: "session.toggle.scrollbar",
613
+ keybind: "scrollbar_toggle",
614
+ category: "Session",
615
+ onSelect: (dialog) => {
616
+ setShowScrollbar((prev) => !prev)
617
+ dialog.clear()
618
+ },
619
+ },
620
+ {
621
+ title: animationsEnabled() ? "Disable animations" : "Enable animations",
622
+ value: "session.toggle.animations",
623
+ category: "Session",
624
+ onSelect: (dialog) => {
625
+ setAnimationsEnabled((prev) => !prev)
626
+ dialog.clear()
627
+ },
628
+ },
629
+ {
630
+ title: "Page up",
631
+ value: "session.page.up",
632
+ keybind: "messages_page_up",
633
+ category: "Session",
634
+ hidden: true,
635
+ onSelect: (dialog) => {
636
+ scroll()!.scrollBy(-scroll()!.height / 2)
637
+ dialog.clear()
638
+ },
639
+ },
640
+ {
641
+ title: "Page down",
642
+ value: "session.page.down",
643
+ keybind: "messages_page_down",
644
+ category: "Session",
645
+ hidden: true,
646
+ onSelect: (dialog) => {
647
+ scroll()!.scrollBy(scroll()!.height / 2)
648
+ dialog.clear()
649
+ },
650
+ },
651
+ {
652
+ title: "Line up",
653
+ value: "session.line.up",
654
+ keybind: "messages_line_up",
655
+ category: "Session",
656
+ disabled: true,
657
+ onSelect: (dialog) => {
658
+ scroll()!.scrollBy(-1)
659
+ dialog.clear()
660
+ },
661
+ },
662
+ {
663
+ title: "Line down",
664
+ value: "session.line.down",
665
+ keybind: "messages_line_down",
666
+ category: "Session",
667
+ disabled: true,
668
+ onSelect: (dialog) => {
669
+ scroll()!.scrollBy(1)
670
+ dialog.clear()
671
+ },
672
+ },
673
+ {
674
+ title: "Half page up",
675
+ value: "session.half.page.up",
676
+ keybind: "messages_half_page_up",
677
+ category: "Session",
678
+ hidden: true,
679
+ onSelect: (dialog) => {
680
+ scroll()!.scrollBy(-scroll()!.height / 4)
681
+ dialog.clear()
682
+ },
683
+ },
684
+ {
685
+ title: "Half page down",
686
+ value: "session.half.page.down",
687
+ keybind: "messages_half_page_down",
688
+ category: "Session",
689
+ hidden: true,
690
+ onSelect: (dialog) => {
691
+ scroll()!.scrollBy(scroll()!.height / 4)
692
+ dialog.clear()
693
+ },
694
+ },
695
+ {
696
+ title: "First message",
697
+ value: "session.first",
698
+ keybind: "messages_first",
699
+ category: "Session",
700
+ hidden: true,
701
+ onSelect: (dialog) => {
702
+ const s = scroll()
703
+ if (s) s.scrollTo(0)
704
+ dialog.clear()
705
+ },
706
+ },
707
+ {
708
+ title: "Last message",
709
+ value: "session.last",
710
+ keybind: "messages_last",
711
+ category: "Session",
712
+ hidden: true,
713
+ onSelect: (dialog) => {
714
+ const s = scroll()
715
+ if (s) s.scrollTo(s.scrollHeight)
716
+ dialog.clear()
717
+ },
718
+ },
719
+ {
720
+ title: "Jump to last user message",
721
+ value: "session.messages_last_user",
722
+ keybind: "messages_last_user",
723
+ category: "Session",
724
+ hidden: true,
725
+ onSelect: () => {
726
+ const messages = sync.data.message[route.sessionID]
727
+ if (!messages || !messages.length) return
728
+
729
+ // Find the most recent user message with non-ignored, non-synthetic text parts
730
+ for (let i = messages.length - 1; i >= 0; i--) {
731
+ const message = messages[i]
732
+ if (!message || message.role !== "user") continue
733
+
734
+ const parts = sync.data.part[message.id]
735
+ if (!parts || !Array.isArray(parts)) continue
736
+
737
+ const hasValidTextPart = parts.some(
738
+ (part) => part && part.type === "text" && !part.synthetic && !part.ignored,
739
+ )
740
+
741
+ if (hasValidTextPart) {
742
+ const child = scroll()!
743
+ .getChildren()
744
+ .find((child) => {
745
+ return child.id === message.id
746
+ })
747
+ if (child) scroll()!.scrollBy(child.y - scroll()!.y - 1)
748
+ break
749
+ }
750
+ }
751
+ },
752
+ },
753
+ {
754
+ title: "Next message",
755
+ value: "session.message.next",
756
+ keybind: "messages_next",
757
+ category: "Session",
758
+ hidden: true,
759
+ onSelect: (dialog) => scrollToMessage("next", dialog),
760
+ },
761
+ {
762
+ title: "Previous message",
763
+ value: "session.message.previous",
764
+ keybind: "messages_previous",
765
+ category: "Session",
766
+ hidden: true,
767
+ onSelect: (dialog) => scrollToMessage("prev", dialog),
768
+ },
769
+ {
770
+ title: "Copy last assistant message",
771
+ value: "messages.copy",
772
+ keybind: "messages_copy",
773
+ category: "Session",
774
+ onSelect: (dialog) => {
775
+ const revertID = session()?.revert?.messageID
776
+ const lastAssistantMessage = messages().findLast(
777
+ (msg) => msg.role === "assistant" && (!revertID || msg.id < revertID),
778
+ )
779
+ if (!lastAssistantMessage) {
780
+ toast.show({ message: "No assistant messages found", variant: "error" })
781
+ dialog.clear()
782
+ return
783
+ }
784
+
785
+ const parts = sync.data.part[lastAssistantMessage.id] ?? []
786
+ const textParts = parts.filter((part) => part.type === "text")
787
+ if (textParts.length === 0) {
788
+ toast.show({ message: "No text parts found in last assistant message", variant: "error" })
789
+ dialog.clear()
790
+ return
791
+ }
792
+
793
+ const text = textParts
794
+ .map((part) => part.text)
795
+ .join("\n")
796
+ .trim()
797
+ if (!text) {
798
+ toast.show({
799
+ message: "No text content found in last assistant message",
800
+ variant: "error",
801
+ })
802
+ dialog.clear()
803
+ return
804
+ }
805
+
806
+ Clipboard.copy(text)
807
+ .then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
808
+ .catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
809
+ dialog.clear()
810
+ },
811
+ },
812
+ {
813
+ title: "Copy session transcript",
814
+ value: "session.copy",
815
+ category: "Session",
816
+ slash: {
817
+ name: "copy",
818
+ },
819
+ onSelect: async (dialog) => {
820
+ try {
821
+ const sessionData = session()
822
+ if (!sessionData) return
823
+ const sessionMessages = messages()
824
+ const transcript = formatTranscript(
825
+ sessionData,
826
+ sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })),
827
+ {
828
+ thinking: showThinking(),
829
+ toolDetails: showDetails(),
830
+ assistantMetadata: showAssistantMetadata(),
831
+ },
832
+ )
833
+ await Clipboard.copy(transcript)
834
+ toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
835
+ } catch (error) {
836
+ toast.show({ message: "Failed to copy session transcript", variant: "error" })
837
+ }
838
+ dialog.clear()
839
+ },
840
+ },
841
+ {
842
+ title: "Export session transcript",
843
+ value: "session.export",
844
+ keybind: "session_export",
845
+ category: "Session",
846
+ slash: {
847
+ name: "export",
848
+ },
849
+ onSelect: async (dialog) => {
850
+ try {
851
+ const sessionData = session()
852
+ if (!sessionData) return
853
+ const sessionMessages = messages()
854
+
855
+ const defaultFilename = `session-${sessionData.id.slice(0, 8)}.md`
856
+
857
+ const options = await DialogExportOptions.show(
858
+ dialog,
859
+ defaultFilename,
860
+ showThinking(),
861
+ showDetails(),
862
+ showAssistantMetadata(),
863
+ false,
864
+ )
865
+
866
+ if (options === null) return
867
+
868
+ const transcript = formatTranscript(
869
+ sessionData,
870
+ sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })),
871
+ {
872
+ thinking: options.thinking,
873
+ toolDetails: options.toolDetails,
874
+ assistantMetadata: options.assistantMetadata,
875
+ },
876
+ )
877
+
878
+ if (options.openWithoutSaving) {
879
+ // Just open in editor without saving
880
+ await Editor.open({ value: transcript, renderer })
881
+ } else {
882
+ const exportDir = process.cwd()
883
+ const filename = options.filename.trim()
884
+ const filepath = path.join(exportDir, filename)
885
+
886
+ await Bun.write(filepath, transcript)
887
+
888
+ // Open with EDITOR if available
889
+ const result = await Editor.open({ value: transcript, renderer })
890
+ if (result !== undefined) {
891
+ await Bun.write(filepath, result)
892
+ }
893
+
894
+ toast.show({ message: `Session exported to ${filename}`, variant: "success" })
895
+ }
896
+ } catch (error) {
897
+ toast.show({ message: "Failed to export session", variant: "error" })
898
+ }
899
+ dialog.clear()
900
+ },
901
+ },
902
+ {
903
+ title: "Next child session",
904
+ value: "session.child.next",
905
+ keybind: "session_child_cycle",
906
+ category: "Session",
907
+ hidden: true,
908
+ onSelect: (dialog) => {
909
+ moveChild(1)
910
+ dialog.clear()
911
+ },
912
+ },
913
+ {
914
+ title: "Previous child session",
915
+ value: "session.child.previous",
916
+ keybind: "session_child_cycle_reverse",
917
+ category: "Session",
918
+ hidden: true,
919
+ onSelect: (dialog) => {
920
+ moveChild(-1)
921
+ dialog.clear()
922
+ },
923
+ },
924
+ {
925
+ title: "Go to parent session",
926
+ value: "session.parent",
927
+ keybind: "session_parent",
928
+ category: "Session",
929
+ hidden: true,
930
+ onSelect: (dialog) => {
931
+ const parentID = session()?.parentID
932
+ if (parentID) {
933
+ navigate({
934
+ type: "session",
935
+ sessionID: parentID,
936
+ })
937
+ }
938
+ dialog.clear()
939
+ },
940
+ },
941
+ ])
942
+
943
+ const revertInfo = createMemo(() => session()?.revert)
944
+ const revertMessageID = createMemo(() => revertInfo()?.messageID)
945
+
946
+ const revertDiffFiles = createMemo(() => {
947
+ const diffText = revertInfo()?.diff ?? ""
948
+ if (!diffText) return []
949
+
950
+ try {
951
+ const patches = parsePatch(diffText)
952
+ return patches.map((patch) => {
953
+ const filename = patch.newFileName || patch.oldFileName || "unknown"
954
+ const cleanFilename = filename.replace(/^[ab]\//, "")
955
+ return {
956
+ filename: cleanFilename,
957
+ additions: patch.hunks.reduce(
958
+ (sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length,
959
+ 0,
960
+ ),
961
+ deletions: patch.hunks.reduce(
962
+ (sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length,
963
+ 0,
964
+ ),
965
+ }
966
+ })
967
+ } catch (error) {
968
+ return []
969
+ }
970
+ })
971
+
972
+ const revertRevertedMessages = createMemo(() => {
973
+ const messageID = revertMessageID()
974
+ if (!messageID) return []
975
+ return messages().filter((x) => x.id >= messageID && x.role === "user")
976
+ })
977
+
978
+ const revert = createMemo(() => {
979
+ const info = revertInfo()
980
+ if (!info) return
981
+ if (!info.messageID) return
982
+ return {
983
+ messageID: info.messageID,
984
+ reverted: revertRevertedMessages(),
985
+ diff: info.diff,
986
+ diffFiles: revertDiffFiles(),
987
+ }
988
+ })
989
+
990
+ const renderer = useRenderer()
991
+
992
+ // snap to bottom when session changes
993
+ createEffect(on(() => route.sessionID, toBottom))
994
+
995
+ return (
996
+ <context.Provider
997
+ value={{
998
+ get width() {
999
+ const chatPanel = layout.getPanelByType("chat")
1000
+ const dims = dimensions()
1001
+ if (!dims?.width) return 80
1002
+ if (!chatPanel?.visible) return dims.width - 4
1003
+ return Math.floor(dims.width * (chatPanel.width / 100))
1004
+ },
1005
+ sessionID: route.sessionID,
1006
+ conceal,
1007
+ showThinking,
1008
+ showTimestamps,
1009
+ showDetails,
1010
+ diffWrapMode,
1011
+ sync,
1012
+ }}
1013
+ >
1014
+ <DynamicLayout
1015
+ explorer={
1016
+ <ExplorerPanel
1017
+ width={explorerWidth()}
1018
+ onSelect={(path, type) => {
1019
+ if (type === "file") {
1020
+ setSelectedFilePath(path)
1021
+ }
1022
+ }}
1023
+ />
1024
+ }
1025
+ chat={
1026
+ <box
1027
+ height="100%"
1028
+ paddingBottom={1}
1029
+ paddingTop={1}
1030
+ paddingLeft={2}
1031
+ paddingRight={2}
1032
+ gap={1}
1033
+ onMouseUp={() => promptRef.current?.focus()}
1034
+ >
1035
+ <Show when={session()}>
1036
+ <Show when={!(layout.getPanelByType("viewer")?.visible && wide())}>
1037
+ <Header
1038
+ navigatorOpen={layout.getPanelByType("explorer")?.visible ?? false}
1039
+ navigatorKeybind={keybind.print("navigator_toggle")}
1040
+ onNavigatorToggle={layoutHelpers.toggleNavigator}
1041
+ />
1042
+ </Show>
1043
+ <scrollbox
1044
+ ref={(r) => setScroll(r)}
1045
+ viewportOptions={{
1046
+ paddingRight: showScrollbar() ? 1 : 0,
1047
+ }}
1048
+ verticalScrollbarOptions={{
1049
+ paddingLeft: 1,
1050
+ visible: showScrollbar(),
1051
+ trackOptions: {
1052
+ backgroundColor: theme.backgroundElement,
1053
+ foregroundColor: theme.border,
1054
+ },
1055
+ }}
1056
+ stickyScroll={true}
1057
+ stickyStart="bottom"
1058
+ flexGrow={1}
1059
+ height="100%"
1060
+ scrollAcceleration={scrollAcceleration()}
1061
+ >
1062
+ <For each={messages()}>
1063
+ {(message, index) => (
1064
+ <Switch>
1065
+ <Match when={message.id === revert()?.messageID}>
1066
+ {(function () {
1067
+ const command = useCommandDialog()
1068
+ const [hover, setHover] = createSignal(false)
1069
+ const dialog = useDialog()
1070
+
1071
+ const handleUnrevert = async () => {
1072
+ const confirmed = await DialogConfirm.show(
1073
+ dialog,
1074
+ "Confirm Redo",
1075
+ "Are you sure you want to restore the reverted messages?",
1076
+ )
1077
+ if (confirmed) {
1078
+ command.trigger("session.redo")
1079
+ }
1080
+ }
1081
+
1082
+ return (
1083
+ <box
1084
+ onMouseOver={() => setHover(true)}
1085
+ onMouseOut={() => setHover(false)}
1086
+ onMouseUp={handleUnrevert}
1087
+ marginTop={1}
1088
+ flexShrink={0}
1089
+ border={["left"]}
1090
+ customBorderChars={SplitBorder.customBorderChars}
1091
+ borderColor={theme.backgroundPanel}
1092
+ >
1093
+ <box
1094
+ paddingTop={1}
1095
+ paddingBottom={1}
1096
+ paddingLeft={2}
1097
+ backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
1098
+ >
1099
+ <text fg={theme.textMuted}>{revert()!.reverted.length} message reverted</text>
1100
+ <text fg={theme.textMuted}>
1101
+ <span style={{ fg: theme.text }}>{keybind.print("messages_redo")}</span> or /redo to
1102
+ restore
1103
+ </text>
1104
+ <Show when={revert()!.diffFiles?.length}>
1105
+ <box marginTop={1}>
1106
+ <For each={revert()!.diffFiles}>
1107
+ {(file) => (
1108
+ <text fg={theme.text}>
1109
+ {file.filename}
1110
+ <Show when={file.additions > 0}>
1111
+ <span style={{ fg: theme.diffAdded }}> +{file.additions}</span>
1112
+ </Show>
1113
+ <Show when={file.deletions > 0}>
1114
+ <span style={{ fg: theme.diffRemoved }}> -{file.deletions}</span>
1115
+ </Show>
1116
+ </text>
1117
+ )}
1118
+ </For>
1119
+ </box>
1120
+ </Show>
1121
+ </box>
1122
+ </box>
1123
+ )
1124
+ })()}
1125
+ </Match>
1126
+ <Match when={revert()?.messageID && message.id >= revert()!.messageID}>
1127
+ <></>
1128
+ </Match>
1129
+ <Match when={message.role === "user"}>
1130
+ <UserMessage
1131
+ index={index()}
1132
+ onMouseUp={() => {
1133
+ if (renderer.getSelection()?.getSelectedText()) return
1134
+ dialog.replace(() => (
1135
+ <DialogMessage
1136
+ messageID={message.id}
1137
+ sessionID={route.sessionID}
1138
+ setPrompt={(promptInfo) => prompt.set(promptInfo)}
1139
+ />
1140
+ ))
1141
+ }}
1142
+ message={message as UserMessage}
1143
+ parts={sync.data.part[message.id] ?? []}
1144
+ pending={pending()}
1145
+ />
1146
+ </Match>
1147
+ <Match when={message.role === "assistant"}>
1148
+ <AssistantMessage
1149
+ last={lastAssistant()?.id === message.id}
1150
+ message={message as AssistantMessage}
1151
+ parts={sync.data.part[message.id] ?? []}
1152
+ />
1153
+ </Match>
1154
+ </Switch>
1155
+ )}
1156
+ </For>
1157
+ </scrollbox>
1158
+ <box flexShrink={0}>
1159
+ <Show when={permissions().length > 0}>
1160
+ <PermissionPrompt request={permissions()[0]} />
1161
+ </Show>
1162
+ <Prompt
1163
+ visible={!session()?.parentID && permissions().length === 0}
1164
+ ref={(r) => {
1165
+ prompt = r
1166
+ promptRef.set(r)
1167
+ if (route.initialPrompt) {
1168
+ r.set(route.initialPrompt)
1169
+ }
1170
+ }}
1171
+ disabled={permissions().length > 0}
1172
+ onSubmit={() => {
1173
+ toBottom()
1174
+ }}
1175
+ sessionID={route.sessionID}
1176
+ />
1177
+ </box>
1178
+ </Show>
1179
+ <Toast />
1180
+ </box>
1181
+ }
1182
+ viewer={<FileViewerPanel width={viewerWidth()} filePath={selectedFilePath()} wrapMode={navigatorWrapMode()} />}
1183
+ />
1184
+ </context.Provider>
1185
+ )
1186
+ }
1187
+
1188
+ const MIME_BADGE: Record<string, string> = {
1189
+ "text/plain": "txt",
1190
+ "image/png": "img",
1191
+ "image/jpeg": "img",
1192
+ "image/gif": "img",
1193
+ "image/webp": "img",
1194
+ "application/pdf": "pdf",
1195
+ "application/x-directory": "dir",
1196
+ }
1197
+
1198
+ function UserMessage(props: {
1199
+ message: UserMessage
1200
+ parts: Part[]
1201
+ onMouseUp: () => void
1202
+ index: number
1203
+ pending?: string
1204
+ }) {
1205
+ const ctx = use()
1206
+ const local = useLocal()
1207
+ const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0])
1208
+ const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
1209
+ const sync = useSync()
1210
+ const { theme } = useTheme()
1211
+ const [hover, setHover] = createSignal(false)
1212
+ const queued = createMemo(() => props.pending && props.message.id > props.pending)
1213
+ const color = createMemo(() => (queued() ? theme.accent : local.agent.color(props.message.agent)))
1214
+ const metadataVisible = createMemo(() => queued() || ctx.showTimestamps())
1215
+
1216
+ const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction"))
1217
+
1218
+ return (
1219
+ <>
1220
+ <Show when={text()}>
1221
+ <box
1222
+ id={props.message.id}
1223
+ border={["left"]}
1224
+ borderColor={color()}
1225
+ customBorderChars={SplitBorder.customBorderChars}
1226
+ marginTop={props.index === 0 ? 0 : 1}
1227
+ >
1228
+ <box
1229
+ onMouseOver={() => {
1230
+ setHover(true)
1231
+ }}
1232
+ onMouseOut={() => {
1233
+ setHover(false)
1234
+ }}
1235
+ onMouseUp={props.onMouseUp}
1236
+ paddingTop={1}
1237
+ paddingBottom={1}
1238
+ paddingLeft={2}
1239
+ backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
1240
+ flexShrink={0}
1241
+ >
1242
+ <text fg={theme.text}>{text()?.text}</text>
1243
+ <Show when={files().length}>
1244
+ <box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
1245
+ <For each={files()}>
1246
+ {(file) => {
1247
+ const bg = createMemo(() => {
1248
+ if (file.mime.startsWith("image/")) return theme.accent
1249
+ if (file.mime === "application/pdf") return theme.primary
1250
+ return theme.secondary
1251
+ })
1252
+ return (
1253
+ <text fg={theme.text}>
1254
+ <span style={{ bg: bg(), fg: theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span>
1255
+ <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span>
1256
+ </text>
1257
+ )
1258
+ }}
1259
+ </For>
1260
+ </box>
1261
+ </Show>
1262
+ <Show
1263
+ when={queued()}
1264
+ fallback={
1265
+ <Show when={ctx.showTimestamps()}>
1266
+ <text fg={theme.textMuted}>
1267
+ <span style={{ fg: theme.textMuted }}>
1268
+ {Locale.todayTimeOrDateTime(props.message.time.created)}
1269
+ </span>
1270
+ </text>
1271
+ </Show>
1272
+ }
1273
+ >
1274
+ <text fg={theme.textMuted}>
1275
+ <span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
1276
+ </text>
1277
+ </Show>
1278
+ </box>
1279
+ </box>
1280
+ </Show>
1281
+ <Show when={compaction()}>
1282
+ <box
1283
+ marginTop={1}
1284
+ border={["top"]}
1285
+ title=" Compaction "
1286
+ titleAlignment="center"
1287
+ borderColor={theme.borderActive}
1288
+ />
1289
+ </Show>
1290
+ </>
1291
+ )
1292
+ }
1293
+
1294
+ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) {
1295
+ const local = useLocal()
1296
+ const { theme } = useTheme()
1297
+ const sync = useSync()
1298
+ const kv = useKV()
1299
+ const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? [])
1300
+
1301
+ const status = createMemo(() => sync.data.session_status?.[props.message.sessionID] ?? { type: "idle" })
1302
+
1303
+ const spinnerDef = createMemo(() => {
1304
+ const color = local.agent.color(props.message.agent)
1305
+ return {
1306
+ frames: createFrames({
1307
+ color,
1308
+ style: "blocks",
1309
+ trailSteps: 1,
1310
+ inactiveFactor: 0.6,
1311
+ minAlpha: 0.3,
1312
+ }),
1313
+ color: createColors({
1314
+ color,
1315
+ style: "blocks",
1316
+ trailSteps: 1,
1317
+ inactiveFactor: 0.6,
1318
+ minAlpha: 0.3,
1319
+ }),
1320
+ }
1321
+ })
1322
+
1323
+ const final = createMemo(() => {
1324
+ return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
1325
+ })
1326
+
1327
+ const duration = createMemo(() => {
1328
+ if (!final()) return 0
1329
+ if (!props.message.time.completed) return 0
1330
+ const user = messages().find((x) => x.role === "user" && x.id === props.message.parentID)
1331
+ if (!user || !user.time) return 0
1332
+ return props.message.time.completed - user.time.created
1333
+ })
1334
+
1335
+ return (
1336
+ <>
1337
+ <For each={props.parts}>
1338
+ {(part, index) => {
1339
+ const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING])
1340
+ return (
1341
+ <Show when={component()}>
1342
+ <Dynamic
1343
+ last={index() === props.parts.length - 1}
1344
+ component={component()}
1345
+ part={part as any}
1346
+ message={props.message}
1347
+ />
1348
+ </Show>
1349
+ )
1350
+ }}
1351
+ </For>
1352
+ <Show when={props.message.error && props.message.error.name !== "MessageAbortedError"}>
1353
+ <box
1354
+ border={["left"]}
1355
+ paddingTop={1}
1356
+ paddingBottom={1}
1357
+ paddingLeft={2}
1358
+ marginTop={1}
1359
+ backgroundColor={theme.backgroundPanel}
1360
+ customBorderChars={SplitBorder.customBorderChars}
1361
+ borderColor={theme.error}
1362
+ >
1363
+ <text fg={theme.textMuted}>{props.message.error?.data.message}</text>
1364
+ </box>
1365
+ </Show>
1366
+ <Switch>
1367
+ <Match when={props.last || final() || props.message.error?.name === "MessageAbortedError"}>
1368
+ <box paddingLeft={3} flexDirection="row" marginTop={1}>
1369
+ <Switch
1370
+ fallback={
1371
+ <text>
1372
+ <span
1373
+ style={{
1374
+ fg:
1375
+ props.message.error?.name === "MessageAbortedError"
1376
+ ? theme.textMuted
1377
+ : local.agent.color(props.message.agent),
1378
+ }}
1379
+ >
1380
+ ▣{" "}
1381
+ </span>
1382
+ </text>
1383
+ }
1384
+ >
1385
+ <Match
1386
+ when={
1387
+ props.last &&
1388
+ status().type !== "idle" &&
1389
+ kv.get("animations_enabled", true) &&
1390
+ props.message.error?.name !== "MessageAbortedError" &&
1391
+ !final()
1392
+ }
1393
+ >
1394
+ <spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
1395
+ <text> </text>
1396
+ </Match>
1397
+ </Switch>
1398
+ <text>
1399
+ <span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
1400
+ <span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span>
1401
+ <Show when={duration()}>
1402
+ <span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
1403
+ </Show>
1404
+ <Show when={props.message.error?.name === "MessageAbortedError"}>
1405
+ <span style={{ fg: theme.textMuted }}> · interrupted</span>
1406
+ </Show>
1407
+ </text>
1408
+ </box>
1409
+ </Match>
1410
+ </Switch>
1411
+ </>
1412
+ )
1413
+ }
1414
+
1415
+ const PART_MAPPING = {
1416
+ text: FileReferenceText,
1417
+ tool: ToolPart,
1418
+ reasoning: ReasoningPart,
1419
+ }
1420
+
1421
+ function FileReferenceText(props: { last: boolean; part: TextPart; message: AssistantMessage }) {
1422
+ const ctx = use()
1423
+ const { theme, syntax } = useTheme()
1424
+ const sdk = useSDK()
1425
+ const kv = useKV()
1426
+
1427
+ // Context keywords that indicate a file reference (case-insensitive)
1428
+ const FILE_REF_KEYWORDS = ["edit", "file", "at", "in", "see", "check", "open", "view", "read"]
1429
+
1430
+ // Regex to match file references with context keywords
1431
+ // Supports: / and \ separators, quoted paths, optional line numbers
1432
+ const fileRefRegex = new RegExp(
1433
+ `(?:^|\\s)(?:${FILE_REF_KEYWORDS.join("|")})(?::)?\\s+([\`'"<]?)([^\\s\`'">:]+)(?::(\\d+))?([\`'">]?)`,
1434
+ "gi",
1435
+ )
1436
+
1437
+ // Regex to detect if something looks like a path
1438
+ // Must contain at least one / or \ to be a path
1439
+ const pathLikeRegex = /[\\/]/
1440
+
1441
+ const hasFileRefs = createMemo(() => fileRefRegex.test(props.part.text.trim()))
1442
+
1443
+ // Cache for file existence checks (to avoid repeated validation)
1444
+ const [fileExistsCache, setFileExistsCache] = createSignal<Map<string, boolean>>(new Map())
1445
+
1446
+ const parseFileReferences = (text: string) => {
1447
+ const parts: Array<{ type: "text" | "fileref"; content: string; path?: string; line?: number; exists?: boolean }> =
1448
+ []
1449
+ let lastIndex = 0
1450
+ let match
1451
+
1452
+ while ((match = fileRefRegex.exec(text)) !== null) {
1453
+ const textBefore = text.slice(lastIndex, match.index)
1454
+ if (textBefore) {
1455
+ parts.push({ type: "text", content: textBefore })
1456
+ }
1457
+
1458
+ const fullMatch = match[0]
1459
+ const filePath = match[2]
1460
+ const lineNum = match[3] ? parseInt(match[3], 10) : undefined
1461
+
1462
+ // Check if it looks like a path
1463
+ const looksLikePath = pathLikeRegex.test(filePath)
1464
+
1465
+ parts.push({
1466
+ type: "fileref",
1467
+ content: fullMatch,
1468
+ path: filePath,
1469
+ line: lineNum,
1470
+ exists: looksLikePath ? undefined : false,
1471
+ })
1472
+
1473
+ lastIndex = match.index + fullMatch.length
1474
+ }
1475
+
1476
+ const remaining = text.slice(lastIndex)
1477
+ if (remaining) {
1478
+ parts.push({ type: "text", content: remaining })
1479
+ }
1480
+
1481
+ return parts
1482
+ }
1483
+
1484
+ const parts = createMemo(() => parseFileReferences(props.part.text.trim()))
1485
+
1486
+ // Validate file existence (async, non-blocking)
1487
+ const validateFile = async (filePath: string): Promise<boolean> => {
1488
+ const normalizedPath = filePath.replace(/\\/g, "/")
1489
+ const cache = fileExistsCache()
1490
+
1491
+ if (cache.has(normalizedPath)) {
1492
+ return cache.get(normalizedPath)!
1493
+ }
1494
+
1495
+ try {
1496
+ const file = await sdk.client.file.read({ path: normalizedPath })
1497
+ const exists = !!file?.data && file.data.encoding !== "base64"
1498
+
1499
+ // Update cache
1500
+ const newCache = new Map(cache)
1501
+ newCache.set(normalizedPath, exists)
1502
+ setFileExistsCache(newCache)
1503
+
1504
+ return exists
1505
+ } catch {
1506
+ const newCache = new Map(cache)
1507
+ newCache.set(normalizedPath, false)
1508
+ setFileExistsCache(newCache)
1509
+ return false
1510
+ }
1511
+ }
1512
+
1513
+ // Validate all file references on mount
1514
+ createEffect(() => {
1515
+ const partsList = parts()
1516
+ partsList.forEach((part) => {
1517
+ if (part.type === "fileref" && part.path && part.exists === undefined) {
1518
+ validateFile(part.path)
1519
+ }
1520
+ })
1521
+ })
1522
+
1523
+ const handleFileClick = (path?: string, line?: number) => {
1524
+ if (!path) return
1525
+ void (async () => {
1526
+ const normalizedPath = path.replace(/\\/g, "/")
1527
+ const file = await sdk.client.file.read({ path: normalizedPath }).catch(() => undefined)
1528
+ if (!file?.data) return
1529
+ if (file.data.encoding === "base64") return
1530
+ const content = file.data.content ?? ""
1531
+ const lines = content.split("\n")
1532
+ let charOffset = 0
1533
+ if (line && line > 0 && line <= lines.length) {
1534
+ const lineIndex = line - 1
1535
+ const linesBefore = lines.slice(0, lineIndex)
1536
+ charOffset = linesBefore.join("\n").length
1537
+ }
1538
+ kv.set("navigator_open", true)
1539
+ kv.set("navigator_active_path", normalizedPath)
1540
+ kv.set("navigator_open_file", { path: normalizedPath, line: charOffset })
1541
+ })()
1542
+ }
1543
+
1544
+ return (
1545
+ <Show when={props.part.text.trim()}>
1546
+ <box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
1547
+ <Show
1548
+ when={hasFileRefs()}
1549
+ fallback={
1550
+ <code
1551
+ filetype="markdown"
1552
+ drawUnstyledText={false}
1553
+ streaming={true}
1554
+ syntaxStyle={syntax()}
1555
+ content={props.part.text.trim()}
1556
+ conceal={ctx.conceal()}
1557
+ fg={theme.text}
1558
+ />
1559
+ }
1560
+ >
1561
+ <For each={parts()}>
1562
+ {(part) => (
1563
+ <Switch>
1564
+ <Match when={part.type === "fileref" && (part.exists ?? true)}>
1565
+ <text fg={theme.markdownLink} onMouseUp={() => handleFileClick(part.path!, part.line)}>
1566
+ <span style={{ fg: theme.textMuted }}>[</span>
1567
+ {part.content}
1568
+ <span style={{ fg: theme.textMuted }}>]</span>
1569
+ </text>
1570
+ </Match>
1571
+ <Match when={part.type === "fileref" && !part.exists}>
1572
+ <text fg={theme.textMuted}>{part.content}</text>
1573
+ </Match>
1574
+ <Match when={true}>
1575
+ <code
1576
+ filetype="markdown"
1577
+ drawUnstyledText={false}
1578
+ streaming={true}
1579
+ syntaxStyle={syntax()}
1580
+ content={part.content}
1581
+ fg={theme.text}
1582
+ />
1583
+ </Match>
1584
+ </Switch>
1585
+ )}
1586
+ </For>
1587
+ </Show>
1588
+ </box>
1589
+ </Show>
1590
+ )
1591
+ }
1592
+
1593
+ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
1594
+ const { theme } = useTheme()
1595
+ const ctx = use()
1596
+ const content = createMemo(() => {
1597
+ // Filter out redacted reasoning chunks from OpenRouter
1598
+ // OpenRouter sends encrypted reasoning data that appears as [REDACTED]
1599
+ return props.part.text.replace("[REDACTED]", "").trim()
1600
+ })
1601
+ return (
1602
+ <Show when={content() && ctx.showThinking()}>
1603
+ <box
1604
+ id={"text-" + props.part.id}
1605
+ paddingLeft={2}
1606
+ marginTop={1}
1607
+ border={["left"]}
1608
+ customBorderChars={SplitBorder.customBorderChars}
1609
+ borderColor={theme.backgroundElement}
1610
+ >
1611
+ <text fg={theme.textMuted} wrapMode="word" width="100%">
1612
+ Thinking: {content()}
1613
+ </text>
1614
+ </box>
1615
+ </Show>
1616
+ )
1617
+ }
1618
+
1619
+ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) {
1620
+ const ctx = use()
1621
+ const { theme, syntax } = useTheme()
1622
+ return (
1623
+ <Show when={props.part.text.trim()}>
1624
+ <box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
1625
+ <code
1626
+ filetype="markdown"
1627
+ drawUnstyledText={false}
1628
+ streaming={true}
1629
+ syntaxStyle={syntax()}
1630
+ content={props.part.text.trim()}
1631
+ conceal={ctx.conceal()}
1632
+ fg={theme.text}
1633
+ />
1634
+ </box>
1635
+ </Show>
1636
+ )
1637
+ }
1638
+
1639
+ // Pending messages moved to individual tool pending functions
1640
+
1641
+ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) {
1642
+ const ctx = use()
1643
+ const sync = useSync()
1644
+
1645
+ // Hide tool if showDetails is false and tool completed successfully
1646
+ const shouldHide = createMemo(() => {
1647
+ if (ctx.showDetails()) return false
1648
+ if (props.part.state.status !== "completed") return false
1649
+ return true
1650
+ })
1651
+
1652
+ const toolprops = {
1653
+ get metadata() {
1654
+ return props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
1655
+ },
1656
+ get input() {
1657
+ return props.part.state.input ?? {}
1658
+ },
1659
+ get output() {
1660
+ return props.part.state.status === "completed" ? props.part.state.output : undefined
1661
+ },
1662
+ get permission() {
1663
+ const permissions = sync.data.permission[props.message.sessionID] ?? []
1664
+ const permissionIndex = permissions.findIndex((x) => x.tool?.callID === props.part.callID)
1665
+ return permissions[permissionIndex]
1666
+ },
1667
+ get tool() {
1668
+ return props.part.tool
1669
+ },
1670
+ get part() {
1671
+ return props.part
1672
+ },
1673
+ }
1674
+
1675
+ return (
1676
+ <Show when={!shouldHide()}>
1677
+ <Switch>
1678
+ <Match when={props.part.tool === "bash"}>
1679
+ <Bash {...toolprops} />
1680
+ </Match>
1681
+ <Match when={props.part.tool === "glob"}>
1682
+ <Glob {...toolprops} />
1683
+ </Match>
1684
+ <Match when={props.part.tool === "read"}>
1685
+ <Read {...toolprops} />
1686
+ </Match>
1687
+ <Match when={props.part.tool === "grep"}>
1688
+ <Grep {...toolprops} />
1689
+ </Match>
1690
+ <Match when={props.part.tool === "list"}>
1691
+ <List {...toolprops} />
1692
+ </Match>
1693
+ <Match when={props.part.tool === "webfetch"}>
1694
+ <WebFetch {...toolprops} />
1695
+ </Match>
1696
+ <Match when={props.part.tool === "codesearch"}>
1697
+ <CodeSearch {...toolprops} />
1698
+ </Match>
1699
+ <Match when={props.part.tool === "websearch"}>
1700
+ <WebSearch {...toolprops} />
1701
+ </Match>
1702
+ <Match when={props.part.tool === "write"}>
1703
+ <Write {...toolprops} />
1704
+ </Match>
1705
+ <Match when={props.part.tool === "edit"}>
1706
+ <Edit {...toolprops} />
1707
+ </Match>
1708
+ <Match when={props.part.tool === "task"}>
1709
+ <Task {...toolprops} />
1710
+ </Match>
1711
+ <Match when={props.part.tool === "apply_patch"}>
1712
+ <ApplyPatch {...toolprops} />
1713
+ </Match>
1714
+ <Match when={props.part.tool === "todowrite"}>
1715
+ <TodoWrite {...toolprops} />
1716
+ </Match>
1717
+ <Match when={props.part.tool === "question"}>
1718
+ <Question {...toolprops} />
1719
+ </Match>
1720
+ <Match when={true}>
1721
+ <GenericTool {...toolprops} />
1722
+ </Match>
1723
+ </Switch>
1724
+ </Show>
1725
+ )
1726
+ }
1727
+
1728
+ type ToolProps<T extends Tool.Info> = {
1729
+ input: Partial<Tool.InferParameters<T>>
1730
+ metadata: Partial<Tool.InferMetadata<T>>
1731
+ permission: Record<string, any>
1732
+ tool: string
1733
+ output?: string
1734
+ part: ToolPart
1735
+ }
1736
+ function GenericTool(props: ToolProps<any>) {
1737
+ return (
1738
+ <InlineTool icon="⚙" pending="Writing command..." complete={true} part={props.part}>
1739
+ {props.tool} {input(props.input)}
1740
+ </InlineTool>
1741
+ )
1742
+ }
1743
+
1744
+ function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) {
1745
+ const { theme } = useTheme()
1746
+ return (
1747
+ <text paddingLeft={3} fg={props.when ? theme.textMuted : theme.text}>
1748
+ <Show fallback={<>~ {props.fallback}</>} when={props.when}>
1749
+ <span style={{ bold: true }}>{props.icon}</span> {props.children}
1750
+ </Show>
1751
+ </text>
1752
+ )
1753
+ }
1754
+
1755
+ function InlineTool(props: {
1756
+ icon: string
1757
+ iconColor?: RGBA
1758
+ complete: any
1759
+ pending: string
1760
+ children: JSX.Element
1761
+ part: ToolPart
1762
+ }) {
1763
+ const [margin, setMargin] = createSignal(0)
1764
+ const { theme } = useTheme()
1765
+ const ctx = use()
1766
+ const sync = useSync()
1767
+
1768
+ const permission = createMemo(() => {
1769
+ const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID
1770
+ if (!callID) return false
1771
+ return callID === props.part.callID
1772
+ })
1773
+
1774
+ const fg = createMemo(() => {
1775
+ if (permission()) return theme.warning
1776
+ if (props.complete) return theme.textMuted
1777
+ return theme.text
1778
+ })
1779
+
1780
+ const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined))
1781
+
1782
+ const denied = createMemo(
1783
+ () =>
1784
+ error()?.includes("rejected permission") ||
1785
+ error()?.includes("specified a rule") ||
1786
+ error()?.includes("user dismissed"),
1787
+ )
1788
+
1789
+ return (
1790
+ <box
1791
+ marginTop={margin()}
1792
+ paddingLeft={3}
1793
+ renderBefore={function () {
1794
+ const el = this as BoxRenderable
1795
+ const parent = el.parent
1796
+ if (!parent) {
1797
+ return
1798
+ }
1799
+ if (el.height > 1) {
1800
+ setMargin(1)
1801
+ return
1802
+ }
1803
+ const children = parent.getChildren()
1804
+ const index = children.indexOf(el)
1805
+ const previous = children[index - 1]
1806
+ if (!previous) {
1807
+ setMargin(0)
1808
+ return
1809
+ }
1810
+ if (previous.height > 1 || previous.id.startsWith("text-")) {
1811
+ setMargin(1)
1812
+ return
1813
+ }
1814
+ }}
1815
+ >
1816
+ <text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
1817
+ <Show fallback={<>~ {props.pending}</>} when={props.complete}>
1818
+ <span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
1819
+ </Show>
1820
+ </text>
1821
+ <Show when={error() && !denied()}>
1822
+ <text fg={theme.error}>{error()}</text>
1823
+ </Show>
1824
+ </box>
1825
+ )
1826
+ }
1827
+
1828
+ function BlockTool(props: {
1829
+ title: string
1830
+ prefix?: JSX.Element
1831
+ children: JSX.Element
1832
+ onClick?: () => void
1833
+ part?: ToolPart
1834
+ }) {
1835
+ const { theme } = useTheme()
1836
+ const renderer = useRenderer()
1837
+ const [hover, setHover] = createSignal(false)
1838
+ const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
1839
+ return (
1840
+ <box
1841
+ border={["left"]}
1842
+ paddingTop={1}
1843
+ paddingBottom={1}
1844
+ paddingLeft={2}
1845
+ marginTop={1}
1846
+ gap={1}
1847
+ backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundPanel}
1848
+ customBorderChars={SplitBorder.customBorderChars}
1849
+ borderColor={theme.background}
1850
+ onMouseOver={() => props.onClick && setHover(true)}
1851
+ onMouseOut={() => setHover(false)}
1852
+ onMouseUp={() => {
1853
+ if (renderer.getSelection()?.getSelectedText()) return
1854
+ props.onClick?.()
1855
+ }}
1856
+ >
1857
+ <box flexDirection="row" paddingLeft={props.prefix ? 1 : 3} gap={1} alignItems="center">
1858
+ <Show when={props.prefix}>{props.prefix}</Show>
1859
+ <text fg={theme.textMuted}>{props.title}</text>
1860
+ </box>
1861
+ {props.children}
1862
+ <Show when={error()}>
1863
+ <text fg={theme.error}>{error()}</text>
1864
+ </Show>
1865
+ </box>
1866
+ )
1867
+ }
1868
+
1869
+ function Bash(props: ToolProps<typeof BashTool>) {
1870
+ const { theme } = useTheme()
1871
+ const sync = useSync()
1872
+ const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
1873
+ const [expanded, setExpanded] = createSignal(false)
1874
+ const lines = createMemo(() => output().split("\n"))
1875
+ const overflow = createMemo(() => lines().length > 10)
1876
+ const limited = createMemo(() => {
1877
+ if (expanded() || !overflow()) return output()
1878
+ return [...lines().slice(0, 10), "…"].join("\n")
1879
+ })
1880
+
1881
+ const workdirDisplay = createMemo(() => {
1882
+ const workdir = props.input.workdir
1883
+ if (!workdir || workdir === ".") return undefined
1884
+
1885
+ const base = sync.data.path.directory
1886
+ if (!base) return undefined
1887
+
1888
+ const absolute = path.resolve(base, workdir)
1889
+ if (absolute === base) return undefined
1890
+
1891
+ const home = Global.Path.home
1892
+ if (!home) return absolute
1893
+
1894
+ const match = absolute === home || absolute.startsWith(home + path.sep)
1895
+ return match ? absolute.replace(home, "~") : absolute
1896
+ })
1897
+
1898
+ const title = createMemo(() => {
1899
+ const desc = props.input.description ?? "Shell"
1900
+ const wd = workdirDisplay()
1901
+ if (!wd) return `# ${desc}`
1902
+ if (desc.includes(wd)) return `# ${desc}`
1903
+ return `# ${desc} in ${wd}`
1904
+ })
1905
+
1906
+ return (
1907
+ <Switch>
1908
+ <Match when={props.metadata.output !== undefined}>
1909
+ <BlockTool
1910
+ title={title()}
1911
+ part={props.part}
1912
+ onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
1913
+ >
1914
+ <box gap={1}>
1915
+ <text fg={theme.text}>$ {props.input.command}</text>
1916
+ <text fg={theme.text}>{limited()}</text>
1917
+ <Show when={overflow()}>
1918
+ <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
1919
+ </Show>
1920
+ </box>
1921
+ </BlockTool>
1922
+ </Match>
1923
+ <Match when={true}>
1924
+ <InlineTool icon="$" pending="Writing command..." complete={props.input.command} part={props.part}>
1925
+ {props.input.command}
1926
+ </InlineTool>
1927
+ </Match>
1928
+ </Switch>
1929
+ )
1930
+ }
1931
+
1932
+ function Write(props: ToolProps<typeof WriteTool>) {
1933
+ const { theme, syntax } = useTheme()
1934
+ const code = createMemo(() => {
1935
+ if (!props.input.content) return ""
1936
+ return props.input.content
1937
+ })
1938
+
1939
+ const diagnostics = createMemo(() => {
1940
+ const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
1941
+ return props.metadata.diagnostics?.[filePath] ?? []
1942
+ })
1943
+
1944
+ return (
1945
+ <Switch>
1946
+ <Match when={props.metadata.diagnostics !== undefined}>
1947
+ <BlockTool title={"# Wrote " + normalizePath(props.input.filePath!)} part={props.part}>
1948
+ <line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
1949
+ <code
1950
+ conceal={false}
1951
+ fg={theme.text}
1952
+ filetype={filetype(props.input.filePath!)}
1953
+ syntaxStyle={syntax()}
1954
+ content={code()}
1955
+ />
1956
+ </line_number>
1957
+ <Show when={diagnostics().length}>
1958
+ <For each={diagnostics()}>
1959
+ {(diagnostic) => (
1960
+ <text fg={theme.error}>
1961
+ Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message}
1962
+ </text>
1963
+ )}
1964
+ </For>
1965
+ </Show>
1966
+ </BlockTool>
1967
+ </Match>
1968
+ <Match when={true}>
1969
+ <InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}>
1970
+ Write {normalizePath(props.input.filePath!)}
1971
+ </InlineTool>
1972
+ </Match>
1973
+ </Switch>
1974
+ )
1975
+ }
1976
+
1977
+ function Glob(props: ToolProps<typeof GlobTool>) {
1978
+ return (
1979
+ <InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part}>
1980
+ Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
1981
+ <Show when={props.metadata.count}>({props.metadata.count} matches)</Show>
1982
+ </InlineTool>
1983
+ )
1984
+ }
1985
+
1986
+ function Read(props: ToolProps<typeof ReadTool>) {
1987
+ return (
1988
+ <InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
1989
+ Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
1990
+ </InlineTool>
1991
+ )
1992
+ }
1993
+
1994
+ function Grep(props: ToolProps<typeof GrepTool>) {
1995
+ return (
1996
+ <InlineTool icon="✱" pending="Searching content..." complete={props.input.pattern} part={props.part}>
1997
+ Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
1998
+ <Show when={props.metadata.matches}>({props.metadata.matches} matches)</Show>
1999
+ </InlineTool>
2000
+ )
2001
+ }
2002
+
2003
+ function List(props: ToolProps<typeof ListTool>) {
2004
+ const dir = createMemo(() => {
2005
+ if (props.input.path) {
2006
+ return normalizePath(props.input.path)
2007
+ }
2008
+ return ""
2009
+ })
2010
+ return (
2011
+ <InlineTool icon="→" pending="Listing directory..." complete={props.input.path !== undefined} part={props.part}>
2012
+ List {dir()}
2013
+ </InlineTool>
2014
+ )
2015
+ }
2016
+
2017
+ function WebFetch(props: ToolProps<typeof WebFetchTool>) {
2018
+ return (
2019
+ <InlineTool icon="%" pending="Fetching from the web..." complete={(props.input as any).url} part={props.part}>
2020
+ WebFetch {(props.input as any).url}
2021
+ </InlineTool>
2022
+ )
2023
+ }
2024
+
2025
+ function CodeSearch(props: ToolProps<any>) {
2026
+ const input = props.input as any
2027
+ const metadata = props.metadata as any
2028
+ return (
2029
+ <InlineTool icon="◇" pending="Searching code..." complete={input.query} part={props.part}>
2030
+ Exa Code Search "{input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
2031
+ </InlineTool>
2032
+ )
2033
+ }
2034
+
2035
+ function WebSearch(props: ToolProps<any>) {
2036
+ const input = props.input as any
2037
+ const metadata = props.metadata as any
2038
+ return (
2039
+ <InlineTool icon="◈" pending="Searching web..." complete={input.query} part={props.part}>
2040
+ Exa Web Search "{input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
2041
+ </InlineTool>
2042
+ )
2043
+ }
2044
+
2045
+ function Task(props: ToolProps<typeof TaskTool>) {
2046
+ const { theme } = useTheme()
2047
+ const keybind = useKeybind()
2048
+ const { navigate } = useRoute()
2049
+ const local = useLocal()
2050
+ const kv = useKV()
2051
+
2052
+ const current = createMemo(() => props.metadata.summary?.findLast((x) => x.state.status !== "pending"))
2053
+ const color = createMemo(() => local.agent.color(props.input.subagent_type ?? "unknown"))
2054
+ const spinnerDef = createMemo(() => {
2055
+ return {
2056
+ frames: createFrames({
2057
+ color: color(),
2058
+ style: "blocks",
2059
+ trailSteps: 1,
2060
+ inactiveFactor: 0.6,
2061
+ minAlpha: 0.3,
2062
+ }),
2063
+ color: createColors({
2064
+ color: color(),
2065
+ style: "blocks",
2066
+ trailSteps: 1,
2067
+ inactiveFactor: 0.6,
2068
+ minAlpha: 0.3,
2069
+ }),
2070
+ }
2071
+ })
2072
+
2073
+ const running = createMemo(() => {
2074
+ // If the tool part itself is still pending or running, the subagent is active
2075
+ return props.part.state.status === "pending" || props.part.state.status === "running"
2076
+ })
2077
+
2078
+ return (
2079
+ <Switch>
2080
+ <Match when={props.metadata.summary?.length}>
2081
+ <BlockTool
2082
+ title={"# " + Locale.titlecase(props.input.subagent_type ?? "unknown") + " Task"}
2083
+ prefix={
2084
+ <Switch
2085
+ fallback={
2086
+ <text>
2087
+ <span style={{ fg: theme.textMuted }}>▣</span>
2088
+ </text>
2089
+ }
2090
+ >
2091
+ <Match when={running() && kv.get("animations_enabled", true)}>
2092
+ <spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
2093
+ </Match>
2094
+ </Switch>
2095
+ }
2096
+ onClick={
2097
+ props.metadata.sessionId
2098
+ ? () => navigate({ type: "session", sessionID: props.metadata.sessionId! })
2099
+ : undefined
2100
+ }
2101
+ part={props.part}
2102
+ >
2103
+ <box>
2104
+ <text style={{ fg: theme.textMuted }}>
2105
+ {props.input.description} ({props.metadata.summary?.length} toolcalls)
2106
+ </text>
2107
+ <Show when={current()}>
2108
+ <text style={{ fg: current()!.state.status === "error" ? theme.error : theme.textMuted }}>
2109
+ └ {Locale.titlecase(current()!.tool)}{" "}
2110
+ {current()!.state.status === "completed" ? current()!.state.title : ""}
2111
+ </text>
2112
+ </Show>
2113
+ </box>
2114
+ <text fg={theme.text}>
2115
+ {keybind.print("session_child_cycle")}
2116
+ <span style={{ fg: theme.textMuted }}> view subagents</span>
2117
+ </text>
2118
+ </BlockTool>
2119
+ </Match>
2120
+ <Match when={true}>
2121
+ <InlineTool
2122
+ icon="◉"
2123
+ iconColor={color()}
2124
+ pending="Delegating..."
2125
+ complete={props.input.subagent_type ?? props.input.description}
2126
+ part={props.part}
2127
+ >
2128
+ <span style={{ fg: theme.text }}>{Locale.titlecase(props.input.subagent_type ?? "unknown")}</span> Task "
2129
+ {props.input.description}"
2130
+ </InlineTool>
2131
+ </Match>
2132
+ </Switch>
2133
+ )
2134
+ }
2135
+
2136
+ function Edit(props: ToolProps<typeof EditTool>) {
2137
+ const ctx = use()
2138
+ const { theme, syntax } = useTheme()
2139
+
2140
+ const view = createMemo(() => {
2141
+ const diffStyle = ctx.sync.data.config.tui?.diff_style
2142
+ if (diffStyle === "stacked") return "unified"
2143
+ // Default to "auto" behavior
2144
+ return ctx.width > 120 ? "split" : "unified"
2145
+ })
2146
+
2147
+ const ft = createMemo(() => filetype(props.input.filePath))
2148
+
2149
+ const diffContent = createMemo(() => props.metadata.diff)
2150
+
2151
+ const diagnostics = createMemo(() => {
2152
+ const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
2153
+ const arr = props.metadata.diagnostics?.[filePath] ?? []
2154
+ return arr.filter((x) => x.severity === 1).slice(0, 3)
2155
+ })
2156
+
2157
+ return (
2158
+ <Switch>
2159
+ <Match when={props.metadata.diff !== undefined}>
2160
+ <BlockTool title={"← Edit " + normalizePath(props.input.filePath!)} part={props.part}>
2161
+ <box paddingLeft={1}>
2162
+ <diff
2163
+ diff={diffContent()}
2164
+ view={view()}
2165
+ filetype={ft()}
2166
+ syntaxStyle={syntax()}
2167
+ showLineNumbers={true}
2168
+ width="100%"
2169
+ wrapMode={ctx.diffWrapMode()}
2170
+ fg={theme.text}
2171
+ addedBg={theme.diffAddedBg}
2172
+ removedBg={theme.diffRemovedBg}
2173
+ contextBg={theme.diffContextBg}
2174
+ addedSignColor={theme.diffHighlightAdded}
2175
+ removedSignColor={theme.diffHighlightRemoved}
2176
+ lineNumberFg={theme.diffLineNumber}
2177
+ lineNumberBg={theme.diffContextBg}
2178
+ addedLineNumberBg={theme.diffAddedLineNumberBg}
2179
+ removedLineNumberBg={theme.diffRemovedLineNumberBg}
2180
+ />
2181
+ </box>
2182
+ <Show when={diagnostics().length}>
2183
+ <box>
2184
+ <For each={diagnostics()}>
2185
+ {(diagnostic) => (
2186
+ <text fg={theme.error}>
2187
+ Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}]{" "}
2188
+ {diagnostic.message}
2189
+ </text>
2190
+ )}
2191
+ </For>
2192
+ </box>
2193
+ </Show>
2194
+ </BlockTool>
2195
+ </Match>
2196
+ <Match when={true}>
2197
+ <InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
2198
+ Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })}
2199
+ </InlineTool>
2200
+ </Match>
2201
+ </Switch>
2202
+ )
2203
+ }
2204
+
2205
+ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
2206
+ const ctx = use()
2207
+ const { theme, syntax } = useTheme()
2208
+
2209
+ const files = createMemo(() => props.metadata.files ?? [])
2210
+
2211
+ const view = createMemo(() => {
2212
+ const diffStyle = ctx.sync.data.config.tui?.diff_style
2213
+ if (diffStyle === "stacked") return "unified"
2214
+ return ctx.width > 120 ? "split" : "unified"
2215
+ })
2216
+
2217
+ function Diff(p: { diff: string; filePath: string }) {
2218
+ return (
2219
+ <box paddingLeft={1}>
2220
+ <diff
2221
+ diff={p.diff}
2222
+ view={view()}
2223
+ filetype={filetype(p.filePath)}
2224
+ syntaxStyle={syntax()}
2225
+ showLineNumbers={true}
2226
+ width="100%"
2227
+ wrapMode={ctx.diffWrapMode()}
2228
+ fg={theme.text}
2229
+ addedBg={theme.diffAddedBg}
2230
+ removedBg={theme.diffRemovedBg}
2231
+ contextBg={theme.diffContextBg}
2232
+ addedSignColor={theme.diffHighlightAdded}
2233
+ removedSignColor={theme.diffHighlightRemoved}
2234
+ lineNumberFg={theme.diffLineNumber}
2235
+ lineNumberBg={theme.diffContextBg}
2236
+ addedLineNumberBg={theme.diffAddedLineNumberBg}
2237
+ removedLineNumberBg={theme.diffRemovedLineNumberBg}
2238
+ />
2239
+ </box>
2240
+ )
2241
+ }
2242
+
2243
+ function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
2244
+ if (file.type === "delete") return "# Deleted " + file.relativePath
2245
+ if (file.type === "add") return "# Created " + file.relativePath
2246
+ if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath
2247
+ return "← Patched " + file.relativePath
2248
+ }
2249
+
2250
+ return (
2251
+ <Switch>
2252
+ <Match when={files().length > 0}>
2253
+ <For each={files()}>
2254
+ {(file) => (
2255
+ <BlockTool title={title(file)} part={props.part}>
2256
+ <Show
2257
+ when={file.type !== "delete"}
2258
+ fallback={
2259
+ <text fg={theme.diffRemoved}>
2260
+ -{file.deletions} line{file.deletions !== 1 ? "s" : ""}
2261
+ </text>
2262
+ }
2263
+ >
2264
+ <Diff diff={file.diff} filePath={file.filePath} />
2265
+ </Show>
2266
+ </BlockTool>
2267
+ )}
2268
+ </For>
2269
+ </Match>
2270
+ <Match when={true}>
2271
+ <InlineTool icon="%" pending="Preparing apply_patch..." complete={false} part={props.part}>
2272
+ apply_patch
2273
+ </InlineTool>
2274
+ </Match>
2275
+ </Switch>
2276
+ )
2277
+ }
2278
+
2279
+ function TodoWrite(props: ToolProps<typeof TodoWriteTool>) {
2280
+ return (
2281
+ <Switch>
2282
+ <Match when={props.metadata.todos?.length}>
2283
+ <BlockTool title="# Todos" part={props.part}>
2284
+ <box>
2285
+ <For each={props.input.todos ?? []}>
2286
+ {(todo) => <TodoItem status={todo.status} content={todo.content} />}
2287
+ </For>
2288
+ </box>
2289
+ </BlockTool>
2290
+ </Match>
2291
+ <Match when={true}>
2292
+ <InlineTool icon="⚙" pending="Updating todos..." complete={false} part={props.part}>
2293
+ Updating todos...
2294
+ </InlineTool>
2295
+ </Match>
2296
+ </Switch>
2297
+ )
2298
+ }
2299
+
2300
+ function Question(props: ToolProps<typeof QuestionTool>) {
2301
+ const { theme } = useTheme()
2302
+ const ctx = use()
2303
+ const pending = createMemo(() => ctx.sync.data.question[ctx.sessionID] ?? [])
2304
+ const request = createMemo(() => pending()[0])
2305
+ const count = createMemo(() => props.input.questions?.length ?? 0)
2306
+
2307
+ function format(answer?: string[]) {
2308
+ if (!answer?.length) return "(no answer)"
2309
+ return answer.join(", ")
2310
+ }
2311
+
2312
+ return (
2313
+ <Switch>
2314
+ <Match when={props.metadata.answers}>
2315
+ <BlockTool title="# Questions" part={props.part}>
2316
+ <box gap={1}>
2317
+ <For each={props.input.questions ?? []}>
2318
+ {(q, i) => (
2319
+ <box flexDirection="column">
2320
+ <text fg={theme.textMuted}>{q.question}</text>
2321
+ <text fg={theme.text}>{format(props.metadata.answers?.[i()])}</text>
2322
+ </box>
2323
+ )}
2324
+ </For>
2325
+ </box>
2326
+ </BlockTool>
2327
+ </Match>
2328
+ <Match when={request()}>
2329
+ <QuestionPrompt request={request()!} />
2330
+ </Match>
2331
+ <Match when={true}>
2332
+ <InlineTool icon="→" pending="Asking questions..." complete={count()} part={props.part}>
2333
+ Asked {count()} question{count() !== 1 ? "s" : ""}
2334
+ </InlineTool>
2335
+ </Match>
2336
+ </Switch>
2337
+ )
2338
+ }
2339
+
2340
+ function normalizePath(input?: string) {
2341
+ if (!input) return ""
2342
+ if (path.isAbsolute(input)) {
2343
+ return path.relative(process.cwd(), input) || "."
2344
+ }
2345
+ return input
2346
+ }
2347
+
2348
+ function input(input: Record<string, any>, omit?: string[]): string {
2349
+ const primitives = Object.entries(input).filter(([key, value]) => {
2350
+ if (omit?.includes(key)) return false
2351
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
2352
+ })
2353
+ if (primitives.length === 0) return ""
2354
+ return `[${primitives.map(([key, value]) => `${key}=${value}`).join(", ")}]`
2355
+ }
2356
+
2357
+ function filetype(input?: string) {
2358
+ if (!input) return "none"
2359
+ const ext = path.extname(input)
2360
+ const language = LANGUAGE_EXTENSIONS[ext]
2361
+ if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
2362
+ return language
2363
+ }