mulmoterminal 4.8.1 → 4.8.3

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 (183) hide show
  1. package/README.md +11 -2
  2. package/common/askQuestion.ts +217 -0
  3. package/common/bundledSkills.ts +1 -0
  4. package/common/collectionPortability.ts +4 -1
  5. package/common/dirChrome.ts +9 -0
  6. package/common/dirConfigSource.ts +2 -0
  7. package/common/githubPaneOrder.ts +83 -0
  8. package/common/gitlabHosts.ts +13 -2
  9. package/common/headerStatusColors.ts +122 -0
  10. package/common/hexColor.ts +12 -0
  11. package/common/terminalReplies.ts +66 -0
  12. package/common/toolGroups.ts +23 -0
  13. package/dist/assets/{abnfDiagram-N423BO3Z-D0pzqjdS-CTSEh1uL.js → abnfDiagram-N423BO3Z-D0pzqjdS-BUEzXU7-.js} +1 -1
  14. package/dist/assets/architecture-TIHT7OUA-Cqhmai3f-DKxow7MX.js +1 -0
  15. package/dist/assets/{architectureDiagram-T3A2C74G-CX-Yoe5G-D1TCsmEW.js → architectureDiagram-T3A2C74G-CX-Yoe5G-WO8ZZ5hV.js} +1 -1
  16. package/dist/assets/{blockDiagram-VBNYF7ZC-B1eQZQ68-0ZOTzgbN.js → blockDiagram-VBNYF7ZC-B1eQZQ68-k3zBEumU.js} +1 -1
  17. package/dist/assets/{c4Diagram-5PPSVZJV-SBrN9MrU-Dj-EgTgq.js → c4Diagram-5PPSVZJV-SBrN9MrU-DJyJmi0Z.js} +1 -1
  18. package/dist/assets/channel-DKMa7Sn5-DXfk-pmR.js +1 -0
  19. package/dist/assets/{chunk-2GRJ4B5K-DdkAx9o--DjDCETVM.js → chunk-2GRJ4B5K-DdkAx9o--CtXZO-EX.js} +1 -1
  20. package/dist/assets/{chunk-3NCLNEKW-CBztX243-DK2bYr2d.js → chunk-3NCLNEKW-CBztX243-ChFYkHfP.js} +1 -1
  21. package/dist/assets/{chunk-4I5QYGJK-CmBJaHzs-BdhY8OlN.js → chunk-4I5QYGJK-CmBJaHzs-Bm62Y3D2.js} +1 -1
  22. package/dist/assets/{chunk-5RXB4S5H-iaipMW_h-Dc9HCjZr.js → chunk-5RXB4S5H-iaipMW_h-DEVTDwqc.js} +1 -1
  23. package/dist/assets/{chunk-6Q2QTUOP-Cx8nz5_t-BmPIYTTz.js → chunk-6Q2QTUOP-Cx8nz5_t-BOfQsmdP.js} +1 -1
  24. package/dist/assets/{chunk-7Z6QIM7H-CZs6l8Md-DEA_98nb.js → chunk-7Z6QIM7H-CZs6l8Md-Do5_5MRy.js} +1 -1
  25. package/dist/assets/{chunk-GF5L2VYU-9OEKHEe4-DQnl6g7D.js → chunk-GF5L2VYU-9OEKHEe4-AmDI7rEM.js} +1 -1
  26. package/dist/assets/{chunk-I66GZJ75-DSPCVCny-DxpV96_X.js → chunk-I66GZJ75-DSPCVCny-j7cLCRvd.js} +1 -1
  27. package/dist/assets/{chunk-J7OUQ5F2-CQoryswr-BthKg0zB.js → chunk-J7OUQ5F2-CQoryswr-Cg0dLUo9.js} +2 -2
  28. package/dist/assets/{chunk-JQJVKLGR-6F2ui09L-QjpkYHh7.js → chunk-JQJVKLGR-6F2ui09L-zzRmhTVI.js} +1 -1
  29. package/dist/assets/{chunk-KBJHAD2P-DXXCSVOS-CEssdQjr.js → chunk-KBJHAD2P-DXXCSVOS-BTbG9EBk.js} +1 -1
  30. package/dist/assets/{chunk-NSK5VX7P-DQgMj0Kn-CdfyTnLD.js → chunk-NSK5VX7P-DQgMj0Kn-C7zBoCQL.js} +1 -1
  31. package/dist/assets/{chunk-QR6OTTB3-BQl8P7pD-DnkT63LY.js → chunk-QR6OTTB3-BQl8P7pD-MEPt0Y91.js} +1 -1
  32. package/dist/assets/{chunk-UBXNYLIW-BbdYEvTI-V5OTlgR8.js → chunk-UBXNYLIW-BbdYEvTI-VqdYieFu.js} +1 -1
  33. package/dist/assets/{chunk-W5SLKNZC-DSJLk1SI-B3X0CacL.js → chunk-W5SLKNZC-DSJLk1SI-VPw5Pjzg.js} +1 -1
  34. package/dist/assets/{chunk-WRU74C26-DOE_TBL8-Ktu8fy3t.js → chunk-WRU74C26-DOE_TBL8-DHbOuCok.js} +1 -1
  35. package/dist/assets/classDiagram-JCYQIIEL-C1ePmEG1-B3i_KKpa.js +1 -0
  36. package/dist/assets/classDiagram-v2-OCEON4UE-C_O3VNBf-B3i_KKpa.js +1 -0
  37. package/dist/assets/cynefin-VYW2F7L2-DvSvuXnG-CqfiaAyR.js +1 -0
  38. package/dist/assets/{cynefinDiagram-MW4NZA55-DuBeESb6-D1mkKqHb.js → cynefinDiagram-MW4NZA55-DuBeESb6-D6_mAjNA.js} +1 -1
  39. package/dist/assets/{dagre-VZM6K2ZE-W5cB3U70-CmGRjn5N.js → dagre-VZM6K2ZE-W5cB3U70-D7FFnwoM.js} +1 -1
  40. package/dist/assets/{diagram-7IWD3JNH-Fk1aWqfX-5ZttPYaC.js → diagram-7IWD3JNH-Fk1aWqfX-Bh12UhXU.js} +1 -1
  41. package/dist/assets/{diagram-B4RE2ZJO-q-8xygfU-Jaes9Saf.js → diagram-B4RE2ZJO-q-8xygfU-C9wxc-GM.js} +1 -1
  42. package/dist/assets/{diagram-LBJQPF4R-1SKCcOis-DOnKMUhx.js → diagram-LBJQPF4R-1SKCcOis-CqRHacMO.js} +1 -1
  43. package/dist/assets/{diagram-Q27KOJAE-CtW4StAQ-CK--yh3Q.js → diagram-Q27KOJAE-CtW4StAQ-CYiDyxcF.js} +1 -1
  44. package/dist/assets/{diagram-UB23O5K3-r5fK-tbF-CDEwYerq.js → diagram-UB23O5K3-r5fK-tbF-Bi_FwLPS.js} +1 -1
  45. package/dist/assets/{dist-Cf1PlkcF.js → dist-CO4BIKZL.js} +1 -1
  46. package/dist/assets/{dist-CSWHG2cH.js → dist-CqzxGXJS.js} +1 -1
  47. package/dist/assets/{dist-BVPKfO9h.js → dist-cFsAUX4o.js} +1 -1
  48. package/dist/assets/{dist-Di66sOMV.js → dist-eE3p97ig.js} +1 -1
  49. package/dist/assets/{ebnfDiagram-BXEA7PRR-BZoIqTyx-Bd0ceZ6T.js → ebnfDiagram-BXEA7PRR-BZoIqTyx-Cd_1jE2t.js} +1 -1
  50. package/dist/assets/{erDiagram-JOGREHBK-CM8pI2JI-B5EFrDqh.js → erDiagram-JOGREHBK-CM8pI2JI-Buk10pBd.js} +1 -1
  51. package/dist/assets/eventmodeling-45OFAUF4-jdctDg0T-Bn8nOYEy.js +1 -0
  52. package/dist/assets/flowDiagram-UKHOOZJN-CR3dcoRl-BxBIpUfi.js +1 -0
  53. package/dist/assets/{ganttDiagram-PKOTCBZU-DYTkxDIh-CdH3rbwy.js → ganttDiagram-PKOTCBZU-DYTkxDIh-CqU6YC49.js} +1 -1
  54. package/dist/assets/gitGraph-TEB2WS4Q-C4cKwl5j-Dbu1Qyjw.js +1 -0
  55. package/dist/assets/{gitGraphDiagram-DS77QQ5N-DafidN4D-dgab4OC4.js → gitGraphDiagram-DS77QQ5N-DafidN4D-w68Vbb_a.js} +1 -1
  56. package/dist/assets/index-BjTAj1ZH.css +1 -0
  57. package/dist/assets/index-Cuz-LCIn.js +625 -0
  58. package/dist/assets/info-DKCQHKI2-DPUCBYWS-SU-TWbo5.js +1 -0
  59. package/dist/assets/{infoDiagram-6WML65LV-DxSxsg1o-D4YGxu1g.js → infoDiagram-6WML65LV-DxSxsg1o-BsTGEEUd.js} +1 -1
  60. package/dist/assets/{ishikawaDiagram-WSZJBQD7-kAIMMFB5-wsr5xc5H.js → ishikawaDiagram-WSZJBQD7-kAIMMFB5-o1K9iQLz.js} +1 -1
  61. package/dist/assets/{journeyDiagram-NVQOT4AX-ClybI8XI-DPUOB6uo.js → journeyDiagram-NVQOT4AX-ClybI8XI-ffXeCp87.js} +1 -1
  62. package/dist/assets/{kanban-definition-27J2QSJJ-DDYHyW1q-CZgSIoc2.js → kanban-definition-27J2QSJJ-DDYHyW1q-DrGRavO5.js} +1 -1
  63. package/dist/assets/{lib-C4gx-pAc.js → lib-B-Uaiffr.js} +1 -1
  64. package/dist/assets/{line-CtRAsVKI-BhCm0031.js → line-CtRAsVKI-BNctooIe.js} +1 -1
  65. package/dist/assets/{marp-CeXrcVjr.js → marp-BOJtZch0.js} +1 -1
  66. package/dist/assets/{mermaid-parser.core-C1baW7NG-m5_U2vyD.js → mermaid-parser.core-C1baW7NG-Bbu-Zwi4.js} +2 -2
  67. package/dist/assets/{mermaid.core-CGR3z-ay-DvwBCqCs.js → mermaid.core-CGR3z-ay-CFg0QiLR.js} +3 -3
  68. package/dist/assets/{mindmap-definition-FAOFIHXS-DU-wbYKK-kWq_dek4.js → mindmap-definition-FAOFIHXS-DU-wbYKK-CgvK273C.js} +1 -1
  69. package/dist/assets/packet-7NZHBO7P-CC7His0F-DAOgr6cQ.js +1 -0
  70. package/dist/assets/{pegDiagram-VL7TDLO6-CZuV1UUk-4WS0bS_y.js → pegDiagram-VL7TDLO6-CZuV1UUk-B6YJmD0c.js} +1 -1
  71. package/dist/assets/pie-RZYD4A2V-CCrcq2sL-xi5VG5VX.js +1 -0
  72. package/dist/assets/{pieDiagram-7S7Q4E2Y-B6cd1CWd-D6qNs4Iu.js → pieDiagram-7S7Q4E2Y-B6cd1CWd-rfzlH8Ma.js} +1 -1
  73. package/dist/assets/{quadrantDiagram-CIZ2JOQS-BvGHVjRA-CXWlp-3-.js → quadrantDiagram-CIZ2JOQS-BvGHVjRA-NzM-AzBP.js} +1 -1
  74. package/dist/assets/radar-I7S5WNFK-C5TUrpHf-i8K7RZ0u.js +1 -0
  75. package/dist/assets/railroad-3IZDKUUU-CGuuFEd8-DB7F_dkQ.js +1 -0
  76. package/dist/assets/railroad-abnf-AHOZXSZD-DuoCSFZY-CDOZa3yV.js +1 -0
  77. package/dist/assets/railroad-ebnf-EBAXGLYW-ig-DG9e_-B6N2Fb5S.js +1 -0
  78. package/dist/assets/railroad-peg-LSFZ7HO6-BkBgpyvu-BgOVUqp7.js +1 -0
  79. package/dist/assets/{railroadDiagram-AXF67PYL-DDtBla5s-C5dmRUSB.js → railroadDiagram-AXF67PYL-DDtBla5s-CfdyAahV.js} +1 -1
  80. package/dist/assets/{requirementDiagram-LRYGKXZP-DEh2MJlW-Dx5xtPm0.js → requirementDiagram-LRYGKXZP-DEh2MJlW-uD_TKJdP.js} +1 -1
  81. package/dist/assets/{sankeyDiagram-W5VNT64P-CHkaBx7U-DfX5prrR.js → sankeyDiagram-W5VNT64P-CHkaBx7U-CUzwA-x1.js} +1 -1
  82. package/dist/assets/{sequenceDiagram-SI44F4Z6-D8tHDhWP-BWGOYuS_.js → sequenceDiagram-SI44F4Z6-D8tHDhWP-8SAFRxEZ.js} +1 -1
  83. package/dist/assets/{stateDiagram-OKZ733FA-KNjKsfsT-Dxoi_j8b.js → stateDiagram-OKZ733FA-KNjKsfsT-xMpzFiGU.js} +1 -1
  84. package/dist/assets/stateDiagram-v2-UEYNNEHI-B88w7Ous-CPHeWl4W.js +1 -0
  85. package/dist/assets/{swimlanes-SLNWSIFB-D_yJNxBK-BuG8H1q8.js → swimlanes-SLNWSIFB-D_yJNxBK-CnzP6SsO.js} +1 -1
  86. package/dist/assets/swimlanesDiagram-ULZ7WXOC-DNcZzqTp-CYrdlCH9.js +8 -0
  87. package/dist/assets/{timeline-definition-Z64GVDOM-BcEcbSq1-D4hjgInK.js → timeline-definition-Z64GVDOM-BcEcbSq1-C8coL46S.js} +1 -1
  88. package/dist/assets/treeView-QDETBFTQ-CqfT0I1z-DoUWczkJ.js +1 -0
  89. package/dist/assets/treemap-6X3UGDF4-CiJkiGWo-DYceyM_q.js +1 -0
  90. package/dist/assets/{vennDiagram-T6HMQDX7-CxS3fcVg-DkIZ05uL.js → vennDiagram-T6HMQDX7-CxS3fcVg-E_L5qNHt.js} +1 -1
  91. package/dist/assets/wardley-OPB4EBWU-D1tuVHAV-D0RuWWEW.js +1 -0
  92. package/dist/assets/{wardleyDiagram-T6FBY63Y-D_XCiR14-DiKk_43E.js → wardleyDiagram-T6FBY63Y-D_XCiR14-C1PqnnKt.js} +1 -1
  93. package/dist/assets/{xychartDiagram-ELKLHX3M-BU4yBDeE-4brN3p2i.js → xychartDiagram-ELKLHX3M-BU4yBDeE-Dm2FQAqw.js} +1 -1
  94. package/dist/index.html +2 -2
  95. package/package.json +13 -10
  96. package/server/backends/collectionSelfContainment.ts +24 -3
  97. package/server/backends/collections.ts +24 -4
  98. package/server/backends/docPath.ts +15 -0
  99. package/server/backends/markdown.ts +24 -6
  100. package/server/backends/remoteHost/getFeed.spec.ts +3 -0
  101. package/server/backends/remoteHost/handlers/deps.ts +6 -0
  102. package/server/backends/remoteHost/handlers/listSkills.ts +6 -0
  103. package/server/backends/remoteHost/handlers/terminalSession.ts +45 -1
  104. package/server/backends/remoteHost/handlers.spec.ts +74 -0
  105. package/server/backends/remoteHost/index.ts +2 -0
  106. package/server/backends/remoteHost/routes.spec.ts +55 -28
  107. package/server/backends/remoteHost/session.spec.ts +19 -1
  108. package/server/backends/remoteHost/session.ts +18 -0
  109. package/server/backends/remoteView.ts +18 -6
  110. package/server/backends/sharedApp/appViews.ts +494 -0
  111. package/server/backends/sharedApp/context.ts +271 -0
  112. package/server/backends/sharedApp/declare.ts +552 -0
  113. package/server/backends/sharedApp/deploy.ts +346 -0
  114. package/server/backends/sharedApp/ensureAid.ts +49 -0
  115. package/server/backends/sharedApp/exclusivity.ts +208 -0
  116. package/server/backends/sharedApp/manifestWrite.ts +207 -0
  117. package/server/backends/sharedApp/publicForm.ts +246 -0
  118. package/server/backends/sharedApp/publicView.ts +247 -0
  119. package/server/backends/sharedApp/publish.ts +380 -0
  120. package/server/backends/sharedApp/records.ts +80 -0
  121. package/server/backends/sharedApp/scopedFields.ts +189 -0
  122. package/server/backends/sharedApp/serialize.ts +33 -0
  123. package/server/backends/sharedApp/slug.ts +165 -0
  124. package/server/backends/sharedApp/staged.ts +55 -0
  125. package/server/backends/sharedApp/unpublish.ts +117 -0
  126. package/server/backends/sharedApp/writes.ts +57 -0
  127. package/server/backends/sharedCollections.ts +46 -0
  128. package/server/config/app-config.ts +37 -0
  129. package/server/config/config-body.ts +1 -1
  130. package/server/config/config-routes.ts +8 -0
  131. package/server/config/config-schema.ts +36 -0
  132. package/server/config/dir-config.ts +8 -0
  133. package/server/config/worktree-dir-config.ts +27 -1
  134. package/server/index.ts +21 -16
  135. package/server/infra/collection-tool.ts +26 -16
  136. package/server/infra/collectionToolAid.ts +123 -0
  137. package/server/infra/host-tools.ts +4 -1
  138. package/server/infra/plugins-registry.ts +6 -2
  139. package/server/infra/shared-app-tool.ts +274 -0
  140. package/server/routes/app-routes.ts +35 -20
  141. package/server/routes/hook-routes.ts +35 -2
  142. package/server/routes/plugin-routes.ts +30 -0
  143. package/server/routes/tool-routes.ts +34 -0
  144. package/server/session/answerQuestion.ts +136 -0
  145. package/server/session/answerQuestionOnHost.ts +63 -0
  146. package/server/session/last-turn.ts +19 -0
  147. package/server/session/lifecycle.ts +6 -0
  148. package/server/session/pty-connection.ts +11 -0
  149. package/server/session/pty-exit.ts +4 -0
  150. package/server/session/session-reads.ts +16 -1
  151. package/server/session/task-push.ts +10 -4
  152. package/server/session/terminal-replay.ts +23 -1
  153. package/server/session/write-to-session.ts +84 -0
  154. package/server/skills/mulmoterminal-config/SKILL.md +5 -0
  155. package/server/skills/mulmoterminal-dirs/SKILL.md +50 -2
  156. package/server/skills/mulmoterminal-keys/SKILL.md +25 -0
  157. package/server/skills/mulmoterminal-shared-app/SKILL.md +505 -0
  158. package/server/skills/mulmoterminal-shared-app/templates/gym.md +198 -0
  159. package/server/skills/mulmoterminal-shared-app/templates/meeting-room.md +320 -0
  160. package/server/skills/mulmoterminal-shared-app/templates/salon.md +414 -0
  161. package/dist/assets/architecture-TIHT7OUA-Cqhmai3f-DYURJf3a.js +0 -1
  162. package/dist/assets/channel-DKMa7Sn5-BaH4NZYv.js +0 -1
  163. package/dist/assets/classDiagram-JCYQIIEL-C1ePmEG1-BW-s5osU.js +0 -1
  164. package/dist/assets/classDiagram-v2-OCEON4UE-C_O3VNBf-BW-s5osU.js +0 -1
  165. package/dist/assets/cynefin-VYW2F7L2-DvSvuXnG-AwH2T8qv.js +0 -1
  166. package/dist/assets/eventmodeling-45OFAUF4-jdctDg0T-_-vzwCaU.js +0 -1
  167. package/dist/assets/flowDiagram-UKHOOZJN-CR3dcoRl-CTSDZ3kq.js +0 -1
  168. package/dist/assets/gitGraph-TEB2WS4Q-C4cKwl5j-DJFY2Jhk.js +0 -1
  169. package/dist/assets/index-ClKZRsCv.css +0 -1
  170. package/dist/assets/index-jaLr_Prz.js +0 -623
  171. package/dist/assets/info-DKCQHKI2-DPUCBYWS-3rgS_gc8.js +0 -1
  172. package/dist/assets/packet-7NZHBO7P-CC7His0F-BdxXKgeT.js +0 -1
  173. package/dist/assets/pie-RZYD4A2V-CCrcq2sL-CP-ev46m.js +0 -1
  174. package/dist/assets/radar-I7S5WNFK-C5TUrpHf-DWVX-MsP.js +0 -1
  175. package/dist/assets/railroad-3IZDKUUU-CGuuFEd8-DKlMYwIS.js +0 -1
  176. package/dist/assets/railroad-abnf-AHOZXSZD-DuoCSFZY-CoCu9q79.js +0 -1
  177. package/dist/assets/railroad-ebnf-EBAXGLYW-ig-DG9e_-xwwvt-Nh.js +0 -1
  178. package/dist/assets/railroad-peg-LSFZ7HO6-BkBgpyvu-DKXIsmZD.js +0 -1
  179. package/dist/assets/stateDiagram-v2-UEYNNEHI-B88w7Ous-RHLLKJ5Z.js +0 -1
  180. package/dist/assets/swimlanesDiagram-ULZ7WXOC-DNcZzqTp-7gQf7HNa.js +0 -8
  181. package/dist/assets/treeView-QDETBFTQ-CqfT0I1z-D-gX-9lw.js +0 -1
  182. package/dist/assets/treemap-6X3UGDF4-CiJkiGWo-C90mQNBy.js +0 -1
  183. package/dist/assets/wardley-OPB4EBWU-D1tuVHAV-DdIlimET.js +0 -1
