mulmoterminal 5.2.0 → 5.3.0

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 (140) hide show
  1. package/README.md +47 -0
  2. package/common/fileChannel.ts +42 -0
  3. package/common/fileSearch.ts +211 -0
  4. package/common/keymap.ts +5 -3
  5. package/dist/assets/{AllPackages-B1YCJRYB-Byv5Y2l8.js → AllPackages-B1YCJRYB-Dq7RtcKf.js} +1 -1
  6. package/dist/assets/{BaseConfiguration-B6s_tKOW-D-gjoRXo.js → BaseConfiguration-B6s_tKOW-Cqnov2Sk.js} +1 -1
  7. package/dist/assets/{Element-BMI7Nj5w-pmadKN44.js → Element-BMI7Nj5w-BNxoLB0I.js} +1 -1
  8. package/dist/assets/{Entities-C8p_0jCn-DOze-aLz.js → Entities-C8p_0jCn-nTjTp-hn.js} +1 -1
  9. package/dist/assets/{FunctionList-YWk54TCy-ChT_85FS.js → FunctionList-YWk54TCy-BE08xiex.js} +1 -1
  10. package/dist/assets/{InputJax-C5dj-ntL-QDnGDgyA.js → InputJax-C5dj-ntL-R1Dc7w96.js} +1 -1
  11. package/dist/assets/{MathItem-YJvQL-XJ-C1WmSlqG.js → MathItem-YJvQL-XJ-CLANtgp6.js} +1 -1
  12. package/dist/assets/{MmlFactory-DSoKkE7s-BSods9g3.js → MmlFactory-DSoKkE7s-5yVDGib6.js} +1 -1
  13. package/dist/assets/{Options-oARQeeuT-BezHG2DV.js → Options-oARQeeuT-8pBPNhr8.js} +1 -1
  14. package/dist/assets/{OutputJax-DfUZNNCu-CiGZyFkj.js → OutputJax-DfUZNNCu-DGRVixhw.js} +1 -1
  15. package/dist/assets/{PrioritizedList-6lun1Xqd-1YFOP2UD.js → PrioritizedList-6lun1Xqd-DRdQwlR3.js} +1 -1
  16. package/dist/assets/{Styles-5NmwS-3M-CckuzfVu.js → Styles-5NmwS-3M-CqPSzBzv.js} +1 -1
  17. package/dist/assets/{TeXAtom-B5ni9myz-czpsshhI.js → TeXAtom-B5ni9myz-7xixu72b.js} +1 -1
  18. package/dist/assets/{abnfDiagram-VCTEODGH-BGhi-oVb-CxSD522K.js → abnfDiagram-VCTEODGH-BGhi-oVb-D9vo48Kc.js} +1 -1
  19. package/dist/assets/{arc-CK5ru50N-DKCl2r1T.js → arc-CK5ru50N-BlqR_Ske.js} +1 -1
  20. package/dist/assets/architecture-7GRP2DOG-otSkCkT5-ifTMCh6E.js +1 -0
  21. package/dist/assets/{architectureDiagram-5GKGNRK7-ELuPYMlf-Dopud1bd.js → architectureDiagram-5GKGNRK7-ELuPYMlf-CXbm8lLb.js} +1 -1
  22. package/dist/assets/{assistive-mml-CdU3rUpx-CBXDqBb7.js → assistive-mml-CdU3rUpx-CCKquCU4.js} +1 -1
  23. package/dist/assets/{blockDiagram-I7D4REHJ-BZ7-OGWQ-o5hOntDX.js → blockDiagram-I7D4REHJ-BZ7-OGWQ-B6ff9p2Y.js} +1 -1
  24. package/dist/assets/{c4Diagram-7LVT6UL2-DCovPZ_4-DmxXKUN2.js → c4Diagram-7LVT6UL2-DCovPZ_4-DNdoEnm0.js} +1 -1
  25. package/dist/assets/channel-Bmu_OOc6-DE5nBHdm.js +1 -0
  26. package/dist/assets/{chunk-4HAMMTFA-DqpzLmar-DiA8EWSJ.js → chunk-4HAMMTFA-DqpzLmar-ggP-4zbL.js} +1 -1
  27. package/dist/assets/{chunk-75Z2AOVW-CT5TYzp5-lldrjFxV.js → chunk-75Z2AOVW-CT5TYzp5-BAYR6Gyo.js} +1 -1
  28. package/dist/assets/{chunk-DU6HZSFF-BVf-rtdf-CwUL1NFa.js → chunk-DU6HZSFF-BVf-rtdf--dgDiPgV.js} +1 -1
  29. package/dist/assets/{chunk-F27PBJKO-rP8pUsk3-CzNXy7AU.js → chunk-F27PBJKO-rP8pUsk3-BUE2LzK8.js} +1 -1
  30. package/dist/assets/{chunk-IMKFNOWR-D_IT0r3G-B9KUwB52.js → chunk-IMKFNOWR-D_IT0r3G-DEq-HDMy.js} +1 -1
  31. package/dist/assets/{chunk-POPQ4Y6H-C5YURnHe-BSqsqkgY.js → chunk-POPQ4Y6H-C5YURnHe-BJUZN0LR.js} +1 -1
  32. package/dist/assets/{chunk-SHT3W25Y-CfF0J8Qh-5fHXXkh7.js → chunk-SHT3W25Y-CfF0J8Qh-BLNIWgW0.js} +1 -1
  33. package/dist/assets/{chunk-SVP7TREG-CTZbRIbX-CWrs29Kz.js → chunk-SVP7TREG-CTZbRIbX-DzHNPM77.js} +1 -1
  34. package/dist/assets/{chunk-TICWLB2K-DP1lYmZm-DDcA7wX4.js → chunk-TICWLB2K-DP1lYmZm-hPrDbcf2.js} +1 -1
  35. package/dist/assets/{chunk-XXDRQBXY-vg0kfC73-B9m3UCLq.js → chunk-XXDRQBXY-vg0kfC73-CA6dD2B2.js} +1 -1
  36. package/dist/assets/classDiagram-ZZMXUADV-BDQOAOBf-DxjUWGXR.js +1 -0
  37. package/dist/assets/classDiagram-v2-VYDZK3BY-CwPXtzoF-DxjUWGXR.js +1 -0
  38. package/dist/assets/{cose-bilkent-JH36ORCC-qIiD3RGD-8iqUd8fN.js → cose-bilkent-JH36ORCC-qIiD3RGD-CgZnBXDi.js} +1 -1
  39. package/dist/assets/cynefin-OW5HDTMX-Xr6IPdZG-DIN74MBN.js +1 -0
  40. package/dist/assets/{cynefinDiagram-5FMLGOSQ-BZrUCS-v-BjGdpO1b.js → cynefinDiagram-5FMLGOSQ-BZrUCS-v-lJIPhbyX.js} +1 -1
  41. package/dist/assets/{dagre-C2SZ8h4o-Bv8J8VUz.js → dagre-C2SZ8h4o-BgSfmUeF.js} +1 -1
  42. package/dist/assets/{dagre-GXQ25YYZ-CQcLM4na-BEUkvakq.js → dagre-GXQ25YYZ-CQcLM4na-mO2les8S.js} +1 -1
  43. package/dist/assets/{diagram-S7CK7UJ4-BdmcmUQh-C17Q-TTS.js → diagram-S7CK7UJ4-BdmcmUQh-DZZPbcxP.js} +1 -1
  44. package/dist/assets/{diagram-UQ7AKVKN-BtvmEiYl-sOGBhdh3.js → diagram-UQ7AKVKN-BtvmEiYl-CXPBFvdO.js} +1 -1
  45. package/dist/assets/{diagram-VSXAHHWV-qEG6N0bV-D-F3WtOn.js → diagram-VSXAHHWV-qEG6N0bV-2P5xWeyN.js} +1 -1
  46. package/dist/assets/{diagram-VX7I27RA-BA8is49N-DDC2KnwX.js → diagram-VX7I27RA-BA8is49N-Ce8-XrQI.js} +1 -1
  47. package/dist/assets/{diagram-Z3DM3KII-XPcU7IOQ-DnNwyEbY.js → diagram-Z3DM3KII-XPcU7IOQ-obu8Hmlg.js} +1 -1
  48. package/dist/assets/{dist-B_fklXwV.js → dist-BaZxeAEj.js} +1 -1
  49. package/dist/assets/{dist-BfgqBdfY.js → dist-DDtEYLX4.js} +1 -1
  50. package/dist/assets/{dist-DeiyD9Ki-CTmQI5BY.js → dist-DeiyD9Ki-B3Qn-Kh4.js} +1 -1
  51. package/dist/assets/{dist-B3WvX9Mu.js → dist-DoZPZkgo.js} +1 -1
  52. package/dist/assets/{dist-CqavRLcS.js → dist-DpShV5wo.js} +1 -1
  53. package/dist/assets/{ebnfDiagram-PWID7BFC-CTHIHdBS-BUK0gvW3.js → ebnfDiagram-PWID7BFC-CTHIHdBS-oyDbKuK_.js} +1 -1
  54. package/dist/assets/{erDiagram-RLTQ6QDP-DVpWTNZj-WczcFdQ5.js → erDiagram-RLTQ6QDP-DVpWTNZj-DD5MfJxq.js} +1 -1
  55. package/dist/assets/eventmodeling-NTZA5JFV-B7bRrpt_-ByYnQ_YE.js +1 -0
  56. package/dist/assets/flowDiagram-HODETNUW-r-3T5g30-DVt4ibmB.js +1 -0
  57. package/dist/assets/{ganttDiagram-EL5Y4UJY-BfAgk15F-DTGMCrLj.js → ganttDiagram-EL5Y4UJY-BfAgk15F-EJ3zMpou.js} +1 -1
  58. package/dist/assets/gitGraph-4MIJSDKK-BhzSx5wq-De8cUs1t.js +1 -0
  59. package/dist/assets/{gitGraphDiagram-WWUBYQGX-DEVVZ6wt-HdaEqyjq.js → gitGraphDiagram-WWUBYQGX-DEVVZ6wt-Dj16r719.js} +1 -1
  60. package/dist/assets/{graphlib-BYLhHyYO-DQGQ3Hua.js → graphlib-BYLhHyYO-BTZzAS5v.js} +1 -1
  61. package/dist/assets/{html-Z7dGbczU-CrSYt5SB.js → html-Z7dGbczU-DHXoUjco.js} +1 -1
  62. package/dist/assets/index-CziTLExz.css +1 -0
  63. package/dist/assets/{index-CFCwHCAC.js → index-DE93mXAl.js} +347 -345
  64. package/dist/assets/info-A6RAGUB7-fR2SUJmJ-DfOs01lb.js +1 -0
  65. package/dist/assets/{infoDiagram-27XIBGKW-CvHUhAHd-Bqc9FCJv.js → infoDiagram-27XIBGKW-CvHUhAHd-DQWRUxRY.js} +1 -1
  66. package/dist/assets/{ishikawaDiagram-5VMMS53U-CeqyKdwk-BTaokajf.js → ishikawaDiagram-5VMMS53U-CeqyKdwk-CaqBsvlx.js} +1 -1
  67. package/dist/assets/{journeyDiagram-3NMN7TZE-Jfi7MPDi-DG9WVFfp.js → journeyDiagram-3NMN7TZE-Jfi7MPDi-D2E43LwV.js} +1 -1
  68. package/dist/assets/{kanban-definition-UXKFOSKX-yOrlojns-B5-MNvAZ.js → kanban-definition-UXKFOSKX-yOrlojns-CHfxXoOA.js} +1 -1
  69. package/dist/assets/{lengths-CvmPfBid-Ccc3uBwx.js → lengths-CvmPfBid-pbRzi4y_.js} +1 -1
  70. package/dist/assets/{linear-jdmEy-qs-BRxqk9pe.js → linear-jdmEy-qs-YK-i28xN.js} +1 -1
  71. package/dist/assets/{liteAdaptor-pH5MXTG0-D2DVKbJh.js → liteAdaptor-pH5MXTG0-3egTzN5I.js} +1 -1
  72. package/dist/assets/{mathjax-DVEo4UYj-z2lf4I_o.js → mathjax-DVEo4UYj-DJM3fB8M.js} +1 -1
  73. package/dist/assets/{mermaid-parser.core-CzBW8Fof-kR4f_D21.js → mermaid-parser.core-CzBW8Fof-BLYnlega.js} +2 -2
  74. package/dist/assets/{mermaid.core-Dv4uylek-Ddm4KdK9.js → mermaid.core-Dv4uylek-BEK0XLuu.js} +4 -4
  75. package/dist/assets/{mindmap-definition-YA3MSWOX-DP_2R9Ga-BamcFU30.js → mindmap-definition-YA3MSWOX-DP_2R9Ga-BCnJS2OL.js} +1 -1
  76. package/dist/assets/{mo-Xv_6CC7A-C4qQoXD7.js → mo-Xv_6CC7A-fsTVwGAJ.js} +1 -1
  77. package/dist/assets/packet-AYTQ26CC-B_oKR9KZ-CIr_QE41.js +1 -0
  78. package/dist/assets/{pegDiagram-XKGWAZYB-BsBWryhi-BPOLi_DS.js → pegDiagram-XKGWAZYB-BsBWryhi-DnlKhijv.js} +1 -1
  79. package/dist/assets/pie-WAS4IAKB-D1ZXrN1W-DtjU4bsm.js +1 -0
  80. package/dist/assets/{pieDiagram-E7YTZNPT-OugkS2pU-BxJPZt-m.js → pieDiagram-E7YTZNPT-OugkS2pU-BQ62uhNn.js} +1 -1
  81. package/dist/assets/{quadrantDiagram-AXDQQJYC-DXEAAaUE-CeLGIG_l.js → quadrantDiagram-AXDQQJYC-DXEAAaUE-DjfBVEH6.js} +1 -1
  82. package/dist/assets/radar-RG4KPBEZ-BmUgDkiH-CuKD9jQP.js +1 -0
  83. package/dist/assets/railroad-74A4TZTK-6lEXVJo8-DYg5FlhY.js +1 -0
  84. package/dist/assets/railroad-abnf-HS5TGJTU-BG28Qiz1-CmzKOp6N.js +1 -0
  85. package/dist/assets/railroad-ebnf-LZEXJU2U-DzpHhtTH-Ca4CUyup.js +1 -0
  86. package/dist/assets/railroad-peg-WCYAUIDC-B2CHCTyG-DW1BPRzc.js +1 -0
  87. package/dist/assets/{railroadDiagram-O6MQD6OU-BJys8O96-CXO-wle8.js → railroadDiagram-O6MQD6OU-BJys8O96-DT83nUM3.js} +1 -1
  88. package/dist/assets/{requirementDiagram-BXWQKSXE-BaDgCtM5-Dt4Cfr6O.js → requirementDiagram-BXWQKSXE-BaDgCtM5-Bk6OMrCS.js} +1 -1
  89. package/dist/assets/{sankeyDiagram-P5KCCOFB-puZsr_vK-CMC9fgwz.js → sankeyDiagram-P5KCCOFB-puZsr_vK-B3mb-QqF.js} +1 -1
  90. package/dist/assets/{sequenceDiagram-WJ2MYXX4-D1ziEryC-DB5eVRRY.js → sequenceDiagram-WJ2MYXX4-D1ziEryC-DA35A244.js} +1 -1
  91. package/dist/assets/{src-CLC0ddSd-ByF0JTrb.js → src-CLC0ddSd-DXUH8o27.js} +1 -1
  92. package/dist/assets/{stateDiagram-D77RDMKH-HSEurT6h-BwSqvh1q.js → stateDiagram-D77RDMKH-HSEurT6h-BZdzB2jB.js} +1 -1
  93. package/dist/assets/stateDiagram-v2-MP3YSRHH-DnOa-Otc-CJvYdnZ6.js +1 -0
  94. package/dist/assets/{svg-D_RYJvQC-BPJwglJJ.js → svg-D_RYJvQC-CymKx1MQ.js} +1 -1
  95. package/dist/assets/{swimlanes-42K2YHIH-umBsuvsI-BAnYSTii.js → swimlanes-42K2YHIH-umBsuvsI-CFo1naDR.js} +1 -1
  96. package/dist/assets/swimlanesDiagram-VR7AAH4N-ZLUyNmnU-BUVjUqff.js +8 -0
  97. package/dist/assets/{tex-Dx2UJ3VU-CCsHHGCD.js → tex-Dx2UJ3VU-CQTk8uUD.js} +1 -1
  98. package/dist/assets/{timeline-definition-24CTP7MA-D9plt22Y-BX4CiOEk.js → timeline-definition-24CTP7MA-D9plt22Y-BhW4PYii.js} +1 -1
  99. package/dist/assets/treeView-Q6P3EWNA-BdvZ0_C5-DXP8JL2N.js +1 -0
  100. package/dist/assets/treemap-WGGIJYW6-CIJLPQlC-DfNE0Ylm.js +1 -0
  101. package/dist/assets/{vennDiagram-4TSXK5OY-DZe7wY85-BRytU48n.js → vennDiagram-4TSXK5OY-DZe7wY85-GcaRUyDZ.js} +1 -1
  102. package/dist/assets/wardley-WFR3VGLG-CKqtFD8V-Bq9rf08k.js +1 -0
  103. package/dist/assets/{wardleyDiagram-VM6X3IG4-DK9yytWx-DVNY3c-A.js → wardleyDiagram-VM6X3IG4-DK9yytWx-D9owzIEw.js} +1 -1
  104. package/dist/assets/{xychartDiagram-S5SC5T6Z-CLSO4Pc9-XnrHm8Bf.js → xychartDiagram-S5SC5T6Z-CLSO4Pc9-B-vSQvUZ.js} +1 -1
  105. package/dist/index.html +2 -2
  106. package/package.json +10 -10
  107. package/server/backends/fileChange.ts +61 -5
  108. package/server/files/documentWatch.ts +212 -0
  109. package/server/files/file-search.ts +185 -0
  110. package/server/files/files-browse.ts +139 -0
  111. package/server/files/pathContainment.ts +41 -0
  112. package/server/git/worktrees.ts +35 -6
  113. package/server/index.ts +12 -1
  114. package/server/infra/pubsub.ts +18 -0
  115. package/server/routes/routeParams.ts +20 -4
  116. package/server/routes/session-routes.ts +6 -5
  117. package/server/session/session-cwd.ts +13 -1
  118. package/server/skills/mulmoterminal-keys/SKILL.md +9 -6
  119. package/dist/assets/architecture-7GRP2DOG-otSkCkT5-YabtMIbr.js +0 -1
  120. package/dist/assets/channel-Bmu_OOc6-LXxQltDk.js +0 -1
  121. package/dist/assets/classDiagram-ZZMXUADV-BDQOAOBf-BUvThf0-.js +0 -1
  122. package/dist/assets/classDiagram-v2-VYDZK3BY-CwPXtzoF-BUvThf0-.js +0 -1
  123. package/dist/assets/cynefin-OW5HDTMX-Xr6IPdZG-BzdrGHCp.js +0 -1
  124. package/dist/assets/eventmodeling-NTZA5JFV-B7bRrpt_-izWjOGe8.js +0 -1
  125. package/dist/assets/flowDiagram-HODETNUW-r-3T5g30-D06E2Yur.js +0 -1
  126. package/dist/assets/gitGraph-4MIJSDKK-BhzSx5wq-HItyG1D5.js +0 -1
  127. package/dist/assets/index-ZQzSIgvD.css +0 -1
  128. package/dist/assets/info-A6RAGUB7-fR2SUJmJ-CCp3Pve-.js +0 -1
  129. package/dist/assets/packet-AYTQ26CC-B_oKR9KZ-P1h4UNRU.js +0 -1
  130. package/dist/assets/pie-WAS4IAKB-D1ZXrN1W-BejuFjHy.js +0 -1
  131. package/dist/assets/radar-RG4KPBEZ-BmUgDkiH-Wu_t-Az-.js +0 -1
  132. package/dist/assets/railroad-74A4TZTK-6lEXVJo8-DJe4dT-U.js +0 -1
  133. package/dist/assets/railroad-abnf-HS5TGJTU-BG28Qiz1-M9sfPaQt.js +0 -1
  134. package/dist/assets/railroad-ebnf-LZEXJU2U-DzpHhtTH-C1RD0jlr.js +0 -1
  135. package/dist/assets/railroad-peg-WCYAUIDC-B2CHCTyG-CsDICDaK.js +0 -1
  136. package/dist/assets/stateDiagram-v2-MP3YSRHH-DnOa-Otc-DgFYwvfo.js +0 -1
  137. package/dist/assets/swimlanesDiagram-VR7AAH4N-ZLUyNmnU-CM3sLR6V.js +0 -8
  138. package/dist/assets/treeView-Q6P3EWNA-BdvZ0_C5-Ce03QaRO.js +0 -1
  139. package/dist/assets/treemap-WGGIJYW6-CIJLPQlC-sLYXmnbr.js +0 -1
  140. package/dist/assets/wardley-WFR3VGLG-CKqtFD8V-CR-W1Hkl.js +0 -1
