detached e5e4ded

Merge pull request 'feat(status): show recent-session ranks while Ctrl is held and fade MRU text' (#26) from feat/mru-text-dimming into main

Reviewed-on: https://git.bnema.dev/bnema/vev/pulls/26
commit
e5e4ded9c916f9bee4ab0306ce83a120ed8a1983
author
bnema · · 2026-10-15 17:37 UTC
merge of
1f54f61 + 83ef129 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.9.0
32 files · +1342 -160
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 +4 -1
@@ -1723,7 +1723,10 @@ 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 {
1729+ pump.keys.disable()
17271730 s.logger.Warn("enabling kitty keyboard protocol failed", "err", err)
17281731 caps.KittyKeyboard = false
17291732 }
@@ -1723,7 +1723,10 @@ 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 {
1729+ pump.keys.disable()
1727 s.logger.Warn("enabling kitty keyboard protocol failed", "err", err)1730 s.logger.Warn("enabling kitty keyboard protocol failed", "err", err)
1728 caps.KittyKeyboard = false1731 caps.KittyKeyboard = false
1729 }1732 }
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 +171 -0
@@ -0,0 +1,171 @@
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+// disable stops normalizing, for a terminal that refused the flags.
43+func (k *terminalKeys) disable() { k.enabled.Store(false) }
44+
45+// normalize rewrites one read. focus is the terminal focus after the read:
46+// a window that lost focus may never report the Ctrl release.
47+func (k *terminalKeys) normalize(data []byte, focus domain.TerminalFocus) []byte {
48+ if !k.enabled.Load() {
49+ return data
50+ }
51+ out := k.normalizer.Normalize(data)
52+ if focus == domain.TerminalFocusUnfocused {
53+ k.normalizer.ReleaseCtrl()
54+ }
55+ k.ctrl.set(k.normalizer.CtrlHeld())
56+ return out
57+}
58+
59+// terminalCtrlState is the client's one held-Ctrl state. The terminal input
60+// pump is its only writer; it outlives attachments.
61+type terminalCtrlState struct {
62+ mu sync.Mutex
63+ held bool
64+ changed chan struct{}
65+}
66+
67+func newTerminalCtrlState() *terminalCtrlState {
68+ return &terminalCtrlState{changed: make(chan struct{})}
69+}
70+
71+// Watch returns whether Ctrl is held and a channel closed on the next change.
72+// A nil state is never held and never changes.
73+func (s *terminalCtrlState) Watch() (bool, <-chan struct{}) {
74+ if s == nil {
75+ return false, nil
76+ }
77+ s.mu.Lock()
78+ defer s.mu.Unlock()
79+ return s.held, s.changed
80+}
81+
82+func (s *terminalCtrlState) set(held bool) {
83+ s.mu.Lock()
84+ defer s.mu.Unlock()
85+ if held == s.held {
86+ return
87+ }
88+ s.held = held
89+ close(s.changed)
90+ s.changed = make(chan struct{})
91+}
92+
93+// attachmentCtrlForeground is the optional seam through which an attached
94+// loop reads the held-Ctrl state.
95+type attachmentCtrlForeground interface {
96+ terminalCtrl() *terminalCtrlState
97+}
98+
99+func (f *attachmentForeground) terminalCtrl() *terminalCtrlState {
100+ if f == nil || f.input == nil {
101+ return nil
102+ }
103+ return f.input.keys.ctrl
104+}
105+
106+func attachmentTerminalCtrl(fg AttachmentForeground) *terminalCtrlState {
107+ if ctrl, ok := fg.(attachmentCtrlForeground); ok {
108+ return ctrl.terminalCtrl()
109+ }
110+ return nil
111+}
112+
113+// ctrlReporter turns the held-Ctrl state into ModifierState messages: a hold
114+// is reported once it lasted ctrlHoldDelay, a release at once, and only after
115+// a reported hold.
116+type ctrlReporter struct {
117+ state *terminalCtrlState
118+ clock ports.Clock
119+ sent bool
120+ changed <-chan struct{}
121+ timer ports.Timer
122+}
123+
124+func newCtrlReporter(state *terminalCtrlState, clock ports.Clock) *ctrlReporter {
125+ r := &ctrlReporter{state: state, clock: clock}
126+ r.next()
127+ return r
128+}
129+
130+// timerC fires when a pending hold is due.
131+func (r *ctrlReporter) timerC() <-chan time.Time {
132+ if r.timer == nil {
133+ return nil
134+ }
135+ return r.timer.C()
136+}
137+
138+// next handles a state change and returns the message to send now, if any.
139+func (r *ctrlReporter) next() (protocol.ModifierState, bool) {
140+ held, changed := r.state.Watch()
141+ r.changed = changed
142+ if held {
143+ if !r.sent && r.timer == nil {
144+ r.timer = r.clock.NewTimer(ctrlHoldDelay)
145+ }
146+ return protocol.ModifierState{}, false
147+ }
148+ r.stop()
149+ if !r.sent {
150+ return protocol.ModifierState{}, false
151+ }
152+ r.sent = false
153+ return protocol.ModifierState{Ctrl: false}, true
154+}
155+
156+// due handles the hold timer and returns the hold to report, if still held.
157+func (r *ctrlReporter) due() (protocol.ModifierState, bool) {
158+ r.stop()
159+ if held, _ := r.state.Watch(); !held || r.sent {
160+ return protocol.ModifierState{}, false
161+ }
162+ r.sent = true
163+ return protocol.ModifierState{Ctrl: true}, true
164+}
165+
166+func (r *ctrlReporter) stop() {
167+ if r.timer != nil {
168+ r.timer.Stop()
169+ r.timer = nil
170+ }
171+}
@@ -0,0 +1,171 @@
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+// disable stops normalizing, for a terminal that refused the flags.
43+func (k *terminalKeys) disable() { k.enabled.Store(false) }
44+
45+// normalize rewrites one read. focus is the terminal focus after the read:
46+// a window that lost focus may never report the Ctrl release.
47+func (k *terminalKeys) normalize(data []byte, focus domain.TerminalFocus) []byte {
48+ if !k.enabled.Load() {
49+ return data
50+ }
51+ out := k.normalizer.Normalize(data)
52+ if focus == domain.TerminalFocusUnfocused {
53+ k.normalizer.ReleaseCtrl()
54+ }
55+ k.ctrl.set(k.normalizer.CtrlHeld())
56+ return out
57+}
58+
59+// terminalCtrlState is the client's one held-Ctrl state. The terminal input
60+// pump is its only writer; it outlives attachments.
61+type terminalCtrlState struct {
62+ mu sync.Mutex
63+ held bool
64+ changed chan struct{}
65+}
66+
67+func newTerminalCtrlState() *terminalCtrlState {
68+ return &terminalCtrlState{changed: make(chan struct{})}
69+}
70+
71+// Watch returns whether Ctrl is held and a channel closed on the next change.
72+// A nil state is never held and never changes.
73+func (s *terminalCtrlState) Watch() (bool, <-chan struct{}) {
74+ if s == nil {
75+ return false, nil
76+ }
77+ s.mu.Lock()
78+ defer s.mu.Unlock()
79+ return s.held, s.changed
80+}
81+
82+func (s *terminalCtrlState) set(held bool) {
83+ s.mu.Lock()
84+ defer s.mu.Unlock()
85+ if held == s.held {
86+ return
87+ }
88+ s.held = held
89+ close(s.changed)
90+ s.changed = make(chan struct{})
91+}
92+
93+// attachmentCtrlForeground is the optional seam through which an attached
94+// loop reads the held-Ctrl state.
95+type attachmentCtrlForeground interface {
96+ terminalCtrl() *terminalCtrlState
97+}
98+
99+func (f *attachmentForeground) terminalCtrl() *terminalCtrlState {
100+ if f == nil || f.input == nil {
101+ return nil
102+ }
103+ return f.input.keys.ctrl
104+}
105+
106+func attachmentTerminalCtrl(fg AttachmentForeground) *terminalCtrlState {
107+ if ctrl, ok := fg.(attachmentCtrlForeground); ok {
108+ return ctrl.terminalCtrl()
109+ }
110+ return nil
111+}
112+
113+// ctrlReporter turns the held-Ctrl state into ModifierState messages: a hold
114+// is reported once it lasted ctrlHoldDelay, a release at once, and only after
115+// a reported hold.
116+type ctrlReporter struct {
117+ state *terminalCtrlState
118+ clock ports.Clock
119+ sent bool
120+ changed <-chan struct{}
121+ timer ports.Timer
122+}
123+
124+func newCtrlReporter(state *terminalCtrlState, clock ports.Clock) *ctrlReporter {
125+ r := &ctrlReporter{state: state, clock: clock}
126+ r.next()
127+ return r
128+}
129+
130+// timerC fires when a pending hold is due.
131+func (r *ctrlReporter) timerC() <-chan time.Time {
132+ if r.timer == nil {
133+ return nil
134+ }
135+ return r.timer.C()
136+}
137+
138+// next handles a state change and returns the message to send now, if any.
139+func (r *ctrlReporter) next() (protocol.ModifierState, bool) {
140+ held, changed := r.state.Watch()
141+ r.changed = changed
142+ if held {
143+ if !r.sent && r.timer == nil {
144+ r.timer = r.clock.NewTimer(ctrlHoldDelay)
145+ }
146+ return protocol.ModifierState{}, false
147+ }
148+ r.stop()
149+ if !r.sent {
150+ return protocol.ModifierState{}, false
151+ }
152+ r.sent = false
153+ return protocol.ModifierState{Ctrl: false}, true
154+}
155+
156+// due handles the hold timer and returns the hold to report, if still held.
157+func (r *ctrlReporter) due() (protocol.ModifierState, bool) {
158+ r.stop()
159+ if held, _ := r.state.Watch(); !held || r.sent {
160+ return protocol.ModifierState{}, false
161+ }
162+ r.sent = true
163+ return protocol.ModifierState{Ctrl: true}, true
164+}
165+
166+func (r *ctrlReporter) stop() {
167+ if r.timer != nil {
168+ r.timer.Stop()
169+ r.timer = nil
170+ }
171+}
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/attachment_suspension.go +3 -0
@@ -51,6 +51,9 @@ func (d *Daemon) suspendAttachment(token attachmentCapability, request protocol.
5151 ac.lifecycle.activity = attachmentSuspended
5252 ac.lifecycle.retained = target
5353 ac.lifecycle.mu.Unlock()
54+ // A suspended attachment drops modifier reports; its activation starts
55+ // from a released Ctrl and the client reports a hold afresh.
56+ ac.ctrlHeld.Store(false)
5457 sess.mu.Unlock()
5558 d.mu.Unlock()
5659 cleanup.finish()
@@ -51,6 +51,9 @@ func (d *Daemon) suspendAttachment(token attachmentCapability, request protocol.
51 ac.lifecycle.activity = attachmentSuspended51 ac.lifecycle.activity = attachmentSuspended
52 ac.lifecycle.retained = target52 ac.lifecycle.retained = target
53 ac.lifecycle.mu.Unlock()53 ac.lifecycle.mu.Unlock()
54+ // A suspended attachment drops modifier reports; its activation starts
55+ // from a released Ctrl and the client reports a hold afresh.
56+ ac.ctrlHeld.Store(false)
54 sess.mu.Unlock()57 sess.mu.Unlock()
55 d.mu.Unlock()58 d.mu.Unlock()
56 cleanup.finish()59 cleanup.finish()
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 +65 -0
@@ -0,0 +1,65 @@
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+}
52+
53+// TestSuspensionReleasesHeldCtrl pins that a suspended attachment, which
54+// drops modifier reports, never activates with ranks stuck on.
55+func TestSuspensionReleasesHeldCtrl(t *testing.T) {
56+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
57+ d.attachCoordinator(sess, nil, ac, true)
58+ token := sess.captureAttachmentCapability(ac, ac.transport())
59+ ac.installTestAttachmentCapability(token)
60+ ac.ctrlHeld.Store(true)
61+
62+ require.NoError(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1}))
63+
64+ require.False(t, ac.ctrlHeld.Load())
65+}
@@ -0,0 +1,65 @@
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+}
52+
53+// TestSuspensionReleasesHeldCtrl pins that a suspended attachment, which
54+// drops modifier reports, never activates with ranks stuck on.
55+func TestSuspensionReleasesHeldCtrl(t *testing.T) {
56+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
57+ d.attachCoordinator(sess, nil, ac, true)
58+ token := sess.captureAttachmentCapability(ac, ac.transport())
59+ ac.installTestAttachmentCapability(token)
60+ ac.ctrlHeld.Store(true)
61+
62+ require.NoError(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1}))
63+
64+ require.False(t, ac.ctrlHeld.Load())
65+}
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 +30 -8
@@ -16,6 +16,7 @@ import (
1616 )
1717
1818 const (
19+ // maxMRUSessions is how many history entries Ctrl+1..9 reach.
1920 maxMRUSessions = 9
2021 pulseFrameCount = 30
2122 pulseFrameInterval = 120 * time.Millisecond
@@ -89,9 +90,13 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styl
8990 drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced)
9091 }
9192 } else {
92- fittedMRU := fitMRU(state.mru, len(row), x, rightText)
93+ fittedMRU := fitMRU(state.mru, len(row), x, rightText, state.showRanks)
9394 for i, sess := range fittedMRU {
9495 style := styles.MRUStyle(i, len(fittedMRU))
96+ if state.showRanks && i < maxMRUSessions {
97+ drawRankedStatusSessionEntry(row, &x, rankedRecent{rank: i + 1, name: sess.name, attention: sess.attention}, style, state.attentionFrame, styles.Reduced)
98+ continue
99+ }
95100 drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced)
96101 }
97102 }
@@ -169,6 +174,9 @@ type barState struct {
169174 statusFeedback string
170175 mru []recentRouteDisplay
171176 rankedRecent []rankedRecent
177+ // showRanks prefixes each mru entry with its Ctrl+N rank while the
178+ // client holds Ctrl.
179+ showRanks bool
172180 attentionFrame int
173181 // theme is the client's terminal theme, if reported. Its zero value
174182 // (Theme{}, Known: false) is a valid "no theme" default that resolves to
@@ -304,6 +312,7 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached
304312 state.rankedRecent = ranked
305313 } else if routeSnapshot.Generation != 0 {
306314 state.mru = formatRecentRouteSnapshot(historyRouteSnapshot(routeSnapshot))
315+ state.showRanks = ac != nil && ac.ctrlHeld.Load()
307316 }
308317 return state
309318 }
@@ -416,13 +425,13 @@ func recentRouteDisplayWidth(entry recentRouteDisplay) int {
416425 return width
417426 }
418427
419-func fitWholeRecentEntries[T any](entries []T, budget int, cost func(T) int) []T {
428+func fitWholeRecentEntries[T any](entries []T, budget int, cost func(int, T) int) []T {
420429 if budget <= 0 || len(entries) == 0 {
421430 return nil
422431 }
423432 used := 0
424433 for i, entry := range entries {
425- used += cost(entry)
434+ used += cost(i, entry)
426435 if used > budget {
427436 return entries[:i]
428437 }
@@ -436,17 +445,30 @@ func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback stri
436445 reserve = statusTextWidth(feedback) + 2
437446 }
438447 budget := rowLen - leftUsed - reserve
439- return fitWholeRecentEntries(entries, budget, func(entry rankedRecent) int {
448+ return fitWholeRecentEntries(entries, budget, func(_ int, entry rankedRecent) int {
440449 return recentRouteDisplayWidth(recentRouteDisplay{
441450 name: entry.name,
442451 kind: entry.kind,
443452 ephemeral: entry.ephemeral,
444453 attention: entry.attention,
445- }) + statusTextWidth(strconv.Itoa(entry.rank)+":")
454+ }) + rankPrefixWidth(entry.rank)
446455 })
447456 }
448457
449-func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string) []recentRouteDisplay {
458+func rankPrefixWidth(rank int) int {
459+ return statusTextWidth(strconv.Itoa(rank) + ":")
460+}
461+
462+// fitMRU keeps the leading entries that fit. ranked adds the "N:" rank prefix
463+// of each entry Ctrl+1..9 reaches to its width.
464+func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string, ranked bool) []recentRouteDisplay {
465+ cost := func(i int, entry recentRouteDisplay) int {
466+ width := recentRouteDisplayWidth(entry)
467+ if ranked && i < maxMRUSessions {
468+ width += rankPrefixWidth(i + 1)
469+ }
470+ return width
471+ }
450472 // With no feedback, keep one blank trailing cell; with feedback, reserve
451473 // its " text" width plus a one-cell gap so drawRightPlainText always fits.
452474 copyReserve := 1
@@ -463,11 +485,11 @@ func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string)
463485 budget -= mruFutureRightReserve(rowLen)
464486 // Keep at least one recent session when it physically fits; the reserved
465487 // right side is only a budget preference, not a reason to hide all recents.
466- if firstCost := recentRouteDisplayWidth(entries[0]); firstCost <= physicalBudget && budget < firstCost {
488+ if firstCost := cost(0, entries[0]); firstCost <= physicalBudget && budget < firstCost {
467489 budget = firstCost
468490 }
469491 }
470- return fitWholeRecentEntries(entries, budget, recentRouteDisplayWidth)
492+ return fitWholeRecentEntries(entries, budget, cost)
471493 }
472494
473495 const (
@@ -16,6 +16,7 @@ import (
16 )16 )
17 17
18 const (18 const (
19+ // maxMRUSessions is how many history entries Ctrl+1..9 reach.
19 maxMRUSessions = 920 maxMRUSessions = 9
20 pulseFrameCount = 3021 pulseFrameCount = 30
21 pulseFrameInterval = 120 * time.Millisecond22 pulseFrameInterval = 120 * time.Millisecond
@@ -89,9 +90,13 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styl
89 drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced)90 drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced)
90 }91 }
91 } else {92 } else {
92- fittedMRU := fitMRU(state.mru, len(row), x, rightText)93+ fittedMRU := fitMRU(state.mru, len(row), x, rightText, state.showRanks)
93 for i, sess := range fittedMRU {94 for i, sess := range fittedMRU {
94 style := styles.MRUStyle(i, len(fittedMRU))95 style := styles.MRUStyle(i, len(fittedMRU))
96+ if state.showRanks && i < maxMRUSessions {
97+ drawRankedStatusSessionEntry(row, &x, rankedRecent{rank: i + 1, name: sess.name, attention: sess.attention}, style, state.attentionFrame, styles.Reduced)
98+ continue
99+ }
95 drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced)100 drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced)
96 }101 }
97 }102 }
@@ -169,6 +174,9 @@ type barState struct {
169 statusFeedback string174 statusFeedback string
170 mru []recentRouteDisplay175 mru []recentRouteDisplay
171 rankedRecent []rankedRecent176 rankedRecent []rankedRecent
177+ // showRanks prefixes each mru entry with its Ctrl+N rank while the
178+ // client holds Ctrl.
179+ showRanks bool
172 attentionFrame int180 attentionFrame int
173 // theme is the client's terminal theme, if reported. Its zero value181 // theme is the client's terminal theme, if reported. Its zero value
174 // (Theme{}, Known: false) is a valid "no theme" default that resolves to182 // (Theme{}, Known: false) is a valid "no theme" default that resolves to
@@ -304,6 +312,7 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached
304 state.rankedRecent = ranked312 state.rankedRecent = ranked
305 } else if routeSnapshot.Generation != 0 {313 } else if routeSnapshot.Generation != 0 {
306 state.mru = formatRecentRouteSnapshot(historyRouteSnapshot(routeSnapshot))314 state.mru = formatRecentRouteSnapshot(historyRouteSnapshot(routeSnapshot))
315+ state.showRanks = ac != nil && ac.ctrlHeld.Load()
307 }316 }
308 return state317 return state
309 }318 }
@@ -416,13 +425,13 @@ func recentRouteDisplayWidth(entry recentRouteDisplay) int {
416 return width425 return width
417 }426 }
418 427
419-func fitWholeRecentEntries[T any](entries []T, budget int, cost func(T) int) []T {428+func fitWholeRecentEntries[T any](entries []T, budget int, cost func(int, T) int) []T {
420 if budget <= 0 || len(entries) == 0 {429 if budget <= 0 || len(entries) == 0 {
421 return nil430 return nil
422 }431 }
423 used := 0432 used := 0
424 for i, entry := range entries {433 for i, entry := range entries {
425- used += cost(entry)434+ used += cost(i, entry)
426 if used > budget {435 if used > budget {
427 return entries[:i]436 return entries[:i]
428 }437 }
@@ -436,17 +445,30 @@ func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback stri
436 reserve = statusTextWidth(feedback) + 2445 reserve = statusTextWidth(feedback) + 2
437 }446 }
438 budget := rowLen - leftUsed - reserve447 budget := rowLen - leftUsed - reserve
439- return fitWholeRecentEntries(entries, budget, func(entry rankedRecent) int {448+ return fitWholeRecentEntries(entries, budget, func(_ int, entry rankedRecent) int {
440 return recentRouteDisplayWidth(recentRouteDisplay{449 return recentRouteDisplayWidth(recentRouteDisplay{
441 name: entry.name,450 name: entry.name,
442 kind: entry.kind,451 kind: entry.kind,
443 ephemeral: entry.ephemeral,452 ephemeral: entry.ephemeral,
444 attention: entry.attention,453 attention: entry.attention,
445- }) + statusTextWidth(strconv.Itoa(entry.rank)+":")454+ }) + rankPrefixWidth(entry.rank)
446 })455 })
447 }456 }
448 457
449-func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string) []recentRouteDisplay {458+func rankPrefixWidth(rank int) int {
459+ return statusTextWidth(strconv.Itoa(rank) + ":")
460+}
461+
462+// fitMRU keeps the leading entries that fit. ranked adds the "N:" rank prefix
463+// of each entry Ctrl+1..9 reaches to its width.
464+func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string, ranked bool) []recentRouteDisplay {
465+ cost := func(i int, entry recentRouteDisplay) int {
466+ width := recentRouteDisplayWidth(entry)
467+ if ranked && i < maxMRUSessions {
468+ width += rankPrefixWidth(i + 1)
469+ }
470+ return width
471+ }
450 // With no feedback, keep one blank trailing cell; with feedback, reserve472 // With no feedback, keep one blank trailing cell; with feedback, reserve
451 // its " text" width plus a one-cell gap so drawRightPlainText always fits.473 // its " text" width plus a one-cell gap so drawRightPlainText always fits.
452 copyReserve := 1474 copyReserve := 1
@@ -463,11 +485,11 @@ func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string)
463 budget -= mruFutureRightReserve(rowLen)485 budget -= mruFutureRightReserve(rowLen)
464 // Keep at least one recent session when it physically fits; the reserved486 // 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.487 // right side is only a budget preference, not a reason to hide all recents.
466- if firstCost := recentRouteDisplayWidth(entries[0]); firstCost <= physicalBudget && budget < firstCost {488+ if firstCost := cost(0, entries[0]); firstCost <= physicalBudget && budget < firstCost {
467 budget = firstCost489 budget = firstCost
468 }490 }
469 }491 }
470- return fitWholeRecentEntries(entries, budget, recentRouteDisplayWidth)492+ return fitWholeRecentEntries(entries, budget, cost)
471 }493 }
472 494
473 const (495 const (
M internal/usecase/daemon/status_test.go +34 -0
@@ -1090,6 +1090,40 @@ 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+ var tenEntries []recentRouteDisplay
1096+ for _, name := range "abcdefghij" {
1097+ tenEntries = append(tenEntries, recentRouteDisplay{name: string(name)})
1098+ }
1099+ tests := []struct {
1100+ name string
1101+ mru []recentRouteDisplay
1102+ showRanks bool
1103+ width int
1104+ want string
1105+ }{
1106+ {name: "names only", width: 44, want: " cur local neferwl@arch old "},
1107+ {name: "ranks while held", showRanks: true, width: 50, want: " cur 1:local 2:neferwl@arch 3:old "},
1108+ {name: "rank widths drop whole oldest entries", showRanks: true, width: 44, want: " cur 1:local 2:neferwl@arch "},
1109+ {name: "only ctrl digits get ranks", showRanks: true, width: 100, mru: tenEntries, want: " cur 1:a 2:b 3:c 4:d 5:e 6:f 7:g 8:h 9:i j "},
1110+ }
1111+ for _, tt := range tests {
1112+ t.Run(tt.name, func(t *testing.T) {
1113+ entries := mru
1114+ if tt.mru != nil {
1115+ entries = tt.mru
1116+ }
1117+ state := barState{status: statusSnapshot{session: "cur"}, mru: entries, showRanks: tt.showRanks}
1118+ row := make([]renderer.Cell, tt.width)
1119+
1120+ drawStatusBarState(row, state, resolveStyles(nil))
1121+
1122+ require.Equal(t, tt.want, strings.TrimRight(rowText(row), " ")+" ")
1123+ })
1124+ }
1125+}
1126+
10931127 func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) {
10941128 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}
10951129 styles := themeui.NewStyles(theme)
@@ -1090,6 +1090,40 @@ 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+ var tenEntries []recentRouteDisplay
1096+ for _, name := range "abcdefghij" {
1097+ tenEntries = append(tenEntries, recentRouteDisplay{name: string(name)})
1098+ }
1099+ tests := []struct {
1100+ name string
1101+ mru []recentRouteDisplay
1102+ showRanks bool
1103+ width int
1104+ want string
1105+ }{
1106+ {name: "names only", width: 44, want: " cur local neferwl@arch old "},
1107+ {name: "ranks while held", showRanks: true, width: 50, want: " cur 1:local 2:neferwl@arch 3:old "},
1108+ {name: "rank widths drop whole oldest entries", showRanks: true, width: 44, want: " cur 1:local 2:neferwl@arch "},
1109+ {name: "only ctrl digits get ranks", showRanks: true, width: 100, mru: tenEntries, want: " cur 1:a 2:b 3:c 4:d 5:e 6:f 7:g 8:h 9:i j "},
1110+ }
1111+ for _, tt := range tests {
1112+ t.Run(tt.name, func(t *testing.T) {
1113+ entries := mru
1114+ if tt.mru != nil {
1115+ entries = tt.mru
1116+ }
1117+ state := barState{status: statusSnapshot{session: "cur"}, mru: entries, showRanks: tt.showRanks}
1118+ row := make([]renderer.Cell, tt.width)
1119+
1120+ drawStatusBarState(row, state, resolveStyles(nil))
1121+
1122+ require.Equal(t, tt.want, strings.TrimRight(rowText(row), " ")+" ")
1123+ })
1124+ }
1125+}
1126+
1093 func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) {1127 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}1128 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)1129 styles := themeui.NewStyles(theme)
M internal/usecase/keys/kittykey/kittykey.go +74 -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,104 @@ 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+ // locks holds the Caps Lock and Num Lock bits Event.Mods leaves out.
121+ locks int
122+ // text holds the associated text (FlagText), control codes removed.
123+ text []rune
124+}
125+
126+func parseParams(params []byte) (fullKey, bool) {
111127 fields := splitByte(params, ';')
112128 if len(fields) > 3 {
113- return Event{}, false
129+ return fullKey{}, false
114130 }
115131 keys := splitByte(fields[0], ':')
116132 if len(keys) > 3 {
117- return Event{}, false
133+ return fullKey{}, false
118134 }
119135 code, ok := atoi(keys[0])
120- if !ok || code == 0 {
121- return Event{}, false
136+ if !ok {
137+ return fullKey{}, false
122138 }
123- ev := Event{Code: rune(code)}
139+ key := fullKey{}
140+ ev := &key.ev
141+ ev.Code = rune(code)
124142 if len(keys) > 1 && len(keys[1]) > 0 {
125143 v, ok := atoi(keys[1])
126144 if !ok {
127- return Event{}, false
145+ return fullKey{}, false
128146 }
129147 ev.Shifted = rune(v)
130148 }
131149 if len(keys) > 2 && len(keys[2]) > 0 {
132150 v, ok := atoi(keys[2])
133151 if !ok {
134- return Event{}, false
152+ return fullKey{}, false
135153 }
136154 ev.Base = rune(v)
137155 }
138156 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
157+ mods, event, ok := parseMods(fields[1])
158+ if !ok {
159+ return fullKey{}, false
146160 }
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
161+ ev.Mods = mods &^ modLocks
162+ key.locks = mods & modLocks
163+ ev.Release = event == eventRelease
164+ }
165+ // A third field carries associated text (FlagText). Only a Normalizer
166+ // needs it; re-encoding a key never does.
167+ if len(fields) > 2 && len(fields[2]) > 0 {
168+ for _, field := range splitByte(fields[2], ':') {
169+ v, ok := atoi(field)
170+ if !ok {
171+ return fullKey{}, false
152172 }
153- ev.Release = event == 3
173+ if r := rune(v); r >= 0x20 && (r < 0x7f || r > 0x9f) && utf8.ValidRune(r) {
174+ key.text = append(key.text, r)
175+ }
176+ }
177+ }
178+ return key, true
179+}
180+
181+// Event types; a repeat (2) is handled as a press.
182+const (
183+ eventPress = 1
184+ eventRelease = 3
185+)
186+
187+// parseMods decodes a "mods[:event]" field: the modifier bits, lock bits
188+// included, and the event type (press when absent).
189+func parseMods(field []byte) (mods, event int, ok bool) {
190+ sub := splitByte(field, ':')
191+ if len(sub) > 2 {
192+ return 0, 0, false
193+ }
194+ encoded, ok := atoi(sub[0])
195+ if !ok || encoded < 1 {
196+ return 0, 0, false
197+ }
198+ event = eventPress
199+ if len(sub) == 2 && len(sub[1]) > 0 {
200+ event, ok = atoi(sub[1])
201+ if !ok || event < eventPress || event > eventRelease {
202+ return 0, 0, false
154203 }
155204 }
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
205+ return encoded - 1, event, true
159206 }
160207
161208 // 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,104 @@ 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+ // locks holds the Caps Lock and Num Lock bits Event.Mods leaves out.
121+ locks int
122+ // text holds the associated text (FlagText), control codes removed.
123+ text []rune
124+}
125+
126+func parseParams(params []byte) (fullKey, bool) {
111 fields := splitByte(params, ';')127 fields := splitByte(params, ';')
112 if len(fields) > 3 {128 if len(fields) > 3 {
113- return Event{}, false129+ return fullKey{}, false
114 }130 }
115 keys := splitByte(fields[0], ':')131 keys := splitByte(fields[0], ':')
116 if len(keys) > 3 {132 if len(keys) > 3 {
117- return Event{}, false133+ return fullKey{}, false
118 }134 }
119 code, ok := atoi(keys[0])135 code, ok := atoi(keys[0])
120- if !ok || code == 0 {136+ if !ok {
121- return Event{}, false137+ return fullKey{}, false
122 }138 }
123- ev := Event{Code: rune(code)}139+ key := fullKey{}
140+ ev := &key.ev
141+ ev.Code = rune(code)
124 if len(keys) > 1 && len(keys[1]) > 0 {142 if len(keys) > 1 && len(keys[1]) > 0 {
125 v, ok := atoi(keys[1])143 v, ok := atoi(keys[1])
126 if !ok {144 if !ok {
127- return Event{}, false145+ return fullKey{}, false
128 }146 }
129 ev.Shifted = rune(v)147 ev.Shifted = rune(v)
130 }148 }
131 if len(keys) > 2 && len(keys[2]) > 0 {149 if len(keys) > 2 && len(keys[2]) > 0 {
132 v, ok := atoi(keys[2])150 v, ok := atoi(keys[2])
133 if !ok {151 if !ok {
134- return Event{}, false152+ return fullKey{}, false
135 }153 }
136 ev.Base = rune(v)154 ev.Base = rune(v)
137 }155 }
138 if len(fields) > 1 && len(fields[1]) > 0 {156 if len(fields) > 1 && len(fields[1]) > 0 {
139- sub := splitByte(fields[1], ':')157+ mods, event, ok := parseMods(fields[1])
140- if len(sub) > 2 {158+ if !ok {
141- return Event{}, false159+ return fullKey{}, false
142- }
143- mods, ok := atoi(sub[0])
144- if !ok || mods < 1 {
145- return Event{}, false
146 }160 }
147- ev.Mods = (mods - 1) &^ modLocks161+ ev.Mods = mods &^ modLocks
148- if len(sub) == 2 && len(sub[1]) > 0 {162+ key.locks = mods & modLocks
149- event, ok := atoi(sub[1])163+ ev.Release = event == eventRelease
150- if !ok || event < 1 || event > 3 {164+ }
151- return Event{}, false165+ // A third field carries associated text (FlagText). Only a Normalizer
166+ // needs it; re-encoding a key never does.
167+ if len(fields) > 2 && len(fields[2]) > 0 {
168+ for _, field := range splitByte(fields[2], ':') {
169+ v, ok := atoi(field)
170+ if !ok {
171+ return fullKey{}, false
152 }172 }
153- ev.Release = event == 3173+ if r := rune(v); r >= 0x20 && (r < 0x7f || r > 0x9f) && utf8.ValidRune(r) {
174+ key.text = append(key.text, r)
175+ }
176+ }
177+ }
178+ return key, true
179+}
180+
181+// Event types; a repeat (2) is handled as a press.
182+const (
183+ eventPress = 1
184+ eventRelease = 3
185+)
186+
187+// parseMods decodes a "mods[:event]" field: the modifier bits, lock bits
188+// included, and the event type (press when absent).
189+func parseMods(field []byte) (mods, event int, ok bool) {
190+ sub := splitByte(field, ':')
191+ if len(sub) > 2 {
192+ return 0, 0, false
193+ }
194+ encoded, ok := atoi(sub[0])
195+ if !ok || encoded < 1 {
196+ return 0, 0, false
197+ }
198+ event = eventPress
199+ if len(sub) == 2 && len(sub[1]) > 0 {
200+ event, ok = atoi(sub[1])
201+ if !ok || event < eventPress || event > eventRelease {
202+ return 0, 0, false
154 }203 }
155 }204 }
156- // A third field carries associated text (FlagText); it is not needed to205+ return encoded - 1, event, true
157- // re-encode the key and is intentionally ignored.
158- return ev, true
159 }206 }
160 207
161 // Translate rewrites every complete CSI u key sequence and kitty F1-F4 form in208 // Translate rewrites every complete CSI u key sequence and kitty F1-F4 form in
A internal/usecase/keys/kittykey/normalize.go +256 -0
@@ -0,0 +1,256 @@
1+package kittykey
2+
3+import (
4+ "bytes"
5+ "strconv"
6+ "unicode/utf8"
7+)
8+
9+// maxNormalizedSeq bounds one held key sequence. Associated text makes a key
10+// event as long as the text an IME commits at once.
11+const maxNormalizedSeq = 4096
12+
13+// Kitty functional key codes of the modifier and lock keys. RequestFlags
14+// reports them; OuterFlags never does.
15+const (
16+ keyCapsLock = 57358
17+ keyNumLock = 57360
18+ keyLeftShift = 57441
19+ keyLeftControl = 57442
20+ keyRightControl = 57448
21+ keyISOLevel5 = 57454
22+)
23+
24+// Normalizer rewrites the outer terminal's RequestFlags key stream into the
25+// OuterFlags encoding the rest of vev consumes, and tracks whether Ctrl is
26+// physically held. Release events, modifier and lock key events are dropped,
27+// repeats become presses, and text keys become their text again.
28+//
29+// Input already in the OuterFlags or legacy encoding passes through
30+// unchanged, so enabling a Normalizer before the terminal switches flags is
31+// safe. A trailing incomplete escape sequence is held until the next call. A
32+// lone trailing ESC is held only once a CSI u key event proved the terminal
33+// reports Escape as CSI 27 u; before that it may be a real Escape press.
34+// A Normalizer is not safe for concurrent use.
35+type Normalizer struct {
36+ pending []byte
37+ paste bool
38+ ctrl bool
39+ // kitty records that a CSI u key event arrived, so the terminal encodes
40+ // Escape itself as one and a lone ESC can only start a split sequence.
41+ kitty bool
42+}
43+
44+// CtrlHeld reports whether Ctrl is held on its own: a Ctrl key is down and no
45+// other key but a top-row digit was pressed since. Only a Ctrl key press sets
46+// it, so a terminal that reports no modifier key events never shows it held.
47+func (n *Normalizer) CtrlHeld() bool { return n.ctrl }
48+
49+// ReleaseCtrl forgets a held Ctrl, for a terminal that lost focus and may
50+// never report the release.
51+func (n *Normalizer) ReleaseCtrl() { n.ctrl = false }
52+
53+// Normalize returns data in the OuterFlags encoding, in a new slice.
54+func (n *Normalizer) Normalize(data []byte) []byte {
55+ if len(n.pending) > 0 {
56+ data = append(n.pending, data...)
57+ n.pending = nil
58+ }
59+ out := make([]byte, 0, len(data))
60+ for i := 0; i < len(data); {
61+ if n.paste {
62+ end := bytes.Index(data[i:], pasteClose)
63+ if end < 0 {
64+ keep := len(data) - partialSuffix(data[i:], pasteClose)
65+ out = append(out, data[i:keep]...)
66+ n.hold(data[keep:])
67+ return out
68+ }
69+ end += i + len(pasteClose)
70+ out = append(out, data[i:end]...)
71+ i = end
72+ n.paste = false
73+ continue
74+ }
75+ if data[i] != 0x1b {
76+ out = append(out, data[i])
77+ i++
78+ continue
79+ }
80+ rest := data[i:]
81+ if bytes.HasPrefix(rest, pasteOpen) {
82+ out = append(out, pasteOpen...)
83+ i += len(pasteOpen)
84+ n.paste = true
85+ continue
86+ }
87+ params, final, size, partial := scanCSI(rest)
88+ if partial && len(rest) == 1 && !n.kitty {
89+ out = append(out, rest...)
90+ return out
91+ }
92+ if partial {
93+ n.hold(rest)
94+ return out
95+ }
96+ if size > 0 {
97+ if repl, ok := n.rewrite(params, final); ok {
98+ out = append(out, repl...)
99+ i += size
100+ continue
101+ }
102+ }
103+ out = append(out, data[i])
104+ i++
105+ }
106+ return out
107+}
108+
109+func (n *Normalizer) hold(tail []byte) {
110+ if len(tail) > 0 {
111+ n.pending = append([]byte(nil), tail...)
112+ }
113+}
114+
115+// partialSuffix returns the length of the longest suffix of data that is a
116+// strict prefix of marker.
117+func partialSuffix(data, marker []byte) int {
118+ for k := min(len(data), len(marker)-1); k > 0; k-- {
119+ if bytes.HasPrefix(marker, data[len(data)-k:]) {
120+ return k
121+ }
122+ }
123+ return 0
124+}
125+
126+// scanCSI reads ESC [ params final at the start of data, where params holds
127+// only digits, ';' and ':'. size is 0 when data starts no such sequence;
128+// partial reports a strict prefix that may still complete.
129+func scanCSI(data []byte) (params []byte, final byte, size int, partial bool) {
130+ if len(data) == 1 {
131+ return nil, 0, 0, true
132+ }
133+ if data[1] != '[' {
134+ return nil, 0, 0, false
135+ }
136+ for i := 2; i < len(data) && i < maxNormalizedSeq; i++ {
137+ b := data[i]
138+ if (b >= '0' && b <= '9') || b == ';' || b == ':' {
139+ continue
140+ }
141+ return data[2:i], b, i + 1, false
142+ }
143+ return nil, 0, 0, len(data) < maxNormalizedSeq
144+}
145+
146+// rewrite returns the OuterFlags form of one key sequence, or false when the
147+// sequence is not a key event this package owns.
148+func (n *Normalizer) rewrite(params []byte, final byte) ([]byte, bool) {
149+ switch final {
150+ case 'u':
151+ if len(params) == 0 {
152+ return nil, false
153+ }
154+ key, ok := parseParams(params)
155+ if !ok {
156+ return nil, false
157+ }
158+ n.kitty = true
159+ n.track(key.ev, true)
160+ return encodeOuter(key), true
161+ case '~', 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'S':
162+ return n.rewriteFunctional(params, final)
163+ }
164+ return nil, false
165+}
166+
167+// rewriteFunctional handles CSI number;mods[:event] ~ and CSI 1;mods[:event]
168+// letter forms. Forms without a modifier field are already in OuterFlags
169+// form, which also keeps bracketed paste markers and replies untouched.
170+func (n *Normalizer) rewriteFunctional(params []byte, final byte) ([]byte, bool) {
171+ fields := splitByte(params, ';')
172+ if len(fields) != 2 {
173+ return nil, false
174+ }
175+ mods, event, ok := parseMods(fields[1])
176+ if !ok {
177+ return nil, false
178+ }
179+ n.track(Event{Mods: mods &^ modLocks, Release: event == eventRelease}, false)
180+ if event == eventRelease {
181+ return nil, true
182+ }
183+ // Functional keys keep their lock bits under OuterFlags.
184+ out := []byte("\x1b[")
185+ if mods != 0 || (final == '~' || string(fields[0]) != "1") {
186+ out = append(out, fields[0]...)
187+ }
188+ if mods != 0 {
189+ out = append(out, ';')
190+ out = strconv.AppendInt(out, int64(mods+1), 10)
191+ }
192+ return append(out, final), true
193+}
194+
195+// track updates the Ctrl state from one key event. A Ctrl key event reports
196+// the Ctrl state after it. Pressing any other key ends a Ctrl held on its own,
197+// except a top-row digit, the Ctrl+1..9 recent-session switch.
198+func (n *Normalizer) track(ev Event, unicodeKey bool) {
199+ switch {
200+ case unicodeKey && (ev.Code == keyLeftControl || ev.Code == keyRightControl):
201+ n.ctrl = ev.Mods&ModCtrl != 0
202+ case unicodeKey && ev.Code == 0:
203+ // A text-only event carries no key.
204+ case unicodeKey && isModifierOrLock(ev.Code):
205+ if ev.Mods&ModCtrl == 0 {
206+ n.ctrl = false
207+ }
208+ case ev.Mods&ModCtrl == 0:
209+ n.ctrl = false
210+ case ev.Release:
211+ case unicodeKey:
212+ if _, digit := ev.Digit(); !digit {
213+ n.ctrl = false
214+ }
215+ default:
216+ n.ctrl = false
217+ }
218+}
219+
220+// encodeOuter re-encodes one RequestFlags key event as the terminal would
221+// have sent it under OuterFlags, or nil when it would have sent nothing.
222+func encodeOuter(key fullKey) []byte {
223+ ev := key.ev
224+ if ev.Release || isModifierOrLock(ev.Code) {
225+ return nil
226+ }
227+ switch {
228+ case len(key.text) > 0 || ev.Code == 0:
229+ // A terminal sends the text a key generates as is, whatever the
230+ // modifiers that produced it.
231+ return appendRunes(nil, key.text)
232+ case ev.Mods == 0 && (ev.Code == KeyEnter || ev.Code == KeyTab || ev.Code == KeyBackspace):
233+ return []byte{byte(ev.Code)}
234+ case ev.Mods&^ModShift == 0 && isTextKey(ev.Code):
235+ return legacyText(ev)
236+ }
237+ // A key sent as an escape code reports the lock bits too.
238+ ev.Mods |= key.locks
239+ return encodeCSIu(ev, OuterFlags)
240+}
241+
242+func isModifierOrLock(code rune) bool {
243+ return (code >= keyLeftShift && code <= keyISOLevel5) || (code >= keyCapsLock && code <= keyNumLock)
244+}
245+
246+// isTextKey reports a key whose unmodified press produces its own text.
247+func isTextKey(code rune) bool {
248+ return code >= 0x20 && code != KeyBackspace && (code < 0xe000 || code > 0xf8ff) && utf8.ValidRune(code)
249+}
250+
251+func appendRunes(out []byte, runes []rune) []byte {
252+ for _, r := range runes {
253+ out = utf8.AppendRune(out, r)
254+ }
255+ return out
256+}
@@ -0,0 +1,256 @@
1+package kittykey
2+
3+import (
4+ "bytes"
5+ "strconv"
6+ "unicode/utf8"
7+)
8+
9+// maxNormalizedSeq bounds one held key sequence. Associated text makes a key
10+// event as long as the text an IME commits at once.
11+const maxNormalizedSeq = 4096
12+
13+// Kitty functional key codes of the modifier and lock keys. RequestFlags
14+// reports them; OuterFlags never does.
15+const (
16+ keyCapsLock = 57358
17+ keyNumLock = 57360
18+ keyLeftShift = 57441
19+ keyLeftControl = 57442
20+ keyRightControl = 57448
21+ keyISOLevel5 = 57454
22+)
23+
24+// Normalizer rewrites the outer terminal's RequestFlags key stream into the
25+// OuterFlags encoding the rest of vev consumes, and tracks whether Ctrl is
26+// physically held. Release events, modifier and lock key events are dropped,
27+// repeats become presses, and text keys become their text again.
28+//
29+// Input already in the OuterFlags or legacy encoding passes through
30+// unchanged, so enabling a Normalizer before the terminal switches flags is
31+// safe. A trailing incomplete escape sequence is held until the next call. A
32+// lone trailing ESC is held only once a CSI u key event proved the terminal
33+// reports Escape as CSI 27 u; before that it may be a real Escape press.
34+// A Normalizer is not safe for concurrent use.
35+type Normalizer struct {
36+ pending []byte
37+ paste bool
38+ ctrl bool
39+ // kitty records that a CSI u key event arrived, so the terminal encodes
40+ // Escape itself as one and a lone ESC can only start a split sequence.
41+ kitty bool
42+}
43+
44+// CtrlHeld reports whether Ctrl is held on its own: a Ctrl key is down and no
45+// other key but a top-row digit was pressed since. Only a Ctrl key press sets
46+// it, so a terminal that reports no modifier key events never shows it held.
47+func (n *Normalizer) CtrlHeld() bool { return n.ctrl }
48+
49+// ReleaseCtrl forgets a held Ctrl, for a terminal that lost focus and may
50+// never report the release.
51+func (n *Normalizer) ReleaseCtrl() { n.ctrl = false }
52+
53+// Normalize returns data in the OuterFlags encoding, in a new slice.
54+func (n *Normalizer) Normalize(data []byte) []byte {
55+ if len(n.pending) > 0 {
56+ data = append(n.pending, data...)
57+ n.pending = nil
58+ }
59+ out := make([]byte, 0, len(data))
60+ for i := 0; i < len(data); {
61+ if n.paste {
62+ end := bytes.Index(data[i:], pasteClose)
63+ if end < 0 {
64+ keep := len(data) - partialSuffix(data[i:], pasteClose)
65+ out = append(out, data[i:keep]...)
66+ n.hold(data[keep:])
67+ return out
68+ }
69+ end += i + len(pasteClose)
70+ out = append(out, data[i:end]...)
71+ i = end
72+ n.paste = false
73+ continue
74+ }
75+ if data[i] != 0x1b {
76+ out = append(out, data[i])
77+ i++
78+ continue
79+ }
80+ rest := data[i:]
81+ if bytes.HasPrefix(rest, pasteOpen) {
82+ out = append(out, pasteOpen...)
83+ i += len(pasteOpen)
84+ n.paste = true
85+ continue
86+ }
87+ params, final, size, partial := scanCSI(rest)
88+ if partial && len(rest) == 1 && !n.kitty {
89+ out = append(out, rest...)
90+ return out
91+ }
92+ if partial {
93+ n.hold(rest)
94+ return out
95+ }
96+ if size > 0 {
97+ if repl, ok := n.rewrite(params, final); ok {
98+ out = append(out, repl...)
99+ i += size
100+ continue
101+ }
102+ }
103+ out = append(out, data[i])
104+ i++
105+ }
106+ return out
107+}
108+
109+func (n *Normalizer) hold(tail []byte) {
110+ if len(tail) > 0 {
111+ n.pending = append([]byte(nil), tail...)
112+ }
113+}
114+
115+// partialSuffix returns the length of the longest suffix of data that is a
116+// strict prefix of marker.
117+func partialSuffix(data, marker []byte) int {
118+ for k := min(len(data), len(marker)-1); k > 0; k-- {
119+ if bytes.HasPrefix(marker, data[len(data)-k:]) {
120+ return k
121+ }
122+ }
123+ return 0
124+}
125+
126+// scanCSI reads ESC [ params final at the start of data, where params holds
127+// only digits, ';' and ':'. size is 0 when data starts no such sequence;
128+// partial reports a strict prefix that may still complete.
129+func scanCSI(data []byte) (params []byte, final byte, size int, partial bool) {
130+ if len(data) == 1 {
131+ return nil, 0, 0, true
132+ }
133+ if data[1] != '[' {
134+ return nil, 0, 0, false
135+ }
136+ for i := 2; i < len(data) && i < maxNormalizedSeq; i++ {
137+ b := data[i]
138+ if (b >= '0' && b <= '9') || b == ';' || b == ':' {
139+ continue
140+ }
141+ return data[2:i], b, i + 1, false
142+ }
143+ return nil, 0, 0, len(data) < maxNormalizedSeq
144+}
145+
146+// rewrite returns the OuterFlags form of one key sequence, or false when the
147+// sequence is not a key event this package owns.
148+func (n *Normalizer) rewrite(params []byte, final byte) ([]byte, bool) {
149+ switch final {
150+ case 'u':
151+ if len(params) == 0 {
152+ return nil, false
153+ }
154+ key, ok := parseParams(params)
155+ if !ok {
156+ return nil, false
157+ }
158+ n.kitty = true
159+ n.track(key.ev, true)
160+ return encodeOuter(key), true
161+ case '~', 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'S':
162+ return n.rewriteFunctional(params, final)
163+ }
164+ return nil, false
165+}
166+
167+// rewriteFunctional handles CSI number;mods[:event] ~ and CSI 1;mods[:event]
168+// letter forms. Forms without a modifier field are already in OuterFlags
169+// form, which also keeps bracketed paste markers and replies untouched.
170+func (n *Normalizer) rewriteFunctional(params []byte, final byte) ([]byte, bool) {
171+ fields := splitByte(params, ';')
172+ if len(fields) != 2 {
173+ return nil, false
174+ }
175+ mods, event, ok := parseMods(fields[1])
176+ if !ok {
177+ return nil, false
178+ }
179+ n.track(Event{Mods: mods &^ modLocks, Release: event == eventRelease}, false)
180+ if event == eventRelease {
181+ return nil, true
182+ }
183+ // Functional keys keep their lock bits under OuterFlags.
184+ out := []byte("\x1b[")
185+ if mods != 0 || (final == '~' || string(fields[0]) != "1") {
186+ out = append(out, fields[0]...)
187+ }
188+ if mods != 0 {
189+ out = append(out, ';')
190+ out = strconv.AppendInt(out, int64(mods+1), 10)
191+ }
192+ return append(out, final), true
193+}
194+
195+// track updates the Ctrl state from one key event. A Ctrl key event reports
196+// the Ctrl state after it. Pressing any other key ends a Ctrl held on its own,
197+// except a top-row digit, the Ctrl+1..9 recent-session switch.
198+func (n *Normalizer) track(ev Event, unicodeKey bool) {
199+ switch {
200+ case unicodeKey && (ev.Code == keyLeftControl || ev.Code == keyRightControl):
201+ n.ctrl = ev.Mods&ModCtrl != 0
202+ case unicodeKey && ev.Code == 0:
203+ // A text-only event carries no key.
204+ case unicodeKey && isModifierOrLock(ev.Code):
205+ if ev.Mods&ModCtrl == 0 {
206+ n.ctrl = false
207+ }
208+ case ev.Mods&ModCtrl == 0:
209+ n.ctrl = false
210+ case ev.Release:
211+ case unicodeKey:
212+ if _, digit := ev.Digit(); !digit {
213+ n.ctrl = false
214+ }
215+ default:
216+ n.ctrl = false
217+ }
218+}
219+
220+// encodeOuter re-encodes one RequestFlags key event as the terminal would
221+// have sent it under OuterFlags, or nil when it would have sent nothing.
222+func encodeOuter(key fullKey) []byte {
223+ ev := key.ev
224+ if ev.Release || isModifierOrLock(ev.Code) {
225+ return nil
226+ }
227+ switch {
228+ case len(key.text) > 0 || ev.Code == 0:
229+ // A terminal sends the text a key generates as is, whatever the
230+ // modifiers that produced it.
231+ return appendRunes(nil, key.text)
232+ case ev.Mods == 0 && (ev.Code == KeyEnter || ev.Code == KeyTab || ev.Code == KeyBackspace):
233+ return []byte{byte(ev.Code)}
234+ case ev.Mods&^ModShift == 0 && isTextKey(ev.Code):
235+ return legacyText(ev)
236+ }
237+ // A key sent as an escape code reports the lock bits too.
238+ ev.Mods |= key.locks
239+ return encodeCSIu(ev, OuterFlags)
240+}
241+
242+func isModifierOrLock(code rune) bool {
243+ return (code >= keyLeftShift && code <= keyISOLevel5) || (code >= keyCapsLock && code <= keyNumLock)
244+}
245+
246+// isTextKey reports a key whose unmodified press produces its own text.
247+func isTextKey(code rune) bool {
248+ return code >= 0x20 && code != KeyBackspace && (code < 0xe000 || code > 0xf8ff) && utf8.ValidRune(code)
249+}
250+
251+func appendRunes(out []byte, runes []rune) []byte {
252+ for _, r := range runes {
253+ out = utf8.AppendRune(out, r)
254+ }
255+ return out
256+}
A internal/usecase/keys/kittykey/normalize_test.go +169 -0
@@ -0,0 +1,169 @@
1+package kittykey
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestNormalizerRewritesRequestFlagsToOuterFlags(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ in string
14+ want string
15+ }{
16+ {name: "legacy passthrough", in: "hello\x1b[A\x1bOP\r", want: "hello\x1b[A\x1bOP\r"},
17+ {name: "outer csi u passthrough", in: "\x1b[97;5u\x1b[38::49;5u", want: "\x1b[97;5u\x1b[38::49;5u"},
18+ {name: "text key", in: "\x1b[97;;97u", want: "a"},
19+ {name: "text key without text field", in: "\x1b[97u", want: "a"},
20+ {name: "shift text", in: "\x1b[97:65;2;65u", want: "A"},
21+ {name: "shift without text uses shifted", in: "\x1b[97:65;2u", want: "A"},
22+ {name: "composed text event", in: "\x1b[0;;229u", want: "å"},
23+ {name: "multi codepoint text", in: "\x1b[101;;101:769u", want: "e\u0301"},
24+ {name: "control codes dropped from text", in: "\x1b[0;;7:97u", want: "a"},
25+ {name: "repeat is press", in: "\x1b[97;1:2;97u", want: "a"},
26+ {name: "release dropped", in: "\x1b[97;1:3u", want: ""},
27+ {name: "space", in: "\x1b[32;;32u", want: " "},
28+ {name: "enter", in: "\x1b[13u", want: "\r"},
29+ {name: "tab", in: "\x1b[9u", want: "\t"},
30+ {name: "backspace", in: "\x1b[127u", want: "\x7f"},
31+ {name: "shift enter stays csi u", in: "\x1b[13;2u", want: "\x1b[13;2u"},
32+ {name: "escape", in: "\x1b[27u", want: "\x1b[27u"},
33+ {name: "ctrl letter", in: "\x1b[99;5u", want: "\x1b[99;5u"},
34+ {name: "ctrl digit with base", in: "\x1b[38::49;5u", want: "\x1b[38::49;5u"},
35+ {name: "alt letter without text", in: "\x1b[97;3u", want: "\x1b[97;3u"},
36+ {name: "altgr text wins over modifiers", in: "\x1b[113;7;64u", want: "@"},
37+ {name: "long ime commit", in: "\x1b[0;;" + strings.Repeat("19968:", 11) + "19968u", want: strings.Repeat("一", 12)},
38+ {name: "caps lock dropped from text key", in: "\x1b[97;65;65u", want: "A"},
39+ {name: "caps lock kept on ctrl letter", in: "\x1b[97;69u", want: "\x1b[97;69u"},
40+ {name: "both locks kept on alt letter", in: "\x1b[97;195u", want: "\x1b[97;195u"},
41+ {name: "num lock kept on escape", in: "\x1b[27;129u", want: "\x1b[27;129u"},
42+ {name: "caps lock kept on arrow", in: "\x1b[1;65A", want: "\x1b[1;65A"},
43+ {name: "caps lock arrow repeat", in: "\x1b[1;65:2A", want: "\x1b[1;65A"},
44+ {name: "caps lock delete", in: "\x1b[3;65~", want: "\x1b[3;65~"},
45+ {name: "ctrl press dropped", in: "\x1b[57442;5u", want: ""},
46+ {name: "ctrl release dropped", in: "\x1b[57442;1:3u", want: ""},
47+ {name: "shift key dropped", in: "\x1b[57441;2u", want: ""},
48+ {name: "caps lock dropped", in: "\x1b[57358;65u", want: ""},
49+ {name: "arrow unmodified passthrough", in: "\x1b[A", want: "\x1b[A"},
50+ {name: "arrow repeat", in: "\x1b[1;1:2A", want: "\x1b[A"},
51+ {name: "arrow release dropped", in: "\x1b[1;1:3A", want: ""},
52+ {name: "ctrl arrow", in: "\x1b[1;5C", want: "\x1b[1;5C"},
53+ {name: "ctrl arrow repeat", in: "\x1b[1;5:2C", want: "\x1b[1;5C"},
54+ {name: "delete release dropped", in: "\x1b[3;1:3~", want: ""},
55+ {name: "delete repeat", in: "\x1b[3;1:2~", want: "\x1b[3~"},
56+ {name: "ctrl delete", in: "\x1b[3;5~", want: "\x1b[3;5~"},
57+ {name: "mouse untouched", in: "\x1b[<0;1;1M", want: "\x1b[<0;1;1M"},
58+ {name: "keyboard reply untouched", in: "\x1b[?5u", want: "\x1b[?5u"},
59+ {name: "cursor report untouched", in: "\x1b[12;40R", want: "\x1b[12;40R"},
60+ {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~"},
61+ {name: "lone esc passes before any kitty key", in: "x\x1b", want: "x\x1b"},
62+ {name: "lone esc held after a kitty key", in: "\x1b[97;;97u\x1b", want: "a"},
63+ {name: "trailing garbage", in: "\x1b[97;;97u\x1b[zz", want: "a\x1b[zz"},
64+ {name: "bad event type kept", in: "\x1b[97;1:4u", want: "\x1b[97;1:4u"},
65+ }
66+ for _, tt := range tests {
67+ t.Run(tt.name, func(t *testing.T) {
68+ var n Normalizer
69+ require.Equal(t, tt.want, string(n.Normalize([]byte(tt.in))))
70+ })
71+ }
72+}
73+
74+func TestNormalizerHoldsSplitSequences(t *testing.T) {
75+ tests := []struct {
76+ name string
77+ reads []string
78+ want []string
79+ }{
80+ {name: "split csi u", reads: []string{"a\x1b[97;", ";98u"}, want: []string{"a", "b"}},
81+ {name: "split csi prefix", reads: []string{"\x1b[", "57442;1:3u"}, want: []string{"", ""}},
82+ {name: "split paste close", reads: []string{"\x1b[200~x\x1b[20", "1~\x1b[97;;97u"}, want: []string{"\x1b[200~x", "\x1b[201~a"}},
83+ {name: "split after esc", reads: []string{"\x1b[27u\x1b", "[57442;5u", "\x1b", "[97;;97u"}, want: []string{"\x1b[27u", "", "", "a"}},
84+ {name: "legacy escape before the switch", reads: []string{"\x1b", "\x1b[97;;97u"}, want: []string{"\x1b", "a"}},
85+ {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~"}},
86+ }
87+ for _, tt := range tests {
88+ t.Run(tt.name, func(t *testing.T) {
89+ var n Normalizer
90+ for i, read := range tt.reads {
91+ require.Equal(t, tt.want[i], string(n.Normalize([]byte(read))), "read %d", i)
92+ }
93+ })
94+ }
95+}
96+
97+// TestNormalizerEverySplitMatchesOneRead feeds each input split at every byte
98+// and requires the same output and Ctrl state as one read.
99+func TestNormalizerEverySplitMatchesOneRead(t *testing.T) {
100+ // Each input starts with a kitty key, as a terminal in RequestFlags mode
101+ // sends before any split can matter.
102+ inputs := []string{
103+ "\x1b[57442;5u\x1b[49;5u\x1b[49;5:3u",
104+ "a\x1b[97;;97u\x1b[57442;1:3u",
105+ "\x1b[200~\x1b[97;1:3u\x1b[201~\x1b[1;65:2A",
106+ "\x1b[0;;19968:19968u\x1b[27u",
107+ }
108+ for _, in := range inputs {
109+ var whole Normalizer
110+ whole.Normalize([]byte("\x1b[27u"))
111+ want := string(whole.Normalize([]byte(in)))
112+ for cut := 1; cut < len(in); cut++ {
113+ var n Normalizer
114+ n.Normalize([]byte("\x1b[27u"))
115+ got := string(n.Normalize([]byte(in[:cut]))) + string(n.Normalize([]byte(in[cut:])))
116+ require.Equal(t, want, got, "input %q cut %d", in, cut)
117+ require.Equal(t, whole.CtrlHeld(), n.CtrlHeld(), "input %q cut %d", in, cut)
118+ }
119+ }
120+}
121+
122+func TestNormalizerTracksCtrl(t *testing.T) {
123+ tests := []struct {
124+ name string
125+ reads []string
126+ held []bool
127+ }{
128+ {name: "left ctrl press and release", reads: []string{"\x1b[57442;5u", "\x1b[57442;1:3u"}, held: []bool{true, false}},
129+ {name: "right ctrl", reads: []string{"\x1b[57448;5u", "\x1b[57448;1:3u"}, held: []bool{true, false}},
130+ {name: "ctrl repeat keeps held", reads: []string{"\x1b[57442;5u", "\x1b[57442;5:2u"}, held: []bool{true, true}},
131+ {name: "one of two ctrl released", reads: []string{"\x1b[57442;5u", "\x1b[57448;5u", "\x1b[57448;5:3u"}, held: []bool{true, true, true}},
132+ {name: "ctrl digit keeps held", reads: []string{"\x1b[57442;5u", "\x1b[49;5u", "\x1b[49;5:3u"}, held: []bool{true, true, true}},
133+ {name: "key without ctrl clears", reads: []string{"\x1b[57442;5u", "\x1b[97;;97u"}, held: []bool{true, false}},
134+ {name: "arrow without ctrl clears", reads: []string{"\x1b[57442;5u", "\x1b[1;1:2A"}, held: []bool{true, false}},
135+ {name: "ctrl shortcut clears", reads: []string{"\x1b[57442;5u", "\x1b[114;5u", "\x1b[114;5:3u"}, held: []bool{true, false, false}},
136+ {name: "ctrl arrow clears", reads: []string{"\x1b[57442;5u", "\x1b[1;5C"}, held: []bool{true, false}},
137+ {name: "azerty ctrl digit keeps held", reads: []string{"\x1b[57442;5u", "\x1b[38::49;5u"}, held: []bool{true, true}},
138+ {name: "ctrl shift keeps held", reads: []string{"\x1b[57442;5u", "\x1b[57441;6u", "\x1b[57441;5:3u"}, held: []bool{true, true, true}},
139+ {name: "composed text keeps held", reads: []string{"\x1b[57442;5u", "\x1b[0;;229u"}, held: []bool{true, true}},
140+ {name: "legacy text never sets", reads: []string{"\x1b[97;5u"}, held: []bool{false}},
141+ {name: "shift does not set", reads: []string{"\x1b[57441;2u"}, held: []bool{false}},
142+ {name: "ctrl in paste ignored", reads: []string{"\x1b[200~\x1b[57442;5u\x1b[201~"}, held: []bool{false}},
143+ }
144+ for _, tt := range tests {
145+ t.Run(tt.name, func(t *testing.T) {
146+ var n Normalizer
147+ for i, read := range tt.reads {
148+ n.Normalize([]byte(read))
149+ require.Equal(t, tt.held[i], n.CtrlHeld(), "read %d", i)
150+ }
151+ })
152+ }
153+ var n Normalizer
154+ n.Normalize([]byte("\x1b[57442;5u"))
155+ n.ReleaseCtrl()
156+ require.False(t, n.CtrlHeld())
157+}
158+
159+func FuzzNormalize(f *testing.F) {
160+ 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"} {
161+ f.Add([]byte(seed))
162+ }
163+ f.Fuzz(func(t *testing.T, data []byte) {
164+ var n Normalizer
165+ out := n.Normalize(data)
166+ out = append(out, n.Normalize(nil)...)
167+ require.LessOrEqual(t, len(out), 4*len(data)+16)
168+ })
169+}
@@ -0,0 +1,169 @@
1+package kittykey
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestNormalizerRewritesRequestFlagsToOuterFlags(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ in string
14+ want string
15+ }{
16+ {name: "legacy passthrough", in: "hello\x1b[A\x1bOP\r", want: "hello\x1b[A\x1bOP\r"},
17+ {name: "outer csi u passthrough", in: "\x1b[97;5u\x1b[38::49;5u", want: "\x1b[97;5u\x1b[38::49;5u"},
18+ {name: "text key", in: "\x1b[97;;97u", want: "a"},
19+ {name: "text key without text field", in: "\x1b[97u", want: "a"},
20+ {name: "shift text", in: "\x1b[97:65;2;65u", want: "A"},
21+ {name: "shift without text uses shifted", in: "\x1b[97:65;2u", want: "A"},
22+ {name: "composed text event", in: "\x1b[0;;229u", want: "å"},
23+ {name: "multi codepoint text", in: "\x1b[101;;101:769u", want: "e\u0301"},
24+ {name: "control codes dropped from text", in: "\x1b[0;;7:97u", want: "a"},
25+ {name: "repeat is press", in: "\x1b[97;1:2;97u", want: "a"},
26+ {name: "release dropped", in: "\x1b[97;1:3u", want: ""},
27+ {name: "space", in: "\x1b[32;;32u", want: " "},
28+ {name: "enter", in: "\x1b[13u", want: "\r"},
29+ {name: "tab", in: "\x1b[9u", want: "\t"},
30+ {name: "backspace", in: "\x1b[127u", want: "\x7f"},
31+ {name: "shift enter stays csi u", in: "\x1b[13;2u", want: "\x1b[13;2u"},
32+ {name: "escape", in: "\x1b[27u", want: "\x1b[27u"},
33+ {name: "ctrl letter", in: "\x1b[99;5u", want: "\x1b[99;5u"},
34+ {name: "ctrl digit with base", in: "\x1b[38::49;5u", want: "\x1b[38::49;5u"},
35+ {name: "alt letter without text", in: "\x1b[97;3u", want: "\x1b[97;3u"},
36+ {name: "altgr text wins over modifiers", in: "\x1b[113;7;64u", want: "@"},
37+ {name: "long ime commit", in: "\x1b[0;;" + strings.Repeat("19968:", 11) + "19968u", want: strings.Repeat("一", 12)},
38+ {name: "caps lock dropped from text key", in: "\x1b[97;65;65u", want: "A"},
39+ {name: "caps lock kept on ctrl letter", in: "\x1b[97;69u", want: "\x1b[97;69u"},
40+ {name: "both locks kept on alt letter", in: "\x1b[97;195u", want: "\x1b[97;195u"},
41+ {name: "num lock kept on escape", in: "\x1b[27;129u", want: "\x1b[27;129u"},
42+ {name: "caps lock kept on arrow", in: "\x1b[1;65A", want: "\x1b[1;65A"},
43+ {name: "caps lock arrow repeat", in: "\x1b[1;65:2A", want: "\x1b[1;65A"},
44+ {name: "caps lock delete", in: "\x1b[3;65~", want: "\x1b[3;65~"},
45+ {name: "ctrl press dropped", in: "\x1b[57442;5u", want: ""},
46+ {name: "ctrl release dropped", in: "\x1b[57442;1:3u", want: ""},
47+ {name: "shift key dropped", in: "\x1b[57441;2u", want: ""},
48+ {name: "caps lock dropped", in: "\x1b[57358;65u", want: ""},
49+ {name: "arrow unmodified passthrough", in: "\x1b[A", want: "\x1b[A"},
50+ {name: "arrow repeat", in: "\x1b[1;1:2A", want: "\x1b[A"},
51+ {name: "arrow release dropped", in: "\x1b[1;1:3A", want: ""},
52+ {name: "ctrl arrow", in: "\x1b[1;5C", want: "\x1b[1;5C"},
53+ {name: "ctrl arrow repeat", in: "\x1b[1;5:2C", want: "\x1b[1;5C"},
54+ {name: "delete release dropped", in: "\x1b[3;1:3~", want: ""},
55+ {name: "delete repeat", in: "\x1b[3;1:2~", want: "\x1b[3~"},
56+ {name: "ctrl delete", in: "\x1b[3;5~", want: "\x1b[3;5~"},
57+ {name: "mouse untouched", in: "\x1b[<0;1;1M", want: "\x1b[<0;1;1M"},
58+ {name: "keyboard reply untouched", in: "\x1b[?5u", want: "\x1b[?5u"},
59+ {name: "cursor report untouched", in: "\x1b[12;40R", want: "\x1b[12;40R"},
60+ {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~"},
61+ {name: "lone esc passes before any kitty key", in: "x\x1b", want: "x\x1b"},
62+ {name: "lone esc held after a kitty key", in: "\x1b[97;;97u\x1b", want: "a"},
63+ {name: "trailing garbage", in: "\x1b[97;;97u\x1b[zz", want: "a\x1b[zz"},
64+ {name: "bad event type kept", in: "\x1b[97;1:4u", want: "\x1b[97;1:4u"},
65+ }
66+ for _, tt := range tests {
67+ t.Run(tt.name, func(t *testing.T) {
68+ var n Normalizer
69+ require.Equal(t, tt.want, string(n.Normalize([]byte(tt.in))))
70+ })
71+ }
72+}
73+
74+func TestNormalizerHoldsSplitSequences(t *testing.T) {
75+ tests := []struct {
76+ name string
77+ reads []string
78+ want []string
79+ }{
80+ {name: "split csi u", reads: []string{"a\x1b[97;", ";98u"}, want: []string{"a", "b"}},
81+ {name: "split csi prefix", reads: []string{"\x1b[", "57442;1:3u"}, want: []string{"", ""}},
82+ {name: "split paste close", reads: []string{"\x1b[200~x\x1b[20", "1~\x1b[97;;97u"}, want: []string{"\x1b[200~x", "\x1b[201~a"}},
83+ {name: "split after esc", reads: []string{"\x1b[27u\x1b", "[57442;5u", "\x1b", "[97;;97u"}, want: []string{"\x1b[27u", "", "", "a"}},
84+ {name: "legacy escape before the switch", reads: []string{"\x1b", "\x1b[97;;97u"}, want: []string{"\x1b", "a"}},
85+ {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~"}},
86+ }
87+ for _, tt := range tests {
88+ t.Run(tt.name, func(t *testing.T) {
89+ var n Normalizer
90+ for i, read := range tt.reads {
91+ require.Equal(t, tt.want[i], string(n.Normalize([]byte(read))), "read %d", i)
92+ }
93+ })
94+ }
95+}
96+
97+// TestNormalizerEverySplitMatchesOneRead feeds each input split at every byte
98+// and requires the same output and Ctrl state as one read.
99+func TestNormalizerEverySplitMatchesOneRead(t *testing.T) {
100+ // Each input starts with a kitty key, as a terminal in RequestFlags mode
101+ // sends before any split can matter.
102+ inputs := []string{
103+ "\x1b[57442;5u\x1b[49;5u\x1b[49;5:3u",
104+ "a\x1b[97;;97u\x1b[57442;1:3u",
105+ "\x1b[200~\x1b[97;1:3u\x1b[201~\x1b[1;65:2A",
106+ "\x1b[0;;19968:19968u\x1b[27u",
107+ }
108+ for _, in := range inputs {
109+ var whole Normalizer
110+ whole.Normalize([]byte("\x1b[27u"))
111+ want := string(whole.Normalize([]byte(in)))
112+ for cut := 1; cut < len(in); cut++ {
113+ var n Normalizer
114+ n.Normalize([]byte("\x1b[27u"))
115+ got := string(n.Normalize([]byte(in[:cut]))) + string(n.Normalize([]byte(in[cut:])))
116+ require.Equal(t, want, got, "input %q cut %d", in, cut)
117+ require.Equal(t, whole.CtrlHeld(), n.CtrlHeld(), "input %q cut %d", in, cut)
118+ }
119+ }
120+}
121+
122+func TestNormalizerTracksCtrl(t *testing.T) {
123+ tests := []struct {
124+ name string
125+ reads []string
126+ held []bool
127+ }{
128+ {name: "left ctrl press and release", reads: []string{"\x1b[57442;5u", "\x1b[57442;1:3u"}, held: []bool{true, false}},
129+ {name: "right ctrl", reads: []string{"\x1b[57448;5u", "\x1b[57448;1:3u"}, held: []bool{true, false}},
130+ {name: "ctrl repeat keeps held", reads: []string{"\x1b[57442;5u", "\x1b[57442;5:2u"}, held: []bool{true, true}},
131+ {name: "one of two ctrl released", reads: []string{"\x1b[57442;5u", "\x1b[57448;5u", "\x1b[57448;5:3u"}, held: []bool{true, true, true}},
132+ {name: "ctrl digit keeps held", reads: []string{"\x1b[57442;5u", "\x1b[49;5u", "\x1b[49;5:3u"}, held: []bool{true, true, true}},
133+ {name: "key without ctrl clears", reads: []string{"\x1b[57442;5u", "\x1b[97;;97u"}, held: []bool{true, false}},
134+ {name: "arrow without ctrl clears", reads: []string{"\x1b[57442;5u", "\x1b[1;1:2A"}, held: []bool{true, false}},
135+ {name: "ctrl shortcut clears", reads: []string{"\x1b[57442;5u", "\x1b[114;5u", "\x1b[114;5:3u"}, held: []bool{true, false, false}},
136+ {name: "ctrl arrow clears", reads: []string{"\x1b[57442;5u", "\x1b[1;5C"}, held: []bool{true, false}},
137+ {name: "azerty ctrl digit keeps held", reads: []string{"\x1b[57442;5u", "\x1b[38::49;5u"}, held: []bool{true, true}},
138+ {name: "ctrl shift keeps held", reads: []string{"\x1b[57442;5u", "\x1b[57441;6u", "\x1b[57441;5:3u"}, held: []bool{true, true, true}},
139+ {name: "composed text keeps held", reads: []string{"\x1b[57442;5u", "\x1b[0;;229u"}, held: []bool{true, true}},
140+ {name: "legacy text never sets", reads: []string{"\x1b[97;5u"}, held: []bool{false}},
141+ {name: "shift does not set", reads: []string{"\x1b[57441;2u"}, held: []bool{false}},
142+ {name: "ctrl in paste ignored", reads: []string{"\x1b[200~\x1b[57442;5u\x1b[201~"}, held: []bool{false}},
143+ }
144+ for _, tt := range tests {
145+ t.Run(tt.name, func(t *testing.T) {
146+ var n Normalizer
147+ for i, read := range tt.reads {
148+ n.Normalize([]byte(read))
149+ require.Equal(t, tt.held[i], n.CtrlHeld(), "read %d", i)
150+ }
151+ })
152+ }
153+ var n Normalizer
154+ n.Normalize([]byte("\x1b[57442;5u"))
155+ n.ReleaseCtrl()
156+ require.False(t, n.CtrlHeld())
157+}
158+
159+func FuzzNormalize(f *testing.F) {
160+ 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"} {
161+ f.Add([]byte(seed))
162+ }
163+ f.Fuzz(func(t *testing.T, data []byte) {
164+ var n Normalizer
165+ out := n.Normalize(data)
166+ out = append(out, n.Normalize(nil)...)
167+ require.LessOrEqual(t, len(out), 4*len(data)+16)
168+ })
169+}
M internal/usecase/theme/ramp.go +35 -4
@@ -1,6 +1,10 @@
11 package theme
22
3-import renderer "github.com/bnema/vev-vt"
3+import (
4+ "math"
5+
6+ renderer "github.com/bnema/vev-vt"
7+)
48
59 const (
610 normalTextContrast = 4.5
@@ -185,9 +189,36 @@ func mruWeight(index, count int) float64 {
185189 return 0.5 * (1 - float64(index)/float64(count))
186190 }
187191
192+// mruMaxTextDim is how far the oldest history entry's text fades toward its
193+// background: it keeps 70% of its strength.
194+const mruMaxTextDim = 0.3
195+
196+// mruTextDim is the share of the way from the text color to the background for
197+// a history entry: the newest keeps full text, the oldest fades by
198+// mruMaxTextDim, linearly in between: 3 entries → 0, 0.15, 0.3.
199+func mruTextDim(index, count int) float64 {
200+ if count <= 1 {
201+ return 0
202+ }
203+ return mruMaxTextDim * float64(index) / float64(count-1)
204+}
205+
206+// dimText fades text toward its surface by up to weight, never below
207+// normal-text contrast unless the undimmed text was already below it.
208+func dimText(text, surface renderer.RGB, weight float64) renderer.RGB {
209+ for step := int(math.Round(weight * 100)); step > 0; step-- {
210+ candidate := okLabLerp(text, surface, float64(step)/100)
211+ if ContrastRatio(candidate, surface) >= normalTextContrast {
212+ return candidate
213+ }
214+ }
215+ return text
216+}
217+
188218 // MRUStyle places history entries by mruWeight between the bar and the active
189-// surface. The background is never moved for contrast; only the text picks the
190-// better of the theme foreground and background.
219+// surface. The background is never moved for contrast; the text picks the
220+// better of the theme foreground and background, then fades by mruTextDim
221+// within normal-text contrast.
191222 func MRUStyle(ramp Ramp, index, count int) renderer.Style {
192223 if !ramp.rgb || count <= 0 {
193224 return ramp.SurfaceRecent
@@ -196,5 +227,5 @@ func MRUStyle(ramp Ramp, index, count int) renderer.Style {
196227 weight := mruWeight(index, count)
197228 background := okLabLerp(ramp.SurfaceBar.BackgroundRGB, ramp.SurfaceActive.BackgroundRGB, weight)
198229 foreground, _ := primaryText(Theme{Foreground: ramp.foreground, Background: ramp.background}, background)
199- return rgbSurface(foreground, background)
230+ return rgbSurface(dimText(foreground, background, mruTextDim(index, count)), background)
200231 }
@@ -1,6 +1,10 @@
1 package theme1 package theme
2 2
3-import renderer "github.com/bnema/vev-vt"3+import (
4+ "math"
5+
6+ renderer "github.com/bnema/vev-vt"
7+)
4 8
5 const (9 const (
6 normalTextContrast = 4.510 normalTextContrast = 4.5
@@ -185,9 +189,36 @@ func mruWeight(index, count int) float64 {
185 return 0.5 * (1 - float64(index)/float64(count))189 return 0.5 * (1 - float64(index)/float64(count))
186 }190 }
187 191
192+// mruMaxTextDim is how far the oldest history entry's text fades toward its
193+// background: it keeps 70% of its strength.
194+const mruMaxTextDim = 0.3
195+
196+// mruTextDim is the share of the way from the text color to the background for
197+// a history entry: the newest keeps full text, the oldest fades by
198+// mruMaxTextDim, linearly in between: 3 entries → 0, 0.15, 0.3.
199+func mruTextDim(index, count int) float64 {
200+ if count <= 1 {
201+ return 0
202+ }
203+ return mruMaxTextDim * float64(index) / float64(count-1)
204+}
205+
206+// dimText fades text toward its surface by up to weight, never below
207+// normal-text contrast unless the undimmed text was already below it.
208+func dimText(text, surface renderer.RGB, weight float64) renderer.RGB {
209+ for step := int(math.Round(weight * 100)); step > 0; step-- {
210+ candidate := okLabLerp(text, surface, float64(step)/100)
211+ if ContrastRatio(candidate, surface) >= normalTextContrast {
212+ return candidate
213+ }
214+ }
215+ return text
216+}
217+
188 // MRUStyle places history entries by mruWeight between the bar and the active218 // MRUStyle places history entries by mruWeight between the bar and the active
189-// surface. The background is never moved for contrast; only the text picks the219+// surface. The background is never moved for contrast; the text picks the
190-// better of the theme foreground and background.220+// better of the theme foreground and background, then fades by mruTextDim
221+// within normal-text contrast.
191 func MRUStyle(ramp Ramp, index, count int) renderer.Style {222 func MRUStyle(ramp Ramp, index, count int) renderer.Style {
192 if !ramp.rgb || count <= 0 {223 if !ramp.rgb || count <= 0 {
193 return ramp.SurfaceRecent224 return ramp.SurfaceRecent
@@ -196,5 +227,5 @@ func MRUStyle(ramp Ramp, index, count int) renderer.Style {
196 weight := mruWeight(index, count)227 weight := mruWeight(index, count)
197 background := okLabLerp(ramp.SurfaceBar.BackgroundRGB, ramp.SurfaceActive.BackgroundRGB, weight)228 background := okLabLerp(ramp.SurfaceBar.BackgroundRGB, ramp.SurfaceActive.BackgroundRGB, weight)
198 foreground, _ := primaryText(Theme{Foreground: ramp.foreground, Background: ramp.background}, background)229 foreground, _ := primaryText(Theme{Foreground: ramp.foreground, Background: ramp.background}, background)
199- return rgbSurface(foreground, background)230+ return rgbSurface(dimText(foreground, background, mruTextDim(index, count)), background)
200 }231 }
M internal/usecase/theme/ramp_test.go +46 -1
@@ -160,7 +160,10 @@ func TestMRUStylePlacesHistoryBetweenActiveAndBar(t *testing.T) {
160160 style := MRUStyle(ramp, index, count)
161161 require.Equal(t, okLabLerp(bar, active, weight), style.BackgroundRGB, "count=%d index=%d", count, index)
162162 foreground, _ := primaryText(tt.theme, style.BackgroundRGB)
163- require.Equal(t, foreground, style.ForegroundRGB)
163+ require.Equal(t, dimText(foreground, style.BackgroundRGB, mruTextDim(index, count)), style.ForegroundRGB)
164+ if ContrastRatio(foreground, style.BackgroundRGB) >= normalTextContrast {
165+ require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, style.BackgroundRGB), normalTextContrast, "count=%d index=%d keeps normal-text contrast", count, index)
166+ }
164167 }
165168 }
166169 require.Equal(t, okLabLerp(bar, active, 0.5), MRUStyle(ramp, 0, 1).BackgroundRGB, "the newest entry always sits halfway")
@@ -168,6 +171,48 @@ func TestMRUStylePlacesHistoryBetweenActiveAndBar(t *testing.T) {
168171 }
169172 }
170173
174+func TestMRUTextDimFadesOldestToSeventyPercent(t *testing.T) {
175+ tests := []struct {
176+ count int
177+ want []float64
178+ }{
179+ {count: 1, want: []float64{0}},
180+ {count: 2, want: []float64{0, 0.3}},
181+ {count: 3, want: []float64{0, 0.15, 0.3}},
182+ {count: 4, want: []float64{0, 0.1, 0.2, 0.3}},
183+ }
184+ for _, tt := range tests {
185+ for index, want := range tt.want {
186+ require.InDelta(t, want, mruTextDim(index, tt.count), 1e-9, "count=%d index=%d", tt.count, index)
187+ }
188+ }
189+}
190+
191+func TestDimTextStopsAtNormalTextContrast(t *testing.T) {
192+ black, white, grey := renderer.RGB{}, renderer.RGB{R: 255, G: 255, B: 255}, renderer.RGB{R: 0x80, G: 0x80, B: 0x80}
193+ tests := []struct {
194+ name string
195+ text renderer.RGB
196+ surface renderer.RGB
197+ want renderer.RGB
198+ }{
199+ {name: "high contrast fades fully", text: white, surface: black, want: okLabLerp(white, black, 0.3)},
200+ {name: "marginal contrast fades only to the floor", text: grey, surface: black},
201+ {name: "low contrast stays undimmed", text: grey, surface: grey, want: grey},
202+ }
203+ for _, tt := range tests {
204+ t.Run(tt.name, func(t *testing.T) {
205+ got := dimText(tt.text, tt.surface, 0.3)
206+ if tt.want != (renderer.RGB{}) {
207+ require.Equal(t, tt.want, got)
208+ }
209+ if ContrastRatio(tt.text, tt.surface) >= normalTextContrast {
210+ require.GreaterOrEqual(t, ContrastRatio(got, tt.surface), normalTextContrast)
211+ }
212+ })
213+ }
214+}
215+
171216 func TestMRUStyleWithoutRGBKeepsRecentSurface(t *testing.T) {
172217 ramp := neutralRamp(rampTheme(false))
173218 require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3))
@@ -160,7 +160,10 @@ func TestMRUStylePlacesHistoryBetweenActiveAndBar(t *testing.T) {
160 style := MRUStyle(ramp, index, count)160 style := MRUStyle(ramp, index, count)
161 require.Equal(t, okLabLerp(bar, active, weight), style.BackgroundRGB, "count=%d index=%d", count, index)161 require.Equal(t, okLabLerp(bar, active, weight), style.BackgroundRGB, "count=%d index=%d", count, index)
162 foreground, _ := primaryText(tt.theme, style.BackgroundRGB)162 foreground, _ := primaryText(tt.theme, style.BackgroundRGB)
163- require.Equal(t, foreground, style.ForegroundRGB)163+ require.Equal(t, dimText(foreground, style.BackgroundRGB, mruTextDim(index, count)), style.ForegroundRGB)
164+ if ContrastRatio(foreground, style.BackgroundRGB) >= normalTextContrast {
165+ require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, style.BackgroundRGB), normalTextContrast, "count=%d index=%d keeps normal-text contrast", count, index)
166+ }
164 }167 }
165 }168 }
166 require.Equal(t, okLabLerp(bar, active, 0.5), MRUStyle(ramp, 0, 1).BackgroundRGB, "the newest entry always sits halfway")169 require.Equal(t, okLabLerp(bar, active, 0.5), MRUStyle(ramp, 0, 1).BackgroundRGB, "the newest entry always sits halfway")
@@ -168,6 +171,48 @@ func TestMRUStylePlacesHistoryBetweenActiveAndBar(t *testing.T) {
168 }171 }
169 }172 }
170 173
174+func TestMRUTextDimFadesOldestToSeventyPercent(t *testing.T) {
175+ tests := []struct {
176+ count int
177+ want []float64
178+ }{
179+ {count: 1, want: []float64{0}},
180+ {count: 2, want: []float64{0, 0.3}},
181+ {count: 3, want: []float64{0, 0.15, 0.3}},
182+ {count: 4, want: []float64{0, 0.1, 0.2, 0.3}},
183+ }
184+ for _, tt := range tests {
185+ for index, want := range tt.want {
186+ require.InDelta(t, want, mruTextDim(index, tt.count), 1e-9, "count=%d index=%d", tt.count, index)
187+ }
188+ }
189+}
190+
191+func TestDimTextStopsAtNormalTextContrast(t *testing.T) {
192+ black, white, grey := renderer.RGB{}, renderer.RGB{R: 255, G: 255, B: 255}, renderer.RGB{R: 0x80, G: 0x80, B: 0x80}
193+ tests := []struct {
194+ name string
195+ text renderer.RGB
196+ surface renderer.RGB
197+ want renderer.RGB
198+ }{
199+ {name: "high contrast fades fully", text: white, surface: black, want: okLabLerp(white, black, 0.3)},
200+ {name: "marginal contrast fades only to the floor", text: grey, surface: black},
201+ {name: "low contrast stays undimmed", text: grey, surface: grey, want: grey},
202+ }
203+ for _, tt := range tests {
204+ t.Run(tt.name, func(t *testing.T) {
205+ got := dimText(tt.text, tt.surface, 0.3)
206+ if tt.want != (renderer.RGB{}) {
207+ require.Equal(t, tt.want, got)
208+ }
209+ if ContrastRatio(tt.text, tt.surface) >= normalTextContrast {
210+ require.GreaterOrEqual(t, ContrastRatio(got, tt.surface), normalTextContrast)
211+ }
212+ })
213+ }
214+}
215+
171 func TestMRUStyleWithoutRGBKeepsRecentSurface(t *testing.T) {216 func TestMRUStyleWithoutRGBKeepsRecentSurface(t *testing.T) {
172 ramp := neutralRamp(rampTheme(false))217 ramp := neutralRamp(rampTheme(false))
173 require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3))218 require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3))

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