aicodeman 0.2.9 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (374) hide show
  1. package/README.md +118 -4
  2. package/dist/ai-idle-checker.d.ts.map +1 -1
  3. package/dist/ai-idle-checker.js +3 -2
  4. package/dist/ai-idle-checker.js.map +1 -1
  5. package/dist/ai-plan-checker.d.ts.map +1 -1
  6. package/dist/ai-plan-checker.js +3 -2
  7. package/dist/ai-plan-checker.js.map +1 -1
  8. package/dist/bash-tool-parser.d.ts +2 -3
  9. package/dist/bash-tool-parser.d.ts.map +1 -1
  10. package/dist/bash-tool-parser.js +14 -31
  11. package/dist/bash-tool-parser.js.map +1 -1
  12. package/dist/config/ai-defaults.d.ts +16 -0
  13. package/dist/config/ai-defaults.d.ts.map +1 -0
  14. package/dist/config/ai-defaults.js +16 -0
  15. package/dist/config/ai-defaults.js.map +1 -0
  16. package/dist/config/auth-config.d.ts +19 -0
  17. package/dist/config/auth-config.d.ts.map +1 -0
  18. package/dist/config/auth-config.js +28 -0
  19. package/dist/config/auth-config.js.map +1 -0
  20. package/dist/config/exec-timeout.d.ts +10 -0
  21. package/dist/config/exec-timeout.d.ts.map +1 -0
  22. package/dist/config/exec-timeout.js +10 -0
  23. package/dist/config/exec-timeout.js.map +1 -0
  24. package/dist/config/map-limits.d.ts +4 -0
  25. package/dist/config/map-limits.d.ts.map +1 -1
  26. package/dist/config/map-limits.js +7 -0
  27. package/dist/config/map-limits.js.map +1 -1
  28. package/dist/config/server-timing.d.ts +42 -0
  29. package/dist/config/server-timing.d.ts.map +1 -0
  30. package/dist/config/server-timing.js +57 -0
  31. package/dist/config/server-timing.js.map +1 -0
  32. package/dist/config/team-config.d.ts +16 -0
  33. package/dist/config/team-config.d.ts.map +1 -0
  34. package/dist/config/team-config.js +16 -0
  35. package/dist/config/team-config.js.map +1 -0
  36. package/dist/config/terminal-limits.d.ts +18 -0
  37. package/dist/config/terminal-limits.d.ts.map +1 -0
  38. package/dist/config/terminal-limits.js +18 -0
  39. package/dist/config/terminal-limits.js.map +1 -0
  40. package/dist/config/tunnel-config.d.ts +27 -0
  41. package/dist/config/tunnel-config.d.ts.map +1 -0
  42. package/dist/config/tunnel-config.js +36 -0
  43. package/dist/config/tunnel-config.js.map +1 -0
  44. package/dist/hooks-config.d.ts +21 -6
  45. package/dist/hooks-config.d.ts.map +1 -1
  46. package/dist/hooks-config.js +28 -12
  47. package/dist/hooks-config.js.map +1 -1
  48. package/dist/image-watcher.d.ts +4 -4
  49. package/dist/image-watcher.d.ts.map +1 -1
  50. package/dist/image-watcher.js +17 -30
  51. package/dist/image-watcher.js.map +1 -1
  52. package/dist/index.js +1 -2
  53. package/dist/index.js.map +1 -1
  54. package/dist/plan-orchestrator.d.ts +2 -24
  55. package/dist/plan-orchestrator.d.ts.map +1 -1
  56. package/dist/plan-orchestrator.js.map +1 -1
  57. package/dist/prompts/planner.d.ts +7 -8
  58. package/dist/prompts/planner.d.ts.map +1 -1
  59. package/dist/prompts/planner.js +7 -8
  60. package/dist/prompts/planner.js.map +1 -1
  61. package/dist/prompts/research-agent.d.ts +6 -4
  62. package/dist/prompts/research-agent.d.ts.map +1 -1
  63. package/dist/prompts/research-agent.js +6 -4
  64. package/dist/prompts/research-agent.js.map +1 -1
  65. package/dist/push-store.d.ts +1 -1
  66. package/dist/push-store.d.ts.map +1 -1
  67. package/dist/push-store.js +4 -12
  68. package/dist/push-store.js.map +1 -1
  69. package/dist/ralph-fix-plan-watcher.d.ts +91 -0
  70. package/dist/ralph-fix-plan-watcher.d.ts.map +1 -0
  71. package/dist/ralph-fix-plan-watcher.js +326 -0
  72. package/dist/ralph-fix-plan-watcher.js.map +1 -0
  73. package/dist/ralph-loop.d.ts +14 -4
  74. package/dist/ralph-loop.d.ts.map +1 -1
  75. package/dist/ralph-loop.js +14 -4
  76. package/dist/ralph-loop.js.map +1 -1
  77. package/dist/ralph-plan-tracker.d.ts +201 -0
  78. package/dist/ralph-plan-tracker.d.ts.map +1 -0
  79. package/dist/ralph-plan-tracker.js +325 -0
  80. package/dist/ralph-plan-tracker.js.map +1 -0
  81. package/dist/ralph-stall-detector.d.ts +84 -0
  82. package/dist/ralph-stall-detector.d.ts.map +1 -0
  83. package/dist/ralph-stall-detector.js +139 -0
  84. package/dist/ralph-stall-detector.js.map +1 -0
  85. package/dist/ralph-status-parser.d.ts +141 -0
  86. package/dist/ralph-status-parser.d.ts.map +1 -0
  87. package/dist/ralph-status-parser.js +478 -0
  88. package/dist/ralph-status-parser.js.map +1 -0
  89. package/dist/ralph-tracker.d.ts +218 -692
  90. package/dist/ralph-tracker.d.ts.map +1 -1
  91. package/dist/ralph-tracker.js +389 -1723
  92. package/dist/ralph-tracker.js.map +1 -1
  93. package/dist/respawn-adaptive-timing.d.ts +61 -0
  94. package/dist/respawn-adaptive-timing.d.ts.map +1 -0
  95. package/dist/respawn-adaptive-timing.js +105 -0
  96. package/dist/respawn-adaptive-timing.js.map +1 -0
  97. package/dist/respawn-controller.d.ts +35 -115
  98. package/dist/respawn-controller.d.ts.map +1 -1
  99. package/dist/respawn-controller.js +167 -607
  100. package/dist/respawn-controller.js.map +1 -1
  101. package/dist/respawn-health.d.ts +54 -0
  102. package/dist/respawn-health.d.ts.map +1 -0
  103. package/dist/respawn-health.js +183 -0
  104. package/dist/respawn-health.js.map +1 -0
  105. package/dist/respawn-metrics.d.ts +81 -0
  106. package/dist/respawn-metrics.d.ts.map +1 -0
  107. package/dist/respawn-metrics.js +198 -0
  108. package/dist/respawn-metrics.js.map +1 -0
  109. package/dist/respawn-patterns.d.ts +45 -0
  110. package/dist/respawn-patterns.d.ts.map +1 -0
  111. package/dist/respawn-patterns.js +125 -0
  112. package/dist/respawn-patterns.js.map +1 -0
  113. package/dist/session-auto-ops.d.ts +89 -0
  114. package/dist/session-auto-ops.d.ts.map +1 -0
  115. package/dist/session-auto-ops.js +224 -0
  116. package/dist/session-auto-ops.js.map +1 -0
  117. package/dist/session-cli-builder.d.ts +62 -0
  118. package/dist/session-cli-builder.d.ts.map +1 -0
  119. package/dist/session-cli-builder.js +121 -0
  120. package/dist/session-cli-builder.js.map +1 -0
  121. package/dist/session-manager.d.ts +17 -5
  122. package/dist/session-manager.d.ts.map +1 -1
  123. package/dist/session-manager.js +17 -5
  124. package/dist/session-manager.js.map +1 -1
  125. package/dist/session-task-cache.d.ts +52 -0
  126. package/dist/session-task-cache.d.ts.map +1 -0
  127. package/dist/session-task-cache.js +90 -0
  128. package/dist/session-task-cache.js.map +1 -0
  129. package/dist/session.d.ts +23 -41
  130. package/dist/session.d.ts.map +1 -1
  131. package/dist/session.js +79 -317
  132. package/dist/session.js.map +1 -1
  133. package/dist/state-store.d.ts +19 -9
  134. package/dist/state-store.d.ts.map +1 -1
  135. package/dist/state-store.js +29 -30
  136. package/dist/state-store.js.map +1 -1
  137. package/dist/subagent-watcher.d.ts +26 -7
  138. package/dist/subagent-watcher.d.ts.map +1 -1
  139. package/dist/subagent-watcher.js +47 -64
  140. package/dist/subagent-watcher.js.map +1 -1
  141. package/dist/team-watcher.d.ts.map +1 -1
  142. package/dist/team-watcher.js +2 -5
  143. package/dist/team-watcher.js.map +1 -1
  144. package/dist/tmux-manager.d.ts.map +1 -1
  145. package/dist/tmux-manager.js +1 -2
  146. package/dist/tmux-manager.js.map +1 -1
  147. package/dist/tunnel-manager.d.ts +26 -0
  148. package/dist/tunnel-manager.d.ts.map +1 -1
  149. package/dist/tunnel-manager.js +126 -7
  150. package/dist/tunnel-manager.js.map +1 -1
  151. package/dist/types/api.d.ts +108 -0
  152. package/dist/types/api.d.ts.map +1 -0
  153. package/dist/types/api.js +98 -0
  154. package/dist/types/api.js.map +1 -0
  155. package/dist/types/app-state.d.ts +117 -0
  156. package/dist/types/app-state.d.ts.map +1 -0
  157. package/dist/types/app-state.js +76 -0
  158. package/dist/types/app-state.js.map +1 -0
  159. package/dist/types/common.d.ts +79 -0
  160. package/dist/types/common.d.ts.map +1 -0
  161. package/dist/types/common.js +17 -0
  162. package/dist/types/common.js.map +1 -0
  163. package/dist/types/index.d.ts +66 -0
  164. package/dist/types/index.d.ts.map +1 -0
  165. package/dist/types/index.js +66 -0
  166. package/dist/types/index.js.map +1 -0
  167. package/dist/types/lifecycle.d.ts +28 -0
  168. package/dist/types/lifecycle.d.ts.map +1 -0
  169. package/dist/types/lifecycle.js +16 -0
  170. package/dist/types/lifecycle.js.map +1 -0
  171. package/dist/types/plan.d.ts +45 -0
  172. package/dist/types/plan.d.ts.map +1 -0
  173. package/dist/types/plan.js +18 -0
  174. package/dist/types/plan.js.map +1 -0
  175. package/dist/types/push.d.ts +36 -0
  176. package/dist/types/push.d.ts.map +1 -0
  177. package/dist/types/push.js +18 -0
  178. package/dist/types/push.js.map +1 -0
  179. package/dist/types/ralph.d.ts +262 -0
  180. package/dist/types/ralph.d.ts.map +1 -0
  181. package/dist/types/ralph.js +70 -0
  182. package/dist/types/ralph.js.map +1 -0
  183. package/dist/types/respawn.d.ts +271 -0
  184. package/dist/types/respawn.d.ts.map +1 -0
  185. package/dist/types/respawn.js +26 -0
  186. package/dist/types/respawn.js.map +1 -0
  187. package/dist/types/run-summary.d.ts +96 -0
  188. package/dist/types/run-summary.d.ts.map +1 -0
  189. package/dist/types/run-summary.js +37 -0
  190. package/dist/types/run-summary.js.map +1 -0
  191. package/dist/types/session.d.ts +152 -0
  192. package/dist/types/session.d.ts.map +1 -0
  193. package/dist/types/session.js +27 -0
  194. package/dist/types/session.js.map +1 -0
  195. package/dist/types/task.d.ts +72 -0
  196. package/dist/types/task.d.ts.map +1 -0
  197. package/dist/types/task.js +19 -0
  198. package/dist/types/task.js.map +1 -0
  199. package/dist/types/teams.d.ts +73 -0
  200. package/dist/types/teams.d.ts.map +1 -0
  201. package/dist/types/teams.js +23 -0
  202. package/dist/types/teams.js.map +1 -0
  203. package/dist/types/tools.d.ts +61 -0
  204. package/dist/types/tools.d.ts.map +1 -0
  205. package/dist/types/tools.js +20 -0
  206. package/dist/types/tools.js.map +1 -0
  207. package/dist/types.d.ts +8 -1134
  208. package/dist/types.d.ts.map +1 -1
  209. package/dist/types.js +8 -210
  210. package/dist/types.js.map +1 -1
  211. package/dist/utils/claude-cli-resolver.d.ts.map +1 -1
  212. package/dist/utils/claude-cli-resolver.js +1 -2
  213. package/dist/utils/claude-cli-resolver.js.map +1 -1
  214. package/dist/utils/debouncer.d.ts +111 -0
  215. package/dist/utils/debouncer.d.ts.map +1 -0
  216. package/dist/utils/debouncer.js +162 -0
  217. package/dist/utils/debouncer.js.map +1 -0
  218. package/dist/utils/index.d.ts +3 -2
  219. package/dist/utils/index.d.ts.map +1 -1
  220. package/dist/utils/index.js +3 -2
  221. package/dist/utils/index.js.map +1 -1
  222. package/dist/utils/opencode-cli-resolver.d.ts.map +1 -1
  223. package/dist/utils/opencode-cli-resolver.js +1 -2
  224. package/dist/utils/opencode-cli-resolver.js.map +1 -1
  225. package/dist/utils/string-similarity.d.ts +0 -57
  226. package/dist/utils/string-similarity.d.ts.map +1 -1
  227. package/dist/utils/string-similarity.js +3 -18
  228. package/dist/utils/string-similarity.js.map +1 -1
  229. package/dist/web/middleware/auth.d.ts +31 -0
  230. package/dist/web/middleware/auth.d.ts.map +1 -0
  231. package/dist/web/middleware/auth.js +154 -0
  232. package/dist/web/middleware/auth.js.map +1 -0
  233. package/dist/web/ports/auth-port.d.ts +18 -0
  234. package/dist/web/ports/auth-port.d.ts.map +1 -0
  235. package/dist/web/ports/auth-port.js +6 -0
  236. package/dist/web/ports/auth-port.js.map +1 -0
  237. package/dist/web/ports/config-port.d.ts +28 -0
  238. package/dist/web/ports/config-port.d.ts.map +1 -0
  239. package/dist/web/ports/config-port.js +6 -0
  240. package/dist/web/ports/config-port.js.map +1 -0
  241. package/dist/web/ports/event-port.d.ts +13 -0
  242. package/dist/web/ports/event-port.d.ts.map +1 -0
  243. package/dist/web/ports/event-port.js +6 -0
  244. package/dist/web/ports/event-port.js.map +1 -0
  245. package/dist/web/ports/index.d.ts +14 -0
  246. package/dist/web/ports/index.d.ts.map +1 -0
  247. package/dist/web/ports/index.js +9 -0
  248. package/dist/web/ports/index.js.map +1 -0
  249. package/dist/web/ports/infra-port.d.ts +36 -0
  250. package/dist/web/ports/infra-port.d.ts.map +1 -0
  251. package/dist/web/ports/infra-port.js +6 -0
  252. package/dist/web/ports/infra-port.js.map +1 -0
  253. package/dist/web/ports/respawn-port.d.ts +20 -0
  254. package/dist/web/ports/respawn-port.d.ts.map +1 -0
  255. package/dist/web/ports/respawn-port.js +6 -0
  256. package/dist/web/ports/respawn-port.js.map +1 -0
  257. package/dist/web/ports/session-port.d.ts +15 -0
  258. package/dist/web/ports/session-port.d.ts.map +1 -0
  259. package/dist/web/ports/session-port.js +6 -0
  260. package/dist/web/ports/session-port.js.map +1 -0
  261. package/dist/web/public/api-client.js +82 -0
  262. package/dist/web/public/api-client.js.br +0 -0
  263. package/dist/web/public/api-client.js.gz +0 -0
  264. package/dist/web/public/app.js +117 -201
  265. package/dist/web/public/app.js.br +0 -0
  266. package/dist/web/public/app.js.gz +0 -0
  267. package/dist/web/public/constants.js +365 -0
  268. package/dist/web/public/constants.js.br +0 -0
  269. package/dist/web/public/constants.js.gz +0 -0
  270. package/dist/web/public/index.html +15 -3
  271. package/dist/web/public/index.html.br +0 -0
  272. package/dist/web/public/index.html.gz +0 -0
  273. package/dist/web/public/keyboard-accessory.js +302 -0
  274. package/dist/web/public/keyboard-accessory.js.br +0 -0
  275. package/dist/web/public/keyboard-accessory.js.gz +0 -0
  276. package/dist/web/public/mobile-handlers.js +491 -0
  277. package/dist/web/public/mobile-handlers.js.br +0 -0
  278. package/dist/web/public/mobile-handlers.js.gz +0 -0
  279. package/dist/web/public/mobile.css.gz +0 -0
  280. package/dist/web/public/notification-manager.js +472 -0
  281. package/dist/web/public/notification-manager.js.br +0 -0
  282. package/dist/web/public/notification-manager.js.gz +0 -0
  283. package/dist/web/public/ralph-wizard.js +33 -9
  284. package/dist/web/public/ralph-wizard.js.br +0 -0
  285. package/dist/web/public/ralph-wizard.js.gz +0 -0
  286. package/dist/web/public/styles.css.gz +0 -0
  287. package/dist/web/public/subagent-windows.js +1149 -0
  288. package/dist/web/public/subagent-windows.js.br +0 -0
  289. package/dist/web/public/subagent-windows.js.gz +0 -0
  290. package/dist/web/public/sw.js +15 -0
  291. package/dist/web/public/sw.js.br +0 -0
  292. package/dist/web/public/sw.js.gz +0 -0
  293. package/dist/web/public/upload.html.gz +0 -0
  294. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  295. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  296. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  297. package/dist/web/public/vendor/xterm-zerolag-input.js +4 -0
  298. package/dist/web/public/vendor/xterm-zerolag-input.js.br +0 -0
  299. package/dist/web/public/vendor/xterm-zerolag-input.js.gz +0 -0
  300. package/dist/web/public/vendor/xterm.css.gz +0 -0
  301. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  302. package/dist/web/public/voice-input.js +882 -0
  303. package/dist/web/public/voice-input.js.br +0 -0
  304. package/dist/web/public/voice-input.js.gz +0 -0
  305. package/dist/web/route-helpers.d.ts +38 -0
  306. package/dist/web/route-helpers.d.ts.map +1 -0
  307. package/dist/web/route-helpers.js +144 -0
  308. package/dist/web/route-helpers.js.map +1 -0
  309. package/dist/web/routes/case-routes.d.ts +9 -0
  310. package/dist/web/routes/case-routes.d.ts.map +1 -0
  311. package/dist/web/routes/case-routes.js +426 -0
  312. package/dist/web/routes/case-routes.js.map +1 -0
  313. package/dist/web/routes/file-routes.d.ts +8 -0
  314. package/dist/web/routes/file-routes.d.ts.map +1 -0
  315. package/dist/web/routes/file-routes.js +337 -0
  316. package/dist/web/routes/file-routes.js.map +1 -0
  317. package/dist/web/routes/hook-event-routes.d.ts +9 -0
  318. package/dist/web/routes/hook-event-routes.d.ts.map +1 -0
  319. package/dist/web/routes/hook-event-routes.js +57 -0
  320. package/dist/web/routes/hook-event-routes.js.map +1 -0
  321. package/dist/web/routes/index.d.ts +16 -0
  322. package/dist/web/routes/index.d.ts.map +1 -0
  323. package/dist/web/routes/index.js +16 -0
  324. package/dist/web/routes/index.js.map +1 -0
  325. package/dist/web/routes/mux-routes.d.ts +8 -0
  326. package/dist/web/routes/mux-routes.d.ts.map +1 -0
  327. package/dist/web/routes/mux-routes.js +32 -0
  328. package/dist/web/routes/mux-routes.js.map +1 -0
  329. package/dist/web/routes/plan-routes.d.ts +9 -0
  330. package/dist/web/routes/plan-routes.d.ts.map +1 -0
  331. package/dist/web/routes/plan-routes.js +385 -0
  332. package/dist/web/routes/plan-routes.js.map +1 -0
  333. package/dist/web/routes/push-routes.d.ts +8 -0
  334. package/dist/web/routes/push-routes.d.ts.map +1 -0
  335. package/dist/web/routes/push-routes.js +49 -0
  336. package/dist/web/routes/push-routes.js.map +1 -0
  337. package/dist/web/routes/ralph-routes.d.ts +9 -0
  338. package/dist/web/routes/ralph-routes.d.ts.map +1 -0
  339. package/dist/web/routes/ralph-routes.js +485 -0
  340. package/dist/web/routes/ralph-routes.js.map +1 -0
  341. package/dist/web/routes/respawn-routes.d.ts +8 -0
  342. package/dist/web/routes/respawn-routes.d.ts.map +1 -0
  343. package/dist/web/routes/respawn-routes.js +270 -0
  344. package/dist/web/routes/respawn-routes.js.map +1 -0
  345. package/dist/web/routes/scheduled-routes.d.ts +8 -0
  346. package/dist/web/routes/scheduled-routes.d.ts.map +1 -0
  347. package/dist/web/routes/scheduled-routes.js +51 -0
  348. package/dist/web/routes/scheduled-routes.js.map +1 -0
  349. package/dist/web/routes/session-routes.d.ts +9 -0
  350. package/dist/web/routes/session-routes.d.ts.map +1 -0
  351. package/dist/web/routes/session-routes.js +751 -0
  352. package/dist/web/routes/session-routes.js.map +1 -0
  353. package/dist/web/routes/system-routes.d.ts +9 -0
  354. package/dist/web/routes/system-routes.d.ts.map +1 -0
  355. package/dist/web/routes/system-routes.js +699 -0
  356. package/dist/web/routes/system-routes.js.map +1 -0
  357. package/dist/web/routes/team-routes.d.ts +8 -0
  358. package/dist/web/routes/team-routes.d.ts.map +1 -0
  359. package/dist/web/routes/team-routes.js +14 -0
  360. package/dist/web/routes/team-routes.js.map +1 -0
  361. package/dist/web/schemas.d.ts +43 -3
  362. package/dist/web/schemas.d.ts.map +1 -1
  363. package/dist/web/schemas.js +6 -2
  364. package/dist/web/schemas.js.map +1 -1
  365. package/dist/web/server.d.ts +35 -15
  366. package/dist/web/server.d.ts.map +1 -1
  367. package/dist/web/server.js +563 -3971
  368. package/dist/web/server.js.map +1 -1
  369. package/dist/web/sse-events.d.ts +361 -0
  370. package/dist/web/sse-events.d.ts.map +1 -0
  371. package/dist/web/sse-events.js +396 -0
  372. package/dist/web/sse-events.js.map +1 -0
  373. package/package.json +2 -1
  374. package/scripts/postinstall.js +58 -0