package/README.md CHANGED
@@ -1220,6 +1220,17 @@ tree; clicking a file opens it in a **CodeMirror** editor (Markdown / JS-TS / JS
1220
1220
  highlighting, everything else as plain text). Markdown files get a **Preview** toggle
1221
1221
  that renders via the server's sandboxed `…/md` HTML. **Save** (or ⌘/Ctrl-S) writes back.
1222
1222
 
1223
+ **The open view follows the file on disk.** When an agent in another cell — or any editor —
1224
+ rewrites what you are looking at, the editor and the preview catch up on their own; there is
1225
+ nothing to reload. If you have unsaved edits of your own, a banner asks which copy to keep
1226
+ rather than choosing for you.
1227
+
1228
+ The server watches a document while a view is open on it, and only under the workspace or under
1229
+ a directory one of your terminals is in — the same directories it serves files from. A Markdown
1230
+ file outside all of those still opens and still edits; it is picked up by the pane's periodic
1231
+ check rather than the moment it changes, and a Canvas card on it waits for the next thing that
1232
+ does announce.
1233
+
1223
1234
  **Beside an enlarged terminal, not only full-screen.** Expand a grid cell (**⤢**) and its
1224
1235
  header gains a **folder** toggle that splits the enlarged area in two: terminal on the left,
