@@ -24,8 +24,10 @@ import type { ThemeSetting } from "./theme-detect.js";
2424import { focusOwner } from "./focus/index.js" ;
2525import { BUNDLED_PLUGIN_MARKER as BUNDLED_MARKER } from "../plugins/origin-marker.js" ;
2626import type { Harness } from "./harness" ;
27+ import type { AltChordLetter } from "./key-chords" ;
2728import { projectPluginsRoot , userPluginsRoot } from "../plugins/uninstall.js" ;
2829import type { AppShell } from "./shell/internals" ;
30+ import { shellInternals } from "./shell/internals" ;
2931import { withAppShell } from "./test-helpers" ;
3032import {
3133 acceptOverlaySelection ,
@@ -585,6 +587,34 @@ describe("plugins surface admin actions", () => {
585587 } ) ;
586588 } ) ;
587589
590+ test ( "hybrid glyph events never reach the pane's bare s/v claims (I-4)" , async ( ) => {
591+ await withShell ( async ( shell ) => {
592+ const { deps, calls } = pluginActionDeps ( {
593+ credentialValues : { apiKey : "saved-key" } ,
594+ } ) ;
595+ openCommandSurface ( shell , "plugins" , deps ) ;
596+ expect ( runOverlayAction ( shell , altKey ( "c" ) ) ) . toBe ( true ) ;
597+ expect ( shell . overlayKind ) . toBe ( "plugin_credentials" ) ;
598+ // {name:"v", sequence:"√"} is the composed Option glyph arriving with
599+ // no modifier bit — the input shape altChordLetterForKey reads via
600+ // `sequence`. Read through `name` it used to fire save-and-verify.
601+ expect (
602+ runOverlayAction ( shell , { ...charKey ( "√" ) , name : "v" } as KeyEvent ) ,
603+ ) . toBe ( false ) ;
604+ expect (
605+ runOverlayAction ( shell , { ...charKey ( "ß" ) , name : "s" } as KeyEvent ) ,
606+ ) . toBe ( false ) ;
607+ expect ( calls . saveCredentials ) . toEqual ( [ ] ) ;
608+ expect ( calls . verify ) . toEqual ( [ ] ) ;
609+ // A true bare key still claims: the guard is sequence-vs-name, not a
610+ // ban on the letters themselves.
611+ expect ( runOverlayAction ( shell , charKey ( "v" ) ) ) . toBe ( true ) ;
612+ await Promise . resolve ( ) ;
613+ expect ( calls . saveCredentials . length ) . toBe ( 1 ) ;
614+ expect ( calls . verify . length ) . toBe ( 1 ) ;
615+ } ) ;
616+ } ) ;
617+
588618 test ( "v verifies the currently saved credentials" , async ( ) => {
589619 await withShell ( async ( shell ) => {
590620 const { deps, calls } = pluginActionDeps ( {
@@ -876,6 +906,212 @@ describe("plugins surface admin actions", () => {
876906 } ) ;
877907} ) ;
878908
909+ /**
910+ * CL-8637: on stock macOS terminals Option+<letter> arrives as the composed
911+ * layout glyph with no modifier bit. On the two surfaces that advertise
912+ * Alt+<letter> chords, that glyph must run the advertised action and must
913+ * never leak into the prompt buffer.
914+ */
915+ describe ( "composed option glyphs on /plugins and /mcp (CL-8637)" , ( ) => {
916+ test ( "every advertised plugins glyph runs its chord with the prompt left alone" , async ( ) => {
917+ const glyphs = [ "å" , "∑" , "ç" , "√" , "†" , "≈" ] as const ;
918+ for ( const glyph of glyphs ) {
919+ await withShell ( async ( shell ) => {
920+ const { deps } = pluginActionDeps ( { needsTrust : true } ) ;
921+ openCommandSurface ( shell , "plugins" , deps ) ;
922+ shell . prompt . value = "" ;
923+ expect ( runOverlayAction ( shell , charKey ( glyph ) ) ) . toBe ( true ) ;
924+ expect ( shell . prompt . value ) . toBe ( "" ) ;
925+ } ) ;
926+ }
927+ } ) ;
928+
929+ test ( "å add-by-path and ∑ web chooser keep working through the glyph" , async ( ) => {
930+ await withShell ( async ( shell ) => {
931+ const { deps, calls } = pluginActionDeps ( ) ;
932+ openCommandSurface ( shell , "plugins" , deps ) ;
933+ expect ( runOverlayAction ( shell , charKey ( "å" ) ) ) . toBe ( true ) ;
934+ typeOverlayText ( shell , "/tmp/glyph-plugin" ) ;
935+ acceptOverlaySelection ( shell ) ;
936+ await Promise . resolve ( ) ;
937+ expect ( calls . addPath ) . toEqual ( [ "/tmp/glyph-plugin" ] ) ;
938+ } ) ;
939+ await withShell ( async ( shell ) => {
940+ const { deps, calls } = pluginActionDeps ( ) ;
941+ openCommandSurface (
942+ shell ,
943+ "plugins" ,
944+ patchPlugins ( deps , { list : ( ) => [ ] } ) ,
945+ ) ;
946+ expect ( runOverlayAction ( shell , charKey ( "∑" ) ) ) . toBe ( true ) ;
947+ moveOverlaySelection ( shell , 1 ) ;
948+ acceptOverlaySelection ( shell ) ;
949+ await Promise . resolve ( ) ;
950+ expect ( calls . setWebProvider ) . toEqual ( [ "exa" ] ) ;
951+ } ) ;
952+ } ) ;
953+
954+ test ( "the trust glyph on a trusted plugin is still a no-op (I-7)" , async ( ) => {
955+ await withShell ( async ( shell ) => {
956+ const { deps, calls } = pluginActionDeps ( ) ;
957+ openCommandSurface ( shell , "plugins" , deps ) ;
958+ expect ( runOverlayAction ( shell , charKey ( "†" ) ) ) . toBe ( false ) ;
959+ expect ( calls . setEnabled ) . toEqual ( [ ] ) ;
960+ } ) ;
961+ } ) ;
962+
963+ test ( "mcp glyphs stay text on /plugins: this surface declares no d or r" , async ( ) => {
964+ await withShell ( async ( shell ) => {
965+ const { deps, calls } = pluginActionDeps ( ) ;
966+ openCommandSurface ( shell , "plugins" , deps ) ;
967+ expect ( runOverlayAction ( shell , charKey ( "∂" ) ) ) . toBe ( false ) ;
968+ expect ( runOverlayAction ( shell , charKey ( "®" ) ) ) . toBe ( false ) ;
969+ expect ( calls . setEnabled ) . toEqual ( [ ] ) ;
970+ expect ( calls . remove ) . toEqual ( [ ] ) ;
971+ } ) ;
972+ } ) ;
973+
974+ test ( "å completes add-name → URL on /mcp with the prompt left alone" , async ( ) => {
975+ await withShell ( async ( shell ) => {
976+ const added : { name : string ; url : string } [ ] = [ ] ;
977+ let liveEntries : readonly McpEntry [ ] = [
978+ { name : "linear" , state : "connected" , toolCount : 12 } ,
979+ ] ;
980+ openMcp ( shell , {
981+ list : ( ) => liveEntries ,
982+ openAuthURL : ( ) => undefined ,
983+ addServer : async ( name , url ) => {
984+ added . push ( { name, url } ) ;
985+ liveEntries = [ ...liveEntries , { name, state : "connecting" } ] ;
986+ return { ok : true , message : `Added ${ name } .` } ;
987+ } ,
988+ } ) ;
989+
990+ expect ( runOverlayAction ( shell , charKey ( "å" ) ) ) . toBe ( true ) ;
991+ expect ( shell . prompt . value ) . toBe ( "" ) ;
992+ typeOverlayText ( shell , "notion" ) ;
993+ acceptOverlaySelection ( shell ) ;
994+ typeOverlayText ( shell , "https://mcp.notion.test/mcp" ) ;
995+ acceptOverlaySelection ( shell ) ;
996+ await flushSurface ( ) ;
997+
998+ expect ( added ) . toEqual ( [
999+ { name : "notion" , url : "https://mcp.notion.test/mcp" } ,
1000+ ] ) ;
1001+ expect ( shell . prompt . value ) . toBe ( "" ) ;
1002+ } ) ;
1003+ } ) ;
1004+
1005+ test ( "∂ disables the focused server on /mcp with the prompt left alone" , async ( ) => {
1006+ await withShell ( async ( shell ) => {
1007+ const toggled : { name : string ; enabled : boolean } [ ] = [ ] ;
1008+ const notes : string [ ] = [ ] ;
1009+ let liveEntries : readonly McpEntry [ ] = [
1010+ { name : "linear" , state : "connected" , toolCount : 12 } ,
1011+ ] ;
1012+ openMcp (
1013+ shell ,
1014+ {
1015+ list : ( ) => liveEntries ,
1016+ openAuthURL : ( ) => undefined ,
1017+ setEnabled : async ( name , enabled ) => {
1018+ toggled . push ( { name, enabled } ) ;
1019+ liveEntries = [
1020+ { name, state : enabled ? "connecting" : "disabled" } ,
1021+ ] ;
1022+ return { ok : true , message : `Disabled ${ name } .` } ;
1023+ } ,
1024+ } ,
1025+ ( note ) => notes . push ( note ) ,
1026+ ) ;
1027+ expect ( shell . prompt . value ) . toBe ( "" ) ;
1028+ expect ( runOverlayAction ( shell , charKey ( "∂" ) ) ) . toBe ( true ) ;
1029+ await flushSurface ( ) ;
1030+ expect ( toggled ) . toEqual ( [ { name : "linear" , enabled : false } ] ) ;
1031+ expect ( shell . prompt . value ) . toBe ( "" ) ;
1032+ expect ( notes ) . toEqual ( [ "Disabled linear." ] ) ;
1033+ } ) ;
1034+ } ) ;
1035+
1036+ test ( "® on /mcp opens the remove confirm and Enter completes it" , async ( ) => {
1037+ await withShell ( async ( shell ) => {
1038+ let liveEntries : readonly McpEntry [ ] = [
1039+ { name : "linear" , state : "connected" , toolCount : 12 } ,
1040+ { name : "notion" , state : "connected" , toolCount : 3 } ,
1041+ ] ;
1042+ openMcp ( shell , {
1043+ list : ( ) => liveEntries ,
1044+ openAuthURL : ( ) => undefined ,
1045+ removeServer : async ( name ) => {
1046+ liveEntries = liveEntries . filter ( ( entry ) => entry . name !== name ) ;
1047+ return { ok : true , message : `Removed ${ name } .` } ;
1048+ } ,
1049+ } ) ;
1050+ moveOverlaySelection ( shell , 1 ) ;
1051+ expect ( runOverlayAction ( shell , charKey ( "®" ) ) ) . toBe ( true ) ;
1052+ acceptOverlaySelection ( shell ) ;
1053+ await flushSurface ( ) ;
1054+
1055+ expect ( liveEntries . map ( ( entry ) => entry . name ) ) . toEqual ( [ "linear" ] ) ;
1056+ expect ( shell . prompt . value ) . toBe ( "" ) ;
1057+ } ) ;
1058+ } ) ;
1059+
1060+ test ( "å is not a chord on /mcp while local settings shadow global (I-3)" , async ( ) => {
1061+ await withShell ( async ( shell ) => {
1062+ openMcp ( shell , {
1063+ list : ( ) => [ { name : "linear" , state : "connected" , toolCount : 1 } ] ,
1064+ openAuthURL : ( ) => undefined ,
1065+ mcpServersSource : "local" ,
1066+ addServer : async ( ) => ( { ok : true , message : "should not run" } ) ,
1067+ } ) ;
1068+ expect ( runOverlayAction ( shell , charKey ( "å" ) ) ) . toBe ( false ) ;
1069+ expect ( shell . prompt . value ) . toBe ( "" ) ;
1070+ } ) ;
1071+ } ) ;
1072+
1073+ test ( "ç is not a chord on /mcp: this surface declares no c" , async ( ) => {
1074+ await withShell ( async ( shell ) => {
1075+ openMcp ( shell , {
1076+ list : ( ) => [ { name : "linear" , state : "connected" , toolCount : 1 } ] ,
1077+ openAuthURL : ( ) => undefined ,
1078+ } ) ;
1079+ expect ( runOverlayAction ( shell , charKey ( "ç" ) ) ) . toBe ( false ) ;
1080+ } ) ;
1081+ } ) ;
1082+
1083+ test ( "rendered footers only advertise declared letters, and never Alt+F (I-3)" , async ( ) => {
1084+ const advertised = ( frame : string ) : string [ ] =>
1085+ [ ...frame . matchAll ( / A l t \+ ( [ A - Z a - z ] ) / g) ] . map ( ( m ) =>
1086+ ( m [ 1 ] ?? "" ) . toLowerCase ( ) ,
1087+ ) ;
1088+ await withWiredShell ( async ( shell , harness ) => {
1089+ const { deps } = pluginActionDeps ( ) ;
1090+ openCommandSurface ( shell , "plugins" , deps ) ;
1091+ await harness . renderOnce ( ) ;
1092+ const declared = shellInternals ( shell ) ?. primaryBindings . altChords ?? [ ] ;
1093+ const letters = advertised ( harness . captureCharFrame ( ) ) ;
1094+ expect ( letters . length ) . toBeGreaterThan ( 0 ) ;
1095+ for ( const letter of letters ) {
1096+ expect ( declared . includes ( letter as AltChordLetter ) ) . toBe ( true ) ;
1097+ }
1098+ } ) ;
1099+ await withWiredShell ( async ( shell , harness ) => {
1100+ openMcp ( shell , {
1101+ list : ( ) => [ { name : "linear" , state : "connected" , toolCount : 1 } ] ,
1102+ openAuthURL : ( ) => undefined ,
1103+ } ) ;
1104+ await harness . renderOnce ( ) ;
1105+ const declared = shellInternals ( shell ) ?. primaryBindings . altChords ?? [ ] ;
1106+ const letters = advertised ( harness . captureCharFrame ( ) ) ;
1107+ expect ( letters . length ) . toBeGreaterThan ( 0 ) ;
1108+ for ( const letter of letters ) {
1109+ expect ( declared . includes ( letter as AltChordLetter ) ) . toBe ( true ) ;
1110+ }
1111+ } ) ;
1112+ } ) ;
1113+ } ) ;
1114+
8791115describe ( "hooks surface" , ( ) => {
8801116 test ( "lists discovered hooks with enabled state and Enter toggles" , async ( ) => {
8811117 await withShell ( async ( shell ) => {
0 commit comments