@hienlh/ppm 0.8.86 → 0.8.88

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 (237) hide show
  1. package/CHANGELOG.md +216 -4
  2. package/bun.lock +5 -0
  3. package/dist/web/assets/{_basePickBy-5eBmZ_lt.js → _basePickBy-3Xe18azI.js} +1 -1
  4. package/dist/web/assets/{_baseUniq-DimLlN0y.js → _baseUniq-Yy35llnn.js} +1 -1
  5. package/dist/web/assets/api-settings-Dh4oFOpX.js +1 -0
  6. package/dist/web/assets/{arc-D4SasZrA.js → arc-B9n1Gvb5.js} +1 -1
  7. package/dist/web/assets/architecture-PBZL5I3N-CFzkFKEL.js +1 -0
  8. package/dist/web/assets/{architectureDiagram-2XIMDMQ5-nv0WbM7d.js → architectureDiagram-2XIMDMQ5-DqAZP_F6.js} +1 -1
  9. package/dist/web/assets/arrow-up--LjUXLEt.js +1 -0
  10. package/dist/web/assets/{blockDiagram-WCTKOSBZ-C1XvYrb8.js → blockDiagram-WCTKOSBZ-h3cDF2vI.js} +1 -1
  11. package/dist/web/assets/browser-tab-DJLH0eDY.js +1 -0
  12. package/dist/web/assets/{c4Diagram-IC4MRINW-CygDrbWJ.js → c4Diagram-IC4MRINW--pF1r5lr.js} +1 -1
  13. package/dist/web/assets/channel-C2fMafck.js +1 -0
  14. package/dist/web/assets/chat-tab-C8HFXqGS.js +8 -0
  15. package/dist/web/assets/chevron-right-CHnjJt4E.js +1 -0
  16. package/dist/web/assets/{chunk-4BX2VUAB-C2FDgsgT.js → chunk-4BX2VUAB-C3aZvW7B.js} +1 -1
  17. package/dist/web/assets/{chunk-55IACEB6-jF4w6cat.js → chunk-55IACEB6-D5cABeB9.js} +1 -1
  18. package/dist/web/assets/{chunk-7E7YKBS2-BVCECZFi.js → chunk-7E7YKBS2-CkFGv6Zs.js} +1 -1
  19. package/dist/web/assets/{chunk-7R4GIKGN-DXTbeu5d.js → chunk-7R4GIKGN-Dvbyu4Zw.js} +2 -2
  20. package/dist/web/assets/{chunk-C72U2L5F-BaZqOsTs.js → chunk-C72U2L5F-CtqKiH4q.js} +1 -1
  21. package/dist/web/assets/{chunk-EGIJ26TM-Bky2tcH7.js → chunk-EGIJ26TM-Cpr87sBR.js} +1 -1
  22. package/dist/web/assets/{chunk-FMBD7UC4-Cp4BK9A8.js → chunk-FMBD7UC4-D23YVTOU.js} +1 -1
  23. package/dist/web/assets/{chunk-GEFDOKGD-BosFEH7G.js → chunk-GEFDOKGD-tDjHsAUs.js} +1 -1
  24. package/dist/web/assets/chunk-GLR3WWYH-DBdWQ3zy.js +2 -0
  25. package/dist/web/assets/chunk-HHEYEP7N-BBw_z0fW.js +1 -0
  26. package/dist/web/assets/{chunk-JSJVCQXG-H5Gbjsbr.js → chunk-JSJVCQXG-BBmymCjA.js} +1 -1
  27. package/dist/web/assets/{chunk-KX2RTZJC-CWerSUwS.js → chunk-KX2RTZJC-DP36BDiU.js} +1 -1
  28. package/dist/web/assets/{chunk-KYZI473N-FvwP7jUy.js → chunk-KYZI473N-Djw13C-3.js} +1 -1
  29. package/dist/web/assets/{chunk-L3YUKLVL-D1PI_ORP.js → chunk-L3YUKLVL-HG_eMj_C.js} +1 -1
  30. package/dist/web/assets/{chunk-MX3YWQON-C7Vzk_AI.js → chunk-MX3YWQON-C2UEioMs.js} +1 -1
  31. package/dist/web/assets/{chunk-NQ4KR5QH-BceYBGYX.js → chunk-NQ4KR5QH-DXUTQ-BL.js} +1 -1
  32. package/dist/web/assets/{chunk-O4XLMI2P-WPtzgxql.js → chunk-O4XLMI2P-BsUWb9d0.js} +1 -1
  33. package/dist/web/assets/{chunk-OZEHJAEY-DlHXDeLY.js → chunk-OZEHJAEY-rG0P22U9.js} +1 -1
  34. package/dist/web/assets/{chunk-PQ6SQG4A-Ci_Prygb.js → chunk-PQ6SQG4A-DX0xW7kO.js} +1 -1
  35. package/dist/web/assets/{chunk-PU5JKC2W-CO0zMN-z.js → chunk-PU5JKC2W-C7Gry6md.js} +1 -1
  36. package/dist/web/assets/chunk-QZHKN3VN-DFKFM_C1.js +1 -0
  37. package/dist/web/assets/{chunk-R5LLSJPH-IAEEzfpM.js → chunk-R5LLSJPH-CMY0PkRK.js} +1 -1
  38. package/dist/web/assets/{chunk-WL4C6EOR-BLXalOgc.js → chunk-WL4C6EOR-CXuQvlyu.js} +1 -1
  39. package/dist/web/assets/{chunk-XIRO2GV7-Dx1Ri_p2.js → chunk-XIRO2GV7-DRJEb7Zb.js} +1 -1
  40. package/dist/web/assets/{chunk-XPW4576I-m9pPGKn7.js → chunk-XPW4576I-BPEX8KhL.js} +1 -1
  41. package/dist/web/assets/{chunk-XZSTWKYB-B_08ExbI.js → chunk-XZSTWKYB-Cb0iqycX.js} +1 -1
  42. package/dist/web/assets/{chunk-YBOYWFTD-DqSOVcYe.js → chunk-YBOYWFTD-av5aeHLq.js} +1 -1
  43. package/dist/web/assets/classDiagram-VBA2DB6C-Dp4Kk3Yb.js +1 -0
  44. package/dist/web/assets/classDiagram-v2-RAHNMMFH-D8IvcV_B.js +1 -0
  45. package/dist/web/assets/clone-B2hUek6n.js +1 -0
  46. package/dist/web/assets/code-editor-CaGdx-lS.js +2 -0
  47. package/dist/web/assets/{cose-bilkent-S5V4N54A-DlL82QHu.js → cose-bilkent-S5V4N54A-qudEiMCT.js} +1 -1
  48. package/dist/web/assets/csv-preview-DUbHtTAS.js +10 -0
  49. package/dist/web/assets/{dagre-BmVoh2At.js → dagre-BFcnKyBF.js} +1 -1
  50. package/dist/web/assets/{dagre-KLK3FWXG-sDrRW9MQ.js → dagre-KLK3FWXG-C3O-MTLf.js} +1 -1
  51. package/dist/web/assets/database-viewer-i4Ddk6mO.js +1 -0
  52. package/dist/web/assets/{diagram-E7M64L7V-ChnAhgni.js → diagram-E7M64L7V-DxPjK7_c.js} +1 -1
  53. package/dist/web/assets/{diagram-IFDJBPK2-DW1J1uJd.js → diagram-IFDJBPK2-sqTog_XV.js} +1 -1
  54. package/dist/web/assets/{diagram-P4PSJMXO-CQ32hyG_.js → diagram-P4PSJMXO-hzmp0GHK.js} +1 -1
  55. package/dist/web/assets/diff-viewer-DQDS7yjv.js +4 -0
  56. package/dist/web/assets/dist-CALwEtco.js +41 -0
  57. package/dist/web/assets/dist-DGDPTxs1.js +13 -0
  58. package/dist/web/assets/{erDiagram-INFDFZHY-6CHo6nOw.js → erDiagram-INFDFZHY-DLeYhAAT.js} +1 -1
  59. package/dist/web/assets/{flowDiagram-PKNHOUZH-DroDiNT0.js → flowDiagram-PKNHOUZH-CRxlE9Sr.js} +1 -1
  60. package/dist/web/assets/{ganttDiagram-A5KZAMGK-DP0QBh8w.js → ganttDiagram-A5KZAMGK-BdjmoMLS.js} +1 -1
  61. package/dist/web/assets/git-graph-DUs-TN1u.js +1 -0
  62. package/dist/web/assets/gitGraph-HDMCJU4V-CNlas3Rz.js +1 -0
  63. package/dist/web/assets/{gitGraphDiagram-K3NZZRJ6-DvU3JGZn.js → gitGraphDiagram-K3NZZRJ6-BeHSX7kk.js} +1 -1
  64. package/dist/web/assets/{graphlib-CQBb2thr.js → graphlib-Duh_bWLa.js} +1 -1
  65. package/dist/web/assets/index-DhtLEnPD.css +2 -0
  66. package/dist/web/assets/index-Dm6RN1A1.js +37 -0
  67. package/dist/web/assets/info-3K5VOQVL-BDzTLc11.js +1 -0
  68. package/dist/web/assets/infoDiagram-LFFYTUFH-ZZmpgc6t.js +2 -0
  69. package/dist/web/assets/{isEmpty-B4kqZBtn.js → isEmpty-B9L-Ge-H.js} +1 -1
  70. package/dist/web/assets/{ishikawaDiagram-PHBUUO56-46yibrV5.js → ishikawaDiagram-PHBUUO56-Cu0Rt1Ok.js} +1 -1
  71. package/dist/web/assets/{journeyDiagram-4ABVD52K-BcmRwjK-.js → journeyDiagram-4ABVD52K-CgDI-UG4.js} +1 -1
  72. package/dist/web/assets/{kanban-definition-K7BYSVSG-B619K53y.js → kanban-definition-K7BYSVSG-h4g10UHL.js} +1 -1
  73. package/dist/web/assets/keybindings-store-qVLDZz97.js +1 -0
  74. package/dist/web/assets/lib-BeaDXEkP.js +4 -0
  75. package/dist/web/assets/{line-1gcO63_w.js → line-B75-Rx70.js} +1 -1
  76. package/dist/web/assets/{linear-DfRqDoVd.js → linear-Bcjv9FQt.js} +1 -1
  77. package/dist/web/assets/markdown-renderer-L1NgC2Rw.js +69 -0
  78. package/dist/web/assets/{mermaid-parser.core-XtjZQOeM.js → mermaid-parser.core-8u2leTXI.js} +2 -2
  79. package/dist/web/assets/{mindmap-definition-YRQLILUH-CifOFo_q.js → mindmap-definition-YRQLILUH-BaOBwb-W.js} +1 -1
  80. package/dist/web/assets/{ordinal-BJYw-iDX.js → ordinal-LFEjVtwQ.js} +1 -1
  81. package/dist/web/assets/packet-RMMSAZCW-IVa5F-go.js +1 -0
  82. package/dist/web/assets/pie-UPGHQEXC-CvXHKAzp.js +1 -0
  83. package/dist/web/assets/{pieDiagram-SKSYHLDU-BuHUh_fO.js → pieDiagram-SKSYHLDU-At5Kz0KK.js} +1 -1
  84. package/dist/web/assets/postgres-viewer-_uDispGW.js +1 -0
  85. package/dist/web/assets/{quadrantDiagram-337W2JSQ-Bau_hj6Z.js → quadrantDiagram-337W2JSQ-CdjGIDfw.js} +1 -1
  86. package/dist/web/assets/radar-KQ55EAFF-Z-Tr5wtS.js +1 -0
  87. package/dist/web/assets/react-dom-Bpkvzu3U.js +1 -0
  88. package/dist/web/assets/{requirementDiagram-Z7DCOOCP-Cq2b-uwp.js → requirementDiagram-Z7DCOOCP-B9F_Cx_p.js} +1 -1
  89. package/dist/web/assets/{sankeyDiagram-WA2Y5GQK-DrdGQxWQ.js → sankeyDiagram-WA2Y5GQK-RolPi8bU.js} +1 -1
  90. package/dist/web/assets/{sequenceDiagram-2WXFIKYE-qPxiTUcS.js → sequenceDiagram-2WXFIKYE-DM-tMAhx.js} +1 -1
  91. package/dist/web/assets/settings-tab-Bp4041i6.js +1 -0
  92. package/dist/web/assets/sqlite-viewer-GW-QCjHn.js +1 -0
  93. package/dist/web/assets/{stateDiagram-RAJIS63D-Dulj2oa8.js → stateDiagram-RAJIS63D-C4EMl6jf.js} +1 -1
  94. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-B-UjZch3.js +1 -0
  95. package/dist/web/assets/tab-store--SlERlDs.js +1 -0
  96. package/dist/web/assets/{terminal-tab-wKgpSPAT.js → terminal-tab-E4cWujj4.js} +2 -2
  97. package/dist/web/assets/{timeline-definition-YZTLITO2-BWyDnCYq.js → timeline-definition-YZTLITO2-A4PN_Efm.js} +1 -1
  98. package/dist/web/assets/treemap-KZPCXAKY-C9TYRE0k.js +1 -0
  99. package/dist/web/assets/use-monaco-theme-zABXAAla.js +11 -0
  100. package/dist/web/assets/{vennDiagram-LZ73GAT5-B9Iv2bNV.js → vennDiagram-LZ73GAT5-ywK7LMaH.js} +1 -1
  101. package/dist/web/assets/{xychartDiagram-JWTSCODW-ChXcMzBQ.js → xychartDiagram-JWTSCODW-DylHYNtJ.js} +1 -1
  102. package/dist/web/index.html +11 -11
  103. package/dist/web/sw.js +1 -1
  104. package/docs/code-standards.md +386 -6
  105. package/docs/codebase-summary.md +270 -98
  106. package/docs/design-guidelines.md +21 -0
  107. package/docs/project-changelog.md +150 -1
  108. package/docs/project-roadmap.md +41 -19
  109. package/docs/system-architecture.md +363 -15
  110. package/package.json +3 -2
  111. package/src/cli/commands/autostart.ts +1 -1
  112. package/src/cli/commands/restart.ts +9 -1
  113. package/src/cli/commands/status.ts +19 -0
  114. package/src/index.ts +2 -3
  115. package/src/providers/claude-agent-sdk.ts +316 -107
  116. package/src/providers/cli-provider-base.ts +238 -0
  117. package/src/providers/cursor-cli/cursor-event-mapper.ts +85 -0
  118. package/src/providers/cursor-cli/cursor-history.ts +207 -0
  119. package/src/providers/cursor-cli/cursor-provider.ts +146 -0
  120. package/src/providers/mock-provider.ts +7 -2
  121. package/src/providers/provider.interface.ts +1 -0
  122. package/src/providers/registry.ts +43 -4
  123. package/src/server/index.ts +44 -166
  124. package/src/server/routes/browser-preview.ts +159 -0
  125. package/src/server/routes/chat.ts +66 -6
  126. package/src/server/routes/mcp.ts +84 -0
  127. package/src/server/routes/project-scoped.ts +2 -0
  128. package/src/server/routes/proxy.ts +46 -53
  129. package/src/server/routes/settings.ts +14 -0
  130. package/src/server/routes/tunnel.ts +0 -32
  131. package/src/server/routes/workspace.ts +35 -0
  132. package/src/server/ws/chat.ts +302 -195
  133. package/src/services/account-selector.service.ts +16 -8
  134. package/src/services/account.service.ts +19 -13
  135. package/src/services/chat.service.ts +10 -15
  136. package/src/services/claude-usage.service.ts +48 -11
  137. package/src/services/cloud-ws.service.ts +227 -0
  138. package/src/services/cloud.service.ts +10 -6
  139. package/src/services/db.service.ts +119 -6
  140. package/src/services/mcp-config.service.ts +102 -0
  141. package/src/services/proxy.service.ts +4 -19
  142. package/src/services/supervisor.ts +285 -25
  143. package/src/types/api.ts +10 -2
  144. package/src/types/chat.ts +25 -2
  145. package/src/types/config.ts +33 -11
  146. package/src/types/mcp.ts +47 -0
  147. package/src/utils/ndjson-line-parser.ts +36 -0
  148. package/src/web/app.tsx +41 -35
  149. package/src/web/components/browser/browser-tab.tsx +106 -97
  150. package/src/web/components/chat/account-rotation-settings.tsx +163 -0
  151. package/src/web/components/chat/chat-history-bar.tsx +116 -31
  152. package/src/web/components/chat/chat-tab.tsx +31 -10
  153. package/src/web/components/chat/chat-welcome.tsx +148 -0
  154. package/src/web/components/chat/message-input.tsx +169 -16
  155. package/src/web/components/chat/message-list.tsx +27 -15
  156. package/src/web/components/chat/provider-selector.tsx +150 -0
  157. package/src/web/components/chat/session-picker.tsx +80 -31
  158. package/src/web/components/chat/usage-badge.tsx +11 -1
  159. package/src/web/components/editor/code-editor.tsx +36 -26
  160. package/src/web/components/editor/csv-preview.tsx +228 -0
  161. package/src/web/components/editor/editor-breadcrumb.tsx +216 -0
  162. package/src/web/components/editor/editor-toolbar.tsx +74 -0
  163. package/src/web/components/layout/command-palette.tsx +3 -1
  164. package/src/web/components/layout/editor-panel.tsx +162 -18
  165. package/src/web/components/layout/panel-layout.tsx +17 -1
  166. package/src/web/components/settings/ai-settings-section.tsx +196 -137
  167. package/src/web/components/settings/mcp-server-dialog.tsx +208 -0
  168. package/src/web/components/settings/mcp-settings-section.tsx +143 -0
  169. package/src/web/components/settings/proxy-settings-section.tsx +40 -42
  170. package/src/web/components/settings/settings-tab.tsx +5 -2
  171. package/src/web/components/shared/connection-lost-overlay.tsx +89 -0
  172. package/src/web/hooks/use-chat.ts +234 -207
  173. package/src/web/hooks/use-global-keybindings.ts +25 -2
  174. package/src/web/hooks/use-server-reload.ts +9 -0
  175. package/src/web/hooks/use-url-sync.ts +173 -21
  176. package/src/web/hooks/use-voice-input.ts +111 -0
  177. package/src/web/lib/api-mcp.ts +38 -0
  178. package/src/web/lib/csv-parser.ts +134 -0
  179. package/src/web/stores/connection-store.ts +39 -0
  180. package/src/web/stores/keybindings-store.ts +1 -0
  181. package/src/web/stores/panel-store.ts +73 -19
  182. package/src/web/stores/panel-utils.ts +145 -3
  183. package/dist/web/assets/api-settings-CFw-lh5k.js +0 -1
  184. package/dist/web/assets/architecture-PBZL5I3N-CJupe6q_.js +0 -1
  185. package/dist/web/assets/browser-tab-CmsL5eny.js +0 -1
  186. package/dist/web/assets/channel-DmKoFTd_.js +0 -1
  187. package/dist/web/assets/chat-tab-CFWsf13Z.js +0 -7
  188. package/dist/web/assets/chunk-GLR3WWYH-BnP-hOp6.js +0 -2
  189. package/dist/web/assets/chunk-HHEYEP7N-DKDPTPEZ.js +0 -1
  190. package/dist/web/assets/chunk-QZHKN3VN-C_wpI9wz.js +0 -1
  191. package/dist/web/assets/classDiagram-VBA2DB6C-B1T5uY-F.js +0 -1
  192. package/dist/web/assets/classDiagram-v2-RAHNMMFH-xs5vI3xC.js +0 -1
  193. package/dist/web/assets/clone-CijCFRT5.js +0 -1
  194. package/dist/web/assets/code-editor-H_dAh_fJ.js +0 -1
  195. package/dist/web/assets/database-viewer-DBzsgEJ8.js +0 -1
  196. package/dist/web/assets/diff-viewer-DzS-OnAR.js +0 -4
  197. package/dist/web/assets/dist-0Va_2L7G.js +0 -16
  198. package/dist/web/assets/dist-D9irYETY.js +0 -41
  199. package/dist/web/assets/git-graph-D3C7F8o3.js +0 -1
  200. package/dist/web/assets/gitGraph-HDMCJU4V-B0KvGQG8.js +0 -1
  201. package/dist/web/assets/index-CIkjfera.js +0 -31
  202. package/dist/web/assets/index-WKLuYsBY.css +0 -2
  203. package/dist/web/assets/info-3K5VOQVL-1uJ6_hCm.js +0 -1
  204. package/dist/web/assets/infoDiagram-LFFYTUFH-DLA5Q-3y.js +0 -2
  205. package/dist/web/assets/input-CGp1nFIg.js +0 -1
  206. package/dist/web/assets/keybindings-store-BdaoLwSo.js +0 -1
  207. package/dist/web/assets/markdown-renderer-DH49Zag7.js +0 -69
  208. package/dist/web/assets/packet-RMMSAZCW-34C4o9yj.js +0 -1
  209. package/dist/web/assets/pie-UPGHQEXC-D9ekKlh9.js +0 -1
  210. package/dist/web/assets/postgres-viewer-B9FYk8sD.js +0 -1
  211. package/dist/web/assets/radar-KQ55EAFF-DEuXOXSD.js +0 -1
  212. package/dist/web/assets/settings-store-DWXGVHsE.js +0 -2
  213. package/dist/web/assets/settings-tab-D-q8pd-5.js +0 -1
  214. package/dist/web/assets/sqlite-viewer-CDqcTePw.js +0 -1
  215. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-CAkzLlhk.js +0 -1
  216. package/dist/web/assets/tab-store-BPeiymiH.js +0 -1
  217. package/dist/web/assets/treemap-KZPCXAKY-nc7a1Ia1.js +0 -1
  218. package/dist/web/assets/use-monaco-theme-CCBTQ0S3.js +0 -11
  219. package/src/services/port-tunnel.service.ts +0 -97
  220. /package/dist/web/assets/{api-client-DOElml5u.js → api-client-BKIT_Qeg.js} +0 -0
  221. /package/dist/web/assets/{array-CYkMkqnU.js → array-DqLCdDFv.js} +0 -0
  222. /package/dist/web/assets/{columns-2-ChOTgl3e.js → columns-2-DbesTfa7.js} +0 -0
  223. /package/dist/web/assets/{cytoscape.esm-HeHO0VhB.js → cytoscape.esm-CWPXKqbJ.js} +0 -0
  224. /package/dist/web/assets/{defaultLocale-Beh6XjaL.js → defaultLocale-CrJzLgRD.js} +0 -0
  225. /package/dist/web/assets/{dist-BUYzeuKe.js → dist-Cep75xXf.js} +0 -0
  226. /package/dist/web/assets/{init-Rr1s_RiX.js → init-C0r9Gk5G.js} +0 -0
  227. /package/dist/web/assets/{isArrayLikeObject-BB-mzMLb.js → isArrayLikeObject-CGBoxvCD.js} +0 -0
  228. /package/dist/web/assets/{katex-CKoArbIw.js → katex-DzXRfQ_m.js} +0 -0
  229. /package/dist/web/assets/{math-B7b0HgJF.js → math-y9zN1W-N.js} +0 -0
  230. /package/dist/web/assets/{path-BAQ3hXlG.js → path-DIKpVbHL.js} +0 -0
  231. /package/dist/web/assets/{preload-helper-DeiOTZKJ.js → preload-helper-Bf_JiD2A.js} +0 -0
  232. /package/dist/web/assets/{react-Dev-wu-s.js → react-SKk5z-bm.js} +0 -0
  233. /package/dist/web/assets/{rough.esm-Dwml_la6.js → rough.esm-nHaDi0Kw.js} +0 -0
  234. /package/dist/web/assets/{src-B_cC68fH.js → src-Dw4QhedI.js} +0 -0
  235. /package/dist/web/assets/{table-COiJDPRA.js → table-CQVQM2SB.js} +0 -0
  236. /package/dist/web/assets/{tag-LMq02LfE.js → tag-Q2dZiSPX.js} +0 -0
  237. /package/dist/web/assets/{utils-btZ8C8-R.js → utils-DMiycH3O.js} +0 -0
