mulmoterminal 2.1.0 → 2.2.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 (138) hide show
  1. package/README.md +116 -25
  2. package/bin/mulmoterminal.js +26 -7
  3. package/common/collectionPush.ts +26 -0
  4. package/common/dirChrome.ts +10 -0
  5. package/common/fileWriteChannel.ts +9 -0
  6. package/common/keymap.ts +22 -1
  7. package/common/notifyKinds.ts +26 -0
  8. package/common/notifySounds.ts +44 -0
  9. package/common/orderPriority.ts +20 -0
  10. package/common/pushKinds.ts +6 -2
  11. package/common/terminalClipboard.ts +52 -0
  12. package/common/terminalFontFamily.ts +79 -0
  13. package/common/voiceInputStatus.ts +21 -0
  14. package/dist/assets/{abnfDiagram-VRR7QNED-RjiYivmv-BMVn5P4x.js → abnfDiagram-VRR7QNED-RjiYivmv-6R9yRbUC.js} +1 -1
  15. package/dist/assets/architecture-TIHT7OUA-0NsD2xW1-C95zkDEU.js +1 -0
  16. package/dist/assets/{architectureDiagram-ZJ3FMSHR-3nWA91tG-BaVwq5YO.js → architectureDiagram-ZJ3FMSHR-3nWA91tG-DzS5ABJE.js} +1 -1
  17. package/dist/assets/{blockDiagram-677ZJIJ3-BPuAJQRW-O8HvKllF.js → blockDiagram-677ZJIJ3-BPuAJQRW-Cw9RkJxY.js} +1 -1
  18. package/dist/assets/{c4Diagram-LMCZKHZV-C0LAqQso-qV_LVJW3.js → c4Diagram-LMCZKHZV-C0LAqQso-CWltXMHP.js} +1 -1
  19. package/dist/assets/channel-7wUqSdoX-BYaGw2yS.js +1 -0
  20. package/dist/assets/{chunk-32BRIVSS-BRrYpgtb-B9twQFRU.js → chunk-32BRIVSS-BRrYpgtb-DrrsZ2u5.js} +1 -1
  21. package/dist/assets/{chunk-52WLFC77-BJ-ss3Xr-OC_lubzj.js → chunk-52WLFC77-BJ-ss3Xr-WJMq_L8L.js} +1 -1
  22. package/dist/assets/{chunk-C7G6YPKG-BZEucKEL-mCVH59ut.js → chunk-C7G6YPKG-BZEucKEL-BafXJkh7.js} +1 -1
  23. package/dist/assets/{chunk-EX3LRPZG-DLS6FBN1-CL8lofeg.js → chunk-EX3LRPZG-DLS6FBN1-Q8lsrJXs.js} +1 -1
  24. package/dist/assets/{chunk-FWX5IMBZ-DHLSFw1H-BcVi6QV6.js → chunk-FWX5IMBZ-DHLSFw1H-Cgm5-zaI.js} +2 -2
  25. package/dist/assets/{chunk-HOUHSVGY-Bhlt8hXJ-D3M0sdaj.js → chunk-HOUHSVGY-Bhlt8hXJ-Cp5J5cOD.js} +1 -1
  26. package/dist/assets/{chunk-ICXQ74PX-DwgHBX_g-9pz8KjdB.js → chunk-ICXQ74PX-DwgHBX_g-CLdnp-d2.js} +1 -1
  27. package/dist/assets/{chunk-MOJQB5TN-CMZRaeqt-DsiY753w.js → chunk-MOJQB5TN-CMZRaeqt-BsIB0ZXR.js} +1 -1
  28. package/dist/assets/{chunk-OGEWGWER-8Qy4a8b5-DzD8vBv9.js → chunk-OGEWGWER-8Qy4a8b5-DlTM-RTP.js} +1 -1
  29. package/dist/assets/{chunk-PUDLZKDR-DcrWQRYh-B3EqHnC7.js → chunk-PUDLZKDR-DcrWQRYh-XujP_H1v.js} +1 -1
  30. package/dist/assets/{chunk-Q4XR5HBZ-ZXVGkG8Z-DAzhqKqQ.js → chunk-Q4XR5HBZ-ZXVGkG8Z-DhxnzVDy.js} +1 -1
  31. package/dist/assets/{chunk-V7JOEXUC-DmGdheTX-sWPxsoge.js → chunk-V7JOEXUC-DmGdheTX-C-xPmCQA.js} +1 -1
  32. package/dist/assets/{chunk-VAUOI2AC-CS9QJ4yz-C6T0-Q0n.js → chunk-VAUOI2AC-CS9QJ4yz-Cng5RPUP.js} +1 -1
  33. package/dist/assets/{chunk-VR4S4FIN-C6a91eNY-CzCW244T.js → chunk-VR4S4FIN-C6a91eNY-Bmb0moA3.js} +1 -1
  34. package/dist/assets/{chunk-WYO6CB5R-BlzOfotS-BRhqiyoJ.js → chunk-WYO6CB5R-BlzOfotS-DmR4SyZL.js} +1 -1
  35. package/dist/assets/{chunk-ZGVPDNZ5-BYwxNFTK-2uN_ofIP.js → chunk-ZGVPDNZ5-BYwxNFTK-CD8nNM8_.js} +1 -1
  36. package/dist/assets/classDiagram-OUVF2IWQ-BYlRi6YQ-EDJcEZLp.js +1 -0
  37. package/dist/assets/classDiagram-v2-EOCWNBFH-BCHaYHxj-EDJcEZLp.js +1 -0
  38. package/dist/assets/cynefin-VYW2F7L2-CGYr2F6h-Co-phwWx.js +1 -0
  39. package/dist/assets/{cynefinDiagram-TSTJHNR4-DHA9iPo--rvLMXkNB.js → cynefinDiagram-TSTJHNR4-DHA9iPo--pv1DCSfy.js} +1 -1
  40. package/dist/assets/{dagre-VKFMJZFB--oJKqXBZ-CGanW7aY.js → dagre-VKFMJZFB--oJKqXBZ-BPcGEiSl.js} +1 -1
  41. package/dist/assets/{diagram-FQU43EPY-CcJCB9bG-BcrxMiAD.js → diagram-FQU43EPY-CcJCB9bG-CCOrHV0U.js} +1 -1
  42. package/dist/assets/{diagram-G47NLZAW-C_o-WGG1-4PTH1NOO.js → diagram-G47NLZAW-C_o-WGG1-Crm_IB8Y.js} +1 -1
  43. package/dist/assets/{diagram-NH7WQ7WH-CXJCYvY--DLVaK2vZ.js → diagram-NH7WQ7WH-CXJCYvY--UcH8gkTE.js} +1 -1
  44. package/dist/assets/{diagram-OA4YK3LP-BMzeJ87A-C0axta9a.js → diagram-OA4YK3LP-BMzeJ87A-7C9Me5sS.js} +1 -1
  45. package/dist/assets/{diagram-WEI45ONY-D_93NKqo-3zfR6xo1.js → diagram-WEI45ONY-D_93NKqo-BBztQ9hK.js} +1 -1
  46. package/dist/assets/{ebnfDiagram-CCIWWBDH-CVai1Ii9-Bfd3ExQu.js → ebnfDiagram-CCIWWBDH-CVai1Ii9-TkJNOqEw.js} +1 -1
  47. package/dist/assets/{erDiagram-Q63AITRT-CiABnA0s-BGq_5dj0.js → erDiagram-Q63AITRT-CiABnA0s-DS2blJuc.js} +1 -1
  48. package/dist/assets/eventmodeling-45OFAUF4-CWmqhm3a-D-dhMOz8.js +1 -0
  49. package/dist/assets/flowDiagram-23GEKE2U-DKSB5AiY-KiTeh-IZ.js +1 -0
  50. package/dist/assets/{ganttDiagram-NO4QXBWP-DQZvdFo1-z8Owlyg6.js → ganttDiagram-NO4QXBWP-DQZvdFo1-ZB_lhurA.js} +1 -1
  51. package/dist/assets/gitGraph-TEB2WS4Q-BR9qPwdN-Cd1rBWSG.js +1 -0
  52. package/dist/assets/{gitGraphDiagram-IHSO6WYX-C2ovBouh-BA-6Yy_K.js → gitGraphDiagram-IHSO6WYX-C2ovBouh-Vm6I27hg.js} +1 -1
  53. package/dist/assets/index-BP05wTLW.js +618 -0
  54. package/dist/assets/index-CGwcisib.css +1 -0
  55. package/dist/assets/info-DKCQHKI2-Dplx5kMp-Dic4-0_Y.js +1 -0
  56. package/dist/assets/{infoDiagram-FWYZ7A6U-7UnoB5AP-BW36KQ8e.js → infoDiagram-FWYZ7A6U-7UnoB5AP-Cr34YkAb.js} +1 -1
  57. package/dist/assets/{ishikawaDiagram-FXEZZL3T-ByUDM_N2-DrQDG1os.js → ishikawaDiagram-FXEZZL3T-ByUDM_N2-at4P1zMV.js} +1 -1
  58. package/dist/assets/{journeyDiagram-5HDEW3XC-c5xIah9o-CtJ0D_mQ.js → journeyDiagram-5HDEW3XC-c5xIah9o-D4ZV57k7.js} +1 -1
  59. package/dist/assets/{kanban-definition-HUTT4EX6-Cnt6loYD-bm-RqJor.js → kanban-definition-HUTT4EX6-Cnt6loYD-BiJ3JUjX.js} +1 -1
  60. package/dist/assets/{lib-BmwIEKvb.js → lib-Du_IVDkN.js} +1 -1
  61. package/dist/assets/{line-D7ziSjKi-DrYnqDhF.js → line-D7ziSjKi-CPM0FMWw.js} +1 -1
  62. package/dist/assets/{marp-gEV1mQM4.js → marp-BBre1w8X.js} +1 -1
  63. package/dist/assets/{mermaid-parser.core-DxEa8E3F-iymJ5XTw.js → mermaid-parser.core-DxEa8E3F-CO4zEYzL.js} +2 -2
  64. package/dist/assets/{mermaid.core-V0OYwIz3-CEFC0tc3.js → mermaid.core-V0OYwIz3-CzPQxhwu.js} +3 -3
  65. package/dist/assets/{mindmap-definition-LN4V7U3C-CexN3O6L-maxHe_oT.js → mindmap-definition-LN4V7U3C-CexN3O6L-Nez_xicT.js} +1 -1
  66. package/dist/assets/packet-7NZHBO7P-CQI3flND-BBywyu3N.js +1 -0
  67. package/dist/assets/{pegDiagram-2B236MQR-BiM4G0cW-CrLCH9dK.js → pegDiagram-2B236MQR-BiM4G0cW-0AEyhHwQ.js} +1 -1
  68. package/dist/assets/pie-RZYD4A2V-CnmKE2Wa-CoicOL1H.js +1 -0
  69. package/dist/assets/{pieDiagram-ENE6RG2P-B_fBBS-2-DVbko3rP.js → pieDiagram-ENE6RG2P-B_fBBS-2-DXBEfgPl.js} +1 -1
  70. package/dist/assets/{quadrantDiagram-ABIIQ3AL-BigGVxCR-DUwLLJXe.js → quadrantDiagram-ABIIQ3AL-BigGVxCR-dZ16XJ8-.js} +1 -1
  71. package/dist/assets/radar-I7S5WNFK-us6x-Z9R-BeFXvO0F.js +1 -0
  72. package/dist/assets/railroad-3IZDKUUU-BKdz6pdN-BZY5Z4oN.js +1 -0
  73. package/dist/assets/railroad-abnf-AHOZXSZD-m_CSSIfd-Dubi7PGc.js +1 -0
  74. package/dist/assets/railroad-ebnf-EBAXGLYW-Nu3nAG7C-wnb7FBOh.js +1 -0
  75. package/dist/assets/railroad-peg-LSFZ7HO6--Y3mD4R_-P7vJjL8M.js +1 -0
  76. package/dist/assets/{railroadDiagram-RFXS5EU6--lNIlG64-t4pt5DRk.js → railroadDiagram-RFXS5EU6--lNIlG64-DHNapLB9.js} +1 -1
  77. package/dist/assets/{requirementDiagram-TGXJPOKE-D-B0Y5Wu-DJVGeSnt.js → requirementDiagram-TGXJPOKE-D-B0Y5Wu-C-9SVxpD.js} +1 -1
  78. package/dist/assets/{sankeyDiagram-HTMAVEWB-DlutaXDv-2m-PGq5V.js → sankeyDiagram-HTMAVEWB-DlutaXDv-Cv-mFz76.js} +1 -1
  79. package/dist/assets/{sequenceDiagram-DBY2YBRQ-BOLgtggH-DSwO6_CW.js → sequenceDiagram-DBY2YBRQ-BOLgtggH-BR0sUcBe.js} +1 -1
  80. package/dist/assets/{stateDiagram-2N3HPSRC-jf_dXUEU-DuBsukdi.js → stateDiagram-2N3HPSRC-jf_dXUEU-DjqY9a1u.js} +1 -1
  81. package/dist/assets/stateDiagram-v2-6OUMAXLB-CuzgCL2v-DjKerI1O.js +1 -0
  82. package/dist/assets/{swimlanes-5IMT3BWC-CYjtALQH-BwT2NtP8.js → swimlanes-5IMT3BWC-CYjtALQH-DV8Ha8XJ.js} +1 -1
  83. package/dist/assets/swimlanesDiagram-G3AALYLV-3bmSBtKs-Dm5TS_6E.js +8 -0
  84. package/dist/assets/{timeline-definition-FHXFAJF6-DYJ4oUm8-B7uglxWU.js → timeline-definition-FHXFAJF6-DYJ4oUm8-D2oq5bFn.js} +1 -1
  85. package/dist/assets/treeView-QDETBFTQ-D27HWG6Q-H3pflPYY.js +1 -0
  86. package/dist/assets/treemap-6X3UGDF4-CmGIU3H5-CIoNkikG.js +1 -0
  87. package/dist/assets/{vennDiagram-L72KCM5P-CdKAHoek-Bi97b0Q1.js → vennDiagram-L72KCM5P-CdKAHoek-I-wP-h9k.js} +1 -1
  88. package/dist/assets/wardley-OPB4EBWU-CbHWsb2k-JQdCXyum.js +1 -0
  89. package/dist/assets/{wardleyDiagram-EHGQE667-BF6c4_CW-DuE3v_zR.js → wardleyDiagram-EHGQE667-BF6c4_CW-BkrH7vLX.js} +1 -1
  90. package/dist/assets/{xychartDiagram-FW5EYKEG-CGiKngj7-B0CJGTzS.js → xychartDiagram-FW5EYKEG-CGiKngj7-DMYCP46V.js} +1 -1
  91. package/dist/index.html +2 -2
  92. package/package.json +8 -8
  93. package/server/agents/claude-args.ts +7 -0
  94. package/server/backends/calendarPush.ts +81 -0
  95. package/server/backends/calendarPushResult.ts +44 -0
  96. package/server/backends/remoteHost/googleCalendar.spec.ts +7 -1
  97. package/server/backends/whisper.ts +3 -5
  98. package/server/config/app-config.ts +56 -0
  99. package/server/config/config-body.ts +12 -1
  100. package/server/config/config-routes.ts +35 -4
  101. package/server/config/config-schema.ts +66 -0
  102. package/server/config/dir-config.ts +75 -19
  103. package/server/config/sound-presets.ts +96 -0
  104. package/server/files/atomic-write.ts +1 -1
  105. package/server/files/backup-store.ts +101 -0
  106. package/server/files/files-browse.ts +85 -5
  107. package/server/files/tool-writes.ts +20 -0
  108. package/server/infra/sandbox.ts +7 -0
  109. package/server/routes/app-routes.ts +11 -2
  110. package/server/routes/dir-routes.ts +16 -5
  111. package/server/routes/hook-routes.ts +19 -4
  112. package/server/session/activity-hook.ts +23 -4
  113. package/server/session/pty-spawn.ts +2 -2
  114. package/server/session/spawn-claude.ts +2 -1
  115. package/server/skills/mulmoterminal-config/SKILL.md +37 -3
  116. package/dist/assets/architecture-TIHT7OUA-0NsD2xW1-JfeahpZ_.js +0 -1
  117. package/dist/assets/channel-7wUqSdoX-C4JGl6VC.js +0 -1
  118. package/dist/assets/classDiagram-OUVF2IWQ-BYlRi6YQ-DtJISQUb.js +0 -1
  119. package/dist/assets/classDiagram-v2-EOCWNBFH-BCHaYHxj-DtJISQUb.js +0 -1
  120. package/dist/assets/cynefin-VYW2F7L2-CGYr2F6h-DB6705af.js +0 -1
  121. package/dist/assets/eventmodeling-45OFAUF4-CWmqhm3a-Bl19zj9_.js +0 -1
  122. package/dist/assets/flowDiagram-23GEKE2U-DKSB5AiY-DdIGCbvn.js +0 -1
  123. package/dist/assets/gitGraph-TEB2WS4Q-BR9qPwdN-BnZW4EXW.js +0 -1
  124. package/dist/assets/index-DbKckN7Z.js +0 -618
  125. package/dist/assets/index-u-r21U2t.css +0 -1
  126. package/dist/assets/info-DKCQHKI2-Dplx5kMp-CrJfKOA1.js +0 -1
  127. package/dist/assets/packet-7NZHBO7P-CQI3flND-mf_ng5Fx.js +0 -1
  128. package/dist/assets/pie-RZYD4A2V-CnmKE2Wa-CSySnxGv.js +0 -1
  129. package/dist/assets/radar-I7S5WNFK-us6x-Z9R-DfA0aimG.js +0 -1
  130. package/dist/assets/railroad-3IZDKUUU-BKdz6pdN-T35PQ5gg.js +0 -1
  131. package/dist/assets/railroad-abnf-AHOZXSZD-m_CSSIfd-DavwfL2m.js +0 -1
  132. package/dist/assets/railroad-ebnf-EBAXGLYW-Nu3nAG7C-Yk8Oj2bi.js +0 -1
  133. package/dist/assets/railroad-peg-LSFZ7HO6--Y3mD4R_-DOPPkkTw.js +0 -1
  134. package/dist/assets/stateDiagram-v2-6OUMAXLB-CuzgCL2v-BuaQTnnn.js +0 -1
  135. package/dist/assets/swimlanesDiagram-G3AALYLV-3bmSBtKs-CVMbYKXX.js +0 -8
  136. package/dist/assets/treeView-QDETBFTQ-D27HWG6Q-DSRzdn6E.js +0 -1
  137. package/dist/assets/treemap-6X3UGDF4-CmGIU3H5-pjqGjUmo.js +0 -1
  138. package/dist/assets/wardley-OPB4EBWU-CbHWsb2k-_qoOv9rX.js +0 -1