package/README.md CHANGED
@@ -595,6 +595,7 @@ The Settings modal (the gear button) persists per-user UI choices to `~/.mulmote
595
595
  | `worklogIntervalHours` | Worklog cadence in hours (default `6`, clamped to `1`–`168`). A stepper in the same Settings section covers the range. |
596
596
  | `terminalSubmit` | Which bytes Claude reads as **submit** vs **newline**: `"cr"` (default — Enter submits, Shift+Enter makes a newline) or `"esc-cr"` (for a Claude Code rebound the other way). Applies to the keyboard **and** the phone remote-view submit, for **Claude sessions only** (shell/codex keep plain Enter). See the [Configuration guide](https://receptron.github.io/mulmoterminal/guide/en/config.html#terminal-submit). Settings → **Terminal keys** offers both, worded as behaviour. |
597
597
  | `copyOnSelect` | `true` puts a **mouse selection on the clipboard the moment it settles**, with no key pressed (the PuTTY / iTerm2 behaviour). **Off by default** — it changes the clipboard when you may only have meant to highlight something. There is a checkbox in Settings → **Terminal keys**, applied at once; a hand edit of the file needs a **server restart, then a tab reload** (the server reads this file once at startup, and the browser reads the value from it on load). Composes with the `copy` keymap action rather than replacing it. Over plain `http://` the browser gives a page no clipboard access, so a fallback asks xterm to copy instead; see the [Configuration guide](https://receptron.github.io/mulmoterminal/guide/en/config.html#copy-on-select). |