1225
1236
  the same explorer + editor on the right, rooted at that cell's directory. Drag the divider
@@ -1228,6 +1239,27 @@ shrinks the pane rather than reflowing xterm into garbage. It works in both zoom
1228
1239
  (cockpit roster and thumbnail filmstrip), the pane re-roots as you walk the zoom between
1229
1240
  terminals, and whether it's open plus how wide it is are remembered per browser.
1230
1241
 
1242
+ **Coming back looks the way you left it.** The pane remembers the open file, the directories
1243
+ you had expanded, and — for a Markdown file — whether you were reading it in **Preview** or
1244
+ editing it: per cell while the session lasts, and per directory across a browser reload (the
1245
+ first pane to open on that directory claims it, so a second terminal in the same repository
1246
+ starts on its own empty tree). A remembered Preview only comes back over that same file while
1247
+ it is still Markdown the server can render; anything else opens in the editor.
1248
+
1249
+ It comes back to **where** you were, too: the line at the top of the editor, the line the caret was
1250
+ on, and how far down the tree was scrolled. The top line matters on its own — scrolling moves
1251
+ neither the selection nor the caret, so someone reading without clicking would otherwise come back
1252
+ to the top of a file they were in the middle of. The caret is kept as a line rather than a scroll offset, so it survives
1253
+ the pane being a different width next time, and a line past the end of a file that has since been
1254
+ edited lands on the nearest real one. The Markdown preview's own scroll position is not remembered
1255
+ — its iframe is sandboxed to the point where the app cannot read it.
1256
+
1257
+ **It also paints before it has finished reading.** The last listing of each directory is kept, so
1258
+ opening the pane on one you have been to before shows that tree at once and swaps in what the
1259
+ server says when it arrives — including any directory you expanded while waiting. A directory the
1260
+ pane has not read before says so (`Loading…`) rather than claiming to be empty, and a read that
1261
+ fails shows the error rather than a tree it can no longer vouch for.
1262
+
1231
1263
  The toggle is not the only way in: while a cell is enlarged, **clicking a file path the agent
