detached 41d5105

feat(keys): kitty keyboard protocol and Ctrl+1..9 recent-session switching (#295)

* refactor(keys): split escape tokenizer from router

Move byte classification (paste, Alt arrow, control prefix, Alt rune,
partial sequences) into scanEscape so the Router only resolves bindings
and decides between Action and Forward. Behavior is unchanged.

* feat(keys): decode kitty keyboard protocol and re-encode per pane

Probe the outer terminal once (Kitty graphics, kitty keyboard, DA1) and,
when supported and keyboard.kitty-protocol is on, push disambiguate and
alternate-key flags on the alternate screen; every restore path pops them
before leaving it. The probe also restores the Kitty graphics declaration
that stopped being sent in Hello.

Hello.KittyKeyboard (protocol version 62) turns on CSI u decoding for that
attachment only. The shared kittykey codec rewrites key events for each
receiver: daemon overlays and client-local readers get legacy bytes, and
panes get the encoding their program requested through the vev-vt flag
stack. Alt bindings, including Alt+Shift, keep working in kitty mode, and
Ctrl+V image paste recognizes the kitty form on any layout.

* feat(keys): switch to recent sessions with Ctrl+1..9

On an attachment that enabled the kitty keyboard protocol, Ctrl+1..9
jumps to the Nth previous session of the client's status-bar history,
through the same route navigation as jump-recent-session. The keys are
fixed, match the PC-101 base key so AZERTY works, and do nothing past the
end of the history. Other terminals keep sending Ctrl+digits to the pane.
commit
41d5105cf2ad30037770ae228950be571ef1db7a
author
bnema · · 2026-09-26 08:19 UTC
committer
GitHub · · 2026-09-26 08:19 UTC
parent
60d0c9f
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
53 files · +1728 -159
M README.md +1 -0
@@ -69,6 +69,7 @@ vev --web-daemon start the browser terminal
6969 | Alt+Space | command palette |
7070 | Alt+f | floating terminal |
7171 | Alt+1 … 9 | switch tab |
72+| Ctrl+1 … 9 | switch to a recent session ([kitty keyboard terminals](docs/terminal.md#kitty-keyboard)) |
7273 | Alt+h/j/k/l or Alt+Arrow | focus pane |
7374 | Alt+a | jump to a session needing attention |
7475
@@ -69,6 +69,7 @@ vev --web-daemon start the browser terminal
69 | Alt+Space | command palette |69 | Alt+Space | command palette |
70 | Alt+f | floating terminal |70 | Alt+f | floating terminal |
71 | Alt+1 … 9 | switch tab |71 | Alt+1 … 9 | switch tab |
72+| Ctrl+1 … 9 | switch to a recent session ([kitty keyboard terminals](docs/terminal.md#kitty-keyboard)) |
72 | Alt+h/j/k/l or Alt+Arrow | focus pane |73 | Alt+h/j/k/l or Alt+Arrow | focus pane |
73 | Alt+a | jump to a session needing attention |74 | Alt+a | jump to a session needing attention |
74 75
M docs/configuration.md +4 -0
@@ -40,6 +40,9 @@ tabs.terminal-title = on
4040 # Remove a numbered (ephemeral) session when its last client exits.
4141 ephemeral.close-on-exit = on
4242
43+# Use the kitty keyboard protocol when the terminal supports it (enables Ctrl+1..9).
44+keyboard.kitty-protocol = on
45+
4346 # Rebindable actions. Leave a line out to keep its built-in binding.
4447 open-palette = alt+space
4548 toggle-floating-pane = alt+f
@@ -213,6 +216,7 @@ Valid keys: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` to `alt
213216
214217 - Setting an action replaces all its default keys. For example, setting `focus-pane-left` removes Alt+Left too.
215218 - Tab switching works with AZERTY and other layouts without extra config.
219+- Ctrl+1 to Ctrl+9 (recent sessions) are fixed and not rebindable. Turn them off with `keyboard.kitty-protocol = off`.
216220 - `alt+[` is not supported: terminals use it for escape sequences.
217221 - Cmd/Super is not supported. Map it in your terminal to `ESC` + a character, then bind `alt+<char>` in vev.
218222
@@ -40,6 +40,9 @@ tabs.terminal-title = on
40 # Remove a numbered (ephemeral) session when its last client exits.40 # Remove a numbered (ephemeral) session when its last client exits.
41 ephemeral.close-on-exit = on41 ephemeral.close-on-exit = on
42 42
43+# Use the kitty keyboard protocol when the terminal supports it (enables Ctrl+1..9).
44+keyboard.kitty-protocol = on
45+
43 # Rebindable actions. Leave a line out to keep its built-in binding.46 # Rebindable actions. Leave a line out to keep its built-in binding.
44 open-palette = alt+space47 open-palette = alt+space
45 toggle-floating-pane = alt+f48 toggle-floating-pane = alt+f
@@ -213,6 +216,7 @@ Valid keys: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` to `alt
213 216
214 - Setting an action replaces all its default keys. For example, setting `focus-pane-left` removes Alt+Left too.217 - Setting an action replaces all its default keys. For example, setting `focus-pane-left` removes Alt+Left too.
215 - Tab switching works with AZERTY and other layouts without extra config.218 - Tab switching works with AZERTY and other layouts without extra config.
219+- Ctrl+1 to Ctrl+9 (recent sessions) are fixed and not rebindable. Turn them off with `keyboard.kitty-protocol = off`.
216 - `alt+[` is not supported: terminals use it for escape sequences.220 - `alt+[` is not supported: terminals use it for escape sequences.
217 - Cmd/Super is not supported. Map it in your terminal to `ESC` + a character, then bind `alt+<char>` in vev.221 - Cmd/Super is not supported. Map it in your terminal to `ESC` + a character, then bind `alt+<char>` in vev.
218 222
M docs/terminal.md +10 -0
@@ -47,6 +47,16 @@ Not supported: file or shared-memory transfer, animations (first frame only), Un
4747
4848 Terminals without Kitty graphics get text only and one warning.
4949
50+## Kitty keyboard
51+
52+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.
53+
54+- Detection uses the same short query as Kitty graphics. Other terminals keep their usual keys, and Ctrl+digits reach the pane unchanged.
55+- It works with AZERTY and other layouts.
56+- Programs in panes that ask for the kitty keyboard protocol (Neovim, Helix, fish) receive it. Others get classic key bytes.
57+- Turn it off with `keyboard.kitty-protocol = off` in the [configuration](configuration.md).
58+- If vev is killed and your shell gets odd keys, run `printf '\e[<u'` to reset the terminal.
59+
5060 ## Environment
5161
5262 - New panes inherit the environment of the client that last attached. Running processes keep theirs.
@@ -47,6 +47,16 @@ Not supported: file or shared-memory transfer, animations (first frame only), Un
47 47
48 Terminals without Kitty graphics get text only and one warning.48 Terminals without Kitty graphics get text only and one warning.
49 49
50+## Kitty keyboard
51+
52+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.
53+
54+- Detection uses the same short query as Kitty graphics. Other terminals keep their usual keys, and Ctrl+digits reach the pane unchanged.
55+- It works with AZERTY and other layouts.
56+- Programs in panes that ask for the kitty keyboard protocol (Neovim, Helix, fish) receive it. Others get classic key bytes.
57+- Turn it off with `keyboard.kitty-protocol = off` in the [configuration](configuration.md).
58+- If vev is killed and your shell gets odd keys, run `printf '\e[<u'` to reset the terminal.
59+
50 ## Environment60 ## Environment
51 61
52 - New panes inherit the environment of the client that last attached. Running processes keep theirs.62 - New panes inherit the environment of the client that last attached. Running processes keep theirs.
M go.mod +1 -1
@@ -3,7 +3,7 @@ module github.com/bnema/vev
33 go 1.27
44
55 require (
6- github.com/bnema/vev-vt v0.5.2-0.20260909134859-55a2b5aa1b3a
6+ github.com/bnema/vev-vt v0.5.2-0.20260926054010-6ae9bc5fde91
77 github.com/coder/websocket v1.8.15
88 github.com/quic-go/quic-go v0.62.0
99 github.com/stretchr/testify v1.12.1
@@ -3,7 +3,7 @@ module github.com/bnema/vev
3 go 1.273 go 1.27
4 4
5 require (5 require (
6- github.com/bnema/vev-vt v0.5.2-0.20260909134859-55a2b5aa1b3a6+ github.com/bnema/vev-vt v0.5.2-0.20260926054010-6ae9bc5fde91
7 github.com/coder/websocket v1.8.157 github.com/coder/websocket v1.8.15
8 github.com/quic-go/quic-go v0.62.08 github.com/quic-go/quic-go v0.62.0
9 github.com/stretchr/testify v1.12.19 github.com/stretchr/testify v1.12.1
M go.sum +2 -0 generated file: collapsed
M internal/adapters/config/config.go +9 -0
@@ -36,6 +36,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
3636 seenNavKeys := make(map[string]bool)
3737 seenTabsKeys := make(map[string]bool)
3838 seenEphemeralKeys := make(map[string]bool)
39+ seenKeyboardKeys := make(map[string]bool)
3940 seenScrollbackKeys := make(map[string]bool)
4041 seenWebKeys := make(map[string]bool)
4142
@@ -162,6 +163,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
162163 continue
163164 }
164165 cfg.Ephemeral.CloseOnExit = on
166+ case key == "keyboard.kitty-protocol":
167+ warnings = warnDuplicateKey(warnings, seenKeyboardKeys, key, lineNo)
168+ on, ok := parseOnOff(value)
169+ if !ok {
170+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid keyboard.kitty-protocol %q", value)})
171+ continue
172+ }
173+ cfg.Keyboard.KittyProtocol = on
165174 case key == "copy.word-separators":
166175 warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo)
167176 separators, ok := parseConfigString(value)
@@ -36,6 +36,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
36 seenNavKeys := make(map[string]bool)36 seenNavKeys := make(map[string]bool)
37 seenTabsKeys := make(map[string]bool)37 seenTabsKeys := make(map[string]bool)
38 seenEphemeralKeys := make(map[string]bool)38 seenEphemeralKeys := make(map[string]bool)
39+ seenKeyboardKeys := make(map[string]bool)
39 seenScrollbackKeys := make(map[string]bool)40 seenScrollbackKeys := make(map[string]bool)
40 seenWebKeys := make(map[string]bool)41 seenWebKeys := make(map[string]bool)
41 42
@@ -162,6 +163,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
162 continue163 continue
163 }164 }
164 cfg.Ephemeral.CloseOnExit = on165 cfg.Ephemeral.CloseOnExit = on
166+ case key == "keyboard.kitty-protocol":
167+ warnings = warnDuplicateKey(warnings, seenKeyboardKeys, key, lineNo)
168+ on, ok := parseOnOff(value)
169+ if !ok {
170+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid keyboard.kitty-protocol %q", value)})
171+ continue
172+ }
173+ cfg.Keyboard.KittyProtocol = on
165 case key == "copy.word-separators":174 case key == "copy.word-separators":
166 warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo)175 warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo)
167 separators, ok := parseConfigString(value)176 separators, ok := parseConfigString(value)
M internal/adapters/config/config_test.go +43 -0
@@ -352,6 +352,9 @@ func TestParse(t *testing.T) {
352352 if !tt.want.Ephemeral.CloseOnExit {
353353 tt.want.Ephemeral = domain.Defaults().Ephemeral
354354 }
355+ if !tt.want.Keyboard.KittyProtocol {
356+ tt.want.Keyboard = domain.Defaults().Keyboard
357+ }
355358 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
356359 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
357360 }
@@ -520,6 +523,46 @@ func TestParseEphemeralCloseOnExit(t *testing.T) {
520523 }
521524 }
522525
526+func TestParseKeyboardKittyProtocol(t *testing.T) {
527+ t.Parallel()
528+
529+ tests := []struct {
530+ name string
531+ input string
532+ want bool
533+ wantWarnings []domain.Warning
534+ }{
535+ {name: "absent key defaults on", input: "theme = dark\n", want: true},
536+ {name: "on", input: "keyboard.kitty-protocol = on\n", want: true},
537+ {name: "off", input: "keyboard.kitty-protocol = off\n", want: false},
538+ {
539+ name: "invalid value warns and keeps default",
540+ input: "keyboard.kitty-protocol = maybe\n",
541+ want: true,
542+ wantWarnings: []domain.Warning{
543+ {Line: 1, Msg: "invalid keyboard.kitty-protocol \"maybe\""},
544+ },
545+ },
546+ }
547+
548+ for _, tt := range tests {
549+ t.Run(tt.name, func(t *testing.T) {
550+ t.Parallel()
551+
552+ got, warnings, err := Parse(strings.NewReader(tt.input))
553+ if err != nil {
554+ t.Fatalf("Parse() error = %v", err)
555+ }
556+ if got.Keyboard.KittyProtocol != tt.want {
557+ t.Fatalf("Parse() Keyboard.KittyProtocol = %v, want %v", got.Keyboard.KittyProtocol, tt.want)
558+ }
559+ if !reflect.DeepEqual(warnings, tt.wantWarnings) {
560+ t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
561+ }
562+ })
563+ }
564+}
565+
523566 func TestParseNavigationOverflow(t *testing.T) {
524567 t.Parallel()
525568
@@ -352,6 +352,9 @@ func TestParse(t *testing.T) {
352 if !tt.want.Ephemeral.CloseOnExit {352 if !tt.want.Ephemeral.CloseOnExit {
353 tt.want.Ephemeral = domain.Defaults().Ephemeral353 tt.want.Ephemeral = domain.Defaults().Ephemeral
354 }354 }
355+ if !tt.want.Keyboard.KittyProtocol {
356+ tt.want.Keyboard = domain.Defaults().Keyboard
357+ }
355 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {358 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
356 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)359 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
357 }360 }
@@ -520,6 +523,46 @@ func TestParseEphemeralCloseOnExit(t *testing.T) {
520 }523 }
521 }524 }
522 525
526+func TestParseKeyboardKittyProtocol(t *testing.T) {
527+ t.Parallel()
528+
529+ tests := []struct {
530+ name string
531+ input string
532+ want bool
533+ wantWarnings []domain.Warning
534+ }{
535+ {name: "absent key defaults on", input: "theme = dark\n", want: true},
536+ {name: "on", input: "keyboard.kitty-protocol = on\n", want: true},
537+ {name: "off", input: "keyboard.kitty-protocol = off\n", want: false},
538+ {
539+ name: "invalid value warns and keeps default",
540+ input: "keyboard.kitty-protocol = maybe\n",
541+ want: true,
542+ wantWarnings: []domain.Warning{
543+ {Line: 1, Msg: "invalid keyboard.kitty-protocol \"maybe\""},
544+ },
545+ },
546+ }
547+
548+ for _, tt := range tests {
549+ t.Run(tt.name, func(t *testing.T) {
550+ t.Parallel()
551+
552+ got, warnings, err := Parse(strings.NewReader(tt.input))
553+ if err != nil {
554+ t.Fatalf("Parse() error = %v", err)
555+ }
556+ if got.Keyboard.KittyProtocol != tt.want {
557+ t.Fatalf("Parse() Keyboard.KittyProtocol = %v, want %v", got.Keyboard.KittyProtocol, tt.want)
558+ }
559+ if !reflect.DeepEqual(warnings, tt.wantWarnings) {
560+ t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
561+ }
562+ })
563+ }
564+}
565+
523 func TestParseNavigationOverflow(t *testing.T) {566 func TestParseNavigationOverflow(t *testing.T) {
524 t.Parallel()567 t.Parallel()
525 568
M internal/adapters/sessionwire/proto_convert_session.go +2 -0
@@ -51,6 +51,7 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) {
5151 NavigationCapabilities: uint32(message.NavigationCapabilities),
5252 Remote: message.Remote,
5353 KittyDirectGraphics: message.KittyDirectGraphics,
54+ KittyKeyboard: message.KittyKeyboard,
5455 }, nil
5556 }
5657
@@ -133,6 +134,7 @@ func helloFromWire(message *wire.Hello) (protocol.Hello, error) {
133134 }
134135 hello.Remote = message.GetRemote()
135136 hello.KittyDirectGraphics = message.GetKittyDirectGraphics()
137+ hello.KittyKeyboard = message.GetKittyKeyboard()
136138 if err := protocol.ValidateHello(hello); err != nil {
137139 return protocol.Hello{}, err
138140 }
@@ -51,6 +51,7 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) {
51 NavigationCapabilities: uint32(message.NavigationCapabilities),51 NavigationCapabilities: uint32(message.NavigationCapabilities),
52 Remote: message.Remote,52 Remote: message.Remote,
53 KittyDirectGraphics: message.KittyDirectGraphics,53 KittyDirectGraphics: message.KittyDirectGraphics,
54+ KittyKeyboard: message.KittyKeyboard,
54 }, nil55 }, nil
55 }56 }
56 57
@@ -133,6 +134,7 @@ func helloFromWire(message *wire.Hello) (protocol.Hello, error) {
133 }134 }
134 hello.Remote = message.GetRemote()135 hello.Remote = message.GetRemote()
135 hello.KittyDirectGraphics = message.GetKittyDirectGraphics()136 hello.KittyDirectGraphics = message.GetKittyDirectGraphics()
137+ hello.KittyKeyboard = message.GetKittyKeyboard()
136 if err := protocol.ValidateHello(hello); err != nil {138 if err := protocol.ValidateHello(hello); err != nil {
137 return protocol.Hello{}, err139 return protocol.Hello{}, err
138 }140 }
M internal/adapters/sessionwire/proto_convert_test.go +1 -0
@@ -38,6 +38,7 @@ func TestProtoClientRoundTrips(t *testing.T) {
3838 target := protoTestTarget()
3939 messages := []protocol.ClientMessage{
4040 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}},
41+ protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, KittyDirectGraphics: true, KittyKeyboard: true},
4142 protocol.Input{InputSeq: 1, ActionID: 2, Data: []byte("x")},
4243 protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}},
4344 protocol.Detach{},
@@ -38,6 +38,7 @@ func TestProtoClientRoundTrips(t *testing.T) {
38 target := protoTestTarget()38 target := protoTestTarget()
39 messages := []protocol.ClientMessage{39 messages := []protocol.ClientMessage{
40 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}},40 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}},
41+ protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, KittyDirectGraphics: true, KittyKeyboard: true},
41 protocol.Input{InputSeq: 1, ActionID: 2, Data: []byte("x")},42 protocol.Input{InputSeq: 1, ActionID: 2, Data: []byte("x")},
42 protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}},43 protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}},
43 protocol.Detach{},44 protocol.Detach{},
M internal/adapters/term/terminal.go +32 -2
@@ -38,6 +38,7 @@ type Terminal struct {
3838 entered bool // true between a successful EnterRaw and its restore
3939 rawSkipped bool // true if fd wasn't a tty, so MakeRaw/Restore were skipped
4040 restoreFn func() error // the single idempotent restore closure for the current session
41+ kittyKeys bool // kitty keyboard flags were pushed and must be popped on restore
4142
4243 // Resize watcher state, also guarded by mu so watcher start
4344 // (ResizeEvents) and watcher stop (restore) are strictly ordered:
@@ -116,6 +117,34 @@ func (t *Terminal) EnterRaw() (func() error, error) {
116117 return t.restoreFn, nil
117118 }
118119
120+// EnableKittyKeyboard pushes kitty keyboard protocol flags on the alternate
121+// screen entered by EnterRaw. The session's restore pops them.
122+func (t *Terminal) EnableKittyKeyboard(flags int) error {
123+ t.mu.Lock()
124+ defer t.mu.Unlock()
125+ if !t.entered || t.kittyKeys {
126+ return nil
127+ }
128+ // Mark first: if any prefix reached the terminal, restore must pop.
129+ t.kittyKeys = true
130+ if _, err := t.bw.Write(KittyKeyboardPushSequence(flags)); err != nil {
131+ return fmt.Errorf("term: enable kitty keyboard: %w", err)
132+ }
133+ if err := t.bw.Flush(); err != nil {
134+ return fmt.Errorf("term: enable kitty keyboard: %w", err)
135+ }
136+ return nil
137+}
138+
139+// restoreSequenceLocked returns the visual cleanup for the current session.
140+// Must be called with t.mu held.
141+func (t *Terminal) restoreSequenceLocked() []byte {
142+ if t.kittyKeys {
143+ return VisualRestoreSequenceWithKittyKeyboard()
144+ }
145+ return VisualRestoreSequence()
146+}
147+
119148 // resetVisualModesDirect writes visualRestore straight to the descriptor
120149 // instead of through the batched writer. It is the fallback for a writer that
121150 // has already failed: any prefix of the enter sequence may have reached the
@@ -127,7 +156,7 @@ func (t *Terminal) resetVisualModesDirect() {
127156 if t.observation != nil {
128157 t.observation.InvalidateTerminalObservation()
129158 }
130- _, _ = t.out.Write(VisualRestoreSequence())
159+ _, _ = t.out.Write(t.restoreSequenceLocked())
131160 }
132161
133162 // makeRestoreLocked returns an idempotent restore closure for the
@@ -150,7 +179,7 @@ func (t *Terminal) restore() error {
150179 t.mu.Lock()
151180 defer t.mu.Unlock()
152181
153- _, werr := t.bw.Write(VisualRestoreSequence())
182+ _, werr := t.bw.Write(t.restoreSequenceLocked())
154183 ferr := t.bw.Flush()
155184 if werr != nil || ferr != nil {
156185 t.resetVisualModesDirect()
@@ -159,6 +188,7 @@ func (t *Terminal) restore() error {
159188 rerr := t.restoreRawLocked()
160189
161190 t.entered = false
191+ t.kittyKeys = false
162192 t.restoreFn = nil
163193
164194 t.stopResizeLocked()
@@ -38,6 +38,7 @@ type Terminal struct {
38 entered bool // true between a successful EnterRaw and its restore38 entered bool // true between a successful EnterRaw and its restore
39 rawSkipped bool // true if fd wasn't a tty, so MakeRaw/Restore were skipped39 rawSkipped bool // true if fd wasn't a tty, so MakeRaw/Restore were skipped
40 restoreFn func() error // the single idempotent restore closure for the current session40 restoreFn func() error // the single idempotent restore closure for the current session
41+ kittyKeys bool // kitty keyboard flags were pushed and must be popped on restore
41 42
42 // Resize watcher state, also guarded by mu so watcher start43 // Resize watcher state, also guarded by mu so watcher start
43 // (ResizeEvents) and watcher stop (restore) are strictly ordered:44 // (ResizeEvents) and watcher stop (restore) are strictly ordered:
@@ -116,6 +117,34 @@ func (t *Terminal) EnterRaw() (func() error, error) {
116 return t.restoreFn, nil117 return t.restoreFn, nil
117 }118 }
118 119
120+// EnableKittyKeyboard pushes kitty keyboard protocol flags on the alternate
121+// screen entered by EnterRaw. The session's restore pops them.
122+func (t *Terminal) EnableKittyKeyboard(flags int) error {
123+ t.mu.Lock()
124+ defer t.mu.Unlock()
125+ if !t.entered || t.kittyKeys {
126+ return nil
127+ }
128+ // Mark first: if any prefix reached the terminal, restore must pop.
129+ t.kittyKeys = true
130+ if _, err := t.bw.Write(KittyKeyboardPushSequence(flags)); err != nil {
131+ return fmt.Errorf("term: enable kitty keyboard: %w", err)
132+ }
133+ if err := t.bw.Flush(); err != nil {
134+ return fmt.Errorf("term: enable kitty keyboard: %w", err)
135+ }
136+ return nil
137+}
138+
139+// restoreSequenceLocked returns the visual cleanup for the current session.
140+// Must be called with t.mu held.
141+func (t *Terminal) restoreSequenceLocked() []byte {
142+ if t.kittyKeys {
143+ return VisualRestoreSequenceWithKittyKeyboard()
144+ }
145+ return VisualRestoreSequence()
146+}
147+
119 // resetVisualModesDirect writes visualRestore straight to the descriptor148 // resetVisualModesDirect writes visualRestore straight to the descriptor
120 // instead of through the batched writer. It is the fallback for a writer that149 // instead of through the batched writer. It is the fallback for a writer that
121 // has already failed: any prefix of the enter sequence may have reached the150 // has already failed: any prefix of the enter sequence may have reached the
@@ -127,7 +156,7 @@ func (t *Terminal) resetVisualModesDirect() {
127 if t.observation != nil {156 if t.observation != nil {
128 t.observation.InvalidateTerminalObservation()157 t.observation.InvalidateTerminalObservation()
129 }158 }
130- _, _ = t.out.Write(VisualRestoreSequence())159+ _, _ = t.out.Write(t.restoreSequenceLocked())
131 }160 }
132 161
133 // makeRestoreLocked returns an idempotent restore closure for the162 // makeRestoreLocked returns an idempotent restore closure for the
@@ -150,7 +179,7 @@ func (t *Terminal) restore() error {
150 t.mu.Lock()179 t.mu.Lock()
151 defer t.mu.Unlock()180 defer t.mu.Unlock()
152 181
153- _, werr := t.bw.Write(VisualRestoreSequence())182+ _, werr := t.bw.Write(t.restoreSequenceLocked())
154 ferr := t.bw.Flush()183 ferr := t.bw.Flush()
155 if werr != nil || ferr != nil {184 if werr != nil || ferr != nil {
156 t.resetVisualModesDirect()185 t.resetVisualModesDirect()
@@ -159,6 +188,7 @@ func (t *Terminal) restore() error {
159 rerr := t.restoreRawLocked()188 rerr := t.restoreRawLocked()
160 189
161 t.entered = false190 t.entered = false
191+ t.kittyKeys = false
162 t.restoreFn = nil192 t.restoreFn = nil
163 193
164 t.stopResizeLocked()194 t.stopResizeLocked()
M internal/adapters/term/terminal_test.go +53 -0
@@ -490,3 +490,56 @@ func TestTerminal_Size_NonTTY_ReturnsError(t *testing.T) {
490490 t.Fatalf("expected Geometry() to error on a non-tty fd")
491491 }
492492 }
493+
494+func TestTerminal_KittyKeyboardPushedInsideAltScreenAndPoppedBeforeExit(t *testing.T) {
495+ tests := []struct {
496+ name string
497+ enable bool
498+ before bool // enable before EnterRaw: must be a no-op
499+ want string
500+ }{
501+ {name: "disabled", want: visualEnter + visualRestore},
502+ {name: "enabled", enable: true, want: visualEnter + "\x1b[>5u" + kittyKeyboardPop + visualRestore},
503+ {name: "enable before enter is ignored", enable: true, before: true, want: visualEnter + visualRestore},
504+ }
505+ for _, tt := range tests {
506+ t.Run(tt.name, func(t *testing.T) {
507+ inR, inW, err := os.Pipe()
508+ if err != nil {
509+ t.Fatalf("os.Pipe(in): %v", err)
510+ }
511+ defer func() { _ = inW.Close() }()
512+ defer func() { _ = inR.Close() }()
513+ outR, outW, captured, done := pipeCapture(t)
514+ defer func() { _ = outR.Close() }()
515+
516+ tm := NewWithFiles(inR, outW)
517+ if tt.before {
518+ if err := tm.EnableKittyKeyboard(5); err != nil {
519+ t.Fatalf("EnableKittyKeyboard: %v", err)
520+ }
521+ }
522+ restore, err := tm.EnterRaw()
523+ if err != nil {
524+ t.Fatalf("EnterRaw: %v", err)
525+ }
526+ if tt.enable && !tt.before {
527+ if err := tm.EnableKittyKeyboard(5); err != nil {
528+ t.Fatalf("EnableKittyKeyboard: %v", err)
529+ }
530+ // A second enable must not push twice.
531+ if err := tm.EnableKittyKeyboard(5); err != nil {
532+ t.Fatalf("second EnableKittyKeyboard: %v", err)
533+ }
534+ }
535+ if err := restore(); err != nil {
536+ t.Fatalf("restore: %v", err)
537+ }
538+ _ = outW.Close()
539+ <-done
540+ if got := captured.String(); got != tt.want {
541+ t.Fatalf("captured escapes = %q, want %q", got, tt.want)
542+ }
543+ })
544+ }
545+}
@@ -490,3 +490,56 @@ func TestTerminal_Size_NonTTY_ReturnsError(t *testing.T) {
490 t.Fatalf("expected Geometry() to error on a non-tty fd")490 t.Fatalf("expected Geometry() to error on a non-tty fd")
491 }491 }
492 }492 }
493+
494+func TestTerminal_KittyKeyboardPushedInsideAltScreenAndPoppedBeforeExit(t *testing.T) {
495+ tests := []struct {
496+ name string
497+ enable bool
498+ before bool // enable before EnterRaw: must be a no-op
499+ want string
500+ }{
501+ {name: "disabled", want: visualEnter + visualRestore},
502+ {name: "enabled", enable: true, want: visualEnter + "\x1b[>5u" + kittyKeyboardPop + visualRestore},
503+ {name: "enable before enter is ignored", enable: true, before: true, want: visualEnter + visualRestore},
504+ }
505+ for _, tt := range tests {
506+ t.Run(tt.name, func(t *testing.T) {
507+ inR, inW, err := os.Pipe()
508+ if err != nil {
509+ t.Fatalf("os.Pipe(in): %v", err)
510+ }
511+ defer func() { _ = inW.Close() }()
512+ defer func() { _ = inR.Close() }()
513+ outR, outW, captured, done := pipeCapture(t)
514+ defer func() { _ = outR.Close() }()
515+
516+ tm := NewWithFiles(inR, outW)
517+ if tt.before {
518+ if err := tm.EnableKittyKeyboard(5); err != nil {
519+ t.Fatalf("EnableKittyKeyboard: %v", err)
520+ }
521+ }
522+ restore, err := tm.EnterRaw()
523+ if err != nil {
524+ t.Fatalf("EnterRaw: %v", err)
525+ }
526+ if tt.enable && !tt.before {
527+ if err := tm.EnableKittyKeyboard(5); err != nil {
528+ t.Fatalf("EnableKittyKeyboard: %v", err)
529+ }
530+ // A second enable must not push twice.
531+ if err := tm.EnableKittyKeyboard(5); err != nil {
532+ t.Fatalf("second EnableKittyKeyboard: %v", err)
533+ }
534+ }
535+ if err := restore(); err != nil {
536+ t.Fatalf("restore: %v", err)
537+ }
538+ _ = outW.Close()
539+ <-done
540+ if got := captured.String(); got != tt.want {
541+ t.Fatalf("captured escapes = %q, want %q", got, tt.want)
542+ }
543+ })
544+ }
545+}
M internal/adapters/term/visual.go +16 -0
@@ -1,5 +1,7 @@
11 package term
22
3+import "strconv"
4+
35 const (
46 altScreenEnter = "\x1b[?1049h"
57 altScreenExit = "\x1b[?1049l"
@@ -14,6 +16,7 @@ const (
1416 colorSchemeDisable = "\x1b[?2031l"
1517 autowrapDisable = "\x1b[?7l"
1618 autowrapEnable = "\x1b[?7h"
19+ kittyKeyboardPop = "\x1b[<u"
1720 )
1821
1922 const visualEnter = altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable
@@ -27,3 +30,16 @@ func VisualEnterSequence() []byte { return []byte(visualEnter) }
2730
2831 // VisualRestoreSequence returns a fresh copy of the idempotent visual cleanup.
2932 func VisualRestoreSequence() []byte { return []byte(visualRestore) }
33+
34+// KittyKeyboardPushSequence pushes kitty keyboard protocol flags. Send it
35+// after VisualEnterSequence so it lands on the alternate screen's stack.
36+func KittyKeyboardPushSequence(flags int) []byte {
37+ return []byte("\x1b[>" + strconv.Itoa(flags) + "u")
38+}
39+
40+// VisualRestoreSequenceWithKittyKeyboard pops the kitty keyboard flags pushed
41+// on the alternate screen before running the visual cleanup, so the main
42+// screen's keyboard mode is left as it was.
43+func VisualRestoreSequenceWithKittyKeyboard() []byte {
44+ return []byte(kittyKeyboardPop + visualRestore)
45+}
@@ -1,5 +1,7 @@
1 package term1 package term
2 2
3+import "strconv"
4+
3 const (5 const (
4 altScreenEnter = "\x1b[?1049h"6 altScreenEnter = "\x1b[?1049h"
5 altScreenExit = "\x1b[?1049l"7 altScreenExit = "\x1b[?1049l"
@@ -14,6 +16,7 @@ const (
14 colorSchemeDisable = "\x1b[?2031l"16 colorSchemeDisable = "\x1b[?2031l"
15 autowrapDisable = "\x1b[?7l"17 autowrapDisable = "\x1b[?7l"
16 autowrapEnable = "\x1b[?7h"18 autowrapEnable = "\x1b[?7h"
19+ kittyKeyboardPop = "\x1b[<u"
17 )20 )
18 21
19 const visualEnter = altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable22 const visualEnter = altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable
@@ -27,3 +30,16 @@ func VisualEnterSequence() []byte { return []byte(visualEnter) }
27 30
28 // VisualRestoreSequence returns a fresh copy of the idempotent visual cleanup.31 // VisualRestoreSequence returns a fresh copy of the idempotent visual cleanup.
29 func VisualRestoreSequence() []byte { return []byte(visualRestore) }32 func VisualRestoreSequence() []byte { return []byte(visualRestore) }
33+
34+// KittyKeyboardPushSequence pushes kitty keyboard protocol flags. Send it
35+// after VisualEnterSequence so it lands on the alternate screen's stack.
36+func KittyKeyboardPushSequence(flags int) []byte {
37+ return []byte("\x1b[>" + strconv.Itoa(flags) + "u")
38+}
39+
40+// VisualRestoreSequenceWithKittyKeyboard pops the kitty keyboard flags pushed
41+// on the alternate screen before running the visual cleanup, so the main
42+// screen's keyboard mode is left as it was.
43+func VisualRestoreSequenceWithKittyKeyboard() []byte {
44+ return []byte(kittyKeyboardPop + visualRestore)
45+}
M internal/adapters/uiterm/terminal.go +3 -0
@@ -124,6 +124,9 @@ func (t *Terminal) runReplies(ctx context.Context) {
124124 }
125125 }
126126
127+// EnableKittyKeyboard is a no-op: a virtual terminal has no outer keyboard.
128+func (t *Terminal) EnableKittyKeyboard(int) error { return nil }
129+
127130 func (t *Terminal) EnterRaw() (func() error, error) {
128131 if _, err := t.Write(term.VisualEnterSequence()); err != nil {
129132 return nil, err
@@ -124,6 +124,9 @@ func (t *Terminal) runReplies(ctx context.Context) {
124 }124 }
125 }125 }
126 126
127+// EnableKittyKeyboard is a no-op: a virtual terminal has no outer keyboard.
128+func (t *Terminal) EnableKittyKeyboard(int) error { return nil }
129+
127 func (t *Terminal) EnterRaw() (func() error, error) {130 func (t *Terminal) EnterRaw() (func() error, error) {
128 if _, err := t.Write(term.VisualEnterSequence()); err != nil {131 if _, err := t.Write(term.VisualEnterSequence()); err != nil {
129 return nil, err132 return nil, err
M internal/adapters/webterm/terminal.go +3 -0
@@ -76,6 +76,9 @@ func New(ctx context.Context, geometry domain.Geometry) (*Terminal, error) {
7676 return t, nil
7777 }
7878
79+// EnableKittyKeyboard is a no-op: a virtual terminal has no outer keyboard.
80+func (t *Terminal) EnableKittyKeyboard(int) error { return nil }
81+
7982 func (t *Terminal) EnterRaw() (func() error, error) {
8083 if _, err := t.Write(term.VisualEnterSequence()); err != nil {
8184 return nil, err
@@ -76,6 +76,9 @@ func New(ctx context.Context, geometry domain.Geometry) (*Terminal, error) {
76 return t, nil76 return t, nil
77 }77 }
78 78
79+// EnableKittyKeyboard is a no-op: a virtual terminal has no outer keyboard.
80+func (t *Terminal) EnableKittyKeyboard(int) error { return nil }
81+
79 func (t *Terminal) EnterRaw() (func() error, error) {82 func (t *Terminal) EnterRaw() (func() error, error) {
80 if _, err := t.Write(term.VisualEnterSequence()); err != nil {83 if _, err := t.Write(term.VisualEnterSequence()); err != nil {
81 return nil, err84 return nil, err
M internal/app/broker_client.go +11 -0
@@ -14,6 +14,7 @@ import (
1414 "github.com/bnema/vev/internal/adapters/term"
1515 "github.com/bnema/vev/internal/domain"
1616 "github.com/bnema/vev/internal/domain/terminalcap"
17+ "github.com/bnema/vev/internal/platform"
1718 "github.com/bnema/vev/internal/ports"
1819 "github.com/bnema/vev/internal/protocol"
1920 "github.com/bnema/vev/internal/protocol/catalogue"
@@ -613,6 +614,16 @@ func terminalAttachmentEnvironment() client.AttachmentEnvironment {
613614 }
614615 }
615616
617+// outerTerminalAttachmentEnvironment extends terminalAttachmentEnvironment for
618+// a client that owns a real outer terminal: it probes that terminal once and
619+// applies the user's keyboard setting. Virtual terminals never probe.
620+func outerTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment {
621+ env := terminalAttachmentEnvironment()
622+ env.ProbeTerminal = true
623+ env.KittyKeyboard = loadConfigOrDefaults(log, platform.ConfigPath()).Keyboard.KittyProtocol
624+ return env
625+}
626+
616627 // terminalSessionEnvironment captures one LocalCLI process snapshot for
617628 // session creation before NewSupervisor. The supervisor remaps each remote
618629 // request to Remote, so the base provenance stays LocalCLI without branching
@@ -14,6 +14,7 @@ import (
14 "github.com/bnema/vev/internal/adapters/term"14 "github.com/bnema/vev/internal/adapters/term"
15 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/domain/terminalcap"16 "github.com/bnema/vev/internal/domain/terminalcap"
17+ "github.com/bnema/vev/internal/platform"
17 "github.com/bnema/vev/internal/ports"18 "github.com/bnema/vev/internal/ports"
18 "github.com/bnema/vev/internal/protocol"19 "github.com/bnema/vev/internal/protocol"
19 "github.com/bnema/vev/internal/protocol/catalogue"20 "github.com/bnema/vev/internal/protocol/catalogue"
@@ -613,6 +614,16 @@ func terminalAttachmentEnvironment() client.AttachmentEnvironment {
613 }614 }
614 }615 }
615 616
617+// outerTerminalAttachmentEnvironment extends terminalAttachmentEnvironment for
618+// a client that owns a real outer terminal: it probes that terminal once and
619+// applies the user's keyboard setting. Virtual terminals never probe.
620+func outerTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment {
621+ env := terminalAttachmentEnvironment()
622+ env.ProbeTerminal = true
623+ env.KittyKeyboard = loadConfigOrDefaults(log, platform.ConfigPath()).Keyboard.KittyProtocol
624+ return env
625+}
626+
616 // terminalSessionEnvironment captures one LocalCLI process snapshot for627 // terminalSessionEnvironment captures one LocalCLI process snapshot for
617 // session creation before NewSupervisor. The supervisor remaps each remote628 // session creation before NewSupervisor. The supervisor remaps each remote
618 // request to Remote, so the base provenance stays LocalCLI without branching629 // request to Remote, so the base provenance stays LocalCLI without branching
M internal/app/broker_client_test.go +2 -0
@@ -427,6 +427,8 @@ func (i *terminalCompositionInput) Read(buffer []byte) (int, error) {
427427 return copy(buffer, chunk), nil
428428 }
429429
430+func (t *terminalCompositionTerminal) EnableKittyKeyboard(int) error { return nil }
431+
430432 func (t *terminalCompositionTerminal) EnterRaw() (func() error, error) {
431433 return func() error {
432434 t.mu.Lock()
@@ -427,6 +427,8 @@ func (i *terminalCompositionInput) Read(buffer []byte) (int, error) {
427 return copy(buffer, chunk), nil427 return copy(buffer, chunk), nil
428 }428 }
429 429
430+func (t *terminalCompositionTerminal) EnableKittyKeyboard(int) error { return nil }
431+
430 func (t *terminalCompositionTerminal) EnterRaw() (func() error, error) {432 func (t *terminalCompositionTerminal) EnterRaw() (func() error, error) {
431 return func() error {433 return func() error {
432 t.mu.Lock()434 t.mu.Lock()
M internal/app/broker_offline_client_render_test.go +2 -0
@@ -51,6 +51,8 @@ func newOfflineRenderTerminal(geometry domain.Geometry) *offlineRenderTerminal {
5151 return &offlineRenderTerminal{geometry: geometry}
5252 }
5353
54+func (t *offlineRenderTerminal) EnableKittyKeyboard(int) error { return nil }
55+
5456 func (t *offlineRenderTerminal) EnterRaw() (func() error, error) {
5557 return func() error { return nil }, nil
5658 }
@@ -51,6 +51,8 @@ func newOfflineRenderTerminal(geometry domain.Geometry) *offlineRenderTerminal {
51 return &offlineRenderTerminal{geometry: geometry}51 return &offlineRenderTerminal{geometry: geometry}
52 }52 }
53 53
54+func (t *offlineRenderTerminal) EnableKittyKeyboard(int) error { return nil }
55+
54 func (t *offlineRenderTerminal) EnterRaw() (func() error, error) {56 func (t *offlineRenderTerminal) EnterRaw() (func() error, error) {
55 return func() error { return nil }, nil57 return func() error { return nil }, nil
56 }58 }
M internal/app/run.go +1 -1
@@ -917,7 +917,7 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re
917917 return runBrokerClient(ctx, brokerClientConfig{
918918 Logger: log, Connector: withPreconnected(newProductionBrokerConnector(), preconnected), Terminal: terminal, Clock: clk,
919919 InitialNavigation: navigation, ResolveInitialNavigation: resolver,
920- AttachmentEnvironment: terminalAttachmentEnvironment(), SessionEnvironment: terminalSessionEnvironment(),
920+ AttachmentEnvironment: outerTerminalAttachmentEnvironment(log), SessionEnvironment: terminalSessionEnvironment(),
921921 OnState: callbacks.OnState, OnLifecycle: callbacks.OnLifecycle, OnFailure: callbacks.OnFailure,
922922 })
923923 }
@@ -917,7 +917,7 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re
917 return runBrokerClient(ctx, brokerClientConfig{917 return runBrokerClient(ctx, brokerClientConfig{
918 Logger: log, Connector: withPreconnected(newProductionBrokerConnector(), preconnected), Terminal: terminal, Clock: clk,918 Logger: log, Connector: withPreconnected(newProductionBrokerConnector(), preconnected), Terminal: terminal, Clock: clk,
919 InitialNavigation: navigation, ResolveInitialNavigation: resolver,919 InitialNavigation: navigation, ResolveInitialNavigation: resolver,
920- AttachmentEnvironment: terminalAttachmentEnvironment(), SessionEnvironment: terminalSessionEnvironment(),920+ AttachmentEnvironment: outerTerminalAttachmentEnvironment(log), SessionEnvironment: terminalSessionEnvironment(),
921 OnState: callbacks.OnState, OnLifecycle: callbacks.OnLifecycle, OnFailure: callbacks.OnFailure,921 OnState: callbacks.OnState, OnLifecycle: callbacks.OnLifecycle, OnFailure: callbacks.OnFailure,
922 })922 })
923 }923 }
M internal/domain/config.go +11 -0
@@ -91,6 +91,13 @@ type EphemeralConfig struct {
9191 CloseOnExit bool
9292 }
9393
94+// KeyboardConfig contains outer-terminal keyboard settings.
95+type KeyboardConfig struct {
96+ // KittyProtocol enables the kitty keyboard protocol on outer terminals
97+ // that support it, which makes Ctrl+1..9 recent-session switching work.
98+ KittyProtocol bool
99+}
100+
94101 // ScrollbackConfig is vev's per-pane retention policy. The terminal library
95102 // only enforces these application-selected limits. Zero megabytes disables
96103 // history; zero lines removes the additional line ceiling.
@@ -131,6 +138,7 @@ type Config struct {
131138 Nav NavConfig
132139 Tabs TabsConfig
133140 Ephemeral EphemeralConfig
141+ Keyboard KeyboardConfig
134142 Scrollback ScrollbackConfig
135143 }
136144
@@ -172,6 +180,9 @@ func Defaults() Config {
172180 Ephemeral: EphemeralConfig{
173181 CloseOnExit: true,
174182 },
183+ Keyboard: KeyboardConfig{
184+ KittyProtocol: true,
185+ },
175186 Scrollback: DefaultScrollbackConfig(),
176187 }
177188 }
@@ -91,6 +91,13 @@ type EphemeralConfig struct {
91 CloseOnExit bool91 CloseOnExit bool
92 }92 }
93 93
94+// KeyboardConfig contains outer-terminal keyboard settings.
95+type KeyboardConfig struct {
96+ // KittyProtocol enables the kitty keyboard protocol on outer terminals
97+ // that support it, which makes Ctrl+1..9 recent-session switching work.
98+ KittyProtocol bool
99+}
100+
94 // ScrollbackConfig is vev's per-pane retention policy. The terminal library101 // ScrollbackConfig is vev's per-pane retention policy. The terminal library
95 // only enforces these application-selected limits. Zero megabytes disables102 // only enforces these application-selected limits. Zero megabytes disables
96 // history; zero lines removes the additional line ceiling.103 // history; zero lines removes the additional line ceiling.
@@ -131,6 +138,7 @@ type Config struct {
131 Nav NavConfig138 Nav NavConfig
132 Tabs TabsConfig139 Tabs TabsConfig
133 Ephemeral EphemeralConfig140 Ephemeral EphemeralConfig
141+ Keyboard KeyboardConfig
134 Scrollback ScrollbackConfig142 Scrollback ScrollbackConfig
135 }143 }
136 144
@@ -172,6 +180,9 @@ func Defaults() Config {
172 Ephemeral: EphemeralConfig{180 Ephemeral: EphemeralConfig{
173 CloseOnExit: true,181 CloseOnExit: true,
174 },182 },
183+ Keyboard: KeyboardConfig{
184+ KittyProtocol: true,
185+ },
175 Scrollback: DefaultScrollbackConfig(),186 Scrollback: DefaultScrollbackConfig(),
176 }187 }
177 }188 }
M internal/ports/mocks/terminal_mock.go +51 -0
@@ -38,6 +38,57 @@ func (_m *MockTerminal) EXPECT() *MockTerminal_Expecter {
3838 return &MockTerminal_Expecter{mock: &_m.Mock}
3939 }
4040
41+// EnableKittyKeyboard provides a mock function for the type MockTerminal
42+func (_mock *MockTerminal) EnableKittyKeyboard(flags int) error {
43+ ret := _mock.Called(flags)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for EnableKittyKeyboard")
47+ }
48+
49+ var r0 error
50+ if returnFunc, ok := ret.Get(0).(func(int) error); ok {
51+ r0 = returnFunc(flags)
52+ } else {
53+ r0 = ret.Error(0)
54+ }
55+ return r0
56+}
57+
58+// MockTerminal_EnableKittyKeyboard_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnableKittyKeyboard'
59+type MockTerminal_EnableKittyKeyboard_Call struct {
60+ *mock.Call
61+}
62+
63+// EnableKittyKeyboard is a helper method to define mock.On call
64+// - flags int
65+func (_e *MockTerminal_Expecter) EnableKittyKeyboard(flags any) *MockTerminal_EnableKittyKeyboard_Call {
66+ return &MockTerminal_EnableKittyKeyboard_Call{Call: _e.mock.On("EnableKittyKeyboard", flags)}
67+}
68+
69+func (_c *MockTerminal_EnableKittyKeyboard_Call) Run(run func(flags int)) *MockTerminal_EnableKittyKeyboard_Call {
70+ _c.Call.Run(func(args mock.Arguments) {
71+ var arg0 int
72+ if args[0] != nil {
73+ arg0 = args[0].(int)
74+ }
75+ run(
76+ arg0,
77+ )
78+ })
79+ return _c
80+}
81+
82+func (_c *MockTerminal_EnableKittyKeyboard_Call) Return(err error) *MockTerminal_EnableKittyKeyboard_Call {
83+ _c.Call.Return(err)
84+ return _c
85+}
86+
87+func (_c *MockTerminal_EnableKittyKeyboard_Call) RunAndReturn(run func(flags int) error) *MockTerminal_EnableKittyKeyboard_Call {
88+ _c.Call.Return(run)
89+ return _c
90+}
91+
4192 // EnterRaw provides a mock function for the type MockTerminal
4293 func (_mock *MockTerminal) EnterRaw() (func() error, error) {
4394 ret := _mock.Called()
@@ -38,6 +38,57 @@ func (_m *MockTerminal) EXPECT() *MockTerminal_Expecter {
38 return &MockTerminal_Expecter{mock: &_m.Mock}38 return &MockTerminal_Expecter{mock: &_m.Mock}
39 }39 }
40 40
41+// EnableKittyKeyboard provides a mock function for the type MockTerminal
42+func (_mock *MockTerminal) EnableKittyKeyboard(flags int) error {
43+ ret := _mock.Called(flags)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for EnableKittyKeyboard")
47+ }
48+
49+ var r0 error
50+ if returnFunc, ok := ret.Get(0).(func(int) error); ok {
51+ r0 = returnFunc(flags)
52+ } else {
53+ r0 = ret.Error(0)
54+ }
55+ return r0
56+}
57+
58+// MockTerminal_EnableKittyKeyboard_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnableKittyKeyboard'
59+type MockTerminal_EnableKittyKeyboard_Call struct {
60+ *mock.Call
61+}
62+
63+// EnableKittyKeyboard is a helper method to define mock.On call
64+// - flags int
65+func (_e *MockTerminal_Expecter) EnableKittyKeyboard(flags any) *MockTerminal_EnableKittyKeyboard_Call {
66+ return &MockTerminal_EnableKittyKeyboard_Call{Call: _e.mock.On("EnableKittyKeyboard", flags)}
67+}
68+
69+func (_c *MockTerminal_EnableKittyKeyboard_Call) Run(run func(flags int)) *MockTerminal_EnableKittyKeyboard_Call {
70+ _c.Call.Run(func(args mock.Arguments) {
71+ var arg0 int
72+ if args[0] != nil {
73+ arg0 = args[0].(int)
74+ }
75+ run(
76+ arg0,
77+ )
78+ })
79+ return _c
80+}
81+
82+func (_c *MockTerminal_EnableKittyKeyboard_Call) Return(err error) *MockTerminal_EnableKittyKeyboard_Call {
83+ _c.Call.Return(err)
84+ return _c
85+}
86+
87+func (_c *MockTerminal_EnableKittyKeyboard_Call) RunAndReturn(run func(flags int) error) *MockTerminal_EnableKittyKeyboard_Call {
88+ _c.Call.Return(run)
89+ return _c
90+}
91+
41 // EnterRaw provides a mock function for the type MockTerminal92 // EnterRaw provides a mock function for the type MockTerminal
42 func (_mock *MockTerminal) EnterRaw() (func() error, error) {93 func (_mock *MockTerminal) EnterRaw() (func() error, error) {
43 ret := _mock.Called()94 ret := _mock.Called()
M internal/ports/terminal.go +5 -0
@@ -14,4 +14,9 @@ type Terminal interface {
1414 In() io.Reader
1515 Out() io.Writer
1616 Flush() error
17+ // EnableKittyKeyboard pushes the given kitty keyboard protocol flags on
18+ // the outer terminal's alternate screen. The restore returned by EnterRaw
19+ // pops them before leaving the alternate screen. Terminals without an
20+ // outer keyboard return nil and do nothing.
21+ EnableKittyKeyboard(flags int) error
1722 }
@@ -14,4 +14,9 @@ type Terminal interface {
14 In() io.Reader14 In() io.Reader
15 Out() io.Writer15 Out() io.Writer
16 Flush() error16 Flush() error
17+ // EnableKittyKeyboard pushes the given kitty keyboard protocol flags on
18+ // the outer terminal's alternate screen. The restore returned by EnterRaw
19+ // pops them before leaving the alternate screen. Terminals without an
20+ // outer keyboard return nil and do nothing.
21+ EnableKittyKeyboard(flags int) error
17 }22 }
M internal/protocol/session.go +5 -2
@@ -16,7 +16,7 @@ import (
1616 // daemonmux physical preamble conversation (multiplex.proto), whose
1717 // negotiated ceilings and accepted daemon binding ride the same wire
1818 // version as the session and broker conversations.
19-const Version uint16 = 61
19+const Version uint16 = 62
2020
2121 // HandshakeTimeout bounds every transport handshake from connect through the
2222 // first committed publication. It excludes the preceding client-local
@@ -112,7 +112,10 @@ type Hello struct {
112112 TrueColor bool
113113 // KittyDirectGraphics is an explicit declaration that the client has
114114 // probed its direct outer terminal and it accepts Kitty graphics output.
115- KittyDirectGraphics bool
115+ KittyDirectGraphics bool
116+ // KittyKeyboard declares that the client enabled the kitty keyboard
117+ // protocol on its outer terminal, so input carries kitty key sequences.
118+ KittyKeyboard bool
116119 MaxOutputInFlight uint8
117120 Env []string
118121 SessionTarget *SessionAttachTarget
@@ -16,7 +16,7 @@ import (
16 // daemonmux physical preamble conversation (multiplex.proto), whose16 // daemonmux physical preamble conversation (multiplex.proto), whose
17 // negotiated ceilings and accepted daemon binding ride the same wire17 // negotiated ceilings and accepted daemon binding ride the same wire
18 // version as the session and broker conversations.18 // version as the session and broker conversations.
19-const Version uint16 = 6119+const Version uint16 = 62
20 20
21 // HandshakeTimeout bounds every transport handshake from connect through the21 // HandshakeTimeout bounds every transport handshake from connect through the
22 // first committed publication. It excludes the preceding client-local22 // first committed publication. It excludes the preceding client-local
@@ -112,7 +112,10 @@ type Hello struct {
112 TrueColor bool112 TrueColor bool
113 // KittyDirectGraphics is an explicit declaration that the client has113 // KittyDirectGraphics is an explicit declaration that the client has
114 // probed its direct outer terminal and it accepts Kitty graphics output.114 // probed its direct outer terminal and it accepts Kitty graphics output.
115- KittyDirectGraphics bool115+ KittyDirectGraphics bool
116+ // KittyKeyboard declares that the client enabled the kitty keyboard
117+ // protocol on its outer terminal, so input carries kitty key sequences.
118+ KittyKeyboard bool
116 MaxOutputInFlight uint8119 MaxOutputInFlight uint8
117 Env []string120 Env []string
118 SessionTarget *SessionAttachTarget121 SessionTarget *SessionAttachTarget
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(61), Version)
13+ require.Equal(t, uint16(62), 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(61), Version)13+ require.Equal(t, uint16(62), 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_inventory_test.go +2 -2
@@ -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 != 61 {
19- t.Fatalf("protocol.Version = %d, want 61", protocol.Version)
18+ if protocol.Version != 62 {
19+ t.Fatalf("protocol.Version = %d, want 62", protocol.Version)
2020 }
2121 if ProtocolEpoch != 1 {
2222 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)
@@ -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 != 61 {18+ if protocol.Version != 62 {
19- t.Fatalf("protocol.Version = %d, want 61", protocol.Version)19+ t.Fatalf("protocol.Version = %d, want 62", 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)
M internal/protocol/wire/schema/session.proto +1 -0
@@ -33,6 +33,7 @@ message Hello {
3333 bool remote = 20;
3434 bool kitty_direct_graphics = 21;
3535 SessionAttachTarget session_target = 22; // optional pointer
36+ bool kitty_keyboard = 23;
3637 }
3738
3839 // Welcome answers an accepted attach.
@@ -33,6 +33,7 @@ message Hello {
33 bool remote = 20;33 bool remote = 20;
34 bool kitty_direct_graphics = 21;34 bool kitty_direct_graphics = 21;
35 SessionAttachTarget session_target = 22; // optional pointer35 SessionAttachTarget session_target = 22; // optional pointer
36+ bool kitty_keyboard = 23;
36 }37 }
37 38
38 // Welcome answers an accepted attach.39 // Welcome answers an accepted attach.
M internal/protocol/wire/session.pb.go +11 -2 generated file: collapsed
M internal/usecase/client/attachment_session.go +26 -19
@@ -68,12 +68,15 @@ func (e *AttachmentIdentityError) Error() string {
6868 // The request is the exact request the supervisor opened the stream with; the
6969 // rest is client-local presentation data the worker mirrors into Hello.
7070 type sessionAttachmentConfig struct {
71- Request ports.BrokerOpenStreamRequest
72- ClientID [16]byte
73- Geometry domain.Geometry
74- TermEnv string
75- Cwd string
76- TrueColor bool
71+ Request ports.BrokerOpenStreamRequest
72+ ClientID [16]byte
73+ Geometry domain.Geometry
74+ TermEnv string
75+ Cwd string
76+ TrueColor bool
77+ // Capabilities are the outer terminal's probed capabilities, declared in
78+ // Hello.
79+ Capabilities terminalCapabilities
7780 SessionEnvironment SessionEnvironment
7881 // BeforeAttached runs after the initial frame commits but before attached
7982 // presentation becomes observable. A failure keeps the run unattached.
@@ -705,19 +708,23 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr
705708 environmentPolicy = protocol.EnvironmentPolicyDaemonOwned
706709 }
707710 hello := protocol.Hello{
708- Version: protocol.Version,
709- Intent: protocol.IntentAttach,
710- ClientID: w.cfg.ClientID,
711- Size: geometry.Size,
712- PixelWidth: geometry.PixelWidth,
713- PixelHeight: geometry.PixelHeight,
714- TermEnv: w.cfg.TermEnv,
715- Cwd: cwd,
716- TrueColor: w.cfg.TrueColor,
717- MaxOutputInFlight: requestedOutputWindow(stream),
718- Env: env,
719- EnvironmentPolicy: environmentPolicy,
720- Remote: !request.Local,
711+ Version: protocol.Version,
712+ Intent: protocol.IntentAttach,
713+ ClientID: w.cfg.ClientID,
714+ Size: geometry.Size,
715+ PixelWidth: geometry.PixelWidth,
716+ PixelHeight: geometry.PixelHeight,
717+ TermEnv: w.cfg.TermEnv,
718+ Cwd: cwd,
719+ TrueColor: w.cfg.TrueColor,
720+ // A remote attach reaches the terminal through this client, so both
721+ // declarations describe this client's outer terminal.
722+ KittyDirectGraphics: w.cfg.Capabilities.KittyGraphics,
723+ KittyKeyboard: w.cfg.Capabilities.KittyKeyboard,
724+ MaxOutputInFlight: requestedOutputWindow(stream),
725+ Env: env,
726+ EnvironmentPolicy: environmentPolicy,
727+ Remote: !request.Local,
721728 }
722729 switch request.Admission {
723730 case ports.BrokerAdmissionExact:
@@ -68,12 +68,15 @@ func (e *AttachmentIdentityError) Error() string {
68 // The request is the exact request the supervisor opened the stream with; the68 // The request is the exact request the supervisor opened the stream with; the
69 // rest is client-local presentation data the worker mirrors into Hello.69 // rest is client-local presentation data the worker mirrors into Hello.
70 type sessionAttachmentConfig struct {70 type sessionAttachmentConfig struct {
71- Request ports.BrokerOpenStreamRequest71+ Request ports.BrokerOpenStreamRequest
72- ClientID [16]byte72+ ClientID [16]byte
73- Geometry domain.Geometry73+ Geometry domain.Geometry
74- TermEnv string74+ TermEnv string
75- Cwd string75+ Cwd string
76- TrueColor bool76+ TrueColor bool
77+ // Capabilities are the outer terminal's probed capabilities, declared in
78+ // Hello.
79+ Capabilities terminalCapabilities
77 SessionEnvironment SessionEnvironment80 SessionEnvironment SessionEnvironment
78 // BeforeAttached runs after the initial frame commits but before attached81 // BeforeAttached runs after the initial frame commits but before attached
79 // presentation becomes observable. A failure keeps the run unattached.82 // presentation becomes observable. A failure keeps the run unattached.
@@ -705,19 +708,23 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr
705 environmentPolicy = protocol.EnvironmentPolicyDaemonOwned708 environmentPolicy = protocol.EnvironmentPolicyDaemonOwned
706 }709 }
707 hello := protocol.Hello{710 hello := protocol.Hello{
708- Version: protocol.Version,711+ Version: protocol.Version,
709- Intent: protocol.IntentAttach,712+ Intent: protocol.IntentAttach,
710- ClientID: w.cfg.ClientID,713+ ClientID: w.cfg.ClientID,
711- Size: geometry.Size,714+ Size: geometry.Size,
712- PixelWidth: geometry.PixelWidth,715+ PixelWidth: geometry.PixelWidth,
713- PixelHeight: geometry.PixelHeight,716+ PixelHeight: geometry.PixelHeight,
714- TermEnv: w.cfg.TermEnv,717+ TermEnv: w.cfg.TermEnv,
715- Cwd: cwd,718+ Cwd: cwd,
716- TrueColor: w.cfg.TrueColor,719+ TrueColor: w.cfg.TrueColor,
717- MaxOutputInFlight: requestedOutputWindow(stream),720+ // A remote attach reaches the terminal through this client, so both
718- Env: env,721+ // declarations describe this client's outer terminal.
719- EnvironmentPolicy: environmentPolicy,722+ KittyDirectGraphics: w.cfg.Capabilities.KittyGraphics,
720- Remote: !request.Local,723+ KittyKeyboard: w.cfg.Capabilities.KittyKeyboard,
724+ MaxOutputInFlight: requestedOutputWindow(stream),
725+ Env: env,
726+ EnvironmentPolicy: environmentPolicy,
727+ Remote: !request.Local,
721 }728 }
722 switch request.Admission {729 switch request.Admission {
723 case ports.BrokerAdmissionExact:730 case ports.BrokerAdmissionExact:
M internal/usecase/client/attachment_worker_test.go +2 -0
@@ -122,6 +122,8 @@ func newWorkerTestTerminal() *workerTestTerminal {
122122 return &workerTestTerminal{resizes: make(chan domain.Geometry, 4)}
123123 }
124124
125+func (t *workerTestTerminal) EnableKittyKeyboard(int) error { return nil }
126+
125127 func (t *workerTestTerminal) EnterRaw() (func() error, error) {
126128 return func() error {
127129 t.mu.Lock()
@@ -122,6 +122,8 @@ func newWorkerTestTerminal() *workerTestTerminal {
122 return &workerTestTerminal{resizes: make(chan domain.Geometry, 4)}122 return &workerTestTerminal{resizes: make(chan domain.Geometry, 4)}
123 }123 }
124 124
125+func (t *workerTestTerminal) EnableKittyKeyboard(int) error { return nil }
126+
125 func (t *workerTestTerminal) EnterRaw() (func() error, error) {127 func (t *workerTestTerminal) EnterRaw() (func() error, error) {
126 return func() error {128 return func() error {
127 t.mu.Lock()129 t.mu.Lock()
M internal/usecase/client/clipboard_intercept.go +28 -6
@@ -8,6 +8,7 @@ import (
88
99 "github.com/bnema/vev/internal/ports"
1010 "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/usecase/keys/kittykey"
1112 )
1213
1314 // ctrlV is the byte a Ctrl+V keypress sends on the wire.
@@ -67,7 +68,7 @@ func (c *clipboardIntercept) Scan(data []byte) {
6768 boundary := pasteMarkerBoundary(data)
6869 segment := data[:boundary]
6970 for len(segment) > 0 {
70- idx := bytes.IndexByte(segment, ctrlV)
71+ idx, size := indexCtrlV(segment)
7172 if idx < 0 {
7273 c.next(segment)
7374 break
@@ -75,8 +76,9 @@ func (c *clipboardIntercept) Scan(data []byte) {
7576 if idx > 0 {
7677 c.next(segment[:idx])
7778 }
78- segment = segment[idx+1:]
79- c.handleCtrlV()
79+ raw := segment[idx : idx+size]
80+ segment = segment[idx+size:]
81+ c.handleCtrlV(raw)
8082 }
8183
8284 // Bytes at/after boundary are (the possible start of) a bracketed
@@ -104,7 +106,27 @@ func pasteMarkerBoundary(data []byte) int {
104106 return len(data)
105107 }
106108
107-func (c *clipboardIntercept) handleCtrlV() {
109+// indexCtrlV finds the first Ctrl+V keypress: the legacy 0x16 byte or a
110+// kitty keyboard protocol press of Ctrl+V on any layout.
111+func indexCtrlV(data []byte) (idx, size int) {
112+ for i, b := range data {
113+ switch b {
114+ case ctrlV:
115+ return i, 1
116+ case 0x1b:
117+ if ev, n, ok, _ := kittykey.Parse(data[i:]); ok && isKittyCtrlV(ev) {
118+ return i, n
119+ }
120+ }
121+ }
122+ return -1, 0
123+}
124+
125+func isKittyCtrlV(ev kittykey.Event) bool {
126+ return !ev.Release && ev.Mods == kittykey.ModCtrl && (ev.Code == 'v' || ev.Base == 'v')
127+}
128+
129+func (c *clipboardIntercept) handleCtrlV(raw []byte) {
108130 ctx := c.ctx
109131 if ctx == nil {
110132 ctx = context.Background()
@@ -117,7 +139,7 @@ func (c *clipboardIntercept) handleCtrlV() {
117139 c.sendNotice(protocol.ClientNoticeClipboardFallback)
118140 }
119141 }
120- c.next([]byte{ctrlV})
142+ c.next(raw)
121143 return
122144 }
123145 if len(data) > maxClipboardImagePush {
@@ -125,7 +147,7 @@ func (c *clipboardIntercept) handleCtrlV() {
125147 if c.sendNotice != nil {
126148 c.sendNotice(protocol.ClientNoticeClipboardTooLarge)
127149 }
128- c.next([]byte{ctrlV})
150+ c.next(raw)
129151 return
130152 }
131153 c.sendImage(mime, data)
@@ -8,6 +8,7 @@ import (
8 8
9 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/protocol"10 "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/usecase/keys/kittykey"
11 )12 )
12 13
13 // ctrlV is the byte a Ctrl+V keypress sends on the wire.14 // ctrlV is the byte a Ctrl+V keypress sends on the wire.
@@ -67,7 +68,7 @@ func (c *clipboardIntercept) Scan(data []byte) {
67 boundary := pasteMarkerBoundary(data)68 boundary := pasteMarkerBoundary(data)
68 segment := data[:boundary]69 segment := data[:boundary]
69 for len(segment) > 0 {70 for len(segment) > 0 {
70- idx := bytes.IndexByte(segment, ctrlV)71+ idx, size := indexCtrlV(segment)
71 if idx < 0 {72 if idx < 0 {
72 c.next(segment)73 c.next(segment)
73 break74 break
@@ -75,8 +76,9 @@ func (c *clipboardIntercept) Scan(data []byte) {
75 if idx > 0 {76 if idx > 0 {
76 c.next(segment[:idx])77 c.next(segment[:idx])
77 }78 }
78- segment = segment[idx+1:]79+ raw := segment[idx : idx+size]
79- c.handleCtrlV()80+ segment = segment[idx+size:]
81+ c.handleCtrlV(raw)
80 }82 }
81 83
82 // Bytes at/after boundary are (the possible start of) a bracketed84 // Bytes at/after boundary are (the possible start of) a bracketed
@@ -104,7 +106,27 @@ func pasteMarkerBoundary(data []byte) int {
104 return len(data)106 return len(data)
105 }107 }
106 108
107-func (c *clipboardIntercept) handleCtrlV() {109+// indexCtrlV finds the first Ctrl+V keypress: the legacy 0x16 byte or a
110+// kitty keyboard protocol press of Ctrl+V on any layout.
111+func indexCtrlV(data []byte) (idx, size int) {
112+ for i, b := range data {
113+ switch b {
114+ case ctrlV:
115+ return i, 1
116+ case 0x1b:
117+ if ev, n, ok, _ := kittykey.Parse(data[i:]); ok && isKittyCtrlV(ev) {
118+ return i, n
119+ }
120+ }
121+ }
122+ return -1, 0
123+}
124+
125+func isKittyCtrlV(ev kittykey.Event) bool {
126+ return !ev.Release && ev.Mods == kittykey.ModCtrl && (ev.Code == 'v' || ev.Base == 'v')
127+}
128+
129+func (c *clipboardIntercept) handleCtrlV(raw []byte) {
108 ctx := c.ctx130 ctx := c.ctx
109 if ctx == nil {131 if ctx == nil {
110 ctx = context.Background()132 ctx = context.Background()
@@ -117,7 +139,7 @@ func (c *clipboardIntercept) handleCtrlV() {
117 c.sendNotice(protocol.ClientNoticeClipboardFallback)139 c.sendNotice(protocol.ClientNoticeClipboardFallback)
118 }140 }
119 }141 }
120- c.next([]byte{ctrlV})142+ c.next(raw)
121 return143 return
122 }144 }
123 if len(data) > maxClipboardImagePush {145 if len(data) > maxClipboardImagePush {
@@ -125,7 +147,7 @@ func (c *clipboardIntercept) handleCtrlV() {
125 if c.sendNotice != nil {147 if c.sendNotice != nil {
126 c.sendNotice(protocol.ClientNoticeClipboardTooLarge)148 c.sendNotice(protocol.ClientNoticeClipboardTooLarge)
127 }149 }
128- c.next([]byte{ctrlV})150+ c.next(raw)
129 return151 return
130 }152 }
131 c.sendImage(mime, data)153 c.sendImage(mime, data)
A internal/usecase/client/clipboard_intercept_test.go +56 -0
@@ -0,0 +1,56 @@
1+package client
2+
3+import (
4+ "io"
5+ "log/slog"
6+ "testing"
7+
8+ "github.com/stretchr/testify/mock"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/ports"
12+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
13+)
14+
15+func TestClipboardInterceptCtrlV(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ in string
19+ hasImage bool
20+ reads bool
21+ wantNext []string
22+ wantImages int
23+ }{
24+ {name: "legacy with image", in: "a\x16b", hasImage: true, wantNext: []string{"a", "b"}, wantImages: 1},
25+ {name: "kitty with image", in: "a\x1b[118;5ub", hasImage: true, wantNext: []string{"a", "b"}, wantImages: 1},
26+ {name: "legacy without image forwards byte", in: "\x16", reads: true, wantNext: []string{"\x16"}},
27+ {name: "kitty without image forwards sequence", in: "\x1b[118;5u", reads: true, wantNext: []string{"\x1b[118;5u"}},
28+ {name: "kitty cyrillic layout base key", in: "\x1b[1084::118;5u", hasImage: true, wantImages: 1},
29+ {name: "kitty with caps lock", in: "\x1b[118;69u", hasImage: true, wantImages: 1},
30+ {name: "kitty release ignored", in: "\x1b[118;5:3u", wantNext: []string{"\x1b[118;5:3u"}},
31+ {name: "other kitty key untouched", in: "\x1b[118;7u", wantNext: []string{"\x1b[118;7u"}},
32+ {name: "inside paste untouched", in: "\x1b[200~\x1b[118;5u\x1b[201~", wantNext: []string{"\x1b[200~\x1b[118;5u\x1b[201~"}},
33+ }
34+ for _, tt := range tests {
35+ t.Run(tt.name, func(t *testing.T) {
36+ reader := portsmocks.NewMockClipboardReader(t)
37+ if tt.hasImage {
38+ reader.EXPECT().ReadImage(mock.Anything).Return("image/png", []byte{1}, nil).Times(tt.wantImages)
39+ } else if tt.reads {
40+ reader.EXPECT().ReadImage(mock.Anything).Return("", nil, ports.ErrNoClipboardImage).Once()
41+ }
42+ var next []string
43+ images := 0
44+ c := &clipboardIntercept{
45+ coalescer: newPasteCoalescer(nil, func([]byte) {}),
46+ reader: reader,
47+ log: slog.New(slog.NewTextHandler(io.Discard, nil)),
48+ sendImage: func(string, []byte) { images++ },
49+ next: func(b []byte) { next = append(next, string(b)) },
50+ }
51+ c.Scan([]byte(tt.in))
52+ require.Equal(t, tt.wantNext, next)
53+ require.Equal(t, tt.wantImages, images)
54+ })
55+ }
56+}
@@ -0,0 +1,56 @@
1+package client
2+
3+import (
4+ "io"
5+ "log/slog"
6+ "testing"
7+
8+ "github.com/stretchr/testify/mock"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/ports"
12+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
13+)
14+
15+func TestClipboardInterceptCtrlV(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ in string
19+ hasImage bool
20+ reads bool
21+ wantNext []string
22+ wantImages int
23+ }{
24+ {name: "legacy with image", in: "a\x16b", hasImage: true, wantNext: []string{"a", "b"}, wantImages: 1},
25+ {name: "kitty with image", in: "a\x1b[118;5ub", hasImage: true, wantNext: []string{"a", "b"}, wantImages: 1},
26+ {name: "legacy without image forwards byte", in: "\x16", reads: true, wantNext: []string{"\x16"}},
27+ {name: "kitty without image forwards sequence", in: "\x1b[118;5u", reads: true, wantNext: []string{"\x1b[118;5u"}},
28+ {name: "kitty cyrillic layout base key", in: "\x1b[1084::118;5u", hasImage: true, wantImages: 1},
29+ {name: "kitty with caps lock", in: "\x1b[118;69u", hasImage: true, wantImages: 1},
30+ {name: "kitty release ignored", in: "\x1b[118;5:3u", wantNext: []string{"\x1b[118;5:3u"}},
31+ {name: "other kitty key untouched", in: "\x1b[118;7u", wantNext: []string{"\x1b[118;7u"}},
32+ {name: "inside paste untouched", in: "\x1b[200~\x1b[118;5u\x1b[201~", wantNext: []string{"\x1b[200~\x1b[118;5u\x1b[201~"}},
33+ }
34+ for _, tt := range tests {
35+ t.Run(tt.name, func(t *testing.T) {
36+ reader := portsmocks.NewMockClipboardReader(t)
37+ if tt.hasImage {
38+ reader.EXPECT().ReadImage(mock.Anything).Return("image/png", []byte{1}, nil).Times(tt.wantImages)
39+ } else if tt.reads {
40+ reader.EXPECT().ReadImage(mock.Anything).Return("", nil, ports.ErrNoClipboardImage).Once()
41+ }
42+ var next []string
43+ images := 0
44+ c := &clipboardIntercept{
45+ coalescer: newPasteCoalescer(nil, func([]byte) {}),
46+ reader: reader,
47+ log: slog.New(slog.NewTextHandler(io.Discard, nil)),
48+ sendImage: func(string, []byte) { images++ },
49+ next: func(b []byte) { next = append(next, string(b)) },
50+ }
51+ c.Scan([]byte(tt.in))
52+ require.Equal(t, tt.wantNext, next)
53+ require.Equal(t, tt.wantImages, images)
54+ })
55+ }
56+}
M internal/usecase/client/move_picker_overlay.go +3 -0
@@ -8,6 +8,7 @@ import (
88 "github.com/bnema/vev/internal/domain"
99 "github.com/bnema/vev/internal/ports"
1010 "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/usecase/keys/kittykey"
1112 pickerusecase "github.com/bnema/vev/internal/usecase/picker"
1213 )
1314
@@ -230,6 +231,8 @@ func (p *attachmentMovePicker) release() {
230231 // supervised when the supervisor-owned navigation picker consumed them: its
231232 // action is then settled by the supervisor, never fenced through the daemon.
232233 func (p *attachmentMovePicker) consumeInput(ctx context.Context, state outputApplyState, input AttachmentInputEvent) (consumed, supervised bool, err error) {
234+ // Overlays decode legacy keys; the session path keeps kitty sequences.
235+ input.Data = kittykey.Translate(input.Data, 0)
233236 if p.move.presenting {
234237 p.stopEscape()
235238 op, changed := p.move.input(input.Data, input.actionID)
@@ -8,6 +8,7 @@ import (
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/protocol"10 "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/usecase/keys/kittykey"
11 pickerusecase "github.com/bnema/vev/internal/usecase/picker"12 pickerusecase "github.com/bnema/vev/internal/usecase/picker"
12 )13 )
13 14
@@ -230,6 +231,8 @@ func (p *attachmentMovePicker) release() {
230 // supervised when the supervisor-owned navigation picker consumed them: its231 // supervised when the supervisor-owned navigation picker consumed them: its
231 // action is then settled by the supervisor, never fenced through the daemon.232 // action is then settled by the supervisor, never fenced through the daemon.
232 func (p *attachmentMovePicker) consumeInput(ctx context.Context, state outputApplyState, input AttachmentInputEvent) (consumed, supervised bool, err error) {233 func (p *attachmentMovePicker) consumeInput(ctx context.Context, state outputApplyState, input AttachmentInputEvent) (consumed, supervised bool, err error) {
234+ // Overlays decode legacy keys; the session path keeps kitty sequences.
235+ input.Data = kittykey.Translate(input.Data, 0)
233 if p.move.presenting {236 if p.move.presenting {
234 p.stopEscape()237 p.stopEscape()
235 op, changed := p.move.input(input.Data, input.actionID)238 op, changed := p.move.input(input.Data, input.actionID)
M internal/usecase/client/picker_composition.go +4 -1
@@ -6,6 +6,7 @@ import (
66 "github.com/bnema/vev/internal/domain"
77 "github.com/bnema/vev/internal/ports"
88 "github.com/bnema/vev/internal/protocol"
9+ "github.com/bnema/vev/internal/usecase/keys/kittykey"
910 )
1011
1112 // Picker is the composition seam over the client-owned picker.
@@ -157,7 +158,9 @@ func (p *Picker) ConsumeTerminalRead(data []byte) bool {
157158 if p == nil || p.controller == nil {
158159 return false
159160 }
160- return p.controller.ConsumeTerminalRead(data)
161+ // The picker decodes legacy keys; kitty keyboard sequences are
162+ // translated back before it sees them.
163+ return p.controller.ConsumeTerminalRead(kittykey.Translate(data, 0))
161164 }
162165
163166 // Render returns the current picker frame for size, or nil when no catalogue
@@ -6,6 +6,7 @@ import (
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
8 "github.com/bnema/vev/internal/protocol"8 "github.com/bnema/vev/internal/protocol"
9+ "github.com/bnema/vev/internal/usecase/keys/kittykey"
9 )10 )
10 11
11 // Picker is the composition seam over the client-owned picker.12 // Picker is the composition seam over the client-owned picker.
@@ -157,7 +158,9 @@ func (p *Picker) ConsumeTerminalRead(data []byte) bool {
157 if p == nil || p.controller == nil {158 if p == nil || p.controller == nil {
158 return false159 return false
159 }160 }
160- return p.controller.ConsumeTerminalRead(data)161+ // The picker decodes legacy keys; kitty keyboard sequences are
162+ // translated back before it sees them.
163+ return p.controller.ConsumeTerminalRead(kittykey.Translate(data, 0))
161 }164 }
162 165
163 // Render returns the current picker frame for size, or nil when no catalogue166 // Render returns the current picker frame for size, or nil when no catalogue
M internal/usecase/client/picker_controller_test.go +2 -0
@@ -83,6 +83,8 @@ type pickerRecordingTerminal struct {
8383 enters int
8484 }
8585
86+func (t *pickerRecordingTerminal) EnableKittyKeyboard(int) error { return nil }
87+
8688 func (t *pickerRecordingTerminal) EnterRaw() (func() error, error) {
8789 t.mu.Lock()
8890 t.enters++
@@ -83,6 +83,8 @@ type pickerRecordingTerminal struct {
83 enters int83 enters int
84 }84 }
85 85
86+func (t *pickerRecordingTerminal) EnableKittyKeyboard(int) error { return nil }
87+
86 func (t *pickerRecordingTerminal) EnterRaw() (func() error, error) {88 func (t *pickerRecordingTerminal) EnterRaw() (func() error, error) {
87 t.mu.Lock()89 t.mu.Lock()
88 t.enters++90 t.enters++
M internal/usecase/client/supervisor.go +43 -1
@@ -14,6 +14,7 @@ import (
1414
1515 "github.com/bnema/vev/internal/domain"
1616 "github.com/bnema/vev/internal/ports"
17+ "github.com/bnema/vev/internal/usecase/keys/kittykey"
1718 )
1819
1920 // Autonomous client supervisor.
@@ -384,6 +385,12 @@ type AttachmentEnvironment struct {
384385 TermEnv string
385386 Cwd string
386387 TrueColor bool
388+ // ProbeTerminal enables the one bounded capability probe of a real outer
389+ // terminal (Kitty graphics and keyboard). Virtual terminals leave it off.
390+ ProbeTerminal bool
391+ // KittyKeyboard enables the kitty keyboard protocol when the probe finds
392+ // it, which makes Ctrl+1..9 recent-session switching available.
393+ KittyKeyboard bool
387394 }
388395
389396 // AttachmentLifecycleAction is an explicit process/attachment decision.
@@ -435,6 +442,9 @@ type Supervisor struct {
435442 // theme retains the terminal-reported colors across attachments, so a
436443 // replacement attachment restores them before its own palette query ends.
437444 theme terminalThemeState
445+ // capabilities is the outer terminal's probed and enabled capabilities,
446+ // written once in Run before any attachment starts.
447+ capabilities terminalCapabilities
438448 // readySub is the adopted connection's subscription while the ready phase
439449 // runs, so the picker overlay over a live attachment keeps folding broker
440450 // publications. It is only touched from the run goroutine.
@@ -564,7 +574,9 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
564574 }
565575 }()
566576
567- input := startTerminalInputLifetime(s.cfg.Terminal.In(), s.cfg.Picker)
577+ input := startTerminalInputLifetimeWith(s.cfg.Terminal.In(), s.cfg.Picker, func(pump *terminalInputPump) {
578+ s.detectTerminalCapabilities(ctx, pump)
579+ })
568580 defer input.stop()
569581 s.attachments.setInput(input.pump)
570582 s.attachments.startGeometry(ctx)
@@ -1311,6 +1323,13 @@ type terminalInputLifetime struct {
13111323 // treated as an immediate orderly EOF. A nil consumer discards read bytes; a
13121324 // non-nil consumer owns them for the picker presentation.
13131325 func startTerminalInputLifetime(in io.Reader, consumer pickerInputConsumer) *terminalInputLifetime {
1326+ return startTerminalInputLifetimeWith(in, consumer, nil)
1327+}
1328+
1329+// startTerminalInputLifetimeWith runs beforeConsumers on the started pump
1330+// before the picker may claim it, so a terminal probe sees its responses
1331+// first and hands every other byte on to the picker.
1332+func startTerminalInputLifetimeWith(in io.Reader, consumer pickerInputConsumer, beforeConsumers func(*terminalInputPump)) *terminalInputLifetime {
13141333 lifetime := &terminalInputLifetime{eof: make(chan error, 1), consumer: consumer}
13151334 if supervisorNil(in) {
13161335 lifetime.finish(io.EOF)
@@ -1318,6 +1337,9 @@ func startTerminalInputLifetime(in io.Reader, consumer pickerInputConsumer) *ter
13181337 }
13191338 lifetime.pump = newTerminalInputPump(in)
13201339 lifetime.pump.start()
1340+ if beforeConsumers != nil {
1341+ beforeConsumers(lifetime.pump)
1342+ }
13211343 if consumer != nil {
13221344 lifetime.acquirePicker()
13231345 }
@@ -1444,3 +1466,23 @@ func supervisorNil(dependency any) bool {
14441466 return false
14451467 }
14461468 }
1469+
1470+// detectTerminalCapabilities probes the outer terminal once and enables the
1471+// kitty keyboard protocol when it is supported and wanted. It runs in Run
1472+// before any input consumer or attachment exists.
1473+func (s *Supervisor) detectTerminalCapabilities(ctx context.Context, pump *terminalInputPump) {
1474+ env := s.cfg.AttachmentEnvironment
1475+ if !env.ProbeTerminal {
1476+ return
1477+ }
1478+ caps := probeTerminalCapabilities(ctx, s.cfg.Terminal, s.cfg.Clock, pump)
1479+ if caps.KittyKeyboard && env.KittyKeyboard {
1480+ if err := s.cfg.Terminal.EnableKittyKeyboard(kittykey.OuterFlags); err != nil {
1481+ s.logger.Warn("enabling kitty keyboard protocol failed", "err", err)
1482+ caps.KittyKeyboard = false
1483+ }
1484+ } else {
1485+ caps.KittyKeyboard = false
1486+ }
1487+ s.capabilities = caps
1488+}
@@ -14,6 +14,7 @@ import (
14 14
15 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
17+ "github.com/bnema/vev/internal/usecase/keys/kittykey"
17 )18 )
18 19
19 // Autonomous client supervisor.20 // Autonomous client supervisor.
@@ -384,6 +385,12 @@ type AttachmentEnvironment struct {
384 TermEnv string385 TermEnv string
385 Cwd string386 Cwd string
386 TrueColor bool387 TrueColor bool
388+ // ProbeTerminal enables the one bounded capability probe of a real outer
389+ // terminal (Kitty graphics and keyboard). Virtual terminals leave it off.
390+ ProbeTerminal bool
391+ // KittyKeyboard enables the kitty keyboard protocol when the probe finds
392+ // it, which makes Ctrl+1..9 recent-session switching available.
393+ KittyKeyboard bool
387 }394 }
388 395
389 // AttachmentLifecycleAction is an explicit process/attachment decision.396 // AttachmentLifecycleAction is an explicit process/attachment decision.
@@ -435,6 +442,9 @@ type Supervisor struct {
435 // theme retains the terminal-reported colors across attachments, so a442 // theme retains the terminal-reported colors across attachments, so a
436 // replacement attachment restores them before its own palette query ends.443 // replacement attachment restores them before its own palette query ends.
437 theme terminalThemeState444 theme terminalThemeState
445+ // capabilities is the outer terminal's probed and enabled capabilities,
446+ // written once in Run before any attachment starts.
447+ capabilities terminalCapabilities
438 // readySub is the adopted connection's subscription while the ready phase448 // readySub is the adopted connection's subscription while the ready phase
439 // runs, so the picker overlay over a live attachment keeps folding broker449 // runs, so the picker overlay over a live attachment keeps folding broker
440 // publications. It is only touched from the run goroutine.450 // publications. It is only touched from the run goroutine.
@@ -564,7 +574,9 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
564 }574 }
565 }()575 }()
566 576
567- input := startTerminalInputLifetime(s.cfg.Terminal.In(), s.cfg.Picker)577+ input := startTerminalInputLifetimeWith(s.cfg.Terminal.In(), s.cfg.Picker, func(pump *terminalInputPump) {
578+ s.detectTerminalCapabilities(ctx, pump)
579+ })
568 defer input.stop()580 defer input.stop()
569 s.attachments.setInput(input.pump)581 s.attachments.setInput(input.pump)
570 s.attachments.startGeometry(ctx)582 s.attachments.startGeometry(ctx)
@@ -1311,6 +1323,13 @@ type terminalInputLifetime struct {
1311 // treated as an immediate orderly EOF. A nil consumer discards read bytes; a1323 // treated as an immediate orderly EOF. A nil consumer discards read bytes; a
1312 // non-nil consumer owns them for the picker presentation.1324 // non-nil consumer owns them for the picker presentation.
1313 func startTerminalInputLifetime(in io.Reader, consumer pickerInputConsumer) *terminalInputLifetime {1325 func startTerminalInputLifetime(in io.Reader, consumer pickerInputConsumer) *terminalInputLifetime {
1326+ return startTerminalInputLifetimeWith(in, consumer, nil)
1327+}
1328+
1329+// startTerminalInputLifetimeWith runs beforeConsumers on the started pump
1330+// before the picker may claim it, so a terminal probe sees its responses
1331+// first and hands every other byte on to the picker.
1332+func startTerminalInputLifetimeWith(in io.Reader, consumer pickerInputConsumer, beforeConsumers func(*terminalInputPump)) *terminalInputLifetime {
1314 lifetime := &terminalInputLifetime{eof: make(chan error, 1), consumer: consumer}1333 lifetime := &terminalInputLifetime{eof: make(chan error, 1), consumer: consumer}
1315 if supervisorNil(in) {1334 if supervisorNil(in) {
1316 lifetime.finish(io.EOF)1335 lifetime.finish(io.EOF)
@@ -1318,6 +1337,9 @@ func startTerminalInputLifetime(in io.Reader, consumer pickerInputConsumer) *ter
1318 }1337 }
1319 lifetime.pump = newTerminalInputPump(in)1338 lifetime.pump = newTerminalInputPump(in)
1320 lifetime.pump.start()1339 lifetime.pump.start()
1340+ if beforeConsumers != nil {
1341+ beforeConsumers(lifetime.pump)
1342+ }
1321 if consumer != nil {1343 if consumer != nil {
1322 lifetime.acquirePicker()1344 lifetime.acquirePicker()
1323 }1345 }
@@ -1444,3 +1466,23 @@ func supervisorNil(dependency any) bool {
1444 return false1466 return false
1445 }1467 }
1446 }1468 }
1469+
1470+// detectTerminalCapabilities probes the outer terminal once and enables the
1471+// kitty keyboard protocol when it is supported and wanted. It runs in Run
1472+// before any input consumer or attachment exists.
1473+func (s *Supervisor) detectTerminalCapabilities(ctx context.Context, pump *terminalInputPump) {
1474+ env := s.cfg.AttachmentEnvironment
1475+ if !env.ProbeTerminal {
1476+ return
1477+ }
1478+ caps := probeTerminalCapabilities(ctx, s.cfg.Terminal, s.cfg.Clock, pump)
1479+ if caps.KittyKeyboard && env.KittyKeyboard {
1480+ if err := s.cfg.Terminal.EnableKittyKeyboard(kittykey.OuterFlags); err != nil {
1481+ s.logger.Warn("enabling kitty keyboard protocol failed", "err", err)
1482+ caps.KittyKeyboard = false
1483+ }
1484+ } else {
1485+ caps.KittyKeyboard = false
1486+ }
1487+ s.capabilities = caps
1488+}
M internal/usecase/client/supervisor_attach.go +1 -0
@@ -325,6 +325,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest,
325325 TermEnv: environment.TermEnv,
326326 Cwd: environment.Cwd,
327327 TrueColor: environment.TrueColor,
328+ Capabilities: s.capabilities,
328329 SessionEnvironment: sessionEnv,
329330 BeforeAttached: beforeAttached,
330331 Clock: s.cfg.Clock,
@@ -325,6 +325,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest,
325 TermEnv: environment.TermEnv,325 TermEnv: environment.TermEnv,
326 Cwd: environment.Cwd,326 Cwd: environment.Cwd,
327 TrueColor: environment.TrueColor,327 TrueColor: environment.TrueColor,
328+ Capabilities: s.capabilities,
328 SessionEnvironment: sessionEnv,329 SessionEnvironment: sessionEnv,
329 BeforeAttached: beforeAttached,330 BeforeAttached: beforeAttached,
330 Clock: s.cfg.Clock,331 Clock: s.cfg.Clock,
M internal/usecase/client/supervisor_attach_test.go +2 -0
@@ -62,6 +62,8 @@ type attachTestEvent struct {
6262 state uint64
6363 }
6464
65+func (t *attachTestTerminal) EnableKittyKeyboard(int) error { return nil }
66+
6567 func (t *attachTestTerminal) EnterRaw() (func() error, error) {
6668 return func() error {
6769 t.mu.Lock()
@@ -62,6 +62,8 @@ type attachTestEvent struct {
62 state uint6462 state uint64
63 }63 }
64 64
65+func (t *attachTestTerminal) EnableKittyKeyboard(int) error { return nil }
66+
65 func (t *attachTestTerminal) EnterRaw() (func() error, error) {67 func (t *attachTestTerminal) EnterRaw() (func() error, error) {
66 return func() error {68 return func() error {
67 t.mu.Lock()69 t.mu.Lock()
M internal/usecase/client/supervisor_test.go +2 -0
@@ -140,6 +140,8 @@ func newSupervisorTestTerminal(in io.Reader) *supervisorTestTerminal {
140140 return &supervisorTestTerminal{in: in}
141141 }
142142
143+func (t *supervisorTestTerminal) EnableKittyKeyboard(int) error { return nil }
144+
143145 func (t *supervisorTestTerminal) EnterRaw() (func() error, error) {
144146 if t.enterErr != nil {
145147 return nil, t.enterErr
@@ -140,6 +140,8 @@ func newSupervisorTestTerminal(in io.Reader) *supervisorTestTerminal {
140 return &supervisorTestTerminal{in: in}140 return &supervisorTestTerminal{in: in}
141 }141 }
142 142
143+func (t *supervisorTestTerminal) EnableKittyKeyboard(int) error { return nil }
144+
143 func (t *supervisorTestTerminal) EnterRaw() (func() error, error) {145 func (t *supervisorTestTerminal) EnterRaw() (func() error, error) {
144 if t.enterErr != nil {146 if t.enterErr != nil {
145 return nil, t.enterErr147 return nil, t.enterErr
A internal/usecase/client/terminal_probe.go +80 -0
@@ -0,0 +1,80 @@
1+package client
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/vev-vt/protocol/terminalquery"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+// terminalCapabilityProbeTimeout bounds the outer-terminal capability probe.
13+// DA1 answers first on any terminal, so a real terminal finishes far sooner.
14+const terminalCapabilityProbeTimeout = 150 * time.Millisecond
15+
16+// terminalCapabilities is what the outer terminal declared to the probe.
17+type terminalCapabilities struct {
18+ KittyGraphics bool
19+ KittyKeyboard bool
20+}
21+
22+// probeTerminalCapabilities performs one bounded probe of the outer terminal
23+// through the lifecycle's only input pump. It must run before any other
24+// consumer claims input. Probe responses are removed; every unrelated byte is
25+// kept, in order, for the next consumer.
26+func probeTerminalCapabilities(ctx context.Context, terminal ports.Terminal, clock ports.Clock, input *terminalInputPump) terminalCapabilities {
27+ if input == nil || supervisorNil(terminal) || supervisorNil(clock) {
28+ return terminalCapabilities{}
29+ }
30+ consumer, ok := input.tryClaim()
31+ if !ok {
32+ return terminalCapabilities{}
33+ }
34+ defer input.revoke(consumer)
35+
36+ query := terminalquery.KittyGraphicsQuery + terminalquery.KittyKeyboardQuery + terminalquery.DeviceAttributesQuery
37+ if _, err := terminal.Out().Write([]byte(query)); err != nil {
38+ return terminalCapabilities{}
39+ }
40+ if err := terminal.Flush(); err != nil {
41+ return terminalCapabilities{}
42+ }
43+
44+ probe := &terminalquery.Probe{}
45+ var replay []byte
46+ timer := clock.NewTimer(terminalCapabilityProbeTimeout)
47+ defer timer.Stop()
48+ // DA1 is the FIFO sentinel: once it arrives, the answers to the earlier
49+ // queries are definitive.
50+wait:
51+ for !probe.DA1() {
52+ select {
53+ case <-ctx.Done():
54+ break wait
55+ case <-timer.C():
56+ break wait
57+ case <-input.readyFor(consumer):
58+ result, ok := input.take(ctx, consumer)
59+ if !ok {
60+ if ctx.Err() != nil {
61+ break wait
62+ }
63+ continue
64+ }
65+ replay = append(replay, probe.Feed(result.data)...)
66+ input.ack(consumer)
67+ if result.err != nil {
68+ break wait
69+ }
70+ }
71+ }
72+ replay = append(replay, probe.Finish()...)
73+ // Preserve before revoke so the next consumer reads these bytes before any
74+ // later terminal read.
75+ input.preserveResidual(consumer, replay)
76+ if !probe.DA1() {
77+ return terminalCapabilities{}
78+ }
79+ return terminalCapabilities{KittyGraphics: probe.KittyGraphics(), KittyKeyboard: probe.KittyKeyboard()}
80+}
@@ -0,0 +1,80 @@
1+package client
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/vev-vt/protocol/terminalquery"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+// terminalCapabilityProbeTimeout bounds the outer-terminal capability probe.
13+// DA1 answers first on any terminal, so a real terminal finishes far sooner.
14+const terminalCapabilityProbeTimeout = 150 * time.Millisecond
15+
16+// terminalCapabilities is what the outer terminal declared to the probe.
17+type terminalCapabilities struct {
18+ KittyGraphics bool
19+ KittyKeyboard bool
20+}
21+
22+// probeTerminalCapabilities performs one bounded probe of the outer terminal
23+// through the lifecycle's only input pump. It must run before any other
24+// consumer claims input. Probe responses are removed; every unrelated byte is
25+// kept, in order, for the next consumer.
26+func probeTerminalCapabilities(ctx context.Context, terminal ports.Terminal, clock ports.Clock, input *terminalInputPump) terminalCapabilities {
27+ if input == nil || supervisorNil(terminal) || supervisorNil(clock) {
28+ return terminalCapabilities{}
29+ }
30+ consumer, ok := input.tryClaim()
31+ if !ok {
32+ return terminalCapabilities{}
33+ }
34+ defer input.revoke(consumer)
35+
36+ query := terminalquery.KittyGraphicsQuery + terminalquery.KittyKeyboardQuery + terminalquery.DeviceAttributesQuery
37+ if _, err := terminal.Out().Write([]byte(query)); err != nil {
38+ return terminalCapabilities{}
39+ }
40+ if err := terminal.Flush(); err != nil {
41+ return terminalCapabilities{}
42+ }
43+
44+ probe := &terminalquery.Probe{}
45+ var replay []byte
46+ timer := clock.NewTimer(terminalCapabilityProbeTimeout)
47+ defer timer.Stop()
48+ // DA1 is the FIFO sentinel: once it arrives, the answers to the earlier
49+ // queries are definitive.
50+wait:
51+ for !probe.DA1() {
52+ select {
53+ case <-ctx.Done():
54+ break wait
55+ case <-timer.C():
56+ break wait
57+ case <-input.readyFor(consumer):
58+ result, ok := input.take(ctx, consumer)
59+ if !ok {
60+ if ctx.Err() != nil {
61+ break wait
62+ }
63+ continue
64+ }
65+ replay = append(replay, probe.Feed(result.data)...)
66+ input.ack(consumer)
67+ if result.err != nil {
68+ break wait
69+ }
70+ }
71+ }
72+ replay = append(replay, probe.Finish()...)
73+ // Preserve before revoke so the next consumer reads these bytes before any
74+ // later terminal read.
75+ input.preserveResidual(consumer, replay)
76+ if !probe.DA1() {
77+ return terminalCapabilities{}
78+ }
79+ return terminalCapabilities{KittyGraphics: probe.KittyGraphics(), KittyKeyboard: probe.KittyKeyboard()}
80+}
A internal/usecase/client/terminal_probe_test.go +98 -0
@@ -0,0 +1,98 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev-vt/protocol/terminalquery"
11+ "github.com/stretchr/testify/require"
12+
13+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+)
15+
16+func TestProbeTerminalCapabilities(t *testing.T) {
17+ const query = terminalquery.KittyGraphicsQuery + terminalquery.KittyKeyboardQuery + terminalquery.DeviceAttributesQuery
18+ tests := []struct {
19+ name string
20+ reply string
21+ timeout bool
22+ want terminalCapabilities
23+ wantRest string
24+ }{
25+ {name: "graphics and keyboard", reply: "\x1b_Gi=31;OK\x1b\\\x1b[?0u\x1b[?62;c", want: terminalCapabilities{KittyGraphics: true, KittyKeyboard: true}},
26+ {name: "keyboard only", reply: "\x1b[?0u\x1b[?62;22c", want: terminalCapabilities{KittyKeyboard: true}},
27+ {name: "neither", reply: "\x1b[?62;22c"},
28+ {name: "typed input is kept in order", reply: "ab\x1b[?1u\x1b[?62;22ccd", want: terminalCapabilities{KittyKeyboard: true}, wantRest: "abcd"},
29+ {name: "no DA1 means nothing", reply: "\x1b[?0u", timeout: true, wantRest: ""},
30+ {name: "silent terminal times out", timeout: true},
31+ }
32+ for _, tt := range tests {
33+ t.Run(tt.name, func(t *testing.T) {
34+ inR, inW := io.Pipe()
35+ defer func() { _ = inW.Close() }()
36+ pump := newTerminalInputPump(inR)
37+ pump.start()
38+ defer pump.stop()
39+
40+ var out bytes.Buffer
41+ term := portsmocks.NewMockTerminal(t)
42+ term.EXPECT().Out().Return(&out).Once()
43+ written := make(chan struct{})
44+ term.EXPECT().Flush().RunAndReturn(func() error {
45+ go func() {
46+ defer close(written)
47+ if tt.reply != "" {
48+ _, _ = inW.Write([]byte(tt.reply))
49+ }
50+ }()
51+ return nil
52+ }).Once()
53+ clk := &pcFakeClock{}
54+
55+ got := make(chan terminalCapabilities, 1)
56+ go func() { got <- probeTerminalCapabilities(context.Background(), term, clk, pump) }()
57+ if tt.timeout {
58+ require.Eventually(t, func() bool {
59+ clk.mu.Lock()
60+ defer clk.mu.Unlock()
61+ return len(clk.timers) == 1
62+ }, timeoutForTest, pollForTest)
63+ // A pipe write returns once the pump read it, so a partial
64+ // reply is in before the deadline fires.
65+ <-written
66+ require.Eventually(t, func() bool { return pumpDrained(pump) }, timeoutForTest, pollForTest)
67+ clk.fireLast()
68+ }
69+ caps := <-got
70+ require.Equal(t, query, out.String())
71+ require.Equal(t, tt.want, caps)
72+
73+ consumer, ok := pump.tryClaim()
74+ require.True(t, ok)
75+ defer pump.revoke(consumer)
76+ if tt.wantRest == "" {
77+ _, ok := pump.take(context.Background(), consumer)
78+ require.False(t, ok, "no byte may be left for the next consumer")
79+ return
80+ }
81+ result, ok := pump.take(context.Background(), consumer)
82+ require.True(t, ok)
83+ require.Equal(t, tt.wantRest, string(result.data))
84+ })
85+ }
86+}
87+
88+const (
89+ timeoutForTest = 2 * time.Second
90+ pollForTest = time.Millisecond
91+)
92+
93+// pumpDrained reports that the probe consumed every read the pump delivered.
94+func pumpDrained(p *terminalInputPump) bool {
95+ p.mu.Lock()
96+ defer p.mu.Unlock()
97+ return p.pending == nil && p.delivering == 0
98+}
@@ -0,0 +1,98 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev-vt/protocol/terminalquery"
11+ "github.com/stretchr/testify/require"
12+
13+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+)
15+
16+func TestProbeTerminalCapabilities(t *testing.T) {
17+ const query = terminalquery.KittyGraphicsQuery + terminalquery.KittyKeyboardQuery + terminalquery.DeviceAttributesQuery
18+ tests := []struct {
19+ name string
20+ reply string
21+ timeout bool
22+ want terminalCapabilities
23+ wantRest string
24+ }{
25+ {name: "graphics and keyboard", reply: "\x1b_Gi=31;OK\x1b\\\x1b[?0u\x1b[?62;c", want: terminalCapabilities{KittyGraphics: true, KittyKeyboard: true}},
26+ {name: "keyboard only", reply: "\x1b[?0u\x1b[?62;22c", want: terminalCapabilities{KittyKeyboard: true}},
27+ {name: "neither", reply: "\x1b[?62;22c"},
28+ {name: "typed input is kept in order", reply: "ab\x1b[?1u\x1b[?62;22ccd", want: terminalCapabilities{KittyKeyboard: true}, wantRest: "abcd"},
29+ {name: "no DA1 means nothing", reply: "\x1b[?0u", timeout: true, wantRest: ""},
30+ {name: "silent terminal times out", timeout: true},
31+ }
32+ for _, tt := range tests {
33+ t.Run(tt.name, func(t *testing.T) {
34+ inR, inW := io.Pipe()
35+ defer func() { _ = inW.Close() }()
36+ pump := newTerminalInputPump(inR)
37+ pump.start()
38+ defer pump.stop()
39+
40+ var out bytes.Buffer
41+ term := portsmocks.NewMockTerminal(t)
42+ term.EXPECT().Out().Return(&out).Once()
43+ written := make(chan struct{})
44+ term.EXPECT().Flush().RunAndReturn(func() error {
45+ go func() {
46+ defer close(written)
47+ if tt.reply != "" {
48+ _, _ = inW.Write([]byte(tt.reply))
49+ }
50+ }()
51+ return nil
52+ }).Once()
53+ clk := &pcFakeClock{}
54+
55+ got := make(chan terminalCapabilities, 1)
56+ go func() { got <- probeTerminalCapabilities(context.Background(), term, clk, pump) }()
57+ if tt.timeout {
58+ require.Eventually(t, func() bool {
59+ clk.mu.Lock()
60+ defer clk.mu.Unlock()
61+ return len(clk.timers) == 1
62+ }, timeoutForTest, pollForTest)
63+ // A pipe write returns once the pump read it, so a partial
64+ // reply is in before the deadline fires.
65+ <-written
66+ require.Eventually(t, func() bool { return pumpDrained(pump) }, timeoutForTest, pollForTest)
67+ clk.fireLast()
68+ }
69+ caps := <-got
70+ require.Equal(t, query, out.String())
71+ require.Equal(t, tt.want, caps)
72+
73+ consumer, ok := pump.tryClaim()
74+ require.True(t, ok)
75+ defer pump.revoke(consumer)
76+ if tt.wantRest == "" {
77+ _, ok := pump.take(context.Background(), consumer)
78+ require.False(t, ok, "no byte may be left for the next consumer")
79+ return
80+ }
81+ result, ok := pump.take(context.Background(), consumer)
82+ require.True(t, ok)
83+ require.Equal(t, tt.wantRest, string(result.data))
84+ })
85+ }
86+}
87+
88+const (
89+ timeoutForTest = 2 * time.Second
90+ pollForTest = time.Millisecond
91+)
92+
93+// pumpDrained reports that the probe consumed every read the pump delivered.
94+func pumpDrained(p *terminalInputPump) bool {
95+ p.mu.Lock()
96+ defer p.mu.Unlock()
97+ return p.pending == nil && p.delivering == 0
98+}
M internal/usecase/daemon/attachment_output.go +4 -0
@@ -49,6 +49,10 @@ func (d *Daemon) reconfigureAttachmentOutput(sess *session, ac *attachedClient,
4949 return
5050 }
5151 ac.terminalCapabilities.KittyGraphics = h.KittyDirectGraphics
52+ // A replacement terminal declares its own keyboard protocol.
53+ if ac.keys != nil {
54+ ac.keys.SetKittyKeyboard(h.KittyKeyboard)
55+ }
5256 ac.output.graphicsUnsupportedWarned.Store(false)
5357 // A replacement connection is a different outer terminal. Retire the old
5458 // attachment-local state without writing cleanup to the new terminal, then
@@ -49,6 +49,10 @@ func (d *Daemon) reconfigureAttachmentOutput(sess *session, ac *attachedClient,
49 return49 return
50 }50 }
51 ac.terminalCapabilities.KittyGraphics = h.KittyDirectGraphics51 ac.terminalCapabilities.KittyGraphics = h.KittyDirectGraphics
52+ // A replacement terminal declares its own keyboard protocol.
53+ if ac.keys != nil {
54+ ac.keys.SetKittyKeyboard(h.KittyKeyboard)
55+ }
52 ac.output.graphicsUnsupportedWarned.Store(false)56 ac.output.graphicsUnsupportedWarned.Store(false)
53 // A replacement connection is a different outer terminal. Retire the old57 // A replacement connection is a different outer terminal. Retire the old
54 // attachment-local state without writing cleanup to the new terminal, then58 // attachment-local state without writing cleanup to the new terminal, then
M internal/usecase/daemon/client.go +2 -0
@@ -630,6 +630,7 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} {
630630
631631 type attachClientOptions struct {
632632 clientID [16]byte
633+ kittyKeyboard bool
633634 resumeCapable bool
634635 maxOutputInFlight uint8
635636 terminalCapabilities terminalcap.Capabilities
@@ -719,6 +720,7 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne
719720 output.attachment = ac
720721 ac.initOverlays()
721722 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
723+ ac.keys.SetKittyKeyboard(opts.kittyKeyboard)
722724 return ac
723725 }
724726
@@ -630,6 +630,7 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} {
630 630
631 type attachClientOptions struct {631 type attachClientOptions struct {
632 clientID [16]byte632 clientID [16]byte
633+ kittyKeyboard bool
633 resumeCapable bool634 resumeCapable bool
634 maxOutputInFlight uint8635 maxOutputInFlight uint8
635 terminalCapabilities terminalcap.Capabilities636 terminalCapabilities terminalcap.Capabilities
@@ -719,6 +720,7 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne
719 output.attachment = ac720 output.attachment = ac
720 ac.initOverlays()721 ac.initOverlays()
721 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)722 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
723+ ac.keys.SetKittyKeyboard(opts.kittyKeyboard)
722 return ac724 return ac
723 }725 }
724 726
M internal/usecase/daemon/daemon.go +1 -0
@@ -1502,6 +1502,7 @@ func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domai
15021502 }
15031503 opts := attachClientOptions{
15041504 clientID: h.ClientID,
1505+ kittyKeyboard: h.KittyKeyboard,
15051506 resumeCapable: true,
15061507 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
15071508 navigationCapabilities: h.NavigationCapabilities,
@@ -1502,6 +1502,7 @@ func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domai
1502 }1502 }
1503 opts := attachClientOptions{1503 opts := attachClientOptions{
1504 clientID: h.ClientID,1504 clientID: h.ClientID,
1505+ kittyKeyboard: h.KittyKeyboard,
1505 resumeCapable: true,1506 resumeCapable: true,
1506 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),1507 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
1507 navigationCapabilities: h.NavigationCapabilities,1508 navigationCapabilities: h.NavigationCapabilities,
M internal/usecase/daemon/input.go +42 -2
@@ -7,7 +7,9 @@ import (
77 "strconv"
88
99 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/usecase/command"
1011 "github.com/bnema/vev/internal/usecase/keys"
12+ "github.com/bnema/vev/internal/usecase/keys/kittykey"
1113 "github.com/bnema/vev/internal/usecase/layout"
1214 "github.com/bnema/vev/internal/usecase/mouse"
1315 )
@@ -38,7 +40,7 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
3840 ac.mouseScan.Scan(data,
3941 func(ev mouse.Event) { d.handleMouse(ac, ev) },
4042 func(b []byte) {
41- if ac.overlays.HandleInput(d, b) {
43+ if ac.overlays.HandleInput(d, ac.legacyKeys(b)) {
4244 return
4345 }
4446 ac.keys.Route(b)
@@ -64,7 +66,9 @@ func (d *Daemon) handleInputForAttachment(effect *attachmentEffect, data []byte)
6466 if !effect.current() {
6567 return
6668 }
67- if ac.overlays.HandleInput(d, b, effect) {
69+ // Overlays parse legacy key bytes; only the router and panes see
70+ // kitty keyboard sequences.
71+ if ac.overlays.HandleInput(d, ac.legacyKeys(b), effect) {
6872 return
6973 }
7074 if !effect.current() {
@@ -436,11 +440,39 @@ func (h daemonKeyHandler) Forward(data []byte) {
436440 tb.mu.Lock()
437441 p := tb.terminalTargetLocked()
438442 tb.mu.Unlock()
443+ if h.ac.keys.KittyKeyboard() {
444+ data = paneKeyInput(p, data)
445+ }
439446 h.d.writeToPane(sess, p, data)
440447 return nil
441448 })
442449 }
443450
451+// paneKeyInput re-encodes kitty keyboard protocol key events for the pane's
452+// program: legacy bytes unless it requested the protocol itself.
453+func paneKeyInput(p *pane, data []byte) []byte {
454+ if p == nil {
455+ return data
456+ }
457+ p.mu.Lock()
458+ if p.screen == nil {
459+ p.mu.Unlock()
460+ return data
461+ }
462+ flags := p.screen.KittyKeyboardFlags()
463+ p.mu.Unlock()
464+ return kittykey.Translate(data, flags)
465+}
466+
467+// legacyKeys returns input as legacy key bytes for daemon overlays. Only an
468+// attachment that enabled the kitty keyboard protocol needs translating.
469+func (ac *attachedClient) legacyKeys(data []byte) []byte {
470+ if !ac.keys.KittyKeyboard() {
471+ return data
472+ }
473+ return kittykey.Translate(data, 0)
474+}
475+
444476 func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
445477 sess, effect, owned := h.acquireAttachmentEffect()
446478 if sess == nil {
@@ -515,6 +547,14 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
515547 runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Left})
516548 case keys.ActionConsumeOrExpelPaneRight:
517549 runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Right})
550+ case keys.ActionSwitchRecent1, keys.ActionSwitchRecent2, keys.ActionSwitchRecent3,
551+ keys.ActionSwitchRecent4, keys.ActionSwitchRecent5, keys.ActionSwitchRecent6,
552+ keys.ActionSwitchRecent7, keys.ActionSwitchRecent8, keys.ActionSwitchRecent9:
553+ rank := int(action-keys.ActionSwitchRecent1) + 1
554+ exec := paletteExec{d: h.d, sess: sess, attachment: sess, ac: h.ac, routeSnapshot: h.ac.routeSnapshotCopy(), effect: effect}
555+ if err := exec.JumpRecentSession(rank); err != nil && !errors.Is(err, errAttachmentTransition) && !errors.Is(err, command.ErrInvalidArguments) {
556+ h.d.reportError(sess, err)
557+ }
518558 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
519559 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
520560 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
@@ -7,7 +7,9 @@ import (
7 "strconv"7 "strconv"
8 8
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/usecase/command"
10 "github.com/bnema/vev/internal/usecase/keys"11 "github.com/bnema/vev/internal/usecase/keys"
12+ "github.com/bnema/vev/internal/usecase/keys/kittykey"
11 "github.com/bnema/vev/internal/usecase/layout"13 "github.com/bnema/vev/internal/usecase/layout"
12 "github.com/bnema/vev/internal/usecase/mouse"14 "github.com/bnema/vev/internal/usecase/mouse"
13 )15 )
@@ -38,7 +40,7 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
38 ac.mouseScan.Scan(data,40 ac.mouseScan.Scan(data,
39 func(ev mouse.Event) { d.handleMouse(ac, ev) },41 func(ev mouse.Event) { d.handleMouse(ac, ev) },
40 func(b []byte) {42 func(b []byte) {
41- if ac.overlays.HandleInput(d, b) {43+ if ac.overlays.HandleInput(d, ac.legacyKeys(b)) {
42 return44 return
43 }45 }
44 ac.keys.Route(b)46 ac.keys.Route(b)
@@ -64,7 +66,9 @@ func (d *Daemon) handleInputForAttachment(effect *attachmentEffect, data []byte)
64 if !effect.current() {66 if !effect.current() {
65 return67 return
66 }68 }
67- if ac.overlays.HandleInput(d, b, effect) {69+ // Overlays parse legacy key bytes; only the router and panes see
70+ // kitty keyboard sequences.
71+ if ac.overlays.HandleInput(d, ac.legacyKeys(b), effect) {
68 return72 return
69 }73 }
70 if !effect.current() {74 if !effect.current() {
@@ -436,11 +440,39 @@ func (h daemonKeyHandler) Forward(data []byte) {
436 tb.mu.Lock()440 tb.mu.Lock()
437 p := tb.terminalTargetLocked()441 p := tb.terminalTargetLocked()
438 tb.mu.Unlock()442 tb.mu.Unlock()
443+ if h.ac.keys.KittyKeyboard() {
444+ data = paneKeyInput(p, data)
445+ }
439 h.d.writeToPane(sess, p, data)446 h.d.writeToPane(sess, p, data)
440 return nil447 return nil
441 })448 })
442 }449 }
443 450
451+// paneKeyInput re-encodes kitty keyboard protocol key events for the pane's
452+// program: legacy bytes unless it requested the protocol itself.
453+func paneKeyInput(p *pane, data []byte) []byte {
454+ if p == nil {
455+ return data
456+ }
457+ p.mu.Lock()
458+ if p.screen == nil {
459+ p.mu.Unlock()
460+ return data
461+ }
462+ flags := p.screen.KittyKeyboardFlags()
463+ p.mu.Unlock()
464+ return kittykey.Translate(data, flags)
465+}
466+
467+// legacyKeys returns input as legacy key bytes for daemon overlays. Only an
468+// attachment that enabled the kitty keyboard protocol needs translating.
469+func (ac *attachedClient) legacyKeys(data []byte) []byte {
470+ if !ac.keys.KittyKeyboard() {
471+ return data
472+ }
473+ return kittykey.Translate(data, 0)
474+}
475+
444 func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {476 func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
445 sess, effect, owned := h.acquireAttachmentEffect()477 sess, effect, owned := h.acquireAttachmentEffect()
446 if sess == nil {478 if sess == nil {
@@ -515,6 +547,14 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
515 runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Left})547 runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Left})
516 case keys.ActionConsumeOrExpelPaneRight:548 case keys.ActionConsumeOrExpelPaneRight:
517 runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Right})549 runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Right})
550+ case keys.ActionSwitchRecent1, keys.ActionSwitchRecent2, keys.ActionSwitchRecent3,
551+ keys.ActionSwitchRecent4, keys.ActionSwitchRecent5, keys.ActionSwitchRecent6,
552+ keys.ActionSwitchRecent7, keys.ActionSwitchRecent8, keys.ActionSwitchRecent9:
553+ rank := int(action-keys.ActionSwitchRecent1) + 1
554+ exec := paletteExec{d: h.d, sess: sess, attachment: sess, ac: h.ac, routeSnapshot: h.ac.routeSnapshotCopy(), effect: effect}
555+ if err := exec.JumpRecentSession(rank); err != nil && !errors.Is(err, errAttachmentTransition) && !errors.Is(err, command.ErrInvalidArguments) {
556+ h.d.reportError(sess, err)
557+ }
518 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,558 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
519 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,559 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
520 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:560 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
A internal/usecase/daemon/input_kitty_test.go +34 -0
@@ -0,0 +1,34 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ vt "github.com/bnema/vev-vt"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestPaneKeyInputFollowsPaneKeyboardFlags(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ screen string
14+ in string
15+ want string
16+ }{
17+ {name: "legacy pane gets control byte", in: "\x1b[97;5u", want: "\x01"},
18+ {name: "legacy pane text untouched", in: "hello", want: "hello"},
19+ {name: "kitty pane keeps sequence", screen: "\x1b[>1u", in: "\x1b[97;5u", want: "\x1b[97;5u"},
20+ {name: "kitty pane without alternates strips them", screen: "\x1b[>1u", in: "\x1b[38::49;5u", want: "\x1b[38;5u"},
21+ {name: "popped pane is legacy again", screen: "\x1b[>1u\x1b[<u", in: "\x1b[27u", want: "\x1b"},
22+ {name: "nil pane passthrough", in: "\x1b[97;5u", want: "\x1b[97;5u"},
23+ }
24+ for _, tt := range tests {
25+ t.Run(tt.name, func(t *testing.T) {
26+ var p *pane
27+ if tt.name != "nil pane passthrough" {
28+ p = &pane{screen: vt.NewScreen(10, 3)}
29+ p.screen.Write([]byte(tt.screen))
30+ }
31+ require.Equal(t, []byte(tt.want), paneKeyInput(p, []byte(tt.in)))
32+ })
33+ }
34+}
@@ -0,0 +1,34 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ vt "github.com/bnema/vev-vt"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestPaneKeyInputFollowsPaneKeyboardFlags(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ screen string
14+ in string
15+ want string
16+ }{
17+ {name: "legacy pane gets control byte", in: "\x1b[97;5u", want: "\x01"},
18+ {name: "legacy pane text untouched", in: "hello", want: "hello"},
19+ {name: "kitty pane keeps sequence", screen: "\x1b[>1u", in: "\x1b[97;5u", want: "\x1b[97;5u"},
20+ {name: "kitty pane without alternates strips them", screen: "\x1b[>1u", in: "\x1b[38::49;5u", want: "\x1b[38;5u"},
21+ {name: "popped pane is legacy again", screen: "\x1b[>1u\x1b[<u", in: "\x1b[27u", want: "\x1b"},
22+ {name: "nil pane passthrough", in: "\x1b[97;5u", want: "\x1b[97;5u"},
23+ }
24+ for _, tt := range tests {
25+ t.Run(tt.name, func(t *testing.T) {
26+ var p *pane
27+ if tt.name != "nil pane passthrough" {
28+ p = &pane{screen: vt.NewScreen(10, 3)}
29+ p.screen.Write([]byte(tt.screen))
30+ }
31+ require.Equal(t, []byte(tt.want), paneKeyInput(p, []byte(tt.in)))
32+ })
33+ }
34+}
M internal/usecase/daemon/recent_route_snapshot_test.go +54 -0
@@ -179,3 +179,57 @@ func TestStatusHistoryShowsVisitedAndRingingRoutes(t *testing.T) {
179179 })
180180 }
181181 }
182+
183+func TestCtrlDigitSwitchesToRecentHistoryRank(t *testing.T) {
184+ snapshot := protocol.RecentRouteSnapshot{
185+ Generation: 4,
186+ Entries: []protocol.RecentRouteEntry{
187+ testRouteEntry(2, 3, "first", 2, protocol.RouteKindLocal),
188+ testRouteEntry(7, 1, "second", 7, protocol.RouteKindRemote),
189+ },
190+ }
191+ tests := []struct {
192+ name string
193+ input string
194+ wantKey uint64
195+ wantGen uint64
196+ none bool
197+ legacy bool
198+ }{
199+ {name: "ctrl 1 previous session", input: "\x1b[49;5u", wantKey: 2, wantGen: 3},
200+ {name: "ctrl 2 second previous", input: "\x1b[50;5u", wantKey: 7, wantGen: 1},
201+ {name: "azerty ctrl 2 base key", input: "\x1b[233::50;5u", wantKey: 7, wantGen: 1},
202+ {name: "ctrl 3 beyond history is a no-op", input: "\x1b[51;5u", none: true},
203+ {name: "legacy client does not intercept", input: "\x1b[49;5u", none: true, legacy: true},
204+ }
205+ for _, tt := range tests {
206+ t.Run(tt.name, func(t *testing.T) {
207+ d, source, ac, _ := newManualSessionWithPTYs(t, nil)
208+ transport := &closeTrackingTransport{}
209+ ac.replaceTransport(transport)
210+ rc := d.attachCoordinator(source, nil, ac, true)
211+ token := source.captureAttachmentCapability(ac, transport)
212+ token.lease = rc.attachmentLease(ac)
213+ ac.installTestAttachmentCapability(token)
214+ ac.setRouteSnapshot(snapshot)
215+ ac.keys.SetKittyKeyboard(!tt.legacy)
216+ effect, admitted := ac.beginAttachmentEffect(token)
217+ require.True(t, admitted)
218+ defer effect.End()
219+
220+ d.handleInputForAttachment(effect, []byte(tt.input))
221+
222+ var actions []protocol.RouteNavigationAction
223+ for _, frame := range transport.Sends() {
224+ if decoded, ok := decodeServerMessage(t, frame).(protocol.RouteNavigationAction); ok {
225+ actions = append(actions, decoded)
226+ }
227+ }
228+ if tt.none {
229+ require.Empty(t, actions)
230+ return
231+ }
232+ require.Equal(t, []protocol.RouteNavigationAction{{SnapshotGeneration: 4, Key: tt.wantKey, Generation: tt.wantGen}}, actions)
233+ })
234+ }
235+}
@@ -179,3 +179,57 @@ func TestStatusHistoryShowsVisitedAndRingingRoutes(t *testing.T) {
179 })179 })
180 }180 }
181 }181 }
182+
183+func TestCtrlDigitSwitchesToRecentHistoryRank(t *testing.T) {
184+ snapshot := protocol.RecentRouteSnapshot{
185+ Generation: 4,
186+ Entries: []protocol.RecentRouteEntry{
187+ testRouteEntry(2, 3, "first", 2, protocol.RouteKindLocal),
188+ testRouteEntry(7, 1, "second", 7, protocol.RouteKindRemote),
189+ },
190+ }
191+ tests := []struct {
192+ name string
193+ input string
194+ wantKey uint64
195+ wantGen uint64
196+ none bool
197+ legacy bool
198+ }{
199+ {name: "ctrl 1 previous session", input: "\x1b[49;5u", wantKey: 2, wantGen: 3},
200+ {name: "ctrl 2 second previous", input: "\x1b[50;5u", wantKey: 7, wantGen: 1},
201+ {name: "azerty ctrl 2 base key", input: "\x1b[233::50;5u", wantKey: 7, wantGen: 1},
202+ {name: "ctrl 3 beyond history is a no-op", input: "\x1b[51;5u", none: true},
203+ {name: "legacy client does not intercept", input: "\x1b[49;5u", none: true, legacy: true},
204+ }
205+ for _, tt := range tests {
206+ t.Run(tt.name, func(t *testing.T) {
207+ d, source, ac, _ := newManualSessionWithPTYs(t, nil)
208+ transport := &closeTrackingTransport{}
209+ ac.replaceTransport(transport)
210+ rc := d.attachCoordinator(source, nil, ac, true)
211+ token := source.captureAttachmentCapability(ac, transport)
212+ token.lease = rc.attachmentLease(ac)
213+ ac.installTestAttachmentCapability(token)
214+ ac.setRouteSnapshot(snapshot)
215+ ac.keys.SetKittyKeyboard(!tt.legacy)
216+ effect, admitted := ac.beginAttachmentEffect(token)
217+ require.True(t, admitted)
218+ defer effect.End()
219+
220+ d.handleInputForAttachment(effect, []byte(tt.input))
221+
222+ var actions []protocol.RouteNavigationAction
223+ for _, frame := range transport.Sends() {
224+ if decoded, ok := decodeServerMessage(t, frame).(protocol.RouteNavigationAction); ok {
225+ actions = append(actions, decoded)
226+ }
227+ }
228+ if tt.none {
229+ require.Empty(t, actions)
230+ return
231+ }
232+ require.Equal(t, []protocol.RouteNavigationAction{{SnapshotGeneration: 4, Key: tt.wantKey, Generation: tt.wantGen}}, actions)
233+ })
234+ }
235+}
M internal/usecase/keys/bindings.go +2 -11
@@ -1,7 +1,5 @@
11 package keys
22
3-import "unicode/utf8"
4-
53 // Bindings maps intercepted Alt keys to actions. A Bindings value is mutable
64 // while being built, then treated as frozen once published through an atomic
75 // pointer; callers must clone before using mutators.
@@ -110,19 +108,12 @@ func (b *Bindings) conflictingAction(spec KeySpec, action Action) (Action, bool)
110108 return 0, false
111109 }
112110
113-func (b *Bindings) actionForAltBytes(data []byte) (Action, int, bool) {
111+func (b *Bindings) actionForAltRune(key rune) (Action, bool) {
114112 if b == nil {
115113 b = defaultBindings
116114 }
117- if len(data) == 0 {
118- return 0, 0, false
119- }
120- key, size := utf8.DecodeRune(data)
121- if key == utf8.RuneError && size == 1 {
122- return 0, 0, false
123- }
124115 action, ok := b.altRunes[key]
125- return action, size, ok
116+ return action, ok
126117 }
127118
128119 func (b *Bindings) actionForAltArrow(final byte) (Action, bool) {
@@ -1,7 +1,5 @@
1 package keys1 package keys
2 2
3-import "unicode/utf8"
4-
5 // Bindings maps intercepted Alt keys to actions. A Bindings value is mutable3 // Bindings maps intercepted Alt keys to actions. A Bindings value is mutable
6 // while being built, then treated as frozen once published through an atomic4 // while being built, then treated as frozen once published through an atomic
7 // pointer; callers must clone before using mutators.5 // pointer; callers must clone before using mutators.
@@ -110,19 +108,12 @@ func (b *Bindings) conflictingAction(spec KeySpec, action Action) (Action, bool)
110 return 0, false108 return 0, false
111 }109 }
112 110
113-func (b *Bindings) actionForAltBytes(data []byte) (Action, int, bool) {111+func (b *Bindings) actionForAltRune(key rune) (Action, bool) {
114 if b == nil {112 if b == nil {
115 b = defaultBindings113 b = defaultBindings
116 }114 }
117- if len(data) == 0 {
118- return 0, 0, false
119- }
120- key, size := utf8.DecodeRune(data)
121- if key == utf8.RuneError && size == 1 {
122- return 0, 0, false
123- }
124 action, ok := b.altRunes[key]115 action, ok := b.altRunes[key]
125- return action, size, ok116+ return action, ok
126 }117 }
127 118
128 func (b *Bindings) actionForAltArrow(final byte) (Action, bool) {119 func (b *Bindings) actionForAltArrow(final byte) (Action, bool) {
M internal/usecase/keys/keyspec_test.go +2 -2
@@ -254,14 +254,14 @@ func TestBuildBindingEntriesFailedOverrideRestoreDoesNotOverwriteEarlierOverride
254254
255255 func assertAltRuneAction(t *testing.T, bindings *Bindings, key rune, want Action) {
256256 t.Helper()
257- got, _, ok := bindings.actionForAltBytes([]byte(string(key)))
257+ got, ok := bindings.actionForAltRune(key)
258258 require.True(t, ok)
259259 require.Equal(t, want, got)
260260 }
261261
262262 func assertAltRuneUnbound(t *testing.T, bindings *Bindings, key rune) {
263263 t.Helper()
264- _, _, ok := bindings.actionForAltBytes([]byte(string(key)))
264+ _, ok := bindings.actionForAltRune(key)
265265 require.False(t, ok)
266266 }
267267
@@ -254,14 +254,14 @@ func TestBuildBindingEntriesFailedOverrideRestoreDoesNotOverwriteEarlierOverride
254 254
255 func assertAltRuneAction(t *testing.T, bindings *Bindings, key rune, want Action) {255 func assertAltRuneAction(t *testing.T, bindings *Bindings, key rune, want Action) {
256 t.Helper()256 t.Helper()
257- got, _, ok := bindings.actionForAltBytes([]byte(string(key)))257+ got, ok := bindings.actionForAltRune(key)
258 require.True(t, ok)258 require.True(t, ok)
259 require.Equal(t, want, got)259 require.Equal(t, want, got)
260 }260 }
261 261
262 func assertAltRuneUnbound(t *testing.T, bindings *Bindings, key rune) {262 func assertAltRuneUnbound(t *testing.T, bindings *Bindings, key rune) {
263 t.Helper()263 t.Helper()
264- _, _, ok := bindings.actionForAltBytes([]byte(string(key)))264+ _, ok := bindings.actionForAltRune(key)
265 require.False(t, ok)265 require.False(t, ok)
266 }266 }
267 267
A internal/usecase/keys/kittykey/kittykey.go +415 -0
@@ -0,0 +1,415 @@
1+// Package kittykey decodes kitty keyboard protocol key events and re-encodes
2+// them for a receiver that requested a given set of enhancement flags.
3+//
4+// vev enables Disambiguate|AlternateKeys on the outer terminal. Keys that the
5+// legacy encoding cannot express unambiguously then arrive as
6+// CSI code[:shifted[:base]] [; mods] u, and F1-F4 as CSI [1;mods] P/Q/S.
7+// Everything else stays legacy. Translate rewrites only those sequences, so
8+// legacy input passes through byte-for-byte.
9+//
10+// The package is pure: no I/O, clocks, or ports.
11+package kittykey
12+
13+import (
14+ "bytes"
15+ "strconv"
16+ "unicode"
17+ "unicode/utf8"
18+)
19+
20+// Enhancement flags from the kitty keyboard protocol.
21+const (
22+ FlagDisambiguate = 1 << 0
23+ FlagReportEvents = 1 << 1
24+ FlagAlternateKeys = 1 << 2
25+ FlagAllKeys = 1 << 3
26+ FlagText = 1 << 4
27+
28+ // OuterFlags are the flags vev requests from the outer terminal.
29+ OuterFlags = FlagDisambiguate | FlagAlternateKeys
30+)
31+
32+// Modifier bits, as encoded in the mods parameter minus one.
33+const (
34+ ModShift = 1 << 0
35+ ModAlt = 1 << 1
36+ ModCtrl = 1 << 2
37+ ModSuper = 1 << 3
38+ ModHyper = 1 << 4
39+ ModMeta = 1 << 5
40+
41+ modLocks = 1<<6 | 1<<7 // caps lock, num lock
42+)
43+
44+// Key codes of the functional keys that have legacy single-byte forms.
45+const (
46+ KeyEscape = 27
47+ KeyEnter = 13
48+ KeyTab = 9
49+ KeyBackspace = 127
50+)
51+
52+// Event is one decoded CSI u key press.
53+type Event struct {
54+ // Code is the unicode key code: the unshifted key of the current layout.
55+ Code rune
56+ // Shifted is the shifted key, when reported (AlternateKeys with Shift).
57+ Shifted rune
58+ // Base is the key in the standard PC-101 layout, when reported.
59+ Base rune
60+ // Mods is the modifier bit set (ModShift...), lock bits removed.
61+ Mods int
62+ // Release reports a key-release event (only with FlagReportEvents).
63+ Release bool
64+}
65+
66+// OnlyMods reports whether exactly the given modifiers are active.
67+func (e Event) OnlyMods(mods int) bool { return e.Mods == mods }
68+
69+// Digit reports the top-row digit 1-9 the key represents, preferring the
70+// PC-101 base key so that layouts like AZERTY (whose unshifted digit keys
71+// produce symbols) still map to digits.
72+func (e Event) Digit() (int, bool) {
73+ for _, r := range [...]rune{e.Base, e.Code} {
74+ if r >= '1' && r <= '9' {
75+ return int(r - '0'), true
76+ }
77+ }
78+ return 0, false
79+}
80+
81+const maxSeq = 64
82+
83+var (
84+ pasteOpen = []byte("\x1b[200~")
85+ pasteClose = []byte("\x1b[201~")
86+)
87+
88+// Parse decodes one CSI u key sequence at the start of data. It returns the
89+// event, the sequence length, and ok. partial reports that data is a strict
90+// prefix of a sequence that could still become a CSI u key event.
91+func Parse(data []byte) (ev Event, n int, ok, partial bool) {
92+ if len(data) < 2 || data[0] != 0x1b || data[1] != '[' {
93+ return Event{}, 0, false, len(data) == 1 && data[0] == 0x1b
94+ }
95+ i := 2
96+ for ; i < len(data) && i < maxSeq; i++ {
97+ b := data[i]
98+ if (b >= '0' && b <= '9') || b == ';' || b == ':' {
99+ continue
100+ }
101+ if b != 'u' || i == 2 {
102+ return Event{}, 0, false, false
103+ }
104+ ev, ok := parseParams(data[2:i])
105+ return ev, i + 1, ok, false
106+ }
107+ return Event{}, 0, false, i < maxSeq
108+}
109+
110+func parseParams(params []byte) (Event, bool) {
111+ fields := splitByte(params, ';')
112+ if len(fields) > 3 {
113+ return Event{}, false
114+ }
115+ keys := splitByte(fields[0], ':')
116+ if len(keys) > 3 {
117+ return Event{}, false
118+ }
119+ code, ok := atoi(keys[0])
120+ if !ok || code == 0 {
121+ return Event{}, false
122+ }
123+ ev := Event{Code: rune(code)}
124+ if len(keys) > 1 && len(keys[1]) > 0 {
125+ v, ok := atoi(keys[1])
126+ if !ok {
127+ return Event{}, false
128+ }
129+ ev.Shifted = rune(v)
130+ }
131+ if len(keys) > 2 && len(keys[2]) > 0 {
132+ v, ok := atoi(keys[2])
133+ if !ok {
134+ return Event{}, false
135+ }
136+ ev.Base = rune(v)
137+ }
138+ 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
146+ }
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
152+ }
153+ ev.Release = event == 3
154+ }
155+ }
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
159+}
160+
161+// Translate rewrites every complete CSI u key sequence and kitty F1-F4 form in
162+// data for a receiver that requested flags. With flags == 0 the result is the
163+// legacy encoding; keys without a legacy form are dropped. With
164+// FlagDisambiguate set, CSI u is kept and only subfields the receiver did not
165+// ask for are removed. Other bytes, including bracketed paste bodies, are
166+// copied unchanged. When nothing changes, data itself is returned.
167+func Translate(data []byte, flags int) []byte {
168+ var out []byte
169+ last := 0
170+ for i := 0; i+2 < len(data); i++ {
171+ if data[i] != 0x1b || data[i+1] != '[' {
172+ continue
173+ }
174+ if bytes.HasPrefix(data[i:], pasteOpen) {
175+ end := bytes.Index(data[i:], pasteClose)
176+ if end < 0 {
177+ break
178+ }
179+ i += end + len(pasteClose) - 1
180+ continue
181+ }
182+ var repl []byte
183+ var n int
184+ if ev, size, ok, _ := Parse(data[i:]); ok {
185+ repl, n = encode(ev, flags), size
186+ } else if fkey, size, ok := parseFunctionKey(data[i:]); ok {
187+ repl, n = fkey.encode(flags), size
188+ } else {
189+ continue
190+ }
191+ if out == nil {
192+ out = make([]byte, 0, len(data))
193+ }
194+ out = append(out, data[last:i]...)
195+ out = append(out, repl...)
196+ i += n - 1
197+ last = i + 1
198+ }
199+ if out == nil {
200+ return data
201+ }
202+ return append(out, data[last:]...)
203+}
204+
205+func encode(ev Event, flags int) []byte {
206+ if ev.Release && flags&FlagReportEvents == 0 {
207+ return nil
208+ }
209+ if flags&FlagDisambiguate != 0 || flags&FlagAllKeys != 0 {
210+ return encodeCSIu(ev, flags)
211+ }
212+ return encodeLegacy(ev)
213+}
214+
215+func encodeCSIu(ev Event, flags int) []byte {
216+ out := []byte("\x1b[")
217+ out = strconv.AppendInt(out, int64(ev.Code), 10)
218+ if flags&FlagAlternateKeys != 0 && (ev.Shifted != 0 || ev.Base != 0) {
219+ out = append(out, ':')
220+ if ev.Shifted != 0 {
221+ out = strconv.AppendInt(out, int64(ev.Shifted), 10)
222+ }
223+ if ev.Base != 0 {
224+ out = append(out, ':')
225+ out = strconv.AppendInt(out, int64(ev.Base), 10)
226+ }
227+ }
228+ release := ev.Release && flags&FlagReportEvents != 0
229+ if ev.Mods != 0 || release {
230+ out = append(out, ';')
231+ out = strconv.AppendInt(out, int64(ev.Mods+1), 10)
232+ if release {
233+ out = append(out, ":3"...)
234+ }
235+ }
236+ return append(out, 'u')
237+}
238+
239+// encodeLegacy produces the xterm-style bytes for ev, or nil when the key has
240+// no legacy representation.
241+func encodeLegacy(ev Event) []byte {
242+ if ev.Mods&^(ModShift|ModAlt|ModCtrl) != 0 {
243+ // Super, Hyper, and Meta have no legacy encoding.
244+ return nil
245+ }
246+ var body []byte
247+ switch ev.Code {
248+ case KeyEscape:
249+ body = []byte{0x1b}
250+ case KeyEnter:
251+ body = []byte{'\r'}
252+ case KeyTab:
253+ if ev.Mods&ModShift != 0 {
254+ body = []byte("\x1b[Z")
255+ } else {
256+ body = []byte{'\t'}
257+ }
258+ case KeyBackspace:
259+ if ev.Mods&ModCtrl != 0 {
260+ body = []byte{0x08}
261+ } else {
262+ body = []byte{0x7f}
263+ }
264+ default:
265+ if seq, ok := keypadLegacy[ev.Code]; ok {
266+ if mods := ev.Mods &^ ModShift; mods != 0 && len(seq) > 2 && seq[0] == 0x1b {
267+ // Modified keypad navigation uses xterm's CSI 1;mods form.
268+ return modifiedCSI(seq, ev.Mods)
269+ }
270+ body = []byte(seq)
271+ break
272+ }
273+ if ev.Code < 0x20 || (ev.Code >= 0xe000 && ev.Code <= 0xf8ff) || !utf8.ValidRune(ev.Code) {
274+ // Functional keys in the private use area have no legacy form.
275+ return nil
276+ }
277+ body = legacyText(ev)
278+ }
279+ if ev.Mods&ModAlt != 0 {
280+ return append([]byte{0x1b}, body...)
281+ }
282+ return body
283+}
284+
285+func legacyText(ev Event) []byte {
286+ r := ev.Code
287+ if ev.Mods&ModShift != 0 {
288+ switch {
289+ case ev.Shifted != 0:
290+ r = ev.Shifted
291+ default:
292+ r = unicode.ToUpper(r)
293+ }
294+ }
295+ if ev.Mods&ModCtrl != 0 {
296+ if c, ok := ctrlByte(r); ok {
297+ return []byte{c}
298+ }
299+ // Non-Latin layouts: fall back to the PC-101 base key so Ctrl+letter
300+ // still produces its control byte.
301+ if c, ok := ctrlByte(ev.Base); ok {
302+ return []byte{c}
303+ }
304+ }
305+ return utf8.AppendRune(nil, r)
306+}
307+
308+// ctrlByte maps a key to the C0 byte xterm sends for Ctrl+key.
309+func ctrlByte(r rune) (byte, bool) {
310+ switch {
311+ case r >= 'a' && r <= 'z':
312+ return byte(r - 'a' + 1), true
313+ case r >= 'A' && r <= 'Z':
314+ return byte(r - 'A' + 1), true
315+ }
316+ switch r {
317+ case ' ', '@', '2':
318+ return 0x00, true
319+ case '[', '3':
320+ return 0x1b, true
321+ case '\\', '4':
322+ return 0x1c, true
323+ case ']', '5':
324+ return 0x1d, true
325+ case '^', '6', '~':
326+ return 0x1e, true
327+ case '_', '/', '7', '-':
328+ return 0x1f, true
329+ case '8', '?':
330+ return 0x7f, true
331+ }
332+ return 0, false
333+}
334+
335+// keypadLegacy maps kitty keypad private-use codes (reported as separate keys
336+// under FlagDisambiguate) to the bytes a legacy terminal sends.
337+var keypadLegacy = map[rune]string{
338+ 57399: "0", 57400: "1", 57401: "2", 57402: "3", 57403: "4",
339+ 57404: "5", 57405: "6", 57406: "7", 57407: "8", 57408: "9",
340+ 57409: ".", 57410: "/", 57411: "*", 57412: "-", 57413: "+",
341+ 57414: "\r", 57415: "=", 57416: ",",
342+ 57417: "\x1b[D", 57418: "\x1b[C", 57419: "\x1b[A", 57420: "\x1b[B",
343+ 57421: "\x1b[5~", 57422: "\x1b[6~", 57423: "\x1b[H", 57424: "\x1b[F",
344+ 57425: "\x1b[2~", 57426: "\x1b[3~", 57427: "\x1b[E",
345+}
346+
347+// modifiedCSI turns a legacy cursor (CSI X) or tilde (CSI n ~) key into its
348+// xterm modified form: CSI 1;m X or CSI n;m ~.
349+func modifiedCSI(seq string, mods int) []byte {
350+ m := strconv.Itoa(mods + 1)
351+ final := seq[len(seq)-1]
352+ if final == '~' {
353+ return []byte(seq[:len(seq)-1] + ";" + m + "~")
354+ }
355+ return []byte("\x1b[1;" + m + string(final))
356+}
357+
358+// functionKey is a kitty CSI [1;mods] P|Q|S form of F1, F2, or F4. Legacy
359+// terminals send SS3 P|Q|S when unmodified.
360+type functionKey struct {
361+ final byte
362+ mods []byte // the raw "1;mods" parameter bytes, empty when unmodified
363+}
364+
365+func parseFunctionKey(data []byte) (functionKey, int, bool) {
366+ for i := 2; i < len(data) && i < maxSeq; i++ {
367+ b := data[i]
368+ if (b >= '0' && b <= '9') || b == ';' || b == ':' {
369+ continue
370+ }
371+ if b != 'P' && b != 'Q' && b != 'S' {
372+ return functionKey{}, 0, false
373+ }
374+ params := data[2:i]
375+ if len(params) != 0 && (len(params) < 3 || params[0] != '1' || params[1] != ';') {
376+ return functionKey{}, 0, false
377+ }
378+ return functionKey{final: b, mods: params}, i + 1, true
379+ }
380+ return functionKey{}, 0, false
381+}
382+
383+func (f functionKey) encode(flags int) []byte {
384+ if len(f.mods) == 0 && flags&FlagDisambiguate == 0 {
385+ return []byte{0x1b, 'O', f.final}
386+ }
387+ out := append([]byte("\x1b["), f.mods...)
388+ return append(out, f.final)
389+}
390+
391+func splitByte(b []byte, sep byte) [][]byte {
392+ var out [][]byte
393+ start := 0
394+ for i := 0; i <= len(b); i++ {
395+ if i == len(b) || b[i] == sep {
396+ out = append(out, b[start:i])
397+ start = i + 1
398+ }
399+ }
400+ return out
401+}
402+
403+func atoi(b []byte) (int, bool) {
404+ if len(b) == 0 || len(b) > 7 {
405+ return 0, false
406+ }
407+ n := 0
408+ for _, c := range b {
409+ if c < '0' || c > '9' {
410+ return 0, false
411+ }
412+ n = n*10 + int(c-'0')
413+ }
414+ return n, true
415+}
@@ -0,0 +1,415 @@
1+// Package kittykey decodes kitty keyboard protocol key events and re-encodes
2+// them for a receiver that requested a given set of enhancement flags.
3+//
4+// vev enables Disambiguate|AlternateKeys on the outer terminal. Keys that the
5+// legacy encoding cannot express unambiguously then arrive as
6+// CSI code[:shifted[:base]] [; mods] u, and F1-F4 as CSI [1;mods] P/Q/S.
7+// Everything else stays legacy. Translate rewrites only those sequences, so
8+// legacy input passes through byte-for-byte.
9+//
10+// The package is pure: no I/O, clocks, or ports.
11+package kittykey
12+
13+import (
14+ "bytes"
15+ "strconv"
16+ "unicode"
17+ "unicode/utf8"
18+)
19+
20+// Enhancement flags from the kitty keyboard protocol.
21+const (
22+ FlagDisambiguate = 1 << 0
23+ FlagReportEvents = 1 << 1
24+ FlagAlternateKeys = 1 << 2
25+ FlagAllKeys = 1 << 3
26+ FlagText = 1 << 4
27+
28+ // OuterFlags are the flags vev requests from the outer terminal.
29+ OuterFlags = FlagDisambiguate | FlagAlternateKeys
30+)
31+
32+// Modifier bits, as encoded in the mods parameter minus one.
33+const (
34+ ModShift = 1 << 0
35+ ModAlt = 1 << 1
36+ ModCtrl = 1 << 2
37+ ModSuper = 1 << 3
38+ ModHyper = 1 << 4
39+ ModMeta = 1 << 5
40+
41+ modLocks = 1<<6 | 1<<7 // caps lock, num lock
42+)
43+
44+// Key codes of the functional keys that have legacy single-byte forms.
45+const (
46+ KeyEscape = 27
47+ KeyEnter = 13
48+ KeyTab = 9
49+ KeyBackspace = 127
50+)
51+
52+// Event is one decoded CSI u key press.
53+type Event struct {
54+ // Code is the unicode key code: the unshifted key of the current layout.
55+ Code rune
56+ // Shifted is the shifted key, when reported (AlternateKeys with Shift).
57+ Shifted rune
58+ // Base is the key in the standard PC-101 layout, when reported.
59+ Base rune
60+ // Mods is the modifier bit set (ModShift...), lock bits removed.
61+ Mods int
62+ // Release reports a key-release event (only with FlagReportEvents).
63+ Release bool
64+}
65+
66+// OnlyMods reports whether exactly the given modifiers are active.
67+func (e Event) OnlyMods(mods int) bool { return e.Mods == mods }
68+
69+// Digit reports the top-row digit 1-9 the key represents, preferring the
70+// PC-101 base key so that layouts like AZERTY (whose unshifted digit keys
71+// produce symbols) still map to digits.
72+func (e Event) Digit() (int, bool) {
73+ for _, r := range [...]rune{e.Base, e.Code} {
74+ if r >= '1' && r <= '9' {
75+ return int(r - '0'), true
76+ }
77+ }
78+ return 0, false
79+}
80+
81+const maxSeq = 64
82+
83+var (
84+ pasteOpen = []byte("\x1b[200~")
85+ pasteClose = []byte("\x1b[201~")
86+)
87+
88+// Parse decodes one CSI u key sequence at the start of data. It returns the
89+// event, the sequence length, and ok. partial reports that data is a strict
90+// prefix of a sequence that could still become a CSI u key event.
91+func Parse(data []byte) (ev Event, n int, ok, partial bool) {
92+ if len(data) < 2 || data[0] != 0x1b || data[1] != '[' {
93+ return Event{}, 0, false, len(data) == 1 && data[0] == 0x1b
94+ }
95+ i := 2
96+ for ; i < len(data) && i < maxSeq; i++ {
97+ b := data[i]
98+ if (b >= '0' && b <= '9') || b == ';' || b == ':' {
99+ continue
100+ }
101+ if b != 'u' || i == 2 {
102+ return Event{}, 0, false, false
103+ }
104+ ev, ok := parseParams(data[2:i])
105+ return ev, i + 1, ok, false
106+ }
107+ return Event{}, 0, false, i < maxSeq
108+}
109+
110+func parseParams(params []byte) (Event, bool) {
111+ fields := splitByte(params, ';')
112+ if len(fields) > 3 {
113+ return Event{}, false
114+ }
115+ keys := splitByte(fields[0], ':')
116+ if len(keys) > 3 {
117+ return Event{}, false
118+ }
119+ code, ok := atoi(keys[0])
120+ if !ok || code == 0 {
121+ return Event{}, false
122+ }
123+ ev := Event{Code: rune(code)}
124+ if len(keys) > 1 && len(keys[1]) > 0 {
125+ v, ok := atoi(keys[1])
126+ if !ok {
127+ return Event{}, false
128+ }
129+ ev.Shifted = rune(v)
130+ }
131+ if len(keys) > 2 && len(keys[2]) > 0 {
132+ v, ok := atoi(keys[2])
133+ if !ok {
134+ return Event{}, false
135+ }
136+ ev.Base = rune(v)
137+ }
138+ 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
146+ }
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
152+ }
153+ ev.Release = event == 3
154+ }
155+ }
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
159+}
160+
161+// Translate rewrites every complete CSI u key sequence and kitty F1-F4 form in
162+// data for a receiver that requested flags. With flags == 0 the result is the
163+// legacy encoding; keys without a legacy form are dropped. With
164+// FlagDisambiguate set, CSI u is kept and only subfields the receiver did not
165+// ask for are removed. Other bytes, including bracketed paste bodies, are
166+// copied unchanged. When nothing changes, data itself is returned.
167+func Translate(data []byte, flags int) []byte {
168+ var out []byte
169+ last := 0
170+ for i := 0; i+2 < len(data); i++ {
171+ if data[i] != 0x1b || data[i+1] != '[' {
172+ continue
173+ }
174+ if bytes.HasPrefix(data[i:], pasteOpen) {
175+ end := bytes.Index(data[i:], pasteClose)
176+ if end < 0 {
177+ break
178+ }
179+ i += end + len(pasteClose) - 1
180+ continue
181+ }
182+ var repl []byte
183+ var n int
184+ if ev, size, ok, _ := Parse(data[i:]); ok {
185+ repl, n = encode(ev, flags), size
186+ } else if fkey, size, ok := parseFunctionKey(data[i:]); ok {
187+ repl, n = fkey.encode(flags), size
188+ } else {
189+ continue
190+ }
191+ if out == nil {
192+ out = make([]byte, 0, len(data))
193+ }
194+ out = append(out, data[last:i]...)
195+ out = append(out, repl...)
196+ i += n - 1
197+ last = i + 1
198+ }
199+ if out == nil {
200+ return data
201+ }
202+ return append(out, data[last:]...)
203+}
204+
205+func encode(ev Event, flags int) []byte {
206+ if ev.Release && flags&FlagReportEvents == 0 {
207+ return nil
208+ }
209+ if flags&FlagDisambiguate != 0 || flags&FlagAllKeys != 0 {
210+ return encodeCSIu(ev, flags)
211+ }
212+ return encodeLegacy(ev)
213+}
214+
215+func encodeCSIu(ev Event, flags int) []byte {
216+ out := []byte("\x1b[")
217+ out = strconv.AppendInt(out, int64(ev.Code), 10)
218+ if flags&FlagAlternateKeys != 0 && (ev.Shifted != 0 || ev.Base != 0) {
219+ out = append(out, ':')
220+ if ev.Shifted != 0 {
221+ out = strconv.AppendInt(out, int64(ev.Shifted), 10)
222+ }
223+ if ev.Base != 0 {
224+ out = append(out, ':')
225+ out = strconv.AppendInt(out, int64(ev.Base), 10)
226+ }
227+ }
228+ release := ev.Release && flags&FlagReportEvents != 0
229+ if ev.Mods != 0 || release {
230+ out = append(out, ';')
231+ out = strconv.AppendInt(out, int64(ev.Mods+1), 10)
232+ if release {
233+ out = append(out, ":3"...)
234+ }
235+ }
236+ return append(out, 'u')
237+}
238+
239+// encodeLegacy produces the xterm-style bytes for ev, or nil when the key has
240+// no legacy representation.
241+func encodeLegacy(ev Event) []byte {
242+ if ev.Mods&^(ModShift|ModAlt|ModCtrl) != 0 {
243+ // Super, Hyper, and Meta have no legacy encoding.
244+ return nil
245+ }
246+ var body []byte
247+ switch ev.Code {
248+ case KeyEscape:
249+ body = []byte{0x1b}
250+ case KeyEnter:
251+ body = []byte{'\r'}
252+ case KeyTab:
253+ if ev.Mods&ModShift != 0 {
254+ body = []byte("\x1b[Z")
255+ } else {
256+ body = []byte{'\t'}
257+ }
258+ case KeyBackspace:
259+ if ev.Mods&ModCtrl != 0 {
260+ body = []byte{0x08}
261+ } else {
262+ body = []byte{0x7f}
263+ }
264+ default:
265+ if seq, ok := keypadLegacy[ev.Code]; ok {
266+ if mods := ev.Mods &^ ModShift; mods != 0 && len(seq) > 2 && seq[0] == 0x1b {
267+ // Modified keypad navigation uses xterm's CSI 1;mods form.
268+ return modifiedCSI(seq, ev.Mods)
269+ }
270+ body = []byte(seq)
271+ break
272+ }
273+ if ev.Code < 0x20 || (ev.Code >= 0xe000 && ev.Code <= 0xf8ff) || !utf8.ValidRune(ev.Code) {
274+ // Functional keys in the private use area have no legacy form.
275+ return nil
276+ }
277+ body = legacyText(ev)
278+ }
279+ if ev.Mods&ModAlt != 0 {
280+ return append([]byte{0x1b}, body...)
281+ }
282+ return body
283+}
284+
285+func legacyText(ev Event) []byte {
286+ r := ev.Code
287+ if ev.Mods&ModShift != 0 {
288+ switch {
289+ case ev.Shifted != 0:
290+ r = ev.Shifted
291+ default:
292+ r = unicode.ToUpper(r)
293+ }
294+ }
295+ if ev.Mods&ModCtrl != 0 {
296+ if c, ok := ctrlByte(r); ok {
297+ return []byte{c}
298+ }
299+ // Non-Latin layouts: fall back to the PC-101 base key so Ctrl+letter
300+ // still produces its control byte.
301+ if c, ok := ctrlByte(ev.Base); ok {
302+ return []byte{c}
303+ }
304+ }
305+ return utf8.AppendRune(nil, r)
306+}
307+
308+// ctrlByte maps a key to the C0 byte xterm sends for Ctrl+key.
309+func ctrlByte(r rune) (byte, bool) {
310+ switch {
311+ case r >= 'a' && r <= 'z':
312+ return byte(r - 'a' + 1), true
313+ case r >= 'A' && r <= 'Z':
314+ return byte(r - 'A' + 1), true
315+ }
316+ switch r {
317+ case ' ', '@', '2':
318+ return 0x00, true
319+ case '[', '3':
320+ return 0x1b, true
321+ case '\\', '4':
322+ return 0x1c, true
323+ case ']', '5':
324+ return 0x1d, true
325+ case '^', '6', '~':
326+ return 0x1e, true
327+ case '_', '/', '7', '-':
328+ return 0x1f, true
329+ case '8', '?':
330+ return 0x7f, true
331+ }
332+ return 0, false
333+}
334+
335+// keypadLegacy maps kitty keypad private-use codes (reported as separate keys
336+// under FlagDisambiguate) to the bytes a legacy terminal sends.
337+var keypadLegacy = map[rune]string{
338+ 57399: "0", 57400: "1", 57401: "2", 57402: "3", 57403: "4",
339+ 57404: "5", 57405: "6", 57406: "7", 57407: "8", 57408: "9",
340+ 57409: ".", 57410: "/", 57411: "*", 57412: "-", 57413: "+",
341+ 57414: "\r", 57415: "=", 57416: ",",
342+ 57417: "\x1b[D", 57418: "\x1b[C", 57419: "\x1b[A", 57420: "\x1b[B",
343+ 57421: "\x1b[5~", 57422: "\x1b[6~", 57423: "\x1b[H", 57424: "\x1b[F",
344+ 57425: "\x1b[2~", 57426: "\x1b[3~", 57427: "\x1b[E",
345+}
346+
347+// modifiedCSI turns a legacy cursor (CSI X) or tilde (CSI n ~) key into its
348+// xterm modified form: CSI 1;m X or CSI n;m ~.
349+func modifiedCSI(seq string, mods int) []byte {
350+ m := strconv.Itoa(mods + 1)
351+ final := seq[len(seq)-1]
352+ if final == '~' {
353+ return []byte(seq[:len(seq)-1] + ";" + m + "~")
354+ }
355+ return []byte("\x1b[1;" + m + string(final))
356+}
357+
358+// functionKey is a kitty CSI [1;mods] P|Q|S form of F1, F2, or F4. Legacy
359+// terminals send SS3 P|Q|S when unmodified.
360+type functionKey struct {
361+ final byte
362+ mods []byte // the raw "1;mods" parameter bytes, empty when unmodified
363+}
364+
365+func parseFunctionKey(data []byte) (functionKey, int, bool) {
366+ for i := 2; i < len(data) && i < maxSeq; i++ {
367+ b := data[i]
368+ if (b >= '0' && b <= '9') || b == ';' || b == ':' {
369+ continue
370+ }
371+ if b != 'P' && b != 'Q' && b != 'S' {
372+ return functionKey{}, 0, false
373+ }
374+ params := data[2:i]
375+ if len(params) != 0 && (len(params) < 3 || params[0] != '1' || params[1] != ';') {
376+ return functionKey{}, 0, false
377+ }
378+ return functionKey{final: b, mods: params}, i + 1, true
379+ }
380+ return functionKey{}, 0, false
381+}
382+
383+func (f functionKey) encode(flags int) []byte {
384+ if len(f.mods) == 0 && flags&FlagDisambiguate == 0 {
385+ return []byte{0x1b, 'O', f.final}
386+ }
387+ out := append([]byte("\x1b["), f.mods...)
388+ return append(out, f.final)
389+}
390+
391+func splitByte(b []byte, sep byte) [][]byte {
392+ var out [][]byte
393+ start := 0
394+ for i := 0; i <= len(b); i++ {
395+ if i == len(b) || b[i] == sep {
396+ out = append(out, b[start:i])
397+ start = i + 1
398+ }
399+ }
400+ return out
401+}
402+
403+func atoi(b []byte) (int, bool) {
404+ if len(b) == 0 || len(b) > 7 {
405+ return 0, false
406+ }
407+ n := 0
408+ for _, c := range b {
409+ if c < '0' || c > '9' {
410+ return 0, false
411+ }
412+ n = n*10 + int(c-'0')
413+ }
414+ return n, true
415+}
A internal/usecase/keys/kittykey/kittykey_test.go +172 -0
@@ -0,0 +1,172 @@
1+package kittykey
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestParse(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ in string
13+ ev Event
14+ ok bool
15+ partial bool
16+ }{
17+ {name: "plain", in: "\x1b[97u", ev: Event{Code: 'a'}, ok: true},
18+ {name: "ctrl", in: "\x1b[49;5u", ev: Event{Code: '1', Mods: ModCtrl}, ok: true},
19+ {name: "azerty ctrl 1 with base", in: "\x1b[38::49;5u", ev: Event{Code: '&', Base: '1', Mods: ModCtrl}, ok: true},
20+ {name: "shifted and base", in: "\x1b[97:65:97;2u", ev: Event{Code: 'a', Shifted: 'A', Base: 'a', Mods: ModShift}, ok: true},
21+ {name: "locks stripped", in: "\x1b[97;197u", ev: Event{Code: 'a', Mods: ModCtrl}, ok: true},
22+ {name: "release", in: "\x1b[97;1:3u", ev: Event{Code: 'a', Release: true}, ok: true},
23+ {name: "text field ignored", in: "\x1b[97;1;97u", ev: Event{Code: 'a'}, ok: true},
24+ {name: "trailing garbage", in: "\x1b[97uxyz", ev: Event{Code: 'a'}, ok: true},
25+ {name: "truncated", in: "\x1b[49;5", partial: true},
26+ {name: "truncated csi", in: "\x1b[", partial: true},
27+ {name: "truncated esc", in: "\x1b", partial: true},
28+ {name: "empty params", in: "\x1b[u"},
29+ {name: "zero code", in: "\x1b[0u"},
30+ {name: "mods zero", in: "\x1b[97;0u"},
31+ {name: "bad event", in: "\x1b[97;1:4u"},
32+ {name: "too many fields", in: "\x1b[97;1;2;3u"},
33+ {name: "other final", in: "\x1b[1;5A"},
34+ {name: "private reply", in: "\x1b[?5u"},
35+ {name: "huge number", in: "\x1b[99999999u"},
36+ {name: "not csi", in: "a"},
37+ }
38+ for _, tt := range tests {
39+ t.Run(tt.name, func(t *testing.T) {
40+ ev, n, ok, partial := Parse([]byte(tt.in))
41+ require.Equal(t, tt.ok, ok)
42+ require.Equal(t, tt.partial, partial)
43+ if tt.ok {
44+ require.Equal(t, tt.ev, ev)
45+ want := len(tt.in)
46+ if tt.name == "trailing garbage" {
47+ want -= len("xyz")
48+ }
49+ require.Equal(t, want, n)
50+ }
51+ })
52+ }
53+}
54+
55+func TestTranslateLegacy(t *testing.T) {
56+ tests := []struct {
57+ name string
58+ in string
59+ want string
60+ }{
61+ {name: "legacy passthrough", in: "hello\x1b[A\x1bj\r", want: "hello\x1b[A\x1bj\r"},
62+ {name: "escape", in: "\x1b[27u", want: "\x1b"},
63+ {name: "alt escape", in: "\x1b[27;3u", want: "\x1b\x1b"},
64+ {name: "enter", in: "\x1b[13u", want: "\r"},
65+ {name: "tab", in: "\x1b[9u", want: "\t"},
66+ {name: "shift tab", in: "\x1b[9;2u", want: "\x1b[Z"},
67+ {name: "backspace", in: "\x1b[127u", want: "\x7f"},
68+ {name: "ctrl backspace", in: "\x1b[127;5u", want: "\x08"},
69+ {name: "ctrl i", in: "\x1b[105;5u", want: "\t"},
70+ {name: "ctrl a", in: "\x1b[97;5u", want: "\x01"},
71+ {name: "ctrl alt a", in: "\x1b[97;7u", want: "\x1b\x01"},
72+ {name: "ctrl shift a", in: "\x1b[97:65;6u", want: "\x01"},
73+ {name: "ctrl space", in: "\x1b[32;5u", want: "\x00"},
74+ {name: "ctrl 1 has no control byte", in: "\x1b[49;5u", want: "1"},
75+ {name: "ctrl 3", in: "\x1b[51;5u", want: "\x1b"},
76+ {name: "azerty ctrl ampersand", in: "\x1b[38::49;5u", want: "&"},
77+ {name: "cyrillic ctrl uses base", in: "\x1b[1092::97;5u", want: "\x01"},
78+ {name: "shift letter with shifted", in: "\x1b[97:65;2u", want: "A"},
79+ {name: "shift letter without shifted", in: "\x1b[97;2u", want: "A"},
80+ {name: "alt utf8", in: "\x1b[233;3u", want: "\x1bé"},
81+ {name: "super dropped", in: "\x1b[97;9u", want: ""},
82+ {name: "release dropped", in: "\x1b[97;1:3u", want: ""},
83+ {name: "private use dropped", in: "\x1b[57441u", want: ""},
84+ {name: "keypad digit", in: "\x1b[57400u", want: "1"},
85+ {name: "keypad enter", in: "\x1b[57414u", want: "\r"},
86+ {name: "ctrl keypad left", in: "\x1b[57417;5u", want: "\x1b[1;5D"},
87+ {name: "ctrl keypad delete", in: "\x1b[57426;5u", want: "\x1b[3;5~"},
88+ {name: "keypad begin", in: "\x1b[57427u", want: "\x1b[E"},
89+ {name: "keypad separator", in: "\x1b[57416u", want: ","},
90+ {name: "f1 unmodified", in: "\x1b[P", want: "\x1bOP"},
91+ {name: "f2 modified kept", in: "\x1b[1;5Q", want: "\x1b[1;5Q"},
92+ {name: "mixed", in: "a\x1b[97;5ub\x1b[27uc", want: "a\x01b\x1bc"},
93+ {name: "truncated prefix untouched", in: "x\x1b[97;5", want: "x\x1b[97;5"},
94+ {name: "trailing garbage", in: "\x1b[97;5u\x1b[zz", want: "\x01\x1b[zz"},
95+ {name: "paste body untouched", in: "\x1b[200~\x1b[97;5u\x1b[201~\x1b[97;5u", want: "\x1b[200~\x1b[97;5u\x1b[201~\x01"},
96+ {name: "unterminated paste untouched", in: "\x1b[200~\x1b[97;5u", want: "\x1b[200~\x1b[97;5u"},
97+ {name: "mouse untouched", in: "\x1b[<0;1;1M", want: "\x1b[<0;1;1M"},
98+ {name: "keyboard reply untouched", in: "\x1b[?5u", want: "\x1b[?5u"},
99+ }
100+ for _, tt := range tests {
101+ t.Run(tt.name, func(t *testing.T) {
102+ require.Equal(t, []byte(tt.want), append([]byte{}, Translate([]byte(tt.in), 0)...))
103+ })
104+ }
105+}
106+
107+func TestTranslateKittyReceiver(t *testing.T) {
108+ tests := []struct {
109+ name string
110+ in string
111+ flags int
112+ want string
113+ }{
114+ {name: "same flags identity", in: "\x1b[38::49;5u", flags: OuterFlags, want: "\x1b[38::49;5u"},
115+ {name: "strip alternates", in: "\x1b[38::49;5u", flags: FlagDisambiguate, want: "\x1b[38;5u"},
116+ {name: "keep shifted", in: "\x1b[97:65;2u", flags: FlagDisambiguate | FlagAlternateKeys, want: "\x1b[97:65;2u"},
117+ {name: "unmodified", in: "\x1b[27u", flags: FlagDisambiguate, want: "\x1b[27u"},
118+ {name: "release dropped without events", in: "\x1b[97;1:3u", flags: FlagDisambiguate, want: ""},
119+ {name: "release kept with events", in: "\x1b[97;1:3u", flags: FlagDisambiguate | FlagReportEvents, want: "\x1b[97;1:3u"},
120+ {name: "f1 stays csi", in: "\x1b[P", flags: FlagDisambiguate, want: "\x1b[P"},
121+ }
122+ for _, tt := range tests {
123+ t.Run(tt.name, func(t *testing.T) {
124+ require.Equal(t, []byte(tt.want), append([]byte{}, Translate([]byte(tt.in), tt.flags)...))
125+ })
126+ }
127+}
128+
129+func TestTranslateReturnsInputWhenUnchanged(t *testing.T) {
130+ in := []byte("plain \x1b[A text")
131+ out := Translate(in, 0)
132+ require.Same(t, &in[0], &out[0])
133+}
134+
135+func TestEventDigit(t *testing.T) {
136+ tests := []struct {
137+ ev Event
138+ want int
139+ ok bool
140+ }{
141+ {ev: Event{Code: '1'}, want: 1, ok: true},
142+ {ev: Event{Code: '&', Base: '1'}, want: 1, ok: true},
143+ {ev: Event{Code: 'é', Base: '2'}, want: 2, ok: true},
144+ {ev: Event{Code: '0'}},
145+ {ev: Event{Code: 'a'}},
146+ }
147+ for _, tt := range tests {
148+ got, ok := tt.ev.Digit()
149+ require.Equal(t, tt.ok, ok, "%+v", tt.ev)
150+ require.Equal(t, tt.want, got)
151+ }
152+}
153+
154+func FuzzTranslate(f *testing.F) {
155+ for _, seed := range []string{"\x1b[97;5u", "\x1b[38::49;5u", "\x1b[200~x\x1b[201~", "\x1b[1;5P", "a\x1b["} {
156+ f.Add([]byte(seed), 0)
157+ f.Add([]byte(seed), OuterFlags)
158+ }
159+ f.Fuzz(func(t *testing.T, data []byte, flags int) {
160+ out := Translate(data, flags&0x1f)
161+ // Translation never grows input by more than a bounded factor.
162+ require.LessOrEqual(t, len(out), 2*len(data)+16)
163+ })
164+}
165+
166+func BenchmarkTranslateLegacyText(b *testing.B) {
167+ data := []byte("the quick brown fox jumps over the lazy dog\r")
168+ b.ReportAllocs()
169+ for b.Loop() {
170+ _ = Translate(data, 0)
171+ }
172+}
@@ -0,0 +1,172 @@
1+package kittykey
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestParse(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ in string
13+ ev Event
14+ ok bool
15+ partial bool
16+ }{
17+ {name: "plain", in: "\x1b[97u", ev: Event{Code: 'a'}, ok: true},
18+ {name: "ctrl", in: "\x1b[49;5u", ev: Event{Code: '1', Mods: ModCtrl}, ok: true},
19+ {name: "azerty ctrl 1 with base", in: "\x1b[38::49;5u", ev: Event{Code: '&', Base: '1', Mods: ModCtrl}, ok: true},
20+ {name: "shifted and base", in: "\x1b[97:65:97;2u", ev: Event{Code: 'a', Shifted: 'A', Base: 'a', Mods: ModShift}, ok: true},
21+ {name: "locks stripped", in: "\x1b[97;197u", ev: Event{Code: 'a', Mods: ModCtrl}, ok: true},
22+ {name: "release", in: "\x1b[97;1:3u", ev: Event{Code: 'a', Release: true}, ok: true},
23+ {name: "text field ignored", in: "\x1b[97;1;97u", ev: Event{Code: 'a'}, ok: true},
24+ {name: "trailing garbage", in: "\x1b[97uxyz", ev: Event{Code: 'a'}, ok: true},
25+ {name: "truncated", in: "\x1b[49;5", partial: true},
26+ {name: "truncated csi", in: "\x1b[", partial: true},
27+ {name: "truncated esc", in: "\x1b", partial: true},
28+ {name: "empty params", in: "\x1b[u"},
29+ {name: "zero code", in: "\x1b[0u"},
30+ {name: "mods zero", in: "\x1b[97;0u"},
31+ {name: "bad event", in: "\x1b[97;1:4u"},
32+ {name: "too many fields", in: "\x1b[97;1;2;3u"},
33+ {name: "other final", in: "\x1b[1;5A"},
34+ {name: "private reply", in: "\x1b[?5u"},
35+ {name: "huge number", in: "\x1b[99999999u"},
36+ {name: "not csi", in: "a"},
37+ }
38+ for _, tt := range tests {
39+ t.Run(tt.name, func(t *testing.T) {
40+ ev, n, ok, partial := Parse([]byte(tt.in))
41+ require.Equal(t, tt.ok, ok)
42+ require.Equal(t, tt.partial, partial)
43+ if tt.ok {
44+ require.Equal(t, tt.ev, ev)
45+ want := len(tt.in)
46+ if tt.name == "trailing garbage" {
47+ want -= len("xyz")
48+ }
49+ require.Equal(t, want, n)
50+ }
51+ })
52+ }
53+}
54+
55+func TestTranslateLegacy(t *testing.T) {
56+ tests := []struct {
57+ name string
58+ in string
59+ want string
60+ }{
61+ {name: "legacy passthrough", in: "hello\x1b[A\x1bj\r", want: "hello\x1b[A\x1bj\r"},
62+ {name: "escape", in: "\x1b[27u", want: "\x1b"},
63+ {name: "alt escape", in: "\x1b[27;3u", want: "\x1b\x1b"},
64+ {name: "enter", in: "\x1b[13u", want: "\r"},
65+ {name: "tab", in: "\x1b[9u", want: "\t"},
66+ {name: "shift tab", in: "\x1b[9;2u", want: "\x1b[Z"},
67+ {name: "backspace", in: "\x1b[127u", want: "\x7f"},
68+ {name: "ctrl backspace", in: "\x1b[127;5u", want: "\x08"},
69+ {name: "ctrl i", in: "\x1b[105;5u", want: "\t"},
70+ {name: "ctrl a", in: "\x1b[97;5u", want: "\x01"},
71+ {name: "ctrl alt a", in: "\x1b[97;7u", want: "\x1b\x01"},
72+ {name: "ctrl shift a", in: "\x1b[97:65;6u", want: "\x01"},
73+ {name: "ctrl space", in: "\x1b[32;5u", want: "\x00"},
74+ {name: "ctrl 1 has no control byte", in: "\x1b[49;5u", want: "1"},
75+ {name: "ctrl 3", in: "\x1b[51;5u", want: "\x1b"},
76+ {name: "azerty ctrl ampersand", in: "\x1b[38::49;5u", want: "&"},
77+ {name: "cyrillic ctrl uses base", in: "\x1b[1092::97;5u", want: "\x01"},
78+ {name: "shift letter with shifted", in: "\x1b[97:65;2u", want: "A"},
79+ {name: "shift letter without shifted", in: "\x1b[97;2u", want: "A"},
80+ {name: "alt utf8", in: "\x1b[233;3u", want: "\x1bé"},
81+ {name: "super dropped", in: "\x1b[97;9u", want: ""},
82+ {name: "release dropped", in: "\x1b[97;1:3u", want: ""},
83+ {name: "private use dropped", in: "\x1b[57441u", want: ""},
84+ {name: "keypad digit", in: "\x1b[57400u", want: "1"},
85+ {name: "keypad enter", in: "\x1b[57414u", want: "\r"},
86+ {name: "ctrl keypad left", in: "\x1b[57417;5u", want: "\x1b[1;5D"},
87+ {name: "ctrl keypad delete", in: "\x1b[57426;5u", want: "\x1b[3;5~"},
88+ {name: "keypad begin", in: "\x1b[57427u", want: "\x1b[E"},
89+ {name: "keypad separator", in: "\x1b[57416u", want: ","},
90+ {name: "f1 unmodified", in: "\x1b[P", want: "\x1bOP"},
91+ {name: "f2 modified kept", in: "\x1b[1;5Q", want: "\x1b[1;5Q"},
92+ {name: "mixed", in: "a\x1b[97;5ub\x1b[27uc", want: "a\x01b\x1bc"},
93+ {name: "truncated prefix untouched", in: "x\x1b[97;5", want: "x\x1b[97;5"},
94+ {name: "trailing garbage", in: "\x1b[97;5u\x1b[zz", want: "\x01\x1b[zz"},
95+ {name: "paste body untouched", in: "\x1b[200~\x1b[97;5u\x1b[201~\x1b[97;5u", want: "\x1b[200~\x1b[97;5u\x1b[201~\x01"},
96+ {name: "unterminated paste untouched", in: "\x1b[200~\x1b[97;5u", want: "\x1b[200~\x1b[97;5u"},
97+ {name: "mouse untouched", in: "\x1b[<0;1;1M", want: "\x1b[<0;1;1M"},
98+ {name: "keyboard reply untouched", in: "\x1b[?5u", want: "\x1b[?5u"},
99+ }
100+ for _, tt := range tests {
101+ t.Run(tt.name, func(t *testing.T) {
102+ require.Equal(t, []byte(tt.want), append([]byte{}, Translate([]byte(tt.in), 0)...))
103+ })
104+ }
105+}
106+
107+func TestTranslateKittyReceiver(t *testing.T) {
108+ tests := []struct {
109+ name string
110+ in string
111+ flags int
112+ want string
113+ }{
114+ {name: "same flags identity", in: "\x1b[38::49;5u", flags: OuterFlags, want: "\x1b[38::49;5u"},
115+ {name: "strip alternates", in: "\x1b[38::49;5u", flags: FlagDisambiguate, want: "\x1b[38;5u"},
116+ {name: "keep shifted", in: "\x1b[97:65;2u", flags: FlagDisambiguate | FlagAlternateKeys, want: "\x1b[97:65;2u"},
117+ {name: "unmodified", in: "\x1b[27u", flags: FlagDisambiguate, want: "\x1b[27u"},
118+ {name: "release dropped without events", in: "\x1b[97;1:3u", flags: FlagDisambiguate, want: ""},
119+ {name: "release kept with events", in: "\x1b[97;1:3u", flags: FlagDisambiguate | FlagReportEvents, want: "\x1b[97;1:3u"},
120+ {name: "f1 stays csi", in: "\x1b[P", flags: FlagDisambiguate, want: "\x1b[P"},
121+ }
122+ for _, tt := range tests {
123+ t.Run(tt.name, func(t *testing.T) {
124+ require.Equal(t, []byte(tt.want), append([]byte{}, Translate([]byte(tt.in), tt.flags)...))
125+ })
126+ }
127+}
128+
129+func TestTranslateReturnsInputWhenUnchanged(t *testing.T) {
130+ in := []byte("plain \x1b[A text")
131+ out := Translate(in, 0)
132+ require.Same(t, &in[0], &out[0])
133+}
134+
135+func TestEventDigit(t *testing.T) {
136+ tests := []struct {
137+ ev Event
138+ want int
139+ ok bool
140+ }{
141+ {ev: Event{Code: '1'}, want: 1, ok: true},
142+ {ev: Event{Code: '&', Base: '1'}, want: 1, ok: true},
143+ {ev: Event{Code: 'é', Base: '2'}, want: 2, ok: true},
144+ {ev: Event{Code: '0'}},
145+ {ev: Event{Code: 'a'}},
146+ }
147+ for _, tt := range tests {
148+ got, ok := tt.ev.Digit()
149+ require.Equal(t, tt.ok, ok, "%+v", tt.ev)
150+ require.Equal(t, tt.want, got)
151+ }
152+}
153+
154+func FuzzTranslate(f *testing.F) {
155+ for _, seed := range []string{"\x1b[97;5u", "\x1b[38::49;5u", "\x1b[200~x\x1b[201~", "\x1b[1;5P", "a\x1b["} {
156+ f.Add([]byte(seed), 0)
157+ f.Add([]byte(seed), OuterFlags)
158+ }
159+ f.Fuzz(func(t *testing.T, data []byte, flags int) {
160+ out := Translate(data, flags&0x1f)
161+ // Translation never grows input by more than a bounded factor.
162+ require.LessOrEqual(t, len(out), 2*len(data)+16)
163+ })
164+}
165+
166+func BenchmarkTranslateLegacyText(b *testing.B) {
167+ data := []byte("the quick brown fox jumps over the lazy dog\r")
168+ b.ReportAllocs()
169+ for b.Loop() {
170+ _ = Translate(data, 0)
171+ }
172+}
M internal/usecase/keys/router.go +109 -106
@@ -3,14 +3,15 @@
33 package keys
44
55 import (
6- "bytes"
76 "slices"
87 "sync"
98 "sync/atomic"
109 "time"
10+ "unicode"
1111 "unicode/utf8"
1212
1313 "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/usecase/keys/kittykey"
1415 )
1516
1617 const (
@@ -45,6 +46,19 @@ const (
4546 ActionEqualizePanes
4647 ActionConsumeOrExpelPaneLeft
4748 ActionConsumeOrExpelPaneRight
49+ // ActionSwitchRecent1..9 switch to the Nth previous session of the
50+ // client's recent history. They are fixed to Ctrl+1..9, which only a
51+ // terminal speaking the kitty keyboard protocol can send, and are not
52+ // rebindable.
53+ ActionSwitchRecent1
54+ ActionSwitchRecent2
55+ ActionSwitchRecent3
56+ ActionSwitchRecent4
57+ ActionSwitchRecent5
58+ ActionSwitchRecent6
59+ ActionSwitchRecent7
60+ ActionSwitchRecent8
61+ ActionSwitchRecent9
4862 )
4963
5064 // Handler receives router outputs. Forward is called only for bytes that should
@@ -63,6 +77,10 @@ type Router struct {
6377 delay time.Duration
6478 h Handler
6579 bindings *atomic.Pointer[Bindings]
80+ // kitty enables kitty keyboard protocol decoding. Only an attachment
81+ // whose client enabled the protocol on its outer terminal sets it, so
82+ // CSI u from any other source passes through untouched.
83+ kitty atomic.Bool
6684
6785 mu sync.Mutex
6886 pending bool
@@ -129,79 +147,53 @@ func (r *Router) route(data []byte, h Handler) {
129147 i++
130148 continue
131149 }
132- if i == len(data)-1 {
150+ tok := r.scan(data[i:])
151+ if tok.kind == escIncomplete {
133152 flush()
134- r.retainESC(h)
153+ r.retainESC(h, tok.raw[1:])
135154 return
136155 }
137- remaining := data[i+1:]
138- if bytes.HasPrefix(data[i:], ports.BracketedPasteOpenMarker) {
139- // A bracketed paste whose closing marker is in this same frame is
140- // forwarded verbatim: pasted content bytes (including embedded
141- // ESC+letter Alt lookalikes or ESC [ 1 ; 3 A sequences) must never
142- // fire an Action mid-paste. Without a closing marker in-frame we
143- // fall through to today's per-byte routing (the '[' passes through
144- // as a control prefix); Part A keeps pastes single-frame in
145- // practice, so no cross-frame paste state is tracked here.
146- if rel := bytes.Index(data[i:], ports.BracketedPasteCloseMarker); rel >= 0 {
147- end := i + rel + len(ports.BracketedPasteCloseMarker)
148- buf = append(buf, data[i:end]...)
149- i = end
150- continue
151- }
152- }
153- if action, size, ok, partial := r.altArrowCSI(remaining); ok {
156+ if action, ok := r.tokenAction(tok); ok {
154157 flush()
155- r.action(action, data[i:i+1+size], h)
156- i += 1 + size
158+ r.action(action, tok.raw, h)
159+ i += len(tok.raw)
157160 continue
158- } else if partial {
159- flush()
160- r.retainESC(h, remaining)
161- return
162161 }
163- next := data[i+1]
164- if passThroughPrefix(next) {
165- buf = append(buf, ESC, next)
166- i += 2
167- continue
168- }
169- if action, size, ok := r.binding(remaining); ok {
170- flush()
171- r.action(action, data[i:i+1+size], h)
172- i += 1 + size
173- continue
174- }
175- if partialUTF8Rune(remaining) {
176- flush()
177- r.retainESC(h, remaining)
178- return
162+ switch tok.kind {
163+ case escPaste, escAltArrow, escControlPrefix, escKittyKey:
164+ // Pastes are forwarded verbatim so embedded Alt lookalikes never
165+ // fire; unbound arrows and control prefixes pass through.
166+ buf = append(buf, tok.raw...)
167+ i += len(tok.raw)
168+ default:
169+ // Unbound Alt rune or bare ESC: forward the ESC alone and route
170+ // the following bytes normally.
171+ buf = append(buf, ESC)
172+ i++
179173 }
180- buf = append(buf, ESC)
181- i++
182174 }
183175 flush()
184176 }
185177
178+// routeAfterPendingESC routes data that follows a retained ESC. data excludes
179+// that ESC; pendingAltLen bytes of data were retained with it. It returns how
180+// many bytes of data it consumed.
186181 func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler) int {
187- if action, size, ok, partial := r.altArrowCSI(data); ok {
188- r.action(action, append([]byte{ESC}, data[:size]...), h)
189- return size
190- } else if partial {
182+ tok := r.scan(append([]byte{ESC}, data...))
183+ if tok.kind == escIncomplete {
191184 r.retainESC(h, data)
192185 return len(data)
193186 }
194- next := data[0]
195- if action, size, ok := r.binding(data); ok {
196- r.action(action, append([]byte{ESC}, data[:size]...), h)
197- return size
187+ if action, ok := r.tokenAction(tok); ok {
188+ r.action(action, tok.raw, h)
189+ return len(tok.raw) - 1
198190 }
199- if partialUTF8Rune(data) {
200- r.retainESC(h, data)
201- return len(data)
191+ if tok.kind == escKittyKey {
192+ // Forward the whole sequence so the handler can re-encode it.
193+ r.forward(tok.raw, h)
194+ return len(tok.raw) - 1
202195 }
203- _, size := utf8.DecodeRune(data)
204- if size > 1 {
196+ if _, size := utf8.DecodeRune(data); size > 1 {
205197 r.forward(append([]byte{ESC}, data[:size]...), h)
206198 return size
207199 }
@@ -209,7 +201,7 @@ func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler)
209201 r.forward(append([]byte{ESC}, data[:pendingAltLen]...), h)
210202 return pendingAltLen
211203 }
212- if passThroughPrefix(next) {
204+ if next := data[0]; next == '[' || next == 'O' {
213205 r.forward([]byte{ESC, next}, h)
214206 return 1
215207 }
@@ -217,6 +209,34 @@ func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler)
217209 return 0
218210 }
219211
212+// SetKittyKeyboard enables or disables kitty keyboard protocol decoding.
213+func (r *Router) SetKittyKeyboard(enabled bool) { r.kitty.Store(enabled) }
214+
215+// KittyKeyboard reports whether kitty keyboard protocol decoding is enabled.
216+// A nil Router reports false.
217+func (r *Router) KittyKeyboard() bool { return r != nil && r.kitty.Load() }
218+
219+// scan lexes one ESC-introduced unit, ignoring kitty key events unless the
220+// attachment enabled the protocol.
221+func (r *Router) scan(data []byte) escToken {
222+ return scanEscape(data, r.kitty.Load())
223+}
224+
225+// tokenAction resolves a lexed unit against the current bindings.
226+func (r *Router) tokenAction(tok escToken) (Action, bool) {
227+ bindings := r.currentBindings()
228+ switch tok.kind {
229+ case escAltArrow:
230+ return bindings.actionForAltArrow(tok.arrow)
231+ case escAltRune:
232+ return bindings.actionForAltRune(tok.rune)
233+ case escKittyKey:
234+ return kittyKeyAction(bindings, tok.key)
235+ default:
236+ return 0, false
237+ }
238+}
239+
220240 func (r *Router) retainESC(h Handler, altBytes ...[]byte) {
221241 r.mu.Lock()
222242 r.pending = true
@@ -267,14 +287,6 @@ func (r *Router) stopTimer() {
267287 }
268288 }
269289
270-func partialUTF8Rune(data []byte) bool {
271- if len(data) == 0 || utf8.FullRune(data) {
272- return false
273- }
274- key, size := utf8.DecodeRune(data)
275- return key == utf8.RuneError && size == 1
276-}
277-
278290 func (r *Router) forward(data []byte, h Handler) {
279291 cp := append([]byte(nil), data...)
280292 h.Forward(cp)
@@ -285,8 +297,6 @@ func (r *Router) action(action Action, raw []byte, h Handler) {
285297 h.Action(action, cp)
286298 }
287299
288-func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
289-
290300 func (r *Router) currentBindings() *Bindings {
291301 if r.bindings == nil {
292302 return defaultBindings
@@ -298,44 +308,6 @@ func (r *Router) currentBindings() *Bindings {
298308 return bindings
299309 }
300310
301-func (r *Router) altArrowCSI(data []byte) (Action, int, bool, bool) {
302- const seqLen = len("[1;3A")
303- if len(data) < seqLen {
304- return 0, 0, false, hasAltArrowCSIPrefix(data)
305- }
306- seq := data[:seqLen]
307- if seq[0] != '[' || seq[1] != '1' || seq[2] != ';' || (seq[3] != '3' && seq[3] != '9') {
308- return 0, 0, false, false
309- }
310- if action, ok := r.currentBindings().actionForAltArrow(seq[4]); ok {
311- return action, seqLen, true, false
312- }
313- return 0, 0, false, false
314-}
315-
316-func hasAltArrowCSIPrefix(data []byte) bool {
317- if len(data) == 0 || len(data) >= len("[1;3A") {
318- return false
319- }
320- return matchesPrefix(data, "[1;3") || matchesPrefix(data, "[1;9")
321-}
322-
323-func matchesPrefix(data []byte, want string) bool {
324- if len(data) > len(want) {
325- return false
326- }
327- for i := range data {
328- if data[i] != want[i] {
329- return false
330- }
331- }
332- return true
333-}
334-
335-func (r *Router) binding(data []byte) (Action, int, bool) {
336- return r.currentBindings().actionForAltBytes(data)
337-}
338-
339311 var topRowDigitAliases = [][]rune{
340312 {'1', '&'},
341313 {'2', 'é'},
@@ -359,3 +331,34 @@ func topRowDigitIndex(key rune) (int, bool) {
359331 }
360332 return 0, false
361333 }
334+
335+// kittyKeyAction resolves a kitty keyboard protocol key event. Alt+key uses
336+// the configured Alt bindings; Ctrl+1..9 is the fixed recent-session switch.
337+// The PC-101 base key lets layouts such as AZERTY match digit bindings.
338+func kittyKeyAction(bindings *Bindings, ev kittykey.Event) (Action, bool) {
339+ if ev.Release {
340+ return 0, false
341+ }
342+ switch ev.Mods {
343+ case kittykey.ModAlt:
344+ if action, ok := bindings.actionForAltRune(ev.Code); ok {
345+ return action, true
346+ }
347+ if ev.Base != 0 {
348+ return bindings.actionForAltRune(ev.Base)
349+ }
350+ case kittykey.ModAlt | kittykey.ModShift:
351+ // Legacy terminals send Alt+Shift+key as ESC plus the shifted rune,
352+ // which is what alt+<shifted char> bindings match.
353+ shifted := ev.Shifted
354+ if shifted == 0 {
355+ shifted = unicode.ToUpper(ev.Code)
356+ }
357+ return bindings.actionForAltRune(shifted)
358+ case kittykey.ModCtrl:
359+ if digit, ok := ev.Digit(); ok {
360+ return ActionSwitchRecent1 + Action(digit-1), true
361+ }
362+ }
363+ return 0, false
364+}
@@ -3,14 +3,15 @@
3 package keys3 package keys
4 4
5 import (5 import (
6- "bytes"
7 "slices"6 "slices"
8 "sync"7 "sync"
9 "sync/atomic"8 "sync/atomic"
10 "time"9 "time"
10+ "unicode"
11 "unicode/utf8"11 "unicode/utf8"
12 12
13 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/usecase/keys/kittykey"
14 )15 )
15 16
16 const (17 const (
@@ -45,6 +46,19 @@ const (
45 ActionEqualizePanes46 ActionEqualizePanes
46 ActionConsumeOrExpelPaneLeft47 ActionConsumeOrExpelPaneLeft
47 ActionConsumeOrExpelPaneRight48 ActionConsumeOrExpelPaneRight
49+ // ActionSwitchRecent1..9 switch to the Nth previous session of the
50+ // client's recent history. They are fixed to Ctrl+1..9, which only a
51+ // terminal speaking the kitty keyboard protocol can send, and are not
52+ // rebindable.
53+ ActionSwitchRecent1
54+ ActionSwitchRecent2
55+ ActionSwitchRecent3
56+ ActionSwitchRecent4
57+ ActionSwitchRecent5
58+ ActionSwitchRecent6
59+ ActionSwitchRecent7
60+ ActionSwitchRecent8
61+ ActionSwitchRecent9
48 )62 )
49 63
50 // Handler receives router outputs. Forward is called only for bytes that should64 // Handler receives router outputs. Forward is called only for bytes that should
@@ -63,6 +77,10 @@ type Router struct {
63 delay time.Duration77 delay time.Duration
64 h Handler78 h Handler
65 bindings *atomic.Pointer[Bindings]79 bindings *atomic.Pointer[Bindings]
80+ // kitty enables kitty keyboard protocol decoding. Only an attachment
81+ // whose client enabled the protocol on its outer terminal sets it, so
82+ // CSI u from any other source passes through untouched.
83+ kitty atomic.Bool
66 84
67 mu sync.Mutex85 mu sync.Mutex
68 pending bool86 pending bool
@@ -129,79 +147,53 @@ func (r *Router) route(data []byte, h Handler) {
129 i++147 i++
130 continue148 continue
131 }149 }
132- if i == len(data)-1 {150+ tok := r.scan(data[i:])
151+ if tok.kind == escIncomplete {
133 flush()152 flush()
134- r.retainESC(h)153+ r.retainESC(h, tok.raw[1:])
135 return154 return
136 }155 }
137- remaining := data[i+1:]156+ if action, ok := r.tokenAction(tok); ok {
138- if bytes.HasPrefix(data[i:], ports.BracketedPasteOpenMarker) {
139- // A bracketed paste whose closing marker is in this same frame is
140- // forwarded verbatim: pasted content bytes (including embedded
141- // ESC+letter Alt lookalikes or ESC [ 1 ; 3 A sequences) must never
142- // fire an Action mid-paste. Without a closing marker in-frame we
143- // fall through to today's per-byte routing (the '[' passes through
144- // as a control prefix); Part A keeps pastes single-frame in
145- // practice, so no cross-frame paste state is tracked here.
146- if rel := bytes.Index(data[i:], ports.BracketedPasteCloseMarker); rel >= 0 {
147- end := i + rel + len(ports.BracketedPasteCloseMarker)
148- buf = append(buf, data[i:end]...)
149- i = end
150- continue
151- }
152- }
153- if action, size, ok, partial := r.altArrowCSI(remaining); ok {
154 flush()157 flush()
155- r.action(action, data[i:i+1+size], h)158+ r.action(action, tok.raw, h)
156- i += 1 + size159+ i += len(tok.raw)
157 continue160 continue
158- } else if partial {
159- flush()
160- r.retainESC(h, remaining)
161- return
162 }161 }
163- next := data[i+1]162+ switch tok.kind {
164- if passThroughPrefix(next) {163+ case escPaste, escAltArrow, escControlPrefix, escKittyKey:
165- buf = append(buf, ESC, next)164+ // Pastes are forwarded verbatim so embedded Alt lookalikes never
166- i += 2165+ // fire; unbound arrows and control prefixes pass through.
167- continue166+ buf = append(buf, tok.raw...)
168- }167+ i += len(tok.raw)
169- if action, size, ok := r.binding(remaining); ok {168+ default:
170- flush()169+ // Unbound Alt rune or bare ESC: forward the ESC alone and route
171- r.action(action, data[i:i+1+size], h)170+ // the following bytes normally.
172- i += 1 + size171+ buf = append(buf, ESC)
173- continue172+ i++
174- }
175- if partialUTF8Rune(remaining) {
176- flush()
177- r.retainESC(h, remaining)
178- return
179 }173 }
180- buf = append(buf, ESC)
181- i++
182 }174 }
183 flush()175 flush()
184 }176 }
185 177
178+// routeAfterPendingESC routes data that follows a retained ESC. data excludes
179+// that ESC; pendingAltLen bytes of data were retained with it. It returns how
180+// many bytes of data it consumed.
186 func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler) int {181 func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler) int {
187- if action, size, ok, partial := r.altArrowCSI(data); ok {182+ tok := r.scan(append([]byte{ESC}, data...))
188- r.action(action, append([]byte{ESC}, data[:size]...), h)183+ if tok.kind == escIncomplete {
189- return size
190- } else if partial {
191 r.retainESC(h, data)184 r.retainESC(h, data)
192 return len(data)185 return len(data)
193 }186 }
194- next := data[0]187+ if action, ok := r.tokenAction(tok); ok {
195- if action, size, ok := r.binding(data); ok {188+ r.action(action, tok.raw, h)
196- r.action(action, append([]byte{ESC}, data[:size]...), h)189+ return len(tok.raw) - 1
197- return size
198 }190 }
199- if partialUTF8Rune(data) {191+ if tok.kind == escKittyKey {
200- r.retainESC(h, data)192+ // Forward the whole sequence so the handler can re-encode it.
201- return len(data)193+ r.forward(tok.raw, h)
194+ return len(tok.raw) - 1
202 }195 }
203- _, size := utf8.DecodeRune(data)196+ if _, size := utf8.DecodeRune(data); size > 1 {
204- if size > 1 {
205 r.forward(append([]byte{ESC}, data[:size]...), h)197 r.forward(append([]byte{ESC}, data[:size]...), h)
206 return size198 return size
207 }199 }
@@ -209,7 +201,7 @@ func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler)
209 r.forward(append([]byte{ESC}, data[:pendingAltLen]...), h)201 r.forward(append([]byte{ESC}, data[:pendingAltLen]...), h)
210 return pendingAltLen202 return pendingAltLen
211 }203 }
212- if passThroughPrefix(next) {204+ if next := data[0]; next == '[' || next == 'O' {
213 r.forward([]byte{ESC, next}, h)205 r.forward([]byte{ESC, next}, h)
214 return 1206 return 1
215 }207 }
@@ -217,6 +209,34 @@ func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler)
217 return 0209 return 0
218 }210 }
219 211
212+// SetKittyKeyboard enables or disables kitty keyboard protocol decoding.
213+func (r *Router) SetKittyKeyboard(enabled bool) { r.kitty.Store(enabled) }
214+
215+// KittyKeyboard reports whether kitty keyboard protocol decoding is enabled.
216+// A nil Router reports false.
217+func (r *Router) KittyKeyboard() bool { return r != nil && r.kitty.Load() }
218+
219+// scan lexes one ESC-introduced unit, ignoring kitty key events unless the
220+// attachment enabled the protocol.
221+func (r *Router) scan(data []byte) escToken {
222+ return scanEscape(data, r.kitty.Load())
223+}
224+
225+// tokenAction resolves a lexed unit against the current bindings.
226+func (r *Router) tokenAction(tok escToken) (Action, bool) {
227+ bindings := r.currentBindings()
228+ switch tok.kind {
229+ case escAltArrow:
230+ return bindings.actionForAltArrow(tok.arrow)
231+ case escAltRune:
232+ return bindings.actionForAltRune(tok.rune)
233+ case escKittyKey:
234+ return kittyKeyAction(bindings, tok.key)
235+ default:
236+ return 0, false
237+ }
238+}
239+
220 func (r *Router) retainESC(h Handler, altBytes ...[]byte) {240 func (r *Router) retainESC(h Handler, altBytes ...[]byte) {
221 r.mu.Lock()241 r.mu.Lock()
222 r.pending = true242 r.pending = true
@@ -267,14 +287,6 @@ func (r *Router) stopTimer() {
267 }287 }
268 }288 }
269 289
270-func partialUTF8Rune(data []byte) bool {
271- if len(data) == 0 || utf8.FullRune(data) {
272- return false
273- }
274- key, size := utf8.DecodeRune(data)
275- return key == utf8.RuneError && size == 1
276-}
277-
278 func (r *Router) forward(data []byte, h Handler) {290 func (r *Router) forward(data []byte, h Handler) {
279 cp := append([]byte(nil), data...)291 cp := append([]byte(nil), data...)
280 h.Forward(cp)292 h.Forward(cp)
@@ -285,8 +297,6 @@ func (r *Router) action(action Action, raw []byte, h Handler) {
285 h.Action(action, cp)297 h.Action(action, cp)
286 }298 }
287 299
288-func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
289-
290 func (r *Router) currentBindings() *Bindings {300 func (r *Router) currentBindings() *Bindings {
291 if r.bindings == nil {301 if r.bindings == nil {
292 return defaultBindings302 return defaultBindings
@@ -298,44 +308,6 @@ func (r *Router) currentBindings() *Bindings {
298 return bindings308 return bindings
299 }309 }
300 310
301-func (r *Router) altArrowCSI(data []byte) (Action, int, bool, bool) {
302- const seqLen = len("[1;3A")
303- if len(data) < seqLen {
304- return 0, 0, false, hasAltArrowCSIPrefix(data)
305- }
306- seq := data[:seqLen]
307- if seq[0] != '[' || seq[1] != '1' || seq[2] != ';' || (seq[3] != '3' && seq[3] != '9') {
308- return 0, 0, false, false
309- }
310- if action, ok := r.currentBindings().actionForAltArrow(seq[4]); ok {
311- return action, seqLen, true, false
312- }
313- return 0, 0, false, false
314-}
315-
316-func hasAltArrowCSIPrefix(data []byte) bool {
317- if len(data) == 0 || len(data) >= len("[1;3A") {
318- return false
319- }
320- return matchesPrefix(data, "[1;3") || matchesPrefix(data, "[1;9")
321-}
322-
323-func matchesPrefix(data []byte, want string) bool {
324- if len(data) > len(want) {
325- return false
326- }
327- for i := range data {
328- if data[i] != want[i] {
329- return false
330- }
331- }
332- return true
333-}
334-
335-func (r *Router) binding(data []byte) (Action, int, bool) {
336- return r.currentBindings().actionForAltBytes(data)
337-}
338-
339 var topRowDigitAliases = [][]rune{311 var topRowDigitAliases = [][]rune{
340 {'1', '&'},312 {'1', '&'},
341 {'2', 'é'},313 {'2', 'é'},
@@ -359,3 +331,34 @@ func topRowDigitIndex(key rune) (int, bool) {
359 }331 }
360 return 0, false332 return 0, false
361 }333 }
334+
335+// kittyKeyAction resolves a kitty keyboard protocol key event. Alt+key uses
336+// the configured Alt bindings; Ctrl+1..9 is the fixed recent-session switch.
337+// The PC-101 base key lets layouts such as AZERTY match digit bindings.
338+func kittyKeyAction(bindings *Bindings, ev kittykey.Event) (Action, bool) {
339+ if ev.Release {
340+ return 0, false
341+ }
342+ switch ev.Mods {
343+ case kittykey.ModAlt:
344+ if action, ok := bindings.actionForAltRune(ev.Code); ok {
345+ return action, true
346+ }
347+ if ev.Base != 0 {
348+ return bindings.actionForAltRune(ev.Base)
349+ }
350+ case kittykey.ModAlt | kittykey.ModShift:
351+ // Legacy terminals send Alt+Shift+key as ESC plus the shifted rune,
352+ // which is what alt+<shifted char> bindings match.
353+ shifted := ev.Shifted
354+ if shifted == 0 {
355+ shifted = unicode.ToUpper(ev.Code)
356+ }
357+ return bindings.actionForAltRune(shifted)
358+ case kittykey.ModCtrl:
359+ if digit, ok := ev.Digit(); ok {
360+ return ActionSwitchRecent1 + Action(digit-1), true
361+ }
362+ }
363+ return 0, false
364+}
M internal/usecase/keys/router_test.go +81 -0
@@ -623,3 +623,84 @@ func TestRouterPasteMarkerSplitAcrossFramesKeepsCurrentBehavior(t *testing.T) {
623623 require.Empty(t, h.actions)
624624 require.Equal(t, [][]byte{[]byte("\x1b[200~hello"), []byte("\x1b[201~")}, h.forwards)
625625 }
626+
627+func TestRouterKittyKeyboardEvents(t *testing.T) {
628+ tests := []struct {
629+ name string
630+ reads []string
631+ wantActions []Action
632+ wantForwards []string
633+ }{
634+ {name: "ctrl 1", reads: []string{"\x1b[49;5u"}, wantActions: []Action{ActionSwitchRecent1}},
635+ {name: "ctrl 9", reads: []string{"\x1b[57;5u"}, wantActions: []Action{ActionSwitchRecent9}},
636+ {name: "azerty ctrl 2 base key", reads: []string{"\x1b[233::50;5u"}, wantActions: []Action{ActionSwitchRecent2}},
637+ {name: "ctrl 0 forwarded", reads: []string{"\x1b[48;5u"}, wantForwards: []string{"\x1b[48;5u"}},
638+ {name: "ctrl shift 1 forwarded", reads: []string{"\x1b[49;6u"}, wantForwards: []string{"\x1b[49;6u"}},
639+ {name: "ctrl 1 release ignored", reads: []string{"\x1b[49;5:3u"}, wantForwards: []string{"\x1b[49;5:3u"}},
640+ {name: "alt j binding", reads: []string{"\x1b[106;3u"}, wantActions: []Action{ActionFocusPaneDown}},
641+ {name: "alt space binding", reads: []string{"\x1b[32;3u"}, wantActions: []Action{ActionOpenPalette}},
642+ {name: "alt azerty 1 via symbol", reads: []string{"\x1b[38::49;3u"}, wantActions: []Action{ActionSwitchTab1}},
643+ {name: "alt unbound forwarded", reads: []string{"\x1b[122;3u"}, wantForwards: []string{"\x1b[122;3u"}},
644+ {name: "ctrl a forwarded", reads: []string{"\x1b[97;5u"}, wantForwards: []string{"\x1b[97;5u"}},
645+ {name: "text around", reads: []string{"ab\x1b[49;5ucd"}, wantActions: []Action{ActionSwitchRecent1}, wantForwards: []string{"ab", "cd"}},
646+ {name: "split across reads", reads: []string{"\x1b[49", ";5u"}, wantActions: []Action{ActionSwitchRecent1}},
647+ {name: "split after esc", reads: []string{"\x1b", "[49;5u"}, wantActions: []Action{ActionSwitchRecent1}},
648+ {name: "unbound split after esc stays whole", reads: []string{"\x1b", "[97;5u"}, wantForwards: []string{"\x1b[97;5u"}},
649+ {name: "unbound split mid sequence stays whole", reads: []string{"\x1b[97", ";5ux"}, wantForwards: []string{"\x1b[97;5u", "x"}},
650+ {name: "alt shift h uses shifted key", reads: []string{"\x1b[104:72;4u"}, wantForwards: []string{"\x1b[104:72;4u"}},
651+ {name: "inside paste untouched", reads: []string{"\x1b[200~\x1b[49;5u\x1b[201~"}, wantForwards: []string{"\x1b[200~\x1b[49;5u\x1b[201~"}},
652+ }
653+ for _, tt := range tests {
654+ t.Run(tt.name, func(t *testing.T) {
655+ clk := &fakeClock{}
656+ h := &captureHandler{}
657+ r := NewRouter(clk, h, nil)
658+ r.SetKittyKeyboard(true)
659+ for _, read := range tt.reads {
660+ r.Route([]byte(read))
661+ }
662+ require.Equal(t, tt.wantActions, h.actions)
663+ var forwards []string
664+ for _, f := range h.forwards {
665+ forwards = append(forwards, string(f))
666+ }
667+ require.Equal(t, tt.wantForwards, forwards)
668+ assertRouterPendingCleared(t, r)
669+ })
670+ }
671+}
672+
673+func TestRouterKittyKeyboardAltShiftBindings(t *testing.T) {
674+ custom := DefaultBindings().clone()
675+ custom.bind(ActionConsumeOrExpelPaneLeft, KeySpec{altRunes: []rune{'H'}})
676+ custom.bind(ActionEqualizePanes, KeySpec{altRunes: []rune{'?'}})
677+ var bindings atomic.Pointer[Bindings]
678+ bindings.Store(custom)
679+ tests := []struct {
680+ name string
681+ in string
682+ want Action
683+ }{
684+ {name: "alt shift h with shifted key", in: "\x1b[104:72;4u", want: ActionConsumeOrExpelPaneLeft},
685+ {name: "alt shift h without shifted key", in: "\x1b[104;4u", want: ActionConsumeOrExpelPaneLeft},
686+ {name: "alt shift slash", in: "\x1b[47:63;4u", want: ActionEqualizePanes},
687+ }
688+ for _, tt := range tests {
689+ t.Run(tt.name, func(t *testing.T) {
690+ h := &captureHandler{}
691+ r := NewRouter(&fakeClock{}, h, &bindings)
692+ r.SetKittyKeyboard(true)
693+ r.Route([]byte(tt.in))
694+ require.Equal(t, []Action{tt.want}, h.actions)
695+ require.Empty(t, h.forwards)
696+ })
697+ }
698+}
699+
700+func TestRouterIgnoresKittyKeysUnlessEnabled(t *testing.T) {
701+ h := &captureHandler{}
702+ r := NewRouter(&fakeClock{}, h, nil)
703+ r.Route([]byte("\x1b[49;5u"))
704+ require.Empty(t, h.actions)
705+ require.Equal(t, [][]byte{[]byte("\x1b[49;5u")}, h.forwards)
706+}
@@ -623,3 +623,84 @@ func TestRouterPasteMarkerSplitAcrossFramesKeepsCurrentBehavior(t *testing.T) {
623 require.Empty(t, h.actions)623 require.Empty(t, h.actions)
624 require.Equal(t, [][]byte{[]byte("\x1b[200~hello"), []byte("\x1b[201~")}, h.forwards)624 require.Equal(t, [][]byte{[]byte("\x1b[200~hello"), []byte("\x1b[201~")}, h.forwards)
625 }625 }
626+
627+func TestRouterKittyKeyboardEvents(t *testing.T) {
628+ tests := []struct {
629+ name string
630+ reads []string
631+ wantActions []Action
632+ wantForwards []string
633+ }{
634+ {name: "ctrl 1", reads: []string{"\x1b[49;5u"}, wantActions: []Action{ActionSwitchRecent1}},
635+ {name: "ctrl 9", reads: []string{"\x1b[57;5u"}, wantActions: []Action{ActionSwitchRecent9}},
636+ {name: "azerty ctrl 2 base key", reads: []string{"\x1b[233::50;5u"}, wantActions: []Action{ActionSwitchRecent2}},
637+ {name: "ctrl 0 forwarded", reads: []string{"\x1b[48;5u"}, wantForwards: []string{"\x1b[48;5u"}},
638+ {name: "ctrl shift 1 forwarded", reads: []string{"\x1b[49;6u"}, wantForwards: []string{"\x1b[49;6u"}},
639+ {name: "ctrl 1 release ignored", reads: []string{"\x1b[49;5:3u"}, wantForwards: []string{"\x1b[49;5:3u"}},
640+ {name: "alt j binding", reads: []string{"\x1b[106;3u"}, wantActions: []Action{ActionFocusPaneDown}},
641+ {name: "alt space binding", reads: []string{"\x1b[32;3u"}, wantActions: []Action{ActionOpenPalette}},
642+ {name: "alt azerty 1 via symbol", reads: []string{"\x1b[38::49;3u"}, wantActions: []Action{ActionSwitchTab1}},
643+ {name: "alt unbound forwarded", reads: []string{"\x1b[122;3u"}, wantForwards: []string{"\x1b[122;3u"}},
644+ {name: "ctrl a forwarded", reads: []string{"\x1b[97;5u"}, wantForwards: []string{"\x1b[97;5u"}},
645+ {name: "text around", reads: []string{"ab\x1b[49;5ucd"}, wantActions: []Action{ActionSwitchRecent1}, wantForwards: []string{"ab", "cd"}},
646+ {name: "split across reads", reads: []string{"\x1b[49", ";5u"}, wantActions: []Action{ActionSwitchRecent1}},
647+ {name: "split after esc", reads: []string{"\x1b", "[49;5u"}, wantActions: []Action{ActionSwitchRecent1}},
648+ {name: "unbound split after esc stays whole", reads: []string{"\x1b", "[97;5u"}, wantForwards: []string{"\x1b[97;5u"}},
649+ {name: "unbound split mid sequence stays whole", reads: []string{"\x1b[97", ";5ux"}, wantForwards: []string{"\x1b[97;5u", "x"}},
650+ {name: "alt shift h uses shifted key", reads: []string{"\x1b[104:72;4u"}, wantForwards: []string{"\x1b[104:72;4u"}},
651+ {name: "inside paste untouched", reads: []string{"\x1b[200~\x1b[49;5u\x1b[201~"}, wantForwards: []string{"\x1b[200~\x1b[49;5u\x1b[201~"}},
652+ }
653+ for _, tt := range tests {
654+ t.Run(tt.name, func(t *testing.T) {
655+ clk := &fakeClock{}
656+ h := &captureHandler{}
657+ r := NewRouter(clk, h, nil)
658+ r.SetKittyKeyboard(true)
659+ for _, read := range tt.reads {
660+ r.Route([]byte(read))
661+ }
662+ require.Equal(t, tt.wantActions, h.actions)
663+ var forwards []string
664+ for _, f := range h.forwards {
665+ forwards = append(forwards, string(f))
666+ }
667+ require.Equal(t, tt.wantForwards, forwards)
668+ assertRouterPendingCleared(t, r)
669+ })
670+ }
671+}
672+
673+func TestRouterKittyKeyboardAltShiftBindings(t *testing.T) {
674+ custom := DefaultBindings().clone()
675+ custom.bind(ActionConsumeOrExpelPaneLeft, KeySpec{altRunes: []rune{'H'}})
676+ custom.bind(ActionEqualizePanes, KeySpec{altRunes: []rune{'?'}})
677+ var bindings atomic.Pointer[Bindings]
678+ bindings.Store(custom)
679+ tests := []struct {
680+ name string
681+ in string
682+ want Action
683+ }{
684+ {name: "alt shift h with shifted key", in: "\x1b[104:72;4u", want: ActionConsumeOrExpelPaneLeft},
685+ {name: "alt shift h without shifted key", in: "\x1b[104;4u", want: ActionConsumeOrExpelPaneLeft},
686+ {name: "alt shift slash", in: "\x1b[47:63;4u", want: ActionEqualizePanes},
687+ }
688+ for _, tt := range tests {
689+ t.Run(tt.name, func(t *testing.T) {
690+ h := &captureHandler{}
691+ r := NewRouter(&fakeClock{}, h, &bindings)
692+ r.SetKittyKeyboard(true)
693+ r.Route([]byte(tt.in))
694+ require.Equal(t, []Action{tt.want}, h.actions)
695+ require.Empty(t, h.forwards)
696+ })
697+ }
698+}
699+
700+func TestRouterIgnoresKittyKeysUnlessEnabled(t *testing.T) {
701+ h := &captureHandler{}
702+ r := NewRouter(&fakeClock{}, h, nil)
703+ r.Route([]byte("\x1b[49;5u"))
704+ require.Empty(t, h.actions)
705+ require.Equal(t, [][]byte{[]byte("\x1b[49;5u")}, h.forwards)
706+}
A internal/usecase/keys/tokenizer.go +121 -0
@@ -0,0 +1,121 @@
1+package keys
2+
3+import (
4+ "bytes"
5+ "unicode/utf8"
6+
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/usecase/keys/kittykey"
9+)
10+
11+// escKind classifies the input introduced by one ESC byte.
12+type escKind uint8
13+
14+const (
15+ // escIncomplete means more bytes are needed before the ESC can be
16+ // classified: a lone trailing ESC, a partial Alt-arrow CSI, or a partial
17+ // UTF-8 rune after ESC.
18+ escIncomplete escKind = iota
19+ // escPaste is a complete bracketed paste, open marker through close marker.
20+ escPaste
21+ // escAltArrow is a complete ESC [ 1 ; 3|9 <A-D> sequence.
22+ escAltArrow
23+ // escControlPrefix is ESC [ or ESC O, the start of a terminal control
24+ // sequence that passes through untouched.
25+ escControlPrefix
26+ // escAltRune is ESC followed by one complete, valid UTF-8 rune.
27+ escAltRune
28+ // escKittyKey is a complete kitty keyboard protocol CSI u key event.
29+ escKittyKey
30+ // escBare is an ESC that starts none of the above.
31+ escBare
32+)
33+
34+// escToken is one lexed ESC-introduced unit. raw always starts with ESC and
35+// aliases the scanned input.
36+type escToken struct {
37+ kind escKind
38+ raw []byte
39+ rune rune // escAltRune and escControlPrefix: the rune after ESC
40+ arrow byte // escAltArrow: the final byte, one of 'A'..'D'
41+ key kittykey.Event
42+}
43+
44+const altArrowLen = len("\x1b[1;3A")
45+
46+// scanEscape lexes the unit starting at data[0], which must be ESC. It only
47+// classifies bytes; binding lookups and forwarding decisions belong to Router.
48+func scanEscape(data []byte, kitty bool) escToken {
49+ if len(data) == 1 {
50+ return escToken{kind: escIncomplete, raw: data}
51+ }
52+ if bytes.HasPrefix(data, ports.BracketedPasteOpenMarker) {
53+ if rel := bytes.Index(data, ports.BracketedPasteCloseMarker); rel >= 0 {
54+ return escToken{kind: escPaste, raw: data[:rel+len(ports.BracketedPasteCloseMarker)]}
55+ }
56+ }
57+ if final, ok := altArrowFinal(data); ok {
58+ return escToken{kind: escAltArrow, raw: data[:altArrowLen], arrow: final}
59+ }
60+ if isAltArrowPrefix(data) {
61+ return escToken{kind: escIncomplete, raw: data}
62+ }
63+ if kitty {
64+ if ev, n, ok, partial := kittykey.Parse(data); ok {
65+ return escToken{kind: escKittyKey, raw: data[:n], key: ev}
66+ } else if partial {
67+ return escToken{kind: escIncomplete, raw: data}
68+ }
69+ }
70+ next := data[1]
71+ if next == '[' || next == 'O' {
72+ return escToken{kind: escControlPrefix, raw: data[:2], rune: rune(next)}
73+ }
74+ rest := data[1:]
75+ if partialUTF8Rune(rest) {
76+ return escToken{kind: escIncomplete, raw: data}
77+ }
78+ if key, size := utf8.DecodeRune(rest); key != utf8.RuneError || size > 1 {
79+ return escToken{kind: escAltRune, raw: data[:1+size], rune: key}
80+ }
81+ return escToken{kind: escBare, raw: data[:1]}
82+}
83+
84+// altArrowFinal reports the arrow final byte of a complete Alt (;3) or Meta
85+// (;9) modified arrow CSI at the start of data.
86+func altArrowFinal(data []byte) (byte, bool) {
87+ if len(data) < altArrowLen {
88+ return 0, false
89+ }
90+ seq := data[:altArrowLen]
91+ if seq[1] != '[' || seq[2] != '1' || seq[3] != ';' || (seq[4] != '3' && seq[4] != '9') {
92+ return 0, false
93+ }
94+ switch final := seq[5]; final {
95+ case 'A', 'B', 'C', 'D':
96+ return final, true
97+ default:
98+ return 0, false
99+ }
100+}
101+
102+// isAltArrowPrefix reports whether data is a strict prefix of an Alt/Meta
103+// arrow CSI, meaning the next read may still complete it.
104+func isAltArrowPrefix(data []byte) bool {
105+ if len(data) < 2 || len(data) >= altArrowLen {
106+ return false
107+ }
108+ return matchesPrefix(data, "\x1b[1;3") || matchesPrefix(data, "\x1b[1;9")
109+}
110+
111+func matchesPrefix(data []byte, want string) bool {
112+ return len(data) <= len(want) && string(data) == want[:len(data)]
113+}
114+
115+func partialUTF8Rune(data []byte) bool {
116+ if len(data) == 0 || utf8.FullRune(data) {
117+ return false
118+ }
119+ key, size := utf8.DecodeRune(data)
120+ return key == utf8.RuneError && size == 1
121+}
@@ -0,0 +1,121 @@
1+package keys
2+
3+import (
4+ "bytes"
5+ "unicode/utf8"
6+
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/usecase/keys/kittykey"
9+)
10+
11+// escKind classifies the input introduced by one ESC byte.
12+type escKind uint8
13+
14+const (
15+ // escIncomplete means more bytes are needed before the ESC can be
16+ // classified: a lone trailing ESC, a partial Alt-arrow CSI, or a partial
17+ // UTF-8 rune after ESC.
18+ escIncomplete escKind = iota
19+ // escPaste is a complete bracketed paste, open marker through close marker.
20+ escPaste
21+ // escAltArrow is a complete ESC [ 1 ; 3|9 <A-D> sequence.
22+ escAltArrow
23+ // escControlPrefix is ESC [ or ESC O, the start of a terminal control
24+ // sequence that passes through untouched.
25+ escControlPrefix
26+ // escAltRune is ESC followed by one complete, valid UTF-8 rune.
27+ escAltRune
28+ // escKittyKey is a complete kitty keyboard protocol CSI u key event.
29+ escKittyKey
30+ // escBare is an ESC that starts none of the above.
31+ escBare
32+)
33+
34+// escToken is one lexed ESC-introduced unit. raw always starts with ESC and
35+// aliases the scanned input.
36+type escToken struct {
37+ kind escKind
38+ raw []byte
39+ rune rune // escAltRune and escControlPrefix: the rune after ESC
40+ arrow byte // escAltArrow: the final byte, one of 'A'..'D'
41+ key kittykey.Event
42+}
43+
44+const altArrowLen = len("\x1b[1;3A")
45+
46+// scanEscape lexes the unit starting at data[0], which must be ESC. It only
47+// classifies bytes; binding lookups and forwarding decisions belong to Router.
48+func scanEscape(data []byte, kitty bool) escToken {
49+ if len(data) == 1 {
50+ return escToken{kind: escIncomplete, raw: data}
51+ }
52+ if bytes.HasPrefix(data, ports.BracketedPasteOpenMarker) {
53+ if rel := bytes.Index(data, ports.BracketedPasteCloseMarker); rel >= 0 {
54+ return escToken{kind: escPaste, raw: data[:rel+len(ports.BracketedPasteCloseMarker)]}
55+ }
56+ }
57+ if final, ok := altArrowFinal(data); ok {
58+ return escToken{kind: escAltArrow, raw: data[:altArrowLen], arrow: final}
59+ }
60+ if isAltArrowPrefix(data) {
61+ return escToken{kind: escIncomplete, raw: data}
62+ }
63+ if kitty {
64+ if ev, n, ok, partial := kittykey.Parse(data); ok {
65+ return escToken{kind: escKittyKey, raw: data[:n], key: ev}
66+ } else if partial {
67+ return escToken{kind: escIncomplete, raw: data}
68+ }
69+ }
70+ next := data[1]
71+ if next == '[' || next == 'O' {
72+ return escToken{kind: escControlPrefix, raw: data[:2], rune: rune(next)}
73+ }
74+ rest := data[1:]
75+ if partialUTF8Rune(rest) {
76+ return escToken{kind: escIncomplete, raw: data}
77+ }
78+ if key, size := utf8.DecodeRune(rest); key != utf8.RuneError || size > 1 {
79+ return escToken{kind: escAltRune, raw: data[:1+size], rune: key}
80+ }
81+ return escToken{kind: escBare, raw: data[:1]}
82+}
83+
84+// altArrowFinal reports the arrow final byte of a complete Alt (;3) or Meta
85+// (;9) modified arrow CSI at the start of data.
86+func altArrowFinal(data []byte) (byte, bool) {
87+ if len(data) < altArrowLen {
88+ return 0, false
89+ }
90+ seq := data[:altArrowLen]
91+ if seq[1] != '[' || seq[2] != '1' || seq[3] != ';' || (seq[4] != '3' && seq[4] != '9') {
92+ return 0, false
93+ }
94+ switch final := seq[5]; final {
95+ case 'A', 'B', 'C', 'D':
96+ return final, true
97+ default:
98+ return 0, false
99+ }
100+}
101+
102+// isAltArrowPrefix reports whether data is a strict prefix of an Alt/Meta
103+// arrow CSI, meaning the next read may still complete it.
104+func isAltArrowPrefix(data []byte) bool {
105+ if len(data) < 2 || len(data) >= altArrowLen {
106+ return false
107+ }
108+ return matchesPrefix(data, "\x1b[1;3") || matchesPrefix(data, "\x1b[1;9")
109+}
110+
111+func matchesPrefix(data []byte, want string) bool {
112+ return len(data) <= len(want) && string(data) == want[:len(data)]
113+}
114+
115+func partialUTF8Rune(data []byte) bool {
116+ if len(data) == 0 || utf8.FullRune(data) {
117+ return false
118+ }
119+ key, size := utf8.DecodeRune(data)
120+ return key == utf8.RuneError && size == 1
121+}
A internal/usecase/keys/tokenizer_test.go +59 -0
@@ -0,0 +1,59 @@
1+package keys
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestScanEscape(t *testing.T) {
10+ paste := "\x1b[200~\x1bj\x1b[1;3A\x1b[201~"
11+ tests := []struct {
12+ name string
13+ in string
14+ kind escKind
15+ raw string
16+ rune rune
17+ arrow byte
18+ }{
19+ {name: "lone esc", in: "\x1b", kind: escIncomplete, raw: "\x1b"},
20+ {name: "alt letter", in: "\x1bj", kind: escAltRune, raw: "\x1bj", rune: 'j'},
21+ {name: "alt letter trailing bytes", in: "\x1bjxyz", kind: escAltRune, raw: "\x1bj", rune: 'j'},
22+ {name: "alt space", in: "\x1b ", kind: escAltRune, raw: "\x1b ", rune: ' '},
23+ {name: "alt utf8", in: "\x1bé", kind: escAltRune, raw: "\x1bé", rune: 'é'},
24+ {name: "alt utf8 truncated", in: "\x1b\xc3", kind: escIncomplete, raw: "\x1b\xc3"},
25+ {name: "alt invalid utf8", in: "\x1b\xc3X", kind: escBare, raw: "\x1b"},
26+ {name: "alt stray continuation", in: "\x1b\xa9", kind: escBare, raw: "\x1b"},
27+ {name: "double esc", in: "\x1b\x1b", kind: escAltRune, raw: "\x1b\x1b", rune: rune(ESC)},
28+ {name: "alt arrow", in: "\x1b[1;3A", kind: escAltArrow, raw: "\x1b[1;3A", arrow: 'A'},
29+ {name: "meta arrow trailing", in: "\x1b[1;9Dzz", kind: escAltArrow, raw: "\x1b[1;9D", arrow: 'D'},
30+ {name: "alt arrow prefix 2", in: "\x1b[", kind: escIncomplete, raw: "\x1b["},
31+ {name: "alt arrow prefix 3", in: "\x1b[1", kind: escIncomplete, raw: "\x1b[1"},
32+ {name: "alt arrow prefix 5", in: "\x1b[1;3", kind: escIncomplete, raw: "\x1b[1;3"},
33+ {name: "alt arrow wrong final", in: "\x1b[1;3E", kind: escControlPrefix, raw: "\x1b[", rune: '['},
34+ {name: "ctrl arrow", in: "\x1b[1;5A", kind: escControlPrefix, raw: "\x1b[", rune: '['},
35+ {name: "bare arrow", in: "\x1b[A", kind: escControlPrefix, raw: "\x1b[", rune: '['},
36+ {name: "ss3", in: "\x1bOP", kind: escControlPrefix, raw: "\x1bO", rune: 'O'},
37+ {name: "paste complete", in: paste + "tail", kind: escPaste, raw: paste},
38+ {name: "paste unterminated", in: "\x1b[200~hello", kind: escControlPrefix, raw: "\x1b[", rune: '['},
39+ }
40+ for _, tt := range tests {
41+ t.Run(tt.name, func(t *testing.T) {
42+ got := scanEscape([]byte(tt.in), false)
43+ require.Equal(t, tt.kind, got.kind)
44+ require.Equal(t, []byte(tt.raw), got.raw)
45+ require.Equal(t, tt.rune, got.rune)
46+ require.Equal(t, tt.arrow, got.arrow)
47+ })
48+ }
49+}
50+
51+func TestScanEscapeEveryAltArrowPrefixIsIncomplete(t *testing.T) {
52+ for _, full := range []string{"\x1b[1;3A", "\x1b[1;9C"} {
53+ for n := 1; n < len(full); n++ {
54+ got := scanEscape([]byte(full[:n]), false)
55+ require.Equal(t, escIncomplete, got.kind, "prefix %q", full[:n])
56+ require.Equal(t, []byte(full[:n]), got.raw)
57+ }
58+ }
59+}
@@ -0,0 +1,59 @@
1+package keys
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestScanEscape(t *testing.T) {
10+ paste := "\x1b[200~\x1bj\x1b[1;3A\x1b[201~"
11+ tests := []struct {
12+ name string
13+ in string
14+ kind escKind
15+ raw string
16+ rune rune
17+ arrow byte
18+ }{
19+ {name: "lone esc", in: "\x1b", kind: escIncomplete, raw: "\x1b"},
20+ {name: "alt letter", in: "\x1bj", kind: escAltRune, raw: "\x1bj", rune: 'j'},
21+ {name: "alt letter trailing bytes", in: "\x1bjxyz", kind: escAltRune, raw: "\x1bj", rune: 'j'},
22+ {name: "alt space", in: "\x1b ", kind: escAltRune, raw: "\x1b ", rune: ' '},
23+ {name: "alt utf8", in: "\x1bé", kind: escAltRune, raw: "\x1bé", rune: 'é'},
24+ {name: "alt utf8 truncated", in: "\x1b\xc3", kind: escIncomplete, raw: "\x1b\xc3"},
25+ {name: "alt invalid utf8", in: "\x1b\xc3X", kind: escBare, raw: "\x1b"},
26+ {name: "alt stray continuation", in: "\x1b\xa9", kind: escBare, raw: "\x1b"},
27+ {name: "double esc", in: "\x1b\x1b", kind: escAltRune, raw: "\x1b\x1b", rune: rune(ESC)},
28+ {name: "alt arrow", in: "\x1b[1;3A", kind: escAltArrow, raw: "\x1b[1;3A", arrow: 'A'},
29+ {name: "meta arrow trailing", in: "\x1b[1;9Dzz", kind: escAltArrow, raw: "\x1b[1;9D", arrow: 'D'},
30+ {name: "alt arrow prefix 2", in: "\x1b[", kind: escIncomplete, raw: "\x1b["},
31+ {name: "alt arrow prefix 3", in: "\x1b[1", kind: escIncomplete, raw: "\x1b[1"},
32+ {name: "alt arrow prefix 5", in: "\x1b[1;3", kind: escIncomplete, raw: "\x1b[1;3"},
33+ {name: "alt arrow wrong final", in: "\x1b[1;3E", kind: escControlPrefix, raw: "\x1b[", rune: '['},
34+ {name: "ctrl arrow", in: "\x1b[1;5A", kind: escControlPrefix, raw: "\x1b[", rune: '['},
35+ {name: "bare arrow", in: "\x1b[A", kind: escControlPrefix, raw: "\x1b[", rune: '['},
36+ {name: "ss3", in: "\x1bOP", kind: escControlPrefix, raw: "\x1bO", rune: 'O'},
37+ {name: "paste complete", in: paste + "tail", kind: escPaste, raw: paste},
38+ {name: "paste unterminated", in: "\x1b[200~hello", kind: escControlPrefix, raw: "\x1b[", rune: '['},
39+ }
40+ for _, tt := range tests {
41+ t.Run(tt.name, func(t *testing.T) {
42+ got := scanEscape([]byte(tt.in), false)
43+ require.Equal(t, tt.kind, got.kind)
44+ require.Equal(t, []byte(tt.raw), got.raw)
45+ require.Equal(t, tt.rune, got.rune)
46+ require.Equal(t, tt.arrow, got.arrow)
47+ })
48+ }
49+}
50+
51+func TestScanEscapeEveryAltArrowPrefixIsIncomplete(t *testing.T) {
52+ for _, full := range []string{"\x1b[1;3A", "\x1b[1;9C"} {
53+ for n := 1; n < len(full); n++ {
54+ got := scanEscape([]byte(full[:n]), false)
55+ require.Equal(t, escIncomplete, got.kind, "prefix %q", full[:n])
56+ require.Equal(t, []byte(full[:n]), got.raw)
57+ }
58+ }
59+}

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