mulmoterminal 2.1.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/README.md +127 -18
  2. package/common/collectionPush.ts +26 -0
  3. package/common/dirChrome.ts +10 -0
  4. package/common/fileWriteChannel.ts +9 -0
  5. package/common/githubRepo.ts +22 -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 +71 -0
  12. package/common/terminalFontFamily.ts +79 -0
  13. package/dist/assets/{abnfDiagram-VRR7QNED-RjiYivmv-1RrSDjxg.js → abnfDiagram-VRR7QNED-RjiYivmv-B8nu2pUR.js} +1 -1
  14. package/dist/assets/architecture-TIHT7OUA-0NsD2xW1-TY_v-qbq.js +1 -0
  15. package/dist/assets/{architectureDiagram-ZJ3FMSHR-3nWA91tG-sTMTrNOq.js → architectureDiagram-ZJ3FMSHR-3nWA91tG-ByxXyiOP.js} +1 -1
  16. package/dist/assets/{blockDiagram-677ZJIJ3-BPuAJQRW-CQmbTR0O.js → blockDiagram-677ZJIJ3-BPuAJQRW-Brt9e7H_.js} +1 -1
  17. package/dist/assets/{c4Diagram-LMCZKHZV-C0LAqQso-kjKqpuxO.js → c4Diagram-LMCZKHZV-C0LAqQso-D598g8JV.js} +1 -1
  18. package/dist/assets/channel-7wUqSdoX-DRUj8A2e.js +1 -0
  19. package/dist/assets/{chunk-32BRIVSS-BRrYpgtb-YnuI_kVF.js → chunk-32BRIVSS-BRrYpgtb-BEOPPcME.js} +1 -1
  20. package/dist/assets/{chunk-52WLFC77-BJ-ss3Xr-Dv-_qNms.js → chunk-52WLFC77-BJ-ss3Xr-BmZ-jgWV.js} +1 -1
  21. package/dist/assets/{chunk-C7G6YPKG-BZEucKEL-VvGb94ad.js → chunk-C7G6YPKG-BZEucKEL-DhvCnMVg.js} +1 -1
  22. package/dist/assets/{chunk-EX3LRPZG-DLS6FBN1-Cv9JdwrA.js → chunk-EX3LRPZG-DLS6FBN1-CxeaHZOV.js} +1 -1
  23. package/dist/assets/{chunk-FWX5IMBZ-DHLSFw1H-CFK4Xl3Y.js → chunk-FWX5IMBZ-DHLSFw1H-COykUZUW.js} +2 -2
  24. package/dist/assets/{chunk-HOUHSVGY-Bhlt8hXJ-l9dGXTcV.js → chunk-HOUHSVGY-Bhlt8hXJ-DFKJuoMh.js} +1 -1
  25. package/dist/assets/{chunk-ICXQ74PX-DwgHBX_g-oBhC9ghH.js → chunk-ICXQ74PX-DwgHBX_g-CFoH6Yjf.js} +1 -1
  26. package/dist/assets/{chunk-MOJQB5TN-CMZRaeqt-CKo27qok.js → chunk-MOJQB5TN-CMZRaeqt-Dy6S_sS_.js} +1 -1
  27. package/dist/assets/{chunk-OGEWGWER-8Qy4a8b5-Di7gzOY_.js → chunk-OGEWGWER-8Qy4a8b5-XcjK3kCc.js} +1 -1
  28. package/dist/assets/{chunk-PUDLZKDR-DcrWQRYh-DuMYBLYy.js → chunk-PUDLZKDR-DcrWQRYh-CFVQ5Aq8.js} +1 -1
  29. package/dist/assets/{chunk-Q4XR5HBZ-ZXVGkG8Z-BH8ZxP7u.js → chunk-Q4XR5HBZ-ZXVGkG8Z-D2iTH8ap.js} +1 -1
  30. package/dist/assets/{chunk-V7JOEXUC-DmGdheTX-e5Lem5jk.js → chunk-V7JOEXUC-DmGdheTX-Dfius2MW.js} +1 -1
  31. package/dist/assets/{chunk-VAUOI2AC-CS9QJ4yz-CfgcnwuB.js → chunk-VAUOI2AC-CS9QJ4yz-Dd_VpRsu.js} +1 -1
  32. package/dist/assets/{chunk-VR4S4FIN-C6a91eNY-BcBE3OXA.js → chunk-VR4S4FIN-C6a91eNY-U_jwmwMS.js} +1 -1
  33. package/dist/assets/{chunk-WYO6CB5R-BlzOfotS-BRwSb4rl.js → chunk-WYO6CB5R-BlzOfotS-04XZ1Jfd.js} +1 -1
  34. package/dist/assets/{chunk-ZGVPDNZ5-BYwxNFTK-yy_t_DUF.js → chunk-ZGVPDNZ5-BYwxNFTK-DBPsh-p3.js} +1 -1
  35. package/dist/assets/classDiagram-OUVF2IWQ-BYlRi6YQ-BVAZDjb0.js +1 -0
  36. package/dist/assets/classDiagram-v2-EOCWNBFH-BCHaYHxj-BVAZDjb0.js +1 -0
  37. package/dist/assets/cynefin-VYW2F7L2-CGYr2F6h-CFSruy5n.js +1 -0
  38. package/dist/assets/{cynefinDiagram-TSTJHNR4-DHA9iPo--CEHib9RV.js → cynefinDiagram-TSTJHNR4-DHA9iPo--D-iTSsn0.js} +1 -1
  39. package/dist/assets/{dagre-VKFMJZFB--oJKqXBZ-DufbQ1ld.js → dagre-VKFMJZFB--oJKqXBZ-G72EKlyP.js} +1 -1
  40. package/dist/assets/{diagram-FQU43EPY-CcJCB9bG-Bcp75QDM.js → diagram-FQU43EPY-CcJCB9bG-DgT-eqMs.js} +1 -1
  41. package/dist/assets/{diagram-G47NLZAW-C_o-WGG1-C_2FVIX_.js → diagram-G47NLZAW-C_o-WGG1-D_JxH6o2.js} +1 -1
  42. package/dist/assets/{diagram-NH7WQ7WH-CXJCYvY--B1kVCM4I.js → diagram-NH7WQ7WH-CXJCYvY--pVhp9uBi.js} +1 -1
  43. package/dist/assets/{diagram-OA4YK3LP-BMzeJ87A-Bd1BRg0M.js → diagram-OA4YK3LP-BMzeJ87A-CFoCoA1K.js} +1 -1
  44. package/dist/assets/{diagram-WEI45ONY-D_93NKqo-VJzlhYeh.js → diagram-WEI45ONY-D_93NKqo-COhtVv5k.js} +1 -1
  45. package/dist/assets/{ebnfDiagram-CCIWWBDH-CVai1Ii9-BXziXSdO.js → ebnfDiagram-CCIWWBDH-CVai1Ii9-cHbupDvE.js} +1 -1
  46. package/dist/assets/{erDiagram-Q63AITRT-CiABnA0s-DzjaMys9.js → erDiagram-Q63AITRT-CiABnA0s-Ch6F8aLL.js} +1 -1
  47. package/dist/assets/eventmodeling-45OFAUF4-CWmqhm3a-B09FMUUq.js +1 -0
  48. package/dist/assets/flowDiagram-23GEKE2U-DKSB5AiY-BswknzuF.js +1 -0
  49. package/dist/assets/{ganttDiagram-NO4QXBWP-DQZvdFo1-DqlM0R3b.js → ganttDiagram-NO4QXBWP-DQZvdFo1-DcmaS_P9.js} +1 -1
  50. package/dist/assets/gitGraph-TEB2WS4Q-BR9qPwdN-Bg_TZB3f.js +1 -0
  51. package/dist/assets/{gitGraphDiagram-IHSO6WYX-C2ovBouh-Dy5WrrGR.js → gitGraphDiagram-IHSO6WYX-C2ovBouh-CgaMpUqZ.js} +1 -1
  52. package/dist/assets/index-DWePsXYt.css +1 -0
  53. package/dist/assets/index-QEGG3YTz.js +618 -0
  54. package/dist/assets/info-DKCQHKI2-Dplx5kMp-BAI4sdgi.js +1 -0
  55. package/dist/assets/{infoDiagram-FWYZ7A6U-7UnoB5AP-ByceYPA5.js → infoDiagram-FWYZ7A6U-7UnoB5AP-f2daXl9J.js} +1 -1
  56. package/dist/assets/{ishikawaDiagram-FXEZZL3T-ByUDM_N2-BZEvgyxt.js → ishikawaDiagram-FXEZZL3T-ByUDM_N2-DYE7Ld9-.js} +1 -1
  57. package/dist/assets/{journeyDiagram-5HDEW3XC-c5xIah9o-D3IMe-Kx.js → journeyDiagram-5HDEW3XC-c5xIah9o-B0woNVtP.js} +1 -1
  58. package/dist/assets/{kanban-definition-HUTT4EX6-Cnt6loYD-cjfYxgHr.js → kanban-definition-HUTT4EX6-Cnt6loYD-Dm8evmYs.js} +1 -1
  59. package/dist/assets/{lib-BP5ftgj4.js → lib-DtgnGSMW.js} +1 -1
  60. package/dist/assets/{line-D7ziSjKi-C07Dlbdw.js → line-D7ziSjKi-CRwDdfSt.js} +1 -1
  61. package/dist/assets/{marp-ytjNgY2U.js → marp-Djuncd6G.js} +1 -1
  62. package/dist/assets/{mermaid-parser.core-DxEa8E3F-BAh0ySn4.js → mermaid-parser.core-DxEa8E3F-BILJo_fK.js} +2 -2
  63. package/dist/assets/{mermaid.core-V0OYwIz3-CIFgdt3N.js → mermaid.core-V0OYwIz3-CabnyPOO.js} +3 -3
  64. package/dist/assets/{mindmap-definition-LN4V7U3C-CexN3O6L-1SEo71tq.js → mindmap-definition-LN4V7U3C-CexN3O6L-DF1aNqGu.js} +1 -1
  65. package/dist/assets/packet-7NZHBO7P-CQI3flND-Bqib4t7m.js +1 -0
  66. package/dist/assets/{pegDiagram-2B236MQR-BiM4G0cW-DkewOOUw.js → pegDiagram-2B236MQR-BiM4G0cW-q-89nsVP.js} +1 -1
  67. package/dist/assets/pie-RZYD4A2V-CnmKE2Wa-C7f4Ij-s.js +1 -0
  68. package/dist/assets/{pieDiagram-ENE6RG2P-B_fBBS-2-DdJe9KqM.js → pieDiagram-ENE6RG2P-B_fBBS-2-B3pcC0Pg.js} +1 -1
  69. package/dist/assets/{quadrantDiagram-ABIIQ3AL-BigGVxCR-C8kNdasc.js → quadrantDiagram-ABIIQ3AL-BigGVxCR-BBKSbiVD.js} +1 -1
  70. package/dist/assets/radar-I7S5WNFK-us6x-Z9R-C0_E6OiV.js +1 -0
  71. package/dist/assets/railroad-3IZDKUUU-BKdz6pdN-C2_X_Txm.js +1 -0
  72. package/dist/assets/railroad-abnf-AHOZXSZD-m_CSSIfd-wMka3Tbm.js +1 -0
  73. package/dist/assets/railroad-ebnf-EBAXGLYW-Nu3nAG7C-B7uu2NPX.js +1 -0
  74. package/dist/assets/railroad-peg-LSFZ7HO6--Y3mD4R_-D4Sj2mdo.js +1 -0
  75. package/dist/assets/{railroadDiagram-RFXS5EU6--lNIlG64-DawLoSCW.js → railroadDiagram-RFXS5EU6--lNIlG64-Do0ZI2kT.js} +1 -1
  76. package/dist/assets/{requirementDiagram-TGXJPOKE-D-B0Y5Wu-BMFbGAmA.js → requirementDiagram-TGXJPOKE-D-B0Y5Wu-B4oci7Dv.js} +1 -1
  77. package/dist/assets/{sankeyDiagram-HTMAVEWB-DlutaXDv-Dwt5PZ2I.js → sankeyDiagram-HTMAVEWB-DlutaXDv-KRXYn9rF.js} +1 -1
  78. package/dist/assets/{sequenceDiagram-DBY2YBRQ-BOLgtggH-yb89czG2.js → sequenceDiagram-DBY2YBRQ-BOLgtggH-zw0J8MZu.js} +1 -1
  79. package/dist/assets/{stateDiagram-2N3HPSRC-jf_dXUEU-kqvWM0jR.js → stateDiagram-2N3HPSRC-jf_dXUEU-vFGH-mvy.js} +1 -1
  80. package/dist/assets/stateDiagram-v2-6OUMAXLB-CuzgCL2v-PiMBzK3e.js +1 -0
  81. package/dist/assets/{swimlanes-5IMT3BWC-CYjtALQH-CiVWAsob.js → swimlanes-5IMT3BWC-CYjtALQH-DhegOTK_.js} +1 -1
  82. package/dist/assets/swimlanesDiagram-G3AALYLV-3bmSBtKs-ByceWEns.js +8 -0
  83. package/dist/assets/{timeline-definition-FHXFAJF6-DYJ4oUm8-DrqIflbo.js → timeline-definition-FHXFAJF6-DYJ4oUm8-a7d-hF5Y.js} +1 -1
  84. package/dist/assets/treeView-QDETBFTQ-D27HWG6Q-Bf6uz3xV.js +1 -0
  85. package/dist/assets/treemap-6X3UGDF4-CmGIU3H5-CLzvNsC2.js +1 -0
  86. package/dist/assets/{vennDiagram-L72KCM5P-CdKAHoek-COepU8y_.js → vennDiagram-L72KCM5P-CdKAHoek-La4xWHGC.js} +1 -1
  87. package/dist/assets/wardley-OPB4EBWU-CbHWsb2k-OXtZqto9.js +1 -0
  88. package/dist/assets/{wardleyDiagram-EHGQE667-BF6c4_CW-Z2AgrgfV.js → wardleyDiagram-EHGQE667-BF6c4_CW-M6-Z6fe5.js} +1 -1
  89. package/dist/assets/{xychartDiagram-FW5EYKEG-CGiKngj7-ChC7g8sJ.js → xychartDiagram-FW5EYKEG-CGiKngj7-BtTeKKIV.js} +1 -1
  90. package/dist/index.html +2 -2
  91. package/package.json +8 -8
  92. package/server/agents/claude-args.ts +12 -0
  93. package/server/agents/session-summary-prompt.ts +49 -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/config/app-config.ts +69 -0
  98. package/server/config/config-body.ts +12 -1
  99. package/server/config/config-routes.ts +35 -4
  100. package/server/config/config-schema.ts +66 -0
  101. package/server/config/dir-config.ts +75 -19
  102. package/server/config/sound-presets.ts +96 -0
  103. package/server/files/atomic-write.ts +1 -1
  104. package/server/files/backup-store.ts +101 -0
  105. package/server/files/files-browse.ts +85 -5
  106. package/server/files/tool-writes.ts +20 -0
  107. package/server/git/github-star.ts +63 -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/routes/repo-routes.ts +25 -0
  113. package/server/session/activity-hook.ts +23 -4
  114. package/server/session/pty-spawn.ts +2 -2
  115. package/server/session/spawn-claude.ts +2 -1
  116. package/server/skills/mulmoterminal-config/SKILL.md +64 -3
  117. package/dist/assets/architecture-TIHT7OUA-0NsD2xW1-CtE1YID5.js +0 -1
  118. package/dist/assets/channel-7wUqSdoX-i8mXF_QX.js +0 -1
  119. package/dist/assets/classDiagram-OUVF2IWQ-BYlRi6YQ-BM_zLSMQ.js +0 -1
  120. package/dist/assets/classDiagram-v2-EOCWNBFH-BCHaYHxj-BM_zLSMQ.js +0 -1
  121. package/dist/assets/cynefin-VYW2F7L2-CGYr2F6h-BBb6pP0M.js +0 -1
  122. package/dist/assets/eventmodeling-45OFAUF4-CWmqhm3a-C1_24lZu.js +0 -1
  123. package/dist/assets/flowDiagram-23GEKE2U-DKSB5AiY-DQrHefx7.js +0 -1
  124. package/dist/assets/gitGraph-TEB2WS4Q-BR9qPwdN-DSTb91Xn.js +0 -1
  125. package/dist/assets/index-D4LB9YoW.js +0 -618
  126. package/dist/assets/index-u-r21U2t.css +0 -1
  127. package/dist/assets/info-DKCQHKI2-Dplx5kMp-Cdy5Xn2F.js +0 -1
  128. package/dist/assets/packet-7NZHBO7P-CQI3flND-BFv5gKuv.js +0 -1
  129. package/dist/assets/pie-RZYD4A2V-CnmKE2Wa-9hvyyDzu.js +0 -1
  130. package/dist/assets/radar-I7S5WNFK-us6x-Z9R-DBeFDuLF.js +0 -1
  131. package/dist/assets/railroad-3IZDKUUU-BKdz6pdN-FnTYQEAW.js +0 -1
  132. package/dist/assets/railroad-abnf-AHOZXSZD-m_CSSIfd-Bo4xQewA.js +0 -1
  133. package/dist/assets/railroad-ebnf-EBAXGLYW-Nu3nAG7C-YFFxMVbA.js +0 -1
  134. package/dist/assets/railroad-peg-LSFZ7HO6--Y3mD4R_-DMzqIAqS.js +0 -1
  135. package/dist/assets/stateDiagram-v2-6OUMAXLB-CuzgCL2v-QnrqYDdZ.js +0 -1
  136. package/dist/assets/swimlanesDiagram-G3AALYLV-3bmSBtKs-CE7hrF_H.js +0 -8
  137. package/dist/assets/treeView-QDETBFTQ-D27HWG6Q-DaEUFpbs.js +0 -1
  138. package/dist/assets/treemap-6X3UGDF4-CmGIU3H5-Cb7ZUpob.js +0 -1
  139. package/dist/assets/wardley-OPB4EBWU-CbHWsb2k-BON96qJv.js +0 -1