1232
1264
  printed** opens it here too, rather than in a new tab or full-screen — see
1233
1265
  [Clicking a file path](#clicking-a-file-path).
@@ -1244,6 +1276,21 @@ than letting you believe an ignore file was applied. There is also a
1244
1276
  [`files-find` shortcut](https://receptron.github.io/mulmoterminal/guide/en/config.html#keymap)
1245
1277
  with no default binding, which opens the pane first if it is closed.
1246
1278
 
1279
+ A second button searches **inside** the files. Matches are grouped by file with the matching
1280
+ lines under them, and picking one opens the file *and* puts the cursor on that line. Queries are
1281
+ literal unless the regex toggle is on, and case is smart — a lower-case query matches either case.
1282
+ It is `git grep` underneath, so the same `.gitignore` applies, files your agent created seconds ago
1283
+ are searched, and — because the default reads the **working tree** rather than the index — an
1284
+ edited-but-unstaged file is searched as it is on disk. The one file no on-disk search can read is
1285
+ the one you have open with unsaved edits. In the default literal mode that one is searched in the
1286
+ browser from the buffer and marked `unsaved`, so its line numbers are the ones on your screen. In
1287
+ **regex mode it is not searched at all** — running a pattern you are still typing on the thread that
1288
+ draws the UI can freeze the tab — so the file is left out and the panel says to save it. Either way
1289
+ its stale on-disk matches are dropped, because those line numbers describe a file you are not
1290
+ looking at. The
1291
+ [`files-search` shortcut](https://receptron.github.io/mulmoterminal/guide/en/config.html#keymap)
1292
+ is likewise unbound by default.
1293
+
1247
1294
  All reads and writes go through `GET/PUT /api/files/browse/*?cwd=&path=`, and every
1248
1295
  `path` is **contained within the project root** (server-side) — `..`/absolute escapes
1249
1296
  are rejected for reads and writes alike, so editing can't reach outside the directory
@@ -0,0 +1,42 @@
1
+ // The "this file changed" channel name.
2
+ //
3
+ // Both sides decide it: the server mints it when a file is written or seen to change, and the
4
+ // UI subscribes to it for the document it has open. That makes it a wire shape rather than a
5
+ // server detail, so it lives here.
6
+ //
7
+ // The format is @mulmoclaude/core/file-change's (`pluginFileChannel`), which the UI cannot
8
+ // import — that entry pulls in node:fs. A spec pins this copy to core's rather than trusting
9
+ // the two to stay the same by inspection.
10
+
11
+ const CHANNEL_PREFIX = "plugin:";
12
+ const PATH_MARKER = ":file:";
13
+
14
+ /** The scope the markdown plugin's View and the server's markdown matcher agree on. */
15
+ export const MARKDOWN_FILE_SCOPE = "markdown";
16
+
17
+ /** The channel one file is announced on, under one plugin scope. */
18
+ export function pluginFileChannel(scope: string, posixPath: string): string {
19
+ return `${CHANNEL_PREFIX}${scope}${PATH_MARKER}${posixPath}`;
20
+ }
21
+
22
+ /** The scope and path a channel names, or null when it is not one of ours.
23
+ *
24
+ * Split on the FIRST `:file:` only: a POSIX filename may itself contain a colon, so
25
+ * everything after that marker is the path verbatim and never re-parsed. */
26
+ export function parsePluginFileChannel(channel: string): { scope: string; path: string } | null {
27
+ if (!channel.startsWith(CHANNEL_PREFIX)) return null;
28
+ const rest = channel.slice(CHANNEL_PREFIX.length);
29
+ const marker = rest.indexOf(PATH_MARKER);
30
+ if (marker <= 0) return null;
31
+ const path = rest.slice(marker + PATH_MARKER.length);
32
+ return path ? { scope: rest.slice(0, marker), path } : null;
33
+ }
34
+
35
+ /** A path spelled the way the channel spells it.
36
+ *
37
+ * The publisher normalises to POSIX before it mints a channel, so a subscriber that keeps
38
+ * Windows separators names a channel nothing is ever published on — and nothing errors,
39
+ * which is why this is a function both sides call rather than a rule both sides remember. */
40
+ export function fileChannelPath(filePath: string): string {
41
+ return filePath.split("\\").join("/");
42
+ }
@@ -0,0 +1,211 @@
1
+ // What a content search IS, for the two sides that decide it together (#2140).
2
+ //
3
+ // In `common/` because BOTH ends read these rules, not because they happen to share a shape: the
4
+ // server builds git's argv from them, and the browser applies the same literal and case rules to
5
+ // the one file open in the editor with unsaved edits — which no on-disk search can see. If the two
6
+ // drifted, that one file would answer to a different notion of "case-insensitive" than every other
7
+ // file in the list.
8
+ //
9
+ // LITERAL MODE ONLY. In regex mode the browser does not search that buffer at all: the pattern
10
+ // comes from the query, and running one on the thread that draws the UI can hang the tab (the
11
+ // measurement is on `literalMatch`). The file is dropped and the panel says so.
12
+ //
13
+ // The git-specific half — argv, output parsing, the exit codes — stays in
14
+ // `server/files/file-search.ts`, where nothing in the browser has any use for it.
15
+ import { byCodeUnit } from "./byCodeUnit.js";
16
+
17
+ /** How many matching LINES are returned across the whole search. */
18
+ export const MAX_SEARCH_MATCHES = 500;
19
+
20
+ /** How many matching lines any ONE file may contribute. Without it a single generated file answers
21
+ * the whole budget and the result reads as "only this file matches". */
22
+ export const MAX_MATCHES_PER_FILE = 20;
23
+
24
+ /** How much of a matching line is carried. A minified bundle is one line of a hundred thousand
25
+ * characters, and shipping it whole would cost more than every other result together. */
26
+ export const MAX_SNIPPET_CHARS = 400;
27
+
28
+ /** Shorter than the shared git timeout: this runs while the user is typing. A repository too slow
29
+ * to answer inside it gives an empty result that says it is incomplete, not a hung panel. */
30
+ export const SEARCH_TIMEOUT_MS = 10_000;
31
+
32
+ export interface SearchRequest {
33
+ query: string;
34
+ /** Treat the query as a regular expression rather than as literal characters. */
35
+ regex: boolean;
36
+ /** Force case sensitivity. Absent means SMART CASE — see `wantsCaseSensitive`. */
37
+ caseSensitive?: boolean;
38
+ }
39
+
40
+ /** One matching line. `line` is 1-based, as git reports it and as an editor counts. */
41
+ export interface SearchMatch {
42
+ /** Relative to the searched directory, `/`-separated — the same spelling `/api/files/browse/*`
43
+ * already speaks, so a result can be opened without translation. */
44
+ path: string;
45
+ line: number;
46
+ /** The matching line, cut to MAX_SNIPPET_CHARS. */
47
+ text: string;
48
+ /** The line was longer than the cap and `text` is its beginning. */
49
+ clipped: boolean;
50
+ }
51
+
52
+ export interface SearchResult {
53
+ matches: SearchMatch[];
54
+ /** Some matches are NOT here — a cap cut the list, or git stopped early. The one wrong answer a
55
+ * search can give is "it is not there" when it only means "I did not look at all of it", so this
56
+ * is reported rather than inferred from the array's length (which cannot show a per-file cut). */
57
+ truncated: boolean;
58
+ /** `git` answered from a repository, so `.gitignore` was applied. `no-index` means it did not —
59
+ * there is no ignore file to apply outside a repository, and the UI says so rather than letting
60
+ * the reader assume one was read. */
61
+ source: "git" | "no-index";
62
+ }
63
+
64
+ /** Smart case: a query typed entirely in lower case matches either case, and one carrying an
65
+ * upper-case letter is taken literally. It is what every comparable tool does, which is the whole
66
+ * argument — a hand reaches for it without being taught.
67
+ *
68
+ * Decided on the query and NOT on a locale-aware fold: `toLowerCase()` on a Turkish `I` produces a
69
+ * dotless `ı` under some locales, so the comparison is against the code points as typed. */
70
+ export function wantsCaseSensitive(request: SearchRequest): boolean {
71
+ if (request.caseSensitive !== undefined) return request.caseSensitive;
72
+ return [...request.query].some((ch) => ch !== ch.toLowerCase() && ch === ch.toUpperCase());
73
+ }
74
+
75
+ /** A query with nothing to search for. Rejected rather than run: an empty pattern matches every
76
+ * line of every file, which is a slow way to answer nothing useful. Whitespace alone IS a real
77
+ * query — someone looking for a tab or trailing space means it — so only the empty string counts. */
78
+ export const isSearchable = (query: string): boolean => query.length > 0;
79
+
80
+ /** Matches grouped by file, files in path order and lines in file order — the shape the panel
81
+ * renders, decided here so the ordering is one rule rather than one per view.
82
+ *
83
+ * `byCodeUnit` rather than `localeCompare` for the reason the file list already uses it: a locale
84
+ * comparison reorders the same project differently on two machines. */
85
+ export function groupByFile(matches: SearchMatch[]): { path: string; matches: SearchMatch[] }[] {
86
+ const byPath = new Map<string, SearchMatch[]>();
87
+ matches.forEach((match) => {
88
+ const existing = byPath.get(match.path);
89
+ if (existing) existing.push(match);
90
+ else byPath.set(match.path, [match]);
91
+ });
92
+ return [...byPath.keys()].sort(byCodeUnit).map((path) => ({ path, matches: (byPath.get(path) ?? []).sort((a, b) => a.line - b.line) }));
93
+ }
94
+
95
+ /** Whether a LITERAL query matches a line, under the same case rule the server applies.
96
+ *
97
+ * A plain string search, deliberately — not a RegExp built from the query. `(a+)+b` against a
98
+ * THIRTY-TWO character line takes the better part of a minute in a JS engine, so a regex built
99
+ * from untrusted text cannot run on the thread that draws the UI: the tab would freeze, taking the
100
+ * unsaved buffer this whole mechanism exists to respect with it.
101
+ *
102
+ * Literal is the default mode and needs no regex to begin with, which is what makes the default
103
+ * path both correct and unable to hang. Regex mode does not come here at all — see
104
+ * `withBufferMatches`. */
105
+ function literalMatch(line: string, request: SearchRequest): boolean {
106
+ if (wantsCaseSensitive(request)) return line.includes(request.query);
107
+ return line.toLowerCase().includes(request.query.toLowerCase());
108
+ }
109
+
110
+ /** The matching lines of one in-memory buffer, in the shape the disk search returns.
111
+ *
112
+ * This exists because an unsaved buffer is invisible to ANY on-disk search — git, ripgrep or a
113
+ * hand-rolled scan alike — and the pane can have one. It is affordable precisely because there is
114
+ * at most one: `FilesPane` opens a single file, so this is never a scan of a project.
115
+ *
116
+ * LITERAL QUERIES ONLY. The caller decides that; see `withBufferMatches`. */
117
+ export function matchesInBuffer(path: string, text: string, request: SearchRequest): SearchMatch[] {
118
+ const out: SearchMatch[] = [];
119
+ text.split("\n").forEach((raw, index) => {
120
+ if (out.length >= MAX_MATCHES_PER_FILE) return;
121
+ const line = raw.replace(/\r$/, "");
122
+ if (!literalMatch(line, request)) return;
123
+ out.push({ path, line: index + 1, text: line.slice(0, MAX_SNIPPET_CHARS), clipped: line.length > MAX_SNIPPET_CHARS });
124
+ });
125
+ return out;
126
+ }
127
+
128
+ /** What the panel shows, once the open buffer has had its say. */
129
+ export interface BufferMerge {
130
+ matches: SearchMatch[];
131
+ /** The open buffer was NOT searched, so a match in it is not in this list. True only in regex
132
+ * mode — see `withBufferMatches`. The panel has to SAY this: silence would read as "there is
133
+ * nothing in that file", which is the one wrong answer a search can give. */
134
+ bufferUnsearched: boolean;
135
+ }
136
+
137
+ /**
138
+ * Disk matches with the open buffer's own answer substituted for it.
139
+ *
140
+ * The trap this closes is the INVERSE of the obvious one. Missing a match in an unsaved file is a
141
+ * loud failure — nothing appears. But a match found ON DISK in a file that is open and edited is a
142
+ * QUIET one: the line number and the snippet describe the saved text, so the snippet shows
143
+ * something not on screen and jumping lands in the wrong place.
144
+ *
145
+ * So the file's disk matches are dropped entirely rather than merged: the two describe different
146
+ * documents and there is no correspondence between their line numbers. **That half holds in BOTH
147
+ * modes** — it needs no matching at all, only the knowledge that the file has diverged.
148
+ *
149
+ * What regex mode loses is the second half: the buffer is not searched, because doing so means
150
+ * running an untrusted pattern on the UI thread (see `literalMatch`). The file is dropped and the
151
+ * panel says so, which is the safe direction — a note the reader can act on by saving the file,
152
+ * rather than a frozen tab or a line number pointing at the wrong place.
153
+ */
154
+ export function withBufferMatches(diskMatches: SearchMatch[], buffer: { path: string; text: string } | null, request: SearchRequest): BufferMerge {
155
+ if (!buffer) return { matches: diskMatches, bufferUnsearched: false };
156
+ const withoutStale = diskMatches.filter((match) => match.path !== buffer.path);
157
+ if (request.regex) return { matches: withoutStale, bufferUnsearched: true };
158
+ return { matches: [...withoutStale, ...matchesInBuffer(buffer.path, buffer.text, request)], bufferUnsearched: false };
159
+ }
160
+
161
+ /** How many lines above and below a selected match the panel shows. Small on purpose: the block
162
+ * replaces a one-line row, so every extra line pushes the rest of the list further down. */
163
+ export const CONTEXT_RADIUS_LINES = 2;
164
+
165
+ /** One line of a context window. Carries `clipped` for the reason `SearchMatch` does — a silently
166
+ * cut line is indistinguishable from a short one, and a minified file's single line is enormous. */
167
+ export interface WindowLine {
168
+ text: string;
169
+ clipped: boolean;
170
+ }
171
+
172
+ /** The lines around one line of a file. */
173
+ export interface LineWindow {
174
+ /** 1-based line number of `lines[0]`. Meaningless when `lines` is empty. */
175
+ from: number;
176
+ lines: WindowLine[];
177
+ }
178
+
179
+ /**
180
+ * The lines around `around` (1-based), clamped to the text.
181
+ *
182
+ * In `common/` because BOTH sides run it: the server over a file it read from disk, the browser
183
+ * over the buffer being edited — whose surroundings no on-disk read can produce. One rule rather
184
+ * than two, so the file on screen does not get a different notion of "the lines around this one"
185
+ * than every other file in the list.
186
+ *
187
+ * `around` past the end gives an EMPTY window rather than the tail of the file. The search answered
188
+ * before this read, so a file that shrank in between has no such line — and the tail would be
189
+ * numbered correctly while still being wrong: it would surround a match that is no longer there
190
+ * with a neighbourhood that looks real. Nothing at all says what is true, which is that the line
191
+ * the caller asked about is gone.
192
+ *
193
+ * The clause earns its place only at the BOUNDARY — one line past the end, where `to < from` has
194
+ * not yet become true on its own. Anything further out is already empty without it.
195
+ */
196
+ export function lineWindow(text: string, around: number, radius: number): LineWindow {
197
+ const split = text.split("\n");
198
+ // A file ending in a newline splits into a trailing "" that is not a line — git does not count
199
+ // it and neither does an editor. Only ONE is dropped: "a\n\n" really does have a blank line 2.
200
+ const all = split.length > 1 && split[split.length - 1] === "" ? split.slice(0, -1) : split;
201
+ const from = Math.max(1, around - radius);
202
+ const to = Math.min(all.length, around + radius);
203
+ if (around > all.length || to < from) return { from, lines: [] };
204
+ return {
205
+ from,
206
+ lines: all.slice(from - 1, to).map((raw) => {
207
+ const line = raw.replace(/\r$/, "");
208
+ return { text: line.slice(0, MAX_SNIPPET_CHARS), clipped: line.length > MAX_SNIPPET_CHARS };
209
+ }),
210
+ };
211
+ }
package/common/keymap.ts CHANGED
@@ -25,6 +25,7 @@ export const KEYMAP_ACTIONS = [
25
25
  "terminal-close",
26
26
  "terminal-restart",
27
27
  "files-find",
28
+ "files-search",
28
29
  "copy",
29
30
  "paste",
30
31
  ] as const;
@@ -52,9 +53,9 @@ export const TERMINAL_SCOPED_ACTIONS: readonly KeymapAction[] = ["copy", "paste"
52
53
  // Here rather than beside gridShortcutFor because BOTH sides decide from it: the grid dispatches on
53
54
  // it, and validateKeymap has to know that these decline the key — the handler returns WITHOUT
54
55
  // stopping the event, so a `send` on the same keystroke fires instead (codex on #1906).
55
- // `files-find` is here because the pane it opens exists only in the ENLARGED row (see
56
- // docs/grid-view-modes.md) — a tiled grid has nowhere to put it, so the key declines rather than
57
- // guessing which of nine terminals was meant.
56
+ // `files-find` and `files-search` are here because the pane they open exists only in the ENLARGED
57
+ // row (see docs/grid-view-modes.md) — a tiled grid has nowhere to put it, so the key declines
58
+ // rather than guessing which of nine terminals was meant.
58
59
  export const NEEDS_A_CURRENT_TERMINAL: readonly KeymapAction[] = [
59
60
  "zoom-next",
60
61
  "zoom-prev",
@@ -62,6 +63,7 @@ export const NEEDS_A_CURRENT_TERMINAL: readonly KeymapAction[] = [
62
63
  "terminal-close",
63
64
  "terminal-restart",
64
65
  "files-find",
66
+ "files-search",
65
67
  ];
66
68
 
67
69
  // The mirror of the list above: actions that walk the TILED grid, and so need nothing enlarged.