mulmoterminal 4.11.0 → 4.13.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 +5 -1
- package/bin/cli-args.d.ts +2 -0
- package/bin/cli-args.js +103 -6
- package/bin/mulmoterminal.js +69 -25
- package/common/keymap.ts +71 -9
- package/common/modelPresets.ts +31 -31
- package/dist/assets/{AllPackages-CF2GkZ5g-TMZZvtbW.js → AllPackages-CF2GkZ5g-CssQbjOa.js} +1 -1
- package/dist/assets/{BaseConfiguration-CAwZOAki-GjB8ynTB.js → BaseConfiguration-CAwZOAki-Dh0EGNE9.js} +1 -1
- package/dist/assets/{Element-BMI7Nj5w-DDy3RxhW.js → Element-BMI7Nj5w-CqxLhpQT.js} +1 -1
- package/dist/assets/{Entities-C8p_0jCn-TW67o_f5.js → Entities-C8p_0jCn-DEEX_Xry.js} +1 -1
- package/dist/assets/{FunctionList-YWk54TCy-BWTkoS8A.js → FunctionList-YWk54TCy-D2PM4n-L.js} +1 -1
- package/dist/assets/{InputJax-C5dj-ntL-D3nfgJug.js → InputJax-C5dj-ntL-Cnbx6KYn.js} +1 -1
- package/dist/assets/{MathItem-YJvQL-XJ-CRfwqo-l.js → MathItem-YJvQL-XJ-afY01gTq.js} +1 -1
- package/dist/assets/{MmlFactory-DuXYIgx8-CPv_nF61.js → MmlFactory-DuXYIgx8-DpHfMoIU.js} +1 -1
- package/dist/assets/{Options-oARQeeuT-B-G7KuuU.js → Options-oARQeeuT-CiIYMpYT.js} +1 -1
- package/dist/assets/{OutputJax-DfUZNNCu-YqxqNBIm.js → OutputJax-DfUZNNCu-B38K9BaX.js} +1 -1
- package/dist/assets/{PrioritizedList-6lun1Xqd-C9mZ74pe.js → PrioritizedList-6lun1Xqd-Dygd27F6.js} +1 -1
- package/dist/assets/{Styles-BPXeoqzi-BlJASvFz.js → Styles-BPXeoqzi-C58TnqKJ.js} +1 -1
- package/dist/assets/{TeXAtom-LHT3KFWH-dhvXc6fF.js → TeXAtom-LHT3KFWH-uPYpzamN.js} +1 -1
- package/dist/assets/{abnfDiagram-VCTEODGH-DoYNjdBB-CTSR-65e.js → abnfDiagram-VCTEODGH-DoYNjdBB-Dai05JsH.js} +1 -1
- package/dist/assets/{arc-CqByPKkF-BWYcebkO.js → arc-CqByPKkF-D5Jg9Zer.js} +1 -1
- package/dist/assets/architecture-7GRP2DOG-CRS69XmP-Bkz68tYo.js +1 -0
- package/dist/assets/{architectureDiagram-5GKGNRK7-BhcT6qS5-ot6sa_to.js → architectureDiagram-5GKGNRK7-BhcT6qS5-DipIYUA_.js} +1 -1
- package/dist/assets/{assistive-mml-DXJXho4o-67vts26y.js → assistive-mml-DXJXho4o-BRWc7vXC.js} +1 -1
- package/dist/assets/{blockDiagram-I7D4REHJ-1Cng0Q4K-DXMuvcCc.js → blockDiagram-I7D4REHJ-1Cng0Q4K-CmrGn63J.js} +1 -1
- package/dist/assets/{c4Diagram-7LVT6UL2-CAb9c8nc-odesI5Pl.js → c4Diagram-7LVT6UL2-CAb9c8nc-EY5oaGlv.js} +1 -1
- package/dist/assets/channel-BjESmYnj-DCJo3CKJ.js +1 -0
- package/dist/assets/{chunk-2E4U76K2-CMyNbRyJ-B-LU-Now.js → chunk-2E4U76K2-CMyNbRyJ-BYHoa5DT.js} +1 -1
- package/dist/assets/{chunk-4HAMMTFA-ChwfIAnF-BiK_fwBU.js → chunk-4HAMMTFA-ChwfIAnF-DQUJxetY.js} +1 -1
- package/dist/assets/{chunk-75Z2AOVW-CbPA0Zt_-D1SUnijv.js → chunk-75Z2AOVW-CbPA0Zt_-BKl9d-co.js} +1 -1
- package/dist/assets/{chunk-CLGD4ZFX-CVw8XHcp-B9QKRmDx.js → chunk-CLGD4ZFX-CVw8XHcp-MCMGRlFw.js} +1 -1
- package/dist/assets/{chunk-DU6HZSFF-DuOx2LFX-C43wsWw1.js → chunk-DU6HZSFF-DuOx2LFX-B86yuhFX.js} +1 -1
- package/dist/assets/{chunk-F27PBJKO-CgBMw9yN-DNwf2ctW.js → chunk-F27PBJKO-CgBMw9yN-Bq3zuyO1.js} +1 -1
- package/dist/assets/{chunk-GMAD6QVW-JTqlimUX-BUdn0gzb.js → chunk-GMAD6QVW-JTqlimUX-BADn_t1m.js} +1 -1
- package/dist/assets/{chunk-GVQU2GXP-D6ccB4DO-B92RdJRw.js → chunk-GVQU2GXP-D6ccB4DO-D67xQv2L.js} +1 -1
- package/dist/assets/{chunk-IMKFNOWR-ij1DSzMb-DOUGEsyB.js → chunk-IMKFNOWR-ij1DSzMb-Cdmwd2WK.js} +1 -1
- package/dist/assets/{chunk-L3NEJ4N5-CUoAQVG2-fG9UphPN.js → chunk-L3NEJ4N5-CUoAQVG2-Buv2AnXy.js} +1 -1
- package/dist/assets/{chunk-OSK3NFVY-_VNbXQSu-CThk-KLN.js → chunk-OSK3NFVY-_VNbXQSu-C5f15YpO.js} +1 -1
- package/dist/assets/{chunk-P2QGCYS3-B8lzOn3Y-jyt35vFj.js → chunk-P2QGCYS3-B8lzOn3Y-B3mLNaJD.js} +1 -1
- package/dist/assets/{chunk-POPQ4Y6H-Bh_uOTmp-Dky8MeiN.js → chunk-POPQ4Y6H-Bh_uOTmp-Dy-RhpSF.js} +1 -1
- package/dist/assets/{chunk-PWAF6VOD-DzjnfYSw-BlUVhmUo.js → chunk-PWAF6VOD-DzjnfYSw-Br7rBQUn.js} +1 -1
- package/dist/assets/{chunk-SHT3W25Y-B8dQLS5w-BcPtuz8O.js → chunk-SHT3W25Y-B8dQLS5w-Bzrea95d.js} +1 -1
- package/dist/assets/{chunk-SVP7TREG-B6A-T8Th-CwjRILYx.js → chunk-SVP7TREG-B6A-T8Th-CD7e9pDy.js} +1 -1
- package/dist/assets/{chunk-TICWLB2K-C1kwD29l--WxpGM-A.js → chunk-TICWLB2K-C1kwD29l-Cqy2hS-h.js} +1 -1
- package/dist/assets/{chunk-TLUHSLCS-DYzGlCmi-CzsyGew0.js → chunk-TLUHSLCS-DYzGlCmi-DQFOalYn.js} +2 -2
- package/dist/assets/{chunk-XXDRQBXY-vg0kfC73-BmBF_aLi.js → chunk-XXDRQBXY-vg0kfC73-Beoe3hey.js} +1 -1
- package/dist/assets/classDiagram-ZZMXUADV-C7gP-pUH-TqSfWtS1.js +1 -0
- package/dist/assets/classDiagram-v2-VYDZK3BY-CUwV5k1F-TqSfWtS1.js +1 -0
- package/dist/assets/{cose-bilkent-JH36ORCC-NuHCtE3P-DukuXr0B.js → cose-bilkent-JH36ORCC-NuHCtE3P-CAm6fB__.js} +1 -1
- package/dist/assets/cynefin-OW5HDTMX-C4iKivN4-9ujipUYq.js +1 -0
- package/dist/assets/{cynefinDiagram-5FMLGOSQ-CA7sro34-B1_Vwft3.js → cynefinDiagram-5FMLGOSQ-CA7sro34-Dw8nX0TQ.js} +1 -1
- package/dist/assets/{dagre-GXQ25YYZ-DOztjUR_-B2ZYuKJq.js → dagre-GXQ25YYZ-DOztjUR_-CLilpzZ6.js} +1 -1
- package/dist/assets/{diagram-S7CK7UJ4-D_nsLfRT-C1iWmOQl.js → diagram-S7CK7UJ4-D_nsLfRT-AWaKWxdg.js} +1 -1
- package/dist/assets/{diagram-UQ7AKVKN-DNN-YX9a-D9BFLsdH.js → diagram-UQ7AKVKN-DNN-YX9a-Cxg2PCC4.js} +1 -1
- package/dist/assets/{diagram-VSXAHHWV--HENzo-l-Bilfd4yf.js → diagram-VSXAHHWV--HENzo-l-Di3J8S1Y.js} +1 -1
- package/dist/assets/{diagram-VX7I27RA-m2m9dROj-Q5Y5GIFz.js → diagram-VX7I27RA-m2m9dROj-DmZLkzRx.js} +1 -1
- package/dist/assets/{diagram-Z3DM3KII-D4qpv4-B-77eizdrr.js → diagram-Z3DM3KII-D4qpv4-B-BhRu_oHO.js} +1 -1
- package/dist/assets/{dist-DLWwOuwV.js → dist-BA8WR926.js} +1 -1
- package/dist/assets/{dist-DGvve3y_.js → dist-CSTVKEO6.js} +1 -1
- package/dist/assets/{dist-Be3Ws2yA.js → dist-CVpznzjS.js} +1 -1
- package/dist/assets/{dist-b5QRFCaM.js → dist-CgQ_oQmc.js} +1 -1
- package/dist/assets/{dist-DeiyD9Ki-tTrbAW3O.js → dist-DeiyD9Ki-R86_Zh4z.js} +1 -1
- package/dist/assets/{ebnfDiagram-PWID7BFC-CAbv3sZg-DuR3ooJN.js → ebnfDiagram-PWID7BFC-CAbv3sZg-C6XDy8ND.js} +1 -1
- package/dist/assets/{erDiagram-RLTQ6QDP-a10Ryh0--D8L6d95w.js → erDiagram-RLTQ6QDP-a10Ryh0--CbOy4ZUP.js} +1 -1
- package/dist/assets/eventmodeling-NTZA5JFV-DhqtNS8R-Dt7SIrGk.js +1 -0
- package/dist/assets/flowDiagram-HODETNUW-CAW_8mwY-CnOc_pLO.js +1 -0
- package/dist/assets/{ganttDiagram-EL5Y4UJY-Cb7leeOC-BYaZlBFC.js → ganttDiagram-EL5Y4UJY-Cb7leeOC-weDAGCBD.js} +1 -1
- package/dist/assets/gitGraph-4MIJSDKK-S_Zs6Nxs-BfjtZvJt.js +1 -0
- package/dist/assets/{gitGraphDiagram-WWUBYQGX-BCSbUpXG-DeD4rKlZ.js → gitGraphDiagram-WWUBYQGX-BCSbUpXG-BomL5zI6.js} +1 -1
- package/dist/assets/{html-DdQiKDqM-BgbS8tLG.js → html-DdQiKDqM-B6wswbaV.js} +1 -1
- package/dist/assets/index-B-BwWalM.css +1 -0
- package/dist/assets/{index-DTF7hxpt.js → index-Cyyg6dqf.js} +245 -245
- package/dist/assets/info-A6RAGUB7-4sGJh1is-B8w9oPi5.js +1 -0
- package/dist/assets/{infoDiagram-27XIBGKW-BJRaZItN-vTwEGfqm.js → infoDiagram-27XIBGKW-BJRaZItN-DDAXs1r3.js} +1 -1
- package/dist/assets/{ishikawaDiagram-5VMMS53U-DHlFe__S-BDYdHY2W.js → ishikawaDiagram-5VMMS53U-DHlFe__S-2FoUZyuK.js} +1 -1
- package/dist/assets/{journeyDiagram-3NMN7TZE-BOIo3JM--BKbx2G9c.js → journeyDiagram-3NMN7TZE-BOIo3JM--CKVCefHi.js} +1 -1
- package/dist/assets/{kanban-definition-UXKFOSKX-yrQdG683-NGZtsrcK.js → kanban-definition-UXKFOSKX-yrQdG683-DLceahDo.js} +1 -1
- package/dist/assets/{lengths-CvmPfBid-BXoQHcuz.js → lengths-CvmPfBid-Bu4CQkge.js} +1 -1
- package/dist/assets/{line-DUb1TjJS-DnfCySOi.js → line-DUb1TjJS-Cy4--5UV.js} +1 -1
- package/dist/assets/{linear-xsuGWu5q-D8AeuxUI.js → linear-xsuGWu5q-BtDBK8pF.js} +1 -1
- package/dist/assets/{liteAdaptor-C0FQiOEx-CVfFqXXv.js → liteAdaptor-C0FQiOEx-Bo-QRNEC.js} +1 -1
- package/dist/assets/{marp-CEgDv2lS.js → marp-BNsGmsYi.js} +1 -1
- package/dist/assets/{mathjax-DVEo4UYj-20UgsMMl.js → mathjax-DVEo4UYj-BDUX9u1X.js} +1 -1
- package/dist/assets/{mermaid-parser.core-fkjeiYO7-QyTXgNIG.js → mermaid-parser.core-fkjeiYO7-Bu045BJA.js} +2 -2
- package/dist/assets/{mermaid.core-CZDx2Cyn-D-r9MD5t.js → mermaid.core-CZDx2Cyn-W7sMU12C.js} +3 -3
- package/dist/assets/{mindmap-definition-YA3MSWOX-BpKAVyqs-ChxVu37V.js → mindmap-definition-YA3MSWOX-BpKAVyqs-B8fXczO5.js} +1 -1
- package/dist/assets/{mo-CrlNnGXX-BXF4Cr9t.js → mo-CrlNnGXX-DvbKdwI8.js} +1 -1
- package/dist/assets/packet-AYTQ26CC-MQQyx8fI-DEraoKKf.js +1 -0
- package/dist/assets/{pegDiagram-XKGWAZYB-C7eG_Ohl-W4Os6F4I.js → pegDiagram-XKGWAZYB-C7eG_Ohl-CL0sh1mk.js} +1 -1
- package/dist/assets/pie-WAS4IAKB-CyaL5i_d-hVe4izaF.js +1 -0
- package/dist/assets/{pieDiagram-E7YTZNPT-5f9wSfDR-BotXdxTh.js → pieDiagram-E7YTZNPT-5f9wSfDR-CLmTFnZr.js} +1 -1
- package/dist/assets/{quadrantDiagram-AXDQQJYC-mWeaOj5S-cfsl-P0E.js → quadrantDiagram-AXDQQJYC-mWeaOj5S-BUPv1MtK.js} +1 -1
- package/dist/assets/radar-RG4KPBEZ-DR-SsHDJ-CUIKMPrd.js +1 -0
- package/dist/assets/railroad-74A4TZTK-C2gqsOGX-Sdqv0l9F.js +1 -0
- package/dist/assets/railroad-abnf-HS5TGJTU-Dpzw6P5u-BWEL2Ih5.js +1 -0
- package/dist/assets/railroad-ebnf-LZEXJU2U-J_BuIrPo-CScwuEKP.js +1 -0
- package/dist/assets/railroad-peg-WCYAUIDC-DzMStOR--CUu-tzEV.js +1 -0
- package/dist/assets/{railroadDiagram-O6MQD6OU-DJskhE7u-DjP6Khna.js → railroadDiagram-O6MQD6OU-DJskhE7u-B2rIG8vE.js} +1 -1
- package/dist/assets/{requirementDiagram-BXWQKSXE-B5E0bU4h-DLRfaRNQ.js → requirementDiagram-BXWQKSXE-B5E0bU4h-BNG0IbS0.js} +1 -1
- package/dist/assets/{sankeyDiagram-P5KCCOFB-DS3AOTai-DcRRSBZW.js → sankeyDiagram-P5KCCOFB-DS3AOTai-BmF8ArEH.js} +1 -1
- package/dist/assets/{sequenceDiagram-WJ2MYXX4-Bn2L9osv-DiKTI-JP.js → sequenceDiagram-WJ2MYXX4-Bn2L9osv-GsnV2qn4.js} +1 -1
- package/dist/assets/{src-CLC0ddSd-BAgR7bTG.js → src-CLC0ddSd-Dquc25MQ.js} +1 -1
- package/dist/assets/{stateDiagram-D77RDMKH-DbtoW7HL-CQtZxWRh.js → stateDiagram-D77RDMKH-DbtoW7HL-fgglcfel.js} +1 -1
- package/dist/assets/stateDiagram-v2-MP3YSRHH-CX6RalKY-D118UaDi.js +1 -0
- package/dist/assets/{svg-CaHU_sxA-C22w-_1l.js → svg-CaHU_sxA-BFRh8mf6.js} +1 -1
- package/dist/assets/{swimlanes-42K2YHIH-90h8w_3B-CORPhAYt.js → swimlanes-42K2YHIH-90h8w_3B-DFNJ2iO4.js} +1 -1
- package/dist/assets/swimlanesDiagram-VR7AAH4N-49XH_TMr-Bkt0WWk-.js +8 -0
- package/dist/assets/{tex-B1cONMvb-CiAo3GBL.js → tex-B1cONMvb-DVhGZSdj.js} +1 -1
- package/dist/assets/{timeline-definition-24CTP7MA-Bx3KcICO-B1qH6cuQ.js → timeline-definition-24CTP7MA-Bx3KcICO-CZbcUu5B.js} +1 -1
- package/dist/assets/treeView-Q6P3EWNA-gFs854Sn-D7488VRG.js +1 -0
- package/dist/assets/treemap-WGGIJYW6-DGh_szUA-B-xoM8YR.js +1 -0
- package/dist/assets/{vennDiagram-4TSXK5OY-DSexNe_H-SEUt9G7Z.js → vennDiagram-4TSXK5OY-DSexNe_H-CTFwoiwN.js} +1 -1
- package/dist/assets/wardley-WFR3VGLG-BVLYL3gB-CjcN7LrQ.js +1 -0
- package/dist/assets/{wardleyDiagram-VM6X3IG4-DesYYyZW-B2VPVh08.js → wardleyDiagram-VM6X3IG4-DesYYyZW-CW8HWmUS.js} +1 -1
- package/dist/assets/{xychartDiagram-S5SC5T6Z-dIt6pHWV-DSXBgmnQ.js → xychartDiagram-S5SC5T6Z-dIt6pHWV-C6EUNlwK.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/backends/mulmoscript.ts +51 -9
- package/server/index.ts +17 -33
- package/server/infra/announce-listening.ts +140 -0
- package/server/infra/loopback-listener.ts +226 -25
- package/server/skills/mulmoterminal-config/SKILL.md +10 -4
- package/server/skills/mulmoterminal-dirs/SKILL.md +1 -1
- package/server/skills/mulmoterminal-header/SKILL.md +33 -0
- package/server/skills/mulmoterminal-keys/SKILL.md +177 -8
- package/server/skills/mulmoterminal-notify/SKILL.md +5 -1
- package/server/skills/mulmoterminal-shared-app/SKILL.md +15 -4
- package/server/skills/mulmoterminal-shared-app/templates/design.md +5 -4
- package/server/skills/mulmoterminal-shared-app/templates/live-poll.md +1 -1
- package/server/skills/mulmoterminal-shared-app/templates/magazine.md +1335 -0
- package/dist/assets/architecture-7GRP2DOG-CRS69XmP-DCR_CQex.js +0 -1
- package/dist/assets/channel-BjESmYnj-BFLfK2V8.js +0 -1
- package/dist/assets/classDiagram-ZZMXUADV-C7gP-pUH-CH08mQjV.js +0 -1
- package/dist/assets/classDiagram-v2-VYDZK3BY-CUwV5k1F-CH08mQjV.js +0 -1
- package/dist/assets/cynefin-OW5HDTMX-C4iKivN4-BZcOh7ip.js +0 -1
- package/dist/assets/eventmodeling-NTZA5JFV-DhqtNS8R-BYT21Mst.js +0 -1
- package/dist/assets/flowDiagram-HODETNUW-CAW_8mwY-xa7OIyau.js +0 -1
- package/dist/assets/gitGraph-4MIJSDKK-S_Zs6Nxs-DZTFZUNY.js +0 -1
- package/dist/assets/index-O8auC4ls.css +0 -1
- package/dist/assets/info-A6RAGUB7-4sGJh1is-Dn_wrRwd.js +0 -1
- package/dist/assets/packet-AYTQ26CC-MQQyx8fI-DcIAZ4rr.js +0 -1
- package/dist/assets/pie-WAS4IAKB-CyaL5i_d-C7pXqqeF.js +0 -1
- package/dist/assets/radar-RG4KPBEZ-DR-SsHDJ-Bo1enSO-.js +0 -1
- package/dist/assets/railroad-74A4TZTK-C2gqsOGX-C9uY65xk.js +0 -1
- package/dist/assets/railroad-abnf-HS5TGJTU-Dpzw6P5u-vTPRJQDL.js +0 -1
- package/dist/assets/railroad-ebnf-LZEXJU2U-J_BuIrPo-CEO_EQLT.js +0 -1
- package/dist/assets/railroad-peg-WCYAUIDC-DzMStOR--DC7VemfO.js +0 -1
- package/dist/assets/stateDiagram-v2-MP3YSRHH-CX6RalKY-C8DhecgJ.js +0 -1
- package/dist/assets/swimlanesDiagram-VR7AAH4N-49XH_TMr-C2_Ucv4j.js +0 -8
- package/dist/assets/treeView-Q6P3EWNA-gFs854Sn-DmraGKxe.js +0 -1
- package/dist/assets/treemap-WGGIJYW6-DGh_szUA-BgIqgXYw.js +0 -1
- package/dist/assets/wardley-WFR3VGLG-BVLYL3gB-C8z5LWTl.js +0 -1
package/README.md
CHANGED
|
@@ -1400,7 +1400,11 @@ Favorited collections get their own toolbar buttons.
|
|
|
1400
1400
|
grid — so you can flip between "see everything" and "focus on one" in a click. While
|
|
1401
1401
|
zoomed, keys you bind walk the enlargement along the on-screen order without reaching for
|
|
1402
1402
|
the mouse — **opt-in, nothing is bound by default**, since any bound key is taken from the
|
|
1403
|
-
terminal underneath.
|
|
1403
|
+
terminal underneath. Press **Set up shortcuts…** in Settings → **Keyboard shortcuts** and an
|
|
1404
|
+
agent writes the `keymap` for you, checking each binding against your existing ones and the traps
|
|
1405
|
+
a browser or a Mac adds; that section also lists every action and the `send` row whether bound or not, including the
|
|
1406
|
+
[keys sent straight to the terminal](https://receptron.github.io/mulmoterminal/guide/en/config.html#keymap-send).
|
|
1407
|
+
To write it by hand, add a `keymap` to `~/.mulmoterminal/config.json` — see the
|
|
1404
1408
|
[guide](https://receptron.github.io/mulmoterminal/guide/en/config.html#keymap)
|
|
1405
1409
|
for the syntax, the action list, and combinations a browser can never bind.
|
|
1406
1410
|
|
package/bin/cli-args.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export declare function probeFailureIsPortInUse(err: { code?: string } | null |
|
|
|
6
6
|
export declare function companionHostsFor(boundAddress: string): string[];
|
|
7
7
|
export declare function launcherReachHost(bindHost: string): string | null;
|
|
8
8
|
export declare function launcherUrl(reachHost: string, port: number): string;
|
|
9
|
+
export declare function browserUrl(port: number): string;
|
|
10
|
+
export declare function launchTarget(reachHost: string, port: number, localhostIsUnambiguous: boolean): { url: string; note: string | null };
|
|
9
11
|
export declare function chooseCwd(args: string[], env: Record<string, string | undefined>): CwdChoice;
|
|
10
12
|
export declare function portInUseMessage(port: number, explicit: boolean): string;
|
|
11
13
|
export declare function portInUseAction(explicit: boolean, isTTY: boolean | undefined): "ask" | "stop";
|
package/bin/cli-args.js
CHANGED
|
@@ -156,13 +156,13 @@ export function launcherReachHost(bindHost) {
|
|
|
156
156
|
return isIP(bindHost) ? bindHost : null;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
/** What to
|
|
159
|
+
/** What to name a concrete address with — never `localhost`, because this one exists to say
|
|
160
|
+
* WHICH ADDRESS WAS CHECKED. `launcherReachHost` picks `::1` precisely so an IPv4 listener
|
|
161
|
+
* cannot answer for us, and writing `localhost` would hand that choice straight back: measured,
|
|
162
|
+
* `localhost` resolves to BOTH `::1` and `127.0.0.1`.
|
|
160
163
|
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* answer for us, and printing `localhost` hands the choice straight back — measured, `localhost`
|
|
164
|
-
* resolves to BOTH `::1` and `127.0.0.1`, so the browser may open the very process the poll was
|
|
165
|
-
* written to avoid. A URL the user clicks has to name the address that was checked.
|
|
164
|
+
* This is no longer what the BROWSER is sent to — see `browserUrl`, and #1889 for why those are
|
|
165
|
+
* two different questions.
|
|
166
166
|
*
|
|
167
167
|
* Built through `URL` so the platform does the escaping: an IPv6 literal has to be bracketed or
|
|
168
168
|
* `http://::1:34567` is not a URL at all. The brackets go on BEFORE the assignment because the
|
|
@@ -174,6 +174,103 @@ export function launcherUrl(reachHost, port) {
|
|
|
174
174
|
return url.origin;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The URL the BROWSER is opened at — always `localhost`, and that is a COMPATIBILITY constraint,
|
|
179
|
+
* not a preference about which spelling reads better.
|
|
180
|
+
*
|
|
181
|
+
* The grid layout (`grid_v2`), the theme, the font size and eleven other things live in
|
|
182
|
+
* `localStorage`, which is partitioned by ORIGIN. So this string is not merely an address the
|
|
183
|
+
* user is sent to; it is the key their state is filed under. Changing it silently empties the
|
|
184
|
+
* app: 4.11.0 moved it to `http://127.0.0.1:<port>` as part of #1876's review and every upgrading
|
|
185
|
+
* user's grid came up blank, with their sessions alive in tmux and nothing on screen to say the
|
|
186
|
+
* layout was one hostname away (#1889).
|
|
187
|
+
*
|
|
188
|
+
* And there is no way back for state left on the old origin. Measured on Chrome 152: a hidden
|
|
189
|
+
* iframe on the old origin reads an EMPTY store (third-party storage partitioning), and
|
|
190
|
+
* `document.requestStorageAccess()` resolving does not change that — it governs cookies. Only a
|
|
191
|
+
* TOP-LEVEL visit to the old origin can see it. A URL that has to be stable is therefore cheaper
|
|
192
|
+
* to keep stable than to migrate.
|
|
193
|
+
*
|
|
194
|
+
* SECOND, INDEPENDENT reason, and the one this comment was missing: Firebase gates
|
|
195
|
+
* `signInWithPopup` on the CALLING PAGE'S ORIGIN, against the authorized-domain list of the
|
|
196
|
+
* project — and RemoteHost's Google sign-in is exactly that call
|
|
197
|
+
* (`src/components/RemoteHostControl.vue`, against `mulmoserver` in `common/firebaseConfig.ts`).
|
|
198
|
+
* Reported on #1900: that project authorizes `localhost` and NOT `127.0.0.1`, checked in the
|
|
199
|
+
* console on 2026-08-28, which is why phone pairing could not sign in on 4.11.0 and can on
|
|
200
|
+
* 4.12.0. Not verified here — this repo cannot read that project's settings.
|
|
201
|
+
*
|
|
202
|
+
* It matters because the two reasons fail INDEPENDENTLY. Someone who solves the storage half —
|
|
203
|
+
* moves the grid layout server-side, say — would read the paragraph above as the whole
|
|
204
|
+
* justification and be free to change this string, and the first thing to break would be a
|
|
205
|
+
* feature nothing in `bin/` mentions. Changing the URL means adding the new loopback address to
|
|
206
|
+
* that authorized-domain list first.
|
|
207
|
+
*
|
|
208
|
+
* Reachability is not the reason it is safe; #1834 is. `server/infra/loopback-listener.ts` makes
|
|
209
|
+
* this server serve `127.0.0.1` in EVERY configuration — as the primary bind, or as a second
|
|
210
|
+
* listener when the operator widens it — because eight local callers write that address as a
|
|
211
|
+
* literal. So `localhost` arrives here whatever `MULMOTERMINAL_HOST` says, falling back to v4
|
|
212
|
+
* when v6 refuses.
|
|
213
|
+
*
|
|
214
|
+
* What this deliberately does NOT do is decide anything about the port PROBE or the readiness
|
|
215
|
+
* POLL. Those still follow the address the child reports binding, which is what #1876 actually
|
|
216
|
+
* fixed — the second-instance guard, and a ready banner that was reporting a stranger's 200. The
|
|
217
|
+
* residual risk restored here is only the one 4.10.1 and every release before it carried: another
|
|
218
|
+
* APP holding this port on `::1` alone could answer a browser resolving `localhost` that way. A
|
|
219
|
+
* second MulmoTerminal cannot, because `companionHostsFor` requires `127.0.0.1` free for every
|
|
220
|
+
* bind before the child is ever spawned.
|
|
221
|
+
*/
|
|
222
|
+
export function browserUrl(port) {
|
|
223
|
+
return new URL(`http://localhost:${port}`).origin;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Where to send the browser, and what to say about it — as one decision, because the two answers
|
|
228
|
+
* have to agree.
|
|
229
|
+
*
|
|
230
|
+
* `localhostIsUnambiguous` is a MEASUREMENT, not a guess: the launcher tried to bind `[::1]:port`
|
|
231
|
+
* before spawning, so `false` means something else already answers there. That is the hole Codex
|
|
232
|
+
* flagged on this change and it is real for the DEFAULT bind, not only a widened one —
|
|
233
|
+
* `companionHostsFor` requires `127.0.0.1` free for every launch but never asks about `::1`, and
|
|
234
|
+
* `localhost` resolves to BOTH (measured). Sending the browser to `localhost` while a stranger
|
|
235
|
+
* holds the v6 loopback is exactly the case #1876's review was worried about.
|
|
236
|
+
*
|
|
237
|
+
* So the rule is: use the name the user's state is filed under WHEN NOTHING ELSE CAN ANSWER TO
|
|
238
|
+
* IT, and otherwise say plainly that we stepped aside — including where their layout went, since
|
|
239
|
+
* a silent switch here is #1889 all over again for that one user.
|
|
240
|
+
*
|
|
241
|
+
* Stepping aside costs more than the layout, and the note says so: on `http://127.0.0.1:<port>`
|
|
242
|
+
* RemoteHost's Google sign-in is refused as well, because Firebase checks the page's origin
|
|
243
|
+
* against its authorized domains and that project lists `localhost` only (#1900). This branch is
|
|
244
|
+
* a real degradation of the app rather than a cosmetic change of address, and the second failure
|
|
245
|
+
* would otherwise be found only by trying to pair a phone — which is the shape of #1889 again,
|
|
246
|
+
* one feature further along.
|
|
247
|
+
*
|
|
248
|
+
* The probe cannot close the window, only narrow it: `::1` is free when asked, and unless the
|
|
249
|
+
* server binds it (a `::1` or `::` bind), a stranger could still take it afterwards. That is the
|
|
250
|
+
* same probe/bind race `isPortFree` already documents and accepts.
|
|
251
|
+
*
|
|
252
|
+
* The loopback spellings below are compared against the address the kernel REPORTED, whose output
|
|
253
|
+
* vocabulary is finite — not against anything a user typed.
|
|
254
|
+
*/
|
|
255
|
+
export function launchTarget(reachHost, port, localhostIsUnambiguous) {
|
|
256
|
+
if (!localhostIsUnambiguous) {
|
|
257
|
+
const checked = launcherUrl(reachHost, port);
|
|
258
|
+
return {
|
|
259
|
+
url: checked,
|
|
260
|
+
note: `Something else is already listening on [::1]:${port}, so the browser is being sent to ${checked} — the address that was checked. If your layout and settings look empty, they are filed under http://localhost:${port}, and the phone's Google sign-in works only there.`,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
const bound = reachHost === V4_LOOPBACK_CLIENTS_DIAL || reachHost === "::1";
|
|
264
|
+
return {
|
|
265
|
+
url: browserUrl(port),
|
|
266
|
+
// `browserUrl` sends everyone to `localhost`, which is right for the machine the launcher runs
|
|
267
|
+
// on and useless to the operator who set `MULMOTERMINAL_HOST` so ANOTHER machine could open
|
|
268
|
+
// the app. 4.11.0 told them their address by putting it in the banner; saying it here means
|
|
269
|
+
// restoring the origin costs them nothing.
|
|
270
|
+
note: bound ? null : `Bound to ${reachHost} — reachable from another machine at ${launcherUrl(reachHost, port)}`,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
177
274
|
/**
|
|
178
275
|
* Which directory to run in, before it is made absolute.
|
|
179
276
|
* Precedence: `--cwd` (relative allowed) > CLAUDE_CWD > the directory the launcher was run
|
package/bin/mulmoterminal.js
CHANGED
|
@@ -19,9 +19,9 @@ import { waitUntilReady } from "./wait-ready.js";
|
|
|
19
19
|
import {
|
|
20
20
|
bindHostFor,
|
|
21
21
|
chooseCwd,
|
|
22
|
+
launchTarget,
|
|
22
23
|
companionHostsFor,
|
|
23
24
|
launcherReachHost,
|
|
24
|
-
launcherUrl,
|
|
25
25
|
probeFailureIsPortInUse,
|
|
26
26
|
parsePortArg,
|
|
27
27
|
portInUseAction,
|
|
@@ -295,6 +295,24 @@ async function isPortFree(port) {
|
|
|
295
295
|
return primary;
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
+
// Can `localhost` reach anything OTHER than the server this launcher is about to start? (#1889)
|
|
299
|
+
//
|
|
300
|
+
// The browser is sent to `localhost` so a user's saved layout stays where it has always been, and
|
|
301
|
+
// `localhost` resolves to BOTH `::1` and `127.0.0.1` (measured). `isPortFree` already proves
|
|
302
|
+
// `127.0.0.1` is free for every launch — `companionHostsFor` requires it — but nothing asks about
|
|
303
|
+
// `::1`, so a stranger holding the v6 loopback could answer a browser that resolves that way.
|
|
304
|
+
//
|
|
305
|
+
// Asked by BINDING rather than by classifying the host string, which is this file's rule
|
|
306
|
+
// throughout: free means nobody is there, and that is true whether the server is about to take
|
|
307
|
+
// `::1` itself (a `::1` or `::` bind) or leave it empty. Both are answers the browser can only
|
|
308
|
+
// reach us with. A machine with no IPv6 fails EADDRNOTAVAIL, which probeFailureIsPortInUse
|
|
309
|
+
// correctly reports as "not in use" — and there `localhost` is v4-only, so it is unambiguous.
|
|
310
|
+
//
|
|
311
|
+
// Must run BEFORE the server is spawned, or the server's own `::1` listener answers the probe.
|
|
312
|
+
async function localhostReachesOnlyUs(port) {
|
|
313
|
+
return (await canBind(port, "::1")).free;
|
|
314
|
+
}
|
|
315
|
+
|
|
298
316
|
function printReadyBanner(url, stopCommand) {
|
|
299
317
|
const bar = "\x1b[32m" + "─".repeat(48) + "\x1b[0m";
|
|
300
318
|
console.log(`\n${bar}`);
|
|
@@ -367,7 +385,7 @@ async function confirmNoRunningInstance() {
|
|
|
367
385
|
log(SECOND_INSTANCE_NOTE);
|
|
368
386
|
}
|
|
369
387
|
|
|
370
|
-
async function
|
|
388
|
+
async function pickPort(requested, explicit) {
|
|
371
389
|
const asked = await isPortFree(requested);
|
|
372
390
|
if (asked.free) return { port: requested, address: asked.address };
|
|
373
391
|
// No SILENT fallback: starting a second server on another port without saying so is how
|
|
@@ -386,12 +404,39 @@ async function choosePort(requested, explicit) {
|
|
|
386
404
|
return fallback;
|
|
387
405
|
}
|
|
388
406
|
|
|
407
|
+
// The port, the address a probe of it landed on, and whether `localhost` can reach anything but us
|
|
408
|
+
// on it — settled together because all three have to be known BEFORE the spawn, which is the only
|
|
409
|
+
// moment `::1` can be asked about honestly (see localhostReachesOnlyUs).
|
|
410
|
+
async function choosePort(requested, explicit) {
|
|
411
|
+
const chosen = await pickPort(requested, explicit);
|
|
412
|
+
return { ...chosen, localhostIsUnambiguous: await localhostReachesOnlyUs(chosen.port) };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// What the launcher does the moment the server answers: say where it is, and open it there.
|
|
416
|
+
//
|
|
417
|
+
// `url` is where the BROWSER goes and `note` is what launchTarget wants said about that choice —
|
|
418
|
+
// two different facts since #1889, and the note is null whenever the URL already covers it.
|
|
419
|
+
function announceReady(url, note, noOpen) {
|
|
420
|
+
printReadyBanner(url, STOP_COMMAND);
|
|
421
|
+
// Either the address a widened bind serves other machines on, or why the browser was NOT sent
|
|
422
|
+
// to `localhost`. Null whenever the URL above already said everything.
|
|
423
|
+
if (note) log(note);
|
|
424
|
+
if (noOpen) return;
|
|
425
|
+
try {
|
|
426
|
+
// The command is a hardcoded literal; url is built by browserUrl from a numeric port, so it
|
|
427
|
+
// is http://localhost:<n>.
|
|
428
|
+
execSync(`${pickOpenCommand()} ${url}`, { stdio: "pipe" });
|
|
429
|
+
} catch {
|
|
430
|
+
log(`Open your browser: ${url}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
389
434
|
// Spawn the server on `port` and report the child via `onChild` (so signal
|
|
390
435
|
// handlers target the live process). Resolves only when the server exits because
|
|
391
436
|
// the port was taken at bind time before it became ready — the caller then
|
|
392
437
|
// reports that and stops. In every other case (clean shutdown, fatal error,
|
|
393
438
|
// or the server simply running) the process exits with the server's code.
|
|
394
|
-
function runServer(port, probedAddress, noOpen, cwd, onChild) {
|
|
439
|
+
function runServer(port, probedAddress, localhostIsUnambiguous, noOpen, cwd, onChild) {
|
|
395
440
|
return new Promise((resolveExit) => {
|
|
396
441
|
log(`Starting MulmoTerminal on port ${port}...`);
|
|
397
442
|
// stderr is piped (and passed through) so a fatal boot error can be inspected once the
|
|
@@ -427,31 +472,29 @@ function runServer(port, probedAddress, noOpen, cwd, onChild) {
|
|
|
427
472
|
let readyStarted = false;
|
|
428
473
|
let cancelReady = () => {};
|
|
429
474
|
// Takes a CONCRETE address — callers resolve first, and a caller that cannot does not call.
|
|
430
|
-
|
|
475
|
+
//
|
|
476
|
+
// Two different questions, and #1889 is what happens when one answer is given to both. The
|
|
477
|
+
// POLL asks "did the server we started come up", so it uses the address the child reported
|
|
478
|
+
// binding — that is #1876's fix and it stays. The URL asks "where does this user's browser
|
|
479
|
+
// keep its state", and the only answer that does not empty the app is the one it has always
|
|
480
|
+
// been given: see browserUrl.
|
|
481
|
+
// `serverSaysLocalhostIsOurs` is the child's own report and OUTRANKS the probe when present.
|
|
482
|
+
// The probe ran before the child existed, so it cannot see a process that claimed EITHER
|
|
483
|
+
// loopback during the boot — only the child knows how its own binds went (Codex, PR #1903).
|
|
484
|
+
// `undefined` means the child said nothing about it, and then the probe is all there is.
|
|
485
|
+
const beginReady = (reachHost, serverSaysLocalhostIsOurs) => {
|
|
431
486
|
if (readyStarted) return;
|
|
432
487
|
readyStarted = true;
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
() => {
|
|
437
|
-
printReadyBanner(url, STOP_COMMAND);
|
|
438
|
-
if (noOpen) return;
|
|
439
|
-
try {
|
|
440
|
-
// The command is a hardcoded literal; url is built by launcherUrl from the address the
|
|
441
|
-
// child reported and a numeric port, so it is http://<addr>:<n> or http://[<v6>]:<n>.
|
|
442
|
-
|
|
443
|
-
execSync(`${pickOpenCommand()} ${url}`, { stdio: "pipe" });
|
|
444
|
-
} catch {
|
|
445
|
-
log(`Open your browser: ${url}`);
|
|
446
|
-
}
|
|
447
|
-
},
|
|
448
|
-
{ host: reachHost },
|
|
449
|
-
);
|
|
488
|
+
const localhostIsOurs = localhostIsUnambiguous && serverSaysLocalhostIsOurs !== false;
|
|
489
|
+
const { url, note } = launchTarget(reachHost, port, localhostIsOurs);
|
|
490
|
+
cancelReady = waitUntilReady(port, () => announceReady(url, note, noOpen), { host: reachHost });
|
|
450
491
|
};
|
|
451
492
|
server.on("message", (msg) => {
|
|
452
493
|
if (!isRecordLike(msg) || msg.type !== "listening" || typeof msg.address !== "string") return;
|
|
453
494
|
const reported = launcherReachHost(msg.address);
|
|
454
|
-
|
|
495
|
+
// Absent rather than false when the field is missing, so "an older child said nothing" and
|
|
496
|
+
// "this child could not take it" stay different answers.
|
|
497
|
+
if (reported) beginReady(reported, typeof msg.localhostIsOurs === "boolean" ? msg.localhostIsOurs : undefined);
|
|
455
498
|
});
|
|
456
499
|
// The fallback runs ONLY on an address we can name without asking anyone. Guessing from a
|
|
457
500
|
// NAME is what the child's report exists to replace, and a fallback that guessed anyway just
|
|
@@ -463,7 +506,8 @@ function runServer(port, probedAddress, noOpen, cwd, onChild) {
|
|
|
463
506
|
// into something connectable; BIND_HOST is the last resort and returns null for a name.
|
|
464
507
|
const guessed = probedAddress ? launcherReachHost(probedAddress) : launcherReachHost(BIND_HOST);
|
|
465
508
|
const fallbackReady = setTimeout(() => {
|
|
466
|
-
|
|
509
|
+
// No report, so no v6 answer either — the probe is all this path ever had.
|
|
510
|
+
if (guessed) return beginReady(guessed, undefined);
|
|
467
511
|
if (!readyStarted)
|
|
468
512
|
log(`Started, but ${BIND_HOST} is a name and the server has not reported which address it bound — not guessing. It may still be starting.`);
|
|
469
513
|
}, REPORTED_ADDRESS_GRACE_MS);
|
|
@@ -613,12 +657,12 @@ async function main() {
|
|
|
613
657
|
// The address comes back with the port because the PROBE learned it from the kernel, which is
|
|
614
658
|
// the one answer that needs no interpreting (#1876). It is the fallback the readiness poll uses
|
|
615
659
|
// when the child reports nothing.
|
|
616
|
-
const { port, address: probedAddress } = await choosePort(requestedPort, portExplicit);
|
|
660
|
+
const { port, address: probedAddress, localhostIsUnambiguous } = await choosePort(requestedPort, portExplicit);
|
|
617
661
|
// Named only now, because the port is half the name — and named at all so that the user who
|
|
618
662
|
// loses this terminal has something to search for (#1820). The server child names itself the
|
|
619
663
|
// same, so `pkill mulmoterminal` reaches whichever half is found first.
|
|
620
664
|
setProcessTitle(port);
|
|
621
|
-
await runServer(port, probedAddress, noOpen, cwd, (c) => {
|
|
665
|
+
await runServer(port, probedAddress, localhostIsUnambiguous, noOpen, cwd, (c) => {
|
|
622
666
|
child = c;
|
|
623
667
|
});
|
|
624
668
|
error(portInUseMessage(port, portExplicit));
|
package/common/keymap.ts
CHANGED
|
@@ -18,6 +18,7 @@ export const KEYMAP_ACTIONS = [
|
|
|
18
18
|
"zoom-prev",
|
|
19
19
|
"next-attention",
|
|
20
20
|
"terminal-new",
|
|
21
|
+
"terminal-new-here",
|
|
21
22
|
"terminal-new-adjacent",
|
|
22
23
|
"terminal-close",
|
|
23
24
|
"copy",
|
|
@@ -39,6 +40,16 @@ export const isKeymapAction = (value: unknown): value is KeymapAction => typeof
|
|
|
39
40
|
// ^C. A handler that has already swallowed the key cannot change its mind.
|
|
40
41
|
export const TERMINAL_SCOPED_ACTIONS: readonly KeymapAction[] = ["copy", "paste"];
|
|
41
42
|
|
|
43
|
+
// Actions that act ON a terminal and so need one the grid can name. The enlarged cell is the only
|
|
44
|
+
// such state the grid has, so un-zoomed these do nothing rather than guessing which cell was meant.
|
|
45
|
+
// `terminal-new` is exempt (appending needs no subject), and so are `zoom-toggle` / `next-attention`,
|
|
46
|
+
// which pick the cell themselves — that is what makes them the keyboard's way INTO the zoom.
|
|
47
|
+
//
|
|
48
|
+
// Here rather than beside gridShortcutFor because BOTH sides decide from it: the grid dispatches on
|
|
49
|
+
// it, and validateKeymap has to know that these decline the key — the handler returns WITHOUT
|
|
50
|
+
// stopping the event, so a `send` on the same keystroke fires instead (codex on #1906).
|
|
51
|
+
export const NEEDS_A_CURRENT_TERMINAL: readonly KeymapAction[] = ["zoom-next", "zoom-prev", "terminal-new-adjacent", "terminal-close"];
|
|
52
|
+
|
|
42
53
|
// A key that puts BYTES into the focused terminal instead of running an app action (#1005) —
|
|
43
54
|
// Cmd+Right as Ctrl+E for end-of-line, say, or Alt+B for word-back.
|
|
44
55
|
//
|
|
@@ -163,9 +174,17 @@ export function validateKeymap(input: unknown): KeymapProblem[] {
|
|
|
163
174
|
return [{ action: "keymap", binding: input, reason: "`keymap` must be an object of action -> key binding", fatal: true }];
|
|
164
175
|
}
|
|
165
176
|
const entries = Object.entries(input);
|
|
166
|
-
// A claim on one keystroke. `rank` is DISPATCH order, so the winner can be named
|
|
167
|
-
//
|
|
168
|
-
//
|
|
177
|
+
// A claim on one keystroke. `rank` is DISPATCH order, so the winner can be named — with ONE
|
|
178
|
+
// exception, which is why `kind` is carried alongside it.
|
|
179
|
+
//
|
|
180
|
+
// Most actions are claimed by the grid in the capture phase, before the terminal sees the key.
|
|
181
|
+
// `copy` and `paste` are not (TERMINAL_SCOPED_ACTIONS — gridShortcutFor skips them); they are
|
|
182
|
+
// decided inside the terminal by clipboardActionFor, which runs ahead of the send handler. So
|
|
183
|
+
// `paste` still outranks a send.
|
|
184
|
+
//
|
|
185
|
+
// `copy` does NOT, unconditionally: clipboardActionFor returns null when nothing is selected —
|
|
186
|
+
// deliberately, so Ctrl+C stays an interrupt — and the same-key send fires then. Naming `copy`
|
|
187
|
+
// as the winner there tells the user the opposite of what they will see half the time (#1901).
|
|
169
188
|
const bound = new Map<string, Claim[]>();
|
|
170
189
|
const claim = (parsed: KeyBinding, entry: Claim): void => {
|
|
171
190
|
const key = canonicalBinding(parsed); // as PARSED: "Shift+PageUp" and "shift+pageup" are one keystroke
|
|
@@ -181,7 +200,7 @@ export function validateKeymap(input: unknown): KeymapProblem[] {
|
|
|
181
200
|
if (parsed === null) {
|
|
182
201
|
return [{ action, binding, reason: 'unparseable key binding — expected e.g. "PageDown" or "Shift+PageUp"', fatal: true }];
|
|
183
202
|
}
|
|
184
|
-
claim(parsed, { label: action, binding, rank: KEYMAP_ACTIONS.indexOf(action) });
|
|
203
|
+
claim(parsed, { label: action, binding, rank: KEYMAP_ACTIONS.indexOf(action), kind: "action" });
|
|
185
204
|
return [];
|
|
186
205
|
});
|
|
187
206
|
return [...problems, ...duplicateWarnings(bound)];
|
|
@@ -191,6 +210,9 @@ interface Claim {
|
|
|
191
210
|
label: string;
|
|
192
211
|
binding: string;
|
|
193
212
|
rank: number;
|
|
213
|
+
/** Which side of the dispatch this is. `copy` vs a `send` is the one collision whose winner
|
|
214
|
+
* depends on runtime state, and that cannot be read back off `label`. */
|
|
215
|
+
kind: "action" | "send";
|
|
194
216
|
}
|
|
195
217
|
|
|
196
218
|
// Everything wrong with the `send` list, claiming the keystrokes that are well-formed.
|
|
@@ -213,7 +235,7 @@ function sendProblems(input: unknown, claim: (parsed: KeyBinding, entry: Claim)
|
|
|
213
235
|
return [{ action: label, binding: entry.key, reason: "`bytes` is empty — the key would be taken from the terminal and nothing sent", fatal: true }];
|
|
214
236
|
}
|
|
215
237
|
// Ranked after every action, matching who actually wins (see the `bound` comment above).
|
|
216
|
-
claim(parsed, { label, binding: entry.key, rank: KEYMAP_ACTIONS.length + i });
|
|
238
|
+
claim(parsed, { label, binding: entry.key, rank: KEYMAP_ACTIONS.length + i, kind: "send" });
|
|
217
239
|
return [];
|
|
218
240
|
});
|
|
219
241
|
}
|
|
@@ -228,15 +250,55 @@ function duplicateWarnings(bound: Map<string, Claim[]>): KeymapProblem[] {
|
|
|
228
250
|
if (claims.length < 2) return [];
|
|
229
251
|
const [winner, ...losers] = [...claims].sort((a, b) => a.rank - b.rank);
|
|
230
252
|
if (!winner) return []; // unreachable: claims.length >= 2 was checked above
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
253
|
+
const runnerUp = fallthroughWinner(winner, losers);
|
|
254
|
+
return losers.map((loser) => ({
|
|
255
|
+
action: loser.label,
|
|
256
|
+
binding: loser.binding,
|
|
257
|
+
reason: collisionReason(winner, runnerUp, loser),
|
|
235
258
|
fatal: false,
|
|
236
259
|
}));
|
|
237
260
|
});
|
|
238
261
|
}
|
|
239
262
|
|
|
263
|
+
// An action that DECLINES the keystroke in some runtime state, letting it fall through to a `send`
|
|
264
|
+
// binding on the same key. Naming such an action as the sole winner is wrong whenever the user is
|
|
265
|
+
// in the other state, which is the whole of #1901.
|
|
266
|
+
//
|
|
267
|
+
// Two handlers decline, for the same structural reason — each returns WITHOUT stopping the event:
|
|
268
|
+
//
|
|
269
|
+
// - `copy` with no selection (clipboardActionFor), deliberately, so Ctrl+C stays interrupt.
|
|
270
|
+
// - NEEDS_A_CURRENT_TERMINAL with nothing enlarged (gridShortcutFor).
|
|
271
|
+
//
|
|
272
|
+
// `acts` and `otherwise` are the two halves of what the user is told.
|
|
273
|
+
interface StandsAside {
|
|
274
|
+
acts: string;
|
|
275
|
+
otherwise: string;
|
|
276
|
+
}
|
|
277
|
+
const WHILE_ENLARGED: StandsAside = { acts: "only while a terminal is enlarged", otherwise: "when none is" };
|
|
278
|
+
const standsAside = (label: string): StandsAside | null => {
|
|
279
|
+
if (label === "copy") return { acts: "only while text is selected", otherwise: "when nothing is" };
|
|
280
|
+
return NEEDS_A_CURRENT_TERMINAL.some((action) => action === label) ? WHILE_ENLARGED : null;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// The claim that fires when the winner stands aside, or null when the winner takes every state.
|
|
284
|
+
//
|
|
285
|
+
// Only a send can be this one. `actionForKey` returns the lowest-ranked bound action and stops, so
|
|
286
|
+
// a second ACTION on the key is never reached in either state; and `sendBytesFor` takes the FIRST
|
|
287
|
+
// match, so only the first send is. A conditional collision therefore has exactly TWO reachable
|
|
288
|
+
// claims and every later one is unreachable in BOTH states.
|
|
289
|
+
const fallthroughWinner = (winner: Claim, losers: Claim[]): Claim | null =>
|
|
290
|
+
winner.kind === "action" && standsAside(winner.label) !== null ? (losers.find((claim) => claim.kind === "send") ?? null) : null;
|
|
291
|
+
|
|
292
|
+
// What the user will actually see, which is not always a single winner.
|
|
293
|
+
const collisionReason = (winner: Claim, runnerUp: Claim | null, loser: Claim): string => {
|
|
294
|
+
const aside = standsAside(winner.label);
|
|
295
|
+
if (runnerUp === null || aside === null) return `same keystroke as \`${winner.label}\` — only \`${winner.label}\` will fire`;
|
|
296
|
+
if (loser === runnerUp) {
|
|
297
|
+
return `same keystroke as \`${winner.label}\` — \`${winner.label}\` acts ${aside.acts}, and \`${loser.label}\` fires ${aside.otherwise}`;
|
|
298
|
+
}
|
|
299
|
+
return `same keystroke as \`${winner.label}\` and \`${runnerUp.label}\` — \`${winner.label}\` acts ${aside.acts} and \`${runnerUp.label}\` fires ${aside.otherwise}, so \`${loser.label}\` never fires`;
|
|
300
|
+
};
|
|
301
|
+
|
|
240
302
|
// A binding's identity as a keystroke, for spotting two actions that claim the same one.
|
|
241
303
|
const canonicalBinding = (b: KeyBinding): string => `${b.shift ? "S" : ""}${b.alt ? "A" : ""}${b.ctrl ? "C" : ""}${b.meta ? "M" : ""}|${b.key}`;
|
|
242
304
|
|
package/common/modelPresets.ts
CHANGED
|
@@ -78,7 +78,7 @@ export const MODEL_PRESETS: readonly ModelPreset[] = [
|
|
|
78
78
|
id: "nvidia/nemotron-3-super-120b-a12b",
|
|
79
79
|
label: "Nemotron 3 Super 120B",
|
|
80
80
|
contextLength: 1_000_000,
|
|
81
|
-
pricePerMTok: { input: 0.
|
|
81
|
+
pricePerMTok: { input: 0.085, output: 0.4 },
|
|
82
82
|
trials: measured(3, 3, 18),
|
|
83
83
|
},
|
|
84
84
|
{
|
|
@@ -86,7 +86,7 @@ export const MODEL_PRESETS: readonly ModelPreset[] = [
|
|
|
86
86
|
id: "qwen/qwen3-235b-a22b-2507",
|
|
87
87
|
label: "Qwen3 235B A22B",
|
|
88
88
|
contextLength: 262_144,
|
|
89
|
-
pricePerMTok: { input: 0.
|
|
89
|
+
pricePerMTok: { input: 0.0875, output: 0.35 },
|
|
90
90
|
trials: measured(3, 3, 16),
|
|
91
91
|
},
|
|
92
92
|
{
|
|
@@ -94,7 +94,7 @@ export const MODEL_PRESETS: readonly ModelPreset[] = [
|
|
|
94
94
|
id: "minimax/minimax-m2.7",
|
|
95
95
|
label: "MiniMax M2.7",
|
|
96
96
|
contextLength: 204_800,
|
|
97
|
-
pricePerMTok: { input: 0.
|
|
97
|
+
pricePerMTok: { input: 0.3, output: 1.2 },
|
|
98
98
|
trials: measured(3, 3, 16),
|
|
99
99
|
},
|
|
100
100
|
{
|
|
@@ -102,7 +102,7 @@ export const MODEL_PRESETS: readonly ModelPreset[] = [
|
|
|
102
102
|
id: "deepseek/deepseek-v3.2",
|
|
103
103
|
label: "DeepSeek V3.2",
|
|
104
104
|
contextLength: 163_840,
|
|
105
|
-
pricePerMTok: { input: 0.
|
|
105
|
+
pricePerMTok: { input: 0.26, output: 0.38 },
|
|
106
106
|
trials: measured(3, 3, 42),
|
|
107
107
|
},
|
|
108
108
|
{
|
|
@@ -118,7 +118,7 @@ export const MODEL_PRESETS: readonly ModelPreset[] = [
|
|
|
118
118
|
id: "deepseek/deepseek-v4-pro",
|
|
119
119
|
label: "DeepSeek V4 Pro",
|
|
120
120
|
contextLength: 1_048_576,
|
|
121
|
-
pricePerMTok: { input: 0.
|
|
121
|
+
pricePerMTok: { input: 0.87, output: 1.74 },
|
|
122
122
|
trials: measured(3, 3, 20),
|
|
123
123
|
},
|
|
124
124
|
{
|
|
@@ -126,7 +126,7 @@ export const MODEL_PRESETS: readonly ModelPreset[] = [
|
|
|
126
126
|
id: "deepseek/deepseek-v4-flash",
|
|
127
127
|
label: "DeepSeek V4 Flash",
|
|
128
128
|
contextLength: 1_048_576,
|
|
129
|
-
pricePerMTok: { input: 0.
|
|
129
|
+
pricePerMTok: { input: 0.088606, output: 0.177212 },
|
|
130
130
|
trials: measured(3, 4, 26),
|
|
131
131
|
},
|
|
132
132
|
{
|
|
@@ -158,7 +158,7 @@ export const MODEL_PRESETS: readonly ModelPreset[] = [
|
|
|
158
158
|
id: "moonshotai/kimi-k2.6",
|
|
159
159
|
label: "Kimi K2.6",
|
|
160
160
|
contextLength: 262_144,
|
|
161
|
-
pricePerMTok: { input: 0.
|
|
161
|
+
pricePerMTok: { input: 0.95, output: 4.0 },
|
|
162
162
|
trials: measured(3, 3, 46),
|
|
163
163
|
},
|
|
164
164
|
{
|
|
@@ -166,7 +166,7 @@ export const MODEL_PRESETS: readonly ModelPreset[] = [
|
|
|
166
166
|
id: "z-ai/glm-5.2",
|
|
167
167
|
label: "GLM 5.2",
|
|
168
168
|
contextLength: 1_048_576,
|
|
169
|
-
pricePerMTok: { input:
|
|
169
|
+
pricePerMTok: { input: 1.19, output: 3.74 },
|
|
170
170
|
trials: measured(3, 3, 21),
|
|
171
171
|
},
|
|
172
172
|
{
|
|
@@ -174,7 +174,7 @@ export const MODEL_PRESETS: readonly ModelPreset[] = [
|
|
|
174
174
|
id: "moonshotai/kimi-k2.7-code",
|
|
175
175
|
label: "Kimi K2.7 Code",
|
|
176
176
|
contextLength: 262_144,
|
|
177
|
-
pricePerMTok: { input: 0.
|
|
177
|
+
pricePerMTok: { input: 0.66, output: 3.4 },
|
|
178
178
|
trials: measured(3, 3, 14),
|
|
179
179
|
},
|
|
180
180
|
{
|
|
@@ -190,19 +190,35 @@ export const MODEL_PRESETS: readonly ModelPreset[] = [
|
|
|
190
190
|
id: "tencent/hy3",
|
|
191
191
|
label: "Tencent Hy3",
|
|
192
192
|
contextLength: 262_144,
|
|
193
|
-
pricePerMTok: { input: 0.
|
|
193
|
+
pricePerMTok: { input: 0.132, output: 0.528 },
|
|
194
194
|
trials: measured(3, 3, 17),
|
|
195
195
|
},
|
|
196
196
|
{
|
|
197
197
|
provider: "openrouter",
|
|
198
198
|
id: "nvidia/nemotron-3-ultra-550b-a55b",
|
|
199
199
|
label: "Nemotron 3 Ultra 550B",
|
|
200
|
-
contextLength:
|
|
201
|
-
pricePerMTok: { input: 0.
|
|
200
|
+
contextLength: 262_144,
|
|
201
|
+
pricePerMTok: { input: 0.5, output: 2.2 },
|
|
202
202
|
trials: measured(3, 3, 13),
|
|
203
203
|
},
|
|
204
204
|
|
|
205
205
|
// ── Frontier vendors: cheapest first ─────────────────────────────────────────
|
|
206
|
+
{
|
|
207
|
+
provider: "openrouter",
|
|
208
|
+
id: "openai/gpt-5.6-luna",
|
|
209
|
+
label: "GPT-5.6 Luna",
|
|
210
|
+
contextLength: 1_050_000,
|
|
211
|
+
pricePerMTok: { input: 0.2, output: 1.2 },
|
|
212
|
+
trials: measured(3, 3, 27),
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
provider: "openrouter",
|
|
216
|
+
id: "openai/gpt-5.6-luna-pro",
|
|
217
|
+
label: "GPT-5.6 Luna Pro",
|
|
218
|
+
contextLength: 1_050_000,
|
|
219
|
+
pricePerMTok: { input: 0.2, output: 1.2 },
|
|
220
|
+
trials: measured(3, 3, 69),
|
|
221
|
+
},
|
|
206
222
|
{
|
|
207
223
|
provider: "openrouter",
|
|
208
224
|
id: "google/gemini-3.5-flash-lite",
|
|
@@ -219,28 +235,12 @@ export const MODEL_PRESETS: readonly ModelPreset[] = [
|
|
|
219
235
|
pricePerMTok: { input: 0.3, output: 2.5 },
|
|
220
236
|
trials: measured(2, 3, 20),
|
|
221
237
|
},
|
|
222
|
-
{
|
|
223
|
-
provider: "openrouter",
|
|
224
|
-
id: "openai/gpt-5.6-luna",
|
|
225
|
-
label: "GPT-5.6 Luna",
|
|
226
|
-
contextLength: 1_050_000,
|
|
227
|
-
pricePerMTok: { input: 1.0, output: 6.0 },
|
|
228
|
-
trials: measured(3, 3, 27),
|
|
229
|
-
},
|
|
230
|
-
{
|
|
231
|
-
provider: "openrouter",
|
|
232
|
-
id: "openai/gpt-5.6-luna-pro",
|
|
233
|
-
label: "GPT-5.6 Luna Pro",
|
|
234
|
-
contextLength: 1_050_000,
|
|
235
|
-
pricePerMTok: { input: 1.0, output: 6.0 },
|
|
236
|
-
trials: measured(3, 3, 69),
|
|
237
|
-
},
|
|
238
238
|
{
|
|
239
239
|
provider: "openrouter",
|
|
240
240
|
id: "google/gemini-3.6-flash",
|
|
241
241
|
label: "Gemini 3.6 Flash",
|
|
242
242
|
contextLength: 1_048_576,
|
|
243
|
-
pricePerMTok: { input:
|
|
243
|
+
pricePerMTok: { input: 0.75, output: 3.75 },
|
|
244
244
|
trials: measured(3, 3, 16),
|
|
245
245
|
},
|
|
246
246
|
{
|
|
@@ -256,7 +256,7 @@ export const MODEL_PRESETS: readonly ModelPreset[] = [
|
|
|
256
256
|
id: "openai/gpt-5.6-terra-pro",
|
|
257
257
|
label: "GPT-5.6 Terra Pro",
|
|
258
258
|
contextLength: 1_050_000,
|
|
259
|
-
pricePerMTok: { input: 2.
|
|
259
|
+
pricePerMTok: { input: 2.0, output: 12.0 },
|
|
260
260
|
trials: measured(3, 3, 38),
|
|
261
261
|
},
|
|
262
262
|
|
|
@@ -303,7 +303,7 @@ export const MODEL_PRESETS: readonly ModelPreset[] = [
|
|
|
303
303
|
id: "mistralai/devstral-2512",
|
|
304
304
|
label: "Devstral 2512",
|
|
305
305
|
contextLength: 262_144,
|
|
306
|
-
pricePerMTok: { input: 0.
|
|
306
|
+
pricePerMTok: { input: 0.44, output: 2.2 },
|
|
307
307
|
trials: unreachable(ACCOUNT_PRIVACY),
|
|
308
308
|
},
|
|
309
309
|
];
|