598
+ | `questionPaneEnabled` | `true` offers a Claude session's **`AskUserQuestion` choices as buttons** in a pane beside the enlarged terminal. **The terminal's own dialog stays** and the pane drives it — a click presses the arrow keys and Enter in the real dialog, so either end can answer and the first one wins. Claude sessions only (the choices arrive on Claude Code's hooks). A **single** question can also be answered in your own words — a text box under the buttons writes into the dialog's own `Type something` row; several questions at once, or a multi-select one, get buttons only, and `Chat about this` stays in the terminal. **Off by default** — it lets a pane type into your terminal. Checkbox in Settings → **Terminal keys**, applied at once (the server re-reads the file per question). See the [Feature reference](https://receptron.github.io/mulmoterminal/guide/en/features.html#question-pane). |
598
599
  | `decisionDigest` | Keep a **Markdown digest of the decisions this project's sessions asked for**, refreshed at startup and every few hours, so an agent can read what has already been decided before asking something similar. Written to `~/.mulmoterminal/decisions/<project>.md` (never into your repository) and served to agents by the bundled `mulmoterminal-decisions` skill. **Off by default** — it is a vision-stage idea, and it writes a file that would otherwise not exist. The digest holds dated facts, never inferred rules. Settings → **Sessions and background tasks** has the switch. |
599
600
  | `issueWorkComments` | Let a cell **comment on the issue it is working on**: **one comment**, posted when the work starts and then **edited** as the PR opens and merges (closing the issue if the forge has not already), each milestone stamped in UTC. The comment names the working **directory** it happened in — the folder name only, never the path — so a reader can tell which clone, and two terminals do not start the same issue twice. It says it came from MulmoTerminal. CI is deliberately not reported: it is on the PR already, and it flaps. **Off by default**; it writes to the forge, often on somebody else's issue. Needs the matching CLI logged in — `gh` for GitHub, `glab` for gitlab.com and any host declared in `gitlabHosts`. See the [Configuration guide](https://receptron.github.io/mulmoterminal/guide/en/config.html#issue-work-comments). Editable in Settings → **GitHub and GitLab**. |
600
601
  | `prWorkdirFooter` | Ends a PR body with `work in <clone>` — the directory name of the clone the work happened in, so a PR says which of several side-by-side checkouts produced it. Applies to **both** paths that open PRs here: **⧉ Open PR** appends it to the PR it creates, and every Claude session is told to end the bodies it writes with the same line (the name is resolved by the server, so a session inside a managed worktree still names the main checkout). **On by default**; set `false` to opt out, from Settings → **GitHub and GitLab** or the file — read per PR and per session spawn, so no restart is needed, and a second MulmoTerminal beside this one sees the change too. Appending is idempotent: an existing PR never gets a second copy. |
@@ -724,7 +725,13 @@ malformed file is ignored.
724
725
  "icon": "docs/logo.png", // image on this dir's cells (path here, URL, or data:); omit to use the repo's favicon
725
726
  "badgeColor": "#cf222e", // badge color (hex #rrggbb)
726
727
  "headerColor": "#190a23", // cell header background (hex #rrggbb)
727
- "headerTextColor": "#ffffff", // cell header text color (hex #rrggbb)
728
+ "headerTextColor": "#ffffff", // cell header text color while idle (hex #rrggbb)
729
+ "headerStatusColors": { // what the header shows once a status takes the background over
730
+ "working": "#6d28d9", // just the background — the text colour is derived from it
731
+ "done": { "background": "#166534" },
732
+ "blocked": { "background": "#7c2d12", "text": "#ffe8a3" }
733
+ },
734
+ "headerStatusTint": "background", // "none" keeps headerColor while working/done (not blocked)
728
735
  "cellColor": "#101014", // cell body background (hex #rrggbb)
729
736
  "cellBorderColor": "#2a2a4e", // cell border color (hex #rrggbb)
730
737
  "dotColor": "#00e676", // idle status dot (hex #rrggbb)
@@ -786,7 +793,9 @@ Settings → Directory settings names both files and lists which keys the local
786
793
  | `icon` | An **image** marking this directory — shown in the cell header, the cockpit roster, the filmstrip thumbnails, the launcher's directory chips, and the phone's terminal list and terminal screen. Either a path **relative to this directory** (an absolute path, or a `../` that escapes it, is rejected), an `http(s)://` URL, or a `data:image/…` URI. PNG / JPEG / **GIF (animated plays)** / WebP / AVIF / SVG / ICO / BMP. Not to be confused with a header **button's** `icon`, which is a Material Symbols name. **Omit it and the repository's own favicon is used** (`public/favicon.svg`, `apple-touch-icon.png`, a web manifest — see `autoDirIcon`); `false` means no icon here and stops that search. |
787
794
  | `badgeColor` | Badge background color (`#rrggbb`); text auto-contrasts. |
788
795
  | `headerColor` | Header **background** color (`#rrggbb`) — the grid cell's header row and the terminal's own header row (grid row 2). While a terminal is working/blocked the status tint still shows; the custom color applies when idle. |
789
- | `headerTextColor` | Header **text** color (`#rrggbb`) — everything written on the header: the dir path, title and prompt, plus the model/context badge, the token counts and any custom chip. **Omit it and a readable colour is derived from `headerColor`**, while that colour is what shows (a working/blocked cell paints its own header tint, so its text returns to the theme's). |
796
+ | `headerTextColor` | Header **text** color (`#rrggbb`) — everything written on the header: the dir path, title and prompt, plus the model/context badge, the token counts and any custom chip. **Omit it and a readable colour is derived from `headerColor`.** It applies while that colour is what shows: a working/done/blocked cell paints the theme's own status tint, so its text returns to the theme's too — an ink chosen for your header colour is not readable on a tint the theme mixed. Recolour those states with `headerStatusColors` instead. |
797
+ | `headerStatusColors` | What the header shows once a **status** owns the background: an object keyed by `working` / `done` / `blocked` (there is no `idle` — `headerColor` is idle). Each value is a background `#rrggbb`, or `{ "background": …, "text": … }`. **Omit `text` and a readable one is derived from the background**, so naming one colour can never come out unreadable. A status you don't name keeps the theme's tint. |
798
+ | `headerStatusTint` | `"background"` (default) lets a status replace the header background. `"none"` keeps `headerColor` while **working** and **done** — the status still reads from the cell border, the status dot and the pill. It deliberately does **not** reach `blocked`, the one state where nothing proceeds until you answer; give that state a colour of its own in `headerStatusColors` if you want one. |
790
799
  | `cellColor` | Cell **body background** color (`#rrggbb`) — the frame around the terminal. |
791
800
  | `cellBorderColor` | Cell **border** color (`#rrggbb`). The status frame (working/blocked) still overrides it while active. |
792
801
  | `dotColor` | **Idle** status-dot color (`#rrggbb`). The working/waiting colors are unchanged so the activity signal stays intact. |
@@ -0,0 +1,217 @@
1
+ import { isRecord } from "./isRecord.js";
2
+
3
+ // The pub/sub channel carrying "this session is asking the user a question". Both sides decide
4
+ // from it — the server publishes what the PreToolUse hook reported, the pane listens to know a
5
+ // dialog is up and what it offers — so the name and the payload shape live here.
6
+ export const ASK_QUESTION_CHANNEL = "ask-question";
7
+
8
+ /** The tool name whose input this module reads. Claude Code's built-in question dialog. */
9
+ export const ASK_QUESTION_TOOL = "AskUserQuestion";
10
+
11
+ export interface AskOption {
12
+ label: string;
13
+ description?: string;
14
+ }
15
+
16
+ export interface AskQuestion {
17
+ question: string;
18
+ header: string;
19
+ options: AskOption[];
20
+ multiSelect: boolean;
21
+ }
22
+
23
+ export interface AskQuestionEvent {
24
+ sessionId: string;
25
+ toolUseId: string;
26
+ questions: AskQuestion[];
27
+ }
28
+
29
+ /** The same dialog, now answered — in the terminal, in the pane, or cancelled with Esc. The pane
30
+ * drops its buttons on this: keys aimed at a dialog that has closed would reach the prompt
31
+ * underneath, where Down walks the input history and Enter would re-submit what it found. */
32
+ export interface AskQuestionDone {
33
+ sessionId: string;
34
+ toolUseId: string;
35
+ done: true;
36
+ }
37
+
38
+ const parseOption = (value: unknown): AskOption | null => {
39
+ if (!isRecord(value) || typeof value.label !== "string" || !value.label) return null;
40
+ return typeof value.description === "string" ? { label: value.label, description: value.description } : { label: value.label };
41
+ };
42
+
43
+ const isOption = (value: AskOption | null): value is AskOption => value !== null;
44
+
45
+ const parseQuestion = (value: unknown): AskQuestion | null => {
46
+ if (!isRecord(value) || typeof value.question !== "string" || !Array.isArray(value.options)) return null;
47
+ const options = value.options.map(parseOption).filter(isOption);
48
+ // Length mismatch means one option failed to parse: answering by index against a list we
49
+ // could not read whole would pick the wrong row, so the whole question is rejected instead.
50
+ if (options.length === 0 || options.length !== value.options.length) return null;
51
+ return { question: value.question, header: typeof value.header === "string" ? value.header : "", options, multiSelect: value.multiSelect === true };
52
+ };
53
+
54
+ const isQuestion = (value: AskQuestion | null): value is AskQuestion => value !== null;
55
+
56
+ /** Read a PreToolUse `tool_input` for AskUserQuestion. Null when it is not one, or not whole. */
57
+ export const parseAskQuestions = (toolInput: unknown): AskQuestion[] | null => {
58
+ if (!isRecord(toolInput) || !Array.isArray(toolInput.questions)) return null;
59
+ const questions = toolInput.questions.map(parseQuestion).filter(isQuestion);
60
+ if (questions.length === 0 || questions.length !== toolInput.questions.length) return null;
61
+ return questions;
62
+ };
63
+
64
+ const identifiesADialog = (data: unknown): data is Record<string, unknown> & { sessionId: string; toolUseId: string } =>
65
+ isRecord(data) && typeof data.sessionId === "string" && typeof data.toolUseId === "string";
66
+
67
+ export const isAskQuestionEvent = (data: unknown): data is AskQuestionEvent =>
68
+ identifiesADialog(data) && data.done !== true && parseAskQuestions(data) !== null;
69
+
70
+ export const isAskQuestionDone = (data: unknown): data is AskQuestionDone => identifiesADialog(data) && data.done === true;
71
+
72
+ // The close is published even while the pane is switched OFF, and only the offer is gated.
73
+ // Turning the switch off mid-dialog would otherwise strand a pane that is already showing
74
+ // buttons: the dialog closes, nothing says so, and the next click sends Down/Enter into the
75
+ // prompt underneath — the exact stale-input case the close exists to prevent. A close for a
76
+ // question that was never offered is a no-op in the pane, so this costs nothing when the switch
77
+ // was off all along, and it carries no question text either way.
78
+ export const shouldPublishQuestion = (event: AskQuestionEvent | AskQuestionDone, paneEnabled: boolean): boolean => isAskQuestionDone(event) || paneEnabled;
79
+
80
+ /** As much of a recorded tool call as picking the open dialog out of a history needs. */
81
+ export interface RecordedCall {
82
+ toolUseId?: string | undefined;
83
+ toolName?: string | undefined;
84
+ toolInput?: unknown;
85
+ status?: string | undefined;
86
+ }
87
+
88
+ // The dialog a session is blocked on right now, rebuilt from its tool-call history. The pane's
89
+ // channel is event-only and replays nothing on reconnect, so a browser that reloads between the
90
+ // question and its answer would otherwise never learn about a session that is waiting — and with
91
+ // no button to open the pane, that question becomes unanswerable from the browser entirely.
92
+ //
93
+ // The LAST call, not the last one that happens to still say `running`. A session blocked on a
94
+ // question runs nothing else until it is answered, so a later call is proof the question is over —
95
+ // however it ended. Scanning back instead would keep offering a dialog whose close never arrived
96
+ // (an interrupted turn, a `/clear`, a hook that did not land) long after the agent moved on, and
97
+ // the buttons would type into whatever the screen became.
98
+ export const openQuestionOf = (calls: readonly RecordedCall[], sessionId: string): AskQuestionEvent | null => {
99
+ const last = calls[calls.length - 1];
100
+ if (last?.toolName !== ASK_QUESTION_TOOL || last.status !== "running" || !last.toolUseId) return null;
101
+ const questions = parseAskQuestions(last.toolInput);
102
+ return questions ? { sessionId, toolUseId: last.toolUseId, questions } : null;
103
+ };
104
+
105
+ /** Why an answer did not reach the dialog. Serialized by the host, rendered by every client, so
106
+ * the values live here rather than once per layer — a drift between them is a client showing the
107
+ * wrong explanation, or none. */
108
+ export type AnswerFailure =
109
+ /** The dialog was answered (in the terminal, or by another client) before this arrived. */
110
+ | "closed"
111
+ /** picks do not fit the questions — wrong count, out of range, or not ascending. */
112
+ | "bad-picks"
113
+ /** No PTY in this process to type into: the session outlived a server restart. */
114
+ | "unwritable"
115
+ /** Some keys reached the dialog before the rest were abandoned. Its cursor has moved and its
116
+ * boxes may be ticked, so where it stands is no longer knowable from here — a fresh sequence
117
+ * would be computed from a state the dialog left behind and could commit a different answer. */
118
+ | "partial";
119
+
120
+ export type AnswerResult = { ok: true } | { ok: false; reason: AnswerFailure };
121
+
122
+ /** The body of `POST /api/question/:sessionId/answer`. The session comes from the route path, not
123
+ * from here — a client names the dialog it is answering, never the session it belongs to. */
124
+ export interface AnswerRequestBody {
125
+ toolUseId: string;
126
+ /** Chosen option indexes, one entry per question. */
127
+ picks?: number[][];
128
+ /** Or the user's own words, for the dialog's text row. */
129
+ text?: string;
130
+ }
131
+
132
+ /** Read a request body's SHAPE. Whether the picks or the words fit the dialog is decided against
133
+ * the questions the host itself recorded — see server/session/answerQuestion.ts. */
134
+ export const readAnswerRequest = (body: unknown): AnswerRequestBody | null => {
135
+ if (!isRecord(body) || typeof body.toolUseId !== "string") return null;
136
+ const text = typeof body.text === "string" ? { text: body.text } : {};
137
+ return { toolUseId: body.toolUseId, ...(body.picks === undefined ? {} : { picks: readPickRows(body.picks) }), ...text };
138
+ };
139
+
140
+ // Rows of numbers, or nothing. A row that is not that is dropped to `[]` rather than coerced: the
141
+ // host then refuses it as bad-picks, and a coerced pick is a keystroke aimed at a row nobody chose.
142
+ const readPickRows = (picks: unknown): number[][] =>
143
+ Array.isArray(picks) ? picks.map((row) => (Array.isArray(row) ? row.filter((idx): idx is number => typeof idx === "number") : [])) : [];
144
+
145
+ export const isAnswerFailure = (value: unknown): value is AnswerFailure =>
146
+ value === "closed" || value === "bad-picks" || value === "unwritable" || value === "partial";
147
+
148
+ const KEY_DOWN = "\x1b[B";
149
+ const KEY_ENTER = "\r";
150
+
151
+ // The dialog lists MORE rows than `options`: a "Type something" row follows them, and a
152
+ // multi-select question puts its Submit row after that. So Submit sits at options.length + 1.
153
+ // Measured against claude 2.1.231, not inferred from the tool schema — the extra rows exist
154
+ // only on screen, and reading the schema alone would aim every multi-select Enter one row short.
155
+ const submitRow = (options: readonly AskOption[]): number => options.length + 1;
156
+
157
+ const moveDown = (from: number, to: number): string[] => Array.from({ length: Math.max(0, to - from) }, () => KEY_DOWN);
158
+
159
+ // Enter TOGGLES a multi-select row and leaves the cursor where it is, so each pick is
160
+ // "walk down to it, toggle", and the walk starts from the previous pick rather than the top.
161
+ const toggleEach = (picks: readonly number[]): { keys: string[]; cursor: number } =>
162
+ picks.reduce<{ keys: string[]; cursor: number }>(({ keys, cursor }, idx) => ({ keys: [...keys, ...moveDown(cursor, idx), KEY_ENTER], cursor: idx }), {
163
+ keys: [],
164
+ cursor: 0,
165
+ });
166
+
167
+ const keysForOne = (question: AskQuestion, picks: readonly number[]): string[] => {
168
+ if (!question.multiSelect) return [...moveDown(0, picks[0] ?? 0), KEY_ENTER];
169
+ const { keys, cursor } = toggleEach(picks);
170
+ return [...keys, ...moveDown(cursor, submitRow(question.options)), KEY_ENTER];
171
+ };
172
+
173
+ // Ascending and distinct, because the walk above only ever moves DOWN: an out-of-order pick
174
+ // would silently toggle the wrong row rather than fail.
175
+ const picksValid = (question: AskQuestion, picks: readonly number[]): boolean => {
176
+ const inRange = picks.every((idx) => Number.isInteger(idx) && idx >= 0 && idx < question.options.length);
177
+ const ascending = picks.every((idx, i) => i === 0 || idx > (picks[i - 1] ?? -1));
178
+ return inRange && ascending && (question.multiSelect || picks.length === 1);
179
+ };
180
+
181
+ // The review screen ("Ready to submit your answers?", with Submit preselected) appears for every
182
+ // shape EXCEPT a lone single-select question, which commits on the option's own Enter. All four
183
+ // shapes were measured; guessing here either leaves the dialog open or drops a stray Enter into
184
+ // the prompt once it has closed.
185
+ const needsReview = (questions: readonly AskQuestion[]): boolean => questions.length > 1 || questions.some((question) => question.multiSelect);
186
+
187
+ // Answering in the user's own words, through the dialog's own `Type something` row.
188
+ //
189
+ // That row IS a text field, and the way in is to highlight it and type: the row then reads back what
190
+ // was typed, and Enter commits it as the ANSWER — `answers` carries the words, not a decline.
191
+ // Measured against claude 2.1.231. (Enter on the row while it is still empty declines instead,
192
+ // which is what a first reading of it mistook for its whole behaviour.)
193
+ //
194
+ // It sits directly after that question's options.
195
+ export const keysToAnswerInWords = (questions: readonly AskQuestion[], text: string): string[] | null => {
196
+ const only = questions.length === 1 ? questions[0] : undefined;
197
+ // The one shape this was measured on: a lone SINGLE-select question, where the text row's Enter
198
+ // is the whole answer. The others each end somewhere this sequence does not reach — a wizard
199
+ // moves on to its next question, and a multi-select dialog stops at its review screen — and the
200
+ // host would claim the dialog while the terminal still waits for something the pane cannot send.
201
+ if (!only || only.multiSelect || !text) return null;
202
+ return [...moveDown(0, only.options.length), text, KEY_ENTER];
203
+ };
204
+
205
+ /**
206
+ * The keystrokes that answer a live AskUserQuestion dialog. `picks[i]` holds the chosen option
207
+ * indexes of `questions[i]` — exactly one for a single-select question, any ascending set for a
208
+ * multi-select one. Null when the picks do not fit the questions, so a caller cannot half-drive
209
+ * a dialog it has misread.
210
+ */
211
+ export const keysForAnswers = (questions: readonly AskQuestion[], picks: readonly (readonly number[])[]): string[] | null => {
212
+ if (questions.length === 0 || picks.length !== questions.length) return null;
213
+ const paired = questions.map((question, i) => ({ question, picks: picks[i] ?? [] }));
214
+ if (!paired.every(({ question, picks: chosen }) => picksValid(question, chosen))) return null;
215
+ const keys = paired.flatMap(({ question, picks: chosen }) => keysForOne(question, chosen));
216
+ return needsReview(questions) ? [...keys, KEY_ENTER] : keys;
217
+ };
@@ -22,6 +22,7 @@ export const BUNDLED_SKILL_NAMES = [
22
22
  "mulmoterminal-notify",
23
23
  "mulmoterminal-bug-report",
24
24
  "mulmoterminal-decisions",
25
+ "mulmoterminal-shared-app",
25
26
  ] as const;
26
27
 
27
28
  export type BundledSkillName = (typeof BUNDLED_SKILL_NAMES)[number];
@@ -22,7 +22,10 @@ export type SelfContainmentCode =
22
22
  /** No `primaryKey`, so ids are 4 random bytes and two machines can mint the same one. */
23
23
  | "no-primary-key"
24
24
  /** The project is not a git repo, so there is nothing to clone and most checks do not apply. */
25
- | "not-a-repo";
25
+ | "not-a-repo"
26
+ /** Records live in the app's Firestore, not in the repository — by design for a SHARED
27
+ * collection, and the reason it clones differently from every other kind. */
28
+ | "shared-store";
26
29
 
27
30
  /** `blocker` stops the clone working; `warning` costs something on the other machine; `info` is
28
31
  * context. Only a blocker makes `portable` false. */
@@ -3,6 +3,8 @@
3
3
  // all extend this, so a field added on one side can't go missing on the other.
4
4
  // `theme` and `colors` stay out of it — each side declares them with its own type
5
5
  // (the server keeps the validated strings, the client narrows them to xterm's ITheme).
6
+ import type { HeaderStatusColors, HeaderStatusTint } from "./headerStatusColors.js";
7
+
6
8
  export interface DirChrome {
7
9
  name: string | null;
8
10
  badgeColor: string | null;
@@ -11,6 +13,11 @@ export interface DirChrome {
11
13
  // palette) — these tint the chrome around the terminal, not the terminal itself.
12
14
  headerColor: string | null;
13
15
  headerTextColor: string | null;
16
+ // What the header shows once a status REPLACES that background — working / done / blocked
17
+ // (common/headerStatusColors.ts). null means "not configured here", so a directory that says
18
+ // nothing falls through to the global default rather than asserting the built-in over it.
19
+ headerStatusColors: HeaderStatusColors | null;
20
+ headerStatusTint: HeaderStatusTint | null;
14
21
  // The cell frame + accents (grid cell): body background, border, the idle status
15
22
  // dot, and the header's icon buttons. Hex #rrggbb or null for the theme default.
16
23
  cellColor: string | null;
@@ -40,6 +47,8 @@ export const EMPTY_DIR_CHROME: Readonly<DirChrome> = {
40
47
  badgeColor: null,
41
48
  headerColor: null,
42
49
  headerTextColor: null,
50
+ headerStatusColors: null,
51
+ headerStatusTint: null,
43
52
  cellColor: null,
44
53
  cellBorderColor: null,
45
54
  dotColor: null,
@@ -14,6 +14,8 @@ export const DIR_CONFIG_KEYS = [
14
14
  "badgeColor",
15
15
  "headerColor",
16
16
  "headerTextColor",
17
+ "headerStatusColors",
18
+ "headerStatusTint",
17
19
  "cellColor",
18
20
  "cellBorderColor",
19
21
  "dotColor",
@@ -0,0 +1,83 @@
1
+ // Which repository's section the GitHub pane leads with.
2
+ //
3
+ // The pane is opened beside a CELL, and the cell names a directory — so the list is most useful
4
+ // with that directory's repository first. Reordering rather than scrolling to it: a scroll that
5
+ // lands slightly off looks like nothing happened, it has to re-run on every reload, and a repo
6
+ // with no open PRs and no open issues has nothing to scroll to at all, where an empty section at
7
+ // the top still answers "yours: none".
8
+ //
9
+ // Pure and shared so it can be tested without mounting the pane, and so the PR list and the issue
10
+ // list cannot drift into ordering themselves differently.
11
+ import { dirPathKey } from "./dirPathKey.js";
12
+
13
+ /** The only field either list is ordered by. Both `RepoPrs` and `RepoIssues` carry it. */
14
+ export interface RepoGrouped {
15
+ repo: string;
16
+ }
17
+
18
+ /** Case-insensitively, the way GitHub itself treats `owner/repo`. */
19
+ const sameRepo = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase();
20
+
21
+ /**
22
+ * Whether `cwd` is the clone at `base`, or anywhere inside it.
23
+ *
24
+ * CONTAINMENT, not equality: a cell is very often somewhere under the clone rather than at its
25
+ * root — the user cd'd into `src/`, or the cell was started there — and an exact match left every
26
+ * one of those leading with the wrong repo (Codex review).
27
+ *
28
+ * Through `dirPathKey` because the browser has no filesystem: it folds both separators, a trailing
29
+ * slash, `.` and `..` into one spelling. Lexical only, so it cannot see through a symlink — which
30
+ * is right here, where the answer decides presentation. (server/infra/path-within.ts carries the
31
+ * strict rule for the places where it is a boundary.) Case-folded on top: Windows and the default
32
+ * macOS volume both treat two spellings of one directory as the same one.
33
+ */
34
+ const isWithinDir = (base: string, cwd: string): boolean => {
35
+ const root = dirPathKey(base).toLowerCase();
36
+ const target = dirPathKey(cwd).toLowerCase();
37
+ if (root === "" || target === "") return false;
38
+ if (target === root) return true;
39
+ // A prefix is NOT containment — `/srv/project-old` starts with `/srv/project`. The separator is
40
+ // what makes it a boundary. `dirPathKey` leaves one only on a filesystem root ("/", "C:/").
41
+ return target.startsWith(root.endsWith("/") ? root : `${root}/`);
42
+ };
43
+
44
+ /**
45
+ * `owner/repo` for the directory a cell is in, from the reverse map `/api/repo-dirs` already
46
+ * serves — no extra request and no `git` subprocess, since the view fetches it anyway for the
47
+ * issue rows' start control.
48
+ *
49
+ * `null` for a directory that names no repository — not a git repo, no `origin`, a forge we cannot
50
+ * act on, or simply a clone the user never registered in Settings. All four are ordinary, and the
51
+ * caller leaves the order alone.
52
+ */
53
+ export function repoForCwd(cwd: string | null | undefined, repoDirs: readonly { repo: string; dirs: readonly { path: string }[] }[]): string | null {
54
+ if (!cwd) return null;
55
+ // The DEEPEST match wins. Clones nest here — a worktree under a repo's managed root, or a repo
56
+ // vendored inside another — and the innermost one is the repository the cell is actually in.
57
+ let best: { repo: string; depth: number } | null = null;
58
+ for (const entry of repoDirs) {
59
+ for (const dir of entry.dirs) {
60
+ if (!isWithinDir(dir.path, cwd)) continue;
61
+ const depth = dirPathKey(dir.path).length;
62
+ if (!best || depth > best.depth) best = { repo: entry.repo, depth };
63
+ }
64
+ }
65
+ return best?.repo ?? null;
66
+ }
67
+
68
+ /**
69
+ * `rows` with `repo`'s entry first, everything else in the order it arrived.
70
+ *
71
+ * A stable move, not a sort: the server's order is meaningful (it follows the configured repo
72
+ * list) and only one element is being promoted. Returns the input unchanged when there is no repo
73
+ * to lead with, or when it has no section — which is why a cell in an unregistered clone simply
74
+ * gets the conventional order.
75
+ */
76
+ export function leadWithRepo<T extends RepoGrouped>(rows: readonly T[], repo: string | null): T[] {
77
+ if (!repo) return [...rows];
78
+ const at = rows.findIndex((row) => sameRepo(row.repo, repo));
79
+ if (at <= 0) return [...rows];
80
+ const lead = rows[at];
81
+ if (lead === undefined) return [...rows]; // unreachable: findIndex answered a real index
82
+ return [lead, ...rows.slice(0, at), ...rows.slice(at + 1)];
83
+ }
@@ -10,7 +10,18 @@ import { GITHUB_HOST, GITLAB_HOST } from "./repoEntry.js";
10
10
  // A hostname, lower case, with at least one dot. The dot is not cosmetic: `parseRepoEntry` reads a
11
11
  // leading segment as a host ONLY when it contains one, so a dotless declaration could never match
12
12
  // an entry and would sit in the config doing nothing.
13
- const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
13
+ // Tested per label rather than as one hostname pattern: the single-regex form nested `[a-z0-9-]*`
14
+ // inside the repeated dotted group, which is the shape that goes exponential on input that almost
15
+ // matches. Measured, this one did not — but the per-label form removes the class instead of
16
+ // depending on that, and reads as the rule it enforces.
17
+ const LABEL_RE = /^[a-z0-9][a-z0-9-]*$/;
18
+
19
+ const isLabel = (label: string): boolean => LABEL_RE.test(label) && !label.endsWith("-");
20
+
21
+ const isHostname = (host: string): boolean => {
22
+ const labels = host.split(".");
23
+ return labels.length > 1 && labels.every(isLabel);
24
+ };
14
25
 
15
26
  const SCHEME_RE = /^https?:\/\//;
16
27
 
@@ -26,7 +37,7 @@ const GITLAB_HOSTS_MAX = 20;
26
37
  export function normalizeGitlabHost(input: unknown): string | null {
27
38
  if (typeof input !== "string") return null;
28
39
  const host = input.trim().toLowerCase().replace(SCHEME_RE, "").replace(/\/$/, "");
29
- return HOSTNAME_RE.test(host) ? host : null;
40
+ return isHostname(host) ? host : null;
30
41
  }
31
42
 
32
43
  /** The declared hosts, de-duplicated and capped. Anything unusable is dropped rather than failing
@@ -0,0 +1,122 @@
1
+ // What a cell header is painted with while a session is working / done / blocked, and who decides.
2
+ //
3
+ // A header has two layers that were designed together and can drift apart: the DIRECTORY's colour
4
+ // (`headerColor` plus its ink) and the STATUS wash the theme mixes over it. #1591 is what the drift
5
+ // looks like — a `headerTextColor` chosen against a dark purple, still applied while a light theme
6
+ // paints the pale `--bg-selected` a running session gets: white on #d6e4fb, measured at 1.15:1 off
7
+ // the reporter's own screenshot.
8
+ //
9
+ // So both layers are resolved HERE, once, and every caller asks rather than combining the pieces
10
+ // itself. That is also what lets a directory recolour a status: the same function answers "what
11
+ // shows now", whether the answer came from the theme or from a config file.
12
+ //
13
+ // `idle` is deliberately not a configurable status. `headerColor` / `headerTextColor` ARE the idle
14
+ // state, and a second way to say the same thing is how one directory ends up written in two
15
+ // different colours.
16
+ import { headerTextColorFor } from "./chromeFromColor.js";
17
+ import { isHexColor } from "./hexColor.js";
18
+ import { isRecord } from "./isRecord.js";
19
+
20
+ /** The states whose header background the theme replaces, and which a config may therefore claim. */
21
+ export const HEADER_STATUS_KEYS = ["working", "done", "blocked"] as const;
22
+ export type HeaderStatusKey = (typeof HEADER_STATUS_KEYS)[number];
23
+
24
+ /** Every state a header paints — the three above plus the directory's own. */
25
+ export type HeaderPaintStatus = HeaderStatusKey | "idle";
26
+
27
+ export interface HeaderStatusColor {
28
+ background: string | null;
29
+ /** Omitted derives an AA ink from `background`. Naming it without a background is honoured
30
+ * as-is: that is a statement about a wash the author can see, unlike `headerTextColor`, which
31
+ * is a statement about the directory's own colour and is what #1591 misapplied. */
32
+ text: string | null;
33
+ }
34
+
35
+ export type HeaderStatusColors = Partial<Record<HeaderStatusKey, HeaderStatusColor>>;
36
+
37
+ /** `background`: the theme washes the header while a session works (today's behaviour).
38
+ * `none`: the directory's own `headerColor` stays, and the status reads from the cell border,
39
+ * the dot and the status pill instead. */
40
+ export const HEADER_STATUS_TINTS = ["background", "none"] as const;
41
+ export type HeaderStatusTint = (typeof HEADER_STATUS_TINTS)[number];
42
+ export const DEFAULT_HEADER_STATUS_TINT: HeaderStatusTint = "background";
43
+
44
+ // `none` says "keep my palette", and `blocked` is the one state where nothing proceeds until the
45
+ // user answers. A switch aimed at colour consistency taking the amber off THAT is an accident, so
46
+ // it doesn't reach it — a directory that wants a different blocked colour says so outright with a
47
+ // `headerStatusColors.blocked` entry, which is honoured below.
48
+ const TINT_NONE_APPLIES_TO: readonly HeaderStatusKey[] = ["working", "done"];
49
+
50
+ /** What a header paints in one state. `null` means "the theme decides" — the caller emits no
51
+ * custom property and the theme's own wash / ink shows through the CSS fallback. */
52
+ export interface HeaderPaint {
53
+ background: string | null;
54
+ text: string | null;
55
+ }
56
+
57
+ const NOTHING: HeaderPaint = { background: null, text: null };
58
+
59
+ export interface HeaderChrome {
60
+ headerColor: string | null;
61
+ headerTextColor: string | null;
62
+ // Both nullable so "nothing configured" is expressible without every caller spreading a default
63
+ // in first — the shape a DirChrome already carries.
64
+ statusColors: HeaderStatusColors | null;
65
+ tint: HeaderStatusTint | null;
66
+ }
67
+
68
+ // A hex background always yields a readable ink, so `background` set and `text` unset cannot
69
+ // produce an unreadable header — the point of letting a config name one colour.
70
+ function paintFor(background: string | null, text: string | null): HeaderPaint {
71
+ if (isHexColor(text)) return { background: isHexColor(background) ? background : null, text };
72
+ if (isHexColor(background)) return { background, text: headerTextColorFor(background) };
73
+ return NOTHING;
74
+ }
75
+
76
+ export function resolveHeaderPaint(status: HeaderPaintStatus, chrome: HeaderChrome): HeaderPaint {
77
+ if (status === "idle") return paintFor(chrome.headerColor, chrome.headerTextColor);
78
+ const declared = chrome.statusColors?.[status];
79
+ if (declared && (isHexColor(declared.background) || isHexColor(declared.text))) return paintFor(declared.background, declared.text);
80
+ // `none` means "keep the colour I chose", so it needs a colour to keep. A directory with a
81
+ // `headerTextColor` and no usable `headerColor` has nothing for the ink to have been chosen
82
+ // against — carrying the ink alone would leave it on the theme's wash, which is the bug this
83
+ // whole file exists to stop (Codex review on #1619).
84
+ const keepsDirectoryColour = chrome.tint === "none" && TINT_NONE_APPLIES_TO.includes(status) && isHexColor(chrome.headerColor);
85
+ return keepsDirectoryColour ? paintFor(chrome.headerColor, chrome.headerTextColor) : NOTHING;
86
+ }
87
+
88
+ // ---- config parsing -------------------------------------------------------------------------
89
+
90
+ const oneEntry = (input: unknown): HeaderStatusColor | null => {
91
+ if (isHexColor(input)) return { background: input, text: null };
92
+ if (!isRecord(input)) return null;
93
+ const background = isHexColor(input.background) ? input.background : null;
94
+ const text = isHexColor(input.text) ? input.text : null;
95
+ return background || text ? { background, text } : null;
96
+ };
97
+
98
+ /** Per status, so one bad entry can't discard the two that are right. A bare hex string is
99
+ * accepted for a status as shorthand for `{ background: … }` — the common case is one colour. */
100
+ export function sanitizeHeaderStatusColors(input: unknown): HeaderStatusColors {
101
+ if (!isRecord(input)) return {};
102
+ const out: HeaderStatusColors = {};
103
+ for (const key of HEADER_STATUS_KEYS) {
104
+ const entry = oneEntry(input[key]);
105
+ if (entry) out[key] = entry;
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /** `null` for anything that isn't one of the two modes, which is what "unset here" has to look
111
+ * like: a directory that says nothing must fall through to the global default rather than
112
+ * asserting the built-in one over it. */
113
+ export function sanitizeHeaderStatusTint(input: unknown): HeaderStatusTint | null {
114
+ return HEADER_STATUS_TINTS.find((mode) => mode === input) ?? null;
115
+ }
116
+
117
+ /** The directory's answer, else the global one. WHOLE-key, not a per-status merge: the same rule
118
+ * `mergedDirConfigRaw` states for the three config files, and for its reason — a block a reader
119
+ * has to assemble from two places is harder to predict than one they can see entire. */
120
+ export function mergeHeaderStatusColors(global: HeaderStatusColors | null, dir: HeaderStatusColors | null): HeaderStatusColors | null {
121
+ return dir && Object.keys(dir).length > 0 ? dir : global;
122
+ }
@@ -0,0 +1,12 @@
1
+ // What counts as a colour anywhere a directory's .mulmoterminal.json supplies one. Only
2
+ // 6-digit hex: the server's schema accepts nothing else, and the UI drops what it can't
3
+ // parse rather than handing an unknown string to a style binding.
4
+ //
5
+ // In common/ because both sides decide from it: the server validates a config file with this
6
+ // rule and the browser drops what it can't paint, and a third copy of the regex is how the two
7
+ // answers start differing.
8
+ const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
9
+
10
+ // `unknown` rather than `string | null | undefined`: this is what a config parser asks about a
11
+ // value straight out of JSON, and narrowing the parameter would make every such site cast first.
12
+ export const isHexColor = (color: unknown): color is string => typeof color === "string" && HEX_COLOR_RE.test(color);
@@ -0,0 +1,66 @@
1
+ // What a terminal emulator sends back on the INPUT channel without anyone typing.
2
+ //
3
+ // xterm.js answers the application's own queries there: device attributes, the foreground and
4
+ // background colours, the cursor position. It also says when the terminal gained or lost focus.
5
+ // All of it arrives exactly as a keystroke does, and none of it is the user answering anything.
6
+ //
7
+ // That matters to anything asking "has the user typed since this question appeared?"
8
+ // (server/session/write-to-session.ts). Counting these replies meant an answer from the question
9
+ // pane was refused after any attach, resize or theme read — measured, not guessed: a single button
10
+ // press was preceded by `ESC[?1;2c`, `ESC[>0;276;0c` and two OSC colour replies (#1693).
11
+ //
12
+ // MOUSE reports are deliberately absent. They look the same on the wire, but a click can select an
13
+ // option in the dialog — that is the user answering, and an answer already being typed must yield
14
+ // to it exactly as it yields to a keystroke.
15
+ //
16
+ // The forms are spelled out rather than generalised — no "any OSC", no "any CSI". This list decides
17
+ // what CANNOT interrupt an answer, so a pattern wider than the replies actually queried would hand
18
+ // that exemption to input nobody has looked at.
19
+ const ESC = "\u001b";
20
+ const BEL = "\u0007";
21
+ const ST = `(?:${BEL}|${ESC}\\\\)`;
22
+
23
+ const REPLIES = [
24
+ `${ESC}\\[[IO]`, // focus in / out
25
+ `${ESC}\\[\\?[\\d;]*c`, // primary device attributes
26
+ `${ESC}\\[>[\\d;]*c`, // secondary / tertiary device attributes
27
+ `${ESC}\\[\\d+;\\d+R`, // cursor position report
28
+ `${ESC}\\]1[0-9];rgb:[\\da-fA-F/]*${ST}`, // colour queries — foreground, background, cursor
29
+ ].join("|");
30
+
31
+ const ANY_REPLY = new RegExp(REPLIES, "g");
32
+
33
+ // A chunk can END mid-sequence: the socket splits where it likes, so `ESC[?1` and `;2c` can arrive
34
+ // separately. Neither half matches, and both would read as "the user typed" — the very false alarm
35
+ // this module exists to stop. A tail that could still GROW into a reply is held instead of judged.
36
+ //
37
+ // It stops before the terminating letter, which is what keeps a real key out of the holding pen:
38
+ // `ESC[B` (Down) carries its `B` already and cannot be waiting for anything.
39
+ //
40
+ // A LONE Escape is never held. It is how the user cancels the dialog, and holding it would let a
41
+ // paced answer carry on typing into a question they had just dismissed — while the reply it might
42
+ // theoretically have grown into is written whole by the emulator, in one frame, every time.
43
+ const STILL_GROWING = new RegExp(`^${ESC}(?:\\[[?>]?[\\d;]*|\\][\\d;]*(?:rgb:[\\da-fA-F/]*)?)$`);
44
+
45
+ /** The longest tail worth holding. Bounded so a stream of junk cannot accumulate. */
46
+ const MAX_PENDING = 64;
47
+
48
+ export interface InputScan {
49
+ /** Did anything in this chunk come from the user? */
50
+ fromUser: boolean;
51
+ /** The unfinished tail, to be carried into the next chunk. */
52
+ pending: string;
53
+ }
54
+
55
+ /**
56
+ * Classify one chunk of terminal input, carrying `pending` from the chunk before it.
57
+ *
58
+ * `fromUser` is false only when everything is a reply the emulator produced by itself. A trailing
59
+ * sequence that is still growing is neither — it waits in `pending` until it finishes.
60
+ */
61
+ export const scanForUserInput = (pending: string, data: string): InputScan => {
62
+ const chunk = `${pending}${data}`;
63
+ const rest = chunk.replace(ANY_REPLY, "");
64
+ if (rest.length > 0 && rest.length <= MAX_PENDING && STILL_GROWING.test(rest)) return { fromUser: false, pending: rest };
65
+ return { fromUser: rest.length > 0, pending: "" };
66
+ };