package/README.md CHANGED
@@ -223,6 +223,7 @@ The launcher detects it and prints the exact, OS-appropriate removal command; ru
223
223
  - [Session model](#session-model)
224
224
  - [Session lifecycle](#session-lifecycle)
225
225
  - [Claude hook injection](#claude-hook-injection)
226
+ - [Closing summary](#closing-summary)
226
227
  - [Session discovery & titles](#session-discovery--titles)
227
228
  - [Project structure](#project-structure)
228
229
  - [Testing](#testing)
@@ -287,7 +288,8 @@ today — **Claude Code** (the default) and **Codex**.
287
288
  - **Claude** — spawned as `claude` (override with `CLAUDE_BIN`). The server passes
288
289
  `--session-id <uuid>`, so it knows the live session's id even before its transcript
289
290
  file exists, and injects activity hooks + the GUI MCP per spawn (see
290
- [Claude hook injection](#claude-hook-injection)).
291
+ [Claude hook injection](#claude-hook-injection)) plus the
292
+ [closing summary](#closing-summary) instruction.
291
293
  - **Codex** — spawned as `codex` (override with `CODEX_BIN`; `CODEX_MODEL` sets
292
294
  `--model`). Codex runs on its own WebSocket (`/ws/codex`) and its sessions appear in the
293
295
  sidebar next to Claude's. Because Codex only mints its rollout id **after** the first
@@ -451,7 +453,9 @@ The Settings modal (⚙) persists per-user UI choices to `~/.mulmoterminal/confi
451
453
  | Field | Meaning |
452
454
  | ------------ | ------- |
453
455
  | `cwdPresets` | Quick-pick directories offered when launching a terminal. |
454
- | `soundFile` | Absolute path to a custom **attention sound** (played when a session needs attention). Empty/unset uses the built-in synthesized chime. |
456
+ | `soundFile` | Absolute path to a custom **attention sound**, the fallback for every kind. Empty/unset uses the built-in synthesized chime. |
457
+ | `soundKinds` | Which moments beep — see [Notification sounds](#notification-sounds). Defaults to `["finished","waiting"]`; the other kinds are opt-in. |
458
+ | `sounds` | Per-kind sound: `{ "waiting": "preset:coin" }`. A `preset:<id>` reference or an absolute path; a kind with no entry falls back to `soundFile`. |
455
459
  | `prRepos` | `owner/repo` entries whose open PRs/issues the cross-repo **PRs & Issues** view aggregates (via your `gh` login). |
456
460
  | `launchers` | `{ label, command }` entries offered in a grid cell's launcher besides Claude — a plain shell, `codex`, any interactive command. |
457
461
  | `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. |
@@ -463,7 +467,9 @@ The Settings modal (⚙) persists per-user UI choices to `~/.mulmoterminal/confi
463
467
  | `worklogEnabled` | `true` to run the built-in **dev worklog** batch (see below). Off by default (each run spawns an LLM session, so it costs tokens). |
464
468
  | `worklogIntervalHours` | Worklog cadence in hours (default `6`, clamped to `1`–`168`). |
465
469
  | `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). |
470
+ | `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. No Settings UI: edit the file and reload the tab. 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). |
466
471
  | `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). |
472
+ | `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). |
467
473
 
468
474
  #### Header buttons
469
475
 
@@ -482,12 +488,46 @@ there's no open PR) / `pickFile: true` (OS file dialog → insert the path).
482
488
  visibility. The `/mulmoterminal-config` skill writes a valid config interactively; per-dir buttons
483
489
  merge over the global ones by `id`.
484
490
 
485
- **Attention sound.** The default chime is generated with the Web Audio API — **no
486
- audio file is bundled**, so the npm package stays light and has no media-licensing
487
- concerns. To use your own sound, set `soundFile` in Settings (Browse / Test / Use
488
- chime) or in the config file; the server streams that file at `GET /api/sound` and
489
- the client decodes it (falling back to the chime if it's missing or not audio). It's
490
- your own local file referenced by absolute path — nothing is added to the package.
491
+ ### Notification sounds
492
+
493
+ Six moments can beep, each with its own sound and its own on/off switch. Running many
494
+ agents at once is what turns notifications into noise, so **only the first two are on by
495
+ default** the rest are opt-in from Settings.
496
+
497
+ | Kind | When | Default |
498
+ | --- | --- | --- |
499
+ | `finished` | the turn ended and the output is unread | **on** |
500
+ | `waiting` | it stopped to ask — a permission prompt or a question | **on** |
501
+ | `command-done` | a **Run cell's** command exited 0 | off |
502
+ | `command-failed` | a **Run cell's** command exited non-zero, or never started | off |
503
+ | `session-exited` | a session's terminal ended — **including when you close the cell yourself** | off |
504
+ | `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 |
505
+
506
+ A **Run cell** is the one-shot cell a `script.json` entry or a `run:"shell"` header button
507
+ opens — not a shell launcher cell. A launcher runs an interactive shell that stays alive, so
508
+ nothing marks where one command inside it ended; only the one-shot cell reports an exit code.
509
+
510
+ `finished` and `waiting` reach the phone too (`pushKinds`); the other four are seen only in
511
+ the browser — a Run PTY never enters the session registry, and a PR phase is something the
512
+ page polls — so Web Push cannot raise them.
513
+
514
+ **What each one plays.** The default chime is generated with the Web Audio API — **no audio
515
+ file is bundled**, so the npm package stays light and has no media-licensing concerns. Beyond
516
+ it there are two options:
517
+
518
+ - **Presets** — seven sounds hosted in the [ownplate](https://github.com/Nakajima-Foundation/ownplate)
519
+ repo (MIT), referenced as `preset:<id>`: `chime` `coin` `cheep` `door` `gong` `magic` `meow`.
520
+ The first play downloads one into `~/.mulmoterminal/sounds/`; every later play reads that
521
+ file, so a preset keeps working offline. A failed download is not remembered as one — you get
522
+ the chime that time and the next play retries. That holds on both sides: the server caches no
523
+ failure, and it answers **503** (not 404) for a preset it could not fetch, because the browser
524
+ remembers a 404 for the life of the page and only retries a 5xx.
525
+ - **Your own file** — an absolute path, per kind in `sounds` or as the all-kind `soundFile`.
526
+
527
+ Resolution per kind, nearest first: the session directory's `sounds[kind]`, its `sound`, your
528
+ `sounds[kind]`, your `soundFile`, then the chime. The server streams whichever applies at
529
+ `GET /api/sound?kind=` / `GET /api/dir-sound?cwd=&kind=`, and the client falls back to the
530
+ chime if it's missing or not audio.
491
531
 
492
532
  **Web Push on task finish.** Enable `pushEnabled` in Settings to have the server send a
493
533
  push (title = the project dir, body = the last prompt) to your registered devices each
@@ -541,7 +581,10 @@ malformed file is ignored.
541
581
  "theme": "nord", // terminal palette: midnight | nord | daylight | solarized
542
582
  "colors": { "background": "#190a23", "cursor": "#ff2e63" }, // per-key palette overrides
543
583
  "fontSize": 16, // terminal font size in px (8–32); overrides Settings
544
- "sound": "./.mulmoterminal/alert.mp3" // attention sound, RELATIVE to this directory
584
+ "fontFamily": "'Cica', monospace", // terminal font stack; overrides the global config
585
+ "orderPriority": 10, // rank in the grid's "priority" ordering (lowest first)
586
+ "sound": "./.mulmoterminal/alert.mp3", // attention sound, RELATIVE to this directory
587
+ "sounds": { "command-failed": "preset:gong" } // per-notification-kind override
545
588
  }
546
589
  ```
547
590
 
@@ -562,12 +605,20 @@ malformed file is ignored.
562
605
  | `theme` | xterm palette for terminals in this directory (one of the built-in theme ids). |
563
606
  | `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. |
564
607
  | `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. |
565
- | `sound` | Attention sound for this directory's sessions, a path **relative to the directory** (served at `GET /api/dir-sound`). |
566
-
567
- **Security.** `sound` is a directory-relative path only absolute paths and any
568
- `../` that escapes the directory are rejected, and the path is never taken from the
608
+ | `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. |
609
+ | `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. |
610
+ | `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. |
611
+ | `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. |
612
+ | `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. |
613
+
614
+ **Security.** `sound` and every `sounds` entry are directory-relative paths only — absolute
615
+ paths and any `../` that escapes the directory are rejected, and the path is never taken from the
569
616
  HTTP request, so an opened project can't point the player at arbitrary files.
570
- Changes take effect when the terminal is next opened (no live file watch).
617
+ **When changes take effect.** A write made *through Claude's tools* which includes the
618
+ `mulmoterminal-config` skill — applies **live**: the tool hook that reports the write doubles
619
+ as the reload signal, so colors, palette, font size and grid order update without reopening
620
+ anything. There is no filesystem watcher, so an edit made **outside** a session (your own
621
+ editor) is picked up when the terminal is next opened.
571
622
 
572
623
  ---
573
624
 
@@ -712,10 +763,33 @@ tree; clicking a file opens it in a **CodeMirror** editor (Markdown / JS-TS / JS
712
763
  highlighting, everything else as plain text). Markdown files get a **Preview** toggle
713
764
  that renders via the server's sandboxed `…/md` HTML. **Save** (or ⌘/Ctrl-S) writes back.
714
765
 
766
+ **Beside an enlarged terminal, not only full-screen.** Expand a grid cell (**⤢**) and its
767
+ header gains a **folder** toggle that splits the enlarged area in two: terminal on the left,
768
+ the same explorer + editor on the right, rooted at that cell's directory. Drag the divider
769
+ (or focus it and use ←/→, Home, End) to resize — the terminal keeps a floor, so a squeeze
770
+ shrinks the pane rather than reflowing xterm into garbage. It works in both zoomed layouts
771
+ (cockpit roster and thumbnail filmstrip), the pane re-roots as you walk the zoom between
772
+ terminals, and whether it's open plus how wide it is are remembered per browser.
773
+
715
774
  All reads and writes go through `GET/PUT /api/files/browse/*?cwd=&path=`, and every
716
775
  `path` is **contained within the project root** (server-side) — `..`/absolute escapes
717
776
  are rejected for reads and writes alike, so editing can't reach outside the directory
718
- the terminal is pointed at.
777
+ the terminal is pointed at. A save sends the version the file had when it was opened, so
778
+ it is **refused (409) rather than silently overwriting** an agent that edited the same
779
+ file meanwhile; the editor then offers to reload or to overwrite deliberately.
780
+
781
+ You usually hear about it before that. An open file that changes on disk is picked up from
782
+ Claude's own write hook (immediately) and from a 30-second version check (which catches Codex,
783
+ git, builds and other editors too). A **clean** buffer just takes the new content — the pane
784
+ reads as a live view — while a **dirty** one raises the same banner rather than choosing for you.
785
+
786
+ **Leaving an open file saves it** — switching files, moving the enlargement to another
787
+ terminal, closing the pane, navigating away. No dialog interrupts you mid-flow, because
788
+ opening a file, and replacing one, keep a copy under `~/.mulmoterminal/backups/` — **three
789
+ generations per file**, outside the project so they never reach `git status` or the agent's
790
+ view of its own repo. A parting save that loses the version race banks your version there
791
+ instead of overwriting the other writer. Re-opening unchanged content doesn't rotate one in, and a backup that
792
+ can't be written never blocks the read or the save it was taken for.
719
793
 
720
794
  ---
721
795
 
@@ -849,6 +923,11 @@ Favorited collections get their own toolbar buttons.
849
923
  session.
850
924
  - **Notifications** (🔔) — a toolbar bell with an unread badge and a dropdown of active
851
925
  notifications; click a row to jump to its session.
926
+ - **Star MulmoTerminal** — a star button in the grid toolbar that stars the project on GitHub
927
+ through your own `gh` login, in one click. It is a one-time ask: once the repo is starred the
928
+ button is gone for good and stops calling the server at all. It shows **only when `gh` can
929
+ answer** — with no `gh`, no login, or no network, one click couldn't star anything, so nothing
930
+ is shown and nothing is recorded. Set `gh` up later and the button appears by itself.
852
931
  - **Voice input** — dictate a prompt via on-device Whisper (`POST /api/transcribe`, macOS
853
932
  only; the model downloads on first use). Settings picks **the language you dictate in**
854
933
  (per browser): your browser's, whisper's own per-clip detection, or a fixed one. Worth
@@ -862,6 +941,11 @@ Favorited collections get their own toolbar buttons.
862
941
  **Option** is treated as Meta so Claude's Alt-key bindings work. If your Claude Code is
863
942
  rebound so Enter and Shift+Enter behave backwards, flip them with
864
943
  [`terminalSubmit`](https://receptron.github.io/mulmoterminal/guide/en/config.html#terminal-submit).
944
+ - **No accidental page zoom** — `Ctrl`+wheel and a trackpad pinch would rescale the whole
945
+ page and drag the layout and the terminal's fit along with it, so both are ignored.
946
+ Keyboard zoom (`Cmd`/`Ctrl` `+` / `-`) still works when you mean it, and a phone's finger
947
+ pinch is untouched. To make terminal text bigger for real, use the font size in Settings
948
+ (or a directory's `fontSize`) — that re-fits the PTY instead of leaving it disagreeing.
865
949
 
866
950
  ---
867
951
 
@@ -1026,6 +1110,7 @@ same-origin-guarded.
1026
1110
  | `GET /api/worktrees?cwd=` · `GET /api/worktrees/diff?cwd=` | List managed worktrees / diff one vs its base. |
1027
1111
  | `POST /api/worktrees/create` · `/remove` · `/push` · `/pr` | Create on `agent/<slug>`, remove (managed root only), push, open a PR (`gh`, else compare URL). |
1028
1112
  | `GET /api/prs` · `GET /api/issues` | Open PRs / issues across the configured `prRepos` (via `gh`). |
1113
+ | `GET /api/github/star` · `POST /api/github/star` | Whether you have starred MulmoTerminal, and star it (via `gh`). `starred: null` means `gh` could not answer, and hides the button. |
1029
1114
 
1030
1115
  **Workspace views**
1031
1116
 
@@ -1033,7 +1118,7 @@ same-origin-guarded.
1033
1118
  | -------- | ------- |
1034
1119
  | `GET /api/wiki` (`?slug=`) · `/api/wiki/graph` · `/api/wiki/lint` | Read-only wiki index / page / graph / lint. |
1035
1120
  | `GET /api/collections/…` · `/api/feeds` · `GET\|PUT /api/shortcuts` | Collections browser, feeds, favorites (see `docs/collection-plugin-integration.md`). |
1036
- | `GET /api/files/browse/{list,text,md}` · `PUT /api/files/browse/write` | File tree / read / Markdown-render / write (contained within the project root). |
1121
+ | `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. |
1037
1122
  | `GET /api/files/raw?path=` | Raw asset bytes (workspace-rooted). |
1038
1123
 
1039
1124
  **GUI panel / plugins / MCP**
@@ -1050,8 +1135,8 @@ same-origin-guarded.
1050
1135
 
1051
1136
  | Endpoint | Purpose |
1052
1137
  | -------- | ------- |
1053
- | `GET\|POST /api/config` | User UI config (`cwdPresets`, `soundFile`, `prRepos`, `launchers`, `quickCommands`, `userMcpServers`, `providers`). |
1054
- | `GET /api/sound` · `/api/dir-sound?cwd=` · `/api/dir-config?cwd=` | Custom / per-directory attention sound + per-dir config. |
1138
+ | `GET\|POST /api/config` | User UI config (`cwdPresets`, `soundFile`, `soundKinds`, `sounds`, `prRepos`, `launchers`, `quickCommands`, `userMcpServers`, `providers`). |
1139
+ | `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. |
1055
1140
  | `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. |
1056
1141
  | `GET /api/notifications`(`/history`) · `POST /api/notifications/:id/clear` | Notification feed. |
1057
1142
  | `POST /api/transcribe`(`/model`…) | Voice-input transcription (Whisper, macOS). |
@@ -1252,6 +1337,30 @@ knows the live session's id — even before the session's `.jsonl` file exists.
1252
1337
 
1253
1338
  ---
1254
1339
 
1340
+ ## Closing summary
1341
+
1342
+ Every Claude session is spawned with `claude --append-system-prompt '<text>'`, asking the
1343
+ agent to end a reply with a short summary **when it hands control back** — the work is
1344
+ finished, or it is stopping to ask a question. Coming back to a grid cell after a while, the
1345
+ standing request and what came of it are otherwise only recoverable by scrolling the whole
1346
+ session.
1347
+
1348
+ The summary states three things: the request **for the conversation as a whole** (not the
1349
+ last message — several turns of refinement do not replace what was asked first), what was
1350
+ achieved, and what was not and why. It is written in the language of the conversation, and
1351
+ placed last with nothing after it.
1352
+
1353
+ It is deliberately **not** written on every turn: mid-work replies and short factual answers
1354
+ carry no standing request, and a summary that always appears stops being read. The wording
1355
+ lives in `server/agents/session-summary-prompt.ts`; there is no setting to turn it off.
1356
+
1357
+ Passed inline rather than as `--append-system-prompt-file` for the same reason `--settings`
1358
+ is: the sandbox spawn runs in a container that cannot read a host path.
1359
+
1360
+ Codex sessions are unaffected — the CLI has no equivalent flag.
1361
+
1362
+ ---
1363
+
1255
1364
  ## Session discovery & titles
1256
1365
 
1257
1366
  Claude stores each project's sessions as JSONL files under
@@ -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
+ }
@@ -0,0 +1,22 @@
1
+ // This project's own GitHub repository, and the wire shape of the star endpoints that act on it.
2
+ // Both sides decide from these — the server builds the `gh api` path and types its response,
3
+ // the client parses that response — so they live here rather than as two copies.
4
+ import { isRecord } from "./isRecord.js";
5
+
6
+ export const GITHUB_REPO = "receptron/mulmoterminal";
7
+
8
+ // GitHub's "star a repository" endpoint for this repo: GET reports the current state, PUT stars it.
9
+ export const STAR_API_PATH = `/user/starred/${GITHUB_REPO}`;
10
+
11
+ // GET/POST /api/github/star. `null` means the server could not tell — no `gh`, not logged in,
12
+ // offline. That is a DIFFERENT answer from "not starred": one click cannot star anything in that
13
+ // state, so the client hides the button rather than offering one that would do nothing.
14
+ export interface GithubStarState {
15
+ starred: boolean | null;
16
+ }
17
+
18
+ // Read a star state off an untrusted response body. Anything unparseable reads as "cannot tell",
19
+ // which is the same degradation as a failed request, so the caller needs no separate error path.
20
+ export function parseStarState(value: unknown): boolean | null {
21
+ return isRecord(value) && typeof value.starred === "boolean" ? value.starred : null;
22
+ }
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,71 @@
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
+ }
53
+
54
+ // Copy-on-select: the text a settled selection should put on the clipboard, or null to leave the
55
+ // clipboard alone. `lastCopied` is what this terminal wrote last, so an unchanged selection is not
56
+ // written twice.
57
+ //
58
+ // Unlike the rules above, this one ends in a clipboard WRITE — no keystroke happened, so nothing in
59
+ // the browser was ever going to copy anything by itself. That is what makes the two skips here
60
+ // matter more than they look:
61
+ //
62
+ // - Whitespace only. Dragging across empty terminal space selects spaces, and silently replacing
63
+ // the user's clipboard with a run of them is this feature's worst failure. Anyone who really
64
+ // wants indentation still has the `copy` keymap action.
65
+ // - Unchanged text. A second identical write buys nothing and costs a duplicate entry in the OS
66
+ // clipboard history (Win+V), which is the same reason the caller waits for the selection to
67
+ // settle instead of writing on every onSelectionChange.
68
+ export function selectionToCopy(enabled: boolean, selection: string, lastCopied: string | null): string | null {
69
+ if (!enabled || selection.trim() === "" || selection === lastCopied) return null;
70
+ return selection;
71
+ }