nikcli 0.0.6

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 (602) hide show
  1. package/.turbo/turbo-typecheck.log +1 -0
  2. package/AGENTS.md +27 -0
  3. package/Dockerfile +18 -0
  4. package/README.md +15 -0
  5. package/bin/nikcli +84 -0
  6. package/config.json +13 -0
  7. package/docs/tailscale-mobile/01-tailscale-setup.md +94 -0
  8. package/docs/tailscale-mobile/02-host-setup.md +115 -0
  9. package/docs/tailscale-mobile/03-phone-and-serve.md +134 -0
  10. package/docs/tailscale-mobile/README.md +59 -0
  11. package/examples/README.md +54 -0
  12. package/package.json +147 -0
  13. package/parsers-config.ts +253 -0
  14. package/script/build.ts +179 -0
  15. package/script/postinstall.mjs +125 -0
  16. package/script/publish-registries.ts +187 -0
  17. package/script/publish.ts +100 -0
  18. package/script/schema.ts +47 -0
  19. package/script/seed-e2e.ts +50 -0
  20. package/sequential-prancing-forest.md +373 -0
  21. package/src/acp/README.md +164 -0
  22. package/src/acp/agent.ts +1303 -0
  23. package/src/acp/session.ts +105 -0
  24. package/src/acp/types.ts +22 -0
  25. package/src/agent/agent.ts +528 -0
  26. package/src/agent/generate.txt +32 -0
  27. package/src/agent/prompt/compaction.txt +14 -0
  28. package/src/agent/prompt/explore.txt +18 -0
  29. package/src/agent/prompt/summary.txt +11 -0
  30. package/src/agent/prompt/title.txt +44 -0
  31. package/src/auth/index.ts +73 -0
  32. package/src/bun/index.ts +119 -0
  33. package/src/bun/registry.ts +54 -0
  34. package/src/bus/bus-event.ts +43 -0
  35. package/src/bus/global.ts +10 -0
  36. package/src/bus/index.ts +105 -0
  37. package/src/chatbot/handlers.ts +150 -0
  38. package/src/chatbot/index.ts +132 -0
  39. package/src/cli/bootstrap.ts +17 -0
  40. package/src/cli/cmd/acp.ts +69 -0
  41. package/src/cli/cmd/ads.ts +377 -0
  42. package/src/cli/cmd/agent.ts +259 -0
  43. package/src/cli/cmd/auth.ts +400 -0
  44. package/src/cli/cmd/chatbot.ts +420 -0
  45. package/src/cli/cmd/cmd.ts +7 -0
  46. package/src/cli/cmd/companion.ts +81 -0
  47. package/src/cli/cmd/connectors.ts +593 -0
  48. package/src/cli/cmd/debug/agent.ts +166 -0
  49. package/src/cli/cmd/debug/config.ts +16 -0
  50. package/src/cli/cmd/debug/file.ts +97 -0
  51. package/src/cli/cmd/debug/index.ts +48 -0
  52. package/src/cli/cmd/debug/lsp.ts +52 -0
  53. package/src/cli/cmd/debug/ripgrep.ts +87 -0
  54. package/src/cli/cmd/debug/scrap.ts +16 -0
  55. package/src/cli/cmd/debug/skill.ts +16 -0
  56. package/src/cli/cmd/debug/snapshot.ts +52 -0
  57. package/src/cli/cmd/export.ts +88 -0
  58. package/src/cli/cmd/generate.ts +38 -0
  59. package/src/cli/cmd/github.ts +412 -0
  60. package/src/cli/cmd/image-model.ts +128 -0
  61. package/src/cli/cmd/import.ts +201 -0
  62. package/src/cli/cmd/lovable.ts +128 -0
  63. package/src/cli/cmd/mcp.ts +738 -0
  64. package/src/cli/cmd/mobile.ts +223 -0
  65. package/src/cli/cmd/models.ts +77 -0
  66. package/src/cli/cmd/plug.ts +231 -0
  67. package/src/cli/cmd/pr.ts +104 -0
  68. package/src/cli/cmd/rag-model.ts +167 -0
  69. package/src/cli/cmd/remote.ts +416 -0
  70. package/src/cli/cmd/run.ts +589 -0
  71. package/src/cli/cmd/serve.ts +51 -0
  72. package/src/cli/cmd/session.ts +133 -0
  73. package/src/cli/cmd/speak-model.ts +204 -0
  74. package/src/cli/cmd/stats.ts +402 -0
  75. package/src/cli/cmd/tui/app.tsx +841 -0
  76. package/src/cli/cmd/tui/attach.ts +31 -0
  77. package/src/cli/cmd/tui/component/border.tsx +75 -0
  78. package/src/cli/cmd/tui/component/dialog-agent.tsx +31 -0
  79. package/src/cli/cmd/tui/component/dialog-command.tsx +172 -0
  80. package/src/cli/cmd/tui/component/dialog-config.tsx +291 -0
  81. package/src/cli/cmd/tui/component/dialog-connectors.tsx +440 -0
  82. package/src/cli/cmd/tui/component/dialog-image-model.tsx +97 -0
  83. package/src/cli/cmd/tui/component/dialog-mcp.tsx +86 -0
  84. package/src/cli/cmd/tui/component/dialog-model.tsx +234 -0
  85. package/src/cli/cmd/tui/component/dialog-provider.tsx +260 -0
  86. package/src/cli/cmd/tui/component/dialog-rag-model.tsx +217 -0
  87. package/src/cli/cmd/tui/component/dialog-remote.tsx +489 -0
  88. package/src/cli/cmd/tui/component/dialog-session-list.tsx +170 -0
  89. package/src/cli/cmd/tui/component/dialog-session-rename.tsx +31 -0
  90. package/src/cli/cmd/tui/component/dialog-settings/index.tsx +59 -0
  91. package/src/cli/cmd/tui/component/dialog-settings/prompt.tsx +40 -0
  92. package/src/cli/cmd/tui/component/dialog-settings/sidebar.tsx +39 -0
  93. package/src/cli/cmd/tui/component/dialog-settings/spinner.tsx +62 -0
  94. package/src/cli/cmd/tui/component/dialog-settings/ui.tsx +58 -0
  95. package/src/cli/cmd/tui/component/dialog-skills.tsx +117 -0
  96. package/src/cli/cmd/tui/component/dialog-speak-model.tsx +304 -0
  97. package/src/cli/cmd/tui/component/dialog-stash.tsx +87 -0
  98. package/src/cli/cmd/tui/component/dialog-status.tsx +165 -0
  99. package/src/cli/cmd/tui/component/dialog-tag.tsx +44 -0
  100. package/src/cli/cmd/tui/component/dialog-theme-create.tsx +717 -0
  101. package/src/cli/cmd/tui/component/dialog-theme-list.tsx +52 -0
  102. package/src/cli/cmd/tui/component/dialog-workspace-list.tsx +350 -0
  103. package/src/cli/cmd/tui/component/error-component.tsx +91 -0
  104. package/src/cli/cmd/tui/component/logo.tsx +103 -0
  105. package/src/cli/cmd/tui/component/plugin-route-missing.tsx +14 -0
  106. package/src/cli/cmd/tui/component/prompt/autocomplete.tsx +669 -0
  107. package/src/cli/cmd/tui/component/prompt/frecency.tsx +89 -0
  108. package/src/cli/cmd/tui/component/prompt/history.tsx +108 -0
  109. package/src/cli/cmd/tui/component/prompt/index.tsx +2165 -0
  110. package/src/cli/cmd/tui/component/prompt/stash.tsx +63 -0
  111. package/src/cli/cmd/tui/component/spinner.tsx +24 -0
  112. package/src/cli/cmd/tui/component/startup-loading.tsx +63 -0
  113. package/src/cli/cmd/tui/component/table/markdown-table.tsx +267 -0
  114. package/src/cli/cmd/tui/component/table-db/db/connections.ts +75 -0
  115. package/src/cli/cmd/tui/component/table-db/db/db-connection.ts +223 -0
  116. package/src/cli/cmd/tui/component/table-db/db/db-preview.ts +202 -0
  117. package/src/cli/cmd/tui/component/table-db/db/factory.ts +77 -0
  118. package/src/cli/cmd/tui/component/table-db/db/index.ts +9 -0
  119. package/src/cli/cmd/tui/component/table-db/db/mysql-connection.ts +330 -0
  120. package/src/cli/cmd/tui/component/table-db/db/postgres-connection.ts +338 -0
  121. package/src/cli/cmd/tui/component/table-db/db/sqlite-connection.ts +302 -0
  122. package/src/cli/cmd/tui/component/table-db/db/types.ts +108 -0
  123. package/src/cli/cmd/tui/component/table-db/table/dbedit-hooks.ts +74 -0
  124. package/src/cli/cmd/tui/component/table-db/table/index.ts +15 -0
  125. package/src/cli/cmd/tui/component/table-db/table/table-events.ts +54 -0
  126. package/src/cli/cmd/tui/component/table-db/table/table-formatters.ts +191 -0
  127. package/src/cli/cmd/tui/component/table-db/table/table-hooks.ts +105 -0
  128. package/src/cli/cmd/tui/component/table-db/table/table-keyboard-handler.ts +255 -0
  129. package/src/cli/cmd/tui/component/table-db/table/table-layout-engine.ts +208 -0
  130. package/src/cli/cmd/tui/component/table-db/table/table-renderable.ts +486 -0
  131. package/src/cli/cmd/tui/component/table-db/table/table-selection-manager.ts +136 -0
  132. package/src/cli/cmd/tui/component/table-db/table/table-state.ts +198 -0
  133. package/src/cli/cmd/tui/component/table-db/table/types.ts +69 -0
  134. package/src/cli/cmd/tui/component/table-db/ui/db-visualizer.tsx +71 -0
  135. package/src/cli/cmd/tui/component/table-db/ui/index.ts +2 -0
  136. package/src/cli/cmd/tui/component/table-db/ui/table-renderer.ts +607 -0
  137. package/src/cli/cmd/tui/component/textarea-keybindings.ts +73 -0
  138. package/src/cli/cmd/tui/component/tips.tsx +195 -0
  139. package/src/cli/cmd/tui/component/todo-item.tsx +32 -0
  140. package/src/cli/cmd/tui/context/args.tsx +14 -0
  141. package/src/cli/cmd/tui/context/directory.ts +13 -0
  142. package/src/cli/cmd/tui/context/exit.tsx +24 -0
  143. package/src/cli/cmd/tui/context/helper.tsx +25 -0
  144. package/src/cli/cmd/tui/context/keybind.tsx +102 -0
  145. package/src/cli/cmd/tui/context/kv.tsx +52 -0
  146. package/src/cli/cmd/tui/context/local.tsx +458 -0
  147. package/src/cli/cmd/tui/context/plugin-keybinds.ts +41 -0
  148. package/src/cli/cmd/tui/context/prompt.tsx +18 -0
  149. package/src/cli/cmd/tui/context/route.tsx +54 -0
  150. package/src/cli/cmd/tui/context/sdk.tsx +128 -0
  151. package/src/cli/cmd/tui/context/server.tsx +8 -0
  152. package/src/cli/cmd/tui/context/sync.tsx +510 -0
  153. package/src/cli/cmd/tui/context/theme/abyss.json +233 -0
  154. package/src/cli/cmd/tui/context/theme/apple.json +235 -0
  155. package/src/cli/cmd/tui/context/theme/arctic.json +232 -0
  156. package/src/cli/cmd/tui/context/theme/aura.json +69 -0
  157. package/src/cli/cmd/tui/context/theme/ayu.json +80 -0
  158. package/src/cli/cmd/tui/context/theme/ayuai.json +229 -0
  159. package/src/cli/cmd/tui/context/theme/blood.json +229 -0
  160. package/src/cli/cmd/tui/context/theme/carbonfox.json +248 -0
  161. package/src/cli/cmd/tui/context/theme/catmoe.json +235 -0
  162. package/src/cli/cmd/tui/context/theme/catppuccin-frappe.json +233 -0
  163. package/src/cli/cmd/tui/context/theme/catppuccin-latte.json +233 -0
  164. package/src/cli/cmd/tui/context/theme/catppuccin-macchiato.json +233 -0
  165. package/src/cli/cmd/tui/context/theme/catppuccin.json +259 -0
  166. package/src/cli/cmd/tui/context/theme/charcoal.json +230 -0
  167. package/src/cli/cmd/tui/context/theme/chromatic.json +235 -0
  168. package/src/cli/cmd/tui/context/theme/cobalt2.json +228 -0
  169. package/src/cli/cmd/tui/context/theme/cosmic.json +234 -0
  170. package/src/cli/cmd/tui/context/theme/cursor.json +249 -0
  171. package/src/cli/cmd/tui/context/theme/cyber.json +235 -0
  172. package/src/cli/cmd/tui/context/theme/dawnfox.json +229 -0
  173. package/src/cli/cmd/tui/context/theme/dimension.json +235 -0
  174. package/src/cli/cmd/tui/context/theme/dracula-official.json +222 -0
  175. package/src/cli/cmd/tui/context/theme/dracula.json +219 -0
  176. package/src/cli/cmd/tui/context/theme/dream.json +235 -0
  177. package/src/cli/cmd/tui/context/theme/duo.json +235 -0
  178. package/src/cli/cmd/tui/context/theme/dusk.json +235 -0
  179. package/src/cli/cmd/tui/context/theme/ebony.json +232 -0
  180. package/src/cli/cmd/tui/context/theme/equilibrium.json +232 -0
  181. package/src/cli/cmd/tui/context/theme/ethereal.json +235 -0
  182. package/src/cli/cmd/tui/context/theme/everforest.json +241 -0
  183. package/src/cli/cmd/tui/context/theme/flexoki.json +237 -0
  184. package/src/cli/cmd/tui/context/theme/fusion.json +235 -0
  185. package/src/cli/cmd/tui/context/theme/ghost.json +235 -0
  186. package/src/cli/cmd/tui/context/theme/github-dark.json +229 -0
  187. package/src/cli/cmd/tui/context/theme/github-dimmed.json +231 -0
  188. package/src/cli/cmd/tui/context/theme/github-light.json +229 -0
  189. package/src/cli/cmd/tui/context/theme/github.json +233 -0
  190. package/src/cli/cmd/tui/context/theme/glass.json +235 -0
  191. package/src/cli/cmd/tui/context/theme/gold.json +235 -0
  192. package/src/cli/cmd/tui/context/theme/gone.json +234 -0
  193. package/src/cli/cmd/tui/context/theme/greyscale.json +229 -0
  194. package/src/cli/cmd/tui/context/theme/gruvbox.json +242 -0
  195. package/src/cli/cmd/tui/context/theme/hacker.json +229 -0
  196. package/src/cli/cmd/tui/context/theme/holo.json +235 -0
  197. package/src/cli/cmd/tui/context/theme/ink.json +235 -0
  198. package/src/cli/cmd/tui/context/theme/jet.json +233 -0
  199. package/src/cli/cmd/tui/context/theme/kanagawa.json +227 -0
  200. package/src/cli/cmd/tui/context/theme/lavender.json +236 -0
  201. package/src/cli/cmd/tui/context/theme/lightph.json +235 -0
  202. package/src/cli/cmd/tui/context/theme/lucent-orng.json +237 -0
  203. package/src/cli/cmd/tui/context/theme/material-ocean.json +230 -0
  204. package/src/cli/cmd/tui/context/theme/material.json +235 -0
  205. package/src/cli/cmd/tui/context/theme/matrix.json +227 -0
  206. package/src/cli/cmd/tui/context/theme/mercury.json +245 -0
  207. package/src/cli/cmd/tui/context/theme/midnight.json +235 -0
  208. package/src/cli/cmd/tui/context/theme/modern.json +235 -0
  209. package/src/cli/cmd/tui/context/theme/monokai.json +221 -0
  210. package/src/cli/cmd/tui/context/theme/muted.json +229 -0
  211. package/src/cli/cmd/tui/context/theme/neon.json +229 -0
  212. package/src/cli/cmd/tui/context/theme/neonfusion.json +235 -0
  213. package/src/cli/cmd/tui/context/theme/neutral.json +235 -0
  214. package/src/cli/cmd/tui/context/theme/nightowl.json +221 -0
  215. package/src/cli/cmd/tui/context/theme/nikcli.json +245 -0
  216. package/src/cli/cmd/tui/context/theme/nord.json +223 -0
  217. package/src/cli/cmd/tui/context/theme/nordic.json +235 -0
  218. package/src/cli/cmd/tui/context/theme/nova.json +235 -0
  219. package/src/cli/cmd/tui/context/theme/obsidian.json +234 -0
  220. package/src/cli/cmd/tui/context/theme/one-dark.json +231 -0
  221. package/src/cli/cmd/tui/context/theme/one-pro.json +229 -0
  222. package/src/cli/cmd/tui/context/theme/onyx.json +233 -0
  223. package/src/cli/cmd/tui/context/theme/orng.json +249 -0
  224. package/src/cli/cmd/tui/context/theme/osaka-jade.json +240 -0
  225. package/src/cli/cmd/tui/context/theme/oxocarbon.json +229 -0
  226. package/src/cli/cmd/tui/context/theme/palenight.json +222 -0
  227. package/src/cli/cmd/tui/context/theme/poimandres.json +230 -0
  228. package/src/cli/cmd/tui/context/theme/prism.json +235 -0
  229. package/src/cli/cmd/tui/context/theme/radiant.json +235 -0
  230. package/src/cli/cmd/tui/context/theme/rosepine.json +234 -0
  231. package/src/cli/cmd/tui/context/theme/shadow.json +235 -0
  232. package/src/cli/cmd/tui/context/theme/silicon.json +235 -0
  233. package/src/cli/cmd/tui/context/theme/slate.json +233 -0
  234. package/src/cli/cmd/tui/context/theme/soft.json +235 -0
  235. package/src/cli/cmd/tui/context/theme/solarized.json +223 -0
  236. package/src/cli/cmd/tui/context/theme/spectrum.json +235 -0
  237. package/src/cli/cmd/tui/context/theme/starlight.json +233 -0
  238. package/src/cli/cmd/tui/context/theme/sunrise.json +235 -0
  239. package/src/cli/cmd/tui/context/theme/synthwave84.json +226 -0
  240. package/src/cli/cmd/tui/context/theme/tech.json +235 -0
  241. package/src/cli/cmd/tui/context/theme/tokyonight-storm.json +245 -0
  242. package/src/cli/cmd/tui/context/theme/tokyonight.json +243 -0
  243. package/src/cli/cmd/tui/context/theme/vapor.json +235 -0
  244. package/src/cli/cmd/tui/context/theme/vercel.json +245 -0
  245. package/src/cli/cmd/tui/context/theme/vesper.json +218 -0
  246. package/src/cli/cmd/tui/context/theme/vivid.json +232 -0
  247. package/src/cli/cmd/tui/context/theme/void.json +235 -0
  248. package/src/cli/cmd/tui/context/theme/vscode.json +235 -0
  249. package/src/cli/cmd/tui/context/theme/zenburn.json +223 -0
  250. package/src/cli/cmd/tui/context/theme/zinc.json +236 -0
  251. package/src/cli/cmd/tui/context/theme.tsx +1303 -0
  252. package/src/cli/cmd/tui/event.ts +48 -0
  253. package/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx +152 -0
  254. package/src/cli/cmd/tui/feature-plugins/home/tips.tsx +50 -0
  255. package/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx +63 -0
  256. package/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx +62 -0
  257. package/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx +93 -0
  258. package/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx +66 -0
  259. package/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx +96 -0
  260. package/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx +48 -0
  261. package/src/cli/cmd/tui/feature-plugins/system/plugins.tsx +288 -0
  262. package/src/cli/cmd/tui/plugin/api.tsx +407 -0
  263. package/src/cli/cmd/tui/plugin/index.ts +3 -0
  264. package/src/cli/cmd/tui/plugin/internal.ts +25 -0
  265. package/src/cli/cmd/tui/plugin/runtime.ts +1048 -0
  266. package/src/cli/cmd/tui/plugin/slots.tsx +61 -0
  267. package/src/cli/cmd/tui/routes/home.tsx +153 -0
  268. package/src/cli/cmd/tui/routes/session/dbedit.tsx +474 -0
  269. package/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx +65 -0
  270. package/src/cli/cmd/tui/routes/session/dialog-message.tsx +110 -0
  271. package/src/cli/cmd/tui/routes/session/dialog-subagent.tsx +105 -0
  272. package/src/cli/cmd/tui/routes/session/dialog-timeline.tsx +47 -0
  273. package/src/cli/cmd/tui/routes/session/footer.tsx +75 -0
  274. package/src/cli/cmd/tui/routes/session/header.tsx +177 -0
  275. package/src/cli/cmd/tui/routes/session/index.tsx +2280 -0
  276. package/src/cli/cmd/tui/routes/session/permission.tsx +540 -0
  277. package/src/cli/cmd/tui/routes/session/question.tsx +435 -0
  278. package/src/cli/cmd/tui/routes/session/sidebar.tsx +313 -0
  279. package/src/cli/cmd/tui/thread.ts +174 -0
  280. package/src/cli/cmd/tui/ui/dialog-alert.tsx +57 -0
  281. package/src/cli/cmd/tui/ui/dialog-confirm.tsx +83 -0
  282. package/src/cli/cmd/tui/ui/dialog-export-options.tsx +204 -0
  283. package/src/cli/cmd/tui/ui/dialog-help.tsx +38 -0
  284. package/src/cli/cmd/tui/ui/dialog-prompt.tsx +102 -0
  285. package/src/cli/cmd/tui/ui/dialog-select.tsx +389 -0
  286. package/src/cli/cmd/tui/ui/dialog.tsx +180 -0
  287. package/src/cli/cmd/tui/ui/link.tsx +34 -0
  288. package/src/cli/cmd/tui/ui/spinner.ts +368 -0
  289. package/src/cli/cmd/tui/ui/toast.tsx +138 -0
  290. package/src/cli/cmd/tui/util/clipboard.ts +154 -0
  291. package/src/cli/cmd/tui/util/editor.ts +32 -0
  292. package/src/cli/cmd/tui/util/signal.ts +7 -0
  293. package/src/cli/cmd/tui/util/terminal.ts +114 -0
  294. package/src/cli/cmd/tui/util/transcript.ts +98 -0
  295. package/src/cli/cmd/tui/win32.ts +110 -0
  296. package/src/cli/cmd/tui/worker.ts +156 -0
  297. package/src/cli/cmd/uninstall.ts +357 -0
  298. package/src/cli/cmd/upgrade.ts +72 -0
  299. package/src/cli/cmd/web.ts +87 -0
  300. package/src/cli/cmd/workspace-serve.ts +16 -0
  301. package/src/cli/error.ts +57 -0
  302. package/src/cli/network.ts +55 -0
  303. package/src/cli/remote/index.ts +36 -0
  304. package/src/cli/remote/notifications.ts +104 -0
  305. package/src/cli/remote/qr-renderer.ts +86 -0
  306. package/src/cli/remote/remote-service.ts +757 -0
  307. package/src/cli/remote/session-manager.ts +284 -0
  308. package/src/cli/remote/subagent-hooks.ts +151 -0
  309. package/src/cli/remote/types.ts +121 -0
  310. package/src/cli/ui.ts +96 -0
  311. package/src/cli/upgrade.ts +25 -0
  312. package/src/command/index.ts +174 -0
  313. package/src/command/template/initialize.txt +10 -0
  314. package/src/command/template/review.txt +99 -0
  315. package/src/config/config.ts +1760 -0
  316. package/src/config/markdown.ts +88 -0
  317. package/src/config/migrate-tui-config.ts +155 -0
  318. package/src/config/paths.ts +174 -0
  319. package/src/config/tui-schema.ts +36 -0
  320. package/src/config/tui.ts +209 -0
  321. package/src/connectors/api/base.ts +75 -0
  322. package/src/connectors/api/figma.ts +103 -0
  323. package/src/connectors/api/github.ts +247 -0
  324. package/src/connectors/api/lovable.ts +126 -0
  325. package/src/connectors/api/slack.ts +137 -0
  326. package/src/connectors/auth.ts +68 -0
  327. package/src/connectors/cache.ts +119 -0
  328. package/src/connectors/credentials.ts +81 -0
  329. package/src/connectors/index.ts +202 -0
  330. package/src/connectors/registry.ts +358 -0
  331. package/src/docs/context.ts +120 -0
  332. package/src/docs/library.ts +189 -0
  333. package/src/env/index.ts +26 -0
  334. package/src/file/ignore.ts +83 -0
  335. package/src/file/index.ts +411 -0
  336. package/src/file/ripgrep.ts +402 -0
  337. package/src/file/time.ts +65 -0
  338. package/src/file/watcher.ts +127 -0
  339. package/src/flag/flag.ts +128 -0
  340. package/src/format/formatter.ts +356 -0
  341. package/src/format/index.ts +137 -0
  342. package/src/global/index.ts +57 -0
  343. package/src/id/id.ts +83 -0
  344. package/src/ide/index.ts +76 -0
  345. package/src/index.ts +184 -0
  346. package/src/installation/index.ts +246 -0
  347. package/src/lsp/client.ts +250 -0
  348. package/src/lsp/index.ts +483 -0
  349. package/src/lsp/language.ts +119 -0
  350. package/src/lsp/server.ts +2046 -0
  351. package/src/mcp/auth.ts +121 -0
  352. package/src/mcp/index.ts +860 -0
  353. package/src/mcp/oauth-callback.ts +198 -0
  354. package/src/mcp/oauth-provider.ts +148 -0
  355. package/src/mobile/auth.ts +97 -0
  356. package/src/mobile/github-repo.ts +185 -0
  357. package/src/patch/index.ts +631 -0
  358. package/src/permission/arity.ts +150 -0
  359. package/src/permission/dbedit.ts +236 -0
  360. package/src/permission/index.ts +210 -0
  361. package/src/permission/next.ts +287 -0
  362. package/src/plugin/codex.ts +493 -0
  363. package/src/plugin/copilot.ts +261 -0
  364. package/src/plugin/index.ts +714 -0
  365. package/src/plugin/install.ts +379 -0
  366. package/src/plugin/meta.ts +165 -0
  367. package/src/plugin/shared.ts +188 -0
  368. package/src/project/bootstrap.ts +35 -0
  369. package/src/project/instance.ts +84 -0
  370. package/src/project/project.ts +373 -0
  371. package/src/project/state.ts +66 -0
  372. package/src/project/vcs.ts +76 -0
  373. package/src/prompt/stash-store.ts +93 -0
  374. package/src/provider/auth.ts +147 -0
  375. package/src/provider/models-macro.ts +22 -0
  376. package/src/provider/models.ts +216 -0
  377. package/src/provider/provider.ts +1483 -0
  378. package/src/provider/sdk/openai-compatible/src/README.md +5 -0
  379. package/src/provider/sdk/openai-compatible/src/index.ts +2 -0
  380. package/src/provider/sdk/openai-compatible/src/openai-compatible-provider.ts +100 -0
  381. package/src/provider/sdk/openai-compatible/src/responses/convert-to-openai-responses-input.ts +303 -0
  382. package/src/provider/sdk/openai-compatible/src/responses/map-openai-responses-finish-reason.ts +22 -0
  383. package/src/provider/sdk/openai-compatible/src/responses/openai-config.ts +18 -0
  384. package/src/provider/sdk/openai-compatible/src/responses/openai-error.ts +22 -0
  385. package/src/provider/sdk/openai-compatible/src/responses/openai-responses-api-types.ts +207 -0
  386. package/src/provider/sdk/openai-compatible/src/responses/openai-responses-language-model.ts +1732 -0
  387. package/src/provider/sdk/openai-compatible/src/responses/openai-responses-prepare-tools.ts +177 -0
  388. package/src/provider/sdk/openai-compatible/src/responses/openai-responses-settings.ts +1 -0
  389. package/src/provider/sdk/openai-compatible/src/responses/tool/code-interpreter.ts +88 -0
  390. package/src/provider/sdk/openai-compatible/src/responses/tool/file-search.ts +128 -0
  391. package/src/provider/sdk/openai-compatible/src/responses/tool/image-generation.ts +115 -0
  392. package/src/provider/sdk/openai-compatible/src/responses/tool/local-shell.ts +65 -0
  393. package/src/provider/sdk/openai-compatible/src/responses/tool/web-search-preview.ts +104 -0
  394. package/src/provider/sdk/openai-compatible/src/responses/tool/web-search.ts +103 -0
  395. package/src/provider/transform.ts +828 -0
  396. package/src/pty/index.ts +241 -0
  397. package/src/question/index.ts +171 -0
  398. package/src/rag/chunk.ts +43 -0
  399. package/src/rag/embed.ts +179 -0
  400. package/src/rag/index.ts +376 -0
  401. package/src/rag/storage.ts +76 -0
  402. package/src/scheduler/index.ts +61 -0
  403. package/src/server/error.ts +36 -0
  404. package/src/server/event.ts +7 -0
  405. package/src/server/mdns.ts +59 -0
  406. package/src/server/routes/chatbot.ts +205 -0
  407. package/src/server/routes/companion.ts +729 -0
  408. package/src/server/routes/config.ts +92 -0
  409. package/src/server/routes/connectors.ts +121 -0
  410. package/src/server/routes/dbedit.ts +76 -0
  411. package/src/server/routes/experimental.ts +210 -0
  412. package/src/server/routes/file.ts +197 -0
  413. package/src/server/routes/global.ts +135 -0
  414. package/src/server/routes/mcp.ts +225 -0
  415. package/src/server/routes/mobile.ts +2044 -0
  416. package/src/server/routes/permission.ts +68 -0
  417. package/src/server/routes/project.ts +82 -0
  418. package/src/server/routes/provider.ts +235 -0
  419. package/src/server/routes/pty.ts +169 -0
  420. package/src/server/routes/question.ts +98 -0
  421. package/src/server/routes/session.ts +968 -0
  422. package/src/server/routes/tui.ts +379 -0
  423. package/src/server/routes/workspace.ts +104 -0
  424. package/src/server/server.ts +761 -0
  425. package/src/server/ssh.ts +207 -0
  426. package/src/session/auth.ts +402 -0
  427. package/src/session/compaction.ts +253 -0
  428. package/src/session/generate.ts +38 -0
  429. package/src/session/index.ts +598 -0
  430. package/src/session/llm.ts +273 -0
  431. package/src/session/message-v2.ts +836 -0
  432. package/src/session/message.ts +189 -0
  433. package/src/session/processor.ts +408 -0
  434. package/src/session/prompt/anthropic-20250930.txt +165 -0
  435. package/src/session/prompt/anthropic.txt +105 -0
  436. package/src/session/prompt/anthropic_spoof.txt +1 -0
  437. package/src/session/prompt/beast.txt +147 -0
  438. package/src/session/prompt/build-switch.txt +5 -0
  439. package/src/session/prompt/codex_header.txt +79 -0
  440. package/src/session/prompt/copilot-gpt-5.txt +143 -0
  441. package/src/session/prompt/gemini.txt +155 -0
  442. package/src/session/prompt/max-steps.txt +16 -0
  443. package/src/session/prompt/plan-reminder-anthropic.txt +67 -0
  444. package/src/session/prompt/plan.txt +25 -0
  445. package/src/session/prompt/qwen.txt +108 -0
  446. package/src/session/prompt.ts +1942 -0
  447. package/src/session/retry.ts +90 -0
  448. package/src/session/revert.ts +120 -0
  449. package/src/session/stats.ts +404 -0
  450. package/src/session/status.ts +84 -0
  451. package/src/session/summary.ts +184 -0
  452. package/src/session/system.ts +195 -0
  453. package/src/session/toast.tsx +105 -0
  454. package/src/session/todo.ts +258 -0
  455. package/src/session/uninstall.ts +357 -0
  456. package/src/share/share-next.ts +421 -0
  457. package/src/share/share.ts +92 -0
  458. package/src/shell/shell.ts +65 -0
  459. package/src/skill/index.ts +1 -0
  460. package/src/skill/skill.ts +232 -0
  461. package/src/snapshot/index.ts +297 -0
  462. package/src/storage/storage.ts +227 -0
  463. package/src/tool/apply_patch.ts +288 -0
  464. package/src/tool/apply_patch.txt +33 -0
  465. package/src/tool/bash.ts +252 -0
  466. package/src/tool/bash.txt +115 -0
  467. package/src/tool/batch.ts +175 -0
  468. package/src/tool/batch.txt +24 -0
  469. package/src/tool/codesearch.ts +132 -0
  470. package/src/tool/codesearch.txt +12 -0
  471. package/src/tool/context_collect.ts +152 -0
  472. package/src/tool/context_collect.txt +9 -0
  473. package/src/tool/context_diagnostics.ts +81 -0
  474. package/src/tool/context_diagnostics.txt +5 -0
  475. package/src/tool/context_related.ts +117 -0
  476. package/src/tool/context_related.txt +5 -0
  477. package/src/tool/context_search.ts +108 -0
  478. package/src/tool/context_search.txt +8 -0
  479. package/src/tool/db-diff.ts +434 -0
  480. package/src/tool/db-table.txt +15 -0
  481. package/src/tool/docs_add.ts +50 -0
  482. package/src/tool/docs_add.txt +5 -0
  483. package/src/tool/docs_context.ts +56 -0
  484. package/src/tool/docs_context.txt +4 -0
  485. package/src/tool/docs_gap_report.ts +79 -0
  486. package/src/tool/docs_gap_report.txt +7 -0
  487. package/src/tool/docs_load.ts +41 -0
  488. package/src/tool/docs_load.txt +4 -0
  489. package/src/tool/docs_request.ts +129 -0
  490. package/src/tool/docs_request.txt +7 -0
  491. package/src/tool/docs_search.ts +51 -0
  492. package/src/tool/docs_search.txt +6 -0
  493. package/src/tool/docs_unload.ts +38 -0
  494. package/src/tool/docs_unload.txt +5 -0
  495. package/src/tool/edit.ts +614 -0
  496. package/src/tool/edit.txt +10 -0
  497. package/src/tool/external-directory.ts +32 -0
  498. package/src/tool/generate_image.ts +174 -0
  499. package/src/tool/generate_image.txt +12 -0
  500. package/src/tool/glob.ts +79 -0
  501. package/src/tool/glob.txt +6 -0
  502. package/src/tool/grep.ts +153 -0
  503. package/src/tool/grep.txt +8 -0
  504. package/src/tool/invalid.ts +17 -0
  505. package/src/tool/ls.ts +116 -0
  506. package/src/tool/ls.txt +1 -0
  507. package/src/tool/lsp.ts +96 -0
  508. package/src/tool/lsp.txt +19 -0
  509. package/src/tool/memory_search.ts +141 -0
  510. package/src/tool/memory_search.txt +8 -0
  511. package/src/tool/multiedit.ts +46 -0
  512. package/src/tool/multiedit.txt +41 -0
  513. package/src/tool/plan-enter.txt +14 -0
  514. package/src/tool/plan-exit.txt +13 -0
  515. package/src/tool/plan.ts +130 -0
  516. package/src/tool/question.ts +33 -0
  517. package/src/tool/question.txt +10 -0
  518. package/src/tool/rag_index.ts +77 -0
  519. package/src/tool/rag_index.txt +10 -0
  520. package/src/tool/rag_reset.ts +26 -0
  521. package/src/tool/rag_reset.txt +4 -0
  522. package/src/tool/rag_search.ts +62 -0
  523. package/src/tool/rag_search.txt +6 -0
  524. package/src/tool/rag_status.ts +45 -0
  525. package/src/tool/rag_status.txt +4 -0
  526. package/src/tool/read.ts +203 -0
  527. package/src/tool/read.txt +12 -0
  528. package/src/tool/registry.ts +214 -0
  529. package/src/tool/skill.ts +169 -0
  530. package/src/tool/skill.txt +3 -0
  531. package/src/tool/smart_docs.ts +74 -0
  532. package/src/tool/smart_docs.txt +7 -0
  533. package/src/tool/speak/elevenlabs.ts +201 -0
  534. package/src/tool/speak/openrouter.ts +240 -0
  535. package/src/tool/speak/provider.ts +83 -0
  536. package/src/tool/speak.ts +440 -0
  537. package/src/tool/task.ts +194 -0
  538. package/src/tool/task.txt +60 -0
  539. package/src/tool/todo.ts +53 -0
  540. package/src/tool/todoread.txt +14 -0
  541. package/src/tool/todowrite.txt +167 -0
  542. package/src/tool/tool.ts +87 -0
  543. package/src/tool/tree.ts +218 -0
  544. package/src/tool/tree.txt +8 -0
  545. package/src/tool/truncation.ts +106 -0
  546. package/src/tool/use-connector.ts +47 -0
  547. package/src/tool/voice.ts +188 -0
  548. package/src/tool/webfetch.ts +205 -0
  549. package/src/tool/webfetch.txt +13 -0
  550. package/src/tool/websearch.ts +150 -0
  551. package/src/tool/websearch.txt +14 -0
  552. package/src/tool/write.ts +80 -0
  553. package/src/tool/write.txt +8 -0
  554. package/src/util/archive.ts +16 -0
  555. package/src/util/color.ts +19 -0
  556. package/src/util/context.ts +25 -0
  557. package/src/util/defer.ts +12 -0
  558. package/src/util/error.ts +77 -0
  559. package/src/util/eventloop.ts +20 -0
  560. package/src/util/filesystem.ts +125 -0
  561. package/src/util/flock.ts +329 -0
  562. package/src/util/fn.ts +11 -0
  563. package/src/util/format.ts +20 -0
  564. package/src/util/hash.ts +7 -0
  565. package/src/util/iife.ts +3 -0
  566. package/src/util/keybind.ts +103 -0
  567. package/src/util/lazy.ts +18 -0
  568. package/src/util/locale.ts +81 -0
  569. package/src/util/lock.ts +98 -0
  570. package/src/util/log.ts +180 -0
  571. package/src/util/network.ts +9 -0
  572. package/src/util/process.ts +15 -0
  573. package/src/util/queue.ts +32 -0
  574. package/src/util/record.ts +3 -0
  575. package/src/util/rpc.ts +66 -0
  576. package/src/util/scrap.ts +10 -0
  577. package/src/util/signal.ts +12 -0
  578. package/src/util/timeout.ts +14 -0
  579. package/src/util/token.ts +7 -0
  580. package/src/util/wildcard.ts +56 -0
  581. package/src/workspace/adaptors/index.ts +271 -0
  582. package/src/workspace/adaptors/types.ts +14 -0
  583. package/src/workspace/adaptors/worktree.ts +31 -0
  584. package/src/workspace/config.ts +19 -0
  585. package/src/workspace/index.ts +223 -0
  586. package/src/workspace/session-proxy-middleware.ts +97 -0
  587. package/src/workspace/sse.ts +66 -0
  588. package/src/workspace/workspace-context.ts +23 -0
  589. package/src/workspace/workspace-server/routes.ts +33 -0
  590. package/src/workspace/workspace-server/server.ts +47 -0
  591. package/src/worktree/index.ts +487 -0
  592. package/sst-env.d.ts +10 -0
  593. package/test/benchmark.test.ts +121 -0
  594. package/test/build-optimizations.test.ts +124 -0
  595. package/test/id-benchmark.test.ts +132 -0
  596. package/test/optimizations.test.ts +302 -0
  597. package/test/preload.ts +1 -0
  598. package/test/solidjs-benchmark.test.ts +262 -0
  599. package/test/solidjs-optimizations.test.ts +259 -0
  600. package/test/tui-benchmark.test.ts +230 -0
  601. package/test/wildcard-benchmark.test.ts +180 -0
  602. package/tsconfig.json +26 -0
