detached 5bdb1f2

feat(status): show recent-session ranks while Ctrl is held

Request every kitty key event from the outer terminal and normalize it
back to the existing encoding in the input pump, tracking a Ctrl held on
its own. The client reports holds as ModifierState (protocol 67) and the
daemon prefixes the status-bar history with Ctrl+N ranks.
commit
5bdb1f28ce44db65314cf12e79c4aa640f8f860f
author
brice · · 2026-10-15 03:24 UTC
parent
56d7b65
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
29 files · +1168 -155
M docs/architecture.md +2 -0
@@ -98,6 +98,8 @@ On remote attachments the client predicts local echo, after mosh. The attached l
9898
9999 The client turns on focus reporting (DEC mode 1004). Its terminal input pump strips each `ESC [ I`/`ESC [ O` report into a `TerminalFocusState` that any client feature can read or watch, `Hello` declares the focus known at attach, so the first paint already respects it, and the attached loop sends every later change to the daemon as `TerminalFocus` ahead of the input read that carried it. The daemon keeps it per attachment as a lock-free `terminalFocus()`. Unknown focus, from a terminal that never reports, counts as possibly seen, so every feature keeps its previous behavior there. Attention uses it: only an attachment that may be seen clears a bell. Geometry uses it too: a reported focus gain makes the attachment the latest shared PTY geometry claimant, so session content resizes to the focused window.
100100
101+With the kitty keyboard protocol the client asks its terminal for every key event, modifier presses and releases included (`kittykey.RequestFlags`). The input pump runs a `kittykey.Normalizer` that rewrites that stream back to the `OuterFlags` encoding every consumer reads, so routers, pickers, and panes see the same bytes as before, and tracks whether Ctrl is held on its own. The attached loop reports a hold that lasted 150 ms, and its release, as `ModifierState`; the daemon keeps it per attachment and prefixes the status-bar history with its Ctrl+N ranks while it is held.
102+
101103 ## Broker-owned hosts and daemon composition
102104
103105 `vev _broker-ready --require local-authority|local-catalogue` is an internal,
@@ -98,6 +98,8 @@ On remote attachments the client predicts local echo, after mosh. The attached l
98 98
99 The client turns on focus reporting (DEC mode 1004). Its terminal input pump strips each `ESC [ I`/`ESC [ O` report into a `TerminalFocusState` that any client feature can read or watch, `Hello` declares the focus known at attach, so the first paint already respects it, and the attached loop sends every later change to the daemon as `TerminalFocus` ahead of the input read that carried it. The daemon keeps it per attachment as a lock-free `terminalFocus()`. Unknown focus, from a terminal that never reports, counts as possibly seen, so every feature keeps its previous behavior there. Attention uses it: only an attachment that may be seen clears a bell. Geometry uses it too: a reported focus gain makes the attachment the latest shared PTY geometry claimant, so session content resizes to the focused window.99 The client turns on focus reporting (DEC mode 1004). Its terminal input pump strips each `ESC [ I`/`ESC [ O` report into a `TerminalFocusState` that any client feature can read or watch, `Hello` declares the focus known at attach, so the first paint already respects it, and the attached loop sends every later change to the daemon as `TerminalFocus` ahead of the input read that carried it. The daemon keeps it per attachment as a lock-free `terminalFocus()`. Unknown focus, from a terminal that never reports, counts as possibly seen, so every feature keeps its previous behavior there. Attention uses it: only an attachment that may be seen clears a bell. Geometry uses it too: a reported focus gain makes the attachment the latest shared PTY geometry claimant, so session content resizes to the focused window.
100 100
101+With the kitty keyboard protocol the client asks its terminal for every key event, modifier presses and releases included (`kittykey.RequestFlags`). The input pump runs a `kittykey.Normalizer` that rewrites that stream back to the `OuterFlags` encoding every consumer reads, so routers, pickers, and panes see the same bytes as before, and tracks whether Ctrl is held on its own. The attached loop reports a hold that lasted 150 ms, and its release, as `ModifierState`; the daemon keeps it per attachment and prefixes the status-bar history with its Ctrl+N ranks while it is held.
102+
101 ## Broker-owned hosts and daemon composition103 ## Broker-owned hosts and daemon composition
102 104
103 `vev _broker-ready --require local-authority|local-catalogue` is an internal,105 `vev _broker-ready --require local-authority|local-catalogue` is an internal,
M docs/protocol.md +1 -1
@@ -12,7 +12,7 @@ wrong-direction frame before any mutation (`sessionwire.ErrWrongDirection`
1212
1313 Client → server: `Hello`, `Input`, `Resize`, `Detach`, `Ping`, `List`,
1414 `Kill`, `Theme`, `Ack`, `ImagePush`, `ClientNotice`, `CommandRequest`,
15-`OutputResetRequest`, `SelectTab`, `TerminalFocus`, `RemotePreviewWatch`,
15+`OutputResetRequest`, `SelectTab`, `TerminalFocus`, `ModifierState`, `RemotePreviewWatch`,
1616 `RouteAttentionSubscription`, `SamePeerSwitchRequest`,
1717 `RecentRouteSnapshot`, `RouteNavigationFailure`,
1818 `SessionCreationFailure`, `UIFence`, `NavigationInventoryRequest`,
@@ -12,7 +12,7 @@ wrong-direction frame before any mutation (`sessionwire.ErrWrongDirection`
12 12
13 Client → server: `Hello`, `Input`, `Resize`, `Detach`, `Ping`, `List`,13 Client → server: `Hello`, `Input`, `Resize`, `Detach`, `Ping`, `List`,
14 `Kill`, `Theme`, `Ack`, `ImagePush`, `ClientNotice`, `CommandRequest`,14 `Kill`, `Theme`, `Ack`, `ImagePush`, `ClientNotice`, `CommandRequest`,
15-`OutputResetRequest`, `SelectTab`, `TerminalFocus`, `RemotePreviewWatch`,15+`OutputResetRequest`, `SelectTab`, `TerminalFocus`, `ModifierState`, `RemotePreviewWatch`,
16 `RouteAttentionSubscription`, `SamePeerSwitchRequest`,16 `RouteAttentionSubscription`, `SamePeerSwitchRequest`,
17 `RecentRouteSnapshot`, `RouteNavigationFailure`,17 `RecentRouteSnapshot`, `RouteNavigationFailure`,
18 `SessionCreationFailure`, `UIFence`, `NavigationInventoryRequest`,18 `SessionCreationFailure`, `UIFence`, `NavigationInventoryRequest`,
M docs/terminal.md +1 -1
@@ -97,7 +97,7 @@ Terminals without Kitty graphics get text only and one warning.
9797
9898 ## Kitty keyboard
9999
100-When your outer terminal supports the kitty keyboard protocol (kitty, foot, ghostty, WezTerm, recent Alacritty), vev enables it at startup. Ctrl+1 to Ctrl+9 then switch to the 1st to 9th previous session in the status-bar history. Ctrl+1 goes back to the session you used last.
100+When your outer terminal supports the kitty keyboard protocol (kitty, foot, ghostty, WezTerm, recent Alacritty), vev enables it at startup. Ctrl+1 to Ctrl+9 then switch to the 1st to 9th previous session in the status-bar history. Ctrl+1 goes back to the session you used last. Hold Ctrl on its own to see each recent session's number in the status bar, for example `1:local 2:neferwl@arch`.
101101
102102 - Detection uses the same short query as Kitty graphics. Other terminals keep their usual keys, and Ctrl+digits reach the pane unchanged.
103103 - It works with AZERTY and other layouts.
@@ -97,7 +97,7 @@ Terminals without Kitty graphics get text only and one warning.
97 97
98 ## Kitty keyboard98 ## Kitty keyboard
99 99
100-When your outer terminal supports the kitty keyboard protocol (kitty, foot, ghostty, WezTerm, recent Alacritty), vev enables it at startup. Ctrl+1 to Ctrl+9 then switch to the 1st to 9th previous session in the status-bar history. Ctrl+1 goes back to the session you used last.100+When your outer terminal supports the kitty keyboard protocol (kitty, foot, ghostty, WezTerm, recent Alacritty), vev enables it at startup. Ctrl+1 to Ctrl+9 then switch to the 1st to 9th previous session in the status-bar history. Ctrl+1 goes back to the session you used last. Hold Ctrl on its own to see each recent session's number in the status bar, for example `1:local 2:neferwl@arch`.
101 101
102 - Detection uses the same short query as Kitty graphics. Other terminals keep their usual keys, and Ctrl+digits reach the pane unchanged.102 - Detection uses the same short query as Kitty graphics. Other terminals keep their usual keys, and Ctrl+digits reach the pane unchanged.
103 - It works with AZERTY and other layouts.103 - It works with AZERTY and other layouts.
M internal/adapters/sessionwire/proto_convert_test.go +35 -0
@@ -346,6 +346,41 @@ func TestProtoTerminalFocusWire(t *testing.T) {
346346 require.Error(t, err, "unknown focus is never encoded")
347347 }
348348
349+// TestProtoModifierStateWire pins the ModifierState payload byte for byte and
350+// refuses a truncated prefix and trailing garbage.
351+func TestProtoModifierStateWire(t *testing.T) {
352+ tests := []struct {
353+ name string
354+ ctrl bool
355+ want []byte
356+ }{
357+ // field 34 (length-delimited) -> ModifierState{field 1 = ctrl}.
358+ {name: "ctrl held", ctrl: true, want: []byte{0x92, 0x02, 0x02, 0x08, 0x01}},
359+ {name: "released", ctrl: false, want: []byte{0x92, 0x02, 0x00}},
360+ }
361+ for _, tt := range tests {
362+ t.Run(tt.name, func(t *testing.T) {
363+ message := protocol.ModifierState{Ctrl: tt.ctrl}
364+ envelope, err := encodeProtoClient(message)
365+ require.NoError(t, err)
366+ raw, err := proto.Marshal(envelope)
367+ require.NoError(t, err)
368+ require.Equal(t, tt.want, raw)
369+
370+ require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw[:len(raw)-1]), "truncated prefix")
371+ require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(append([]byte(nil), raw...), 0xFF)), "trailing garbage")
372+ require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw))
373+ decoded := &wire.ClientEnvelope{}
374+ require.NoError(t, proto.Unmarshal(raw, decoded))
375+ got, err := decodeProtoClient(decoded)
376+ require.NoError(t, err)
377+ require.Equal(t, message, got)
378+ })
379+ }
380+ _, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_ModifierState{}})
381+ require.Error(t, err, "a missing payload is never decoded")
382+}
383+
349384 func protoMessageName(message any) string {
350385 return protoShortName(message)
351386 }
@@ -346,6 +346,41 @@ func TestProtoTerminalFocusWire(t *testing.T) {
346 require.Error(t, err, "unknown focus is never encoded")346 require.Error(t, err, "unknown focus is never encoded")
347 }347 }
348 348
349+// TestProtoModifierStateWire pins the ModifierState payload byte for byte and
350+// refuses a truncated prefix and trailing garbage.
351+func TestProtoModifierStateWire(t *testing.T) {
352+ tests := []struct {
353+ name string
354+ ctrl bool
355+ want []byte
356+ }{
357+ // field 34 (length-delimited) -> ModifierState{field 1 = ctrl}.
358+ {name: "ctrl held", ctrl: true, want: []byte{0x92, 0x02, 0x02, 0x08, 0x01}},
359+ {name: "released", ctrl: false, want: []byte{0x92, 0x02, 0x00}},
360+ }
361+ for _, tt := range tests {
362+ t.Run(tt.name, func(t *testing.T) {
363+ message := protocol.ModifierState{Ctrl: tt.ctrl}
364+ envelope, err := encodeProtoClient(message)
365+ require.NoError(t, err)
366+ raw, err := proto.Marshal(envelope)
367+ require.NoError(t, err)
368+ require.Equal(t, tt.want, raw)
369+
370+ require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw[:len(raw)-1]), "truncated prefix")
371+ require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(append([]byte(nil), raw...), 0xFF)), "trailing garbage")
372+ require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw))
373+ decoded := &wire.ClientEnvelope{}
374+ require.NoError(t, proto.Unmarshal(raw, decoded))
375+ got, err := decodeProtoClient(decoded)
376+ require.NoError(t, err)
377+ require.Equal(t, message, got)
378+ })
379+ }
380+ _, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_ModifierState{}})
381+ require.Error(t, err, "a missing payload is never decoded")
382+}
383+
349 func protoMessageName(message any) string {384 func protoMessageName(message any) string {
350 return protoShortName(message)385 return protoShortName(message)
351 }386 }
M internal/adapters/sessionwire/proto_envelope.go +12 -0
@@ -170,6 +170,13 @@ func encodeProtoClient(message protocol.ClientMessage) (*wire.ClientEnvelope, er
170170 return nil, ErrInvalidMessage
171171 }
172172 return encodeProtoClient(*m)
173+ case protocol.ModifierState:
174+ return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_ModifierState{ModifierState: &wire.ModifierState{Ctrl: m.Ctrl}}}, nil
175+ case *protocol.ModifierState:
176+ if m == nil {
177+ return nil, ErrInvalidMessage
178+ }
179+ return encodeProtoClient(*m)
173180 case protocol.UIFence:
174181 return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_UiFence{UiFence: uiFenceToWire(m)}}, nil
175182 case *protocol.UIFence:
@@ -656,6 +663,11 @@ func decodeProtoClient(envelope *wire.ClientEnvelope) (protocol.ClientMessage, e
656663 return selectTabFromWire(payload.SelectTab)
657664 case *wire.ClientEnvelope_TerminalFocus:
658665 return terminalFocusFromWire(payload.TerminalFocus)
666+ case *wire.ClientEnvelope_ModifierState:
667+ if payload.ModifierState == nil {
668+ return nil, errProtoConvertRange
669+ }
670+ return protocol.ModifierState{Ctrl: payload.ModifierState.GetCtrl()}, nil
659671 case *wire.ClientEnvelope_RemotePreviewWatch:
660672 return remotePreviewWatchFromWire(payload.RemotePreviewWatch)
661673 case *wire.ClientEnvelope_RouteAttentionSubscription:
@@ -170,6 +170,13 @@ func encodeProtoClient(message protocol.ClientMessage) (*wire.ClientEnvelope, er
170 return nil, ErrInvalidMessage170 return nil, ErrInvalidMessage
171 }171 }
172 return encodeProtoClient(*m)172 return encodeProtoClient(*m)
173+ case protocol.ModifierState:
174+ return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_ModifierState{ModifierState: &wire.ModifierState{Ctrl: m.Ctrl}}}, nil
175+ case *protocol.ModifierState:
176+ if m == nil {
177+ return nil, ErrInvalidMessage
178+ }
179+ return encodeProtoClient(*m)
173 case protocol.UIFence:180 case protocol.UIFence:
174 return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_UiFence{UiFence: uiFenceToWire(m)}}, nil181 return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_UiFence{UiFence: uiFenceToWire(m)}}, nil
175 case *protocol.UIFence:182 case *protocol.UIFence:
@@ -656,6 +663,11 @@ func decodeProtoClient(envelope *wire.ClientEnvelope) (protocol.ClientMessage, e
656 return selectTabFromWire(payload.SelectTab)663 return selectTabFromWire(payload.SelectTab)
657 case *wire.ClientEnvelope_TerminalFocus:664 case *wire.ClientEnvelope_TerminalFocus:
658 return terminalFocusFromWire(payload.TerminalFocus)665 return terminalFocusFromWire(payload.TerminalFocus)
666+ case *wire.ClientEnvelope_ModifierState:
667+ if payload.ModifierState == nil {
668+ return nil, errProtoConvertRange
669+ }
670+ return protocol.ModifierState{Ctrl: payload.ModifierState.GetCtrl()}, nil
659 case *wire.ClientEnvelope_RemotePreviewWatch:671 case *wire.ClientEnvelope_RemotePreviewWatch:
660 return remotePreviewWatchFromWire(payload.RemotePreviewWatch)672 return remotePreviewWatchFromWire(payload.RemotePreviewWatch)
661 case *wire.ClientEnvelope_RouteAttentionSubscription:673 case *wire.ClientEnvelope_RouteAttentionSubscription:
M internal/protocol/messages.go +1 -0
@@ -21,6 +21,7 @@ func (CommandRequest) clientMessage() {}
2121 func (OutputResetRequest) clientMessage() {}
2222 func (SelectTab) clientMessage() {}
2323 func (TerminalFocus) clientMessage() {}
24+func (ModifierState) clientMessage() {}
2425 func (RemotePreviewWatch) clientMessage() {}
2526 func (RouteAttentionSubscription) clientMessage() {}
2627 func (SamePeerSwitchRequest) clientMessage() {}
@@ -21,6 +21,7 @@ func (CommandRequest) clientMessage() {}
21 func (OutputResetRequest) clientMessage() {}21 func (OutputResetRequest) clientMessage() {}
22 func (SelectTab) clientMessage() {}22 func (SelectTab) clientMessage() {}
23 func (TerminalFocus) clientMessage() {}23 func (TerminalFocus) clientMessage() {}
24+func (ModifierState) clientMessage() {}
24 func (RemotePreviewWatch) clientMessage() {}25 func (RemotePreviewWatch) clientMessage() {}
25 func (RouteAttentionSubscription) clientMessage() {}26 func (RouteAttentionSubscription) clientMessage() {}
26 func (SamePeerSwitchRequest) clientMessage() {}27 func (SamePeerSwitchRequest) clientMessage() {}
M internal/protocol/session.go +8 -1
@@ -17,7 +17,7 @@ import (
1717 // daemonmux physical preamble conversation (multiplex.proto), whose
1818 // negotiated ceilings and accepted daemon binding ride the same wire
1919 // version as the session and broker conversations.
20-const Version uint16 = 66
20+const Version uint16 = 67
2121
2222 // HandshakeTimeout bounds every transport handshake from connect through the
2323 // first committed publication. It excludes the preceding client-local
@@ -401,6 +401,13 @@ func (m TerminalFocus) Validate() error {
401401 return nil
402402 }
403403
404+// ModifierState reports the modifier keys held on their own in the client's
405+// terminal. A client sends it only when its terminal reports modifier key
406+// events, so an attachment that never sends it shows no modifier held.
407+type ModifierState struct {
408+ Ctrl bool
409+}
410+
404411 func validEnvironmentPolicy(policy EnvironmentPolicy) bool {
405412 return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned
406413 }
@@ -17,7 +17,7 @@ import (
17 // daemonmux physical preamble conversation (multiplex.proto), whose17 // daemonmux physical preamble conversation (multiplex.proto), whose
18 // negotiated ceilings and accepted daemon binding ride the same wire18 // negotiated ceilings and accepted daemon binding ride the same wire
19 // version as the session and broker conversations.19 // version as the session and broker conversations.
20-const Version uint16 = 6620+const Version uint16 = 67
21 21
22 // HandshakeTimeout bounds every transport handshake from connect through the22 // HandshakeTimeout bounds every transport handshake from connect through the
23 // first committed publication. It excludes the preceding client-local23 // first committed publication. It excludes the preceding client-local
@@ -401,6 +401,13 @@ func (m TerminalFocus) Validate() error {
401 return nil401 return nil
402 }402 }
403 403
404+// ModifierState reports the modifier keys held on their own in the client's
405+// terminal. A client sends it only when its terminal reports modifier key
406+// events, so an attachment that never sends it shows no modifier held.
407+type ModifierState struct {
408+ Ctrl bool
409+}
410+
404 func validEnvironmentPolicy(policy EnvironmentPolicy) bool {411 func validEnvironmentPolicy(policy EnvironmentPolicy) bool {
405 return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned412 return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned
406 }413 }
M internal/protocol/session_test.go +1 -1
@@ -10,7 +10,7 @@ import (
1010 )
1111
1212 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(66), Version)
13+ require.Equal(t, uint16(67), Version)
1414 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
1515 }
1616
@@ -10,7 +10,7 @@ import (
10 )10 )
11 11
12 func TestVersionRemainsWireCompatible(t *testing.T) {12 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(66), Version)13+ require.Equal(t, uint16(67), Version)
14 require.Equal(t, (16<<20)-407, MaxOutputDataLen)14 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
15 }15 }
16 16
M internal/protocol/wire/envelope.pb.go +84 -64 generated file: collapsed
M internal/protocol/wire/envelope_inventory_test.go +3 -3
@@ -15,8 +15,8 @@ import (
1515 // shares a payload type except route navigation failure, and the preamble
1616 // constants agree with the schema authority.
1717 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
18- if protocol.Version != 66 {
19- t.Fatalf("protocol.Version = %d, want 66", protocol.Version)
18+ if protocol.Version != 67 {
19+ t.Fatalf("protocol.Version = %d, want 67", protocol.Version)
2020 }
2121 if ProtocolEpoch != 1 {
2222 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)
@@ -40,7 +40,7 @@ func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
4040 "ui_fence": true, "navigation_inventory_request": true,
4141 "navigation_inventory_publication": true, "navigation_inventory_failure": true,
4242 "picker_close": true, "picker_selection": true,
43- "select_tab": true, "terminal_focus": true,
43+ "select_tab": true, "terminal_focus": true, "modifier_state": true,
4444 }
4545 serverSemantic := map[string]bool{
4646 "attachment_suspended": true, "attachment_activated": true,
@@ -15,8 +15,8 @@ import (
15 // shares a payload type except route navigation failure, and the preamble15 // shares a payload type except route navigation failure, and the preamble
16 // constants agree with the schema authority.16 // constants agree with the schema authority.
17 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {17 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
18- if protocol.Version != 66 {18+ if protocol.Version != 67 {
19- t.Fatalf("protocol.Version = %d, want 66", protocol.Version)19+ t.Fatalf("protocol.Version = %d, want 67", protocol.Version)
20 }20 }
21 if ProtocolEpoch != 1 {21 if ProtocolEpoch != 1 {
22 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)22 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)
@@ -40,7 +40,7 @@ func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
40 "ui_fence": true, "navigation_inventory_request": true,40 "ui_fence": true, "navigation_inventory_request": true,
41 "navigation_inventory_publication": true, "navigation_inventory_failure": true,41 "navigation_inventory_publication": true, "navigation_inventory_failure": true,
42 "picker_close": true, "picker_selection": true,42 "picker_close": true, "picker_selection": true,
43- "select_tab": true, "terminal_focus": true,43+ "select_tab": true, "terminal_focus": true, "modifier_state": true,
44 }44 }
45 serverSemantic := map[string]bool{45 serverSemantic := map[string]bool{
46 "attachment_suspended": true, "attachment_activated": true,46 "attachment_suspended": true, "attachment_activated": true,
M internal/protocol/wire/schema/envelope.proto +1 -0
@@ -100,6 +100,7 @@ message ClientEnvelope {
100100 SelectTab select_tab = 31;
101101 RemotePreviewWatch remote_preview_watch = 32;
102102 TerminalFocus terminal_focus = 33;
103+ ModifierState modifier_state = 34;
103104 }
104105 }
105106
@@ -100,6 +100,7 @@ message ClientEnvelope {
100 SelectTab select_tab = 31;100 SelectTab select_tab = 31;
101 RemotePreviewWatch remote_preview_watch = 32;101 RemotePreviewWatch remote_preview_watch = 32;
102 TerminalFocus terminal_focus = 33;102 TerminalFocus terminal_focus = 33;
103+ ModifierState modifier_state = 34;
103 }104 }
104 }105 }
105 106
M internal/protocol/wire/schema/terminal.proto +6 -0
@@ -93,6 +93,12 @@ message TerminalFocus {
9393 uint32 focus = 1;
9494 }
9595
96+// ModifierState reports which held modifier keys the client's terminal
97+// reports on their own.
98+message ModifierState {
99+ bool ctrl = 1;
100+}
101+
96102 // SelectTab shows one exact tab of the attached session on this attachment.
97103 message SelectTab {
98104 string tab_id = 1;
@@ -93,6 +93,12 @@ message TerminalFocus {
93 uint32 focus = 1;93 uint32 focus = 1;
94 }94 }
95 95
96+// ModifierState reports which held modifier keys the client's terminal
97+// reports on their own.
98+message ModifierState {
99+ bool ctrl = 1;
100+}
101+
96 // SelectTab shows one exact tab of the attached session on this attachment.102 // SelectTab shows one exact tab of the attached session on this attachment.
97 message SelectTab {103 message SelectTab {
98 string tab_id = 1;104 string tab_id = 1;
M internal/protocol/wire/terminal.pb.go +93 -44 generated file: collapsed
M internal/usecase/client/attachment_session.go +17 -0
@@ -386,6 +386,15 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
386386 if err := sendFocus(); err != nil {
387387 return w.settle(ctx, fg, stream, token, err)
388388 }
389+ // A Ctrl held on its own shows the recent-session ranks.
390+ ctrl := newCtrlReporter(attachmentTerminalCtrl(fg), w.cfg.Clock)
391+ defer ctrl.stop()
392+ sendCtrl := func(message protocol.ModifierState, ok bool) error {
393+ if ok {
394+ return w.send(ctx, fg, stream, message)
395+ }
396+ return nil
397+ }
389398 // pending is the one undecided input delivery waiting on the outbox.
390399 var pending *pendingInputAck
391400 defer func() {
@@ -458,6 +467,14 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
458467 if err := sendFocus(); err != nil {
459468 return w.settle(ctx, fg, stream, token, err)
460469 }
470+ case <-ctrl.changed:
471+ if err := sendCtrl(ctrl.next()); err != nil {
472+ return w.settle(ctx, fg, stream, token, err)
473+ }
474+ case <-ctrl.timerC():
475+ if err := sendCtrl(ctrl.due()); err != nil {
476+ return w.settle(ctx, fg, stream, token, err)
477+ }
461478 case <-input.wake:
462479 if err := input.flush(ctx); err != nil {
463480 return w.settle(ctx, fg, stream, token, err)
@@ -386,6 +386,15 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
386 if err := sendFocus(); err != nil {386 if err := sendFocus(); err != nil {
387 return w.settle(ctx, fg, stream, token, err)387 return w.settle(ctx, fg, stream, token, err)
388 }388 }
389+ // A Ctrl held on its own shows the recent-session ranks.
390+ ctrl := newCtrlReporter(attachmentTerminalCtrl(fg), w.cfg.Clock)
391+ defer ctrl.stop()
392+ sendCtrl := func(message protocol.ModifierState, ok bool) error {
393+ if ok {
394+ return w.send(ctx, fg, stream, message)
395+ }
396+ return nil
397+ }
389 // pending is the one undecided input delivery waiting on the outbox.398 // pending is the one undecided input delivery waiting on the outbox.
390 var pending *pendingInputAck399 var pending *pendingInputAck
391 defer func() {400 defer func() {
@@ -458,6 +467,14 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
458 if err := sendFocus(); err != nil {467 if err := sendFocus(); err != nil {
459 return w.settle(ctx, fg, stream, token, err)468 return w.settle(ctx, fg, stream, token, err)
460 }469 }
470+ case <-ctrl.changed:
471+ if err := sendCtrl(ctrl.next()); err != nil {
472+ return w.settle(ctx, fg, stream, token, err)
473+ }
474+ case <-ctrl.timerC():
475+ if err := sendCtrl(ctrl.due()); err != nil {
476+ return w.settle(ctx, fg, stream, token, err)
477+ }
461 case <-input.wake:478 case <-input.wake:
462 if err := input.flush(ctx); err != nil {479 if err := input.flush(ctx); err != nil {
463 return w.settle(ctx, fg, stream, token, err)480 return w.settle(ctx, fg, stream, token, err)
M internal/usecase/client/supervisor.go +3 -1
@@ -1723,7 +1723,9 @@ func (s *Supervisor) detectTerminalCapabilities(ctx context.Context, pump *termi
17231723 }
17241724 caps := probeTerminalCapabilities(ctx, s.cfg.Terminal, s.cfg.Clock, pump)
17251725 if caps.KittyKeyboard && env.KittyKeyboard {
1726- if err := s.cfg.Terminal.EnableKittyKeyboard(kittykey.OuterFlags); err != nil {
1726+ // Normalize before the terminal switches, so no read escapes it.
1727+ pump.keys.enable()
1728+ if err := s.cfg.Terminal.EnableKittyKeyboard(kittykey.RequestFlags); err != nil {
17271729 s.logger.Warn("enabling kitty keyboard protocol failed", "err", err)
17281730 caps.KittyKeyboard = false
17291731 }
@@ -1723,7 +1723,9 @@ func (s *Supervisor) detectTerminalCapabilities(ctx context.Context, pump *termi
1723 }1723 }
1724 caps := probeTerminalCapabilities(ctx, s.cfg.Terminal, s.cfg.Clock, pump)1724 caps := probeTerminalCapabilities(ctx, s.cfg.Terminal, s.cfg.Clock, pump)
1725 if caps.KittyKeyboard && env.KittyKeyboard {1725 if caps.KittyKeyboard && env.KittyKeyboard {
1726- if err := s.cfg.Terminal.EnableKittyKeyboard(kittykey.OuterFlags); err != nil {1726+ // Normalize before the terminal switches, so no read escapes it.
1727+ pump.keys.enable()
1728+ if err := s.cfg.Terminal.EnableKittyKeyboard(kittykey.RequestFlags); err != nil {
1727 s.logger.Warn("enabling kitty keyboard protocol failed", "err", err)1729 s.logger.Warn("enabling kitty keyboard protocol failed", "err", err)
1728 caps.KittyKeyboard = false1730 caps.KittyKeyboard = false
1729 }1731 }
M internal/usecase/client/terminal_input_pump.go +9 -3
@@ -51,7 +51,10 @@ type terminalInputPump struct {
5151 in io.Reader
5252 // focus is the terminal focus decoded from this reader. The pump strips
5353 // focus reports before any consumer sees the bytes.
54- focus *TerminalFocusState
54+ focus *TerminalFocusState
55+ // keys rewrites kitty key events to the encoding consumers read and
56+ // tracks a held Ctrl, once the terminal reports every key event.
57+ keys *terminalKeys
5558 done chan struct{}
5659 automation chan terminalAutomationRequest
5760
@@ -91,6 +94,7 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump {
9194 return &terminalInputPump{
9295 in: in,
9396 focus: newTerminalFocusState(),
97+ keys: newTerminalKeys(),
9498 done: make(chan struct{}),
9599 automation: make(chan terminalAutomationRequest),
96100 activation: 1,
@@ -392,10 +396,12 @@ func (p *terminalInputPump) start() {
392396 n, err := p.in.Read(buf)
393397 result := terminalReadResult{err: err}
394398 if n > 0 {
395- if data := stripTerminalFocusReports(p.focus, buf[:n]); len(data) != 0 {
399+ data := stripTerminalFocusReports(p.focus, buf[:n])
400+ if data = p.keys.normalize(data, p.focus.Focus()); len(data) != 0 {
396401 result.data = append([]byte(nil), data...)
397402 } else if err == nil {
398- // A read that only carried focus reports delivers nothing.
403+ // A read that only carried focus reports or dropped key
404+ // events delivers nothing.
399405 continue
400406 }
401407 }
@@ -51,7 +51,10 @@ type terminalInputPump struct {
51 in io.Reader51 in io.Reader
52 // focus is the terminal focus decoded from this reader. The pump strips52 // focus is the terminal focus decoded from this reader. The pump strips
53 // focus reports before any consumer sees the bytes.53 // focus reports before any consumer sees the bytes.
54- focus *TerminalFocusState54+ focus *TerminalFocusState
55+ // keys rewrites kitty key events to the encoding consumers read and
56+ // tracks a held Ctrl, once the terminal reports every key event.
57+ keys *terminalKeys
55 done chan struct{}58 done chan struct{}
56 automation chan terminalAutomationRequest59 automation chan terminalAutomationRequest
57 60
@@ -91,6 +94,7 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump {
91 return &terminalInputPump{94 return &terminalInputPump{
92 in: in,95 in: in,
93 focus: newTerminalFocusState(),96 focus: newTerminalFocusState(),
97+ keys: newTerminalKeys(),
94 done: make(chan struct{}),98 done: make(chan struct{}),
95 automation: make(chan terminalAutomationRequest),99 automation: make(chan terminalAutomationRequest),
96 activation: 1,100 activation: 1,
@@ -392,10 +396,12 @@ func (p *terminalInputPump) start() {
392 n, err := p.in.Read(buf)396 n, err := p.in.Read(buf)
393 result := terminalReadResult{err: err}397 result := terminalReadResult{err: err}
394 if n > 0 {398 if n > 0 {
395- if data := stripTerminalFocusReports(p.focus, buf[:n]); len(data) != 0 {399+ data := stripTerminalFocusReports(p.focus, buf[:n])
400+ if data = p.keys.normalize(data, p.focus.Focus()); len(data) != 0 {
396 result.data = append([]byte(nil), data...)401 result.data = append([]byte(nil), data...)
397 } else if err == nil {402 } else if err == nil {
398- // A read that only carried focus reports delivers nothing.403+ // A read that only carried focus reports or dropped key
404+ // events delivers nothing.
399 continue405 continue
400 }406 }
401 }407 }
A internal/usecase/client/terminal_keys.go +168 -0
@@ -0,0 +1,168 @@
1+package client
2+
3+import (
4+ "sync"
5+ "sync/atomic"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/usecase/keys/kittykey"
12+)
13+
14+// Held Ctrl.
15+//
16+// With the kitty keyboard protocol the client asks its terminal for every key
17+// event, modifier presses and releases included (kittykey.RequestFlags). The
18+// terminal input pump rewrites that stream back to the encoding the rest of
19+// vev reads (kittykey.OuterFlags) and records whether Ctrl is held on its own
20+// in a terminalCtrlState. The attached loop reports a hold that lasted
21+// ctrlHoldDelay to the daemon, which then shows recent-session ranks.
22+
23+// ctrlHoldDelay keeps a quick Ctrl shortcut from flashing the ranks.
24+const ctrlHoldDelay = 150 * time.Millisecond
25+
26+// terminalKeys is the pump's key normalization. Only the pump's reader
27+// goroutine calls normalize; enable may run on any goroutine.
28+type terminalKeys struct {
29+ enabled atomic.Bool
30+ normalizer kittykey.Normalizer
31+ ctrl *terminalCtrlState
32+}
33+
34+func newTerminalKeys() *terminalKeys {
35+ return &terminalKeys{ctrl: newTerminalCtrlState()}
36+}
37+
38+// enable starts normalizing. Call it before asking the terminal for
39+// kittykey.RequestFlags; input still in the old encoding passes unchanged.
40+func (k *terminalKeys) enable() { k.enabled.Store(true) }
41+
42+// normalize rewrites one read. focus is the terminal focus after the read:
43+// a window that lost focus may never report the Ctrl release.
44+func (k *terminalKeys) normalize(data []byte, focus domain.TerminalFocus) []byte {
45+ if !k.enabled.Load() {
46+ return data
47+ }
48+ out := k.normalizer.Normalize(data)
49+ if focus == domain.TerminalFocusUnfocused {
50+ k.normalizer.ReleaseCtrl()
51+ }
52+ k.ctrl.set(k.normalizer.CtrlHeld())
53+ return out
54+}
55+
56+// terminalCtrlState is the client's one held-Ctrl state. The terminal input
57+// pump is its only writer; it outlives attachments.
58+type terminalCtrlState struct {
59+ mu sync.Mutex
60+ held bool
61+ changed chan struct{}
62+}
63+
64+func newTerminalCtrlState() *terminalCtrlState {
65+ return &terminalCtrlState{changed: make(chan struct{})}
66+}
67+
68+// Watch returns whether Ctrl is held and a channel closed on the next change.
69+// A nil state is never held and never changes.
70+func (s *terminalCtrlState) Watch() (bool, <-chan struct{}) {
71+ if s == nil {
72+ return false, nil
73+ }
74+ s.mu.Lock()
75+ defer s.mu.Unlock()
76+ return s.held, s.changed
77+}
78+
79+func (s *terminalCtrlState) set(held bool) {
80+ s.mu.Lock()
81+ defer s.mu.Unlock()
82+ if held == s.held {
83+ return
84+ }
85+ s.held = held
86+ close(s.changed)
87+ s.changed = make(chan struct{})
88+}
89+
90+// attachmentCtrlForeground is the optional seam through which an attached
91+// loop reads the held-Ctrl state.
92+type attachmentCtrlForeground interface {
93+ terminalCtrl() *terminalCtrlState
94+}
95+
96+func (f *attachmentForeground) terminalCtrl() *terminalCtrlState {
97+ if f == nil || f.input == nil {
98+ return nil
99+ }
100+ return f.input.keys.ctrl
101+}
102+
103+func attachmentTerminalCtrl(fg AttachmentForeground) *terminalCtrlState {
104+ if ctrl, ok := fg.(attachmentCtrlForeground); ok {
105+ return ctrl.terminalCtrl()
106+ }
107+ return nil
108+}
109+
110+// ctrlReporter turns the held-Ctrl state into ModifierState messages: a hold
111+// is reported once it lasted ctrlHoldDelay, a release at once, and only after
112+// a reported hold.
113+type ctrlReporter struct {
114+ state *terminalCtrlState
115+ clock ports.Clock
116+ sent bool
117+ changed <-chan struct{}
118+ timer ports.Timer
119+}
120+
121+func newCtrlReporter(state *terminalCtrlState, clock ports.Clock) *ctrlReporter {
122+ r := &ctrlReporter{state: state, clock: clock}
123+ r.next()
124+ return r
125+}
126+
127+// timerC fires when a pending hold is due.
128+func (r *ctrlReporter) timerC() <-chan time.Time {
129+ if r.timer == nil {
130+ return nil
131+ }
132+ return r.timer.C()
133+}
134+
135+// next handles a state change and returns the message to send now, if any.
136+func (r *ctrlReporter) next() (protocol.ModifierState, bool) {
137+ held, changed := r.state.Watch()
138+ r.changed = changed
139+ if held {
140+ if !r.sent && r.timer == nil {
141+ r.timer = r.clock.NewTimer(ctrlHoldDelay)
142+ }
143+ return protocol.ModifierState{}, false
144+ }
145+ r.stop()
146+ if !r.sent {
147+ return protocol.ModifierState{}, false
148+ }
149+ r.sent = false
150+ return protocol.ModifierState{Ctrl: false}, true
151+}
152+
153+// due handles the hold timer and returns the hold to report, if still held.
154+func (r *ctrlReporter) due() (protocol.ModifierState, bool) {
155+ r.stop()
156+ if held, _ := r.state.Watch(); !held || r.sent {
157+ return protocol.ModifierState{}, false
158+ }
159+ r.sent = true
160+ return protocol.ModifierState{Ctrl: true}, true
161+}
162+
163+func (r *ctrlReporter) stop() {
164+ if r.timer != nil {
165+ r.timer.Stop()
166+ r.timer = nil
167+ }
168+}
@@ -0,0 +1,168 @@
1+package client
2+
3+import (
4+ "sync"
5+ "sync/atomic"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/usecase/keys/kittykey"
12+)
13+
14+// Held Ctrl.
15+//
16+// With the kitty keyboard protocol the client asks its terminal for every key
17+// event, modifier presses and releases included (kittykey.RequestFlags). The
18+// terminal input pump rewrites that stream back to the encoding the rest of
19+// vev reads (kittykey.OuterFlags) and records whether Ctrl is held on its own
20+// in a terminalCtrlState. The attached loop reports a hold that lasted
21+// ctrlHoldDelay to the daemon, which then shows recent-session ranks.
22+
23+// ctrlHoldDelay keeps a quick Ctrl shortcut from flashing the ranks.
24+const ctrlHoldDelay = 150 * time.Millisecond
25+
26+// terminalKeys is the pump's key normalization. Only the pump's reader
27+// goroutine calls normalize; enable may run on any goroutine.
28+type terminalKeys struct {
29+ enabled atomic.Bool
30+ normalizer kittykey.Normalizer
31+ ctrl *terminalCtrlState
32+}
33+
34+func newTerminalKeys() *terminalKeys {
35+ return &terminalKeys{ctrl: newTerminalCtrlState()}
36+}
37+
38+// enable starts normalizing. Call it before asking the terminal for
39+// kittykey.RequestFlags; input still in the old encoding passes unchanged.
40+func (k *terminalKeys) enable() { k.enabled.Store(true) }
41+
42+// normalize rewrites one read. focus is the terminal focus after the read:
43+// a window that lost focus may never report the Ctrl release.
44+func (k *terminalKeys) normalize(data []byte, focus domain.TerminalFocus) []byte {
45+ if !k.enabled.Load() {
46+ return data
47+ }
48+ out := k.normalizer.Normalize(data)
49+ if focus == domain.TerminalFocusUnfocused {
50+ k.normalizer.ReleaseCtrl()
51+ }
52+ k.ctrl.set(k.normalizer.CtrlHeld())
53+ return out
54+}
55+
56+// terminalCtrlState is the client's one held-Ctrl state. The terminal input
57+// pump is its only writer; it outlives attachments.
58+type terminalCtrlState struct {
59+ mu sync.Mutex
60+ held bool
61+ changed chan struct{}
62+}
63+
64+func newTerminalCtrlState() *terminalCtrlState {
65+ return &terminalCtrlState{changed: make(chan struct{})}
66+}
67+
68+// Watch returns whether Ctrl is held and a channel closed on the next change.
69+// A nil state is never held and never changes.
70+func (s *terminalCtrlState) Watch() (bool, <-chan struct{}) {
71+ if s == nil {
72+ return false, nil
73+ }
74+ s.mu.Lock()
75+ defer s.mu.Unlock()
76+ return s.held, s.changed
77+}
78+
79+func (s *terminalCtrlState) set(held bool) {
80+ s.mu.Lock()
81+ defer s.mu.Unlock()
82+ if held == s.held {
83+ return
84+ }
85+ s.held = held
86+ close(s.changed)
87+ s.changed = make(chan struct{})
88+}
89+
90+// attachmentCtrlForeground is the optional seam through which an attached
91+// loop reads the held-Ctrl state.
92+type attachmentCtrlForeground interface {
93+ terminalCtrl() *terminalCtrlState
94+}
95+
96+func (f *attachmentForeground) terminalCtrl() *terminalCtrlState {
97+ if f == nil || f.input == nil {
98+ return nil
99+ }
100+ return f.input.keys.ctrl
101+}
102+
103+func attachmentTerminalCtrl(fg AttachmentForeground) *terminalCtrlState {
104+ if ctrl, ok := fg.(attachmentCtrlForeground); ok {
105+ return ctrl.terminalCtrl()
106+ }
107+ return nil
108+}
109+
110+// ctrlReporter turns the held-Ctrl state into ModifierState messages: a hold
111+// is reported once it lasted ctrlHoldDelay, a release at once, and only after
112+// a reported hold.
113+type ctrlReporter struct {
114+ state *terminalCtrlState
115+ clock ports.Clock
116+ sent bool
117+ changed <-chan struct{}
118+ timer ports.Timer
119+}
120+
121+func newCtrlReporter(state *terminalCtrlState, clock ports.Clock) *ctrlReporter {
122+ r := &ctrlReporter{state: state, clock: clock}
123+ r.next()
124+ return r
125+}
126+
127+// timerC fires when a pending hold is due.
128+func (r *ctrlReporter) timerC() <-chan time.Time {
129+ if r.timer == nil {
130+ return nil
131+ }
132+ return r.timer.C()
133+}
134+
135+// next handles a state change and returns the message to send now, if any.
136+func (r *ctrlReporter) next() (protocol.ModifierState, bool) {
137+ held, changed := r.state.Watch()
138+ r.changed = changed
139+ if held {
140+ if !r.sent && r.timer == nil {
141+ r.timer = r.clock.NewTimer(ctrlHoldDelay)
142+ }
143+ return protocol.ModifierState{}, false
144+ }
145+ r.stop()
146+ if !r.sent {
147+ return protocol.ModifierState{}, false
148+ }
149+ r.sent = false
150+ return protocol.ModifierState{Ctrl: false}, true
151+}
152+
153+// due handles the hold timer and returns the hold to report, if still held.
154+func (r *ctrlReporter) due() (protocol.ModifierState, bool) {
155+ r.stop()
156+ if held, _ := r.state.Watch(); !held || r.sent {
157+ return protocol.ModifierState{}, false
158+ }
159+ r.sent = true
160+ return protocol.ModifierState{Ctrl: true}, true
161+}
162+
163+func (r *ctrlReporter) stop() {
164+ if r.timer != nil {
165+ r.timer.Stop()
166+ r.timer = nil
167+ }
168+}
A internal/usecase/client/terminal_keys_test.go +152 -0
@@ -0,0 +1,152 @@
1+package client
2+
3+import (
4+ "context"
5+ "io"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/protocol"
13+)
14+
15+func TestTerminalKeysNormalizeOnlyOnceEnabled(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ enabled bool
19+ focus domain.TerminalFocus
20+ in string
21+ want string
22+ wantHeld bool
23+ }{
24+ {name: "disabled passes through", in: "\x1b[57442;5u", want: "\x1b[57442;5u"},
25+ {name: "enabled drops ctrl press", enabled: true, in: "\x1b[57442;5u", want: "", wantHeld: true},
26+ {name: "enabled rewrites text", enabled: true, in: "\x1b[97;;97u", want: "a"},
27+ {name: "lost focus forgets ctrl", enabled: true, focus: domain.TerminalFocusUnfocused, in: "\x1b[57442;5u", want: ""},
28+ }
29+ for _, tt := range tests {
30+ t.Run(tt.name, func(t *testing.T) {
31+ keys := newTerminalKeys()
32+ if tt.enabled {
33+ keys.enable()
34+ }
35+ require.Equal(t, tt.want, string(keys.normalize([]byte(tt.in), tt.focus)))
36+ held, _ := keys.ctrl.Watch()
37+ require.Equal(t, tt.wantHeld, held)
38+ })
39+ }
40+}
41+
42+func TestCtrlReporterReportsHoldsAfterDelayAndReleasesAtOnce(t *testing.T) {
43+ type step struct {
44+ held bool // set the state, then handle the change
45+ fire bool // instead, fire the hold timer
46+ want *protocol.ModifierState
47+ }
48+ held, released := &protocol.ModifierState{Ctrl: true}, &protocol.ModifierState{}
49+ tests := []struct {
50+ name string
51+ steps []step
52+ }{
53+ {name: "hold then release", steps: []step{{held: true}, {fire: true, want: held}, {held: false, want: released}}},
54+ {name: "quick tap sends nothing", steps: []step{{held: true}, {held: false}, {fire: true}}},
55+ {name: "hold reported once", steps: []step{{held: true}, {fire: true, want: held}, {fire: true}}},
56+ }
57+ for _, tt := range tests {
58+ t.Run(tt.name, func(t *testing.T) {
59+ state := newTerminalCtrlState()
60+ clock := newSupervisorTestClock()
61+ r := newCtrlReporter(state, clock)
62+ var timer *supervisorTestTimer
63+ for i, s := range tt.steps {
64+ var message protocol.ModifierState
65+ var ok bool
66+ if s.fire {
67+ if r.timerC() == nil {
68+ require.Nil(t, s.want, "step %d: no timer to fire", i)
69+ continue
70+ }
71+ timer.ch <- time.Time{}
72+ <-r.timerC()
73+ message, ok = r.due()
74+ } else {
75+ state.set(s.held)
76+ <-r.changed
77+ message, ok = r.next()
78+ if s.held {
79+ timer = clock.awaitTimer(t)
80+ require.Equal(t, ctrlHoldDelay, timer.delay)
81+ }
82+ }
83+ require.Equal(t, s.want != nil, ok, "step %d", i)
84+ if s.want != nil {
85+ require.Equal(t, *s.want, message, "step %d", i)
86+ }
87+ }
88+ r.stop()
89+ require.True(t, clock.allStopped())
90+ })
91+ }
92+}
93+
94+// TestAttachmentReportsHeldCtrl drives a real pump: once kitty key events are
95+// enabled, a held Ctrl reaches the daemon as ModifierState and its release
96+// follows, while the dropped modifier events never reach the session as input.
97+func TestAttachmentReportsHeldCtrl(t *testing.T) {
98+ pr, pw := io.Pipe()
99+ defer pw.Close()
100+ pump := newTerminalInputPump(pr)
101+ pump.keys.enable()
102+ pump.start()
103+ defer pump.stop()
104+ stream := newSessionTestStream()
105+ host := newWorkerTestHost(newWorkerTestTerminal(), pump, nil)
106+ cfg := sessionTestWorkerConfig(sessionTestRequest(true))
107+ clock := newSupervisorTestClock()
108+ cfg.Clock = clock
109+ worker, err := newSessionAttachmentWorker(cfg)
110+ require.NoError(t, err)
111+ ctx, cancel := context.WithCancel(context.Background())
112+ defer cancel()
113+ go func() { _, _ = host.Run(ctx, AttachmentToken{Generation: 4, Attempt: 1}, worker, stream) }()
114+
115+ awaitHello(t, stream)
116+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
117+ stream.deliver(sessionTestOutput(1, "\x1b[Hready"))
118+
119+ sent := func(want protocol.ModifierState) func() bool {
120+ return func() bool {
121+ for _, message := range stream.messages() {
122+ if message == want {
123+ return true
124+ }
125+ }
126+ return false
127+ }
128+ }
129+ _, err = pw.Write([]byte("\x1b[57442;5u"))
130+ require.NoError(t, err)
131+ var timer *supervisorTestTimer
132+ require.Eventually(t, func() bool {
133+ select {
134+ case created := <-clock.created:
135+ if created.delay == ctrlHoldDelay {
136+ timer = created
137+ }
138+ default:
139+ }
140+ return timer != nil
141+ }, 5*time.Second, time.Millisecond, "a Ctrl press arms the hold delay")
142+ timer.ch <- time.Time{}
143+ require.Eventually(t, sent(protocol.ModifierState{Ctrl: true}), 5*time.Second, time.Millisecond)
144+
145+ _, err = pw.Write([]byte("\x1b[57442;1:3u"))
146+ require.NoError(t, err)
147+ require.Eventually(t, sent(protocol.ModifierState{}), 5*time.Second, time.Millisecond)
148+ for _, message := range stream.messages() {
149+ _, isInput := message.(protocol.Input)
150+ require.False(t, isInput, "modifier events are never session input")
151+ }
152+}
@@ -0,0 +1,152 @@
1+package client
2+
3+import (
4+ "context"
5+ "io"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/protocol"
13+)
14+
15+func TestTerminalKeysNormalizeOnlyOnceEnabled(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ enabled bool
19+ focus domain.TerminalFocus
20+ in string
21+ want string
22+ wantHeld bool
23+ }{
24+ {name: "disabled passes through", in: "\x1b[57442;5u", want: "\x1b[57442;5u"},
25+ {name: "enabled drops ctrl press", enabled: true, in: "\x1b[57442;5u", want: "", wantHeld: true},
26+ {name: "enabled rewrites text", enabled: true, in: "\x1b[97;;97u", want: "a"},
27+ {name: "lost focus forgets ctrl", enabled: true, focus: domain.TerminalFocusUnfocused, in: "\x1b[57442;5u", want: ""},
28+ }
29+ for _, tt := range tests {
30+ t.Run(tt.name, func(t *testing.T) {
31+ keys := newTerminalKeys()
32+ if tt.enabled {
33+ keys.enable()
34+ }
35+ require.Equal(t, tt.want, string(keys.normalize([]byte(tt.in), tt.focus)))
36+ held, _ := keys.ctrl.Watch()
37+ require.Equal(t, tt.wantHeld, held)
38+ })
39+ }
40+}
41+
42+func TestCtrlReporterReportsHoldsAfterDelayAndReleasesAtOnce(t *testing.T) {
43+ type step struct {
44+ held bool // set the state, then handle the change
45+ fire bool // instead, fire the hold timer
46+ want *protocol.ModifierState
47+ }
48+ held, released := &protocol.ModifierState{Ctrl: true}, &protocol.ModifierState{}
49+ tests := []struct {
50+ name string
51+ steps []step
52+ }{
53+ {name: "hold then release", steps: []step{{held: true}, {fire: true, want: held}, {held: false, want: released}}},
54+ {name: "quick tap sends nothing", steps: []step{{held: true}, {held: false}, {fire: true}}},
55+ {name: "hold reported once", steps: []step{{held: true}, {fire: true, want: held}, {fire: true}}},
56+ }
57+ for _, tt := range tests {
58+ t.Run(tt.name, func(t *testing.T) {
59+ state := newTerminalCtrlState()
60+ clock := newSupervisorTestClock()
61+ r := newCtrlReporter(state, clock)
62+ var timer *supervisorTestTimer
63+ for i, s := range tt.steps {
64+ var message protocol.ModifierState
65+ var ok bool
66+ if s.fire {
67+ if r.timerC() == nil {
68+ require.Nil(t, s.want, "step %d: no timer to fire", i)
69+ continue
70+ }
71+ timer.ch <- time.Time{}
72+ <-r.timerC()
73+ message, ok = r.due()
74+ } else {
75+ state.set(s.held)
76+ <-r.changed
77+ message, ok = r.next()
78+ if s.held {
79+ timer = clock.awaitTimer(t)
80+ require.Equal(t, ctrlHoldDelay, timer.delay)
81+ }
82+ }
83+ require.Equal(t, s.want != nil, ok, "step %d", i)
84+ if s.want != nil {
85+ require.Equal(t, *s.want, message, "step %d", i)
86+ }
87+ }
88+ r.stop()
89+ require.True(t, clock.allStopped())
90+ })
91+ }
92+}
93+
94+// TestAttachmentReportsHeldCtrl drives a real pump: once kitty key events are
95+// enabled, a held Ctrl reaches the daemon as ModifierState and its release
96+// follows, while the dropped modifier events never reach the session as input.
97+func TestAttachmentReportsHeldCtrl(t *testing.T) {
98+ pr, pw := io.Pipe()
99+ defer pw.Close()
100+ pump := newTerminalInputPump(pr)
101+ pump.keys.enable()
102+ pump.start()
103+ defer pump.stop()
104+ stream := newSessionTestStream()
105+ host := newWorkerTestHost(newWorkerTestTerminal(), pump, nil)
106+ cfg := sessionTestWorkerConfig(sessionTestRequest(true))
107+ clock := newSupervisorTestClock()
108+ cfg.Clock = clock
109+ worker, err := newSessionAttachmentWorker(cfg)
110+ require.NoError(t, err)
111+ ctx, cancel := context.WithCancel(context.Background())
112+ defer cancel()
113+ go func() { _, _ = host.Run(ctx, AttachmentToken{Generation: 4, Attempt: 1}, worker, stream) }()
114+
115+ awaitHello(t, stream)
116+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
117+ stream.deliver(sessionTestOutput(1, "\x1b[Hready"))
118+
119+ sent := func(want protocol.ModifierState) func() bool {
120+ return func() bool {
121+ for _, message := range stream.messages() {
122+ if message == want {
123+ return true
124+ }
125+ }
126+ return false
127+ }
128+ }
129+ _, err = pw.Write([]byte("\x1b[57442;5u"))
130+ require.NoError(t, err)
131+ var timer *supervisorTestTimer
132+ require.Eventually(t, func() bool {
133+ select {
134+ case created := <-clock.created:
135+ if created.delay == ctrlHoldDelay {
136+ timer = created
137+ }
138+ default:
139+ }
140+ return timer != nil
141+ }, 5*time.Second, time.Millisecond, "a Ctrl press arms the hold delay")
142+ timer.ch <- time.Time{}
143+ require.Eventually(t, sent(protocol.ModifierState{Ctrl: true}), 5*time.Second, time.Millisecond)
144+
145+ _, err = pw.Write([]byte("\x1b[57442;1:3u"))
146+ require.NoError(t, err)
147+ require.Eventually(t, sent(protocol.ModifierState{}), 5*time.Second, time.Millisecond)
148+ for _, message := range stream.messages() {
149+ _, isInput := message.(protocol.Input)
150+ require.False(t, isInput, "modifier events are never session input")
151+ }
152+}
M internal/usecase/daemon/client.go +3 -0
@@ -112,6 +112,9 @@ type attachedClient struct {
112112 // atomic so any daemon path reads it without taking a lock; see
113113 // terminalFocus.
114114 focus atomic.Uint32
115+ // ctrlHeld is whether the client last reported Ctrl held on its own; the
116+ // status bar then shows recent-session ranks.
117+ ctrlHeld atomic.Bool
115118 // clientPID is the local client process from Hello, 0 when remote. It is
116119 // atomic because a resume replaces it while bar scripts read it.
117120 clientPID atomic.Uint32
@@ -112,6 +112,9 @@ type attachedClient struct {
112 // atomic so any daemon path reads it without taking a lock; see112 // atomic so any daemon path reads it without taking a lock; see
113 // terminalFocus.113 // terminalFocus.
114 focus atomic.Uint32114 focus atomic.Uint32
115+ // ctrlHeld is whether the client last reported Ctrl held on its own; the
116+ // status bar then shows recent-session ranks.
117+ ctrlHeld atomic.Bool
115 // clientPID is the local client process from Hello, 0 when remote. It is118 // clientPID is the local client process from Hello, 0 when remote. It is
116 // atomic because a resume replaces it while bar scripts read it.119 // atomic because a resume replaces it while bar scripts read it.
117 clientPID atomic.Uint32120 clientPID atomic.Uint32
M internal/usecase/daemon/client_frame_routing.go +2 -0
@@ -188,6 +188,8 @@ func (d *Daemon) handleActiveAttachmentClientMessage(capability attachmentCapabi
188188 d.selectTabForAttachment(effect, message)
189189 case protocol.TerminalFocus:
190190 d.applyTerminalFocusForAttachment(effect, message)
191+ case protocol.ModifierState:
192+ d.applyModifierStateForAttachment(effect, message)
191193 case protocol.RecentRouteSnapshot:
192194 replayIdentity := effect.ac.setRouteSnapshot(message)
193195 d.pokeAttentionTicker()
@@ -188,6 +188,8 @@ func (d *Daemon) handleActiveAttachmentClientMessage(capability attachmentCapabi
188 d.selectTabForAttachment(effect, message)188 d.selectTabForAttachment(effect, message)
189 case protocol.TerminalFocus:189 case protocol.TerminalFocus:
190 d.applyTerminalFocusForAttachment(effect, message)190 d.applyTerminalFocusForAttachment(effect, message)
191+ case protocol.ModifierState:
192+ d.applyModifierStateForAttachment(effect, message)
191 case protocol.RecentRouteSnapshot:193 case protocol.RecentRouteSnapshot:
192 replayIdentity := effect.ac.setRouteSnapshot(message)194 replayIdentity := effect.ac.setRouteSnapshot(message)
193 d.pokeAttentionTicker()195 d.pokeAttentionTicker()
A internal/usecase/daemon/modifier_state.go +21 -0
@@ -0,0 +1,21 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/protocol"
4+
5+// Held modifiers.
6+//
7+// A client whose terminal reports modifier key events tells the daemon when
8+// Ctrl is held on its own. While it is, the attachment's status bar prefixes
9+// each recent session with the rank Ctrl+1..9 jumps to.
10+
11+// applyModifierStateForAttachment records a reported Ctrl state and repaints
12+// the attachment's bar when it changed.
13+func (d *Daemon) applyModifierStateForAttachment(effect *attachmentEffect, message protocol.ModifierState) {
14+ if effect == nil || effect.sess == nil || effect.ac == nil {
15+ return
16+ }
17+ if effect.ac.ctrlHeld.Swap(message.Ctrl) == message.Ctrl {
18+ return
19+ }
20+ d.invalidateRender(effect.sess, effect.ac, false, "modifier_state.go")
21+}
@@ -0,0 +1,21 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/protocol"
4+
5+// Held modifiers.
6+//
7+// A client whose terminal reports modifier key events tells the daemon when
8+// Ctrl is held on its own. While it is, the attachment's status bar prefixes
9+// each recent session with the rank Ctrl+1..9 jumps to.
10+
11+// applyModifierStateForAttachment records a reported Ctrl state and repaints
12+// the attachment's bar when it changed.
13+func (d *Daemon) applyModifierStateForAttachment(effect *attachmentEffect, message protocol.ModifierState) {
14+ if effect == nil || effect.sess == nil || effect.ac == nil {
15+ return
16+ }
17+ if effect.ac.ctrlHeld.Swap(message.Ctrl) == message.Ctrl {
18+ return
19+ }
20+ d.invalidateRender(effect.sess, effect.ac, false, "modifier_state.go")
21+}
A internal/usecase/daemon/modifier_state_test.go +51 -0
@@ -0,0 +1,51 @@
1+package daemon
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ renderer "github.com/bnema/vev-vt"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/protocol"
11+)
12+
13+// TestHeldCtrlShowsRecentSessionRanks pins that a reported Ctrl hold prefixes
14+// the attachment's recent sessions with their Ctrl+N ranks, and its release
15+// removes them.
16+func TestHeldCtrlShowsRecentSessionRanks(t *testing.T) {
17+ tests := []struct {
18+ name string
19+ ctrl bool
20+ ranks bool
21+ }{
22+ {name: "held shows ranks", ctrl: true, ranks: true},
23+ {name: "released hides ranks", ctrl: false, ranks: false},
24+ }
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ d, sess, ac, _, releases := newManualTabSession(t, 1)
28+ defer releases[0]()
29+ ac.setRouteSnapshot(testRecentRouteSnapshot())
30+ effect := beginRecentRoutePaletteEffect(t, d, sess, ac)
31+ if !tt.ctrl {
32+ ac.ctrlHeld.Store(true)
33+ }
34+
35+ d.applyModifierStateForAttachment(effect, protocol.ModifierState{Ctrl: tt.ctrl})
36+
37+ require.Equal(t, tt.ctrl, ac.ctrlHeld.Load())
38+ bars := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, protocol.RecentRouteSnapshot{})
39+ require.Equal(t, tt.ranks, bars.showRanks)
40+ row := renderTestStatusRow(bars)
41+ require.Equal(t, tt.ranks, strings.Contains(row, "1:recent"), row)
42+ require.Equal(t, tt.ranks, strings.Contains(row, "2:older"), row)
43+ })
44+ }
45+}
46+
47+func renderTestStatusRow(state barState) string {
48+ row := make([]renderer.Cell, 80)
49+ drawStatusBarState(row, state, resolveStyles(nil))
50+ return rowText(row)
51+}
@@ -0,0 +1,51 @@
1+package daemon
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ renderer "github.com/bnema/vev-vt"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/protocol"
11+)
12+
13+// TestHeldCtrlShowsRecentSessionRanks pins that a reported Ctrl hold prefixes
14+// the attachment's recent sessions with their Ctrl+N ranks, and its release
15+// removes them.
16+func TestHeldCtrlShowsRecentSessionRanks(t *testing.T) {
17+ tests := []struct {
18+ name string
19+ ctrl bool
20+ ranks bool
21+ }{
22+ {name: "held shows ranks", ctrl: true, ranks: true},
23+ {name: "released hides ranks", ctrl: false, ranks: false},
24+ }
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ d, sess, ac, _, releases := newManualTabSession(t, 1)
28+ defer releases[0]()
29+ ac.setRouteSnapshot(testRecentRouteSnapshot())
30+ effect := beginRecentRoutePaletteEffect(t, d, sess, ac)
31+ if !tt.ctrl {
32+ ac.ctrlHeld.Store(true)
33+ }
34+
35+ d.applyModifierStateForAttachment(effect, protocol.ModifierState{Ctrl: tt.ctrl})
36+
37+ require.Equal(t, tt.ctrl, ac.ctrlHeld.Load())
38+ bars := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, protocol.RecentRouteSnapshot{})
39+ require.Equal(t, tt.ranks, bars.showRanks)
40+ row := renderTestStatusRow(bars)
41+ require.Equal(t, tt.ranks, strings.Contains(row, "1:recent"), row)
42+ require.Equal(t, tt.ranks, strings.Contains(row, "2:older"), row)
43+ })
44+ }
45+}
46+
47+func renderTestStatusRow(state barState) string {
48+ row := make([]renderer.Cell, 80)
49+ drawStatusBarState(row, state, resolveStyles(nil))
50+ return rowText(row)
51+}
M internal/usecase/daemon/recent_route_presentation_test.go +1 -1
@@ -146,7 +146,7 @@ func TestFitRecentRouteEntriesKeepsWholeWideAndAttentionEntries(t *testing.T) {
146146
147147 for _, tt := range tests {
148148 t.Run(tt.name, func(t *testing.T) {
149- got := fitMRU(tt.entries, tt.width, 0, "copy")
149+ got := fitMRU(tt.entries, tt.width, 0, "copy", false)
150150 names := make([]string, len(got))
151151 for i, entry := range got {
152152 names[i] = entry.name
@@ -146,7 +146,7 @@ func TestFitRecentRouteEntriesKeepsWholeWideAndAttentionEntries(t *testing.T) {
146 146
147 for _, tt := range tests {147 for _, tt := range tests {
148 t.Run(tt.name, func(t *testing.T) {148 t.Run(tt.name, func(t *testing.T) {
149- got := fitMRU(tt.entries, tt.width, 0, "copy")149+ got := fitMRU(tt.entries, tt.width, 0, "copy", false)
150 names := make([]string, len(got))150 names := make([]string, len(got))
151 for i, entry := range got {151 for i, entry := range got {
152 names[i] = entry.name152 names[i] = entry.name
M internal/usecase/daemon/resume.go +2 -0
@@ -636,6 +636,8 @@ func (d *Daemon) resumeParkedLocked(h protocol.Hello, tr ports.ServerConnection,
636636 d.reconfigureAttachmentOutput(sess, ac, h)
637637 // The replacement terminal's focus, too, is declared before first paint.
638638 ac.setTerminalFocus(h.TerminalFocus)
639+ // The replacement attachment reports a held Ctrl afresh.
640+ ac.ctrlHeld.Store(false)
639641 ac.clientPID.Store(h.ClientPID)
640642 ac.setClientEnvironment(helloClientEnvironment(h))
641643 geometry := h.Geometry()
@@ -636,6 +636,8 @@ func (d *Daemon) resumeParkedLocked(h protocol.Hello, tr ports.ServerConnection,
636 d.reconfigureAttachmentOutput(sess, ac, h)636 d.reconfigureAttachmentOutput(sess, ac, h)
637 // The replacement terminal's focus, too, is declared before first paint.637 // The replacement terminal's focus, too, is declared before first paint.
638 ac.setTerminalFocus(h.TerminalFocus)638 ac.setTerminalFocus(h.TerminalFocus)
639+ // The replacement attachment reports a held Ctrl afresh.
640+ ac.ctrlHeld.Store(false)
639 ac.clientPID.Store(h.ClientPID)641 ac.clientPID.Store(h.ClientPID)
640 ac.setClientEnvironment(helloClientEnvironment(h))642 ac.setClientEnvironment(helloClientEnvironment(h))
641 geometry := h.Geometry()643 geometry := h.Geometry()
M internal/usecase/daemon/status.go +29 -8
@@ -89,9 +89,13 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styl
8989 drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced)
9090 }
9191 } else {
92- fittedMRU := fitMRU(state.mru, len(row), x, rightText)
92+ fittedMRU := fitMRU(state.mru, len(row), x, rightText, state.showRanks)
9393 for i, sess := range fittedMRU {
9494 style := styles.MRUStyle(i, len(fittedMRU))
95+ if state.showRanks {
96+ drawRankedStatusSessionEntry(row, &x, rankedRecent{rank: i + 1, name: sess.name, attention: sess.attention}, style, state.attentionFrame, styles.Reduced)
97+ continue
98+ }
9599 drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced)
96100 }
97101 }
@@ -169,6 +173,9 @@ type barState struct {
169173 statusFeedback string
170174 mru []recentRouteDisplay
171175 rankedRecent []rankedRecent
176+ // showRanks prefixes each mru entry with its Ctrl+N rank while the
177+ // client holds Ctrl.
178+ showRanks bool
172179 attentionFrame int
173180 // theme is the client's terminal theme, if reported. Its zero value
174181 // (Theme{}, Known: false) is a valid "no theme" default that resolves to
@@ -304,6 +311,7 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached
304311 state.rankedRecent = ranked
305312 } else if routeSnapshot.Generation != 0 {
306313 state.mru = formatRecentRouteSnapshot(historyRouteSnapshot(routeSnapshot))
314+ state.showRanks = ac != nil && ac.ctrlHeld.Load()
307315 }
308316 return state
309317 }
@@ -416,13 +424,13 @@ func recentRouteDisplayWidth(entry recentRouteDisplay) int {
416424 return width
417425 }
418426
419-func fitWholeRecentEntries[T any](entries []T, budget int, cost func(T) int) []T {
427+func fitWholeRecentEntries[T any](entries []T, budget int, cost func(int, T) int) []T {
420428 if budget <= 0 || len(entries) == 0 {
421429 return nil
422430 }
423431 used := 0
424432 for i, entry := range entries {
425- used += cost(entry)
433+ used += cost(i, entry)
426434 if used > budget {
427435 return entries[:i]
428436 }
@@ -436,17 +444,30 @@ func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback stri
436444 reserve = statusTextWidth(feedback) + 2
437445 }
438446 budget := rowLen - leftUsed - reserve
439- return fitWholeRecentEntries(entries, budget, func(entry rankedRecent) int {
447+ return fitWholeRecentEntries(entries, budget, func(_ int, entry rankedRecent) int {
440448 return recentRouteDisplayWidth(recentRouteDisplay{
441449 name: entry.name,
442450 kind: entry.kind,
443451 ephemeral: entry.ephemeral,
444452 attention: entry.attention,
445- }) + statusTextWidth(strconv.Itoa(entry.rank)+":")
453+ }) + rankPrefixWidth(entry.rank)
446454 })
447455 }
448456
449-func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string) []recentRouteDisplay {
457+func rankPrefixWidth(rank int) int {
458+ return statusTextWidth(strconv.Itoa(rank) + ":")
459+}
460+
461+// fitMRU keeps the leading entries that fit. ranked adds each entry's "N:"
462+// rank prefix to its width.
463+func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string, ranked bool) []recentRouteDisplay {
464+ cost := func(i int, entry recentRouteDisplay) int {
465+ width := recentRouteDisplayWidth(entry)
466+ if ranked {
467+ width += rankPrefixWidth(i + 1)
468+ }
469+ return width
470+ }
450471 // With no feedback, keep one blank trailing cell; with feedback, reserve
451472 // its " text" width plus a one-cell gap so drawRightPlainText always fits.
452473 copyReserve := 1
@@ -463,11 +484,11 @@ func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string)
463484 budget -= mruFutureRightReserve(rowLen)
464485 // Keep at least one recent session when it physically fits; the reserved
465486 // right side is only a budget preference, not a reason to hide all recents.
466- if firstCost := recentRouteDisplayWidth(entries[0]); firstCost <= physicalBudget && budget < firstCost {
487+ if firstCost := cost(0, entries[0]); firstCost <= physicalBudget && budget < firstCost {
467488 budget = firstCost
468489 }
469490 }
470- return fitWholeRecentEntries(entries, budget, recentRouteDisplayWidth)
491+ return fitWholeRecentEntries(entries, budget, cost)
471492 }
472493
473494 const (
@@ -89,9 +89,13 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styl
89 drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced)89 drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced)
90 }90 }
91 } else {91 } else {
92- fittedMRU := fitMRU(state.mru, len(row), x, rightText)92+ fittedMRU := fitMRU(state.mru, len(row), x, rightText, state.showRanks)
93 for i, sess := range fittedMRU {93 for i, sess := range fittedMRU {
94 style := styles.MRUStyle(i, len(fittedMRU))94 style := styles.MRUStyle(i, len(fittedMRU))
95+ if state.showRanks {
96+ drawRankedStatusSessionEntry(row, &x, rankedRecent{rank: i + 1, name: sess.name, attention: sess.attention}, style, state.attentionFrame, styles.Reduced)
97+ continue
98+ }
95 drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced)99 drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced)
96 }100 }
97 }101 }
@@ -169,6 +173,9 @@ type barState struct {
169 statusFeedback string173 statusFeedback string
170 mru []recentRouteDisplay174 mru []recentRouteDisplay
171 rankedRecent []rankedRecent175 rankedRecent []rankedRecent
176+ // showRanks prefixes each mru entry with its Ctrl+N rank while the
177+ // client holds Ctrl.
178+ showRanks bool
172 attentionFrame int179 attentionFrame int
173 // theme is the client's terminal theme, if reported. Its zero value180 // theme is the client's terminal theme, if reported. Its zero value
174 // (Theme{}, Known: false) is a valid "no theme" default that resolves to181 // (Theme{}, Known: false) is a valid "no theme" default that resolves to
@@ -304,6 +311,7 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached
304 state.rankedRecent = ranked311 state.rankedRecent = ranked
305 } else if routeSnapshot.Generation != 0 {312 } else if routeSnapshot.Generation != 0 {
306 state.mru = formatRecentRouteSnapshot(historyRouteSnapshot(routeSnapshot))313 state.mru = formatRecentRouteSnapshot(historyRouteSnapshot(routeSnapshot))
314+ state.showRanks = ac != nil && ac.ctrlHeld.Load()
307 }315 }
308 return state316 return state
309 }317 }
@@ -416,13 +424,13 @@ func recentRouteDisplayWidth(entry recentRouteDisplay) int {
416 return width424 return width
417 }425 }
418 426
419-func fitWholeRecentEntries[T any](entries []T, budget int, cost func(T) int) []T {427+func fitWholeRecentEntries[T any](entries []T, budget int, cost func(int, T) int) []T {
420 if budget <= 0 || len(entries) == 0 {428 if budget <= 0 || len(entries) == 0 {
421 return nil429 return nil
422 }430 }
423 used := 0431 used := 0
424 for i, entry := range entries {432 for i, entry := range entries {
425- used += cost(entry)433+ used += cost(i, entry)
426 if used > budget {434 if used > budget {
427 return entries[:i]435 return entries[:i]
428 }436 }
@@ -436,17 +444,30 @@ func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback stri
436 reserve = statusTextWidth(feedback) + 2444 reserve = statusTextWidth(feedback) + 2
437 }445 }
438 budget := rowLen - leftUsed - reserve446 budget := rowLen - leftUsed - reserve
439- return fitWholeRecentEntries(entries, budget, func(entry rankedRecent) int {447+ return fitWholeRecentEntries(entries, budget, func(_ int, entry rankedRecent) int {
440 return recentRouteDisplayWidth(recentRouteDisplay{448 return recentRouteDisplayWidth(recentRouteDisplay{
441 name: entry.name,449 name: entry.name,
442 kind: entry.kind,450 kind: entry.kind,
443 ephemeral: entry.ephemeral,451 ephemeral: entry.ephemeral,
444 attention: entry.attention,452 attention: entry.attention,
445- }) + statusTextWidth(strconv.Itoa(entry.rank)+":")453+ }) + rankPrefixWidth(entry.rank)
446 })454 })
447 }455 }
448 456
449-func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string) []recentRouteDisplay {457+func rankPrefixWidth(rank int) int {
458+ return statusTextWidth(strconv.Itoa(rank) + ":")
459+}
460+
461+// fitMRU keeps the leading entries that fit. ranked adds each entry's "N:"
462+// rank prefix to its width.
463+func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string, ranked bool) []recentRouteDisplay {
464+ cost := func(i int, entry recentRouteDisplay) int {
465+ width := recentRouteDisplayWidth(entry)
466+ if ranked {
467+ width += rankPrefixWidth(i + 1)
468+ }
469+ return width
470+ }
450 // With no feedback, keep one blank trailing cell; with feedback, reserve471 // With no feedback, keep one blank trailing cell; with feedback, reserve
451 // its " text" width plus a one-cell gap so drawRightPlainText always fits.472 // its " text" width plus a one-cell gap so drawRightPlainText always fits.
452 copyReserve := 1473 copyReserve := 1
@@ -463,11 +484,11 @@ func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string)
463 budget -= mruFutureRightReserve(rowLen)484 budget -= mruFutureRightReserve(rowLen)
464 // Keep at least one recent session when it physically fits; the reserved485 // Keep at least one recent session when it physically fits; the reserved
465 // right side is only a budget preference, not a reason to hide all recents.486 // right side is only a budget preference, not a reason to hide all recents.
466- if firstCost := recentRouteDisplayWidth(entries[0]); firstCost <= physicalBudget && budget < firstCost {487+ if firstCost := cost(0, entries[0]); firstCost <= physicalBudget && budget < firstCost {
467 budget = firstCost488 budget = firstCost
468 }489 }
469 }490 }
470- return fitWholeRecentEntries(entries, budget, recentRouteDisplayWidth)491+ return fitWholeRecentEntries(entries, budget, cost)
471 }492 }
472 493
473 const (494 const (
M internal/usecase/daemon/status_test.go +24 -0
@@ -1090,6 +1090,30 @@ func TestStatusBarRendersMRUNamesAndInlineBell(t *testing.T) {
10901090 t.Fatalf("inline bell not rendered: %q", rowText(row))
10911091 }
10921092
1093+func TestStatusBarShowsMRURanksWhileCtrlHeld(t *testing.T) {
1094+ mru := []recentRouteDisplay{{name: "local"}, {name: "neferwl@arch"}, {name: "old"}}
1095+ tests := []struct {
1096+ name string
1097+ showRanks bool
1098+ width int
1099+ want string
1100+ }{
1101+ {name: "names only", width: 44, want: " cur local neferwl@arch old "},
1102+ {name: "ranks while held", showRanks: true, width: 50, want: " cur 1:local 2:neferwl@arch 3:old "},
1103+ {name: "rank widths drop whole oldest entries", showRanks: true, width: 44, want: " cur 1:local 2:neferwl@arch "},
1104+ }
1105+ for _, tt := range tests {
1106+ t.Run(tt.name, func(t *testing.T) {
1107+ state := barState{status: statusSnapshot{session: "cur"}, mru: mru, showRanks: tt.showRanks}
1108+ row := make([]renderer.Cell, tt.width)
1109+
1110+ drawStatusBarState(row, state, resolveStyles(nil))
1111+
1112+ require.Equal(t, tt.want, strings.TrimRight(rowText(row), " ")+" ")
1113+ })
1114+ }
1115+}
1116+
10931117 func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) {
10941118 theme := themeui.Theme{Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}, HasFG: true, HasBG: true, Known: true}
10951119 styles := themeui.NewStyles(theme)
@@ -1090,6 +1090,30 @@ func TestStatusBarRendersMRUNamesAndInlineBell(t *testing.T) {
1090 t.Fatalf("inline bell not rendered: %q", rowText(row))1090 t.Fatalf("inline bell not rendered: %q", rowText(row))
1091 }1091 }
1092 1092
1093+func TestStatusBarShowsMRURanksWhileCtrlHeld(t *testing.T) {
1094+ mru := []recentRouteDisplay{{name: "local"}, {name: "neferwl@arch"}, {name: "old"}}
1095+ tests := []struct {
1096+ name string
1097+ showRanks bool
1098+ width int
1099+ want string
1100+ }{
1101+ {name: "names only", width: 44, want: " cur local neferwl@arch old "},
1102+ {name: "ranks while held", showRanks: true, width: 50, want: " cur 1:local 2:neferwl@arch 3:old "},
1103+ {name: "rank widths drop whole oldest entries", showRanks: true, width: 44, want: " cur 1:local 2:neferwl@arch "},
1104+ }
1105+ for _, tt := range tests {
1106+ t.Run(tt.name, func(t *testing.T) {
1107+ state := barState{status: statusSnapshot{session: "cur"}, mru: mru, showRanks: tt.showRanks}
1108+ row := make([]renderer.Cell, tt.width)
1109+
1110+ drawStatusBarState(row, state, resolveStyles(nil))
1111+
1112+ require.Equal(t, tt.want, strings.TrimRight(rowText(row), " ")+" ")
1113+ })
1114+ }
1115+}
1116+
1093 func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) {1117 func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) {
1094 theme := themeui.Theme{Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}, HasFG: true, HasBG: true, Known: true}1118 theme := themeui.Theme{Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}, HasFG: true, HasBG: true, Known: true}
1095 styles := themeui.NewStyles(theme)1119 styles := themeui.NewStyles(theme)
M internal/usecase/keys/kittykey/kittykey.go +71 -27
@@ -25,8 +25,12 @@ const (
2525 FlagAllKeys = 1 << 3
2626 FlagText = 1 << 4
2727
28- // OuterFlags are the flags vev requests from the outer terminal.
28+ // OuterFlags is the key encoding the rest of vev consumes. A Normalizer
29+ // rewrites the richer RequestFlags stream into it.
2930 OuterFlags = FlagDisambiguate | FlagAlternateKeys
31+ // RequestFlags are the flags vev requests from the outer terminal: every
32+ // key, modifier presses and releases included, as an escape code.
33+ RequestFlags = OuterFlags | FlagReportEvents | FlagAllKeys | FlagText
3034 )
3135
3236 // Modifier bits, as encoded in the mods parameter minus one.
@@ -101,61 +105,101 @@ func Parse(data []byte) (ev Event, n int, ok, partial bool) {
101105 if b != 'u' || i == 2 {
102106 return Event{}, 0, false, false
103107 }
104- ev, ok := parseParams(data[2:i])
105- return ev, i + 1, ok, false
108+ key, ok := parseParams(data[2:i])
109+ if !ok || key.ev.Code == 0 {
110+ return Event{}, 0, false, false
111+ }
112+ return key.ev, i + 1, true, false
106113 }
107114 return Event{}, 0, false, i < maxSeq
108115 }
109116
110-func parseParams(params []byte) (Event, bool) {
117+// fullKey is one CSI u key event with the fields Event leaves out.
118+type fullKey struct {
119+ ev Event
120+ // text holds the associated text (FlagText), control codes removed.
121+ text []rune
122+}
123+
124+func parseParams(params []byte) (fullKey, bool) {
111125 fields := splitByte(params, ';')
112126 if len(fields) > 3 {
113- return Event{}, false
127+ return fullKey{}, false
114128 }
115129 keys := splitByte(fields[0], ':')
116130 if len(keys) > 3 {
117- return Event{}, false
131+ return fullKey{}, false
118132 }
119133 code, ok := atoi(keys[0])
120- if !ok || code == 0 {
121- return Event{}, false
134+ if !ok {
135+ return fullKey{}, false
122136 }
123- ev := Event{Code: rune(code)}
137+ key := fullKey{}
138+ ev := &key.ev
139+ ev.Code = rune(code)
124140 if len(keys) > 1 && len(keys[1]) > 0 {
125141 v, ok := atoi(keys[1])
126142 if !ok {
127- return Event{}, false
143+ return fullKey{}, false
128144 }
129145 ev.Shifted = rune(v)
130146 }
131147 if len(keys) > 2 && len(keys[2]) > 0 {
132148 v, ok := atoi(keys[2])
133149 if !ok {
134- return Event{}, false
150+ return fullKey{}, false
135151 }
136152 ev.Base = rune(v)
137153 }
138154 if len(fields) > 1 && len(fields[1]) > 0 {
139- sub := splitByte(fields[1], ':')
140- if len(sub) > 2 {
141- return Event{}, false
142- }
143- mods, ok := atoi(sub[0])
144- if !ok || mods < 1 {
145- return Event{}, false
155+ mods, event, ok := parseMods(fields[1])
156+ if !ok {
157+ return fullKey{}, false
146158 }
147- ev.Mods = (mods - 1) &^ modLocks
148- if len(sub) == 2 && len(sub[1]) > 0 {
149- event, ok := atoi(sub[1])
150- if !ok || event < 1 || event > 3 {
151- return Event{}, false
159+ ev.Mods = mods
160+ ev.Release = event == eventRelease
161+ }
162+ // A third field carries associated text (FlagText). Only a Normalizer
163+ // needs it; re-encoding a key never does.
164+ if len(fields) > 2 && len(fields[2]) > 0 {
165+ for _, field := range splitByte(fields[2], ':') {
166+ v, ok := atoi(field)
167+ if !ok {
168+ return fullKey{}, false
152169 }
153- ev.Release = event == 3
170+ if r := rune(v); r >= 0x20 && (r < 0x7f || r > 0x9f) && utf8.ValidRune(r) {
171+ key.text = append(key.text, r)
172+ }
173+ }
174+ }
175+ return key, true
176+}
177+
178+// Event types; a repeat (2) is handled as a press.
179+const (
180+ eventPress = 1
181+ eventRelease = 3
182+)
183+
184+// parseMods decodes a "mods[:event]" field: the modifier bits with the lock
185+// bits removed, and the event type (press when absent).
186+func parseMods(field []byte) (mods, event int, ok bool) {
187+ sub := splitByte(field, ':')
188+ if len(sub) > 2 {
189+ return 0, 0, false
190+ }
191+ encoded, ok := atoi(sub[0])
192+ if !ok || encoded < 1 {
193+ return 0, 0, false
194+ }
195+ event = eventPress
196+ if len(sub) == 2 && len(sub[1]) > 0 {
197+ event, ok = atoi(sub[1])
198+ if !ok || event < eventPress || event > eventRelease {
199+ return 0, 0, false
154200 }
155201 }
156- // A third field carries associated text (FlagText); it is not needed to
157- // re-encode the key and is intentionally ignored.
158- return ev, true
202+ return (encoded - 1) &^ modLocks, event, true
159203 }
160204
161205 // Translate rewrites every complete CSI u key sequence and kitty F1-F4 form in
@@ -25,8 +25,12 @@ const (
25 FlagAllKeys = 1 << 325 FlagAllKeys = 1 << 3
26 FlagText = 1 << 426 FlagText = 1 << 4
27 27
28- // OuterFlags are the flags vev requests from the outer terminal.28+ // OuterFlags is the key encoding the rest of vev consumes. A Normalizer
29+ // rewrites the richer RequestFlags stream into it.
29 OuterFlags = FlagDisambiguate | FlagAlternateKeys30 OuterFlags = FlagDisambiguate | FlagAlternateKeys
31+ // RequestFlags are the flags vev requests from the outer terminal: every
32+ // key, modifier presses and releases included, as an escape code.
33+ RequestFlags = OuterFlags | FlagReportEvents | FlagAllKeys | FlagText
30 )34 )
31 35
32 // Modifier bits, as encoded in the mods parameter minus one.36 // Modifier bits, as encoded in the mods parameter minus one.
@@ -101,61 +105,101 @@ func Parse(data []byte) (ev Event, n int, ok, partial bool) {
101 if b != 'u' || i == 2 {105 if b != 'u' || i == 2 {
102 return Event{}, 0, false, false106 return Event{}, 0, false, false
103 }107 }
104- ev, ok := parseParams(data[2:i])108+ key, ok := parseParams(data[2:i])
105- return ev, i + 1, ok, false109+ if !ok || key.ev.Code == 0 {
110+ return Event{}, 0, false, false
111+ }
112+ return key.ev, i + 1, true, false
106 }113 }
107 return Event{}, 0, false, i < maxSeq114 return Event{}, 0, false, i < maxSeq
108 }115 }
109 116
110-func parseParams(params []byte) (Event, bool) {117+// fullKey is one CSI u key event with the fields Event leaves out.
118+type fullKey struct {
119+ ev Event
120+ // text holds the associated text (FlagText), control codes removed.
121+ text []rune
122+}
123+
124+func parseParams(params []byte) (fullKey, bool) {
111 fields := splitByte(params, ';')125 fields := splitByte(params, ';')
112 if len(fields) > 3 {126 if len(fields) > 3 {
113- return Event{}, false127+ return fullKey{}, false
114 }128 }
115 keys := splitByte(fields[0], ':')129 keys := splitByte(fields[0], ':')
116 if len(keys) > 3 {130 if len(keys) > 3 {
117- return Event{}, false131+ return fullKey{}, false
118 }132 }
119 code, ok := atoi(keys[0])133 code, ok := atoi(keys[0])
120- if !ok || code == 0 {134+ if !ok {
121- return Event{}, false135+ return fullKey{}, false
122 }136 }
123- ev := Event{Code: rune(code)}137+ key := fullKey{}
138+ ev := &key.ev
139+ ev.Code = rune(code)
124 if len(keys) > 1 && len(keys[1]) > 0 {140 if len(keys) > 1 && len(keys[1]) > 0 {
125 v, ok := atoi(keys[1])141 v, ok := atoi(keys[1])
126 if !ok {142 if !ok {
127- return Event{}, false143+ return fullKey{}, false
128 }144 }
129 ev.Shifted = rune(v)145 ev.Shifted = rune(v)
130 }146 }
131 if len(keys) > 2 && len(keys[2]) > 0 {147 if len(keys) > 2 && len(keys[2]) > 0 {
132 v, ok := atoi(keys[2])148 v, ok := atoi(keys[2])
133 if !ok {149 if !ok {
134- return Event{}, false150+ return fullKey{}, false
135 }151 }
136 ev.Base = rune(v)152 ev.Base = rune(v)
137 }153 }
138 if len(fields) > 1 && len(fields[1]) > 0 {154 if len(fields) > 1 && len(fields[1]) > 0 {
139- sub := splitByte(fields[1], ':')155+ mods, event, ok := parseMods(fields[1])
140- if len(sub) > 2 {156+ if !ok {
141- return Event{}, false157+ return fullKey{}, false
142- }
143- mods, ok := atoi(sub[0])
144- if !ok || mods < 1 {
145- return Event{}, false
146 }158 }
147- ev.Mods = (mods - 1) &^ modLocks159+ ev.Mods = mods
148- if len(sub) == 2 && len(sub[1]) > 0 {160+ ev.Release = event == eventRelease
149- event, ok := atoi(sub[1])161+ }
150- if !ok || event < 1 || event > 3 {162+ // A third field carries associated text (FlagText). Only a Normalizer
151- return Event{}, false163+ // needs it; re-encoding a key never does.
164+ if len(fields) > 2 && len(fields[2]) > 0 {
165+ for _, field := range splitByte(fields[2], ':') {
166+ v, ok := atoi(field)
167+ if !ok {
168+ return fullKey{}, false
152 }169 }
153- ev.Release = event == 3170+ if r := rune(v); r >= 0x20 && (r < 0x7f || r > 0x9f) && utf8.ValidRune(r) {
171+ key.text = append(key.text, r)
172+ }
173+ }
174+ }
175+ return key, true
176+}
177+
178+// Event types; a repeat (2) is handled as a press.
179+const (
180+ eventPress = 1
181+ eventRelease = 3
182+)
183+
184+// parseMods decodes a "mods[:event]" field: the modifier bits with the lock
185+// bits removed, and the event type (press when absent).
186+func parseMods(field []byte) (mods, event int, ok bool) {
187+ sub := splitByte(field, ':')
188+ if len(sub) > 2 {
189+ return 0, 0, false
190+ }
191+ encoded, ok := atoi(sub[0])
192+ if !ok || encoded < 1 {
193+ return 0, 0, false
194+ }
195+ event = eventPress
196+ if len(sub) == 2 && len(sub[1]) > 0 {
197+ event, ok = atoi(sub[1])
198+ if !ok || event < eventPress || event > eventRelease {
199+ return 0, 0, false
154 }200 }
155 }201 }
156- // A third field carries associated text (FlagText); it is not needed to202+ return (encoded - 1) &^ modLocks, event, true
157- // re-encode the key and is intentionally ignored.
158- return ev, true
159 }203 }
160 204
161 // Translate rewrites every complete CSI u key sequence and kitty F1-F4 form in205 // Translate rewrites every complete CSI u key sequence and kitty F1-F4 form in
A internal/usecase/keys/kittykey/normalize.go +236 -0
@@ -0,0 +1,236 @@
1+package kittykey
2+
3+import (
4+ "bytes"
5+ "strconv"
6+ "unicode/utf8"
7+)
8+
9+// Kitty functional key codes of the modifier and lock keys. RequestFlags
10+// reports them; OuterFlags never does.
11+const (
12+ keyCapsLock = 57358
13+ keyNumLock = 57360
14+ keyLeftShift = 57441
15+ keyLeftControl = 57442
16+ keyRightControl = 57448
17+ keyISOLevel5 = 57454
18+)
19+
20+// Normalizer rewrites the outer terminal's RequestFlags key stream into the
21+// OuterFlags encoding the rest of vev consumes, and tracks whether Ctrl is
22+// physically held. Release events, modifier and lock key events are dropped,
23+// repeats become presses, and text keys become their text again.
24+//
25+// Input already in the OuterFlags or legacy encoding passes through
26+// unchanged, so enabling a Normalizer before the terminal switches flags is
27+// safe. A trailing incomplete CSI sequence is held until the next call; a
28+// lone trailing ESC is not, because the key router owns its timing.
29+// A Normalizer is not safe for concurrent use.
30+type Normalizer struct {
31+ pending []byte
32+ paste bool
33+ ctrl bool
34+}
35+
36+// CtrlHeld reports whether Ctrl is held on its own: a Ctrl key is down and no
37+// other key but a top-row digit was pressed since. Only a Ctrl key press sets
38+// it, so a terminal that reports no modifier key events never shows it held.
39+func (n *Normalizer) CtrlHeld() bool { return n.ctrl }
40+
41+// ReleaseCtrl forgets a held Ctrl, for a terminal that lost focus and may
42+// never report the release.
43+func (n *Normalizer) ReleaseCtrl() { n.ctrl = false }
44+
45+// Normalize returns data in the OuterFlags encoding, in a new slice.
46+func (n *Normalizer) Normalize(data []byte) []byte {
47+ if len(n.pending) > 0 {
48+ data = append(n.pending, data...)
49+ n.pending = nil
50+ }
51+ out := make([]byte, 0, len(data))
52+ for i := 0; i < len(data); {
53+ if n.paste {
54+ end := bytes.Index(data[i:], pasteClose)
55+ if end < 0 {
56+ keep := len(data) - partialSuffix(data[i:], pasteClose)
57+ out = append(out, data[i:keep]...)
58+ n.hold(data[keep:])
59+ return out
60+ }
61+ end += i + len(pasteClose)
62+ out = append(out, data[i:end]...)
63+ i = end
64+ n.paste = false
65+ continue
66+ }
67+ if data[i] != 0x1b {
68+ out = append(out, data[i])
69+ i++
70+ continue
71+ }
72+ rest := data[i:]
73+ if bytes.HasPrefix(rest, pasteOpen) {
74+ out = append(out, pasteOpen...)
75+ i += len(pasteOpen)
76+ n.paste = true
77+ continue
78+ }
79+ params, final, size, partial := scanCSI(rest)
80+ if partial {
81+ n.hold(rest)
82+ return out
83+ }
84+ if size > 0 {
85+ if repl, ok := n.rewrite(params, final); ok {
86+ out = append(out, repl...)
87+ i += size
88+ continue
89+ }
90+ }
91+ out = append(out, data[i])
92+ i++
93+ }
94+ return out
95+}
96+
97+func (n *Normalizer) hold(tail []byte) {
98+ if len(tail) > 0 {
99+ n.pending = append([]byte(nil), tail...)
100+ }
101+}
102+
103+// partialSuffix returns the length of the longest suffix of data that is a
104+// strict prefix of marker.
105+func partialSuffix(data, marker []byte) int {
106+ for k := min(len(data), len(marker)-1); k > 0; k-- {
107+ if bytes.HasPrefix(marker, data[len(data)-k:]) {
108+ return k
109+ }
110+ }
111+ return 0
112+}
113+
114+// scanCSI reads ESC [ params final at the start of data, where params holds
115+// only digits, ';' and ':'. size is 0 when data starts no such sequence;
116+// partial reports a strict prefix that may still complete.
117+func scanCSI(data []byte) (params []byte, final byte, size int, partial bool) {
118+ if len(data) < 2 || data[1] != '[' {
119+ return nil, 0, 0, false
120+ }
121+ for i := 2; i < len(data) && i < maxSeq; i++ {
122+ b := data[i]
123+ if (b >= '0' && b <= '9') || b == ';' || b == ':' {
124+ continue
125+ }
126+ return data[2:i], b, i + 1, false
127+ }
128+ return nil, 0, 0, len(data) < maxSeq
129+}
130+
131+// rewrite returns the OuterFlags form of one key sequence, or false when the
132+// sequence is not a key event this package owns.
133+func (n *Normalizer) rewrite(params []byte, final byte) ([]byte, bool) {
134+ switch final {
135+ case 'u':
136+ if len(params) == 0 {
137+ return nil, false
138+ }
139+ key, ok := parseParams(params)
140+ if !ok {
141+ return nil, false
142+ }
143+ n.track(key.ev, true)
144+ return encodeOuter(key), true
145+ case '~', 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'S':
146+ return n.rewriteFunctional(params, final)
147+ }
148+ return nil, false
149+}
150+
151+// rewriteFunctional handles CSI number;mods[:event] ~ and CSI 1;mods[:event]
152+// letter forms. Forms without a modifier field are already in OuterFlags
153+// form, which also keeps bracketed paste markers and replies untouched.
154+func (n *Normalizer) rewriteFunctional(params []byte, final byte) ([]byte, bool) {
155+ fields := splitByte(params, ';')
156+ if len(fields) != 2 {
157+ return nil, false
158+ }
159+ mods, event, ok := parseMods(fields[1])
160+ if !ok {
161+ return nil, false
162+ }
163+ n.track(Event{Mods: mods, Release: event == eventRelease}, false)
164+ if event == eventRelease {
165+ return nil, true
166+ }
167+ out := []byte("\x1b[")
168+ if mods != 0 || (final == '~' || string(fields[0]) != "1") {
169+ out = append(out, fields[0]...)
170+ }
171+ if mods != 0 {
172+ out = append(out, ';')
173+ out = strconv.AppendInt(out, int64(mods+1), 10)
174+ }
175+ return append(out, final), true
176+}
177+
178+// track updates the Ctrl state from one key event. A Ctrl key event reports
179+// the Ctrl state after it. Pressing any other key ends a Ctrl held on its own,
180+// except a top-row digit, the Ctrl+1..9 recent-session switch.
181+func (n *Normalizer) track(ev Event, unicodeKey bool) {
182+ switch {
183+ case unicodeKey && (ev.Code == keyLeftControl || ev.Code == keyRightControl):
184+ n.ctrl = ev.Mods&ModCtrl != 0
185+ case unicodeKey && ev.Code == 0:
186+ // A text-only event carries no key.
187+ case unicodeKey && isModifierOrLock(ev.Code):
188+ if ev.Mods&ModCtrl == 0 {
189+ n.ctrl = false
190+ }
191+ case ev.Mods&ModCtrl == 0:
192+ n.ctrl = false
193+ case ev.Release:
194+ case unicodeKey:
195+ if _, digit := ev.Digit(); !digit {
196+ n.ctrl = false
197+ }
198+ default:
199+ n.ctrl = false
200+ }
201+}
202+
203+// encodeOuter re-encodes one RequestFlags key event as the terminal would
204+// have sent it under OuterFlags, or nil when it would have sent nothing.
205+func encodeOuter(key fullKey) []byte {
206+ ev := key.ev
207+ if ev.Release || isModifierOrLock(ev.Code) {
208+ return nil
209+ }
210+ plain := ev.Mods&^ModShift == 0
211+ switch {
212+ case ev.Code == 0 || (plain && len(key.text) > 0):
213+ return appendRunes(nil, key.text)
214+ case ev.Mods == 0 && (ev.Code == KeyEnter || ev.Code == KeyTab || ev.Code == KeyBackspace):
215+ return []byte{byte(ev.Code)}
216+ case plain && isTextKey(ev.Code):
217+ return legacyText(ev)
218+ }
219+ return encodeCSIu(ev, OuterFlags)
220+}
221+
222+func isModifierOrLock(code rune) bool {
223+ return (code >= keyLeftShift && code <= keyISOLevel5) || (code >= keyCapsLock && code <= keyNumLock)
224+}
225+
226+// isTextKey reports a key whose unmodified press produces its own text.
227+func isTextKey(code rune) bool {
228+ return code >= 0x20 && code != KeyBackspace && (code < 0xe000 || code > 0xf8ff) && utf8.ValidRune(code)
229+}
230+
231+func appendRunes(out []byte, runes []rune) []byte {
232+ for _, r := range runes {
233+ out = utf8.AppendRune(out, r)
234+ }
235+ return out
236+}
@@ -0,0 +1,236 @@
1+package kittykey
2+
3+import (
4+ "bytes"
5+ "strconv"
6+ "unicode/utf8"
7+)
8+
9+// Kitty functional key codes of the modifier and lock keys. RequestFlags
10+// reports them; OuterFlags never does.
11+const (
12+ keyCapsLock = 57358
13+ keyNumLock = 57360
14+ keyLeftShift = 57441
15+ keyLeftControl = 57442
16+ keyRightControl = 57448
17+ keyISOLevel5 = 57454
18+)
19+
20+// Normalizer rewrites the outer terminal's RequestFlags key stream into the
21+// OuterFlags encoding the rest of vev consumes, and tracks whether Ctrl is
22+// physically held. Release events, modifier and lock key events are dropped,
23+// repeats become presses, and text keys become their text again.
24+//
25+// Input already in the OuterFlags or legacy encoding passes through
26+// unchanged, so enabling a Normalizer before the terminal switches flags is
27+// safe. A trailing incomplete CSI sequence is held until the next call; a
28+// lone trailing ESC is not, because the key router owns its timing.
29+// A Normalizer is not safe for concurrent use.
30+type Normalizer struct {
31+ pending []byte
32+ paste bool
33+ ctrl bool
34+}
35+
36+// CtrlHeld reports whether Ctrl is held on its own: a Ctrl key is down and no
37+// other key but a top-row digit was pressed since. Only a Ctrl key press sets
38+// it, so a terminal that reports no modifier key events never shows it held.
39+func (n *Normalizer) CtrlHeld() bool { return n.ctrl }
40+
41+// ReleaseCtrl forgets a held Ctrl, for a terminal that lost focus and may
42+// never report the release.
43+func (n *Normalizer) ReleaseCtrl() { n.ctrl = false }
44+
45+// Normalize returns data in the OuterFlags encoding, in a new slice.
46+func (n *Normalizer) Normalize(data []byte) []byte {
47+ if len(n.pending) > 0 {
48+ data = append(n.pending, data...)
49+ n.pending = nil
50+ }
51+ out := make([]byte, 0, len(data))
52+ for i := 0; i < len(data); {
53+ if n.paste {
54+ end := bytes.Index(data[i:], pasteClose)
55+ if end < 0 {
56+ keep := len(data) - partialSuffix(data[i:], pasteClose)
57+ out = append(out, data[i:keep]...)
58+ n.hold(data[keep:])
59+ return out
60+ }
61+ end += i + len(pasteClose)
62+ out = append(out, data[i:end]...)
63+ i = end
64+ n.paste = false
65+ continue
66+ }
67+ if data[i] != 0x1b {
68+ out = append(out, data[i])
69+ i++
70+ continue
71+ }
72+ rest := data[i:]
73+ if bytes.HasPrefix(rest, pasteOpen) {
74+ out = append(out, pasteOpen...)
75+ i += len(pasteOpen)
76+ n.paste = true
77+ continue
78+ }
79+ params, final, size, partial := scanCSI(rest)
80+ if partial {
81+ n.hold(rest)
82+ return out
83+ }
84+ if size > 0 {
85+ if repl, ok := n.rewrite(params, final); ok {
86+ out = append(out, repl...)
87+ i += size
88+ continue
89+ }
90+ }
91+ out = append(out, data[i])
92+ i++
93+ }
94+ return out
95+}
96+
97+func (n *Normalizer) hold(tail []byte) {
98+ if len(tail) > 0 {
99+ n.pending = append([]byte(nil), tail...)
100+ }
101+}
102+
103+// partialSuffix returns the length of the longest suffix of data that is a
104+// strict prefix of marker.
105+func partialSuffix(data, marker []byte) int {
106+ for k := min(len(data), len(marker)-1); k > 0; k-- {
107+ if bytes.HasPrefix(marker, data[len(data)-k:]) {
108+ return k
109+ }
110+ }
111+ return 0
112+}
113+
114+// scanCSI reads ESC [ params final at the start of data, where params holds
115+// only digits, ';' and ':'. size is 0 when data starts no such sequence;
116+// partial reports a strict prefix that may still complete.
117+func scanCSI(data []byte) (params []byte, final byte, size int, partial bool) {
118+ if len(data) < 2 || data[1] != '[' {
119+ return nil, 0, 0, false
120+ }
121+ for i := 2; i < len(data) && i < maxSeq; i++ {
122+ b := data[i]
123+ if (b >= '0' && b <= '9') || b == ';' || b == ':' {
124+ continue
125+ }
126+ return data[2:i], b, i + 1, false
127+ }
128+ return nil, 0, 0, len(data) < maxSeq
129+}
130+
131+// rewrite returns the OuterFlags form of one key sequence, or false when the
132+// sequence is not a key event this package owns.
133+func (n *Normalizer) rewrite(params []byte, final byte) ([]byte, bool) {
134+ switch final {
135+ case 'u':
136+ if len(params) == 0 {
137+ return nil, false
138+ }
139+ key, ok := parseParams(params)
140+ if !ok {
141+ return nil, false
142+ }
143+ n.track(key.ev, true)
144+ return encodeOuter(key), true
145+ case '~', 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'S':
146+ return n.rewriteFunctional(params, final)
147+ }
148+ return nil, false
149+}
150+
151+// rewriteFunctional handles CSI number;mods[:event] ~ and CSI 1;mods[:event]
152+// letter forms. Forms without a modifier field are already in OuterFlags
153+// form, which also keeps bracketed paste markers and replies untouched.
154+func (n *Normalizer) rewriteFunctional(params []byte, final byte) ([]byte, bool) {
155+ fields := splitByte(params, ';')
156+ if len(fields) != 2 {
157+ return nil, false
158+ }
159+ mods, event, ok := parseMods(fields[1])
160+ if !ok {
161+ return nil, false
162+ }
163+ n.track(Event{Mods: mods, Release: event == eventRelease}, false)
164+ if event == eventRelease {
165+ return nil, true
166+ }
167+ out := []byte("\x1b[")
168+ if mods != 0 || (final == '~' || string(fields[0]) != "1") {
169+ out = append(out, fields[0]...)
170+ }
171+ if mods != 0 {
172+ out = append(out, ';')
173+ out = strconv.AppendInt(out, int64(mods+1), 10)
174+ }
175+ return append(out, final), true
176+}
177+
178+// track updates the Ctrl state from one key event. A Ctrl key event reports
179+// the Ctrl state after it. Pressing any other key ends a Ctrl held on its own,
180+// except a top-row digit, the Ctrl+1..9 recent-session switch.
181+func (n *Normalizer) track(ev Event, unicodeKey bool) {
182+ switch {
183+ case unicodeKey && (ev.Code == keyLeftControl || ev.Code == keyRightControl):
184+ n.ctrl = ev.Mods&ModCtrl != 0
185+ case unicodeKey && ev.Code == 0:
186+ // A text-only event carries no key.
187+ case unicodeKey && isModifierOrLock(ev.Code):
188+ if ev.Mods&ModCtrl == 0 {
189+ n.ctrl = false
190+ }
191+ case ev.Mods&ModCtrl == 0:
192+ n.ctrl = false
193+ case ev.Release:
194+ case unicodeKey:
195+ if _, digit := ev.Digit(); !digit {
196+ n.ctrl = false
197+ }
198+ default:
199+ n.ctrl = false
200+ }
201+}
202+
203+// encodeOuter re-encodes one RequestFlags key event as the terminal would
204+// have sent it under OuterFlags, or nil when it would have sent nothing.
205+func encodeOuter(key fullKey) []byte {
206+ ev := key.ev
207+ if ev.Release || isModifierOrLock(ev.Code) {
208+ return nil
209+ }
210+ plain := ev.Mods&^ModShift == 0
211+ switch {
212+ case ev.Code == 0 || (plain && len(key.text) > 0):
213+ return appendRunes(nil, key.text)
214+ case ev.Mods == 0 && (ev.Code == KeyEnter || ev.Code == KeyTab || ev.Code == KeyBackspace):
215+ return []byte{byte(ev.Code)}
216+ case plain && isTextKey(ev.Code):
217+ return legacyText(ev)
218+ }
219+ return encodeCSIu(ev, OuterFlags)
220+}
221+
222+func isModifierOrLock(code rune) bool {
223+ return (code >= keyLeftShift && code <= keyISOLevel5) || (code >= keyCapsLock && code <= keyNumLock)
224+}
225+
226+// isTextKey reports a key whose unmodified press produces its own text.
227+func isTextKey(code rune) bool {
228+ return code >= 0x20 && code != KeyBackspace && (code < 0xe000 || code > 0xf8ff) && utf8.ValidRune(code)
229+}
230+
231+func appendRunes(out []byte, runes []rune) []byte {
232+ for _, r := range runes {
233+ out = utf8.AppendRune(out, r)
234+ }
235+ return out
236+}
A internal/usecase/keys/kittykey/normalize_test.go +131 -0
@@ -0,0 +1,131 @@
1+package kittykey
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestNormalizerRewritesRequestFlagsToOuterFlags(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ in string
13+ want string
14+ }{
15+ {name: "legacy passthrough", in: "hello\x1b[A\x1bOP\r", want: "hello\x1b[A\x1bOP\r"},
16+ {name: "outer csi u passthrough", in: "\x1b[97;5u\x1b[38::49;5u", want: "\x1b[97;5u\x1b[38::49;5u"},
17+ {name: "text key", in: "\x1b[97;;97u", want: "a"},
18+ {name: "text key without text field", in: "\x1b[97u", want: "a"},
19+ {name: "shift text", in: "\x1b[97:65;2;65u", want: "A"},
20+ {name: "shift without text uses shifted", in: "\x1b[97:65;2u", want: "A"},
21+ {name: "composed text event", in: "\x1b[0;;229u", want: "å"},
22+ {name: "multi codepoint text", in: "\x1b[101;;101:769u", want: "e\u0301"},
23+ {name: "control codes dropped from text", in: "\x1b[0;;7:97u", want: "a"},
24+ {name: "repeat is press", in: "\x1b[97;1:2;97u", want: "a"},
25+ {name: "release dropped", in: "\x1b[97;1:3u", want: ""},
26+ {name: "space", in: "\x1b[32;;32u", want: " "},
27+ {name: "enter", in: "\x1b[13u", want: "\r"},
28+ {name: "tab", in: "\x1b[9u", want: "\t"},
29+ {name: "backspace", in: "\x1b[127u", want: "\x7f"},
30+ {name: "shift enter stays csi u", in: "\x1b[13;2u", want: "\x1b[13;2u"},
31+ {name: "escape", in: "\x1b[27u", want: "\x1b[27u"},
32+ {name: "ctrl letter", in: "\x1b[99;5u", want: "\x1b[99;5u"},
33+ {name: "ctrl digit with base", in: "\x1b[38::49;5u", want: "\x1b[38::49;5u"},
34+ {name: "alt letter ignores text", in: "\x1b[97;3;97u", want: "\x1b[97;3u"},
35+ {name: "ctrl press dropped", in: "\x1b[57442;5u", want: ""},
36+ {name: "ctrl release dropped", in: "\x1b[57442;1:3u", want: ""},
37+ {name: "shift key dropped", in: "\x1b[57441;2u", want: ""},
38+ {name: "caps lock dropped", in: "\x1b[57358;65u", want: ""},
39+ {name: "arrow unmodified passthrough", in: "\x1b[A", want: "\x1b[A"},
40+ {name: "arrow repeat", in: "\x1b[1;1:2A", want: "\x1b[A"},
41+ {name: "arrow release dropped", in: "\x1b[1;1:3A", want: ""},
42+ {name: "ctrl arrow", in: "\x1b[1;5C", want: "\x1b[1;5C"},
43+ {name: "ctrl arrow repeat", in: "\x1b[1;5:2C", want: "\x1b[1;5C"},
44+ {name: "delete release dropped", in: "\x1b[3;1:3~", want: ""},
45+ {name: "delete repeat", in: "\x1b[3;1:2~", want: "\x1b[3~"},
46+ {name: "ctrl delete", in: "\x1b[3;5~", want: "\x1b[3;5~"},
47+ {name: "mouse untouched", in: "\x1b[<0;1;1M", want: "\x1b[<0;1;1M"},
48+ {name: "keyboard reply untouched", in: "\x1b[?5u", want: "\x1b[?5u"},
49+ {name: "cursor report untouched", in: "\x1b[12;40R", want: "\x1b[12;40R"},
50+ {name: "paste body untouched", in: "\x1b[200~\x1b[97;1:3u\x1b[201~\x1b[97;1:3u", want: "\x1b[200~\x1b[97;1:3u\x1b[201~"},
51+ {name: "lone esc passes", in: "x\x1b", want: "x\x1b"},
52+ {name: "trailing garbage", in: "\x1b[97;;97u\x1b[zz", want: "a\x1b[zz"},
53+ {name: "bad event type kept", in: "\x1b[97;1:4u", want: "\x1b[97;1:4u"},
54+ }
55+ for _, tt := range tests {
56+ t.Run(tt.name, func(t *testing.T) {
57+ var n Normalizer
58+ require.Equal(t, tt.want, string(n.Normalize([]byte(tt.in))))
59+ })
60+ }
61+}
62+
63+func TestNormalizerHoldsSplitSequences(t *testing.T) {
64+ tests := []struct {
65+ name string
66+ reads []string
67+ want []string
68+ }{
69+ {name: "split csi u", reads: []string{"a\x1b[97;", ";98u"}, want: []string{"a", "b"}},
70+ {name: "split csi prefix", reads: []string{"\x1b[", "57442;1:3u"}, want: []string{"", ""}},
71+ {name: "split paste close", reads: []string{"\x1b[200~x\x1b[20", "1~\x1b[97;;97u"}, want: []string{"\x1b[200~x", "\x1b[201~a"}},
72+ {name: "split paste open", reads: []string{"\x1b[20", "0~\x1b[97;1:3u\x1b[201~"}, want: []string{"", "\x1b[200~\x1b[97;1:3u\x1b[201~"}},
73+ }
74+ for _, tt := range tests {
75+ t.Run(tt.name, func(t *testing.T) {
76+ var n Normalizer
77+ for i, read := range tt.reads {
78+ require.Equal(t, tt.want[i], string(n.Normalize([]byte(read))), "read %d", i)
79+ }
80+ })
81+ }
82+}
83+
84+func TestNormalizerTracksCtrl(t *testing.T) {
85+ tests := []struct {
86+ name string
87+ reads []string
88+ held []bool
89+ }{
90+ {name: "left ctrl press and release", reads: []string{"\x1b[57442;5u", "\x1b[57442;1:3u"}, held: []bool{true, false}},
91+ {name: "right ctrl", reads: []string{"\x1b[57448;5u", "\x1b[57448;1:3u"}, held: []bool{true, false}},
92+ {name: "ctrl repeat keeps held", reads: []string{"\x1b[57442;5u", "\x1b[57442;5:2u"}, held: []bool{true, true}},
93+ {name: "one of two ctrl released", reads: []string{"\x1b[57442;5u", "\x1b[57448;5u", "\x1b[57448;5:3u"}, held: []bool{true, true, true}},
94+ {name: "ctrl digit keeps held", reads: []string{"\x1b[57442;5u", "\x1b[49;5u", "\x1b[49;5:3u"}, held: []bool{true, true, true}},
95+ {name: "key without ctrl clears", reads: []string{"\x1b[57442;5u", "\x1b[97;;97u"}, held: []bool{true, false}},
96+ {name: "arrow without ctrl clears", reads: []string{"\x1b[57442;5u", "\x1b[1;1:2A"}, held: []bool{true, false}},
97+ {name: "ctrl shortcut clears", reads: []string{"\x1b[57442;5u", "\x1b[114;5u", "\x1b[114;5:3u"}, held: []bool{true, false, false}},
98+ {name: "ctrl arrow clears", reads: []string{"\x1b[57442;5u", "\x1b[1;5C"}, held: []bool{true, false}},
99+ {name: "azerty ctrl digit keeps held", reads: []string{"\x1b[57442;5u", "\x1b[38::49;5u"}, held: []bool{true, true}},
100+ {name: "ctrl shift keeps held", reads: []string{"\x1b[57442;5u", "\x1b[57441;6u", "\x1b[57441;5:3u"}, held: []bool{true, true, true}},
101+ {name: "composed text keeps held", reads: []string{"\x1b[57442;5u", "\x1b[0;;229u"}, held: []bool{true, true}},
102+ {name: "legacy text never sets", reads: []string{"\x1b[97;5u"}, held: []bool{false}},
103+ {name: "shift does not set", reads: []string{"\x1b[57441;2u"}, held: []bool{false}},
104+ {name: "ctrl in paste ignored", reads: []string{"\x1b[200~\x1b[57442;5u\x1b[201~"}, held: []bool{false}},
105+ }
106+ for _, tt := range tests {
107+ t.Run(tt.name, func(t *testing.T) {
108+ var n Normalizer
109+ for i, read := range tt.reads {
110+ n.Normalize([]byte(read))
111+ require.Equal(t, tt.held[i], n.CtrlHeld(), "read %d", i)
112+ }
113+ })
114+ }
115+ var n Normalizer
116+ n.Normalize([]byte("\x1b[57442;5u"))
117+ n.ReleaseCtrl()
118+ require.False(t, n.CtrlHeld())
119+}
120+
121+func FuzzNormalize(f *testing.F) {
122+ for _, seed := range []string{"\x1b[97;;97u", "\x1b[57442;5u", "\x1b[200~x\x1b[201~", "\x1b[1;5:3A", "a\x1b[", "\x1b[0;;229:7u"} {
123+ f.Add([]byte(seed))
124+ }
125+ f.Fuzz(func(t *testing.T, data []byte) {
126+ var n Normalizer
127+ out := n.Normalize(data)
128+ out = append(out, n.Normalize(nil)...)
129+ require.LessOrEqual(t, len(out), 4*len(data)+16)
130+ })
131+}
@@ -0,0 +1,131 @@
1+package kittykey
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestNormalizerRewritesRequestFlagsToOuterFlags(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ in string
13+ want string
14+ }{
15+ {name: "legacy passthrough", in: "hello\x1b[A\x1bOP\r", want: "hello\x1b[A\x1bOP\r"},
16+ {name: "outer csi u passthrough", in: "\x1b[97;5u\x1b[38::49;5u", want: "\x1b[97;5u\x1b[38::49;5u"},
17+ {name: "text key", in: "\x1b[97;;97u", want: "a"},
18+ {name: "text key without text field", in: "\x1b[97u", want: "a"},
19+ {name: "shift text", in: "\x1b[97:65;2;65u", want: "A"},
20+ {name: "shift without text uses shifted", in: "\x1b[97:65;2u", want: "A"},
21+ {name: "composed text event", in: "\x1b[0;;229u", want: "å"},
22+ {name: "multi codepoint text", in: "\x1b[101;;101:769u", want: "e\u0301"},
23+ {name: "control codes dropped from text", in: "\x1b[0;;7:97u", want: "a"},
24+ {name: "repeat is press", in: "\x1b[97;1:2;97u", want: "a"},
25+ {name: "release dropped", in: "\x1b[97;1:3u", want: ""},
26+ {name: "space", in: "\x1b[32;;32u", want: " "},
27+ {name: "enter", in: "\x1b[13u", want: "\r"},
28+ {name: "tab", in: "\x1b[9u", want: "\t"},
29+ {name: "backspace", in: "\x1b[127u", want: "\x7f"},
30+ {name: "shift enter stays csi u", in: "\x1b[13;2u", want: "\x1b[13;2u"},
31+ {name: "escape", in: "\x1b[27u", want: "\x1b[27u"},
32+ {name: "ctrl letter", in: "\x1b[99;5u", want: "\x1b[99;5u"},
33+ {name: "ctrl digit with base", in: "\x1b[38::49;5u", want: "\x1b[38::49;5u"},
34+ {name: "alt letter ignores text", in: "\x1b[97;3;97u", want: "\x1b[97;3u"},
35+ {name: "ctrl press dropped", in: "\x1b[57442;5u", want: ""},
36+ {name: "ctrl release dropped", in: "\x1b[57442;1:3u", want: ""},
37+ {name: "shift key dropped", in: "\x1b[57441;2u", want: ""},
38+ {name: "caps lock dropped", in: "\x1b[57358;65u", want: ""},
39+ {name: "arrow unmodified passthrough", in: "\x1b[A", want: "\x1b[A"},
40+ {name: "arrow repeat", in: "\x1b[1;1:2A", want: "\x1b[A"},
41+ {name: "arrow release dropped", in: "\x1b[1;1:3A", want: ""},
42+ {name: "ctrl arrow", in: "\x1b[1;5C", want: "\x1b[1;5C"},
43+ {name: "ctrl arrow repeat", in: "\x1b[1;5:2C", want: "\x1b[1;5C"},
44+ {name: "delete release dropped", in: "\x1b[3;1:3~", want: ""},
45+ {name: "delete repeat", in: "\x1b[3;1:2~", want: "\x1b[3~"},
46+ {name: "ctrl delete", in: "\x1b[3;5~", want: "\x1b[3;5~"},
47+ {name: "mouse untouched", in: "\x1b[<0;1;1M", want: "\x1b[<0;1;1M"},
48+ {name: "keyboard reply untouched", in: "\x1b[?5u", want: "\x1b[?5u"},
49+ {name: "cursor report untouched", in: "\x1b[12;40R", want: "\x1b[12;40R"},
50+ {name: "paste body untouched", in: "\x1b[200~\x1b[97;1:3u\x1b[201~\x1b[97;1:3u", want: "\x1b[200~\x1b[97;1:3u\x1b[201~"},
51+ {name: "lone esc passes", in: "x\x1b", want: "x\x1b"},
52+ {name: "trailing garbage", in: "\x1b[97;;97u\x1b[zz", want: "a\x1b[zz"},
53+ {name: "bad event type kept", in: "\x1b[97;1:4u", want: "\x1b[97;1:4u"},
54+ }
55+ for _, tt := range tests {
56+ t.Run(tt.name, func(t *testing.T) {
57+ var n Normalizer
58+ require.Equal(t, tt.want, string(n.Normalize([]byte(tt.in))))
59+ })
60+ }
61+}
62+
63+func TestNormalizerHoldsSplitSequences(t *testing.T) {
64+ tests := []struct {
65+ name string
66+ reads []string
67+ want []string
68+ }{
69+ {name: "split csi u", reads: []string{"a\x1b[97;", ";98u"}, want: []string{"a", "b"}},
70+ {name: "split csi prefix", reads: []string{"\x1b[", "57442;1:3u"}, want: []string{"", ""}},
71+ {name: "split paste close", reads: []string{"\x1b[200~x\x1b[20", "1~\x1b[97;;97u"}, want: []string{"\x1b[200~x", "\x1b[201~a"}},
72+ {name: "split paste open", reads: []string{"\x1b[20", "0~\x1b[97;1:3u\x1b[201~"}, want: []string{"", "\x1b[200~\x1b[97;1:3u\x1b[201~"}},
73+ }
74+ for _, tt := range tests {
75+ t.Run(tt.name, func(t *testing.T) {
76+ var n Normalizer
77+ for i, read := range tt.reads {
78+ require.Equal(t, tt.want[i], string(n.Normalize([]byte(read))), "read %d", i)
79+ }
80+ })
81+ }
82+}
83+
84+func TestNormalizerTracksCtrl(t *testing.T) {
85+ tests := []struct {
86+ name string
87+ reads []string
88+ held []bool
89+ }{
90+ {name: "left ctrl press and release", reads: []string{"\x1b[57442;5u", "\x1b[57442;1:3u"}, held: []bool{true, false}},
91+ {name: "right ctrl", reads: []string{"\x1b[57448;5u", "\x1b[57448;1:3u"}, held: []bool{true, false}},
92+ {name: "ctrl repeat keeps held", reads: []string{"\x1b[57442;5u", "\x1b[57442;5:2u"}, held: []bool{true, true}},
93+ {name: "one of two ctrl released", reads: []string{"\x1b[57442;5u", "\x1b[57448;5u", "\x1b[57448;5:3u"}, held: []bool{true, true, true}},
94+ {name: "ctrl digit keeps held", reads: []string{"\x1b[57442;5u", "\x1b[49;5u", "\x1b[49;5:3u"}, held: []bool{true, true, true}},
95+ {name: "key without ctrl clears", reads: []string{"\x1b[57442;5u", "\x1b[97;;97u"}, held: []bool{true, false}},
96+ {name: "arrow without ctrl clears", reads: []string{"\x1b[57442;5u", "\x1b[1;1:2A"}, held: []bool{true, false}},
97+ {name: "ctrl shortcut clears", reads: []string{"\x1b[57442;5u", "\x1b[114;5u", "\x1b[114;5:3u"}, held: []bool{true, false, false}},
98+ {name: "ctrl arrow clears", reads: []string{"\x1b[57442;5u", "\x1b[1;5C"}, held: []bool{true, false}},
99+ {name: "azerty ctrl digit keeps held", reads: []string{"\x1b[57442;5u", "\x1b[38::49;5u"}, held: []bool{true, true}},
100+ {name: "ctrl shift keeps held", reads: []string{"\x1b[57442;5u", "\x1b[57441;6u", "\x1b[57441;5:3u"}, held: []bool{true, true, true}},
101+ {name: "composed text keeps held", reads: []string{"\x1b[57442;5u", "\x1b[0;;229u"}, held: []bool{true, true}},
102+ {name: "legacy text never sets", reads: []string{"\x1b[97;5u"}, held: []bool{false}},
103+ {name: "shift does not set", reads: []string{"\x1b[57441;2u"}, held: []bool{false}},
104+ {name: "ctrl in paste ignored", reads: []string{"\x1b[200~\x1b[57442;5u\x1b[201~"}, held: []bool{false}},
105+ }
106+ for _, tt := range tests {
107+ t.Run(tt.name, func(t *testing.T) {
108+ var n Normalizer
109+ for i, read := range tt.reads {
110+ n.Normalize([]byte(read))
111+ require.Equal(t, tt.held[i], n.CtrlHeld(), "read %d", i)
112+ }
113+ })
114+ }
115+ var n Normalizer
116+ n.Normalize([]byte("\x1b[57442;5u"))
117+ n.ReleaseCtrl()
118+ require.False(t, n.CtrlHeld())
119+}
120+
121+func FuzzNormalize(f *testing.F) {
122+ for _, seed := range []string{"\x1b[97;;97u", "\x1b[57442;5u", "\x1b[200~x\x1b[201~", "\x1b[1;5:3A", "a\x1b[", "\x1b[0;;229:7u"} {
123+ f.Add([]byte(seed))
124+ }
125+ f.Fuzz(func(t *testing.T, data []byte) {
126+ var n Normalizer
127+ out := n.Normalize(data)
128+ out = append(out, n.Normalize(nil)...)
129+ require.LessOrEqual(t, len(out), 4*len(data)+16)
130+ })
131+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close