@@ -21,6 +21,7 @@ export function useGlobalKeybindings() {
21
21
 
22
22
  useEffect(() => {
23
23
  let lastShiftUp = 0;
24
+ let shiftAlone = false; // true if Shift was pressed without any other key
24
25
  const { matchesEvent } = useKeybindingsStore.getState();
25
26
 
26
27
  let composing = false;
@@ -28,9 +29,24 @@ export function useGlobalKeybindings() {
28
29
  function onCompositionEnd() { composing = false; }
29
30
 
30
31
  function handler(e: KeyboardEvent) {
32
+ // Track whether Shift is pressed alone (not as a modifier for another key)
33
+ if (e.type === "keydown" && e.key === "Shift") {
34
+ shiftAlone = true;
35
+ return;
36
+ }
37
+ // Any non-Shift keydown while Shift is held means Shift is used as modifier
38
+ if (e.type === "keydown" && e.shiftKey) {
39
+ shiftAlone = false;
40
+ }
41
+ // Any non-Shift key resets the double-tap timer (user is typing, not double-tapping)
42
+ if (e.type === "keydown" && e.key !== "Shift") {
43
+ lastShiftUp = 0;
44
+ }
45
+
31
46
  // Double-Shift detection (on keyup to avoid repeats) — always active
32
- // Skip during IME composition (e.g. Vietnamese Telex) to prevent false triggers
33
- if (e.type === "keyup" && e.key === "Shift" && !e.ctrlKey && !e.metaKey && !e.altKey && !composing && !e.isComposing) {
47
+ // Only counts if Shift was pressed alone (not used as modifier e.g. Shift+T for uppercase)
48
+ // Also skip during IME composition (e.g. Vietnamese Telex) to prevent false triggers
49
+ if (e.type === "keyup" && e.key === "Shift" && shiftAlone && !e.ctrlKey && !e.metaKey && !e.altKey && !composing && !e.isComposing) {
34
50
  const now = Date.now();
35
51
  if (now - lastShiftUp < 400) {
36
52
  lastShiftUp = 0;
@@ -124,6 +140,13 @@ export function useGlobalKeybindings() {
124
140
  return;
125
141
  }
126
142
 
143
+ // Toggle voice input in chat
144
+ if (match(e, "voice-input")) {
145
+ e.preventDefault();
146
+ window.dispatchEvent(new CustomEvent("toggle-voice-input"));
147
+ return;
148
+ }
149
+
127
150
  // Open search (sidebar)
128
151
  if (match(e, "open-search")) {
129
152
  e.preventDefault();
@@ -1,4 +1,5 @@
1
1
  import { useEffect } from "react";
2
+ import { useConnectionStore } from "@/stores/connection-store";
2
3
 
3
4
  const POLL_NORMAL_MS = 10_000; // 10s when server is up
4
5
  const POLL_DOWN_MS = 2_000; // 2s when server is down (waiting for it to come back)
@@ -8,17 +9,21 @@ const POLL_DOWN_MS = 2_000; // 2s when server is down (waiting for it to com
8
9
  * When the server goes down and comes back up (restart/stop+start),
9
10
  * clears all browser/SW caches and reloads the page so the user
10
11
  * always gets fresh assets.
12
+ *
13
+ * Also updates the connection store to drive the ConnectionLostOverlay.
11
14
  */
12
15
  export function useServerReload() {
13
16
  useEffect(() => {
14
17
  let serverWasDown = false;
15
18
  let timer: ReturnType<typeof setTimeout>;
19
+ const { markDown, markUp } = useConnectionStore.getState();
16
20
 
17
21
  async function check() {
18
22
  try {
19
23
  const res = await fetch("/api/health", { cache: "no-store" });
20
24
  if (res.ok && serverWasDown) {
21
25
  // Server came back — clear caches then reload
26
+ markUp();
22
27
  if ("caches" in window) {
23
28
  const keys = await caches.keys();
24
29
  await Promise.all(keys.map((k) => caches.delete(k)));
@@ -26,9 +31,13 @@ export function useServerReload() {
26
31
  window.location.reload();
27
32
  return;
28
33
  }
34
+ if (res.ok) {
35
+ markUp();
36
+ }
29
37
  serverWasDown = false;
30
38
  } catch {
31
39
  serverWasDown = true;
40
+ markDown();
32
41
  }
33
42
  timer = setTimeout(check, serverWasDown ? POLL_DOWN_MS : POLL_NORMAL_MS);
34
43
  }
@@ -1,37 +1,181 @@
1
1
  import { useEffect, useRef } from "react";
2
- import { useTabStore } from "@/stores/tab-store";
2
+ import { useTabStore, type TabType } from "@/stores/tab-store";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // URL state types
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export interface UrlState {
9
+ projectName: string | null;
10
+ tabType: TabType | null;
11
+ tabIdentifier: string | null;
12
+ openChat: string | null;
13
+ }
14
+
15
+ const VALID_TAB_TYPES: TabType[] = [
16
+ "terminal", "chat", "editor", "database", "sqlite",
17
+ "postgres", "git-graph", "git-diff", "settings", "browser",
18
+ ];
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Parse URL → state
22
+ // ---------------------------------------------------------------------------
3
23
 
4
24
  /**
5
- * Parse the current URL to extract project name and tab ID.
6
- * Expected format: /project/:projectName/tab/:tabId
25
+ * Parse the current URL to extract project name and tab info.
26
+ * Format: /project/{name}/{tabType}/{...identifier}
7
27
  */
8
- export function parseUrlState(): { projectName: string | null; tabId: string | null; openChat: string | null } {
28
+ export function parseUrlState(): UrlState {
9
29
  const path = window.location.pathname;
10
- const match = path.match(/^\/project\/([^/]+)(?:\/tab\/([^/]+))?/);
11
30
  const params = new URLSearchParams(window.location.search);
12
31
  const openChat = params.get("openChat");
13
- if (!match) return { projectName: null, tabId: null, openChat };
14
- return {
15
- projectName: match[1] ? decodeURIComponent(match[1]) : null,
16
- tabId: match[2] ? decodeURIComponent(match[2]) : null,
17
- openChat,
18
- };
32
+
33
+ const match = path.match(/^\/project\/([^/]+)(?:\/([^/]+)(\/.*)?)?/);
34
+ if (!match) return { projectName: null, tabType: null, tabIdentifier: null, openChat };
35
+
36
+ const projectName = decodeURIComponent(match[1]!);
37
+ const rawType = match[2] ?? null;
38
+ const rawIdentifier = match[3] ? match[3].slice(1) : null; // strip leading /
39
+
40
+ // Legacy fallback: /project/{name}/tab/{tabId}
41
+ if (rawType === "tab") {
42
+ return { projectName, tabType: null, tabIdentifier: null, openChat };
43
+ }
44
+
45
+ const tabType = VALID_TAB_TYPES.includes(rawType as TabType) ? (rawType as TabType) : null;
46
+
47
+ return { projectName, tabType, tabIdentifier: rawIdentifier, openChat };
19
48
  }
20
49
 
50
+ // ---------------------------------------------------------------------------
51
+ // Build URL from state
52
+ // ---------------------------------------------------------------------------
53
+
21
54
  /**
22
- * Build URL path from project name and tab ID.
55
+ * Build URL path from project name and deterministic tab ID.
23
56
  */
24
- function buildUrl(projectName: string | null, tabId: string | null): string {
57
+ export function buildUrl(projectName: string | null, tabId: string | null): string {
25
58
  if (!projectName || projectName === "__global__") return "/";
59
+
26
60
  let url = `/project/${encodeURIComponent(projectName)}`;
27
- if (tabId) url += `/tab/${encodeURIComponent(tabId)}`;
61
+ if (!tabId) return url;
62
+
63
+ // Strip panel suffix (@panel-xxx) — not meaningful in URLs
64
+ const atIdx = tabId.indexOf("@");
65
+ const cleanId = atIdx !== -1 ? tabId.slice(0, atIdx) : tabId;
66
+
67
+ // tabId format: "type:identifier" or "type" (singletons)
68
+ const colonIdx = cleanId.indexOf(":");
69
+ if (colonIdx === -1) {
70
+ // Singleton: git-graph, settings
71
+ url += `/${cleanId}`;
72
+ } else {
73
+ const type = cleanId.slice(0, colonIdx);
74
+ const identifier = cleanId.slice(colonIdx + 1);
75
+ // Real slashes — no encoding for paths. Only encode special URL chars.
76
+ url += `/${type}/${identifier.replace(/[?#]/g, encodeURIComponent)}`;
77
+ }
28
78
  return url;
29
79
  }
30
80
 
81
+ // ---------------------------------------------------------------------------
82
+ // Tab ID reconstruction from URL
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /** Reconstruct deterministic tab ID from parsed URL */
86
+ export function tabIdFromUrl(tabType: TabType, tabIdentifier: string | null): string {
87
+ if (!tabIdentifier) return tabType; // singleton
88
+ return `${tabType}:${tabIdentifier}`;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Auto-open tab from URL
93
+ // ---------------------------------------------------------------------------
94
+
95
+ function buildMetadataFromUrl(
96
+ type: TabType, identifier: string | null, projectName: string,
97
+ ): Record<string, unknown> | null {
98
+ switch (type) {
99
+ case "editor": return identifier ? { filePath: identifier, projectName } : null;
100
+ case "chat": {
101
+ if (!identifier) return null;
102
+ const slashIdx = identifier.indexOf("/");
103
+ if (slashIdx === -1) return { sessionId: identifier, projectName };
104
+ const providerId = identifier.slice(0, slashIdx);
105
+ const sessionId = identifier.slice(slashIdx + 1);
106
+ return sessionId ? { sessionId, providerId, projectName } : null;
107
+ }
108
+ case "terminal": return { terminalIndex: parseInt(identifier ?? "1", 10), projectName };
109
+ case "git-graph": return { projectName };
110
+ case "git-diff": return identifier ? { filePath: identifier, projectName } : null;
111
+ case "settings": return {};
112
+ case "database": {
113
+ const [connId, tableName] = (identifier ?? "").split(":");
114
+ return connId ? { connectionId: connId, tableName: tableName ?? "" } : null;
115
+ }
116
+ case "sqlite": return identifier ? { filePath: identifier, projectName } : null;
117
+ case "postgres": {
118
+ const [connId, tableName] = (identifier ?? "").split(":");
119
+ return connId ? { connectionId: connId, tableName: tableName ?? "" } : null;
120
+ }
121
+ case "browser": return identifier ? { url: identifier } : null;
122
+ default: return null;
123
+ }
124
+ }
125
+
126
+ function buildTitleFromUrl(type: TabType, identifier: string | null): string {
127
+ switch (type) {
128
+ case "editor": return identifier?.split("/").pop() ?? "File";
129
+ case "chat": return "Chat";
130
+ case "terminal": return `Terminal ${identifier ?? "1"}`;
131
+ case "git-graph": return "Git Graph";
132
+ case "git-diff": return identifier?.split("/").pop() ?? "Diff";
133
+ case "settings": return "Settings";
134
+ case "database": return identifier ?? "Database";
135
+ case "sqlite": return identifier?.split("/").pop() ?? "SQLite";
136
+ case "postgres": return identifier ?? "PostgreSQL";
137
+ case "browser": return "Browser";
138
+ default: return type;
139
+ }
140
+ }
141
+
142
+ /** Auto-open or focus a tab based on URL state */
143
+ export function autoOpenFromUrl(
144
+ tabType: TabType,
145
+ tabIdentifier: string | null,
146
+ projectName: string,
147
+ ): void {
148
+ const { tabs, setActiveTab, openTab } = useTabStore.getState();
149
+ const expectedId = tabIdFromUrl(tabType, tabIdentifier);
150
+
151
+ // Check if tab already exists
152
+ const existing = tabs.find((t) => t.id === expectedId);
153
+ if (existing) {
154
+ setActiveTab(existing.id);
155
+ return;
156
+ }
157
+
158
+ // Auto-create tab from URL
159
+ const metadata = buildMetadataFromUrl(tabType, tabIdentifier, projectName);
160
+ if (!metadata) return;
161
+
162
+ openTab({
163
+ type: tabType,
164
+ title: buildTitleFromUrl(tabType, tabIdentifier),
165
+ projectId: projectName,
166
+ closable: true,
167
+ metadata,
168
+ });
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Hook: sync URL ↔ tab state
173
+ // ---------------------------------------------------------------------------
174
+
31
175
  /**
32
176
  * Sync tab/project state with browser URL.
33
- * - On tab/project change → pushState (enables back/forward navigation)
34
- * - On popstate (back/forward) → restore tab from URL
177
+ * - On tab/project change → pushState with type-based URL
178
+ * - On popstate (back/forward) → restore/create tab from URL
35
179
  */
36
180
  export function useUrlSync() {
37
181
  const activeTabId = useTabStore((s) => s.activeTabId);
@@ -40,7 +184,6 @@ export function useUrlSync() {
40
184
 
41
185
  // Push URL when active tab or project changes
42
186
  useEffect(() => {
43
- // Skip push if this change was triggered by popstate (back/forward)
44
187
  if (isPopState.current) {
45
188
  isPopState.current = false;
46
189
  return;
@@ -55,11 +198,20 @@ export function useUrlSync() {
55
198
  // Listen for back/forward navigation
56
199
  useEffect(() => {
57
200
  function handlePopState() {
58
- const { tabId } = parseUrlState();
201
+ const { tabType, tabIdentifier } = parseUrlState();
202
+ if (!tabType) return;
203
+
204
+ isPopState.current = true;
59
205
  const { tabs, setActiveTab } = useTabStore.getState();
60
- if (tabId && tabs.some((t) => t.id === tabId)) {
61
- isPopState.current = true;
62
- setActiveTab(tabId);
206
+ const expectedId = tabIdFromUrl(tabType, tabIdentifier);
207
+ const existing = tabs.find((t) => t.id === expectedId);
208
+
209
+ if (existing) {
210
+ setActiveTab(existing.id);
211
+ } else {
212
+ // Auto-open tab on back/forward if it was closed
213
+ const project = useTabStore.getState().currentProject;
214
+ if (project) autoOpenFromUrl(tabType, tabIdentifier, project);
63
215
  }
64
216
  }
65
217
 
@@ -0,0 +1,111 @@
1
+ import { useState, useRef, useCallback } from "react";
2
+
3
+ // Extend Window for webkit prefix
4
+ interface SpeechRecognitionEvent extends Event {
5
+ results: SpeechRecognitionResultList;
6
+ resultIndex: number;
7
+ }
8
+
9
+ type SpeechRecognitionInstance = {
10
+ lang: string;
11
+ continuous: boolean;
12
+ interimResults: boolean;
13
+ start(): void;
14
+ stop(): void;
15
+ abort(): void;
16
+ onresult: ((event: SpeechRecognitionEvent) => void) | null;
17
+ onend: (() => void) | null;
18
+ onerror: ((event: Event & { error: string }) => void) | null;
19
+ };
20
+
21
+ type SpeechRecognitionConstructor = new () => SpeechRecognitionInstance;
22
+
23
+ function getSpeechRecognition(): SpeechRecognitionConstructor | null {
24
+ const w = window as unknown as {
25
+ SpeechRecognition?: SpeechRecognitionConstructor;
26
+ webkitSpeechRecognition?: SpeechRecognitionConstructor;
27
+ };
28
+ return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
29
+ }
30
+
31
+ export function useVoiceInput(options?: { lang?: string }) {
32
+ const [isListening, setIsListening] = useState(false);
33
+ const [interimText, setInterimText] = useState("");
34
+ const recognitionRef = useRef<SpeechRecognitionInstance | null>(null);
35
+ // Accumulate finalized text across multiple result events
36
+ const finalizedRef = useRef("");
37
+
38
+ const supported = typeof window !== "undefined" && getSpeechRecognition() !== null;
39
+
40
+ const start = useCallback(
41
+ (onResult: (text: string, isFinal: boolean) => void) => {
42
+ const SR = getSpeechRecognition();
43
+ if (!SR) return;
44
+
45
+ // Stop any existing session
46
+ recognitionRef.current?.abort();
47
+
48
+ const recognition = new SR();
49
+ recognition.lang = options?.lang ?? "vi-VN";
50
+ recognition.continuous = true;
51
+ recognition.interimResults = true;
52
+
53
+ finalizedRef.current = "";
54
+
55
+ recognition.onresult = (event: SpeechRecognitionEvent) => {
56
+ let interim = "";
57
+ let newFinalized = "";
58
+
59
+ for (let i = 0; i < event.results.length; i++) {
60
+ const result = event.results[i]!;
61
+ if (result.isFinal) {
62
+ newFinalized += result[0]!.transcript;
63
+ } else {
64
+ interim += result[0]!.transcript;
65
+ }
66
+ }
67
+
68
+ // Update finalized accumulator
69
+ if (newFinalized) {
70
+ finalizedRef.current = newFinalized;
71
+ }
72
+
73
+ const fullText = (finalizedRef.current + " " + interim).trim();
74
+ setInterimText(interim);
75
+ onResult(fullText, interim.length === 0 && finalizedRef.current.length > 0);
76
+ };
77
+
78
+ recognition.onend = () => {
79
+ setIsListening(false);
80
+ setInterimText("");
81
+ // Deliver final text if any
82
+ if (finalizedRef.current) {
83
+ onResult(finalizedRef.current.trim(), true);
84
+ }
85
+ };
86
+
87
+ recognition.onerror = (event) => {
88
+ // "no-speech" and "aborted" are expected, not real errors
89
+ if (event.error !== "no-speech" && event.error !== "aborted") {
90
+ console.warn("[voice-input] error:", event.error);
91
+ }
92
+ setIsListening(false);
93
+ setInterimText("");
94
+ };
95
+
96
+ recognitionRef.current = recognition;
97
+ recognition.start();
98
+ setIsListening(true);
99
+ },
100
+ [options?.lang],
101
+ );
102
+
103
+ const stop = useCallback(() => {
104
+ recognitionRef.current?.stop();
105
+ recognitionRef.current = null;
106
+ setIsListening(false);
107
+ setInterimText("");
108
+ }, []);
109
+
110
+ return { isListening, interimText, start, stop, supported };
111
+ }
@@ -0,0 +1,38 @@
1
+ import { api } from "./api-client";
2
+ import type { McpServerConfig } from "../../types/mcp";
3
+
4
+ export interface McpServerEntry {
5
+ name: string;
6
+ transport: string;
7
+ config: McpServerConfig;
8
+ createdAt: string;
9
+ updatedAt: string;
10
+ }
11
+
12
+ export function getMcpServers(): Promise<McpServerEntry[]> {
13
+ return api.get<McpServerEntry[]>("/api/settings/mcp");
14
+ }
15
+
16
+ export function getMcpServer(name: string): Promise<McpServerConfig> {
17
+ return api.get<McpServerConfig>(`/api/settings/mcp/${encodeURIComponent(name)}`);
18
+ }
19
+
20
+ export function addMcpServer(name: string, config: McpServerConfig): Promise<{ name: string }> {
21
+ return api.post<{ name: string }>("/api/settings/mcp", { name, config });
22
+ }
23
+
24
+ export function updateMcpServer(name: string, config: McpServerConfig): Promise<{ name: string }> {
25
+ return api.put<{ name: string }>(`/api/settings/mcp/${encodeURIComponent(name)}`, config);
26
+ }
27
+
28
+ export function deleteMcpServer(name: string): Promise<void> {
29
+ return api.del(`/api/settings/mcp/${encodeURIComponent(name)}`);
30
+ }
31
+
32
+ export function importMcpServers(): Promise<{ imported: number; skipped: number }> {
33
+ return api.post<{ imported: number; skipped: number }>("/api/settings/mcp/import", {});
34
+ }
35
+
36
+ export function previewMcpImport(): Promise<{ available: boolean; servers: Record<string, McpServerConfig> }> {
37
+ return api.get<{ available: boolean; servers: Record<string, McpServerConfig> }>("/api/settings/mcp/import/preview");
38
+ }
@@ -0,0 +1,134 @@
1
+ /** Simple state-machine CSV parser handling quoted fields, embedded commas, newlines */
2
+
3
+ const enum State {
4
+ FIELD_START,
5
+ UNQUOTED,
6
+ QUOTED,
7
+ QUOTE_IN_QUOTED,
8
+ }
9
+
10
+ export interface CsvData {
11
+ headers: string[];
12
+ rows: string[][];
13
+ }
14
+
15
+ export function parseCsv(content: string): CsvData {
16
+ const rows: string[][] = [];
17
+ let row: string[] = [];
18
+ let field = "";
19
+ let state: State = State.FIELD_START;
20
+
21
+ for (let i = 0; i < content.length; i++) {
22
+ const ch = content[i]!;
23
+
24
+ switch (state) {
25
+ case State.FIELD_START:
26
+ if (ch === '"') {
27
+ state = State.QUOTED;
28
+ } else if (ch === ",") {
29
+ row.push(field);
30
+ field = "";
31
+ } else if (ch === "\r") {
32
+ // skip \r, handle \n next
33
+ } else if (ch === "\n") {
34
+ row.push(field);
35
+ field = "";
36
+ rows.push(row);
37
+ row = [];
38
+ } else {
39
+ field += ch;
40
+ state = State.UNQUOTED;
41
+ }
42
+ break;
43
+
44
+ case State.UNQUOTED:
45
+ if (ch === ",") {
46
+ row.push(field);
47
+ field = "";
48
+ state = State.FIELD_START;
49
+ } else if (ch === "\r") {
50
+ // skip
51
+ } else if (ch === "\n") {
52
+ row.push(field);
53
+ field = "";
54
+ rows.push(row);
55
+ row = [];
56
+ state = State.FIELD_START;
57
+ } else {
58
+ field += ch;
59
+ }
60
+ break;
61
+
62
+ case State.QUOTED:
63
+ if (ch === '"') {
64
+ state = State.QUOTE_IN_QUOTED;
65
+ } else {
66
+ field += ch;
67
+ }
68
+ break;
69
+
70
+ case State.QUOTE_IN_QUOTED:
71
+ if (ch === '"') {
72
+ // Escaped quote ""
73
+ field += '"';
74
+ state = State.QUOTED;
75
+ } else if (ch === ",") {
76
+ row.push(field);
77
+ field = "";
78
+ state = State.FIELD_START;
79
+ } else if (ch === "\r") {
80
+ // skip
81
+ } else if (ch === "\n") {
82
+ row.push(field);
83
+ field = "";
84
+ rows.push(row);
85
+ row = [];
86
+ state = State.FIELD_START;
87
+ } else {
88
+ // Malformed — treat closing quote as literal
89
+ field += ch;
90
+ state = State.UNQUOTED;
91
+ }
92
+ break;
93
+ }
94
+ }
95
+
96
+ // Flush last field/row
97
+ if (field || row.length > 0) {
98
+ row.push(field);
99
+ rows.push(row);
100
+ }
101
+
102
+ if (rows.length === 0) return { headers: [], rows: [] };
103
+
104
+ const headers = rows[0]!;
105
+ const dataRows = rows.slice(1);
106
+
107
+ // Normalize column count — pad short rows, truncate long ones
108
+ const colCount = headers.length;
109
+ for (let i = 0; i < dataRows.length; i++) {
110
+ const r = dataRows[i]!;
111
+ if (r.length < colCount) {
112
+ while (r.length < colCount) r.push("");
113
+ } else if (r.length > colCount) {
114
+ dataRows[i] = r.slice(0, colCount);
115
+ }
116
+ }
117
+
118
+ return { headers, rows: dataRows };
119
+ }
120
+
121
+ export function serializeCsv(headers: string[], rows: string[][]): string {
122
+ const escape = (val: string): string => {
123
+ if (val.includes(",") || val.includes('"') || val.includes("\n")) {
124
+ return `"${val.replace(/"/g, '""')}"`;
125
+ }
126
+ return val;
127
+ };
128
+
129
+ const lines = [headers.map(escape).join(",")];
130
+ for (const row of rows) {
131
+ lines.push(row.map(escape).join(","));
132
+ }
133
+ return lines.join("\n");
134
+ }
@@ -0,0 +1,39 @@
1
+ import { create } from "zustand";
2
+
3
+ interface ConnectionState {
4
+ /** Whether the server is currently unreachable */
5
+ isDown: boolean;
6
+ /** Timestamp when the server first went down */
7
+ downSince: number | null;
8
+ /** Whether the overlay should be shown (down for > threshold) */
9
+ showOverlay: boolean;
10
+
11
+ markDown: () => void;
12
+ markUp: () => void;
13
+ }
14
+
15
+ /** How long the server must be unreachable before showing the overlay */
16
+ const OVERLAY_THRESHOLD_MS = 15_000;
17
+
18
+ export const useConnectionStore = create<ConnectionState>((set, get) => ({
19
+ isDown: false,
20
+ downSince: null,
21
+ showOverlay: false,
22
+
23
+ markDown: () => {
24
+ const { downSince } = get();
25
+ const now = Date.now();
26
+ const since = downSince ?? now;
27
+ const elapsed = now - since;
28
+
29
+ set({
30
+ isDown: true,
31
+ downSince: since,
32
+ showOverlay: elapsed >= OVERLAY_THRESHOLD_MS,
33
+ });
34
+ },
35
+
36
+ markUp: () => {
37
+ set({ isDown: false, downSince: null, showOverlay: false });
38
+ },
39
+ }));
@@ -36,6 +36,7 @@ export const KEY_ACTIONS: KeyAction[] = [
36
36
  { id: "open-git-graph", label: "Git Graph", category: "tabs", defaultKey: "Mod+G" },
37
37
  { id: "open-git-status", label: "Git Status (sidebar)", category: "tabs", defaultKey: "Mod+Shift+E" },
38
38
  { id: "open-search", label: "Search Files (sidebar)", category: "tabs", defaultKey: "Mod+Shift+F" },
39
+ { id: "voice-input", label: "Voice Input", category: "general", defaultKey: "Mod+Shift+V", note: "Toggle speech-to-text in chat" },
39
40
  // Projects — Mod+1..9
40
41
  ...Array.from({ length: 9 }, (_, i) => ({
41
42
  id: `switch-project-${i + 1}`,