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.
- package/README.md +127 -18
- package/common/collectionPush.ts +26 -0
- package/common/dirChrome.ts +10 -0
- package/common/fileWriteChannel.ts +9 -0
- package/common/githubRepo.ts +22 -0
- package/common/keymap.ts +22 -1
- package/common/notifyKinds.ts +26 -0
- package/common/notifySounds.ts +44 -0
- package/common/orderPriority.ts +20 -0
- package/common/pushKinds.ts +6 -2
- package/common/terminalClipboard.ts +71 -0
- package/common/terminalFontFamily.ts +79 -0
- package/dist/assets/{abnfDiagram-VRR7QNED-RjiYivmv-1RrSDjxg.js → abnfDiagram-VRR7QNED-RjiYivmv-B8nu2pUR.js} +1 -1
- package/dist/assets/architecture-TIHT7OUA-0NsD2xW1-TY_v-qbq.js +1 -0
- package/dist/assets/{architectureDiagram-ZJ3FMSHR-3nWA91tG-sTMTrNOq.js → architectureDiagram-ZJ3FMSHR-3nWA91tG-ByxXyiOP.js} +1 -1
- package/dist/assets/{blockDiagram-677ZJIJ3-BPuAJQRW-CQmbTR0O.js → blockDiagram-677ZJIJ3-BPuAJQRW-Brt9e7H_.js} +1 -1
- package/dist/assets/{c4Diagram-LMCZKHZV-C0LAqQso-kjKqpuxO.js → c4Diagram-LMCZKHZV-C0LAqQso-D598g8JV.js} +1 -1
- package/dist/assets/channel-7wUqSdoX-DRUj8A2e.js +1 -0
- package/dist/assets/{chunk-32BRIVSS-BRrYpgtb-YnuI_kVF.js → chunk-32BRIVSS-BRrYpgtb-BEOPPcME.js} +1 -1
- package/dist/assets/{chunk-52WLFC77-BJ-ss3Xr-Dv-_qNms.js → chunk-52WLFC77-BJ-ss3Xr-BmZ-jgWV.js} +1 -1
- package/dist/assets/{chunk-C7G6YPKG-BZEucKEL-VvGb94ad.js → chunk-C7G6YPKG-BZEucKEL-DhvCnMVg.js} +1 -1
- package/dist/assets/{chunk-EX3LRPZG-DLS6FBN1-Cv9JdwrA.js → chunk-EX3LRPZG-DLS6FBN1-CxeaHZOV.js} +1 -1
- package/dist/assets/{chunk-FWX5IMBZ-DHLSFw1H-CFK4Xl3Y.js → chunk-FWX5IMBZ-DHLSFw1H-COykUZUW.js} +2 -2
- package/dist/assets/{chunk-HOUHSVGY-Bhlt8hXJ-l9dGXTcV.js → chunk-HOUHSVGY-Bhlt8hXJ-DFKJuoMh.js} +1 -1
- package/dist/assets/{chunk-ICXQ74PX-DwgHBX_g-oBhC9ghH.js → chunk-ICXQ74PX-DwgHBX_g-CFoH6Yjf.js} +1 -1
- package/dist/assets/{chunk-MOJQB5TN-CMZRaeqt-CKo27qok.js → chunk-MOJQB5TN-CMZRaeqt-Dy6S_sS_.js} +1 -1
- package/dist/assets/{chunk-OGEWGWER-8Qy4a8b5-Di7gzOY_.js → chunk-OGEWGWER-8Qy4a8b5-XcjK3kCc.js} +1 -1
- package/dist/assets/{chunk-PUDLZKDR-DcrWQRYh-DuMYBLYy.js → chunk-PUDLZKDR-DcrWQRYh-CFVQ5Aq8.js} +1 -1
- package/dist/assets/{chunk-Q4XR5HBZ-ZXVGkG8Z-BH8ZxP7u.js → chunk-Q4XR5HBZ-ZXVGkG8Z-D2iTH8ap.js} +1 -1
- package/dist/assets/{chunk-V7JOEXUC-DmGdheTX-e5Lem5jk.js → chunk-V7JOEXUC-DmGdheTX-Dfius2MW.js} +1 -1
- package/dist/assets/{chunk-VAUOI2AC-CS9QJ4yz-CfgcnwuB.js → chunk-VAUOI2AC-CS9QJ4yz-Dd_VpRsu.js} +1 -1
- package/dist/assets/{chunk-VR4S4FIN-C6a91eNY-BcBE3OXA.js → chunk-VR4S4FIN-C6a91eNY-U_jwmwMS.js} +1 -1
- package/dist/assets/{chunk-WYO6CB5R-BlzOfotS-BRwSb4rl.js → chunk-WYO6CB5R-BlzOfotS-04XZ1Jfd.js} +1 -1
- package/dist/assets/{chunk-ZGVPDNZ5-BYwxNFTK-yy_t_DUF.js → chunk-ZGVPDNZ5-BYwxNFTK-DBPsh-p3.js} +1 -1
- package/dist/assets/classDiagram-OUVF2IWQ-BYlRi6YQ-BVAZDjb0.js +1 -0
- package/dist/assets/classDiagram-v2-EOCWNBFH-BCHaYHxj-BVAZDjb0.js +1 -0
- package/dist/assets/cynefin-VYW2F7L2-CGYr2F6h-CFSruy5n.js +1 -0
- package/dist/assets/{cynefinDiagram-TSTJHNR4-DHA9iPo--CEHib9RV.js → cynefinDiagram-TSTJHNR4-DHA9iPo--D-iTSsn0.js} +1 -1
- package/dist/assets/{dagre-VKFMJZFB--oJKqXBZ-DufbQ1ld.js → dagre-VKFMJZFB--oJKqXBZ-G72EKlyP.js} +1 -1
- package/dist/assets/{diagram-FQU43EPY-CcJCB9bG-Bcp75QDM.js → diagram-FQU43EPY-CcJCB9bG-DgT-eqMs.js} +1 -1
- package/dist/assets/{diagram-G47NLZAW-C_o-WGG1-C_2FVIX_.js → diagram-G47NLZAW-C_o-WGG1-D_JxH6o2.js} +1 -1
- package/dist/assets/{diagram-NH7WQ7WH-CXJCYvY--B1kVCM4I.js → diagram-NH7WQ7WH-CXJCYvY--pVhp9uBi.js} +1 -1
- package/dist/assets/{diagram-OA4YK3LP-BMzeJ87A-Bd1BRg0M.js → diagram-OA4YK3LP-BMzeJ87A-CFoCoA1K.js} +1 -1
- package/dist/assets/{diagram-WEI45ONY-D_93NKqo-VJzlhYeh.js → diagram-WEI45ONY-D_93NKqo-COhtVv5k.js} +1 -1
- package/dist/assets/{ebnfDiagram-CCIWWBDH-CVai1Ii9-BXziXSdO.js → ebnfDiagram-CCIWWBDH-CVai1Ii9-cHbupDvE.js} +1 -1
- package/dist/assets/{erDiagram-Q63AITRT-CiABnA0s-DzjaMys9.js → erDiagram-Q63AITRT-CiABnA0s-Ch6F8aLL.js} +1 -1
- package/dist/assets/eventmodeling-45OFAUF4-CWmqhm3a-B09FMUUq.js +1 -0
- package/dist/assets/flowDiagram-23GEKE2U-DKSB5AiY-BswknzuF.js +1 -0
- package/dist/assets/{ganttDiagram-NO4QXBWP-DQZvdFo1-DqlM0R3b.js → ganttDiagram-NO4QXBWP-DQZvdFo1-DcmaS_P9.js} +1 -1
- package/dist/assets/gitGraph-TEB2WS4Q-BR9qPwdN-Bg_TZB3f.js +1 -0
- package/dist/assets/{gitGraphDiagram-IHSO6WYX-C2ovBouh-Dy5WrrGR.js → gitGraphDiagram-IHSO6WYX-C2ovBouh-CgaMpUqZ.js} +1 -1
- package/dist/assets/index-DWePsXYt.css +1 -0
- package/dist/assets/index-QEGG3YTz.js +618 -0
- package/dist/assets/info-DKCQHKI2-Dplx5kMp-BAI4sdgi.js +1 -0
- package/dist/assets/{infoDiagram-FWYZ7A6U-7UnoB5AP-ByceYPA5.js → infoDiagram-FWYZ7A6U-7UnoB5AP-f2daXl9J.js} +1 -1
- package/dist/assets/{ishikawaDiagram-FXEZZL3T-ByUDM_N2-BZEvgyxt.js → ishikawaDiagram-FXEZZL3T-ByUDM_N2-DYE7Ld9-.js} +1 -1
- package/dist/assets/{journeyDiagram-5HDEW3XC-c5xIah9o-D3IMe-Kx.js → journeyDiagram-5HDEW3XC-c5xIah9o-B0woNVtP.js} +1 -1
- package/dist/assets/{kanban-definition-HUTT4EX6-Cnt6loYD-cjfYxgHr.js → kanban-definition-HUTT4EX6-Cnt6loYD-Dm8evmYs.js} +1 -1
- package/dist/assets/{lib-BP5ftgj4.js → lib-DtgnGSMW.js} +1 -1
- package/dist/assets/{line-D7ziSjKi-C07Dlbdw.js → line-D7ziSjKi-CRwDdfSt.js} +1 -1
- package/dist/assets/{marp-ytjNgY2U.js → marp-Djuncd6G.js} +1 -1
- package/dist/assets/{mermaid-parser.core-DxEa8E3F-BAh0ySn4.js → mermaid-parser.core-DxEa8E3F-BILJo_fK.js} +2 -2
- package/dist/assets/{mermaid.core-V0OYwIz3-CIFgdt3N.js → mermaid.core-V0OYwIz3-CabnyPOO.js} +3 -3
- package/dist/assets/{mindmap-definition-LN4V7U3C-CexN3O6L-1SEo71tq.js → mindmap-definition-LN4V7U3C-CexN3O6L-DF1aNqGu.js} +1 -1
- package/dist/assets/packet-7NZHBO7P-CQI3flND-Bqib4t7m.js +1 -0
- package/dist/assets/{pegDiagram-2B236MQR-BiM4G0cW-DkewOOUw.js → pegDiagram-2B236MQR-BiM4G0cW-q-89nsVP.js} +1 -1
- package/dist/assets/pie-RZYD4A2V-CnmKE2Wa-C7f4Ij-s.js +1 -0
- package/dist/assets/{pieDiagram-ENE6RG2P-B_fBBS-2-DdJe9KqM.js → pieDiagram-ENE6RG2P-B_fBBS-2-B3pcC0Pg.js} +1 -1
- package/dist/assets/{quadrantDiagram-ABIIQ3AL-BigGVxCR-C8kNdasc.js → quadrantDiagram-ABIIQ3AL-BigGVxCR-BBKSbiVD.js} +1 -1
- package/dist/assets/radar-I7S5WNFK-us6x-Z9R-C0_E6OiV.js +1 -0
- package/dist/assets/railroad-3IZDKUUU-BKdz6pdN-C2_X_Txm.js +1 -0
- package/dist/assets/railroad-abnf-AHOZXSZD-m_CSSIfd-wMka3Tbm.js +1 -0
- package/dist/assets/railroad-ebnf-EBAXGLYW-Nu3nAG7C-B7uu2NPX.js +1 -0
- package/dist/assets/railroad-peg-LSFZ7HO6--Y3mD4R_-D4Sj2mdo.js +1 -0
- package/dist/assets/{railroadDiagram-RFXS5EU6--lNIlG64-DawLoSCW.js → railroadDiagram-RFXS5EU6--lNIlG64-Do0ZI2kT.js} +1 -1
- package/dist/assets/{requirementDiagram-TGXJPOKE-D-B0Y5Wu-BMFbGAmA.js → requirementDiagram-TGXJPOKE-D-B0Y5Wu-B4oci7Dv.js} +1 -1
- package/dist/assets/{sankeyDiagram-HTMAVEWB-DlutaXDv-Dwt5PZ2I.js → sankeyDiagram-HTMAVEWB-DlutaXDv-KRXYn9rF.js} +1 -1
- package/dist/assets/{sequenceDiagram-DBY2YBRQ-BOLgtggH-yb89czG2.js → sequenceDiagram-DBY2YBRQ-BOLgtggH-zw0J8MZu.js} +1 -1
- package/dist/assets/{stateDiagram-2N3HPSRC-jf_dXUEU-kqvWM0jR.js → stateDiagram-2N3HPSRC-jf_dXUEU-vFGH-mvy.js} +1 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-CuzgCL2v-PiMBzK3e.js +1 -0
- package/dist/assets/{swimlanes-5IMT3BWC-CYjtALQH-CiVWAsob.js → swimlanes-5IMT3BWC-CYjtALQH-DhegOTK_.js} +1 -1
- package/dist/assets/swimlanesDiagram-G3AALYLV-3bmSBtKs-ByceWEns.js +8 -0
- package/dist/assets/{timeline-definition-FHXFAJF6-DYJ4oUm8-DrqIflbo.js → timeline-definition-FHXFAJF6-DYJ4oUm8-a7d-hF5Y.js} +1 -1
- package/dist/assets/treeView-QDETBFTQ-D27HWG6Q-Bf6uz3xV.js +1 -0
- package/dist/assets/treemap-6X3UGDF4-CmGIU3H5-CLzvNsC2.js +1 -0
- package/dist/assets/{vennDiagram-L72KCM5P-CdKAHoek-COepU8y_.js → vennDiagram-L72KCM5P-CdKAHoek-La4xWHGC.js} +1 -1
- package/dist/assets/wardley-OPB4EBWU-CbHWsb2k-OXtZqto9.js +1 -0
- package/dist/assets/{wardleyDiagram-EHGQE667-BF6c4_CW-Z2AgrgfV.js → wardleyDiagram-EHGQE667-BF6c4_CW-M6-Z6fe5.js} +1 -1
- package/dist/assets/{xychartDiagram-FW5EYKEG-CGiKngj7-ChC7g8sJ.js → xychartDiagram-FW5EYKEG-CGiKngj7-BtTeKKIV.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -8
- package/server/agents/claude-args.ts +12 -0
- package/server/agents/session-summary-prompt.ts +49 -0
- package/server/backends/calendarPush.ts +81 -0
- package/server/backends/calendarPushResult.ts +44 -0
- package/server/backends/remoteHost/googleCalendar.spec.ts +7 -1
- package/server/config/app-config.ts +69 -0
- package/server/config/config-body.ts +12 -1
- package/server/config/config-routes.ts +35 -4
- package/server/config/config-schema.ts +66 -0
- package/server/config/dir-config.ts +75 -19
- package/server/config/sound-presets.ts +96 -0
- package/server/files/atomic-write.ts +1 -1
- package/server/files/backup-store.ts +101 -0
- package/server/files/files-browse.ts +85 -5
- package/server/files/tool-writes.ts +20 -0
- package/server/git/github-star.ts +63 -0
- package/server/infra/sandbox.ts +7 -0
- package/server/routes/app-routes.ts +11 -2
- package/server/routes/dir-routes.ts +16 -5
- package/server/routes/hook-routes.ts +19 -4
- package/server/routes/repo-routes.ts +25 -0
- package/server/session/activity-hook.ts +23 -4
- package/server/session/pty-spawn.ts +2 -2
- package/server/session/spawn-claude.ts +2 -1
- package/server/skills/mulmoterminal-config/SKILL.md +64 -3
- package/dist/assets/architecture-TIHT7OUA-0NsD2xW1-CtE1YID5.js +0 -1
- package/dist/assets/channel-7wUqSdoX-i8mXF_QX.js +0 -1
- package/dist/assets/classDiagram-OUVF2IWQ-BYlRi6YQ-BM_zLSMQ.js +0 -1
- package/dist/assets/classDiagram-v2-EOCWNBFH-BCHaYHxj-BM_zLSMQ.js +0 -1
- package/dist/assets/cynefin-VYW2F7L2-CGYr2F6h-BBb6pP0M.js +0 -1
- package/dist/assets/eventmodeling-45OFAUF4-CWmqhm3a-C1_24lZu.js +0 -1
- package/dist/assets/flowDiagram-23GEKE2U-DKSB5AiY-DQrHefx7.js +0 -1
- package/dist/assets/gitGraph-TEB2WS4Q-BR9qPwdN-DSTb91Xn.js +0 -1
- package/dist/assets/index-D4LB9YoW.js +0 -618
- package/dist/assets/index-u-r21U2t.css +0 -1
- package/dist/assets/info-DKCQHKI2-Dplx5kMp-Cdy5Xn2F.js +0 -1
- package/dist/assets/packet-7NZHBO7P-CQI3flND-BFv5gKuv.js +0 -1
- package/dist/assets/pie-RZYD4A2V-CnmKE2Wa-9hvyyDzu.js +0 -1
- package/dist/assets/radar-I7S5WNFK-us6x-Z9R-DBeFDuLF.js +0 -1
- package/dist/assets/railroad-3IZDKUUU-BKdz6pdN-FnTYQEAW.js +0 -1
- package/dist/assets/railroad-abnf-AHOZXSZD-m_CSSIfd-Bo4xQewA.js +0 -1
- package/dist/assets/railroad-ebnf-EBAXGLYW-Nu3nAG7C-YFFxMVbA.js +0 -1
- package/dist/assets/railroad-peg-LSFZ7HO6--Y3mD4R_-DMzqIAqS.js +0 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-CuzgCL2v-QnrqYDdZ.js +0 -1
- package/dist/assets/swimlanesDiagram-G3AALYLV-3bmSBtKs-CE7hrF_H.js +0 -8
- package/dist/assets/treeView-QDETBFTQ-D27HWG6Q-DaEUFpbs.js +0 -1
- package/dist/assets/treemap-6X3UGDF4-CmGIU3H5-Cb7ZUpob.js +0 -1
- 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
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
"
|
|
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
|
-
| `
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/common/dirChrome.ts
CHANGED
|
@@ -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 = [
|
|
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
|
+
}
|
package/common/pushKinds.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|