@@ -0,0 +1,882 @@
1
+ /**
2
+ * @fileoverview Voice input with Deepgram Nova-3 (primary) and Web Speech API (fallback).
3
+ *
4
+ * Defines two singleton objects:
5
+ *
6
+ * - DeepgramProvider — Direct browser-to-Deepgram WebSocket connection for speech-to-text.
7
+ * Captures audio via MediaRecorder, streams chunks every 250ms, handles KeepAlive pings,
8
+ * auto-detects MIME type (opus/webm/mp4), and supports custom key terms for dev vocabulary.
9
+ *
10
+ * - VoiceInput — High-level voice input controller. Toggle mode: tap mic to start, tap
11
+ * again to stop. Auto-stops after 3s silence. Shows floating preview overlay with recording
12
+ * indicator, level meter (AnalyserNode), and elapsed timer. Two insert modes: "direct"
13
+ * (inject into local echo overlay or PTY) and "compose" (editable textarea overlay).
14
+ * Includes a temporary green Send button that replaces the settings gear icon after voice input.
15
+ * Web Speech API has auto-retry (up to 2x) for premature onend and iOS Safari stability check.
16
+ *
17
+ * @globals {object} DeepgramProvider
18
+ * @globals {object} VoiceInput
19
+ *
20
+ * @dependency mobile-handlers.js (MobileDetection for device checks)
21
+ * @dependency app.js (uses global `app` for sendInput, showToast, terminal focus)
22
+ * @loadorder 3 of 9 — loaded after mobile-handlers.js, before notification-manager.js
23
+ */
24
+
25
+ // Codeman — Voice input with Deepgram Nova-3 and Web Speech API fallback
26
+ // Loaded after mobile-handlers.js, before app.js
27
+
28
+ // ═══════════════════════════════════════════════════════════════
29
+ // Voice Input (Deepgram Nova-3 + Web Speech API fallback)
30
+ // ═══════════════════════════════════════════════════════════════
31
+
32
+ /**
33
+ * DeepgramProvider - Speech-to-text via Deepgram Nova-3 WebSocket API.
34
+ * Direct browser-to-Deepgram connection (no server proxy).
35
+ * Uses MediaRecorder to capture audio and streams via WebSocket.
36
+ */
37
+ const DeepgramProvider = {
38
+ _ws: null,
39
+ _mediaRecorder: null,
40
+ _stream: null,
41
+ _silenceTimeout: null,
42
+ _keepAliveInterval: null,
43
+ _onResult: null,
44
+ _onError: null,
45
+ _onEnd: null,
46
+
47
+ /**
48
+ * Start streaming audio to Deepgram.
49
+ * @param {object} opts - { apiKey, language, keyterms[], onResult(text, isFinal), onError(msg), onEnd(), onStream(stream) }
50
+ */
51
+ async start(opts) {
52
+ this._onResult = opts.onResult;
53
+ this._onError = opts.onError;
54
+ this._onEnd = opts.onEnd;
55
+
56
+ // 1. Get microphone access
57
+ if (!navigator.mediaDevices?.getUserMedia) {
58
+ this._onError?.('Microphone requires a secure context (HTTPS). Use --https flag or access via localhost.');
59
+ this._cleanup();
60
+ return;
61
+ }
62
+ try {
63
+ this._stream = await navigator.mediaDevices.getUserMedia({
64
+ audio: { noiseSuppression: true, echoCancellation: true, autoGainControl: true }
65
+ });
66
+ } catch (err) {
67
+ const msg = err.name === 'NotAllowedError'
68
+ ? 'Microphone access denied. Check browser settings.'
69
+ : 'Microphone error: ' + err.message;
70
+ this._onError?.(msg);
71
+ this._cleanup();
72
+ return;
73
+ }
74
+ // Notify caller so it can set up audio level meter
75
+ opts.onStream?.(this._stream);
76
+
77
+ // 2. Detect best supported MIME type for MediaRecorder
78
+ const mimeTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4'];
79
+ this._selectedMime = null;
80
+ for (const mt of mimeTypes) {
81
+ if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(mt)) {
82
+ this._selectedMime = mt;
83
+ break;
84
+ }
85
+ }
86
+
87
+ // 3. Build WebSocket URL (no encoding param — Deepgram auto-detects from container format)
88
+
89
+ const params = new URLSearchParams({
90
+ model: 'nova-3',
91
+ smart_format: 'false',
92
+ punctuate: 'false',
93
+ interim_results: 'true',
94
+ utterance_end_ms: '1500',
95
+ vad_events: 'true',
96
+ });
97
+ if (opts.language && opts.language !== 'multi') {
98
+ params.set('language', opts.language);
99
+ } else if (opts.language === 'multi') {
100
+ params.set('detect_language', 'true');
101
+ }
102
+ if (opts.keyterms?.length) {
103
+ for (const term of opts.keyterms) {
104
+ const trimmed = term.trim();
105
+ if (trimmed) params.append('keyterm', trimmed + ':2');
106
+ }
107
+ }
108
+
109
+ // 4. Connect WebSocket (trim API key to avoid whitespace auth failures)
110
+ const apiKey = (opts.apiKey || '').trim();
111
+ if (!apiKey) {
112
+ this._onError?.('No Deepgram API key configured. Add one in Settings > Voice.');
113
+ this._cleanup();
114
+ return;
115
+ }
116
+ const wsUrl = `wss://api.deepgram.com/v1/listen?${params}`;
117
+ try {
118
+ this._ws = new WebSocket(wsUrl, ['token', apiKey]);
119
+ } catch (err) {
120
+ this._onError?.('Failed to connect to Deepgram: ' + err.message);
121
+ this._cleanup();
122
+ return;
123
+ }
124
+
125
+ this._ws.onopen = () => {
126
+ // 5. Send KeepAlive every 8s to prevent Deepgram from closing idle connections
127
+ // (covers the gap before MediaRecorder produces its first chunk)
128
+ this._keepAliveInterval = setInterval(() => {
129
+ if (this._ws?.readyState === WebSocket.OPEN) {
130
+ try { this._ws.send(JSON.stringify({ type: 'KeepAlive' })); } catch (_e) { /* ignore */ }
131
+ }
132
+ }, 8000);
133
+ // 6. Start MediaRecorder once connected
134
+ this._startRecording();
135
+ };
136
+
137
+ this._ws.onmessage = (event) => {
138
+ try {
139
+ const data = JSON.parse(event.data);
140
+ if (data.type === 'Results' && data.channel?.alternatives?.[0]) {
141
+ const alt = data.channel.alternatives[0];
142
+ const transcript = alt.transcript || '';
143
+ if (transcript) {
144
+ const isFinal = data.is_final === true;
145
+ this._onResult?.(transcript, isFinal);
146
+ this._resetSilenceTimeout();
147
+ }
148
+ }
149
+ } catch (_e) {
150
+ // Ignore parse errors for non-JSON messages
151
+ }
152
+ };
153
+
154
+ this._ws.onerror = () => {
155
+ // WebSocket onerror doesn't carry useful info — onclose handles it
156
+ };
157
+
158
+ this._ws.onclose = (event) => {
159
+ clearInterval(this._keepAliveInterval);
160
+ this._keepAliveInterval = null;
161
+ if (event.code === 1008) {
162
+ this._onError?.('Authentication failed. Check your Deepgram API key in Settings > Voice.');
163
+ } else if (event.code === 1006) {
164
+ // 1006 = abnormal closure (no close frame). Usually auth failure, expired key, or no credits.
165
+ this._onError?.('Deepgram connection failed (1006). Check your API key is valid and has credits in Settings > Voice.');
166
+ } else if (event.code !== 1000) {
167
+ this._onError?.('Deepgram connection closed: ' + (event.reason || `code ${event.code}`));
168
+ }
169
+ this._stopRecording();
170
+ this._onEnd?.();
171
+ };
172
+ },
173
+
174
+ _startRecording() {
175
+ if (!this._stream || !this._ws || this._ws.readyState !== WebSocket.OPEN) return;
176
+
177
+ const recorderOpts = this._selectedMime ? { mimeType: this._selectedMime } : {};
178
+ try {
179
+ this._mediaRecorder = new MediaRecorder(this._stream, recorderOpts);
180
+ } catch (err) {
181
+ this._onError?.('MediaRecorder failed: ' + err.message);
182
+ this._cleanup();
183
+ return;
184
+ }
185
+
186
+ this._mediaRecorder.ondataavailable = (event) => {
187
+ if (event.data.size > 0 && this._ws?.readyState === WebSocket.OPEN) {
188
+ this._ws.send(event.data);
189
+ }
190
+ };
191
+
192
+ this._mediaRecorder.start(250); // Send chunks every 250ms
193
+ this._resetSilenceTimeout();
194
+ },
195
+
196
+ _stopRecording() {
197
+ if (this._mediaRecorder && this._mediaRecorder.state !== 'inactive') {
198
+ try { this._mediaRecorder.stop(); } catch (_e) { /* already stopped */ }
199
+ }
200
+ // Stop all mic tracks
201
+ if (this._stream) {
202
+ this._stream.getTracks().forEach(t => t.stop());
203
+ }
204
+ },
205
+
206
+ _resetSilenceTimeout() {
207
+ clearTimeout(this._silenceTimeout);
208
+ this._silenceTimeout = setTimeout(() => {
209
+ this.stop();
210
+ }, 3000);
211
+ },
212
+
213
+ stop() {
214
+ clearTimeout(this._silenceTimeout);
215
+ this._silenceTimeout = null;
216
+ clearInterval(this._keepAliveInterval);
217
+ this._keepAliveInterval = null;
218
+ this._stopRecording();
219
+ // Detach WS handlers before closing to prevent stale onclose from
220
+ // killing a subsequent recording that starts before the close completes
221
+ if (this._ws) {
222
+ this._ws.onclose = null;
223
+ this._ws.onmessage = null;
224
+ this._ws.onerror = null;
225
+ if (this._ws.readyState === WebSocket.OPEN) {
226
+ try { this._ws.close(1000); } catch (_e) { /* ignore */ }
227
+ }
228
+ this._ws = null;
229
+ }
230
+ // Save onEnd before nulling — must notify VoiceInput when silence timeout
231
+ // triggers stop internally (VoiceInput.onEnd guards with isRecording check)
232
+ const onEnd = this._onEnd;
233
+ this._onResult = null;
234
+ this._onError = null;
235
+ this._onEnd = null;
236
+ onEnd?.();
237
+ },
238
+
239
+ _cleanup() {
240
+ this.stop();
241
+ this._mediaRecorder = null;
242
+ this._stream = null;
243
+ this._selectedMime = null;
244
+ }
245
+ };
246
+
247
+ /**
248
+ * VoiceInput - Speech-to-text with Deepgram Nova-3 (primary) and Web Speech API (fallback).
249
+ * Toggle mode: tap mic to start, tap again to stop. Auto-stops after silence.
250
+ * Shows interim transcription in a floating preview overlay.
251
+ * Inserts final text into the active session (user presses Enter to submit).
252
+ */
253
+ const VoiceInput = {
254
+ recognition: null,
255
+ isRecording: false,
256
+ supported: false,
257
+ silenceTimeout: null,
258
+ previewEl: null,
259
+ _lastTranscript: '',
260
+ _stabilityTimer: null,
261
+ _accumulatedFinal: '',
262
+ _activeProvider: null, // 'deepgram' | 'webspeech' | null
263
+ _recordingStartedAt: 0, // timestamp when recording started
264
+ _retryCount: 0, // auto-retry counter for premature Web Speech API ends
265
+ _hasReceivedResult: false, // whether any speech result came in this session
266
+ _durationInterval: null, // timer for updating elapsed time display
267
+ _analyser: null, // AudioContext analyser for level meter
268
+ _analyserSource: null, // MediaStreamSource for level meter
269
+ _audioContext: null, // AudioContext for level meter
270
+ _levelAnimFrame: null, // rAF handle for level meter
271
+
272
+ init() {
273
+ this._initRecognition();
274
+ // Always show buttons — if unsupported, toggle() shows a toast
275
+ this._showButtons();
276
+ },
277
+
278
+ // --- Deepgram config (localStorage only, never sent to server) ---
279
+
280
+ _getDeepgramConfig() {
281
+ try {
282
+ return JSON.parse(localStorage.getItem('codeman-voice-settings') || '{}');
283
+ } catch (_e) {
284
+ return {};
285
+ }
286
+ },
287
+
288
+ _saveDeepgramConfig(config) {
289
+ localStorage.setItem('codeman-voice-settings', JSON.stringify(config));
290
+ },
291
+
292
+ _shouldUseDeepgram() {
293
+ const cfg = this._getDeepgramConfig();
294
+ return !!(cfg.apiKey && cfg.apiKey.trim());
295
+ },
296
+
297
+ /** Get the active provider name for display */
298
+ getActiveProviderName() {
299
+ if (this._shouldUseDeepgram()) return 'Deepgram Nova-3';
300
+ if (this.supported) return 'Web Speech API';
301
+ return 'None';
302
+ },
303
+
304
+ /** Try to create a SpeechRecognition instance */
305
+ _initRecognition() {
306
+ const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
307
+ this.supported = !!SR;
308
+ if (!this.supported) return;
309
+
310
+ this.recognition = new SR();
311
+ this.recognition.continuous = true;
312
+ this.recognition.interimResults = true;
313
+ this.recognition.lang = 'en-US';
314
+ this.recognition.maxAlternatives = 1;
315
+
316
+ this.recognition.onresult = (e) => this._onWebSpeechResult(e);
317
+ this.recognition.onerror = (e) => this._onWebSpeechError(e);
318
+ this.recognition.onend = () => this._onWebSpeechEnd();
319
+ },
320
+
321
+ toggle() {
322
+ if (this.isRecording) {
323
+ this.stop();
324
+ } else {
325
+ this.start();
326
+ }
327
+ },
328
+
329
+ start() {
330
+ if (this.isRecording) return;
331
+ if (!app.activeSessionId) {
332
+ app.showToast('No active session', 'warning');
333
+ return;
334
+ }
335
+ this._retryCount = 0;
336
+
337
+ if (this._shouldUseDeepgram()) {
338
+ this._startDeepgram();
339
+ } else {
340
+ this._startWebSpeech();
341
+ }
342
+ },
343
+
344
+ _startDeepgram() {
345
+ const cfg = this._getDeepgramConfig();
346
+ this.isRecording = true;
347
+ this._activeProvider = 'deepgram';
348
+ this._accumulatedFinal = '';
349
+ this._lastTranscript = '';
350
+ this._hasReceivedResult = false;
351
+ this._recordingStartedAt = Date.now();
352
+ this._updateButtons('recording');
353
+ this._showPreview('Listening...', 'deepgram');
354
+ this._startDurationTimer();
355
+
356
+ const keyterms = (cfg.keyterms || 'refactor, endpoint, middleware, callback, async, regex, TypeScript, npm, API, deploy, config, linter, env, webhook, schema, CLI, JSON, CSS, DOM, SSE, backend, frontend, localhost, dependencies, repository, merge, rebase, diff, commit, com')
357
+ .split(',').map(t => t.trim()).filter(Boolean);
358
+
359
+ DeepgramProvider.start({
360
+ apiKey: cfg.apiKey,
361
+ language: cfg.language || 'en-US',
362
+ keyterms,
363
+ onStream: (stream) => {
364
+ this._startLevelMeter(stream);
365
+ },
366
+ onResult: (text, isFinal) => {
367
+ if (!this.isRecording) return;
368
+ this._hasReceivedResult = true;
369
+ if (isFinal) {
370
+ this._accumulatedFinal += text;
371
+ this._hidePreview();
372
+ this._insertText(this._accumulatedFinal);
373
+ this.stop();
374
+ } else {
375
+ const display = this._accumulatedFinal + text;
376
+ this._showPreview(display, 'deepgram');
377
+ }
378
+ },
379
+ onError: (msg) => {
380
+ const wasRecording = this.isRecording;
381
+ this.stop();
382
+ if (wasRecording) app.showToast(msg, 'error');
383
+ },
384
+ onEnd: () => {
385
+ if (this.isRecording) {
386
+ if (this._accumulatedFinal) {
387
+ this._insertText(this._accumulatedFinal);
388
+ }
389
+ this.stop();
390
+ }
391
+ }
392
+ });
393
+
394
+ // Haptic feedback on mobile
395
+ if (navigator.vibrate) navigator.vibrate(50);
396
+ },
397
+
398
+ _startWebSpeech() {
399
+ // Lazy-init: retry if recognition was cleaned up or not available at page load
400
+ if (!this.recognition) this._initRecognition();
401
+ if (!this.supported) {
402
+ if (!this._shouldUseDeepgram()) {
403
+ app.showToast('Voice input not available. Configure Deepgram in Settings > Voice.', 'warning');
404
+ } else {
405
+ app.showToast('Voice input not supported in this browser', 'warning');
406
+ }
407
+ return;
408
+ }
409
+ this.isRecording = true;
410
+ this._activeProvider = 'webspeech';
411
+ this._accumulatedFinal = '';
412
+ this._lastTranscript = '';
413
+ this._hasReceivedResult = false;
414
+ this._recordingStartedAt = Date.now();
415
+ this._updateButtons('recording');
416
+ this._showPreview('Listening...');
417
+ this._startDurationTimer();
418
+ try {
419
+ this.recognition.start();
420
+ } catch (e) {
421
+ // InvalidStateError = already started — ignore. Other errors = genuine failure.
422
+ if (e.name !== 'InvalidStateError') {
423
+ this.stop();
424
+ app.showToast('Voice input failed to start: ' + e.message, 'error');
425
+ return;
426
+ }
427
+ }
428
+ this._resetSilenceTimeout();
429
+ // Get mic stream for level meter (non-blocking — level meter is cosmetic)
430
+ navigator.mediaDevices?.getUserMedia({ audio: true }).then(stream => {
431
+ if (this.isRecording && this._activeProvider === 'webspeech') {
432
+ this._webSpeechStream = stream;
433
+ this._startLevelMeter(stream);
434
+ } else {
435
+ stream.getTracks().forEach(t => t.stop());
436
+ }
437
+ }).catch(() => { /* level meter just won't show */ });
438
+ // Haptic feedback on mobile
439
+ if (navigator.vibrate) navigator.vibrate(50);
440
+ },
441
+
442
+ stop() {
443
+ if (!this.isRecording) return;
444
+ this.isRecording = false;
445
+ clearTimeout(this.silenceTimeout);
446
+ clearTimeout(this._stabilityTimer);
447
+ this.silenceTimeout = null;
448
+ this._stabilityTimer = null;
449
+ this._retryCount = 0;
450
+ this._stopDurationTimer();
451
+ this._stopLevelMeter();
452
+ this._updateButtons('idle');
453
+ this._hidePreview();
454
+
455
+ if (this._activeProvider === 'deepgram') {
456
+ DeepgramProvider.stop();
457
+ } else if (this._activeProvider === 'webspeech') {
458
+ try {
459
+ this.recognition?.stop();
460
+ } catch (_e) {
461
+ // Already stopped — ignore
462
+ }
463
+ // Stop the mic stream we opened for the level meter
464
+ if (this._webSpeechStream) {
465
+ this._webSpeechStream.getTracks().forEach(t => t.stop());
466
+ this._webSpeechStream = null;
467
+ }
468
+ }
469
+ this._activeProvider = null;
470
+
471
+ // Haptic feedback on mobile
472
+ if (navigator.vibrate) navigator.vibrate([30, 50, 30]);
473
+ },
474
+
475
+ _onWebSpeechResult(event) {
476
+ if (!this.isRecording) return;
477
+ this._hasReceivedResult = true;
478
+ this._resetSilenceTimeout();
479
+ let interim = '';
480
+ let finalText = '';
481
+ for (let i = event.resultIndex; i < event.results.length; i++) {
482
+ const transcript = event.results[i][0].transcript;
483
+ if (event.results[i].isFinal) {
484
+ finalText += transcript;
485
+ } else {
486
+ interim += transcript;
487
+ }
488
+ }
489
+
490
+ if (finalText) {
491
+ this._accumulatedFinal += finalText;
492
+ this._hidePreview();
493
+ this._insertText(this._accumulatedFinal);
494
+ this.stop();
495
+ } else if (interim) {
496
+ const display = this._accumulatedFinal + interim;
497
+ this._showPreview(display);
498
+ // iOS Safari workaround: isFinal is always false.
499
+ // Detect when interim results stop changing for 750ms → treat as final.
500
+ this._iosStabilityCheck(interim);
501
+ }
502
+ },
503
+
504
+ _onWebSpeechError(event) {
505
+ // During auto-retry, 'aborted' and 'no-speech' errors are expected — ignore them
506
+ if (this._retryCount > 0 && (event.error === 'aborted' || event.error === 'no-speech')) return;
507
+
508
+ const wasRecording = this.isRecording;
509
+ this.stop();
510
+ if (!wasRecording) return;
511
+
512
+ switch (event.error) {
513
+ case 'not-allowed':
514
+ app.showToast('Microphone access denied. Check browser settings.', 'error');
515
+ break;
516
+ case 'no-speech':
517
+ // Silent — auto-stop is enough feedback
518
+ break;
519
+ case 'network':
520
+ app.showToast('Voice input requires internet connection.', 'error');
521
+ break;
522
+ case 'aborted':
523
+ // User cancelled — no message needed
524
+ break;
525
+ default:
526
+ app.showToast('Voice input error: ' + event.error, 'error');
527
+ }
528
+ },
529
+
530
+ _onWebSpeechEnd() {
531
+ // Recognition ended (browser auto-stopped or we called stop())
532
+ if (!this.isRecording) return;
533
+
534
+ const elapsed = Date.now() - this._recordingStartedAt;
535
+ // Web Speech API often fires onend prematurely on the first attempt (< 500ms, no results).
536
+ // Auto-retry up to 2 times to avoid the "needs two clicks" problem.
537
+ if (elapsed < 500 && !this._hasReceivedResult && this._retryCount < 2) {
538
+ this._retryCount++;
539
+ try {
540
+ this.recognition.start();
541
+ } catch (_e) {
542
+ // If restart fails, fall through to stop
543
+ if (this._accumulatedFinal) this._insertText(this._accumulatedFinal);
544
+ this.stop();
545
+ }
546
+ return;
547
+ }
548
+
549
+ // Genuine end — finalize any accumulated text
550
+ if (this._accumulatedFinal) {
551
+ this._insertText(this._accumulatedFinal);
552
+ }
553
+ this.stop();
554
+ },
555
+
556
+ _insertText(text) {
557
+ if (!app.activeSessionId || !text.trim()) return;
558
+ const trimmed = text.trim();
559
+ const mode = this._getDeepgramConfig().insertMode || 'direct';
560
+
561
+ if (mode === 'compose') {
562
+ // If a compose overlay is already open, populate its textarea instead of recreating
563
+ const existingTextarea = document.querySelector('.voice-compose-overlay .paste-textarea');
564
+ if (existingTextarea) {
565
+ existingTextarea.value = trimmed;
566
+ existingTextarea.focus();
567
+ existingTextarea.selectionStart = existingTextarea.selectionEnd = trimmed.length;
568
+ } else {
569
+ this._showComposeOverlay(trimmed);
570
+ }
571
+ } else {
572
+ // Direct mode: inject into local echo overlay if available, else send to PTY
573
+ if (app._localEchoEnabled && app._localEchoOverlay) {
574
+ app._localEchoOverlay.appendText(trimmed);
575
+ } else {
576
+ app.sendInput(trimmed).catch(() => {});
577
+ }
578
+ this._showVoiceSendBtn();
579
+ setTimeout(() => { if (app.terminal) app.terminal.focus(); }, 150);
580
+ }
581
+ },
582
+
583
+ /** Show a green Enter button by transforming the gear icon in-place */
584
+ _showVoiceSendBtn() {
585
+ // Find the gear button (mobile or desktop header)
586
+ const gear = document.querySelector('.btn-settings-mobile') || document.querySelector('.btn-settings');
587
+ if (!gear || gear.classList.contains('voice-send-active')) return;
588
+
589
+ // Remove existing if any
590
+ this._hideVoiceSendBtn();
591
+
592
+ // Save original state
593
+ this._voiceSendGear = gear;
594
+ this._voiceSendOriginalHTML = gear.innerHTML;
595
+ this._voiceSendOriginalOnclick = gear.getAttribute('onclick');
596
+
597
+ // Transform into green send button
598
+ gear.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>';
599
+ gear.classList.add('voice-send-active');
600
+ gear.removeAttribute('onclick');
601
+ gear.title = 'Send (Enter)';
602
+
603
+ // Click handler
604
+ this._voiceSendHandler = () => {
605
+ if (!app.activeSessionId) return;
606
+ // Simulate Enter key: if local echo is active, flush its buffer + send \r;
607
+ // otherwise just send \r directly to the PTY
608
+ if (app._localEchoEnabled && app._localEchoOverlay) {
609
+ const text = app._localEchoOverlay.pendingText || '';
610
+ app._localEchoOverlay.clear();
611
+ app._localEchoOverlay.suppressBufferDetection();
612
+ if (text) app.sendInput(text).catch(() => {});
613
+ setTimeout(() => app.sendInput('\r').catch(() => {}), 80);
614
+ } else {
615
+ app.sendInput('\r').catch(() => {});
616
+ }
617
+ // Blink then restore
618
+ gear.classList.add('voice-send-blink');
619
+ setTimeout(() => this._hideVoiceSendBtn(), 400);
620
+ };
621
+ gear.addEventListener('click', this._voiceSendHandler);
622
+ },
623
+
624
+ _hideVoiceSendBtn() {
625
+ const gear = this._voiceSendGear;
626
+ if (!gear) return;
627
+ gear.removeEventListener('click', this._voiceSendHandler);
628
+ gear.classList.remove('voice-send-active', 'voice-send-blink');
629
+ gear.innerHTML = this._voiceSendOriginalHTML || '';
630
+ if (this._voiceSendOriginalOnclick) {
631
+ gear.setAttribute('onclick', this._voiceSendOriginalOnclick);
632
+ }
633
+ gear.title = 'App Settings';
634
+ this._voiceSendGear = null;
635
+ this._voiceSendHandler = null;
636
+ this._voiceSendOriginalHTML = null;
637
+ this._voiceSendOriginalOnclick = null;
638
+ },
639
+
640
+ /** Show an editable compose overlay so the user can review/edit before sending */
641
+ _showComposeOverlay(text) {
642
+ document.querySelector('.voice-compose-overlay')?.remove();
643
+ const overlay = document.createElement('div');
644
+ overlay.className = 'voice-compose-overlay paste-overlay';
645
+ overlay.innerHTML = `
646
+ <div class="paste-dialog">
647
+ <textarea class="paste-textarea">${text.replace(/</g, '&lt;')}</textarea>
648
+ <div class="paste-actions">
649
+ <button class="paste-cancel">Cancel</button>
650
+ <button class="paste-new"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg> New</button>
651
+ <button class="paste-send">Send</button>
652
+ </div>
653
+ </div>
654
+ `;
655
+ const textarea = overlay.querySelector('textarea');
656
+ const send = () => {
657
+ const val = textarea.value.trim();
658
+ overlay.remove();
659
+ if (val) app.sendInput(val + '\r').catch(() => {});
660
+ };
661
+ const cancel = () => overlay.remove();
662
+ const newInput = () => {
663
+ textarea.value = '';
664
+ textarea.blur();
665
+ this.start();
666
+ };
667
+ overlay.querySelector('.paste-cancel').addEventListener('click', cancel);
668
+ overlay.querySelector('.paste-new').addEventListener('click', newInput);
669
+ overlay.querySelector('.paste-send').addEventListener('click', send);
670
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) cancel(); });
671
+ document.body.appendChild(overlay);
672
+ textarea.focus();
673
+ textarea.selectionStart = textarea.selectionEnd = textarea.value.length;
674
+ },
675
+
676
+ _resetSilenceTimeout() {
677
+ clearTimeout(this.silenceTimeout);
678
+ this.silenceTimeout = setTimeout(() => {
679
+ if (this.isRecording) {
680
+ // Finalize any accumulated text before stopping
681
+ if (this._accumulatedFinal) {
682
+ this._insertText(this._accumulatedFinal);
683
+ }
684
+ this.stop();
685
+ }
686
+ }, 3000);
687
+ },
688
+
689
+ _iosStabilityCheck(transcript) {
690
+ if (transcript !== this._lastTranscript) {
691
+ this._lastTranscript = transcript;
692
+ clearTimeout(this._stabilityTimer);
693
+ this._stabilityTimer = setTimeout(() => {
694
+ if (this.isRecording) {
695
+ const finalText = this._accumulatedFinal + transcript;
696
+ this._hidePreview();
697
+ this._insertText(finalText);
698
+ this.stop();
699
+ }
700
+ }, 750);
701
+ }
702
+ },
703
+
704
+ _startDurationTimer() {
705
+ this._stopDurationTimer();
706
+ this._durationInterval = setInterval(() => {
707
+ if (!this.isRecording || !this.previewEl) return;
708
+ const elapsed = Math.floor((Date.now() - this._recordingStartedAt) / 1000);
709
+ const mins = Math.floor(elapsed / 60);
710
+ const secs = elapsed % 60;
711
+ const timeStr = mins > 0 ? `${mins}:${String(secs).padStart(2, '0')}` : `0:${String(secs).padStart(2, '0')}`;
712
+ const timerEl = this.previewEl.querySelector('.voice-timer');
713
+ if (timerEl) timerEl.textContent = timeStr;
714
+ }, 1000);
715
+ },
716
+
717
+ _stopDurationTimer() {
718
+ if (this._durationInterval) {
719
+ clearInterval(this._durationInterval);
720
+ this._durationInterval = null;
721
+ }
722
+ },
723
+
724
+ /** Start audio level meter using AnalyserNode — attaches to the active mic stream */
725
+ _startLevelMeter(stream) {
726
+ this._stopLevelMeter();
727
+ try {
728
+ this._audioContext = new (window.AudioContext || window.webkitAudioContext)();
729
+ this._analyserSource = this._audioContext.createMediaStreamSource(stream);
730
+ this._analyser = this._audioContext.createAnalyser();
731
+ this._analyser.fftSize = 256;
732
+ this._analyserSource.connect(this._analyser);
733
+ this._drawLevelMeter();
734
+ } catch (_e) {
735
+ // AudioContext not available — level meter just won't show
736
+ }
737
+ },
738
+
739
+ _stopLevelMeter() {
740
+ if (this._levelAnimFrame) {
741
+ cancelAnimationFrame(this._levelAnimFrame);
742
+ this._levelAnimFrame = null;
743
+ }
744
+ if (this._analyserSource) {
745
+ try { this._analyserSource.disconnect(); } catch (_e) { /* */ }
746
+ this._analyserSource = null;
747
+ }
748
+ if (this._audioContext) {
749
+ try { this._audioContext.close(); } catch (_e) { /* */ }
750
+ this._audioContext = null;
751
+ }
752
+ this._analyser = null;
753
+ },
754
+
755
+ _drawLevelMeter() {
756
+ if (!this._analyser || !this.isRecording) return;
757
+ const dataArray = new Uint8Array(this._analyser.frequencyBinCount);
758
+ this._analyser.getByteFrequencyData(dataArray);
759
+ // Compute RMS level 0-1
760
+ let sum = 0;
761
+ for (let i = 0; i < dataArray.length; i++) sum += dataArray[i] * dataArray[i];
762
+ const rms = Math.sqrt(sum / dataArray.length) / 255;
763
+ // Update the level bars in the preview
764
+ const barsEl = this.previewEl?.querySelector('.voice-level-bars');
765
+ if (barsEl) {
766
+ const bars = barsEl.children;
767
+ for (let i = 0; i < bars.length; i++) {
768
+ const threshold = (i + 1) / bars.length;
769
+ bars[i].classList.toggle('active', rms >= threshold * 0.7);
770
+ }
771
+ }
772
+ this._levelAnimFrame = requestAnimationFrame(() => this._drawLevelMeter());
773
+ },
774
+
775
+ _showPreview(text, provider) {
776
+ if (!this.previewEl) {
777
+ this.previewEl = document.createElement('div');
778
+ this.previewEl.className = 'voice-preview';
779
+ this.previewEl.setAttribute('aria-live', 'polite');
780
+ document.body.appendChild(this.previewEl);
781
+ }
782
+
783
+ // Build the indicator structure once, then just update the text node
784
+ if (!this.previewEl.querySelector('.voice-recording-indicator')) {
785
+ this.previewEl.textContent = '';
786
+ // Recording indicator: red dot + level bars + timer
787
+ const indicator = document.createElement('span');
788
+ indicator.className = 'voice-recording-indicator';
789
+ indicator.innerHTML = '<span class="voice-rec-dot"></span>';
790
+ const barsEl = document.createElement('span');
791
+ barsEl.className = 'voice-level-bars';
792
+ for (let i = 0; i < 5; i++) {
793
+ const bar = document.createElement('span');
794
+ bar.className = 'voice-level-bar';
795
+ barsEl.appendChild(bar);
796
+ }
797
+ indicator.appendChild(barsEl);
798
+ const timerEl = document.createElement('span');
799
+ timerEl.className = 'voice-timer';
800
+ timerEl.textContent = '0:00';
801
+ indicator.appendChild(timerEl);
802
+ this.previewEl.appendChild(indicator);
803
+ // Provider badge for Deepgram
804
+ if (provider === 'deepgram') {
805
+ const badge = document.createElement('span');
806
+ badge.className = 'voice-preview-badge';
807
+ badge.textContent = 'DG';
808
+ this.previewEl.appendChild(badge);
809
+ this.previewEl.appendChild(document.createTextNode(' '));
810
+ }
811
+ // Text node for transcript
812
+ this._previewTextNode = document.createTextNode(text || 'Listening...');
813
+ this.previewEl.appendChild(this._previewTextNode);
814
+ } else {
815
+ // Just update the text content
816
+ if (this._previewTextNode) {
817
+ this._previewTextNode.textContent = text || 'Listening...';
818
+ }
819
+ }
820
+ this.previewEl.style.display = '';
821
+ },
822
+
823
+ _hidePreview() {
824
+ if (this.previewEl) {
825
+ this.previewEl.style.display = 'none';
826
+ this.previewEl.textContent = '';
827
+ }
828
+ },
829
+
830
+ _updateButtons(state) {
831
+ const isRecording = state === 'recording';
832
+ // Desktop button
833
+ const desktopBtn = document.getElementById('voiceInputBtn');
834
+ if (desktopBtn) {
835
+ desktopBtn.classList.toggle('recording', isRecording);
836
+ desktopBtn.setAttribute('aria-pressed', String(isRecording));
837
+ desktopBtn.setAttribute('aria-label', isRecording ? 'Stop voice input' : 'Start voice input');
838
+ desktopBtn.title = isRecording ? 'Stop voice input (Ctrl+Shift+V)' : 'Voice input (Ctrl+Shift+V)';
839
+ }
840
+ // Mobile toolbar button (always visible on mobile)
841
+ const mobileToolbarBtn = document.getElementById('voiceInputBtnMobile');
842
+ if (mobileToolbarBtn) {
843
+ mobileToolbarBtn.classList.toggle('recording', isRecording);
844
+ mobileToolbarBtn.setAttribute('aria-pressed', String(isRecording));
845
+ mobileToolbarBtn.setAttribute('aria-label', isRecording ? 'Stop voice input' : 'Start voice input');
846
+ }
847
+ },
848
+
849
+ _showButtons() {
850
+ const desktopBtn = document.getElementById('voiceInputBtn');
851
+ if (desktopBtn) desktopBtn.style.display = '';
852
+ const mobileToolbarBtn = document.getElementById('voiceInputBtnMobile');
853
+ if (mobileToolbarBtn) mobileToolbarBtn.style.display = '';
854
+ },
855
+
856
+ /** Cleanup on SSE reconnect or page unload */
857
+ cleanup() {
858
+ if (this.isRecording) this.stop();
859
+ this._hideVoiceSendBtn();
860
+ DeepgramProvider._cleanup();
861
+ this.recognition = null;
862
+ this._activeProvider = null;
863
+ this._stopDurationTimer();
864
+ this._stopLevelMeter();
865
+ if (this._webSpeechStream) {
866
+ this._webSpeechStream.getTracks().forEach(t => t.stop());
867
+ this._webSpeechStream = null;
868
+ }
869
+ if (this.previewEl) {
870
+ this.previewEl.remove();
871
+ this.previewEl = null;
872
+ }
873
+ clearTimeout(this.silenceTimeout);
874
+ clearTimeout(this._stabilityTimer);
875
+ this.silenceTimeout = null;
876
+ this._stabilityTimer = null;
877
+ this._accumulatedFinal = '';
878
+ this._lastTranscript = '';
879
+ this._retryCount = 0;
880
+ this._hasReceivedResult = false;
881
+ }
882
+ };