package/README.md CHANGED
@@ -111,11 +111,23 @@ dotfiles are server-only. The 45 extensions both sides agree on live in
111
111
 
112
112
  ## Install & run
113
113
 
114
- Requires the [`claude`](https://claude.com/claude-code) CLI on your `PATH` and
115
- **Node ≥ 22.9**. Optional but recommended: **`tmux`** so terminals survive a server
116
- restart (see [Session persistence (tmux)](#session-persistence-tmux)), the **`gh`** CLI
117
- logged in for the PRs/Issues view and one-click PR creation, and — for Codex sessions —
118
- the **`codex`** CLI on your `PATH`.
114
+ Needs **Node ≥ 22.9**, plus these CLIs on your `PATH`:
115
+
116
+ | | Tool | What it gives you | Install |
117
+ | --- | --- | --- | --- |
118
+ | **Required** | [`claude`](https://claude.com/claude-code) | every Claude session — this app is a cockpit for it | `npm i -g @anthropic-ai/claude-code`, then run `claude` once to log in |
119
+ | **Required** | `git` | [worktree isolation](#git-worktrees--pull-requests), each cell's branch / unsaved-dot / diff readout, the PR footer | `brew install git` · `sudo apt install git` · `sudo dnf install git` · Windows: [git-scm.com](https://git-scm.com/download/win) |
120
+ | **Required** | `gh` | the cross-repo **PRs & Issues** view and one-click PR creation — it uses your `gh` login, so no token is stored | [cli.github.com](https://cli.github.com), then `gh auth login` |
121
+ | Recommended | `tmux` | [session persistence](#session-persistence-tmux) — terminals survive a server restart | `brew install tmux` · `sudo apt install tmux` · `sudo dnf install tmux` · no native Windows build (falls back to plain PTYs) |
122
+ | Optional | `codex` | [Codex sessions](#agents-claude--codex) in a cell, alongside Claude | `npm i -g @openai/codex` |
123
+ | Optional | `docker` | the experimental [Docker sandbox](#docker-sandbox-experimental-single-view) | [docs.docker.com](https://docs.docker.com/get-started/get-docker/) |
124
+ | Optional | `ffmpeg` | video rendering from the [mulmo-script panel](#wiki-collections--the-gui-panel) (its plugin ships enabled) | `brew install ffmpeg` · `sudo apt install ffmpeg` · `sudo dnf install ffmpeg` |
125
+ | Optional | `ollama` | [`claude-ollama`](https://receptron.github.io/mulmoterminal/guide/en/claude-ollama.html) — Claude Code against a fully local model | [ollama.com/download](https://ollama.com/download) |
126
+
127
+ The server starts without any of the non-required rows; you just lose that row's feature,
128
+ and the header/panel for it says so. `git` and `gh` are marked required because losing them
129
+ costs whole views rather than one button. `npx mulmoterminal init` (below) reports which of
130
+ these it can find.
119
131
 
120
132
  ```bash
121
133
  npx mulmoterminal # start on http://localhost:34567 and open the browser
@@ -124,8 +136,8 @@ npm install -g mulmoterminal
124
136
  mulmoterminal
125
137
  ```
126
138
 
127
- **First-run setup (optional).** `npx mulmoterminal init` checks your environment (Node ≥ 22.9,
128
- the `claude` CLI, plus optional `tmux` / `gh` / `codex`), seeds the launcher's **directory
139
+ **First-run setup (optional).** `npx mulmoterminal init` checks your environment (Node ≥ 22.9
140
+ and every CLI in the table above), seeds the launcher's **directory
129
141
  presets** from the projects in your Claude Code history, and writes `~/.mulmoterminal/config.json`.
130
142
  It's **idempotent** — re-run it any time to refresh the presets; it overwrites the managed parts
131
143
  and keeps your other settings. When `claude` is installed it can hand off to the
@@ -439,7 +451,9 @@ The Settings modal (⚙) persists per-user UI choices to `~/.mulmoterminal/confi
439
451
  | Field | Meaning |
440
452
  | ------------ | ------- |
441
453
  | `cwdPresets` | Quick-pick directories offered when launching a terminal. |
442
- | `soundFile` | Absolute path to a custom **attention sound** (played when a session needs attention). Empty/unset uses the built-in synthesized chime. |
454
+ | `soundFile` | Absolute path to a custom **attention sound**, the fallback for every kind. Empty/unset uses the built-in synthesized chime. |
455
+ | `soundKinds` | Which moments beep — see [Notification sounds](#notification-sounds). Defaults to `["finished","waiting"]`; the other kinds are opt-in. |
456
+ | `sounds` | Per-kind sound: `{ "waiting": "preset:coin" }`. A `preset:<id>` reference or an absolute path; a kind with no entry falls back to `soundFile`. |
443
457
  | `prRepos` | `owner/repo` entries whose open PRs/issues the cross-repo **PRs & Issues** view aggregates (via your `gh` login). |
444
458
  | `launchers` | `{ label, command }` entries offered in a grid cell's launcher besides Claude — a plain shell, `codex`, any interactive command. |
445
459
  | `quickCommands` | `{ label, text, agents? }` phrases the **phone** offers as chips on a session's terminal view. Tapping one puts `text` in the input box; it is not sent until you press send. `agents` (`"claude"` / `"codex"` / `"shell"`) scopes a chip to session kinds — omit it to offer the chip everywhere. Empty by default. |
@@ -452,6 +466,7 @@ The Settings modal (⚙) persists per-user UI choices to `~/.mulmoterminal/confi
452
466
  | `worklogIntervalHours` | Worklog cadence in hours (default `6`, clamped to `1`–`168`). |
453
467
  | `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). |
454
468
  | `prWorkdirFooter` | Ends the body of a PR **⧉ Open PR** creates 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. **On by default**; set `false` to opt out — read from the file per PR, so no restart is needed (there is no Settings control for it). Only applied to PRs this app creates (pressing the button again on an existing PR never re-appends). |
469
+ | `fontFamily` | The **terminal font** every session renders in — a CSS font-family stack, e.g. `"'Cica', 'MS Gothic', monospace"`. No Settings UI: edit the file, then **restart** (this config is read once at startup). Unset uses the built-in stack (JetBrains Mono / Fira Code / Menlo / Consolas, then CJK faces for Japanese, Korean and Chinese). Unlike the per-browser font **size**, this is one value for the whole host — it names fonts, and which fonts exist is a property of the machine. A directory can override it. See the [Configuration guide](https://receptron.github.io/mulmoterminal/guide/en/config.html#font-family). |
455
470
 
456
471
  #### Header buttons
457
472
 
@@ -470,12 +485,46 @@ there's no open PR) / `pickFile: true` (OS file dialog → insert the path).
470
485
  visibility. The `/mulmoterminal-config` skill writes a valid config interactively; per-dir buttons
471
486
  merge over the global ones by `id`.
472
487
 
473
- **Attention sound.** The default chime is generated with the Web Audio API — **no
474
- audio file is bundled**, so the npm package stays light and has no media-licensing
475
- concerns. To use your own sound, set `soundFile` in Settings (Browse / Test / Use
476
- chime) or in the config file; the server streams that file at `GET /api/sound` and
477
- the client decodes it (falling back to the chime if it's missing or not audio). It's
478
- your own local file referenced by absolute path — nothing is added to the package.
488
+ ### Notification sounds
489
+
490
+ Six moments can beep, each with its own sound and its own on/off switch. Running many
491
+ agents at once is what turns notifications into noise, so **only the first two are on by
492
+ default** the rest are opt-in from Settings.
493
+
494
+ | Kind | When | Default |
495
+ | --- | --- | --- |
496
+ | `finished` | the turn ended and the output is unread | **on** |
497
+ | `waiting` | it stopped to ask — a permission prompt or a question | **on** |
498
+ | `command-done` | a **Run cell's** command exited 0 | off |
499
+ | `command-failed` | a **Run cell's** command exited non-zero, or never started | off |
500
+ | `session-exited` | a session's terminal ended — **including when you close the cell yourself** | off |
501
+ | `pr-ci-failed` | a directory's PR went red. Only seen **while the roster is on screen**, since that is what polls the phase | off |
502
+
503
+ A **Run cell** is the one-shot cell a `script.json` entry or a `run:"shell"` header button
504
+ opens — not a shell launcher cell. A launcher runs an interactive shell that stays alive, so
505
+ nothing marks where one command inside it ended; only the one-shot cell reports an exit code.
506
+
507
+ `finished` and `waiting` reach the phone too (`pushKinds`); the other four are seen only in
508
+ the browser — a Run PTY never enters the session registry, and a PR phase is something the
509
+ page polls — so Web Push cannot raise them.
510
+
511
+ **What each one plays.** The default chime is generated with the Web Audio API — **no audio
512
+ file is bundled**, so the npm package stays light and has no media-licensing concerns. Beyond
513
+ it there are two options:
514
+
515
+ - **Presets** — seven sounds hosted in the [ownplate](https://github.com/Nakajima-Foundation/ownplate)
516
+ repo (MIT), referenced as `preset:<id>`: `chime` `coin` `cheep` `door` `gong` `magic` `meow`.
517
+ The first play downloads one into `~/.mulmoterminal/sounds/`; every later play reads that
518
+ file, so a preset keeps working offline. A failed download is not remembered as one — you get
519
+ the chime that time and the next play retries. That holds on both sides: the server caches no
520
+ failure, and it answers **503** (not 404) for a preset it could not fetch, because the browser
521
+ remembers a 404 for the life of the page and only retries a 5xx.
522
+ - **Your own file** — an absolute path, per kind in `sounds` or as the all-kind `soundFile`.
523
+
524
+ Resolution per kind, nearest first: the session directory's `sounds[kind]`, its `sound`, your
525
+ `sounds[kind]`, your `soundFile`, then the chime. The server streams whichever applies at
526
+ `GET /api/sound?kind=` / `GET /api/dir-sound?cwd=&kind=`, and the client falls back to the
527
+ chime if it's missing or not audio.
479
528
 
480
529
  **Web Push on task finish.** Enable `pushEnabled` in Settings to have the server send a
481
530
  push (title = the project dir, body = the last prompt) to your registered devices each
@@ -529,7 +578,10 @@ malformed file is ignored.
529
578
  "theme": "nord", // terminal palette: midnight | nord | daylight | solarized
530
579
  "colors": { "background": "#190a23", "cursor": "#ff2e63" }, // per-key palette overrides
531
580
  "fontSize": 16, // terminal font size in px (8–32); overrides Settings
532
- "sound": "./.mulmoterminal/alert.mp3" // attention sound, RELATIVE to this directory
581
+ "fontFamily": "'Cica', monospace", // terminal font stack; overrides the global config
582
+ "orderPriority": 10, // rank in the grid's "priority" ordering (lowest first)
583
+ "sound": "./.mulmoterminal/alert.mp3", // attention sound, RELATIVE to this directory
584
+ "sounds": { "command-failed": "preset:gong" } // per-notification-kind override
533
585
  }
534
586
  ```
535
587
 
@@ -550,12 +602,20 @@ malformed file is ignored.
550
602
  | `theme` | xterm palette for terminals in this directory (one of the built-in theme ids). |
551
603
  | `colors` | Per-key xterm palette overrides applied on top of `theme` (or the app theme when `theme` is unset). Keys are xterm `ITheme` names (`background`, `foreground`, `cursor`, `selectionBackground`, the 16 ANSI colors, …); values are hex (`#rgb` / `#rrggbb` / `#rrggbbaa`). Unknown keys / bad values are dropped. |
552
604
  | `fontSize` | Terminal font size in px for this directory (8–32), overriding the Settings value. A size outside the range is clamped; a non-number is ignored. Changing it re-fits the terminal, so the PTY learns the new width — unlike browser zoom, which leaves the two disagreeing. |
553
- | `sound` | Attention sound for this directory's sessions, a path **relative to the directory** (served at `GET /api/dir-sound`). |
554
-
555
- **Security.** `sound` is a directory-relative path only absolute paths and any
556
- `../` that escapes the directory are rejected, and the path is never taken from the
605
+ | `orderPriority` | This directory's rank in the grid's **priority** ordering — the third mode on the toolbar's ordering button, next to auto (attention-first) and manual (the move buttons). Any integer, **lowest first**; negatives are allowed. Directories that set nothing sort last, keeping their existing order, so adding the key to one project doesn't shuffle the rest. Only the priority mode reads it. |
606
+ | `fontFamily` | CSS font-family stack for this directory's terminals, overriding the global `fontFamily`. Use the names as your OS lists them (`"'Cica', 'MS Gothic', monospace"`). An unusable stack is ignored whole rather than half-applied; `monospace` is appended if you name no generic family. Prefer fonts whose fullwidth glyphs are exactly twice the Latin width, or box-drawing frames tear. |
607
+ | `sound` | Attention sound for this directory's sessions, a path **relative to the directory** (served at `GET /api/dir-sound`). The fallback for every kind. |
608
+ | `sounds` | Per-kind override of `sound`: `{ "command-failed": "preset:gong" }`. Each value is a `preset:<id>` or a directory-relative path, under the same confinement. |
609
+ | `addDirs` | Extra directories this project's Claude sessions may read and edit — the terminal-side equivalent of opening several folders in one VS Code workspace, via Claude Code's `--add-dir`. Relative entries resolve against **this file's directory** (`"../shared-lib"`), a path that doesn't exist is dropped, max 16. In the Docker sandbox each one is bind-mounted too, so the grant is real inside the container — which widens the sandbox on purpose. Claude only: codex has no equivalent flag and ignores the key. |
610
+
611
+ **Security.** `sound` and every `sounds` entry are directory-relative paths only — absolute
612
+ paths and any `../` that escapes the directory are rejected, and the path is never taken from the
557
613
  HTTP request, so an opened project can't point the player at arbitrary files.
558
- Changes take effect when the terminal is next opened (no live file watch).
614
+ **When changes take effect.** A write made *through Claude's tools* which includes the
615
+ `mulmoterminal-config` skill — applies **live**: the tool hook that reports the write doubles
616
+ as the reload signal, so colors, palette, font size and grid order update without reopening
617
+ anything. There is no filesystem watcher, so an edit made **outside** a session (your own
618
+ editor) is picked up when the terminal is next opened.
559
619
 
560
620
  ---
561
621
 
@@ -700,10 +760,33 @@ tree; clicking a file opens it in a **CodeMirror** editor (Markdown / JS-TS / JS
700
760
  highlighting, everything else as plain text). Markdown files get a **Preview** toggle
701
761
  that renders via the server's sandboxed `…/md` HTML. **Save** (or ⌘/Ctrl-S) writes back.
702
762
 
763
+ **Beside an enlarged terminal, not only full-screen.** Expand a grid cell (**⤢**) and its
764
+ header gains a **folder** toggle that splits the enlarged area in two: terminal on the left,
765
+ the same explorer + editor on the right, rooted at that cell's directory. Drag the divider
766
+ (or focus it and use ←/→, Home, End) to resize — the terminal keeps a floor, so a squeeze
767
+ shrinks the pane rather than reflowing xterm into garbage. It works in both zoomed layouts
768
+ (cockpit roster and thumbnail filmstrip), the pane re-roots as you walk the zoom between
769
+ terminals, and whether it's open plus how wide it is are remembered per browser.
770
+
703
771
  All reads and writes go through `GET/PUT /api/files/browse/*?cwd=&path=`, and every
704
772
  `path` is **contained within the project root** (server-side) — `..`/absolute escapes
705
773
  are rejected for reads and writes alike, so editing can't reach outside the directory
706
- the terminal is pointed at.
774
+ the terminal is pointed at. A save sends the version the file had when it was opened, so
775
+ it is **refused (409) rather than silently overwriting** an agent that edited the same
776
+ file meanwhile; the editor then offers to reload or to overwrite deliberately.
777
+
778
+ You usually hear about it before that. An open file that changes on disk is picked up from
779
+ Claude's own write hook (immediately) and from a 30-second version check (which catches Codex,
780
+ git, builds and other editors too). A **clean** buffer just takes the new content — the pane
781
+ reads as a live view — while a **dirty** one raises the same banner rather than choosing for you.
782
+
783
+ **Leaving an open file saves it** — switching files, moving the enlargement to another
784
+ terminal, closing the pane, navigating away. No dialog interrupts you mid-flow, because
785
+ opening a file, and replacing one, keep a copy under `~/.mulmoterminal/backups/` — **three
786
+ generations per file**, outside the project so they never reach `git status` or the agent's
787
+ view of its own repo. A parting save that loses the version race banks your version there
788
+ instead of overwriting the other writer. Re-opening unchanged content doesn't rotate one in, and a backup that
789
+ can't be written never blocks the read or the save it was taken for.
707
790
 
708
791
  ---
709
792
 
@@ -838,7 +921,10 @@ Favorited collections get their own toolbar buttons.
838
921
  - **Notifications** (🔔) — a toolbar bell with an unread badge and a dropdown of active
839
922
  notifications; click a row to jump to its session.
840
923
  - **Voice input** — dictate a prompt via on-device Whisper (`POST /api/transcribe`, macOS
841
- only; the model downloads on first use).
924
+ only; the model downloads on first use). Settings picks **the language you dictate in**
925
+ (per browser): your browser's, whisper's own per-clip detection, or a fixed one. Worth
926
+ setting — speech in a language the mic is not expecting comes back *translated* into the
927
+ one it is, so an English browser silently turned Japanese dictation into English.
842
928
  - **Remote host** — link MulmoTerminal to the companion phone client (Google sign-in) to
843
929
  watch and start sessions from your phone.
844
930
  - **Themes** — four terminal palettes (midnight / nord / daylight / solarized), your pick
@@ -847,6 +933,11 @@ Favorited collections get their own toolbar buttons.
847
933
  **Option** is treated as Meta so Claude's Alt-key bindings work. If your Claude Code is
848
934
  rebound so Enter and Shift+Enter behave backwards, flip them with
849
935
  [`terminalSubmit`](https://receptron.github.io/mulmoterminal/guide/en/config.html#terminal-submit).
936
+ - **No accidental page zoom** — `Ctrl`+wheel and a trackpad pinch would rescale the whole
937
+ page and drag the layout and the terminal's fit along with it, so both are ignored.
938
+ Keyboard zoom (`Cmd`/`Ctrl` `+` / `-`) still works when you mean it, and a phone's finger
939
+ pinch is untouched. To make terminal text bigger for real, use the font size in Settings
940
+ (or a directory's `fontSize`) — that re-fits the PTY instead of leaving it disagreeing.
850
941
 
851
942
  ---
852
943
 
@@ -1018,7 +1109,7 @@ same-origin-guarded.
1018
1109
  | -------- | ------- |
1019
1110
  | `GET /api/wiki` (`?slug=`) · `/api/wiki/graph` · `/api/wiki/lint` | Read-only wiki index / page / graph / lint. |
1020
1111
  | `GET /api/collections/…` · `/api/feeds` · `GET\|PUT /api/shortcuts` | Collections browser, feeds, favorites (see `docs/collection-plugin-integration.md`). |
1021
- | `GET /api/files/browse/{list,text,md}` · `PUT /api/files/browse/write` | File tree / read / Markdown-render / write (contained within the project root). |
1112
+ | `GET /api/files/browse/{list,text,version,md}` · `PUT /api/files/browse/{write,backup}` | File tree / read / Markdown-render / write (contained within the project root). `text` answers `{ text, version }`; `write` takes `{ text, baseVersion }` (`null` = expecting to create it) and answers **409** with the version now on disk if the file changed since — so a save can't silently overwrite the agent that edits the same files. `version` answers that token alone, for the editor's periodic check. `backup` banks a buffer the editor is about to discard. |
1022
1113
  | `GET /api/files/raw?path=` | Raw asset bytes (workspace-rooted). |
1023
1114
 
1024
1115
  **GUI panel / plugins / MCP**
@@ -1035,8 +1126,8 @@ same-origin-guarded.
1035
1126
 
1036
1127
  | Endpoint | Purpose |
1037
1128
  | -------- | ------- |
1038
- | `GET\|POST /api/config` | User UI config (`cwdPresets`, `soundFile`, `prRepos`, `launchers`, `quickCommands`, `userMcpServers`, `providers`). |
1039
- | `GET /api/sound` · `/api/dir-sound?cwd=` · `/api/dir-config?cwd=` | Custom / per-directory attention sound + per-dir config. |
1129
+ | `GET\|POST /api/config` | User UI config (`cwdPresets`, `soundFile`, `soundKinds`, `sounds`, `prRepos`, `launchers`, `quickCommands`, `userMcpServers`, `providers`). |
1130
+ | `GET /api/sound?kind=` · `/api/dir-sound?cwd=&kind=` · `/api/sound-preset/:id` · `/api/dir-config?cwd=` | Custom / per-directory / preset attention sound + per-dir config. `kind` selects a config entry, never a path. |
1040
1131
  | `GET /api/launch-options` | The Anthropic-compatible backends this server can reach, each with its models and — when it can't — the reason. Reports the **name** of the env var a key is read from, never the key. |
1041
1132
  | `GET /api/notifications`(`/history`) · `POST /api/notifications/:id/clear` | Notification feed. |
1042
1133
  | `POST /api/transcribe`(`/model`…) | Voice-input transcription (Whisper, macOS). |
@@ -76,6 +76,31 @@ function claudeInstalled() {
76
76
  return hasCommand("claude");
77
77
  }
78
78
 
79
+ // PATH tools the app shells out to; mirrors the requirements table in README.md. `required`
80
+ // ones back the core grid — without them a developer loses whole views rather than one
81
+ // feature — so a miss is an ✗, not an ○.
82
+ const PATH_TOOLS = [
83
+ { cmd: "git", versionArg: "--version", required: true, why: "worktrees, per-cell branch/diff, PR footer", hint: "brew install git · apt install git" },
84
+ { cmd: "gh", versionArg: "--version", required: true, why: "PRs & Issues view + one-click PRs", hint: "https://cli.github.com (then: gh auth login)" },
85
+ { cmd: "tmux", versionArg: "-V", required: false, why: "sessions survive a restart", hint: "brew install tmux · apt install tmux" },
86
+ { cmd: "codex", versionArg: "--version", required: false, why: "run OpenAI Codex as an agent", hint: "npm install -g @openai/codex" },
87
+ { cmd: "docker", versionArg: "--version", required: false, why: "the experimental Docker sandbox", hint: "https://docs.docker.com/get-started/get-docker/" },
88
+ {
89
+ cmd: "ffmpeg",
90
+ versionArg: "-version",
91
+ required: false,
92
+ why: "video rendering in the mulmo-script panel",
93
+ hint: "brew install ffmpeg · apt install ffmpeg",
94
+ },
95
+ { cmd: "ollama", versionArg: "--version", required: false, why: "a fully local model via claude-ollama", hint: "https://ollama.com/download" },
96
+ ];
97
+
98
+ function toolCheckLine({ cmd, versionArg, required, why, hint }) {
99
+ if (hasCommand(cmd, versionArg)) return ` ✓ ${cmd} — ${why}`;
100
+ const head = required ? ` ✗ ${cmd} — not found, needed for ${why}` : ` ○ ${cmd} — optional (${why})`;
101
+ return `${head}\n → ${hint}`;
102
+ }
103
+
79
104
  function promptYesNo(question) {
80
105
  return new Promise((res) => {
81
106
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -103,13 +128,7 @@ async function runInit(initArgs) {
103
128
  console.log(" → npm install -g @anthropic-ai/claude-code (then run `claude` and log in)");
104
129
  }
105
130
 
106
- for (const [cmd, versionArg, why, hint] of [
107
- ["tmux", "-V", "sessions survive a restart", "brew install tmux · apt install tmux"],
108
- ["gh", "--version", "PRs & Issues view + one-click PRs", "https://cli.github.com (then: gh auth login)"],
109
- ["codex", "--version", "run OpenAI Codex as an agent", "npm install -g @openai/codex"],
110
- ]) {
111
- console.log(hasCommand(cmd, versionArg) ? ` ✓ ${cmd} — ${why}` : ` ○ ${cmd} — optional (${why})\n → ${hint}`);
112
- }
131
+ PATH_TOOLS.forEach((tool) => console.log(toolCheckLine(tool)));
113
132
 
114
133
  // Config half: derive working-dir presets from Claude history + write config.json.
115
134
  console.log("");
@@ -0,0 +1,26 @@
1
+ // The `POST /api/collections/:slug/calendar-push` response. The server builds it from the
2
+ // engine's outcome; the collection view reads it to say what the click did — both sides
3
+ // decide from it, so it lives here.
4
+ //
5
+ // The shape (and the route's path) mirrors MulmoClaude's `CollectionPushBody`
6
+ // (server/api/routes/collectionCalendarPush.ts) so the two hosts over the shared workspace
7
+ // answer the same plugin identically. Re-stated rather than imported: the plugin ships its
8
+ // `CollectionPushResult` from `@mulmoclaude/collection-plugin/vue`, and the server has no
9
+ // business pulling a Vue package in to describe its own response.
10
+
11
+ export interface CollectionPushResult {
12
+ /** Always true — "the push ran", not "records moved". The plugin's own type widens this
13
+ * to `boolean` but never reads it, and MulmoClaude pins it to `true`; a refusal is told
14
+ * through `errors`, so the two hosts stay identical on a field neither of them uses. */
15
+ pushed: true;
16
+ created: number;
17
+ updated: number;
18
+ /** Edited on both sides; skipped so neither version is destroyed. */
19
+ conflicts: number;
20
+ /** Deleted locally. Reported only — a push never deletes in Google. */
21
+ localDeletes: number;
22
+ /** Records that could not be pushed as they stand, each with its reason. */
23
+ skipped: string[];
24
+ /** Why the push as a whole did not do what was asked. */
25
+ errors: string[];
26
+ }
@@ -21,6 +21,14 @@ export interface DirChrome {
21
21
  // Unlike the colors above, this changes the cell metrics — every path that applies it has
22
22
  // to re-fit and push the new cols/rows to the PTY, or the grid drifts from the canvas.
23
23
  fontSize: number | null;
24
+ // The CSS font-family stack for terminals opened here, or null to use the global setting.
25
+ // Changes the cell metrics for the same reason `fontSize` does — a different face has a
26
+ // different advance width — so it re-fits on the same path.
27
+ fontFamily: string | null;
28
+ // Where this directory's cells sit in the grid's "priority" sort order — ascending, and
29
+ // null (unset) sorts last so adding it to one directory doesn't displace every other cell.
30
+ // Only that one sort mode reads it; "auto" and "manual" ignore it entirely.
31
+ orderPriority: number | null;
24
32
  }
25
33
 
26
34
  // "Nothing configured" — the base every DirConfig/PublicDirConfig empty spreads, so adding
@@ -37,4 +45,6 @@ export const EMPTY_DIR_CHROME: Readonly<DirChrome> = {
37
45
  dotColor: null,
38
46
  buttonColor: null,
39
47
  fontSize: null,
48
+ fontFamily: null,
49
+ orderPriority: null,
40
50
  };
@@ -0,0 +1,9 @@
1
+ // The pub/sub channel carrying "an agent just wrote this file". Both sides decide from it —
2
+ // the server publishes, the editor listens to know its open file moved under it — so the name
3
+ // and the payload shape live here rather than as a string literal on each side.
4
+ export const FILE_WRITE_CHANNEL = "file-write";
5
+
6
+ /** Absolute path, as the server resolved it. */
7
+ export interface FileWriteEvent {
8
+ file: string;
9
+ }
package/common/keymap.ts CHANGED
@@ -10,12 +10,33 @@
10
10
  // "keymap": { "zoom-next": "PageDown", "zoom-prev": "Shift+PageUp" }
11
11
 
12
12
  // Actions a key can be bound to. Adding one here is all it takes for the config to accept it.
13
- export const KEYMAP_ACTIONS = ["zoom-toggle", "zoom-next", "zoom-prev", "next-attention", "terminal-new", "terminal-new-adjacent", "terminal-close"] as const;
13
+ export const KEYMAP_ACTIONS = [
14
+ "zoom-toggle",
15
+ "zoom-next",
16
+ "zoom-prev",
17
+ "next-attention",
18
+ "terminal-new",
19
+ "terminal-new-adjacent",
20
+ "terminal-close",
21
+ "copy",
22
+ "paste",
23
+ ] as const;
14
24
  export type KeymapAction = (typeof KEYMAP_ACTIONS)[number];
15
25
 
16
26
  export const isKeymapAction = (value: unknown): value is KeymapAction => typeof value === "string" && (KEYMAP_ACTIONS as readonly string[]).includes(value);
17
27
 
18
28
  // action -> binding string. Absent action = unbound = that shortcut does nothing.
29
+ // Actions the GRID's key handler must never claim, because they are decided inside the terminal
30
+ // instead (see terminalClipboard.ts). One `keymap` block stays the user's single place to bind a
31
+ // key; only the dispatch differs, and it has to:
32
+ //
33
+ // - The grid handler ends every match with preventDefault(). For `paste` that is fatal — the
34
+ // browser's own paste is what actually inserts the text, and cancelling the keydown cancels
35
+ // it. xterm implements paste as a `paste` DOM listener, not a key binding.
36
+ // - `copy` must fall through to the terminal when there is NO selection, so Ctrl+C still sends
37
+ // ^C. A handler that has already swallowed the key cannot change its mind.
38
+ export const TERMINAL_SCOPED_ACTIONS: readonly KeymapAction[] = ["copy", "paste"];
39
+
19
40
  export type Keymap = Partial<Record<KeymapAction, string>>;
20
41
 
21
42
  // A parsed binding. `key` is matched against `KeyboardEvent.key` exactly as the browser
@@ -0,0 +1,26 @@
1
+ // The moments MulmoTerminal can notify you about. The attention sound reads this whole
2
+ // list; Web Push reads the PUSH_KINDS subset in pushKinds.ts, because a phone can only be
3
+ // told about what the SERVER observes — the four kinds added below are seen in the browser.
4
+
5
+ // Every kind that exists.
6
+ // finished — the turn ended, output is waiting to be reviewed.
7
+ // waiting — the agent is blocked on input (a permission prompt or a question).
8
+ // command-done — a Run cell's command exited 0.
9
+ // command-failed — a Run cell's command exited non-zero.
10
+ // session-exited — a session's PTY ended. Closing a cell yourself goes through the same
11
+ // path, so this one fires on a deliberate close too.
12
+ // pr-ci-failed — a directory's PR phase became ci-failing. The phase poll runs only
13
+ // while the roster is on screen, so a failure that lands while you are
14
+ // in another view is not seen.
15
+ export const NOTIFY_KINDS = ["finished", "waiting", "command-done", "command-failed", "session-exited", "pr-ci-failed"] as const;
16
+
17
+ export type NotifyKind = (typeof NOTIFY_KINDS)[number];
18
+
19
+ // What a config with no `soundKinds` gets. Deliberately a SEPARATE list from NOTIFY_KINDS,
20
+ // not a copy of it: a kind added later must NOT start beeping at people who never asked for
21
+ // it — the same rule DEFAULT_PUSH_KINDS documents, and the reason the four kinds above are
22
+ // absent here. Add a kind to NOTIFY_KINDS so it can be switched on, and leave it out of
23
+ // here so it stays opt-in.
24
+ export const DEFAULT_SOUND_KINDS: NotifyKind[] = ["finished", "waiting"];
25
+
26
+ export const isNotifyKind = (value: unknown): value is NotifyKind => NOTIFY_KINDS.some((kind) => kind === value);
@@ -0,0 +1,44 @@
1
+ // The preset attention sounds, and how a configured sound names one.
2
+ //
3
+ // The audio lives in the ownplate repo (MIT, same org) rather than in this package, so an
4
+ // install doesn't carry ~380 KB of audio nobody may play. The server fetches a preset once
5
+ // into ~/.mulmoterminal/sounds/ and serves it from there afterwards, so it keeps working
6
+ // offline — see server/config/sound-presets.ts.
7
+
8
+ // Pinned to the commit that last touched these files (2022-03-30) rather than to a branch:
9
+ // a branch ref would let the bytes behind a cached preset change, and a user who picked
10
+ // "coin" would silently get something else on a machine that hadn't cached it yet.
11
+ export const SOUND_PRESET_COMMIT = "fb36eb8748b7f3d181d7fc0e366e01971a56ad2f";
12
+ export const SOUND_PRESET_BASE_URL = `https://raw.githubusercontent.com/Nakajima-Foundation/ownplate/${SOUND_PRESET_COMMIT}/public/`;
13
+
14
+ export interface SoundPreset {
15
+ id: string;
16
+ file: string;
17
+ label: string;
18
+ }
19
+
20
+ export const SOUND_PRESETS: readonly SoundPreset[] = [
21
+ { id: "chime", file: "sound_default.mp3", label: "Chime" },
22
+ { id: "coin", file: "sound_coin.mp3", label: "Coin" },
23
+ { id: "cheep", file: "sound_cheep_cheep.mp3", label: "Cheep" },
24
+ { id: "door", file: "sound_door_chime.mp3", label: "Door chime" },
25
+ { id: "gong", file: "sound_gong.mp3", label: "Gong" },
26
+ { id: "magic", file: "sound_magic.mp3", label: "Magic" },
27
+ { id: "meow", file: "sound_meow.mp3", label: "Meow" },
28
+ ];
29
+
30
+ // A sound value is either a preset reference or a path to the user's own file. The prefix is
31
+ // what tells them apart, and it can't collide with a path: an absolute path starts with "/"
32
+ // (or a drive letter), and a relative one is rejected before it gets here.
33
+ const PRESET_PREFIX = "preset:";
34
+
35
+ export const presetRef = (id: string): string => `${PRESET_PREFIX}${id}`;
36
+
37
+ export const soundPresetById = (id: string): SoundPreset | null => SOUND_PRESETS.find((preset) => preset.id === id) ?? null;
38
+
39
+ /** The preset id in a `preset:<id>` value, or null when it names a file path or an unknown preset. */
40
+ export function parsePresetRef(value: string): string | null {
41
+ if (!value.startsWith(PRESET_PREFIX)) return null;
42
+ const id = value.slice(PRESET_PREFIX.length);
43
+ return soundPresetById(id) ? id : null;
44
+ }
@@ -0,0 +1,20 @@
1
+ // A directory's rank in the grid's "priority" sort order. Shared so the two boundaries that
2
+ // validate it — the server reading .mulmoterminal.json, and the client reading /api/dir-config —
3
+ // cannot disagree about what counts as a rank. They did briefly: one accepted any finite number
4
+ // while the other required an integer, so a fractional value would have sorted on one side and
5
+ // read as unset on the other.
6
+ //
7
+ // Integers only: a rank is an ordering, so 1.5 buys nothing and invites float-comparison
8
+ // surprises. Not range-limited, unlike a font size — every integer is a usable rank, and
9
+ // negatives are how a project sorts ahead of everything at 0.
10
+ //
11
+ // SAFE integers, because the strict half of the pair is `z.number().int()` and zod@4 reads that
12
+ // as safe-only. `Number.isInteger` alone accepts 2^53+1 and 1e300, which this would have taken
13
+ // while writableDirConfigSchema rejected them — the exact disagreement the paragraph above says
14
+ // this module exists to prevent, one boundary over. Past 2^53 an integer is not distinct from its
15
+ // neighbours anyway, so it cannot express a rank.
16
+ //
17
+ // null means "unset", which the sort reads as "after everything that declares a rank".
18
+ export function normalizeOrderPriority(input: unknown): number | null {
19
+ return typeof input === "number" && Number.isSafeInteger(input) ? input : null;
20
+ }
@@ -1,12 +1,16 @@
1
1
  // The kinds of Web Push a session can raise. The server decides which one a hook warrants and
2
2
  // the settings UI offers them as checkboxes, so the list is a value both sides read.
3
3
 
4
- // Every kind that exists.
4
+ import type { NotifyKind } from "./notifyKinds.js";
5
+
6
+ // Every kind that exists — a SUBSET of NOTIFY_KINDS, since a push can only report what the
7
+ // server itself observes. The rest of the notify kinds are browser-side signals (a Run cell's
8
+ // exit, a PR phase poll) that never reach the process holding the Firebase auth.
5
9
  // finished — the turn ended, output is waiting to be reviewed.
6
10
  // waiting — the agent is blocked on input (a permission prompt or a question), so answering
7
11
  // from the phone unblocks real work. Fires once per prompt, which on a long task
8
12
  // that asks repeatedly is what makes pushes feel frequent (#850).
9
- export const PUSH_KINDS = ["finished", "waiting"] as const;
13
+ export const PUSH_KINDS = ["finished", "waiting"] as const satisfies readonly NotifyKind[];
10
14
 
11
15
  export type PushKind = (typeof PUSH_KINDS)[number];
12
16
 
@@ -0,0 +1,52 @@
1
+ // Whether a keystroke should hand the terminal's clipboard work to the BROWSER, decided without
2
+ // touching the DOM so the rules are unit-testable on their own (same shape as `enterKeyOverride`
3
+ // in terminalSubmit.ts).
4
+ //
5
+ // The thing to understand before changing any of this: xterm already implements copy and paste.
6
+ // It listens for the `copy` and `paste` DOM events on its own element and textarea, writes the
7
+ // selection out, and brackets pasted text. Nothing here reads or writes a clipboard — which is
8
+ // also why no clipboard PERMISSION is involved, unlike `navigator.clipboard.readText()`.
9
+ //
10
+ // What is missing is only that the browser never fires those events: xterm's key handling turns
11
+ // Ctrl+C into ^C and cancels the keydown, so the platform's copy shortcut never happens. The one
12
+ // decision this module makes is when to stand back and let it.
13
+ import { actionForKey, type Keymap, type KeymapAction } from "./keymap.js";
14
+
15
+ // The structural shape of a keydown these rules need; a real KeyboardEvent satisfies it and so
16
+ // does a plain test object.
17
+ export interface ClipboardKeyEvent {
18
+ type: string;
19
+ key: string;
20
+ shiftKey: boolean;
21
+ altKey: boolean;
22
+ ctrlKey: boolean;
23
+ metaKey: boolean;
24
+ isComposing?: boolean;
25
+ }
26
+
27
+ export type ClipboardAction = Extract<KeymapAction, "copy" | "paste">;
28
+
29
+ // `hasSelection` is the terminal's own answer, passed in rather than read here so this stays
30
+ // DOM-free.
31
+ //
32
+ // Returning an action means ONE thing to the caller: return false from xterm's custom key
33
+ // handler. xterm then skips its own translation and — critically — does NOT preventDefault, so
34
+ // the browser performs the copy or paste it was always going to, and xterm's own listeners see
35
+ // it. Verified in @xterm/xterm 6.0.0:
36
+ //
37
+ // if (this._customKeyEventHandler && false === this._customKeyEventHandler(e)) return false;
38
+ //
39
+ // null means "not ours" — the key goes to the terminal exactly as before.
40
+ export function clipboardActionFor(keymap: Keymap, e: ClipboardKeyEvent, hasSelection: boolean): ClipboardAction | null {
41
+ if (e.type !== "keydown") return null;
42
+ // An IME candidate list drives itself with ordinary keys; that keystroke belongs to the
43
+ // composition, never to us.
44
+ if (e.isComposing) return null;
45
+ const action = actionForKey(keymap, e);
46
+ if (action !== "copy" && action !== "paste") return null;
47
+ // Copy only when there is something to copy. This is what keeps Ctrl+C usable as INTERRUPT:
48
+ // with no selection the key is not ours, so the terminal sends ^C exactly as it always did.
49
+ // Deciding up front — rather than copying and undoing it on failure — is why nothing here has
50
+ // to be reversed.
51
+ return action === "copy" && !hasSelection ? null : action;
52
+ }