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,1347 @@
1
+ import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg } from "@opentui/core"
2
+ import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, Show, Switch, Match, on } from "solid-js"
3
+ import "opentui-spinner/solid"
4
+ import { useLocal } from "@tui/context/local"
5
+ import { useTheme } from "@tui/context/theme"
6
+ import { EmptyBorder } from "@tui/component/border"
7
+ import { useSDK } from "@tui/context/sdk"
8
+ import { useRoute } from "@tui/context/route"
9
+ import { useSync } from "@tui/context/sync"
10
+ import { Identifier } from "@/id/id"
11
+ import { createStore, produce } from "solid-js/store"
12
+ import { useKeybind } from "@tui/context/keybind"
13
+ import { usePromptHistory, type PromptInfo } from "./history"
14
+ import { usePromptStash } from "./stash"
15
+ import { DialogStash } from "../dialog-stash"
16
+ import { type AutocompleteRef, Autocomplete } from "./autocomplete"
17
+ import { useCommandDialog } from "../dialog-command"
18
+ import { useRenderer } from "@opentui/solid"
19
+ import { Editor } from "@tui/util/editor"
20
+ import { useExit } from "../../context/exit"
21
+ import { Clipboard } from "../../util/clipboard"
22
+ import type { FilePart } from "@jonsoc/sdk/v2"
23
+ import { TuiEvent } from "../../event"
24
+ import { iife } from "@/util/iife"
25
+ import { Locale } from "@/util/locale"
26
+ import { formatDuration } from "@/util/format"
27
+ import { createColors, createFrames } from "../../ui/spinner.ts"
28
+ import { useDialog } from "@tui/ui/dialog"
29
+ import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
30
+ import { DialogAlert } from "../../ui/dialog-alert"
31
+ import { useToast } from "../../ui/toast"
32
+ import { useKV } from "../../context/kv"
33
+ import { useTextareaKeybindings } from "../textarea-keybindings"
34
+ import path from "path"
35
+ import { mkdir } from "fs/promises"
36
+ import { tmpdir } from "os"
37
+
38
+ export type PromptProps = {
39
+ sessionID?: string
40
+ visible?: boolean
41
+ disabled?: boolean
42
+ onSubmit?: () => void
43
+ ref?: (ref: PromptRef) => void
44
+ hint?: JSX.Element
45
+ showPlaceholder?: boolean
46
+ }
47
+
48
+ export type PromptRef = {
49
+ focused: boolean
50
+ current: PromptInfo
51
+ set(prompt: PromptInfo): void
52
+ reset(): void
53
+ blur(): void
54
+ focus(): void
55
+ submit(): void
56
+ }
57
+
58
+ const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"]
59
+
60
+ export function Prompt(props: PromptProps) {
61
+ let input: TextareaRenderable
62
+ let anchor: BoxRenderable
63
+ let autocomplete: AutocompleteRef
64
+
65
+ const keybind = useKeybind()
66
+ const local = useLocal()
67
+ const sdk = useSDK()
68
+ const route = useRoute()
69
+ const sync = useSync()
70
+ const dialog = useDialog()
71
+ const toast = useToast()
72
+ const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
73
+ const history = usePromptHistory()
74
+ const stash = usePromptStash()
75
+ const command = useCommandDialog()
76
+ const renderer = useRenderer()
77
+ const { theme, syntax } = useTheme()
78
+ const kv = useKV()
79
+
80
+ async function pasteImagePath(filepath: string, event: PasteEvent) {
81
+ const full = path.isAbsolute(filepath) ? filepath : path.resolve(filepath)
82
+ const file = Bun.file(full)
83
+ if (file.type === "image/svg+xml") {
84
+ const content = await file.text().catch(() => { })
85
+ if (!content) return false
86
+ event.preventDefault()
87
+ pasteText(content, `[SVG: ${file.name ?? "image"}]`)
88
+ return true
89
+ }
90
+ if (file.type.startsWith("image/")) {
91
+ const content = await file
92
+ .arrayBuffer()
93
+ .then((buffer) => Buffer.from(buffer).toString("base64"))
94
+ .catch(() => { })
95
+ if (!content) return false
96
+ event.preventDefault()
97
+ await pasteImage({ filename: file.name, mime: file.type, content, path: full })
98
+ return true
99
+ }
100
+ return false
101
+ }
102
+
103
+ function imageExt(mime: string) {
104
+ return iife(() => {
105
+ if (mime === "image/png") return "png"
106
+ if (mime === "image/jpeg") return "jpg"
107
+ if (mime === "image/jpg") return "jpg"
108
+ if (mime === "image/webp") return "webp"
109
+ if (mime === "image/gif") return "gif"
110
+ if (mime === "image/svg+xml") return "svg"
111
+ return "png"
112
+ })
113
+ }
114
+
115
+ async function persistImage(file: { content: string; mime: string; filename?: string }) {
116
+ const dir = path.join(tmpdir(), "jonsoc", "attachments")
117
+ await mkdir(dir, { recursive: true })
118
+ const parsed = file.filename ? path.parse(file.filename) : undefined
119
+ const baseRaw = parsed?.name ?? "image"
120
+ const base = baseRaw.replace(/[<>:"/\\|?*]+/g, "-")
121
+ const ext = parsed?.ext ? parsed.ext.slice(1) : imageExt(file.mime)
122
+ const name = `${base}-${crypto.randomUUID()}.${ext}`
123
+ const filepath = path.join(dir, name)
124
+ const buffer = Buffer.from(file.content, "base64")
125
+ await Bun.write(filepath, buffer)
126
+ return { path: filepath, filename: name }
127
+ }
128
+
129
+ async function readLatestScreenClipOnce() {
130
+ if (process.platform !== "win32") return
131
+ const local = process.env["LOCALAPPDATA"]
132
+ if (!local) return
133
+ const cwd = path.join(local, "Packages")
134
+ const glob = new Bun.Glob("{MicrosoftWindows.Client.CBS_*,Microsoft.ScreenSketch_*}/TempState/ScreenClip/*.png")
135
+ const entries: Array<{ path: string; time: number }> = []
136
+ for await (const item of glob.scan({ cwd, absolute: true })) {
137
+ const file = Bun.file(item)
138
+ const stat = await file.stat().catch(() => { })
139
+ if (!stat?.isFile()) continue
140
+ const time = typeof stat.mtimeMs === "number" ? stat.mtimeMs : (stat.mtime?.getTime?.() ?? 0)
141
+ entries.push({ path: item, time })
142
+ }
143
+ if (entries.length === 0) return
144
+ const latest = entries.reduce((acc, entry) => (entry.time > acc.time ? entry : acc), {
145
+ path: "",
146
+ time: -1,
147
+ })
148
+ if (latest.time < 0) return
149
+ const file = Bun.file(latest.path)
150
+ if (!file.type.startsWith("image/")) return
151
+ const content = await file
152
+ .arrayBuffer()
153
+ .then((buffer) => Buffer.from(buffer).toString("base64"))
154
+ .catch(() => { })
155
+ if (!content) return
156
+ return { filename: file.name, mime: file.type, content, path: latest.path }
157
+ }
158
+
159
+ async function readLatestScreenClip() {
160
+ const delays = [0, 50, 150]
161
+ for (const delay of delays) {
162
+ if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay))
163
+ const result = await readLatestScreenClipOnce()
164
+ if (result) return result
165
+ }
166
+ }
167
+
168
+ async function readClipboardImage(opts?: { allowScreenClip?: boolean }) {
169
+ const content = await Clipboard.read()
170
+ if (content?.mime.startsWith("image/")) {
171
+ const saved = await persistImage({ content: content.data, mime: content.mime, filename: "clipboard" }).catch(
172
+ () => undefined,
173
+ )
174
+ const savedPath = saved?.path
175
+ const savedName = saved?.filename ?? "clipboard"
176
+ return {
177
+ filename: savedName,
178
+ mime: content.mime,
179
+ content: content.data,
180
+ path: savedPath,
181
+ source: "clipboard" as const,
182
+ }
183
+ }
184
+ if (content) return
185
+ if (!opts?.allowScreenClip) return
186
+ const screenClip = await readLatestScreenClip()
187
+ if (screenClip) return { ...screenClip, source: "screenclip" as const }
188
+ }
189
+
190
+ function promptModelWarning() {
191
+ toast.show({
192
+ variant: "warning",
193
+ message: "Connect a provider to send prompts",
194
+ duration: 3000,
195
+ })
196
+ if (sync.data.provider.length === 0) {
197
+ dialog.replace(() => <DialogProviderConnect />)
198
+ }
199
+ }
200
+
201
+ const textareaKeybindings = useTextareaKeybindings()
202
+
203
+ const fileStyleId = syntax().getStyleId("extmark.file")!
204
+ const agentStyleId = syntax().getStyleId("extmark.agent")!
205
+ const pasteStyleId = syntax().getStyleId("extmark.paste")!
206
+ let promptPartTypeId = 0
207
+
208
+ sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
209
+ input.insertText(evt.properties.text)
210
+ setTimeout(() => {
211
+ input.getLayoutNode().markDirty()
212
+ input.gotoBufferEnd()
213
+ renderer.requestRender()
214
+ }, 0)
215
+ })
216
+
217
+ createEffect(() => {
218
+ if (props.disabled) input.cursorColor = theme.backgroundElement
219
+ if (!props.disabled) input.cursorColor = theme.text
220
+ })
221
+
222
+ const lastUserMessage = createMemo(() => {
223
+ if (!props.sessionID) return undefined
224
+ const messages = sync.data.message[props.sessionID]
225
+ if (!messages) return undefined
226
+ return messages.findLast((m) => m.role === "user")
227
+ })
228
+
229
+ const [store, setStore] = createStore<{
230
+ prompt: PromptInfo
231
+ mode: "normal" | "shell"
232
+ extmarkToPartIndex: Map<number, number>
233
+ interrupt: number
234
+ placeholder: number
235
+ }>({
236
+ placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
237
+ prompt: {
238
+ input: "",
239
+ parts: [],
240
+ },
241
+ mode: "normal",
242
+ extmarkToPartIndex: new Map(),
243
+ interrupt: 0,
244
+ })
245
+
246
+ const [escClearAt, setEscClearAt] = createSignal<number | undefined>()
247
+ const escClearWindowMs = 1500
248
+
249
+ // Initialize agent/model/variant from last user message when session changes
250
+ let syncedSessionID: string | undefined
251
+ createEffect(() => {
252
+ const sessionID = props.sessionID
253
+ const msg = lastUserMessage()
254
+
255
+ if (sessionID !== syncedSessionID) {
256
+ if (!sessionID || !msg) return
257
+
258
+ syncedSessionID = sessionID
259
+
260
+ // Only set agent if it's a primary agent (not a subagent)
261
+ const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent)
262
+ if (msg.agent && isPrimaryAgent) {
263
+ local.agent.set(msg.agent)
264
+ if (msg.model) local.model.set(msg.model)
265
+ if (msg.variant) local.model.variant.set(msg.variant)
266
+ }
267
+ }
268
+ })
269
+
270
+ createEffect(() => {
271
+ if (store.prompt.input === "") setEscClearAt(undefined)
272
+ })
273
+
274
+ command.register(() => {
275
+ return [
276
+ {
277
+ title: "Clear prompt",
278
+ value: "prompt.clear",
279
+ category: "Prompt",
280
+ hidden: true,
281
+ onSelect: (dialog) => {
282
+ clearPromptInput()
283
+ dialog.clear()
284
+ },
285
+ },
286
+ {
287
+ title: "Submit prompt",
288
+ value: "prompt.submit",
289
+ keybind: "input_submit",
290
+ category: "Prompt",
291
+ hidden: true,
292
+ onSelect: (dialog) => {
293
+ if (!input.focused) return
294
+ submit()
295
+ dialog.clear()
296
+ },
297
+ },
298
+ {
299
+ title: "Paste",
300
+ value: "prompt.paste",
301
+ keybind: "input_paste",
302
+ category: "Prompt",
303
+ hidden: false,
304
+ onSelect: async () => {
305
+ const allowClipboardImage = sync.data.config.experimental?.paste_clipboard_image !== false
306
+ const text = await Clipboard.readText()
307
+ if (text) {
308
+ insertPlainText(text)
309
+ return
310
+ }
311
+ if (!allowClipboardImage) {
312
+ toast.show({
313
+ variant: "warning",
314
+ message: "No text in clipboard",
315
+ })
316
+ return
317
+ }
318
+ const content = await readClipboardImage({ allowScreenClip: true })
319
+ if (!content) {
320
+ toast.show({
321
+ variant: "warning",
322
+ message: "No image in clipboard",
323
+ })
324
+ return
325
+ }
326
+ await pasteImage({
327
+ filename: content.filename,
328
+ mime: content.mime,
329
+ content: content.content,
330
+ path: content.path,
331
+ })
332
+ },
333
+ },
334
+ {
335
+ title: "Interrupt session",
336
+ value: "session.interrupt",
337
+ keybind: "session_interrupt",
338
+ category: "Session",
339
+ hidden: true,
340
+ enabled: status().type !== "idle",
341
+ onSelect: (dialog) => {
342
+ if (autocomplete.visible) return
343
+ if (!input.focused) return
344
+ // TODO: this should be its own command
345
+ if (store.mode === "shell") {
346
+ setStore("mode", "normal")
347
+ return
348
+ }
349
+ if (!props.sessionID) return
350
+
351
+ setStore("interrupt", store.interrupt + 1)
352
+
353
+ setTimeout(() => {
354
+ setStore("interrupt", 0)
355
+ }, 5000)
356
+
357
+ if (store.interrupt >= 2) {
358
+ sdk.client.session.abort({
359
+ sessionID: props.sessionID,
360
+ })
361
+ setStore("interrupt", 0)
362
+ }
363
+ dialog.clear()
364
+ },
365
+ },
366
+ {
367
+ title: "Open editor",
368
+ category: "Session",
369
+ keybind: "editor_open",
370
+ value: "prompt.editor",
371
+ slash: {
372
+ name: "editor",
373
+ },
374
+ onSelect: async (dialog) => {
375
+ dialog.clear()
376
+
377
+ // replace summarized text parts with the actual text
378
+ const text = store.prompt.parts
379
+ .filter((p) => p.type === "text")
380
+ .reduce((acc, p) => {
381
+ if (!p.source) return acc
382
+ return acc.replace(p.source.text.value, p.text)
383
+ }, store.prompt.input)
384
+
385
+ const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text")
386
+
387
+ const value = text
388
+ const content = await Editor.open({ value, renderer })
389
+ if (!content) return
390
+
391
+ input.setText(content)
392
+
393
+ // Update positions for nonTextParts based on their location in new content
394
+ // Filter out parts whose virtual text was deleted
395
+ // this handles a case where the user edits the text in the editor
396
+ // such that the virtual text moves around or is deleted
397
+ const updatedNonTextParts = nonTextParts
398
+ .map((part) => {
399
+ let virtualText = ""
400
+ if (part.type === "file" && part.source?.text) {
401
+ virtualText = part.source.text.value
402
+ } else if (part.type === "agent" && part.source) {
403
+ virtualText = part.source.value
404
+ }
405
+
406
+ if (!virtualText) return part
407
+
408
+ const newStart = content.indexOf(virtualText)
409
+ // if the virtual text is deleted, remove the part
410
+ if (newStart === -1) return null
411
+
412
+ const newEnd = newStart + virtualText.length
413
+
414
+ if (part.type === "file" && part.source?.text) {
415
+ return {
416
+ ...part,
417
+ source: {
418
+ ...part.source,
419
+ text: {
420
+ ...part.source.text,
421
+ start: newStart,
422
+ end: newEnd,
423
+ },
424
+ },
425
+ }
426
+ }
427
+
428
+ if (part.type === "agent" && part.source) {
429
+ return {
430
+ ...part,
431
+ source: {
432
+ ...part.source,
433
+ start: newStart,
434
+ end: newEnd,
435
+ },
436
+ }
437
+ }
438
+
439
+ return part
440
+ })
441
+ .filter((part) => part !== null)
442
+
443
+ setStore("prompt", {
444
+ input: content,
445
+ // keep only the non-text parts because the text parts were
446
+ // already expanded inline
447
+ parts: updatedNonTextParts,
448
+ })
449
+ restoreExtmarksFromParts(updatedNonTextParts)
450
+ input.cursorOffset = Bun.stringWidth(content)
451
+ },
452
+ },
453
+ ]
454
+ })
455
+
456
+ const ref: PromptRef = {
457
+ get focused() {
458
+ return input.focused
459
+ },
460
+ get current() {
461
+ return store.prompt
462
+ },
463
+ focus() {
464
+ input.focus()
465
+ },
466
+ blur() {
467
+ input.blur()
468
+ },
469
+ set(prompt) {
470
+ input.setText(prompt.input)
471
+ setStore("prompt", prompt)
472
+ restoreExtmarksFromParts(prompt.parts)
473
+ input.gotoBufferEnd()
474
+ },
475
+ reset() {
476
+ input.clear()
477
+ input.extmarks.clear()
478
+ setStore("prompt", {
479
+ input: "",
480
+ parts: [],
481
+ })
482
+ setStore("extmarkToPartIndex", new Map())
483
+ },
484
+ submit() {
485
+ submit()
486
+ },
487
+ }
488
+
489
+ createEffect(
490
+ on(
491
+ () => props.visible !== false,
492
+ (visible, prev) => {
493
+ if (visible && !prev) input?.focus()
494
+ if (!visible) input?.blur()
495
+ },
496
+ ),
497
+ )
498
+
499
+ function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
500
+ input.extmarks.clear()
501
+ const newMap = new Map<number, number>()
502
+
503
+ parts.forEach((part, partIndex) => {
504
+ let start = 0
505
+ let end = 0
506
+ let virtualText = ""
507
+ let styleId: number | undefined
508
+
509
+ if (part.type === "file" && part.source?.text) {
510
+ start = part.source.text.start
511
+ end = part.source.text.end
512
+ virtualText = part.source.text.value
513
+ styleId = fileStyleId
514
+ } else if (part.type === "agent" && part.source) {
515
+ start = part.source.start
516
+ end = part.source.end
517
+ virtualText = part.source.value
518
+ styleId = agentStyleId
519
+ } else if (part.type === "text" && part.source?.text) {
520
+ start = part.source.text.start
521
+ end = part.source.text.end
522
+ virtualText = part.source.text.value
523
+ styleId = pasteStyleId
524
+ }
525
+
526
+ if (virtualText) {
527
+ const extmarkId = input.extmarks.create({
528
+ start,
529
+ end,
530
+ virtual: true,
531
+ styleId,
532
+ typeId: promptPartTypeId,
533
+ })
534
+ newMap.set(extmarkId, partIndex)
535
+ }
536
+ })
537
+
538
+ setStore("extmarkToPartIndex", newMap)
539
+ }
540
+
541
+ function syncExtmarksWithPromptParts() {
542
+ const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
543
+ const currentMap = store.extmarkToPartIndex
544
+ const currentParts = store.prompt.parts
545
+
546
+ setStore(
547
+ produce((draft) => {
548
+ const newMap = new Map<number, number>()
549
+ const newParts: typeof draft.prompt.parts = []
550
+
551
+ for (const extmark of allExtmarks) {
552
+ const partIndex = currentMap.get(extmark.id)
553
+ if (partIndex !== undefined) {
554
+ const part = currentParts[partIndex]
555
+ if (part) {
556
+ // Create a deep copy to avoid mutating the original
557
+ const updatedPart = { ...part }
558
+ if (part.type === "agent" && part.source) {
559
+ updatedPart.source = {
560
+ ...part.source,
561
+ start: extmark.start,
562
+ end: extmark.end,
563
+ }
564
+ } else if (part.type === "file" && part.source?.text) {
565
+ updatedPart.source = {
566
+ ...part.source,
567
+ text: {
568
+ ...part.source.text,
569
+ start: extmark.start,
570
+ end: extmark.end,
571
+ },
572
+ }
573
+ } else if (part.type === "text" && part.source?.text) {
574
+ updatedPart.source = {
575
+ ...part.source,
576
+ text: {
577
+ ...part.source.text,
578
+ start: extmark.start,
579
+ end: extmark.end,
580
+ },
581
+ }
582
+ }
583
+ newMap.set(extmark.id, newParts.length)
584
+ newParts.push(updatedPart)
585
+ }
586
+ }
587
+ }
588
+
589
+ draft.extmarkToPartIndex = newMap
590
+ draft.prompt.parts = newParts
591
+ }),
592
+ )
593
+ }
594
+
595
+ command.register(() => [
596
+ {
597
+ title: "Stash prompt",
598
+ value: "prompt.stash",
599
+ category: "Prompt",
600
+ enabled: !!store.prompt.input,
601
+ onSelect: (dialog) => {
602
+ if (!store.prompt.input) return
603
+ stash.push({
604
+ input: store.prompt.input,
605
+ parts: store.prompt.parts,
606
+ })
607
+ input.extmarks.clear()
608
+ input.clear()
609
+ setStore("prompt", { input: "", parts: [] })
610
+ setStore("extmarkToPartIndex", new Map())
611
+ dialog.clear()
612
+ },
613
+ },
614
+ {
615
+ title: "Stash pop",
616
+ value: "prompt.stash.pop",
617
+ category: "Prompt",
618
+ enabled: stash.list().length > 0,
619
+ onSelect: (dialog) => {
620
+ const entry = stash.pop()
621
+ if (entry) {
622
+ input.setText(entry.input)
623
+ setStore("prompt", { input: entry.input, parts: entry.parts })
624
+ restoreExtmarksFromParts(entry.parts)
625
+ input.gotoBufferEnd()
626
+ }
627
+ dialog.clear()
628
+ },
629
+ },
630
+ {
631
+ title: "Stash list",
632
+ value: "prompt.stash.list",
633
+ category: "Prompt",
634
+ enabled: stash.list().length > 0,
635
+ onSelect: (dialog) => {
636
+ dialog.replace(() => (
637
+ <DialogStash
638
+ onSelect={(entry) => {
639
+ input.setText(entry.input)
640
+ setStore("prompt", { input: entry.input, parts: entry.parts })
641
+ restoreExtmarksFromParts(entry.parts)
642
+ input.gotoBufferEnd()
643
+ }}
644
+ />
645
+ ))
646
+ },
647
+ },
648
+ ])
649
+
650
+ async function submit() {
651
+ if (props.disabled) return
652
+ if (autocomplete?.visible) return
653
+ if (!store.prompt.input) return
654
+ const trimmed = store.prompt.input.trim()
655
+ if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
656
+ exit()
657
+ return
658
+ }
659
+ const selectedModel = local.model.current()
660
+ if (!selectedModel) {
661
+ promptModelWarning()
662
+ return
663
+ }
664
+ const sessionID = props.sessionID
665
+ ? props.sessionID
666
+ : await (async () => {
667
+ const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id)
668
+ return sessionID
669
+ })()
670
+ const messageID = Identifier.ascending("message")
671
+ let inputText = store.prompt.input
672
+
673
+ // Expand pasted text inline before submitting
674
+ const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
675
+ const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start)
676
+
677
+ for (const extmark of sortedExtmarks) {
678
+ const partIndex = store.extmarkToPartIndex.get(extmark.id)
679
+ if (partIndex !== undefined) {
680
+ const part = store.prompt.parts[partIndex]
681
+ if (part?.type === "text" && part.text) {
682
+ const before = inputText.slice(0, extmark.start)
683
+ const after = inputText.slice(extmark.end)
684
+ inputText = before + part.text + after
685
+ }
686
+ }
687
+ }
688
+
689
+ // Filter out text parts (pasted content) since they're now expanded inline
690
+ const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")
691
+
692
+ // Capture mode before it gets reset
693
+ const currentMode = store.mode
694
+ const variant = local.model.variant.current()
695
+
696
+ if (store.mode === "shell") {
697
+ sdk.client.session.shell({
698
+ sessionID,
699
+ agent: local.agent.current().name,
700
+ model: {
701
+ providerID: selectedModel.providerID,
702
+ modelID: selectedModel.modelID,
703
+ },
704
+ command: inputText,
705
+ })
706
+ setStore("mode", "normal")
707
+ } else if (
708
+ inputText.startsWith("/") &&
709
+ iife(() => {
710
+ const firstLine = inputText.split("\n")[0]
711
+ const command = firstLine.split(" ")[0].slice(1)
712
+ return sync.data.command.some((x) => x.name === command)
713
+ })
714
+ ) {
715
+ // Parse command from first line, preserve multi-line content in arguments
716
+ const firstLineEnd = inputText.indexOf("\n")
717
+ const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
718
+ const [command, ...firstLineArgs] = firstLine.split(" ")
719
+ const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
720
+ const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
721
+
722
+ sdk.client.session.command({
723
+ sessionID,
724
+ command: command.slice(1),
725
+ arguments: args,
726
+ agent: local.agent.current().name,
727
+ model: `${selectedModel.providerID}/${selectedModel.modelID}`,
728
+ messageID,
729
+ variant,
730
+ parts: nonTextParts
731
+ .filter((x) => x.type === "file")
732
+ .map((x) => ({
733
+ id: Identifier.ascending("part"),
734
+ ...x,
735
+ })),
736
+ })
737
+ } else {
738
+ sdk.client.session
739
+ .prompt({
740
+ sessionID,
741
+ ...selectedModel,
742
+ messageID,
743
+ agent: local.agent.current().name,
744
+ model: selectedModel,
745
+ variant,
746
+ parts: [
747
+ {
748
+ id: Identifier.ascending("part"),
749
+ type: "text",
750
+ text: inputText,
751
+ },
752
+ ...nonTextParts.map((x) => ({
753
+ id: Identifier.ascending("part"),
754
+ ...x,
755
+ })),
756
+ ],
757
+ })
758
+ .catch(() => { })
759
+ }
760
+ history.append({
761
+ ...store.prompt,
762
+ mode: currentMode,
763
+ })
764
+ input.extmarks.clear()
765
+ setStore("prompt", {
766
+ input: "",
767
+ parts: [],
768
+ })
769
+ setStore("extmarkToPartIndex", new Map())
770
+ props.onSubmit?.()
771
+
772
+ // temporary hack to make sure the message is sent
773
+ if (!props.sessionID)
774
+ setTimeout(() => {
775
+ route.navigate({
776
+ type: "session",
777
+ sessionID,
778
+ })
779
+ }, 50)
780
+ input.clear()
781
+ }
782
+ const exit = useExit()
783
+
784
+ function pasteText(text: string, virtualText: string) {
785
+ const currentOffset = input.visualCursor.offset
786
+ const extmarkStart = currentOffset
787
+ const extmarkEnd = extmarkStart + virtualText.length
788
+
789
+ input.insertText(virtualText + " ")
790
+
791
+ const extmarkId = input.extmarks.create({
792
+ start: extmarkStart,
793
+ end: extmarkEnd,
794
+ virtual: true,
795
+ styleId: pasteStyleId,
796
+ typeId: promptPartTypeId,
797
+ })
798
+
799
+ setStore(
800
+ produce((draft) => {
801
+ const partIndex = draft.prompt.parts.length
802
+ draft.prompt.parts.push({
803
+ type: "text" as const,
804
+ text,
805
+ source: {
806
+ text: {
807
+ start: extmarkStart,
808
+ end: extmarkEnd,
809
+ value: virtualText,
810
+ },
811
+ },
812
+ })
813
+ draft.extmarkToPartIndex.set(extmarkId, partIndex)
814
+ }),
815
+ )
816
+ }
817
+
818
+ function clearPromptInput() {
819
+ input.clear()
820
+ input.extmarks.clear()
821
+ setStore("prompt", {
822
+ input: "",
823
+ parts: [],
824
+ })
825
+ setStore("extmarkToPartIndex", new Map())
826
+ setEscClearAt(undefined)
827
+ }
828
+
829
+ function insertPlainText(text: string) {
830
+ if (!text) return
831
+ input.focus()
832
+ input.insertText(text)
833
+ input.gotoBufferEnd()
834
+ const value = input.plainText
835
+ setStore("prompt", "input", value)
836
+ autocomplete.onInput(value)
837
+ syncExtmarksWithPromptParts()
838
+ setTimeout(() => {
839
+ input.getLayoutNode().markDirty()
840
+ renderer.requestRender()
841
+ }, 0)
842
+ }
843
+
844
+ async function pasteImage(file: { filename?: string; content: string; mime: string; path?: string }) {
845
+ const currentOffset = input.visualCursor.offset
846
+ const extmarkStart = currentOffset
847
+ const count = store.prompt.parts.filter((x) => x.type === "file").length
848
+ const virtualText = file.path ? `[Image ${count + 1}: ${file.path}]` : `[Image ${count + 1}]`
849
+ const extmarkEnd = extmarkStart + virtualText.length
850
+ const textToInsert = virtualText + " "
851
+
852
+ input.insertText(textToInsert)
853
+
854
+ const extmarkId = input.extmarks.create({
855
+ start: extmarkStart,
856
+ end: extmarkEnd,
857
+ virtual: true,
858
+ styleId: pasteStyleId,
859
+ typeId: promptPartTypeId,
860
+ })
861
+
862
+ const sourcePath = file.path ?? file.filename ?? ""
863
+ const part: Omit<FilePart, "id" | "messageID" | "sessionID"> = {
864
+ type: "file" as const,
865
+ mime: file.mime,
866
+ filename: file.filename,
867
+ url: `data:${file.mime};base64,${file.content}`,
868
+ source: {
869
+ type: "file",
870
+ path: sourcePath,
871
+ text: {
872
+ start: extmarkStart,
873
+ end: extmarkEnd,
874
+ value: virtualText,
875
+ },
876
+ },
877
+ }
878
+ setStore(
879
+ produce((draft) => {
880
+ const partIndex = draft.prompt.parts.length
881
+ draft.prompt.parts.push(part)
882
+ draft.extmarkToPartIndex.set(extmarkId, partIndex)
883
+ }),
884
+ )
885
+ return
886
+ }
887
+
888
+ const highlight = createMemo(() => {
889
+ if (keybind.leader) return theme.border
890
+ if (store.mode === "shell") return theme.primary
891
+ return local.agent.color(local.agent.current().name)
892
+ })
893
+
894
+ const showVariant = createMemo(() => {
895
+ const variants = local.model.variant.list()
896
+ if (variants.length === 0) return false
897
+ const current = local.model.variant.current()
898
+ return !!current
899
+ })
900
+
901
+ const spinnerDef = createMemo(() => {
902
+ const color = local.agent.color(local.agent.current().name)
903
+ return {
904
+ frames: createFrames({
905
+ color,
906
+ style: "custom",
907
+ activeChar: "▣",
908
+ inactiveChar: "·",
909
+ trailSteps: 1,
910
+ inactiveFactor: 0.6,
911
+ // enableFading: false,
912
+ minAlpha: 0.3,
913
+ }),
914
+ color: createColors({
915
+ color,
916
+ style: "custom",
917
+ activeChar: "▣",
918
+ inactiveChar: "·",
919
+ trailSteps: 1,
920
+ inactiveFactor: 0.6,
921
+ // enableFading: false,
922
+ minAlpha: 0.3,
923
+ }),
924
+ }
925
+ })
926
+
927
+ return (
928
+ <>
929
+ <Autocomplete
930
+ sessionID={props.sessionID}
931
+ ref={(r) => (autocomplete = r)}
932
+ anchor={() => anchor}
933
+ input={() => input}
934
+ setPrompt={(cb) => {
935
+ setStore("prompt", produce(cb))
936
+ }}
937
+ setExtmark={(partIndex, extmarkId) => {
938
+ setStore("extmarkToPartIndex", (map: Map<number, number>) => {
939
+ const newMap = new Map(map)
940
+ newMap.set(extmarkId, partIndex)
941
+ return newMap
942
+ })
943
+ }}
944
+ value={store.prompt.input}
945
+ fileStyleId={fileStyleId}
946
+ agentStyleId={agentStyleId}
947
+ promptPartTypeId={() => promptPartTypeId}
948
+ />
949
+ <box ref={(r) => (anchor = r)} visible={props.visible !== false}>
950
+ <Show when={status().type !== "idle"}>
951
+ <box flexDirection="row" height={1} marginLeft={1}>
952
+ <text fg={store.interrupt > 0 ? theme.primary : theme.text}>
953
+ esc{" "}
954
+ <span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
955
+ {store.interrupt > 0 ? "again to interrupt" : "interrupt"}
956
+ </span>
957
+ </text>
958
+ </box>
959
+ </Show>
960
+ <box
961
+ border={["left"]}
962
+ borderColor={highlight()}
963
+ customBorderChars={{
964
+ ...EmptyBorder,
965
+ vertical: "┃",
966
+ bottomLeft: "╹",
967
+ }}
968
+ >
969
+ <box
970
+ paddingLeft={2}
971
+ paddingRight={2}
972
+ paddingTop={1}
973
+ flexShrink={0}
974
+ backgroundColor={theme.backgroundElement}
975
+ flexGrow={1}
976
+ >
977
+ <textarea
978
+ placeholder={props.sessionID ? undefined : `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
979
+ textColor={keybind.leader ? theme.textMuted : theme.text}
980
+ focusedTextColor={keybind.leader ? theme.textMuted : theme.text}
981
+ minHeight={1}
982
+ maxHeight={6}
983
+ onContentChange={() => {
984
+ const value = input.plainText
985
+ setStore("prompt", "input", value)
986
+ autocomplete.onInput(value)
987
+ syncExtmarksWithPromptParts()
988
+ }}
989
+ keyBindings={textareaKeybindings()}
990
+ onKeyDown={async (e) => {
991
+ if (props.disabled) {
992
+ e.preventDefault()
993
+ return
994
+ }
995
+ if (e.name === "escape" && store.mode === "normal" && store.prompt.input !== "") {
996
+ const now = Date.now()
997
+ const last = escClearAt()
998
+ const within = last !== undefined && now - last < escClearWindowMs
999
+ if (within && status().type === "idle") {
1000
+ clearPromptInput()
1001
+ e.preventDefault()
1002
+ return
1003
+ }
1004
+ if (!within) {
1005
+ setEscClearAt(now)
1006
+ toast.show({
1007
+ variant: "warning",
1008
+ message: "Press esc again to clear input",
1009
+ duration: escClearWindowMs,
1010
+ })
1011
+ if (status().type === "idle") {
1012
+ e.preventDefault()
1013
+ return
1014
+ }
1015
+ }
1016
+ }
1017
+ if (keybind.match("input_clear", e) && store.prompt.input !== "") {
1018
+ clearPromptInput()
1019
+ return
1020
+ }
1021
+ if (keybind.match("app_exit", e)) {
1022
+ if (store.prompt.input === "") {
1023
+ await exit()
1024
+ // Don't preventDefault - let textarea potentially handle the event
1025
+ e.preventDefault()
1026
+ return
1027
+ }
1028
+ }
1029
+ if (e.name === "!" && input.visualCursor.offset === 0) {
1030
+ setStore("mode", "shell")
1031
+ e.preventDefault()
1032
+ return
1033
+ }
1034
+ if (store.mode === "shell") {
1035
+ if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") {
1036
+ setStore("mode", "normal")
1037
+ e.preventDefault()
1038
+ return
1039
+ }
1040
+ }
1041
+ if (store.mode === "normal") autocomplete.onKeyDown(e)
1042
+ if (!autocomplete.visible) {
1043
+ if (
1044
+ (keybind.match("history_previous", e) && input.cursorOffset === 0) ||
1045
+ (keybind.match("history_next", e) && input.cursorOffset === input.plainText.length)
1046
+ ) {
1047
+ const direction = keybind.match("history_previous", e) ? -1 : 1
1048
+ const item = history.move(direction, input.plainText)
1049
+
1050
+ if (item) {
1051
+ input.setText(item.input)
1052
+ setStore("prompt", item)
1053
+ setStore("mode", item.mode ?? "normal")
1054
+ restoreExtmarksFromParts(item.parts)
1055
+ e.preventDefault()
1056
+ if (direction === -1) input.cursorOffset = 0
1057
+ if (direction === 1) input.cursorOffset = input.plainText.length
1058
+ }
1059
+ return
1060
+ }
1061
+
1062
+ if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0) input.cursorOffset = 0
1063
+ if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1)
1064
+ input.cursorOffset = input.plainText.length
1065
+ }
1066
+ }}
1067
+ onSubmit={submit}
1068
+ onPaste={async (event: PasteEvent) => {
1069
+ if (props.disabled) {
1070
+ event.preventDefault()
1071
+ return
1072
+ }
1073
+
1074
+ const clipboardText = async () => {
1075
+ return await Clipboard.readText()
1076
+ }
1077
+
1078
+ // Normalize line endings at the boundary
1079
+ // Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
1080
+ // Replace CRLF first, then any remaining CR
1081
+ const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\0/g, "")
1082
+ const allowClipboardImage = sync.data.config.experimental?.paste_clipboard_image !== false
1083
+ const clipboardFallback = normalizedText.trim() ? undefined : await clipboardText()
1084
+ const text = clipboardFallback ? clipboardFallback : normalizedText
1085
+ const pastedContent = text.trim()
1086
+ if (!pastedContent) {
1087
+ if (allowClipboardImage) {
1088
+ const clipboard = await readClipboardImage({ allowScreenClip: true })
1089
+ if (clipboard) {
1090
+ event.preventDefault()
1091
+ await pasteImage(clipboard)
1092
+ return
1093
+ }
1094
+ }
1095
+ return
1096
+ }
1097
+
1098
+ if (allowClipboardImage) {
1099
+ const dataImage = Clipboard.parseImageDataUrl(pastedContent)
1100
+ if (dataImage) {
1101
+ event.preventDefault()
1102
+ await pasteImage({
1103
+ filename: "clipboard",
1104
+ mime: dataImage.mime,
1105
+ content: dataImage.data,
1106
+ })
1107
+ return
1108
+ }
1109
+ }
1110
+
1111
+ // Some terminals paste images as a placeholder string like:
1112
+ // "img 483795798.png". On Win11, Snipping Tool also writes a temp file
1113
+ // under Packages/*/TempState/ScreenClip. Prefer that when present.
1114
+ if (allowClipboardImage && pastedContent.toLowerCase().startsWith("img ")) {
1115
+ const name = pastedContent
1116
+ .slice(4)
1117
+ .trim()
1118
+ .replace(/^'+|'+$/g, "")
1119
+ const fromPath = path.isAbsolute(name) ? name : ""
1120
+ if (fromPath) {
1121
+ const handled = await pasteImagePath(fromPath, event)
1122
+ if (handled) return
1123
+ }
1124
+ if (name.endsWith(".png")) {
1125
+ const local = process.env["LOCALAPPDATA"]
1126
+ if (local) {
1127
+ const cwd = path.join(local, "Packages")
1128
+ const glob = new Bun.Glob(
1129
+ `{MicrosoftWindows.Client.CBS_*,Microsoft.ScreenSketch_*}/TempState/ScreenClip/${name}`,
1130
+ )
1131
+ for await (const item of glob.scan({ cwd, absolute: true })) {
1132
+ const file = Bun.file(item)
1133
+ if (file.type.startsWith("image/")) {
1134
+ const content = await file
1135
+ .arrayBuffer()
1136
+ .then((buffer) => Buffer.from(buffer).toString("base64"))
1137
+ .catch(() => { })
1138
+ if (content) {
1139
+ event.preventDefault()
1140
+ await pasteImage({ filename: file.name, mime: file.type, content })
1141
+ return
1142
+ }
1143
+ }
1144
+ }
1145
+ }
1146
+ }
1147
+
1148
+ if (allowClipboardImage) {
1149
+ const content = await Clipboard.read()
1150
+ if (content?.mime.startsWith("image/")) {
1151
+ event.preventDefault()
1152
+ await pasteImage({
1153
+ filename: "clipboard",
1154
+ mime: content.mime,
1155
+ content: content.data,
1156
+ })
1157
+ return
1158
+ }
1159
+ }
1160
+ }
1161
+
1162
+ // trim ' from the beginning and end of the pasted content. just
1163
+ // ' and nothing else
1164
+ const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ")
1165
+ const isUrl = /^(https?):\/\//.test(filepath)
1166
+ if (!isUrl) {
1167
+ const handled = await pasteImagePath(filepath, event)
1168
+ if (handled) return
1169
+ }
1170
+
1171
+ const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
1172
+ if (
1173
+ (lineCount >= 3 || pastedContent.length > 150) &&
1174
+ !sync.data.config.experimental?.disable_paste_summary
1175
+ ) {
1176
+ event.preventDefault()
1177
+ pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
1178
+ return
1179
+ }
1180
+
1181
+ if (clipboardFallback) {
1182
+ event.preventDefault()
1183
+ insertPlainText(text)
1184
+ return
1185
+ }
1186
+
1187
+ // Force layout update and render for the pasted content
1188
+ setTimeout(() => {
1189
+ input.getLayoutNode().markDirty()
1190
+ renderer.requestRender()
1191
+ }, 0)
1192
+ }}
1193
+ ref={(r: TextareaRenderable) => {
1194
+ input = r
1195
+ if (promptPartTypeId === 0) {
1196
+ promptPartTypeId = input.extmarks.registerType("prompt-part")
1197
+ }
1198
+ props.ref?.(ref)
1199
+ setTimeout(() => {
1200
+ input.cursorColor = theme.text
1201
+ }, 0)
1202
+ }}
1203
+ onMouseDown={(r: MouseEvent) => r.target?.focus()}
1204
+ focusedBackgroundColor={theme.backgroundElement}
1205
+ cursorColor={theme.text}
1206
+ syntaxStyle={syntax()}
1207
+ />
1208
+ <box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
1209
+ <text fg={highlight()}>
1210
+ {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
1211
+ </text>
1212
+ <Show when={store.mode === "normal"}>
1213
+ <box flexDirection="row" gap={1}>
1214
+ <text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
1215
+ {local.model.parsed().model}
1216
+ </text>
1217
+ <text fg={theme.textMuted}>{local.model.parsed().provider}</text>
1218
+ <Show when={showVariant()}>
1219
+ <text>
1220
+ <span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
1221
+ </text>
1222
+ </Show>
1223
+ </box>
1224
+ </Show>
1225
+ </box>
1226
+ </box>
1227
+ </box>
1228
+ <box
1229
+ height={1}
1230
+ border={["left"]}
1231
+ borderColor={highlight()}
1232
+ customBorderChars={{
1233
+ ...EmptyBorder,
1234
+ vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",
1235
+ }}
1236
+ >
1237
+ <box
1238
+ height={1}
1239
+ border={["bottom"]}
1240
+ borderColor={theme.backgroundElement}
1241
+ customBorderChars={
1242
+ theme.backgroundElement.a !== 0
1243
+ ? {
1244
+ ...EmptyBorder,
1245
+ horizontal: "▀",
1246
+ }
1247
+ : {
1248
+ ...EmptyBorder,
1249
+ horizontal: " ",
1250
+ }
1251
+ }
1252
+ />
1253
+ </box>
1254
+ <box flexDirection="row" justifyContent="space-between">
1255
+ <Show when={status().type === "retry"} fallback={<text />}>
1256
+ <box flexDirection="row" gap={1} flexGrow={1} justifyContent="space-between">
1257
+ <box flexShrink={0} flexDirection="row" gap={1}>
1258
+ <box flexDirection="row" gap={1} flexShrink={0}>
1259
+ {(() => {
1260
+ const retry = createMemo(() => {
1261
+ const s = status()
1262
+ if (s.type !== "retry") return
1263
+ return s
1264
+ })
1265
+ const message = createMemo(() => {
1266
+ const r = retry()
1267
+ if (!r) return
1268
+ if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
1269
+ return "gemini is way too hot right now"
1270
+ if (r.message.length > 80) return r.message.slice(0, 80) + "..."
1271
+ return r.message
1272
+ })
1273
+ const isTruncated = createMemo(() => {
1274
+ const r = retry()
1275
+ if (!r) return false
1276
+ return r.message.length > 120
1277
+ })
1278
+ const [seconds, setSeconds] = createSignal(0)
1279
+ onMount(() => {
1280
+ const timer = setInterval(() => {
1281
+ const next = retry()?.next
1282
+ if (next) setSeconds(Math.round((next - Date.now()) / 1000))
1283
+ }, 1000)
1284
+
1285
+ onCleanup(() => {
1286
+ clearInterval(timer)
1287
+ })
1288
+ })
1289
+ const handleMessageClick = () => {
1290
+ const r = retry()
1291
+ if (!r) return
1292
+ if (isTruncated()) {
1293
+ DialogAlert.show(dialog, "Retry Error", r.message)
1294
+ }
1295
+ }
1296
+
1297
+ const retryText = () => {
1298
+ const r = retry()
1299
+ if (!r) return ""
1300
+ const baseMessage = message()
1301
+ const truncatedHint = isTruncated() ? " (click to expand)" : ""
1302
+ const duration = formatDuration(seconds())
1303
+ const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
1304
+ return baseMessage + truncatedHint + retryInfo
1305
+ }
1306
+
1307
+ return (
1308
+ <Show when={retry()}>
1309
+ <box onMouseUp={handleMessageClick}>
1310
+ <text fg={theme.error}>{retryText()}</text>
1311
+ </box>
1312
+ </Show>
1313
+ )
1314
+ })()}
1315
+ </box>
1316
+ </box>
1317
+ </box>
1318
+ </Show>
1319
+ <Show when={status().type !== "retry"}>
1320
+ <box gap={2} flexDirection="row">
1321
+ <Switch>
1322
+ <Match when={store.mode === "normal"}>
1323
+ <Show when={local.model.variant.list().length > 0}>
1324
+ <text fg={theme.text}>
1325
+ {keybind.print("variant_cycle")} <span style={{ fg: theme.textMuted }}>variants</span>
1326
+ </text>
1327
+ </Show>
1328
+ <text fg={theme.text}>
1329
+ {keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
1330
+ </text>
1331
+ <text fg={theme.text}>
1332
+ {keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
1333
+ </text>
1334
+ </Match>
1335
+ <Match when={store.mode === "shell"}>
1336
+ <text fg={theme.text}>
1337
+ esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
1338
+ </text>
1339
+ </Match>
1340
+ </Switch>
1341
+ </box>
1342
+ </Show>
1343
+ </box>
1344
+ </box>
1345
+ </>
1346
+ )
1347
+ }