@@ -0,0 +1,2165 @@
1
+ import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent } from "@opentui/core"
2
+ import {
3
+ createEffect,
4
+ createMemo,
5
+ type JSX,
6
+ onMount,
7
+ createSignal,
8
+ onCleanup,
9
+ on,
10
+ Show,
11
+ Switch,
12
+ Match,
13
+ For,
14
+ } from "solid-js"
15
+ import "opentui-spinner/solid"
16
+ import { useLocal } from "@tui/context/local"
17
+ import { useTheme } from "@tui/context/theme"
18
+ import { EmptyBorder } from "@tui/component/border"
19
+ import { useSDK } from "@tui/context/sdk"
20
+ import { useRoute } from "@tui/context/route"
21
+ import { useSync } from "@tui/context/sync"
22
+ import { Identifier } from "@/id/id"
23
+ import { createStore, produce } from "solid-js/store"
24
+ import { useKeybind } from "@tui/context/keybind"
25
+ import { usePromptHistory, type PromptInfo } from "./history"
26
+ import { usePromptStash } from "./stash"
27
+ import { DialogStash } from "../dialog-stash"
28
+ import { type AutocompleteRef, Autocomplete } from "./autocomplete"
29
+ import { useCommandDialog } from "../dialog-command"
30
+ import { useRenderer } from "@opentui/solid"
31
+ import { Editor } from "@tui/util/editor"
32
+ import { useExit } from "../../context/exit"
33
+ import { Clipboard } from "../../util/clipboard"
34
+ import type { FilePart } from "@nikcli-ai/sdk/v2"
35
+ import { TuiEvent } from "../../event"
36
+ import { iife } from "@/util/iife"
37
+ import { Locale } from "@/util/locale"
38
+ import { formatDuration } from "@/util/format"
39
+ import { createColors, createFrames } from "../../ui/spinner.ts"
40
+ import type { SpinnerStyle } from "../dialog-settings/spinner"
41
+ import { useDialog } from "@tui/ui/dialog"
42
+ import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
43
+ import { DialogAlert } from "../../ui/dialog-alert"
44
+ import { useToast } from "../../ui/toast"
45
+ import { useKV } from "../../context/kv"
46
+ import { useTextareaKeybindings } from "../textarea-keybindings"
47
+ import { DialogThemeCreate } from "../dialog-theme-create"
48
+ import { DialogRagModel } from "../dialog-rag-model"
49
+ import { DialogImageModel } from "../dialog-image-model"
50
+ import { DialogSpeakModel } from "../dialog-speak-model"
51
+ import { DialogRemote } from "../dialog-remote"
52
+ import { DialogSubagent } from "@tui/routes/session/dialog-subagent"
53
+ import os from "os"
54
+ import path from "path"
55
+ import { rmSync } from "fs"
56
+ import { Auth } from "@/auth"
57
+
58
+ export type PromptProps = {
59
+ sessionID?: string
60
+ workspaceID?: string
61
+ visible?: boolean
62
+ disabled?: boolean
63
+ onSubmit?: () => void
64
+ ref?: (ref: PromptRef) => void
65
+ hint?: JSX.Element
66
+ showPlaceholder?: boolean
67
+ }
68
+
69
+ export type PromptRef = {
70
+ focused: boolean
71
+ current: PromptInfo
72
+ set(prompt: PromptInfo): void
73
+ reset(): void
74
+ blur(): void
75
+ focus(): void
76
+ submit(): void
77
+ }
78
+
79
+ const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"]
80
+ const SHELL_PLACEHOLDERS = ["ls -la", "git status", "pwd"]
81
+ const VOICE_TRANSCRIBE_MODEL = "openai/gpt-audio-mini"
82
+ const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
83
+ const SWIFT_MIC_PERMISSION_ERROR = "__NIKCLI_MIC_PERMISSION_DENIED__"
84
+
85
+ const SWIFT_RECORDER_SOURCE = String.raw`
86
+ import Foundation
87
+ import AVFoundation
88
+
89
+ let permissionMarker = "__NIKCLI_MIC_PERMISSION_DENIED__"
90
+
91
+ guard CommandLine.arguments.count >= 2 else {
92
+ fputs("missing output path\n", stderr)
93
+ exit(2)
94
+ }
95
+
96
+ let outputPath = CommandLine.arguments[1]
97
+ let outputURL = URL(fileURLWithPath: outputPath)
98
+
99
+ let semaphore = DispatchSemaphore(value: 0)
100
+ var granted = false
101
+
102
+ AVCaptureDevice.requestAccess(for: .audio) { allowed in
103
+ granted = allowed
104
+ semaphore.signal()
105
+ }
106
+
107
+ _ = semaphore.wait(timeout: .now() + 30)
108
+
109
+ if !granted {
110
+ fputs(permissionMarker + "\n", stderr)
111
+ exit(13)
112
+ }
113
+
114
+ let settings: [String: Any] = [
115
+ AVFormatIDKey: Int(kAudioFormatLinearPCM),
116
+ AVSampleRateKey: 16000,
117
+ AVNumberOfChannelsKey: 1,
118
+ AVLinearPCMBitDepthKey: 16,
119
+ AVLinearPCMIsBigEndianKey: false,
120
+ AVLinearPCMIsFloatKey: false,
121
+ ]
122
+
123
+ do {
124
+ let recorder = try AVAudioRecorder(url: outputURL, settings: settings)
125
+ recorder.prepareToRecord()
126
+
127
+ if !recorder.record() {
128
+ fputs("failed to start recording\n", stderr)
129
+ exit(14)
130
+ }
131
+
132
+ signal(SIGINT, SIG_IGN)
133
+ signal(SIGTERM, SIG_IGN)
134
+
135
+ let stop: () -> Void = {
136
+ recorder.stop()
137
+ exit(0)
138
+ }
139
+
140
+ let sigintSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
141
+ sigintSource.setEventHandler(handler: stop)
142
+ sigintSource.resume()
143
+
144
+ let sigtermSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
145
+ sigtermSource.setEventHandler(handler: stop)
146
+ sigtermSource.resume()
147
+
148
+ RunLoop.main.run()
149
+ } catch {
150
+ fputs("recorder error: \(error)\n", stderr)
151
+ exit(15)
152
+ }
153
+ `
154
+
155
+ export function Prompt(props: PromptProps) {
156
+ let input: TextareaRenderable
157
+ let anchor: BoxRenderable
158
+ let autocomplete: AutocompleteRef
159
+
160
+ const keybind = useKeybind()
161
+ const local = useLocal()
162
+ const sdk = useSDK()
163
+ const route = useRoute()
164
+ const sync = useSync()
165
+ const dialog = useDialog()
166
+ const toast = useToast()
167
+ const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
168
+ const history = usePromptHistory()
169
+ const stash = usePromptStash()
170
+ const command = useCommandDialog()
171
+ const renderer = useRenderer()
172
+ const { theme, syntax } = useTheme()
173
+ const kv = useKV()
174
+ const ads = createMemo(() => sync.data.config.ads)
175
+ const [currentAd, setCurrentAd] = createSignal<string | null>(null)
176
+ const [voiceStatus, setVoiceStatus] = createSignal<"idle" | "recording" | "transcribing">("idle")
177
+
178
+ let voiceRecorder: ReturnType<typeof Bun.spawn> | null = null
179
+ let voiceAudioPath: string | null = null
180
+ let voiceAutoStopTimer: ReturnType<typeof setTimeout> | undefined
181
+ let voicePressStartedAt = 0
182
+ let swiftRecorderScriptPath: string | null = null
183
+ let hasShownMicHint = false
184
+
185
+ function cleanupVoiceAudio(filePath: string | null) {
186
+ if (!filePath) return
187
+ try {
188
+ rmSync(filePath, { force: true })
189
+ } catch {
190
+ // ignore cleanup errors
191
+ }
192
+ }
193
+
194
+ async function ensureSwiftRecorderScriptPath(): Promise<string | null> {
195
+ if (process.platform !== "darwin") return null
196
+ if (swiftRecorderScriptPath) return swiftRecorderScriptPath
197
+
198
+ const swift = Bun.which("swift")
199
+ if (!swift) return null
200
+
201
+ const scriptPath = path.join(os.tmpdir(), "nikcli-mic-recorder.swift")
202
+ const file = Bun.file(scriptPath)
203
+ const exists = await file.exists()
204
+
205
+ if (!exists) {
206
+ await Bun.write(scriptPath, SWIFT_RECORDER_SOURCE)
207
+ } else {
208
+ const current = await file.text().catch(() => "")
209
+ if (current !== SWIFT_RECORDER_SOURCE) {
210
+ await Bun.write(scriptPath, SWIFT_RECORDER_SOURCE)
211
+ }
212
+ }
213
+
214
+ swiftRecorderScriptPath = scriptPath
215
+ return swiftRecorderScriptPath
216
+ }
217
+
218
+ async function detectVoiceRecorder(filePath: string): Promise<{ command: string[]; name: string } | null> {
219
+ const ffmpeg = Bun.which("ffmpeg")
220
+ if (ffmpeg) {
221
+ if (process.platform === "darwin") {
222
+ return {
223
+ name: "ffmpeg",
224
+ command: [
225
+ ffmpeg,
226
+ "-hide_banner",
227
+ "-loglevel",
228
+ "error",
229
+ "-f",
230
+ "avfoundation",
231
+ "-i",
232
+ "none:0",
233
+ "-ac",
234
+ "1",
235
+ "-ar",
236
+ "16000",
237
+ "-c:a",
238
+ "pcm_s16le",
239
+ "-y",
240
+ filePath,
241
+ ],
242
+ }
243
+ }
244
+
245
+ if (process.platform === "linux") {
246
+ return {
247
+ name: "ffmpeg",
248
+ command: [
249
+ ffmpeg,
250
+ "-hide_banner",
251
+ "-loglevel",
252
+ "error",
253
+ "-f",
254
+ "pulse",
255
+ "-i",
256
+ "default",
257
+ "-ac",
258
+ "1",
259
+ "-ar",
260
+ "16000",
261
+ "-c:a",
262
+ "pcm_s16le",
263
+ "-y",
264
+ filePath,
265
+ ],
266
+ }
267
+ }
268
+ }
269
+
270
+ const rec = Bun.which("rec")
271
+ if (rec) {
272
+ return {
273
+ name: "rec",
274
+ command: [rec, "-q", "-c", "1", "-r", "16000", filePath],
275
+ }
276
+ }
277
+
278
+ if (process.platform === "darwin") {
279
+ const swift = Bun.which("swift")
280
+ const scriptPath = await ensureSwiftRecorderScriptPath()
281
+ if (swift && scriptPath) {
282
+ return {
283
+ name: "swift-avfoundation",
284
+ command: [swift, scriptPath, filePath],
285
+ }
286
+ }
287
+ }
288
+
289
+ return null
290
+ }
291
+
292
+ function looksLikeMicPermissionError(message: string): boolean {
293
+ const value = message.toLowerCase()
294
+ return (
295
+ value.includes(SWIFT_MIC_PERMISSION_ERROR.toLowerCase()) ||
296
+ value.includes("permission denied") ||
297
+ value.includes("not permitted") ||
298
+ value.includes("not authorized") ||
299
+ value.includes("operation not permitted")
300
+ )
301
+ }
302
+
303
+ function currentTerminalName(): string {
304
+ return process.env.TERM_PROGRAM || process.env.TERMINAL_EMULATOR || process.env.TERM || "terminal"
305
+ }
306
+
307
+ function isLikelyIntegratedTerminal(): boolean {
308
+ const value = currentTerminalName().toLowerCase()
309
+ return value.includes("vscode") || value.includes("zed") || value.includes("warp") || value.includes("jetbrains")
310
+ }
311
+
312
+ function extractTranscriptContent(content: unknown): string {
313
+ if (typeof content === "string") return content.trim()
314
+ if (!Array.isArray(content)) return ""
315
+
316
+ const merged = content
317
+ .map((part) => {
318
+ if (typeof part === "string") return part
319
+ if (part && typeof part === "object" && "text" in part && typeof (part as any).text === "string") {
320
+ return (part as any).text
321
+ }
322
+ return ""
323
+ })
324
+ .join(" ")
325
+ .trim()
326
+
327
+ return merged
328
+ }
329
+
330
+ function openRouterEndpoint(baseURL: string, endpoint: string): string {
331
+ return `${baseURL.replace(/\/+$/, "")}${endpoint}`
332
+ }
333
+
334
+ function normalizeOpenRouterBaseURL(value: string | undefined): string {
335
+ if (!value) return OPENROUTER_BASE_URL
336
+ try {
337
+ const parsed = new URL(value)
338
+ if (!parsed.hostname.endsWith("openrouter.ai")) {
339
+ return OPENROUTER_BASE_URL
340
+ }
341
+ return `${parsed.origin}/api/v1`
342
+ } catch {
343
+ return OPENROUTER_BASE_URL
344
+ }
345
+ }
346
+
347
+ async function openRouterErrorDetail(response: Response): Promise<string> {
348
+ const text = await response.text().catch(() => "")
349
+ if (!text) return response.statusText
350
+ try {
351
+ const parsed = JSON.parse(text) as { error?: { message?: string }; message?: string }
352
+ return parsed.error?.message ?? parsed.message ?? text
353
+ } catch {
354
+ return text
355
+ }
356
+ }
357
+
358
+ async function resolveOpenRouterConfig(): Promise<{ apiKey: string; baseURL: string }> {
359
+ const auth = await Auth.get("openrouter").catch(() => undefined)
360
+ const providerOptions = (sync.data.config as any)?.provider?.openrouter?.options ?? {}
361
+ const optionApiKey = typeof providerOptions.apiKey === "string" ? providerOptions.apiKey : undefined
362
+
363
+ const apiKey =
364
+ process.env.NIKCLI_OPENROUTER_API_KEY ??
365
+ process.env.OPENROUTER_API_KEY ??
366
+ (auth?.type === "api" ? auth.key : undefined) ??
367
+ optionApiKey
368
+
369
+ if (!apiKey || !apiKey.trim()) {
370
+ throw new Error("OpenRouter API key not configured")
371
+ }
372
+
373
+ const baseURL = normalizeOpenRouterBaseURL(
374
+ process.env.NIKCLI_OPENROUTER_BASE_URL ??
375
+ process.env.OPENROUTER_BASE_URL ??
376
+ (typeof providerOptions.baseURL === "string" ? providerOptions.baseURL : undefined),
377
+ )
378
+
379
+ return {
380
+ apiKey: apiKey.trim(),
381
+ baseURL,
382
+ }
383
+ }
384
+
385
+ async function transcribeVoiceAudioViaResponses(
386
+ base64Audio: string,
387
+ config: { apiKey: string; baseURL: string },
388
+ signal: AbortSignal,
389
+ ): Promise<string> {
390
+ const response = await fetch(openRouterEndpoint(config.baseURL, "/responses"), {
391
+ method: "POST",
392
+ headers: {
393
+ Authorization: `Bearer ${config.apiKey}`,
394
+ "Content-Type": "application/json",
395
+ "HTTP-Referer": "https://nikcli.store/",
396
+ "X-Title": "nikcli",
397
+ },
398
+ body: JSON.stringify({
399
+ model: process.env.NIKCLI_VOICE_TRANSCRIBE_MODEL ?? VOICE_TRANSCRIBE_MODEL,
400
+ temperature: 0,
401
+ input: [
402
+ {
403
+ role: "user",
404
+ content: [
405
+ {
406
+ type: "input_text",
407
+ text: "Transcribe this audio. Return only the transcript text without extra commentary.",
408
+ },
409
+ {
410
+ type: "input_audio",
411
+ input_audio: {
412
+ data: base64Audio,
413
+ format: "wav",
414
+ },
415
+ },
416
+ ],
417
+ },
418
+ ],
419
+ }),
420
+ signal,
421
+ })
422
+
423
+ if (!response.ok) {
424
+ const detail = await openRouterErrorDetail(response)
425
+ throw new Error(`OpenRouter transcription failed (${response.status}): ${detail}`)
426
+ }
427
+
428
+ const result = (await response.json()) as {
429
+ output_text?: string
430
+ output?: Array<{
431
+ content?: Array<{
432
+ type?: string
433
+ text?: string
434
+ }>
435
+ }>
436
+ }
437
+
438
+ const fromOutputText = (result.output_text ?? "").trim()
439
+ if (fromOutputText) return fromOutputText
440
+
441
+ const fromContent =
442
+ result.output
443
+ ?.flatMap((x) => x.content ?? [])
444
+ .map((x) => (x.type === "output_text" && x.text ? x.text : ""))
445
+ .join(" ")
446
+ .trim() ?? ""
447
+
448
+ if (!fromContent) {
449
+ throw new Error("No transcript returned")
450
+ }
451
+
452
+ return fromContent
453
+ }
454
+
455
+ async function transcribeVoiceAudio(filePath: string): Promise<string> {
456
+ const audio = await Bun.file(filePath).arrayBuffer()
457
+ if (audio.byteLength === 0) {
458
+ throw new Error("Recorded audio is empty")
459
+ }
460
+
461
+ const config = await resolveOpenRouterConfig()
462
+ const base64Audio = Buffer.from(audio).toString("base64")
463
+ const controller = new AbortController()
464
+ const timeout = setTimeout(() => controller.abort(), 60_000)
465
+
466
+ try {
467
+ const response = await fetch(openRouterEndpoint(config.baseURL, "/chat/completions"), {
468
+ method: "POST",
469
+ headers: {
470
+ Authorization: `Bearer ${config.apiKey}`,
471
+ "Content-Type": "application/json",
472
+ "HTTP-Referer": "https://nikcli.store/",
473
+ "X-Title": "nikcli",
474
+ },
475
+ body: JSON.stringify({
476
+ model: process.env.NIKCLI_VOICE_TRANSCRIBE_MODEL ?? VOICE_TRANSCRIBE_MODEL,
477
+ temperature: 0,
478
+ messages: [
479
+ {
480
+ role: "user",
481
+ content: [
482
+ {
483
+ type: "text",
484
+ text: "Transcribe this audio. Return only the transcript text without extra commentary.",
485
+ },
486
+ {
487
+ type: "input_audio",
488
+ input_audio: {
489
+ data: base64Audio,
490
+ format: "wav",
491
+ },
492
+ },
493
+ ],
494
+ },
495
+ ],
496
+ }),
497
+ signal: controller.signal,
498
+ })
499
+
500
+ if (!response.ok) {
501
+ if (response.status === 402) {
502
+ const detail = await openRouterErrorDetail(response)
503
+ throw new Error(`OpenRouter audio credits required: ${detail}`)
504
+ }
505
+ const detail = await openRouterErrorDetail(response)
506
+ throw new Error(`OpenRouter transcription failed (${response.status}): ${detail}`)
507
+ }
508
+
509
+ const result = (await response.json()) as {
510
+ choices?: Array<{
511
+ message?: {
512
+ content?: unknown
513
+ }
514
+ }>
515
+ }
516
+
517
+ const content = result.choices?.[0]?.message?.content
518
+ const transcript = extractTranscriptContent(content)
519
+ if (!transcript) {
520
+ throw new Error("No transcript returned")
521
+ }
522
+ return transcript
523
+ } catch (error) {
524
+ const message = error instanceof Error ? error.message : ""
525
+ if (message.includes("credits required") || message.includes("(402)")) {
526
+ throw error
527
+ }
528
+
529
+ return transcribeVoiceAudioViaResponses(base64Audio, config, controller.signal)
530
+ } finally {
531
+ clearTimeout(timeout)
532
+ }
533
+ }
534
+
535
+ let isStartingRecording = false
536
+ async function startVoiceRecording() {
537
+ if (voiceStatus() !== "idle") return
538
+ if (isStartingRecording) return
539
+ isStartingRecording = true
540
+
541
+ try {
542
+ const filePath = path.join(os.tmpdir(), `nikcli-voice-${Date.now()}-${Math.random().toString(16).slice(2)}.wav`)
543
+ const recorder = await detectVoiceRecorder(filePath)
544
+
545
+ if (!recorder) {
546
+ toast.show({
547
+ variant: "error",
548
+ message:
549
+ process.platform === "darwin"
550
+ ? "Voice mode requires ffmpeg, sox, or macOS Command Line Tools (swift)"
551
+ : "Voice mode requires ffmpeg or sox (rec) installed",
552
+ duration: 4000,
553
+ })
554
+ return
555
+ }
556
+
557
+ if (!hasShownMicHint) {
558
+ hasShownMicHint = true
559
+ const message =
560
+ process.platform === "darwin"
561
+ ? "If prompted, allow microphone access for your terminal"
562
+ : "Allow microphone access when prompted by your operating system"
563
+ toast.show({ variant: "info", message, duration: 3500 })
564
+ }
565
+
566
+ try {
567
+ voiceAudioPath = filePath
568
+ voiceRecorder = Bun.spawn(recorder.command, {
569
+ stdout: "ignore",
570
+ stderr: "pipe",
571
+ })
572
+ voiceAutoStopTimer = setTimeout(() => {
573
+ void stopVoiceRecording()
574
+ }, 90_000)
575
+ setVoiceStatus("recording")
576
+ toast.show({
577
+ variant: "info",
578
+ message: `Recording started (${recorder.name}). Hold to talk, or click again to stop`,
579
+ duration: 2500,
580
+ })
581
+ } catch {
582
+ cleanupVoiceAudio(filePath)
583
+ voiceAudioPath = null
584
+ voiceRecorder = null
585
+ if (voiceAutoStopTimer) {
586
+ clearTimeout(voiceAutoStopTimer)
587
+ voiceAutoStopTimer = undefined
588
+ }
589
+ setVoiceStatus("idle")
590
+ toast.show({ variant: "error", message: "Failed to start voice recording", duration: 3000 })
591
+ }
592
+ } finally {
593
+ isStartingRecording = false
594
+ }
595
+ }
596
+
597
+ async function stopVoiceRecording() {
598
+ if (!voiceRecorder || !voiceAudioPath) {
599
+ setVoiceStatus("idle")
600
+ return
601
+ }
602
+
603
+ const recorder = voiceRecorder
604
+ const filePath = voiceAudioPath
605
+ voiceRecorder = null
606
+ voiceAudioPath = null
607
+ if (voiceAutoStopTimer) {
608
+ clearTimeout(voiceAutoStopTimer)
609
+ voiceAutoStopTimer = undefined
610
+ }
611
+ setVoiceStatus("transcribing")
612
+
613
+ try {
614
+ try {
615
+ recorder.kill("SIGINT")
616
+ } catch {
617
+ recorder.kill()
618
+ }
619
+
620
+ await Promise.race([recorder.exited, new Promise((resolve) => setTimeout(resolve, 4000))])
621
+
622
+ const stderrText =
623
+ recorder.stderr && typeof recorder.stderr !== "number"
624
+ ? await new Response(recorder.stderr).text().catch(() => "")
625
+ : ""
626
+ if (looksLikeMicPermissionError(stderrText)) {
627
+ const detail = stderrText
628
+ .split("\n")
629
+ .map((x) => x.trim())
630
+ .filter(Boolean)
631
+ .at(-1)
632
+ const terminalName = currentTerminalName()
633
+ const message =
634
+ process.platform === "darwin"
635
+ ? isLikelyIntegratedTerminal()
636
+ ? `Microphone denied for ${terminalName}. Allow it in System Settings > Privacy & Security > Microphone, or run nikcli in Terminal.app/iTerm2`
637
+ : `Microphone access denied for ${terminalName}. Enable it in System Settings > Privacy & Security > Microphone`
638
+ : "Microphone access denied. Allow microphone permission for your terminal"
639
+ toast.show({
640
+ variant: "error",
641
+ message: detail ? `${message} (${detail})` : message,
642
+ duration: 8000,
643
+ })
644
+ return
645
+ }
646
+
647
+ const recordedBytes = await Bun.file(filePath)
648
+ .arrayBuffer()
649
+ .then((x) => x.byteLength)
650
+ .catch(() => 0)
651
+
652
+ if (recordedBytes < 1024) {
653
+ const detail = stderrText.trim().split("\n").at(-1)
654
+ toast.show({
655
+ variant: "error",
656
+ message: detail ? `No audio captured: ${detail}` : "No audio captured. Try holding the button longer",
657
+ duration: 5000,
658
+ })
659
+ return
660
+ }
661
+
662
+ const transcript = await transcribeVoiceAudio(filePath)
663
+ const withSpacing = input.plainText.length > 0 && !input.plainText.endsWith(" ") ? ` ${transcript}` : transcript
664
+ const nextInput = `${input.plainText}${withSpacing}`
665
+
666
+ input.focus()
667
+ input.setText(nextInput)
668
+ setStore("prompt", "input", nextInput)
669
+ autocomplete.onInput(nextInput)
670
+ await Clipboard.copy(transcript).catch(() => {})
671
+
672
+ setTimeout(() => {
673
+ input.cursorOffset = nextInput.length
674
+ renderer.requestRender()
675
+ }, 0)
676
+
677
+ toast.show({ variant: "success", message: "Voice transcript inserted and copied", duration: 2000 })
678
+ } catch (error) {
679
+ const message = error instanceof Error ? error.message : "Voice transcription failed"
680
+ toast.show({ variant: "error", message, duration: 4000 })
681
+ } finally {
682
+ cleanupVoiceAudio(filePath)
683
+ setVoiceStatus("idle")
684
+ }
685
+ }
686
+
687
+ async function handleVoiceButtonDown() {
688
+ if (props.disabled) return
689
+ if (voiceStatus() === "transcribing") return
690
+ if (voiceStatus() === "recording") {
691
+ await stopVoiceRecording()
692
+ return
693
+ }
694
+ voicePressStartedAt = Date.now()
695
+ await startVoiceRecording()
696
+ }
697
+
698
+ async function handleVoiceButtonUp() {
699
+ if (props.disabled) return
700
+ if (Date.now() - voicePressStartedAt < 220) return
701
+ if (voiceStatus() !== "recording") return
702
+ await stopVoiceRecording()
703
+ }
704
+
705
+ onCleanup(() => {
706
+ if (voiceAutoStopTimer) {
707
+ clearTimeout(voiceAutoStopTimer)
708
+ voiceAutoStopTimer = undefined
709
+ }
710
+ if (voiceRecorder) {
711
+ try {
712
+ voiceRecorder.kill("SIGINT")
713
+ } catch {
714
+ try {
715
+ voiceRecorder.kill()
716
+ } catch {
717
+ // ignore
718
+ }
719
+ }
720
+ voiceRecorder = null
721
+ }
722
+ cleanupVoiceAudio(voiceAudioPath)
723
+ voiceAudioPath = null
724
+ })
725
+
726
+ type BackgroundSubtasksMap = Record<string, string[]>
727
+
728
+ function getBackgroundSubtasksMap(): BackgroundSubtasksMap {
729
+ return (kv.get("background_subtasks", {}) ?? {}) as BackgroundSubtasksMap
730
+ }
731
+
732
+ function setBackgroundSubtasksMap(next: BackgroundSubtasksMap) {
733
+ kv.set("background_subtasks", next)
734
+ }
735
+
736
+ function removeBackgroundSubtask(parentID: string, childID: string) {
737
+ const map = getBackgroundSubtasksMap()
738
+ const list = map[parentID] ?? []
739
+ if (!list.includes(childID)) return
740
+ setBackgroundSubtasksMap({ ...map, [parentID]: list.filter((x) => x !== childID) })
741
+ }
742
+
743
+ const backgroundedSubtaskIDs = createMemo(() => {
744
+ if (!props.sessionID) return [] as string[]
745
+ const map = getBackgroundSubtasksMap()
746
+ return map[props.sessionID] ?? []
747
+ })
748
+
749
+ const backgroundedSubtaskCount = createMemo(() => backgroundedSubtaskIDs().length)
750
+
751
+ function openBackgroundSubtasks() {
752
+ if (!props.sessionID) return
753
+ dialog.replace(() => <DialogSubagent sessionID={props.sessionID!} />)
754
+ }
755
+
756
+ function stripSubagentSuffix(title: string): string {
757
+ return title.replace(/\s*\(@[^\s]+\s+subagent\)$/, "")
758
+ }
759
+
760
+ // Auto-resurface: when a backgrounded subtask finishes, reopen it in the foreground.
761
+ const previousSubtaskStatus = new Map<string, string>()
762
+ createEffect(() => {
763
+ if (!props.sessionID) return
764
+
765
+ const ids = backgroundedSubtaskIDs()
766
+ const live = new Set(ids)
767
+ for (const existing of previousSubtaskStatus.keys()) {
768
+ if (!live.has(existing)) previousSubtaskStatus.delete(existing)
769
+ }
770
+
771
+ // Resurface the first task that transitioned to idle.
772
+ for (const id of ids) {
773
+ const current = sync.data.session_status?.[id]?.type ?? "idle"
774
+ const prev = previousSubtaskStatus.get(id)
775
+ previousSubtaskStatus.set(id, current)
776
+ if (!prev) continue
777
+ if (prev !== "idle" && current === "idle") {
778
+ const title = sync.data.session.find((s) => s.id === id)?.title
779
+ toast.show({
780
+ variant: "success",
781
+ message: `${stripSubagentSuffix(title ?? "Subtask")} finished`,
782
+ duration: 3000,
783
+ })
784
+ removeBackgroundSubtask(props.sessionID, id)
785
+ route.navigate({ type: "session", sessionID: id, workspaceID: sync.session.get(id)?.workspaceID })
786
+ break
787
+ }
788
+ }
789
+ })
790
+
791
+ const getAvailableAds = () => {
792
+ const adsConfig = ads()
793
+ const items = (adsConfig?.items ?? []).filter((item) => item.enabled !== false)
794
+ const enabled = adsConfig?.enabled ?? true
795
+ if (!enabled || items.length === 0) return []
796
+ return items
797
+ }
798
+
799
+ const selectNextAd = () => {
800
+ const items = getAvailableAds()
801
+ if (items.length === 0) {
802
+ setCurrentAd(null)
803
+ return
804
+ }
805
+
806
+ const adsConfig = ads()
807
+ const ratio = adsConfig?.ratio ?? 0.3
808
+ if (Math.random() >= ratio) {
809
+ setCurrentAd(null)
810
+ return
811
+ }
812
+
813
+ const index = store.currentAdIndex % items.length
814
+ const item = items[index]
815
+ setStore("currentAdIndex", (store.currentAdIndex + 1) % items.length)
816
+
817
+ if (item.url) setCurrentAd(`${item.text} {highlight}${item.url}{/highlight}`)
818
+ else setCurrentAd(item.text)
819
+ }
820
+
821
+ createEffect(
822
+ on(
823
+ () => status(),
824
+ (currentStatus) => {
825
+ if (currentStatus.type === "idle") {
826
+ selectNextAd()
827
+ }
828
+ },
829
+ { defer: true },
830
+ ),
831
+ )
832
+
833
+ const sponsoredTip = currentAd
834
+
835
+ const parseTipParts = (tip: string) => {
836
+ const parts: { text: string; highlight: boolean }[] = []
837
+ const regex = /\{highlight\}(.*?)\{\/highlight\}/g
838
+ let lastIndex = 0
839
+ for (const match of tip.matchAll(regex)) {
840
+ if (match.index! > lastIndex) {
841
+ parts.push({ text: tip.slice(lastIndex, match.index), highlight: false })
842
+ }
843
+ parts.push({ text: match[1], highlight: true })
844
+ lastIndex = match.index! + match[0].length
845
+ }
846
+ if (lastIndex < tip.length) {
847
+ parts.push({ text: tip.slice(lastIndex), highlight: false })
848
+ }
849
+ return parts
850
+ }
851
+
852
+ function promptModelWarning() {
853
+ toast.show({
854
+ variant: "warning",
855
+ message: "Connect a provider to send prompts",
856
+ duration: 3000,
857
+ })
858
+ if (sync.data.provider.length === 0) {
859
+ dialog.replace(() => <DialogProviderConnect />)
860
+ }
861
+ }
862
+
863
+ const textareaKeybindings = useTextareaKeybindings()
864
+
865
+ const fileStyleId = syntax().getStyleId("extmark.file")!
866
+ const agentStyleId = syntax().getStyleId("extmark.agent")!
867
+ const pasteStyleId = syntax().getStyleId("extmark.paste")!
868
+ let promptPartTypeId = 0
869
+
870
+ sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
871
+ input.insertText(evt.properties.text)
872
+ setTimeout(() => {
873
+ input.getLayoutNode().markDirty()
874
+ input.gotoBufferEnd()
875
+ renderer.requestRender()
876
+ }, 0)
877
+ })
878
+
879
+ createEffect(
880
+ on(
881
+ () => [props.disabled, theme.backgroundElement, theme.text] as const,
882
+ ([disabled, bg, text]) => {
883
+ if (disabled) input.cursorColor = bg
884
+ if (!disabled) input.cursorColor = text
885
+ },
886
+ { defer: true },
887
+ ),
888
+ )
889
+
890
+ const lastUserMessage = createMemo(() => {
891
+ if (!props.sessionID) return undefined
892
+ const messages = sync.data.message[props.sessionID]
893
+ if (!messages) return undefined
894
+ return messages.findLast((m) => m.role === "user")
895
+ })
896
+
897
+ const [store, setStore] = createStore<{
898
+ prompt: PromptInfo
899
+ mode: "normal" | "shell"
900
+ extmarkToPartIndex: Map<number, number>
901
+ interrupt: number
902
+ placeholder: number
903
+ currentAdIndex: number
904
+ }>({
905
+ placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
906
+ prompt: {
907
+ input: "",
908
+ parts: [],
909
+ },
910
+ mode: "normal",
911
+ extmarkToPartIndex: new Map(),
912
+ interrupt: 0,
913
+ currentAdIndex: 0,
914
+ })
915
+
916
+ createEffect(
917
+ on(
918
+ () => props.sessionID,
919
+ () => {
920
+ setStore("placeholder", Math.floor(Math.random() * PLACEHOLDERS.length))
921
+ },
922
+ { defer: true },
923
+ ),
924
+ )
925
+
926
+ // Initialize agent/model/variant from last user message when session changes
927
+ let syncedSessionID: string | undefined
928
+ createEffect(
929
+ on(
930
+ () => ({ sessionID: props.sessionID, msg: lastUserMessage() }),
931
+ ({ sessionID, msg }) => {
932
+ if (sessionID !== syncedSessionID) {
933
+ if (!sessionID || !msg) return
934
+
935
+ syncedSessionID = sessionID
936
+
937
+ const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent)
938
+ if (msg.agent && isPrimaryAgent) {
939
+ local.agent.set(msg.agent)
940
+ if (msg.model) local.model.set(msg.model)
941
+ if (msg.variant) local.model.variant.set(msg.variant)
942
+ }
943
+ }
944
+ },
945
+ { defer: true },
946
+ ),
947
+ )
948
+
949
+ command.register(() => {
950
+ return [
951
+ {
952
+ title: "Clear prompt",
953
+ value: "prompt.clear",
954
+ category: "Prompt",
955
+ hidden: true,
956
+ onSelect: (dialog) => {
957
+ input.extmarks.clear()
958
+ input.clear()
959
+ dialog.clear()
960
+ },
961
+ },
962
+ {
963
+ title: "Submit prompt",
964
+ value: "prompt.submit",
965
+ keybind: "input_submit",
966
+ category: "Prompt",
967
+ hidden: true,
968
+ onSelect: (dialog) => {
969
+ if (!input.focused) return
970
+ submit()
971
+ dialog.clear()
972
+ },
973
+ },
974
+ {
975
+ title: "Paste",
976
+ value: "prompt.paste",
977
+ keybind: "input_paste",
978
+ category: "Prompt",
979
+ hidden: true,
980
+ onSelect: async () => {
981
+ const content = await Clipboard.read()
982
+ if (content?.mime.startsWith("image/")) {
983
+ await pasteImage({
984
+ filename: "clipboard",
985
+ mime: content.mime,
986
+ content: content.data,
987
+ })
988
+ }
989
+ },
990
+ },
991
+ {
992
+ title: "Interrupt session",
993
+ value: "session.interrupt",
994
+ keybind: "session_interrupt",
995
+ category: "Session",
996
+ hidden: true,
997
+ enabled: status().type !== "idle",
998
+ onSelect: (dialog) => {
999
+ if (autocomplete.visible) return
1000
+ if (!input.focused) return
1001
+ // TODO: this should be its own command
1002
+ if (store.mode === "shell") {
1003
+ setStore("mode", "normal")
1004
+ return
1005
+ }
1006
+ if (!props.sessionID) return
1007
+
1008
+ setStore("interrupt", store.interrupt + 1)
1009
+
1010
+ setTimeout(() => {
1011
+ setStore("interrupt", 0)
1012
+ }, 5000)
1013
+
1014
+ if (store.interrupt >= 2) {
1015
+ sdk.client.session.abort({
1016
+ sessionID: props.sessionID,
1017
+ })
1018
+ setStore("interrupt", 0)
1019
+ }
1020
+ dialog.clear()
1021
+ },
1022
+ },
1023
+ {
1024
+ title: "Open editor",
1025
+ category: "Session",
1026
+ keybind: "editor_open",
1027
+ value: "prompt.editor",
1028
+ slash: {
1029
+ name: "editor",
1030
+ },
1031
+ onSelect: async (dialog) => {
1032
+ dialog.clear()
1033
+
1034
+ // replace summarized text parts with the actual text
1035
+ const text = store.prompt.parts
1036
+ .filter((p) => p.type === "text")
1037
+ .reduce((acc, p) => {
1038
+ if (!p.source) return acc
1039
+ return acc.replace(p.source.text.value, p.text)
1040
+ }, store.prompt.input)
1041
+
1042
+ const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text")
1043
+
1044
+ const value = text
1045
+ const content = await Editor.open({ value, renderer })
1046
+ if (!content) return
1047
+
1048
+ input.setText(content)
1049
+
1050
+ // Update positions for nonTextParts based on their location in new content
1051
+ // Filter out parts whose virtual text was deleted
1052
+ // this handles a case where the user edits the text in the editor
1053
+ // such that the virtual text moves around or is deleted
1054
+ const updatedNonTextParts = nonTextParts
1055
+ .map((part) => {
1056
+ let virtualText = ""
1057
+ if (part.type === "file" && part.source?.text) {
1058
+ virtualText = part.source.text.value
1059
+ } else if (part.type === "agent" && part.source) {
1060
+ virtualText = part.source.value
1061
+ }
1062
+
1063
+ if (!virtualText) return part
1064
+
1065
+ const newStart = content.indexOf(virtualText)
1066
+ // if the virtual text is deleted, remove the part
1067
+ if (newStart === -1) return null
1068
+
1069
+ const newEnd = newStart + virtualText.length
1070
+
1071
+ if (part.type === "file" && part.source?.text) {
1072
+ return {
1073
+ ...part,
1074
+ source: {
1075
+ ...part.source,
1076
+ text: {
1077
+ ...part.source.text,
1078
+ start: newStart,
1079
+ end: newEnd,
1080
+ },
1081
+ },
1082
+ }
1083
+ }
1084
+
1085
+ if (part.type === "agent" && part.source) {
1086
+ return {
1087
+ ...part,
1088
+ source: {
1089
+ ...part.source,
1090
+ start: newStart,
1091
+ end: newEnd,
1092
+ },
1093
+ }
1094
+ }
1095
+
1096
+ return part
1097
+ })
1098
+ .filter((part) => part !== null)
1099
+
1100
+ setStore("prompt", {
1101
+ input: content,
1102
+ // keep only the non-text parts because the text parts were
1103
+ // already expanded inline
1104
+ parts: updatedNonTextParts,
1105
+ })
1106
+ restoreExtmarksFromParts(updatedNonTextParts)
1107
+ input.cursorOffset = Bun.stringWidth(content)
1108
+ },
1109
+ },
1110
+ ]
1111
+ })
1112
+
1113
+ const ref: PromptRef = {
1114
+ get focused() {
1115
+ return input.focused
1116
+ },
1117
+ get current() {
1118
+ return store.prompt
1119
+ },
1120
+ focus() {
1121
+ input.focus()
1122
+ },
1123
+ blur() {
1124
+ input.blur()
1125
+ },
1126
+ set(prompt) {
1127
+ input.setText(prompt.input)
1128
+ setStore("prompt", prompt)
1129
+ restoreExtmarksFromParts(prompt.parts)
1130
+ input.gotoBufferEnd()
1131
+ },
1132
+ reset() {
1133
+ input.clear()
1134
+ input.extmarks.clear()
1135
+ setStore("prompt", {
1136
+ input: "",
1137
+ parts: [],
1138
+ })
1139
+ setStore("extmarkToPartIndex", new Map())
1140
+ },
1141
+ submit() {
1142
+ submit()
1143
+ },
1144
+ }
1145
+
1146
+ createEffect(
1147
+ on(
1148
+ () => props.visible,
1149
+ (visible) => {
1150
+ if (visible !== false) input?.focus()
1151
+ if (visible === false) input?.blur()
1152
+ },
1153
+ { defer: true },
1154
+ ),
1155
+ )
1156
+
1157
+ function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
1158
+ input.extmarks.clear()
1159
+ setStore("extmarkToPartIndex", new Map())
1160
+
1161
+ parts.forEach((part, partIndex) => {
1162
+ let start = 0
1163
+ let end = 0
1164
+ let virtualText = ""
1165
+ let styleId: number | undefined
1166
+
1167
+ if (part.type === "file" && part.source?.text) {
1168
+ start = part.source.text.start
1169
+ end = part.source.text.end
1170
+ virtualText = part.source.text.value
1171
+ styleId = fileStyleId
1172
+ } else if (part.type === "agent" && part.source) {
1173
+ start = part.source.start
1174
+ end = part.source.end
1175
+ virtualText = part.source.value
1176
+ styleId = agentStyleId
1177
+ } else if (part.type === "text" && part.source?.text) {
1178
+ start = part.source.text.start
1179
+ end = part.source.text.end
1180
+ virtualText = part.source.text.value
1181
+ styleId = pasteStyleId
1182
+ }
1183
+
1184
+ if (virtualText) {
1185
+ const extmarkId = input.extmarks.create({
1186
+ start,
1187
+ end,
1188
+ virtual: true,
1189
+ styleId,
1190
+ typeId: promptPartTypeId,
1191
+ })
1192
+ setStore("extmarkToPartIndex", (map: Map<number, number>) => {
1193
+ const newMap = new Map(map)
1194
+ newMap.set(extmarkId, partIndex)
1195
+ return newMap
1196
+ })
1197
+ }
1198
+ })
1199
+ }
1200
+
1201
+ function syncExtmarksWithPromptParts() {
1202
+ const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
1203
+ setStore(
1204
+ produce((draft) => {
1205
+ const newMap = new Map<number, number>()
1206
+ const newParts: typeof draft.prompt.parts = []
1207
+
1208
+ for (const extmark of allExtmarks) {
1209
+ const partIndex = draft.extmarkToPartIndex.get(extmark.id)
1210
+ if (partIndex !== undefined) {
1211
+ const part = draft.prompt.parts[partIndex]
1212
+ if (part) {
1213
+ if (part.type === "agent" && part.source) {
1214
+ part.source.start = extmark.start
1215
+ part.source.end = extmark.end
1216
+ } else if (part.type === "file" && part.source?.text) {
1217
+ part.source.text.start = extmark.start
1218
+ part.source.text.end = extmark.end
1219
+ } else if (part.type === "text" && part.source?.text) {
1220
+ part.source.text.start = extmark.start
1221
+ part.source.text.end = extmark.end
1222
+ }
1223
+ newMap.set(extmark.id, newParts.length)
1224
+ newParts.push(part)
1225
+ }
1226
+ }
1227
+ }
1228
+
1229
+ draft.extmarkToPartIndex = newMap
1230
+ draft.prompt.parts = newParts
1231
+ }),
1232
+ )
1233
+ }
1234
+
1235
+ command.register(() => [
1236
+ {
1237
+ title: "Stash prompt",
1238
+ value: "prompt.stash",
1239
+ category: "Prompt",
1240
+ enabled: !!store.prompt.input,
1241
+ onSelect: (dialog) => {
1242
+ if (!store.prompt.input) return
1243
+ stash.push({
1244
+ input: store.prompt.input,
1245
+ parts: store.prompt.parts,
1246
+ })
1247
+ input.extmarks.clear()
1248
+ input.clear()
1249
+ setStore("prompt", { input: "", parts: [] })
1250
+ setStore("extmarkToPartIndex", new Map())
1251
+ dialog.clear()
1252
+ },
1253
+ },
1254
+ {
1255
+ title: "Stash pop",
1256
+ value: "prompt.stash.pop",
1257
+ category: "Prompt",
1258
+ enabled: stash.list().length > 0,
1259
+ onSelect: (dialog) => {
1260
+ const entry = stash.pop()
1261
+ if (entry) {
1262
+ input.setText(entry.input)
1263
+ setStore("prompt", { input: entry.input, parts: entry.parts })
1264
+ restoreExtmarksFromParts(entry.parts)
1265
+ input.gotoBufferEnd()
1266
+ }
1267
+ dialog.clear()
1268
+ },
1269
+ },
1270
+ {
1271
+ title: "Stash list",
1272
+ value: "prompt.stash.list",
1273
+ category: "Prompt",
1274
+ enabled: stash.list().length > 0,
1275
+ onSelect: (dialog) => {
1276
+ dialog.replace(() => (
1277
+ <DialogStash
1278
+ onSelect={(entry) => {
1279
+ input.setText(entry.input)
1280
+ setStore("prompt", { input: entry.input, parts: entry.parts })
1281
+ restoreExtmarksFromParts(entry.parts)
1282
+ input.gotoBufferEnd()
1283
+ }}
1284
+ />
1285
+ ))
1286
+ },
1287
+ },
1288
+ ])
1289
+
1290
+ command.register(() => [
1291
+ {
1292
+ title: "Create Theme",
1293
+ value: "theme.create",
1294
+ category: "Theme",
1295
+ slash: { name: "theme-create" },
1296
+ onSelect: (dialog) => {
1297
+ dialog.replace(() => <DialogThemeCreate />)
1298
+ },
1299
+ },
1300
+ {
1301
+ title: "RAG Embedding Models",
1302
+ value: "rag-model",
1303
+ category: "Config",
1304
+ slash: { name: "rag-models", aliases: ["rag-model"] },
1305
+ onSelect: (dialog) => {
1306
+ dialog.replace(() => <DialogRagModel />)
1307
+ },
1308
+ },
1309
+ {
1310
+ title: "Image Models",
1311
+ value: "image-models",
1312
+ category: "Config",
1313
+ slash: { name: "image-models" },
1314
+ onSelect: (dialog) => {
1315
+ dialog.replace(() => <DialogImageModel />)
1316
+ },
1317
+ },
1318
+ {
1319
+ title: "TTS Voice",
1320
+ value: "speak-model",
1321
+ category: "Config",
1322
+ slash: { name: "speak-model" },
1323
+ onSelect: (dialog) => {
1324
+ dialog.replace(() => <DialogSpeakModel />)
1325
+ },
1326
+ },
1327
+ {
1328
+ title: "Remote Access",
1329
+ value: "remote",
1330
+ category: "Config",
1331
+ slash: { name: "remote" },
1332
+ onSelect: (dialog) => {
1333
+ dialog.replace(() => <DialogRemote />)
1334
+ },
1335
+ },
1336
+ ])
1337
+
1338
+ async function submit() {
1339
+ if (props.disabled) return
1340
+ if (autocomplete?.visible) return
1341
+ if (!store.prompt.input) return
1342
+ const trimmed = store.prompt.input.trim()
1343
+ if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
1344
+ exit()
1345
+ return
1346
+ }
1347
+ const selectedModel = local.model.current()
1348
+ if (!selectedModel) {
1349
+ promptModelWarning()
1350
+ return
1351
+ }
1352
+ const sessionID = props.sessionID
1353
+ ? props.sessionID
1354
+ : await (async () => {
1355
+ const sessionID = await sdk.client.session.create({ workspaceID: props.workspaceID }).then((x) => x.data!.id)
1356
+ return sessionID
1357
+ })()
1358
+ const messageID = Identifier.ascending("message")
1359
+ let inputText = store.prompt.input
1360
+
1361
+ // Expand pasted text inline before submitting
1362
+ const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
1363
+ const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start)
1364
+
1365
+ for (const extmark of sortedExtmarks) {
1366
+ const partIndex = store.extmarkToPartIndex.get(extmark.id)
1367
+ if (partIndex !== undefined) {
1368
+ const part = store.prompt.parts[partIndex]
1369
+ if (part?.type === "text" && part.text) {
1370
+ const before = inputText.slice(0, extmark.start)
1371
+ const after = inputText.slice(extmark.end)
1372
+ inputText = before + part.text + after
1373
+ }
1374
+ }
1375
+ }
1376
+
1377
+ // Filter out text parts (pasted content) since they're now expanded inline
1378
+ const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")
1379
+
1380
+ // Capture mode before it gets reset
1381
+ const currentMode = store.mode
1382
+ const variant = local.model.variant.current()
1383
+
1384
+ if (store.mode === "shell") {
1385
+ sdk.client.session.shell({
1386
+ sessionID,
1387
+ agent: local.agent.current().name,
1388
+ model: {
1389
+ providerID: selectedModel.providerID,
1390
+ modelID: selectedModel.modelID,
1391
+ },
1392
+ command: inputText,
1393
+ })
1394
+ setStore("mode", "normal")
1395
+ } else if (
1396
+ inputText.startsWith("/") &&
1397
+ iife(() => {
1398
+ const firstLine = inputText.split("\n")[0]
1399
+ const command = firstLine.split(" ")[0].slice(1)
1400
+ return sync.data.command.some((x) => x.name === command)
1401
+ })
1402
+ ) {
1403
+ // Parse command from first line, preserve multi-line content in arguments
1404
+ const firstLineEnd = inputText.indexOf("\n")
1405
+ const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
1406
+ const [command, ...firstLineArgs] = firstLine.split(" ")
1407
+ const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
1408
+ const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
1409
+
1410
+ sdk.client.session.command({
1411
+ sessionID,
1412
+ command: command.slice(1),
1413
+ arguments: args,
1414
+ agent: local.agent.current().name,
1415
+ model: `${selectedModel.providerID}/${selectedModel.modelID}`,
1416
+ messageID,
1417
+ variant,
1418
+ parts: nonTextParts
1419
+ .filter((x) => x.type === "file")
1420
+ .map((x) => ({
1421
+ id: Identifier.ascending("part"),
1422
+ ...x,
1423
+ })),
1424
+ })
1425
+ } else {
1426
+ sdk.client.session
1427
+ .prompt({
1428
+ sessionID,
1429
+ ...selectedModel,
1430
+ messageID,
1431
+ agent: local.agent.current().name,
1432
+ model: selectedModel,
1433
+ variant,
1434
+ parts: [
1435
+ {
1436
+ id: Identifier.ascending("part"),
1437
+ type: "text",
1438
+ text: inputText,
1439
+ },
1440
+ ...nonTextParts.map((x) => ({
1441
+ id: Identifier.ascending("part"),
1442
+ ...x,
1443
+ })),
1444
+ ],
1445
+ })
1446
+ .catch(() => {})
1447
+ }
1448
+ history.append({
1449
+ ...store.prompt,
1450
+ mode: currentMode,
1451
+ })
1452
+ input.extmarks.clear()
1453
+ setStore("prompt", {
1454
+ input: "",
1455
+ parts: [],
1456
+ })
1457
+ setStore("extmarkToPartIndex", new Map())
1458
+ props.onSubmit?.()
1459
+
1460
+ // temporary hack to make sure the message is sent
1461
+ if (!props.sessionID)
1462
+ setTimeout(() => {
1463
+ route.navigate({
1464
+ type: "session",
1465
+ sessionID,
1466
+ workspaceID: props.workspaceID ?? sync.session.get(sessionID)?.workspaceID,
1467
+ })
1468
+ }, 50)
1469
+ input.clear()
1470
+ }
1471
+ const exit = useExit()
1472
+
1473
+ function pasteText(text: string, virtualText: string) {
1474
+ const currentOffset = input.visualCursor.offset
1475
+ const extmarkStart = currentOffset
1476
+ const extmarkEnd = extmarkStart + virtualText.length
1477
+
1478
+ input.insertText(virtualText + " ")
1479
+
1480
+ const extmarkId = input.extmarks.create({
1481
+ start: extmarkStart,
1482
+ end: extmarkEnd,
1483
+ virtual: true,
1484
+ styleId: pasteStyleId,
1485
+ typeId: promptPartTypeId,
1486
+ })
1487
+
1488
+ setStore(
1489
+ produce((draft) => {
1490
+ const partIndex = draft.prompt.parts.length
1491
+ draft.prompt.parts.push({
1492
+ type: "text" as const,
1493
+ text,
1494
+ source: {
1495
+ text: {
1496
+ start: extmarkStart,
1497
+ end: extmarkEnd,
1498
+ value: virtualText,
1499
+ },
1500
+ },
1501
+ })
1502
+ draft.extmarkToPartIndex.set(extmarkId, partIndex)
1503
+ }),
1504
+ )
1505
+ }
1506
+
1507
+ async function pasteImage(file: { filename?: string; content: string; mime: string }) {
1508
+ const currentOffset = input.visualCursor.offset
1509
+ const extmarkStart = currentOffset
1510
+ const count = store.prompt.parts.filter((x) => x.type === "file").length
1511
+ const virtualText = `[Image ${count + 1}]`
1512
+ const extmarkEnd = extmarkStart + virtualText.length
1513
+ const textToInsert = virtualText + " "
1514
+
1515
+ input.insertText(textToInsert)
1516
+
1517
+ const extmarkId = input.extmarks.create({
1518
+ start: extmarkStart,
1519
+ end: extmarkEnd,
1520
+ virtual: true,
1521
+ styleId: pasteStyleId,
1522
+ typeId: promptPartTypeId,
1523
+ })
1524
+
1525
+ const part: Omit<FilePart, "id" | "messageID" | "sessionID"> = {
1526
+ type: "file" as const,
1527
+ mime: file.mime,
1528
+ filename: file.filename,
1529
+ url: `data:${file.mime};base64,${file.content}`,
1530
+ source: {
1531
+ type: "file",
1532
+ path: file.filename ?? "",
1533
+ text: {
1534
+ start: extmarkStart,
1535
+ end: extmarkEnd,
1536
+ value: virtualText,
1537
+ },
1538
+ },
1539
+ }
1540
+ setStore(
1541
+ produce((draft) => {
1542
+ const partIndex = draft.prompt.parts.length
1543
+ draft.prompt.parts.push(part)
1544
+ draft.extmarkToPartIndex.set(extmarkId, partIndex)
1545
+ }),
1546
+ )
1547
+ return
1548
+ }
1549
+
1550
+ const highlight = createMemo(() => {
1551
+ if (keybind.leader) return theme.border
1552
+ if (store.mode === "shell") return theme.primary
1553
+ return local.agent.color(local.agent.current().name)
1554
+ })
1555
+
1556
+ const showVariant = createMemo(() => {
1557
+ const variants = local.model.variant.list()
1558
+ if (variants.length === 0) return false
1559
+ const current = local.model.variant.current()
1560
+ return !!current
1561
+ })
1562
+
1563
+ const spinnerDef = createMemo(() => {
1564
+ const style = kv.get("settings.spinner.style", "knight_rider_blocks") as SpinnerStyle
1565
+ const enabled = kv.get("settings.spinner.enabled", true)
1566
+
1567
+ if (!enabled) {
1568
+ return null
1569
+ }
1570
+
1571
+ const color = local.agent.color(local.agent.current().name)
1572
+
1573
+ const brailleFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
1574
+ const dotsFrames = ["·", "⠂", "⠄", "⠆", "⠖", "⠗", "⠞", "⠟", "⠿", "⠛"]
1575
+ const lineFrames = ["│", "⠐", "⠔", "⠤", "⠄", "⠦"]
1576
+ const bouncingFrames = ["①", "②", "③", "④", "⑤", "⑥", "⑦", "⑧"]
1577
+ const pulseFrames = ["▖", "▗", "▘", "▙", "▚", "▛", "▜", "▝", "▞", "▟"]
1578
+
1579
+ if (style === "knight_rider_blocks") {
1580
+ return {
1581
+ frames: createFrames({
1582
+ color,
1583
+ style: "blocks",
1584
+ inactiveFactor: 0.6,
1585
+ minAlpha: 0.3,
1586
+ }),
1587
+ color: createColors({
1588
+ color,
1589
+ style: "blocks",
1590
+ inactiveFactor: 0.6,
1591
+ minAlpha: 0.3,
1592
+ }),
1593
+ }
1594
+ }
1595
+
1596
+ if (style === "knight_rider_diamonds") {
1597
+ return {
1598
+ frames: createFrames({
1599
+ color,
1600
+ style: "diamonds",
1601
+ inactiveFactor: 0.6,
1602
+ minAlpha: 0.3,
1603
+ }),
1604
+ color: createColors({
1605
+ color,
1606
+ style: "diamonds",
1607
+ inactiveFactor: 0.6,
1608
+ minAlpha: 0.3,
1609
+ }),
1610
+ }
1611
+ }
1612
+
1613
+ if (style === "braille") {
1614
+ return {
1615
+ frames: brailleFrames,
1616
+ color: undefined,
1617
+ }
1618
+ }
1619
+
1620
+ if (style === "dots") {
1621
+ return {
1622
+ frames: dotsFrames,
1623
+ color: undefined,
1624
+ }
1625
+ }
1626
+
1627
+ if (style === "line") {
1628
+ return {
1629
+ frames: lineFrames,
1630
+ color: undefined,
1631
+ }
1632
+ }
1633
+
1634
+ if (style === "bouncing") {
1635
+ return {
1636
+ frames: bouncingFrames,
1637
+ color: undefined,
1638
+ }
1639
+ }
1640
+
1641
+ if (style === "pulse") {
1642
+ return {
1643
+ frames: pulseFrames,
1644
+ color: undefined,
1645
+ }
1646
+ }
1647
+
1648
+ // Default fallback
1649
+ return {
1650
+ frames: createFrames({
1651
+ color,
1652
+ style: "blocks",
1653
+ inactiveFactor: 0.6,
1654
+ minAlpha: 0.3,
1655
+ }),
1656
+ color: createColors({
1657
+ color,
1658
+ style: "blocks",
1659
+ inactiveFactor: 0.6,
1660
+ minAlpha: 0.3,
1661
+ }),
1662
+ }
1663
+ })
1664
+
1665
+ const placeholderText = createMemo(() => {
1666
+ if (props.sessionID) return undefined
1667
+ if (store.mode === "shell") {
1668
+ const example = SHELL_PLACEHOLDERS[store.placeholder % SHELL_PLACEHOLDERS.length]
1669
+ return `Run a command... "${example}"`
1670
+ }
1671
+ return `Ask anything... "${PLACEHOLDERS[store.placeholder % PLACEHOLDERS.length]}"`
1672
+ })
1673
+
1674
+ return (
1675
+ <>
1676
+ <Autocomplete
1677
+ sessionID={props.sessionID}
1678
+ ref={(r) => (autocomplete = r)}
1679
+ anchor={() => anchor}
1680
+ input={() => input}
1681
+ setPrompt={(cb) => {
1682
+ setStore("prompt", produce(cb))
1683
+ }}
1684
+ setExtmark={(partIndex, extmarkId) => {
1685
+ setStore("extmarkToPartIndex", (map: Map<number, number>) => {
1686
+ const newMap = new Map(map)
1687
+ newMap.set(extmarkId, partIndex)
1688
+ return newMap
1689
+ })
1690
+ }}
1691
+ value={store.prompt.input}
1692
+ fileStyleId={fileStyleId}
1693
+ agentStyleId={agentStyleId}
1694
+ promptPartTypeId={() => promptPartTypeId}
1695
+ />
1696
+ <box ref={(r) => (anchor = r)} visible={props.visible !== false}>
1697
+ <box
1698
+ border={["left"]}
1699
+ borderColor={highlight()}
1700
+ customBorderChars={{
1701
+ ...EmptyBorder,
1702
+ vertical: "┃",
1703
+ bottomLeft: "╹",
1704
+ }}
1705
+ >
1706
+ <box
1707
+ paddingLeft={2}
1708
+ paddingRight={2}
1709
+ paddingTop={1}
1710
+ flexShrink={0}
1711
+ backgroundColor={theme.backgroundElement}
1712
+ flexGrow={1}
1713
+ >
1714
+ <textarea
1715
+ placeholder={placeholderText()}
1716
+ textColor={keybind.leader ? theme.textMuted : theme.text}
1717
+ focusedTextColor={keybind.leader ? theme.textMuted : theme.text}
1718
+ minHeight={1}
1719
+ maxHeight={6}
1720
+ onContentChange={() => {
1721
+ const value = input.plainText
1722
+ setStore("prompt", "input", value)
1723
+ autocomplete.onInput(value)
1724
+ syncExtmarksWithPromptParts()
1725
+ }}
1726
+ keyBindings={textareaKeybindings()}
1727
+ onKeyDown={async (e) => {
1728
+ if (props.disabled) {
1729
+ e.preventDefault()
1730
+ return
1731
+ }
1732
+ // Handle clipboard paste (Ctrl+V) - check for images first on Windows
1733
+ // This is needed because Windows terminal doesn't properly send image data
1734
+ // through bracketed paste, so we need to intercept the keypress and
1735
+ // directly read from clipboard before the terminal handles it
1736
+ if (keybind.match("input_paste", e)) {
1737
+ const content = await Clipboard.read()
1738
+ if (content?.mime.startsWith("image/")) {
1739
+ e.preventDefault()
1740
+ await pasteImage({
1741
+ filename: "clipboard",
1742
+ mime: content.mime,
1743
+ content: content.data,
1744
+ })
1745
+ return
1746
+ }
1747
+ // If no image, let the default paste behavior continue
1748
+ }
1749
+
1750
+ if (keybind.match("input_clear", e) && store.prompt.input !== "") {
1751
+ input.clear()
1752
+ input.extmarks.clear()
1753
+ setStore("prompt", {
1754
+ input: "",
1755
+ parts: [],
1756
+ })
1757
+ setStore("extmarkToPartIndex", new Map())
1758
+ return
1759
+ }
1760
+ if (keybind.match("app_exit", e)) {
1761
+ if (store.prompt.input === "") {
1762
+ await exit()
1763
+ // Don't preventDefault - let textarea potentially handle the event
1764
+ e.preventDefault()
1765
+ return
1766
+ }
1767
+ }
1768
+
1769
+ // Background subtasks picker (Down arrow when prompt is empty)
1770
+ if (
1771
+ !autocomplete.visible &&
1772
+ store.mode === "normal" &&
1773
+ props.sessionID &&
1774
+ store.prompt.input === "" &&
1775
+ backgroundedSubtaskCount() > 0 &&
1776
+ keybind.match("subtask_picker", e)
1777
+ ) {
1778
+ e.preventDefault()
1779
+ openBackgroundSubtasks()
1780
+ return
1781
+ }
1782
+ if (e.name === "!" && input.visualCursor.offset === 0) {
1783
+ setStore("placeholder", Math.floor(Math.random() * SHELL_PLACEHOLDERS.length))
1784
+ setStore("mode", "shell")
1785
+ e.preventDefault()
1786
+ return
1787
+ }
1788
+ if (store.mode === "shell") {
1789
+ if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") {
1790
+ setStore("mode", "normal")
1791
+ e.preventDefault()
1792
+ return
1793
+ }
1794
+ }
1795
+ if (store.mode === "normal") autocomplete.onKeyDown(e)
1796
+ if (!autocomplete.visible) {
1797
+ if (
1798
+ (keybind.match("history_previous", e) && input.cursorOffset === 0) ||
1799
+ (keybind.match("history_next", e) && input.cursorOffset === input.plainText.length)
1800
+ ) {
1801
+ const direction = keybind.match("history_previous", e) ? -1 : 1
1802
+ const item = history.move(direction, input.plainText)
1803
+
1804
+ if (item) {
1805
+ input.setText(item.input)
1806
+ setStore("prompt", item)
1807
+ setStore("mode", item.mode ?? "normal")
1808
+ restoreExtmarksFromParts(item.parts)
1809
+ e.preventDefault()
1810
+ if (direction === -1) input.cursorOffset = 0
1811
+ if (direction === 1) input.cursorOffset = input.plainText.length
1812
+ }
1813
+ return
1814
+ }
1815
+
1816
+ if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0) input.cursorOffset = 0
1817
+ if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1)
1818
+ input.cursorOffset = input.plainText.length
1819
+
1820
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1821
+ if (keybind.match("voice_record" as any, e)) {
1822
+ e.preventDefault()
1823
+ void handleVoiceButtonDown()
1824
+ return
1825
+ }
1826
+ }
1827
+ }}
1828
+ onSubmit={submit}
1829
+ onPaste={async (event: PasteEvent) => {
1830
+ if (props.disabled) {
1831
+ event.preventDefault()
1832
+ return
1833
+ }
1834
+
1835
+ const text = new TextDecoder().decode(event.bytes)
1836
+
1837
+ // Normalize line endings at the boundary
1838
+ // Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
1839
+ // Replace CRLF first, then any remaining CR
1840
+ const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
1841
+ const pastedContent = normalizedText.trim()
1842
+ if (!pastedContent) {
1843
+ command.trigger("prompt.paste")
1844
+ return
1845
+ }
1846
+
1847
+ // trim ' from the beginning and end of the pasted content. just
1848
+ // ' and nothing else
1849
+ const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ")
1850
+ const isUrl = /^(https?):\/\//.test(filepath)
1851
+ if (!isUrl) {
1852
+ try {
1853
+ const file = Bun.file(filepath)
1854
+ // Handle SVG as raw text content, not as base64 image
1855
+ if (file.type === "image/svg+xml") {
1856
+ event.preventDefault()
1857
+ const content = await file.text().catch(() => {})
1858
+ if (content) {
1859
+ pasteText(content, `[SVG: ${file.name ?? "image"}]`)
1860
+ return
1861
+ }
1862
+ }
1863
+ if (file.type.startsWith("image/")) {
1864
+ event.preventDefault()
1865
+ const content = await file
1866
+ .arrayBuffer()
1867
+ .then((buffer) => Buffer.from(buffer).toString("base64"))
1868
+ .catch(() => {})
1869
+ if (content) {
1870
+ await pasteImage({
1871
+ filename: file.name,
1872
+ mime: file.type,
1873
+ content,
1874
+ })
1875
+ return
1876
+ }
1877
+ }
1878
+ } catch {}
1879
+ }
1880
+
1881
+ const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
1882
+ if (
1883
+ (lineCount >= 3 || pastedContent.length > 150) &&
1884
+ !sync.data.config.experimental?.disable_paste_summary
1885
+ ) {
1886
+ event.preventDefault()
1887
+ pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
1888
+ return
1889
+ }
1890
+
1891
+ // Force layout update and render for the pasted content
1892
+ setTimeout(() => {
1893
+ input.getLayoutNode().markDirty()
1894
+ renderer.requestRender()
1895
+ }, 0)
1896
+ }}
1897
+ ref={(r: TextareaRenderable) => {
1898
+ input = r
1899
+ if (promptPartTypeId === 0) {
1900
+ promptPartTypeId = input.extmarks.registerType("prompt-part")
1901
+ }
1902
+ props.ref?.(ref)
1903
+ setTimeout(() => {
1904
+ input.cursorColor = theme.text
1905
+ }, 0)
1906
+ }}
1907
+ onMouseDown={(r: MouseEvent) => r.target?.focus()}
1908
+ focusedBackgroundColor={theme.backgroundElement}
1909
+ cursorColor={theme.text}
1910
+ syntaxStyle={syntax()}
1911
+ />
1912
+ <box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
1913
+ <Show when={kv.get("show_agent", true)}>
1914
+ <text fg={highlight()}>
1915
+ {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
1916
+ </text>
1917
+ </Show>
1918
+ <Show when={store.mode === "normal" && kv.get("show_model", true)}>
1919
+ <box flexDirection="row" gap={1}>
1920
+ <text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
1921
+ {local.model.parsed().model}
1922
+ </text>
1923
+ <text fg={theme.textMuted}>{local.model.parsed().provider}</text>
1924
+ <Show when={showVariant()}>
1925
+ <text fg={theme.textMuted}>·</text>
1926
+ <text>
1927
+ <span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
1928
+ </text>
1929
+ </Show>
1930
+ </box>
1931
+ </Show>
1932
+ </box>
1933
+ </box>
1934
+ </box>
1935
+ <box
1936
+ height={1}
1937
+ border={["left"]}
1938
+ borderColor={highlight()}
1939
+ customBorderChars={{
1940
+ ...EmptyBorder,
1941
+ vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",
1942
+ }}
1943
+ >
1944
+ <box
1945
+ height={1}
1946
+ border={["bottom"]}
1947
+ borderColor={theme.backgroundElement}
1948
+ customBorderChars={
1949
+ theme.backgroundElement.a !== 0
1950
+ ? {
1951
+ ...EmptyBorder,
1952
+ horizontal: "▀",
1953
+ }
1954
+ : {
1955
+ ...EmptyBorder,
1956
+ horizontal: " ",
1957
+ }
1958
+ }
1959
+ />
1960
+ </box>
1961
+ <box flexDirection="row" justifyContent="space-between">
1962
+ <Show
1963
+ when={status().type !== "idle"}
1964
+ fallback={
1965
+ <box flexDirection="row" gap={2} flexGrow={1}>
1966
+ <Show when={props.sessionID && backgroundedSubtaskCount() > 0}>
1967
+ <box
1968
+ onMouseUp={() => openBackgroundSubtasks()}
1969
+ backgroundColor={theme.primary}
1970
+ paddingLeft={1}
1971
+ paddingRight={1}
1972
+ flexShrink={0}
1973
+ >
1974
+ <text fg={theme.background}>
1975
+ <span style={{ bold: true }}>{backgroundedSubtaskCount()}</span> subtasks
1976
+ </text>
1977
+ </box>
1978
+ </Show>
1979
+ <text fg={theme.text}>
1980
+ esc <span style={{ fg: theme.textMuted }}>interrupt</span>
1981
+ </text>
1982
+ <Show when={sponsoredTip() && kv.get("show_sponsored", true)}>
1983
+ <text fg={theme.warning}>·</text>
1984
+ <text fg={theme.textMuted}>Sponsored:</text>
1985
+ <text fg={theme.text}>
1986
+ <For each={parseTipParts(sponsoredTip()!)}>
1987
+ {(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
1988
+ </For>
1989
+ </text>
1990
+ </Show>
1991
+ </box>
1992
+ }
1993
+ >
1994
+ <box
1995
+ flexDirection="row"
1996
+ gap={1}
1997
+ flexGrow={1}
1998
+ justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
1999
+ >
2000
+ <box flexShrink={0} flexDirection="row" gap={1}>
2001
+ <box marginLeft={1}>
2002
+ <Show
2003
+ when={kv.get("animations_enabled", true) && spinnerDef()}
2004
+ fallback={<text fg={theme.textMuted}>[⋯]</text>}
2005
+ >
2006
+ <spinner color={spinnerDef()!.color} frames={spinnerDef()!.frames} interval={40} />
2007
+ </Show>
2008
+ </box>
2009
+ <box flexDirection="row" gap={1} flexShrink={0}>
2010
+ {(() => {
2011
+ const retry = createMemo(() => {
2012
+ const s = status()
2013
+ if (s.type !== "retry") return
2014
+ return s
2015
+ })
2016
+ const message = createMemo(() => {
2017
+ const r = retry()
2018
+ if (!r) return
2019
+ if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
2020
+ return "gemini is way too hot right now"
2021
+ if (r.message.length > 80) return r.message.slice(0, 80) + "..."
2022
+ return r.message
2023
+ })
2024
+ const isTruncated = createMemo(() => {
2025
+ const r = retry()
2026
+ if (!r) return false
2027
+ return r.message.length > 120
2028
+ })
2029
+ const [seconds, setSeconds] = createSignal(0)
2030
+ onMount(() => {
2031
+ const timer = setInterval(() => {
2032
+ const next = retry()?.next
2033
+ if (next) setSeconds(Math.round((next - Date.now()) / 1000))
2034
+ }, 1000)
2035
+
2036
+ onCleanup(() => {
2037
+ clearInterval(timer)
2038
+ })
2039
+ })
2040
+ const handleMessageClick = () => {
2041
+ const r = retry()
2042
+ if (!r) return
2043
+ if (isTruncated()) {
2044
+ DialogAlert.show(dialog, "Retry Error", r.message)
2045
+ }
2046
+ }
2047
+
2048
+ const retryText = () => {
2049
+ const r = retry()
2050
+ if (!r) return ""
2051
+ const baseMessage = message()
2052
+ const truncatedHint = isTruncated() ? " (click to expand)" : ""
2053
+ const duration = formatDuration(seconds())
2054
+ const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
2055
+ return baseMessage + truncatedHint + retryInfo
2056
+ }
2057
+
2058
+ return (
2059
+ <Show when={retry()}>
2060
+ <box onMouseUp={handleMessageClick}>
2061
+ <text fg={theme.error}>{retryText()}</text>
2062
+ </box>
2063
+ </Show>
2064
+ )
2065
+ })()}
2066
+ </box>
2067
+ </box>
2068
+ <box flexDirection="row" gap={2} flexGrow={1}>
2069
+ <Show when={props.sessionID && backgroundedSubtaskCount() > 0}>
2070
+ <box
2071
+ onMouseUp={() => openBackgroundSubtasks()}
2072
+ backgroundColor={theme.primary}
2073
+ paddingLeft={1}
2074
+ paddingRight={1}
2075
+ flexShrink={0}
2076
+ >
2077
+ <text fg={theme.background}>
2078
+ <span style={{ bold: true }}>{backgroundedSubtaskCount()}</span> subtasks
2079
+ </text>
2080
+ </box>
2081
+ </Show>
2082
+ <text fg={store.interrupt > 0 ? theme.primary : theme.text}>
2083
+ esc{" "}
2084
+ <span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
2085
+ {store.interrupt > 0 ? "again to interrupt" : "interrupt"}
2086
+ </span>
2087
+ </text>
2088
+ <Show when={sponsoredTip() && kv.get("show_sponsored", true)}>
2089
+ <text fg={theme.warning}>·</text>
2090
+ <text fg={theme.textMuted}>Sponsored:</text>
2091
+ <text fg={theme.text}>
2092
+ <For each={parseTipParts(sponsoredTip()!)}>
2093
+ {(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
2094
+ </For>
2095
+ </text>
2096
+ </Show>
2097
+ </box>
2098
+ </box>
2099
+ </Show>
2100
+ <Show when={status().type !== "retry"}>
2101
+ <box gap={2} flexDirection="row">
2102
+ <box
2103
+ onMouseDown={() => {
2104
+ void handleVoiceButtonDown()
2105
+ }}
2106
+ onMouseUp={() => {
2107
+ void handleVoiceButtonUp()
2108
+ }}
2109
+ backgroundColor={theme.error}
2110
+ paddingLeft={1}
2111
+ paddingRight={1}
2112
+ flexShrink={0}
2113
+ >
2114
+ <text fg={theme.background}>
2115
+ <span style={{ bold: voiceStatus() === "recording" }}>
2116
+ {voiceStatus() === "recording"
2117
+ ? "release to send"
2118
+ : voiceStatus() === "transcribing"
2119
+ ? "transcribing..."
2120
+ : (() => {
2121
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2122
+ const shortcut = keybind.print("voice_record" as any)
2123
+ return shortcut ? (
2124
+ <>
2125
+ ⏺ <span style={{ fg: theme.textMuted }}>rec</span>
2126
+ </>
2127
+ ) : (
2128
+ "⏺"
2129
+ )
2130
+ })()}
2131
+ </span>
2132
+ </text>
2133
+ </box>
2134
+
2135
+ <Show when={kv.get("show_shortcuts", true)}>
2136
+ <box gap={2} flexDirection="row">
2137
+ <Switch>
2138
+ <Match when={store.mode === "normal"}>
2139
+ <Show when={local.model.variant.list().length > 0}>
2140
+ <text fg={theme.text}>
2141
+ {keybind.print("variant_cycle")} <span style={{ fg: theme.textMuted }}>variants</span>
2142
+ </text>
2143
+ </Show>
2144
+ <text fg={theme.text}>
2145
+ {keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
2146
+ </text>
2147
+ <text fg={theme.text}>
2148
+ {keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
2149
+ </text>
2150
+ </Match>
2151
+ <Match when={store.mode === "shell"}>
2152
+ <text fg={theme.text}>
2153
+ esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
2154
+ </text>
2155
+ </Match>
2156
+ </Switch>
2157
+ </box>
2158
+ </Show>
2159
+ </box>
2160
+ </Show>
2161
+ </box>
2162
+ </box>
2163
+ </>
2164
+ )
2165
+ }