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 Unverified
B5690EEEBB952194The key is unknown
53 files · +1728 -159
M README.md +1 -0
| @@ -69,6 +69,7 @@ vev --web-daemon start the browser terminal | |||
| 69 | 69 | | Alt+Space | command palette | | |
| 70 | 70 | | Alt+f | floating terminal | | |
| 71 | 71 | | Alt+1 … 9 | switch tab | | |
| 72 | + | | Ctrl+1 … 9 | switch to a recent session ([kitty keyboard terminals](docs/terminal.md#kitty-keyboard)) | | |
| 72 | 73 | | Alt+h/j/k/l or Alt+Arrow | focus pane | | |
| 73 | 74 | | Alt+a | jump to a session needing attention | | |
| 74 | 75 | ||
| @@ -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 | |||
| 40 | 40 | # Remove a numbered (ephemeral) session when its last client exits. | |
| 41 | 41 | 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 | 46 | # Rebindable actions. Leave a line out to keep its built-in binding. | |
| 44 | 47 | open-palette = alt+space | |
| 45 | 48 | 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 | 217 | - Setting an action replaces all its default keys. For example, setting `focus-pane-left` removes Alt+Left too. | |
| 215 | 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 | 220 | - `alt+[` is not supported: terminals use it for escape sequences. | |
| 217 | 221 | - Cmd/Super is not supported. Map it in your terminal to `ESC` + a character, then bind `alt+<char>` in vev. | |
| 218 | 222 | ||
| @@ -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 = on | 41 | 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+space | 47 | open-palette = alt+space | ||
| 45 | toggle-floating-pane = alt+f | 48 | 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 | |||
| 47 | 47 | ||
| 48 | 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 | 60 | ## Environment | |
| 51 | 61 | ||
| 52 | 62 | - 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 | ## Environment | 60 | ## 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 | |||
| 3 | 3 | go 1.27 | |
| 4 | 4 | ||
| 5 | 5 | 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 | |
| 7 | 7 | github.com/coder/websocket v1.8.15 | |
| 8 | 8 | github.com/quic-go/quic-go v0.62.0 | |
| 9 | 9 | github.com/stretchr/testify v1.12.1 | |
| @@ -3,7 +3,7 @@ module github.com/bnema/vev | |||||
| 3 | go 1.27 | 3 | go 1.27 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | 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 |
| 7 | github.com/coder/websocket v1.8.15 | 7 | github.com/coder/websocket v1.8.15 | ||
| 8 | github.com/quic-go/quic-go v0.62.0 | 8 | github.com/quic-go/quic-go v0.62.0 | ||
| 9 | github.com/stretchr/testify v1.12.1 | 9 | github.com/stretchr/testify v1.12.1 | ||
M go.sum +2 -0
| @@ -44,6 +44,8 @@ github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6 | |||
| 44 | 44 | github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= | |
| 45 | 45 | github.com/bnema/vev-vt v0.5.2-0.20260909134859-55a2b5aa1b3a h1:n25PbH+nlilKwrNHzR9OJjiKsAZDbDuSQzE/GDtArUY= | |
| 46 | 46 | github.com/bnema/vev-vt v0.5.2-0.20260909134859-55a2b5aa1b3a/go.mod h1:mBdVV+ALtBgPNVHPrxY50S+d1ZO/sDn0AA7EtJf00M4= | |
| 47 | + | github.com/bnema/vev-vt v0.5.2-0.20260926054010-6ae9bc5fde91 h1:ALEfartmRuNPmiJ6tmN2pI4rfeE1faEY9FrX3y5O4Ls= | |
| 48 | + | github.com/bnema/vev-vt v0.5.2-0.20260926054010-6ae9bc5fde91/go.mod h1:mBdVV+ALtBgPNVHPrxY50S+d1ZO/sDn0AA7EtJf00M4= | |
| 47 | 49 | github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= | |
| 48 | 50 | github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= | |
| 49 | 51 | github.com/bufbuild/buf v1.73.0 h1:fDNKlxaf+PYi20dmVM7Yt3kOfXEhwrgKDLMNZFjztDs= | |
| @@ -44,6 +44,8 @@ github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6 | |||||
| 44 | github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= | 44 | github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= | ||
| 45 | github.com/bnema/vev-vt v0.5.2-0.20260909134859-55a2b5aa1b3a h1:n25PbH+nlilKwrNHzR9OJjiKsAZDbDuSQzE/GDtArUY= | 45 | github.com/bnema/vev-vt v0.5.2-0.20260909134859-55a2b5aa1b3a h1:n25PbH+nlilKwrNHzR9OJjiKsAZDbDuSQzE/GDtArUY= | ||
| 46 | github.com/bnema/vev-vt v0.5.2-0.20260909134859-55a2b5aa1b3a/go.mod h1:mBdVV+ALtBgPNVHPrxY50S+d1ZO/sDn0AA7EtJf00M4= | 46 | github.com/bnema/vev-vt v0.5.2-0.20260909134859-55a2b5aa1b3a/go.mod h1:mBdVV+ALtBgPNVHPrxY50S+d1ZO/sDn0AA7EtJf00M4= | ||
| 47 | + | github.com/bnema/vev-vt v0.5.2-0.20260926054010-6ae9bc5fde91 h1:ALEfartmRuNPmiJ6tmN2pI4rfeE1faEY9FrX3y5O4Ls= | |||
| 48 | + | github.com/bnema/vev-vt v0.5.2-0.20260926054010-6ae9bc5fde91/go.mod h1:mBdVV+ALtBgPNVHPrxY50S+d1ZO/sDn0AA7EtJf00M4= | |||
| 47 | github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= | 49 | github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= | ||
| 48 | github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= | 50 | github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= | ||
| 49 | github.com/bufbuild/buf v1.73.0 h1:fDNKlxaf+PYi20dmVM7Yt3kOfXEhwrgKDLMNZFjztDs= | 51 | github.com/bufbuild/buf v1.73.0 h1:fDNKlxaf+PYi20dmVM7Yt3kOfXEhwrgKDLMNZFjztDs= | ||
M internal/adapters/config/config.go +9 -0
| @@ -36,6 +36,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 36 | 36 | seenNavKeys := make(map[string]bool) | |
| 37 | 37 | seenTabsKeys := make(map[string]bool) | |
| 38 | 38 | seenEphemeralKeys := make(map[string]bool) | |
| 39 | + | seenKeyboardKeys := make(map[string]bool) | |
| 39 | 40 | seenScrollbackKeys := make(map[string]bool) | |
| 40 | 41 | seenWebKeys := make(map[string]bool) | |
| 41 | 42 | ||
| @@ -162,6 +163,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 162 | 163 | continue | |
| 163 | 164 | } | |
| 164 | 165 | 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 | 174 | case key == "copy.word-separators": | |
| 166 | 175 | warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo) | |
| 167 | 176 | 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 | continue | 163 | continue | ||
| 163 | } | 164 | } | ||
| 164 | cfg.Ephemeral.CloseOnExit = on | 165 | 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) { | |||
| 352 | 352 | if !tt.want.Ephemeral.CloseOnExit { | |
| 353 | 353 | tt.want.Ephemeral = domain.Defaults().Ephemeral | |
| 354 | 354 | } | |
| 355 | + | if !tt.want.Keyboard.KittyProtocol { | |
| 356 | + | tt.want.Keyboard = domain.Defaults().Keyboard | |
| 357 | + | } | |
| 355 | 358 | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | |
| 356 | 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 | 566 | func TestParseNavigationOverflow(t *testing.T) { | |
| 524 | 567 | t.Parallel() | |
| 525 | 568 | ||
| @@ -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().Ephemeral | 353 | 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) { | |||
| 51 | 51 | NavigationCapabilities: uint32(message.NavigationCapabilities), | |
| 52 | 52 | Remote: message.Remote, | |
| 53 | 53 | KittyDirectGraphics: message.KittyDirectGraphics, | |
| 54 | + | KittyKeyboard: message.KittyKeyboard, | |
| 54 | 55 | }, nil | |
| 55 | 56 | } | |
| 56 | 57 | ||
| @@ -133,6 +134,7 @@ func helloFromWire(message *wire.Hello) (protocol.Hello, error) { | |||
| 133 | 134 | } | |
| 134 | 135 | hello.Remote = message.GetRemote() | |
| 135 | 136 | hello.KittyDirectGraphics = message.GetKittyDirectGraphics() | |
| 137 | + | hello.KittyKeyboard = message.GetKittyKeyboard() | |
| 136 | 138 | if err := protocol.ValidateHello(hello); err != nil { | |
| 137 | 139 | return protocol.Hello{}, err | |
| 138 | 140 | } | |
| @@ -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 | }, nil | 55 | }, 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{}, err | 139 | 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) { | |||
| 38 | 38 | target := protoTestTarget() | |
| 39 | 39 | messages := []protocol.ClientMessage{ | |
| 40 | 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 | 42 | protocol.Input{InputSeq: 1, ActionID: 2, Data: []byte("x")}, | |
| 42 | 43 | protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}}, | |
| 43 | 44 | 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 { | |||
| 38 | 38 | entered bool // true between a successful EnterRaw and its restore | |
| 39 | 39 | rawSkipped bool // true if fd wasn't a tty, so MakeRaw/Restore were skipped | |
| 40 | 40 | 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 | 43 | // Resize watcher state, also guarded by mu so watcher start | |
| 43 | 44 | // (ResizeEvents) and watcher stop (restore) are strictly ordered: | |
| @@ -116,6 +117,34 @@ func (t *Terminal) EnterRaw() (func() error, error) { | |||
| 116 | 117 | 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 | 148 | // resetVisualModesDirect writes visualRestore straight to the descriptor | |
| 120 | 149 | // instead of through the batched writer. It is the fallback for a writer that | |
| 121 | 150 | // has already failed: any prefix of the enter sequence may have reached the | |
| @@ -127,7 +156,7 @@ func (t *Terminal) resetVisualModesDirect() { | |||
| 127 | 156 | if t.observation != nil { | |
| 128 | 157 | t.observation.InvalidateTerminalObservation() | |
| 129 | 158 | } | |
| 130 | - | _, _ = t.out.Write(VisualRestoreSequence()) | |
| 159 | + | _, _ = t.out.Write(t.restoreSequenceLocked()) | |
| 131 | 160 | } | |
| 132 | 161 | ||
| 133 | 162 | // makeRestoreLocked returns an idempotent restore closure for the | |
| @@ -150,7 +179,7 @@ func (t *Terminal) restore() error { | |||
| 150 | 179 | t.mu.Lock() | |
| 151 | 180 | defer t.mu.Unlock() | |
| 152 | 181 | ||
| 153 | - | _, werr := t.bw.Write(VisualRestoreSequence()) | |
| 182 | + | _, werr := t.bw.Write(t.restoreSequenceLocked()) | |
| 154 | 183 | ferr := t.bw.Flush() | |
| 155 | 184 | if werr != nil || ferr != nil { | |
| 156 | 185 | t.resetVisualModesDirect() | |
| @@ -159,6 +188,7 @@ func (t *Terminal) restore() error { | |||
| 159 | 188 | rerr := t.restoreRawLocked() | |
| 160 | 189 | ||
| 161 | 190 | t.entered = false | |
| 191 | + | t.kittyKeys = false | |
| 162 | 192 | t.restoreFn = nil | |
| 163 | 193 | ||
| 164 | 194 | t.stopResizeLocked() | |
| @@ -38,6 +38,7 @@ type Terminal struct { | |||||
| 38 | entered bool // true between a successful EnterRaw and its restore | 38 | entered bool // true between a successful EnterRaw and its restore | ||
| 39 | rawSkipped bool // true if fd wasn't a tty, so MakeRaw/Restore were skipped | 39 | 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 session | 40 | 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 start | 43 | // 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, nil | 117 | 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 descriptor | 148 | // resetVisualModesDirect writes visualRestore straight to the descriptor | ||
| 120 | // instead of through the batched writer. It is the fallback for a writer that | 149 | // 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 the | 150 | // 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 the | 162 | // 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 = false | 190 | t.entered = false | ||
| 191 | + | t.kittyKeys = false | |||
| 162 | t.restoreFn = nil | 192 | 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) { | |||
| 490 | 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 | + | } | |
| @@ -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 @@ | |||
| 1 | 1 | package term | |
| 2 | 2 | ||
| 3 | + | import "strconv" | |
| 4 | + | ||
| 3 | 5 | const ( | |
| 4 | 6 | altScreenEnter = "\x1b[?1049h" | |
| 5 | 7 | altScreenExit = "\x1b[?1049l" | |
| @@ -14,6 +16,7 @@ const ( | |||
| 14 | 16 | colorSchemeDisable = "\x1b[?2031l" | |
| 15 | 17 | autowrapDisable = "\x1b[?7l" | |
| 16 | 18 | autowrapEnable = "\x1b[?7h" | |
| 19 | + | kittyKeyboardPop = "\x1b[<u" | |
| 17 | 20 | ) | |
| 18 | 21 | ||
| 19 | 22 | const visualEnter = altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable | |
| @@ -27,3 +30,16 @@ func VisualEnterSequence() []byte { return []byte(visualEnter) } | |||
| 27 | 30 | ||
| 28 | 31 | // VisualRestoreSequence returns a fresh copy of the idempotent visual cleanup. | |
| 29 | 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 | + | } | |
| @@ -1,5 +1,7 @@ | |||||
| 1 | package term | 1 | 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 + colorSchemeEnable | 22 | 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) { | |||
| 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 | 130 | func (t *Terminal) EnterRaw() (func() error, error) { | |
| 128 | 131 | if _, err := t.Write(term.VisualEnterSequence()); err != nil { | |
| 129 | 132 | 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, err | 132 | 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) { | |||
| 76 | 76 | 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 | 82 | func (t *Terminal) EnterRaw() (func() error, error) { | |
| 80 | 83 | if _, err := t.Write(term.VisualEnterSequence()); err != nil { | |
| 81 | 84 | return nil, err | |
| @@ -76,6 +76,9 @@ func New(ctx context.Context, geometry domain.Geometry) (*Terminal, error) { | |||||
| 76 | return t, nil | 76 | 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, err | 84 | return nil, err | ||
M internal/app/broker_client.go +11 -0
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | "github.com/bnema/vev/internal/adapters/term" | |
| 15 | 15 | "github.com/bnema/vev/internal/domain" | |
| 16 | 16 | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 17 | + | "github.com/bnema/vev/internal/platform" | |
| 17 | 18 | "github.com/bnema/vev/internal/ports" | |
| 18 | 19 | "github.com/bnema/vev/internal/protocol" | |
| 19 | 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 | 627 | // terminalSessionEnvironment captures one LocalCLI process snapshot for | |
| 617 | 628 | // session creation before NewSupervisor. The supervisor remaps each remote | |
| 618 | 629 | // 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 for | 627 | // terminalSessionEnvironment captures one LocalCLI process snapshot for | ||
| 617 | // session creation before NewSupervisor. The supervisor remaps each remote | 628 | // session creation before NewSupervisor. The supervisor remaps each remote | ||
| 618 | // request to Remote, so the base provenance stays LocalCLI without branching | 629 | // 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) { | |||
| 427 | 427 | return copy(buffer, chunk), nil | |
| 428 | 428 | } | |
| 429 | 429 | ||
| 430 | + | func (t *terminalCompositionTerminal) EnableKittyKeyboard(int) error { return nil } | |
| 431 | + | ||
| 430 | 432 | func (t *terminalCompositionTerminal) EnterRaw() (func() error, error) { | |
| 431 | 433 | return func() error { | |
| 432 | 434 | t.mu.Lock() | |
| @@ -427,6 +427,8 @@ func (i *terminalCompositionInput) Read(buffer []byte) (int, error) { | |||||
| 427 | return copy(buffer, chunk), nil | 427 | 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 { | |||
| 51 | 51 | return &offlineRenderTerminal{geometry: geometry} | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | + | func (t *offlineRenderTerminal) EnableKittyKeyboard(int) error { return nil } | |
| 55 | + | ||
| 54 | 56 | func (t *offlineRenderTerminal) EnterRaw() (func() error, error) { | |
| 55 | 57 | return func() error { return nil }, nil | |
| 56 | 58 | } | |
| @@ -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 }, nil | 57 | 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 | |||
| 917 | 917 | return runBrokerClient(ctx, brokerClientConfig{ | |
| 918 | 918 | Logger: log, Connector: withPreconnected(newProductionBrokerConnector(), preconnected), Terminal: terminal, Clock: clk, | |
| 919 | 919 | InitialNavigation: navigation, ResolveInitialNavigation: resolver, | |
| 920 | - | AttachmentEnvironment: terminalAttachmentEnvironment(), SessionEnvironment: terminalSessionEnvironment(), | |
| 920 | + | AttachmentEnvironment: outerTerminalAttachmentEnvironment(log), SessionEnvironment: terminalSessionEnvironment(), | |
| 921 | 921 | OnState: callbacks.OnState, OnLifecycle: callbacks.OnLifecycle, OnFailure: callbacks.OnFailure, | |
| 922 | 922 | }) | |
| 923 | 923 | } | |
| @@ -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 { | |||
| 91 | 91 | 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 | 101 | // ScrollbackConfig is vev's per-pane retention policy. The terminal library | |
| 95 | 102 | // only enforces these application-selected limits. Zero megabytes disables | |
| 96 | 103 | // history; zero lines removes the additional line ceiling. | |
| @@ -131,6 +138,7 @@ type Config struct { | |||
| 131 | 138 | Nav NavConfig | |
| 132 | 139 | Tabs TabsConfig | |
| 133 | 140 | Ephemeral EphemeralConfig | |
| 141 | + | Keyboard KeyboardConfig | |
| 134 | 142 | Scrollback ScrollbackConfig | |
| 135 | 143 | } | |
| 136 | 144 | ||
| @@ -172,6 +180,9 @@ func Defaults() Config { | |||
| 172 | 180 | Ephemeral: EphemeralConfig{ | |
| 173 | 181 | CloseOnExit: true, | |
| 174 | 182 | }, | |
| 183 | + | Keyboard: KeyboardConfig{ | |
| 184 | + | KittyProtocol: true, | |
| 185 | + | }, | |
| 175 | 186 | Scrollback: DefaultScrollbackConfig(), | |
| 176 | 187 | } | |
| 177 | 188 | } | |
| @@ -91,6 +91,13 @@ type EphemeralConfig struct { | |||||
| 91 | CloseOnExit bool | 91 | 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 library | 101 | // ScrollbackConfig is vev's per-pane retention policy. The terminal library | ||
| 95 | // only enforces these application-selected limits. Zero megabytes disables | 102 | // 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 NavConfig | 138 | Nav NavConfig | ||
| 132 | Tabs TabsConfig | 139 | Tabs TabsConfig | ||
| 133 | Ephemeral EphemeralConfig | 140 | Ephemeral EphemeralConfig | ||
| 141 | + | Keyboard KeyboardConfig | |||
| 134 | Scrollback ScrollbackConfig | 142 | 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 { | |||
| 38 | 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 | 92 | // EnterRaw provides a mock function for the type MockTerminal | |
| 42 | 93 | func (_mock *MockTerminal) EnterRaw() (func() error, error) { | |
| 43 | 94 | 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 MockTerminal | 92 | // 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 { | |||
| 14 | 14 | In() io.Reader | |
| 15 | 15 | Out() io.Writer | |
| 16 | 16 | 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 | } | |
| @@ -14,4 +14,9 @@ type Terminal interface { | |||||
| 14 | In() io.Reader | 14 | In() io.Reader | ||
| 15 | Out() io.Writer | 15 | Out() io.Writer | ||
| 16 | Flush() error | 16 | 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 ( | |||
| 16 | 16 | // daemonmux physical preamble conversation (multiplex.proto), whose | |
| 17 | 17 | // negotiated ceilings and accepted daemon binding ride the same wire | |
| 18 | 18 | // version as the session and broker conversations. | |
| 19 | - | const Version uint16 = 61 | |
| 19 | + | const Version uint16 = 62 | |
| 20 | 20 | ||
| 21 | 21 | // HandshakeTimeout bounds every transport handshake from connect through the | |
| 22 | 22 | // first committed publication. It excludes the preceding client-local | |
| @@ -112,7 +112,10 @@ type Hello struct { | |||
| 112 | 112 | TrueColor bool | |
| 113 | 113 | // KittyDirectGraphics is an explicit declaration that the client has | |
| 114 | 114 | // 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 | |
| 116 | 119 | MaxOutputInFlight uint8 | |
| 117 | 120 | Env []string | |
| 118 | 121 | SessionTarget *SessionAttachTarget | |
| @@ -16,7 +16,7 @@ import ( | |||||
| 16 | // daemonmux physical preamble conversation (multiplex.proto), whose | 16 | // daemonmux physical preamble conversation (multiplex.proto), whose | ||
| 17 | // negotiated ceilings and accepted daemon binding ride the same wire | 17 | // 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 = 61 | 19 | + | const Version uint16 = 62 |
| 20 | 20 | ||||
| 21 | // HandshakeTimeout bounds every transport handshake from connect through the | 21 | // HandshakeTimeout bounds every transport handshake from connect through the | ||
| 22 | // first committed publication. It excludes the preceding client-local | 22 | // first committed publication. It excludes the preceding client-local | ||
| @@ -112,7 +112,10 @@ type Hello struct { | |||||
| 112 | TrueColor bool | 112 | TrueColor bool | ||
| 113 | // KittyDirectGraphics is an explicit declaration that the client has | 113 | // 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 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 | |||
| 116 | MaxOutputInFlight uint8 | 119 | MaxOutputInFlight uint8 | ||
| 117 | Env []string | 120 | Env []string | ||
| 118 | SessionTarget *SessionAttachTarget | 121 | SessionTarget *SessionAttachTarget | ||
M internal/protocol/session_test.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | |
| 13 | - | require.Equal(t, uint16(61), Version) | |
| 13 | + | require.Equal(t, uint16(62), Version) | |
| 14 | 14 | require.Equal(t, (16<<20)-407, MaxOutputDataLen) | |
| 15 | 15 | } | |
| 16 | 16 | ||
| @@ -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 ( | |||
| 15 | 15 | // shares a payload type except route navigation failure, and the preamble | |
| 16 | 16 | // constants agree with the schema authority. | |
| 17 | 17 | 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) | |
| 20 | 20 | } | |
| 21 | 21 | if ProtocolEpoch != 1 { | |
| 22 | 22 | t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch) | |
| @@ -15,8 +15,8 @@ import ( | |||||
| 15 | // shares a payload type except route navigation failure, and the preamble | 15 | // 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 { | |||
| 33 | 33 | bool remote = 20; | |
| 34 | 34 | bool kitty_direct_graphics = 21; | |
| 35 | 35 | SessionAttachTarget session_target = 22; // optional pointer | |
| 36 | + | bool kitty_keyboard = 23; | |
| 36 | 37 | } | |
| 37 | 38 | ||
| 38 | 39 | // 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 pointer | 35 | 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 { | |||
| 68 | 68 | // The request is the exact request the supervisor opened the stream with; the | |
| 69 | 69 | // rest is client-local presentation data the worker mirrors into Hello. | |
| 70 | 70 | 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 | |
| 77 | 80 | SessionEnvironment SessionEnvironment | |
| 78 | 81 | // BeforeAttached runs after the initial frame commits but before attached | |
| 79 | 82 | // presentation becomes observable. A failure keeps the run unattached. | |
| @@ -705,19 +708,23 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr | |||
| 705 | 708 | environmentPolicy = protocol.EnvironmentPolicyDaemonOwned | |
| 706 | 709 | } | |
| 707 | 710 | 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, | |
| 721 | 728 | } | |
| 722 | 729 | switch request.Admission { | |
| 723 | 730 | 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; the | 68 | // 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.BrokerOpenStreamRequest | 71 | + | Request ports.BrokerOpenStreamRequest |
| 72 | - | ClientID [16]byte | 72 | + | ClientID [16]byte |
| 73 | - | Geometry domain.Geometry | 73 | + | Geometry domain.Geometry |
| 74 | - | TermEnv string | 74 | + | TermEnv string |
| 75 | - | Cwd string | 75 | + | Cwd string |
| 76 | - | TrueColor bool | 76 | + | TrueColor bool |
| 77 | + | // Capabilities are the outer terminal's probed capabilities, declared in | |||
| 78 | + | // Hello. | |||
| 79 | + | Capabilities terminalCapabilities | |||
| 77 | SessionEnvironment SessionEnvironment | 80 | SessionEnvironment SessionEnvironment | ||
| 78 | // BeforeAttached runs after the initial frame commits but before attached | 81 | // 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.EnvironmentPolicyDaemonOwned | 708 | 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 { | |||
| 122 | 122 | return &workerTestTerminal{resizes: make(chan domain.Geometry, 4)} | |
| 123 | 123 | } | |
| 124 | 124 | ||
| 125 | + | func (t *workerTestTerminal) EnableKittyKeyboard(int) error { return nil } | |
| 126 | + | ||
| 125 | 127 | func (t *workerTestTerminal) EnterRaw() (func() error, error) { | |
| 126 | 128 | return func() error { | |
| 127 | 129 | 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 ( | |||
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/vev/internal/ports" | |
| 10 | 10 | "github.com/bnema/vev/internal/protocol" | |
| 11 | + | "github.com/bnema/vev/internal/usecase/keys/kittykey" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| 13 | 14 | // ctrlV is the byte a Ctrl+V keypress sends on the wire. | |
| @@ -67,7 +68,7 @@ func (c *clipboardIntercept) Scan(data []byte) { | |||
| 67 | 68 | boundary := pasteMarkerBoundary(data) | |
| 68 | 69 | segment := data[:boundary] | |
| 69 | 70 | for len(segment) > 0 { | |
| 70 | - | idx := bytes.IndexByte(segment, ctrlV) | |
| 71 | + | idx, size := indexCtrlV(segment) | |
| 71 | 72 | if idx < 0 { | |
| 72 | 73 | c.next(segment) | |
| 73 | 74 | break | |
| @@ -75,8 +76,9 @@ func (c *clipboardIntercept) Scan(data []byte) { | |||
| 75 | 76 | if idx > 0 { | |
| 76 | 77 | c.next(segment[:idx]) | |
| 77 | 78 | } | |
| 78 | - | segment = segment[idx+1:] | |
| 79 | - | c.handleCtrlV() | |
| 79 | + | raw := segment[idx : idx+size] | |
| 80 | + | segment = segment[idx+size:] | |
| 81 | + | c.handleCtrlV(raw) | |
| 80 | 82 | } | |
| 81 | 83 | ||
| 82 | 84 | // Bytes at/after boundary are (the possible start of) a bracketed | |
| @@ -104,7 +106,27 @@ func pasteMarkerBoundary(data []byte) int { | |||
| 104 | 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 | 130 | ctx := c.ctx | |
| 109 | 131 | if ctx == nil { | |
| 110 | 132 | ctx = context.Background() | |
| @@ -117,7 +139,7 @@ func (c *clipboardIntercept) handleCtrlV() { | |||
| 117 | 139 | c.sendNotice(protocol.ClientNoticeClipboardFallback) | |
| 118 | 140 | } | |
| 119 | 141 | } | |
| 120 | - | c.next([]byte{ctrlV}) | |
| 142 | + | c.next(raw) | |
| 121 | 143 | return | |
| 122 | 144 | } | |
| 123 | 145 | if len(data) > maxClipboardImagePush { | |
| @@ -125,7 +147,7 @@ func (c *clipboardIntercept) handleCtrlV() { | |||
| 125 | 147 | if c.sendNotice != nil { | |
| 126 | 148 | c.sendNotice(protocol.ClientNoticeClipboardTooLarge) | |
| 127 | 149 | } | |
| 128 | - | c.next([]byte{ctrlV}) | |
| 150 | + | c.next(raw) | |
| 129 | 151 | return | |
| 130 | 152 | } | |
| 131 | 153 | 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 | break | 74 | 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 bracketed | 84 | // 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.ctx | 130 | 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 | return | 143 | 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 | return | 151 | 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 ( | |||
| 8 | 8 | "github.com/bnema/vev/internal/domain" | |
| 9 | 9 | "github.com/bnema/vev/internal/ports" | |
| 10 | 10 | "github.com/bnema/vev/internal/protocol" | |
| 11 | + | "github.com/bnema/vev/internal/usecase/keys/kittykey" | |
| 11 | 12 | pickerusecase "github.com/bnema/vev/internal/usecase/picker" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| @@ -230,6 +231,8 @@ func (p *attachmentMovePicker) release() { | |||
| 230 | 231 | // supervised when the supervisor-owned navigation picker consumed them: its | |
| 231 | 232 | // action is then settled by the supervisor, never fenced through the daemon. | |
| 232 | 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 | 236 | if p.move.presenting { | |
| 234 | 237 | p.stopEscape() | |
| 235 | 238 | 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: its | 231 | // 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 ( | |||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | 7 | "github.com/bnema/vev/internal/ports" | |
| 8 | 8 | "github.com/bnema/vev/internal/protocol" | |
| 9 | + | "github.com/bnema/vev/internal/usecase/keys/kittykey" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 11 | 12 | // Picker is the composition seam over the client-owned picker. | |
| @@ -157,7 +158,9 @@ func (p *Picker) ConsumeTerminalRead(data []byte) bool { | |||
| 157 | 158 | if p == nil || p.controller == nil { | |
| 158 | 159 | 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 | 166 | // 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 false | 159 | 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 catalogue | 166 | // 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 { | |||
| 83 | 83 | enters int | |
| 84 | 84 | } | |
| 85 | 85 | ||
| 86 | + | func (t *pickerRecordingTerminal) EnableKittyKeyboard(int) error { return nil } | |
| 87 | + | ||
| 86 | 88 | func (t *pickerRecordingTerminal) EnterRaw() (func() error, error) { | |
| 87 | 89 | t.mu.Lock() | |
| 88 | 90 | t.enters++ | |
| @@ -83,6 +83,8 @@ type pickerRecordingTerminal struct { | |||||
| 83 | enters int | 83 | 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 ( | |||
| 14 | 14 | ||
| 15 | 15 | "github.com/bnema/vev/internal/domain" | |
| 16 | 16 | "github.com/bnema/vev/internal/ports" | |
| 17 | + | "github.com/bnema/vev/internal/usecase/keys/kittykey" | |
| 17 | 18 | ) | |
| 18 | 19 | ||
| 19 | 20 | // Autonomous client supervisor. | |
| @@ -384,6 +385,12 @@ type AttachmentEnvironment struct { | |||
| 384 | 385 | TermEnv string | |
| 385 | 386 | Cwd string | |
| 386 | 387 | 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 | 396 | // AttachmentLifecycleAction is an explicit process/attachment decision. | |
| @@ -435,6 +442,9 @@ type Supervisor struct { | |||
| 435 | 442 | // theme retains the terminal-reported colors across attachments, so a | |
| 436 | 443 | // replacement attachment restores them before its own palette query ends. | |
| 437 | 444 | 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 | 448 | // readySub is the adopted connection's subscription while the ready phase | |
| 439 | 449 | // runs, so the picker overlay over a live attachment keeps folding broker | |
| 440 | 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 | 580 | defer input.stop() | |
| 569 | 581 | s.attachments.setInput(input.pump) | |
| 570 | 582 | s.attachments.startGeometry(ctx) | |
| @@ -1311,6 +1323,13 @@ type terminalInputLifetime struct { | |||
| 1311 | 1323 | // treated as an immediate orderly EOF. A nil consumer discards read bytes; a | |
| 1312 | 1324 | // non-nil consumer owns them for the picker presentation. | |
| 1313 | 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 | 1333 | lifetime := &terminalInputLifetime{eof: make(chan error, 1), consumer: consumer} | |
| 1315 | 1334 | if supervisorNil(in) { | |
| 1316 | 1335 | lifetime.finish(io.EOF) | |
| @@ -1318,6 +1337,9 @@ func startTerminalInputLifetime(in io.Reader, consumer pickerInputConsumer) *ter | |||
| 1318 | 1337 | } | |
| 1319 | 1338 | lifetime.pump = newTerminalInputPump(in) | |
| 1320 | 1339 | lifetime.pump.start() | |
| 1340 | + | if beforeConsumers != nil { | |
| 1341 | + | beforeConsumers(lifetime.pump) | |
| 1342 | + | } | |
| 1321 | 1343 | if consumer != nil { | |
| 1322 | 1344 | lifetime.acquirePicker() | |
| 1323 | 1345 | } | |
| @@ -1444,3 +1466,23 @@ func supervisorNil(dependency any) bool { | |||
| 1444 | 1466 | 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 | + | } | |
| @@ -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 string | 385 | TermEnv string | ||
| 385 | Cwd string | 386 | Cwd string | ||
| 386 | TrueColor bool | 387 | 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 a | 442 | // 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 terminalThemeState | 444 | 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 phase | 448 | // readySub is the adopted connection's subscription while the ready phase | ||
| 439 | // runs, so the picker overlay over a live attachment keeps folding broker | 449 | // 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; a | 1323 | // 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 false | 1466 | 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, | |||
| 325 | 325 | TermEnv: environment.TermEnv, | |
| 326 | 326 | Cwd: environment.Cwd, | |
| 327 | 327 | TrueColor: environment.TrueColor, | |
| 328 | + | Capabilities: s.capabilities, | |
| 328 | 329 | SessionEnvironment: sessionEnv, | |
| 329 | 330 | BeforeAttached: beforeAttached, | |
| 330 | 331 | 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 { | |||
| 62 | 62 | state uint64 | |
| 63 | 63 | } | |
| 64 | 64 | ||
| 65 | + | func (t *attachTestTerminal) EnableKittyKeyboard(int) error { return nil } | |
| 66 | + | ||
| 65 | 67 | func (t *attachTestTerminal) EnterRaw() (func() error, error) { | |
| 66 | 68 | return func() error { | |
| 67 | 69 | t.mu.Lock() | |
| @@ -62,6 +62,8 @@ type attachTestEvent struct { | |||||
| 62 | state uint64 | 62 | 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 { | |||
| 140 | 140 | return &supervisorTestTerminal{in: in} | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | + | func (t *supervisorTestTerminal) EnableKittyKeyboard(int) error { return nil } | |
| 144 | + | ||
| 143 | 145 | func (t *supervisorTestTerminal) EnterRaw() (func() error, error) { | |
| 144 | 146 | if t.enterErr != nil { | |
| 145 | 147 | 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.enterErr | 147 | 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, | |||
| 49 | 49 | return | |
| 50 | 50 | } | |
| 51 | 51 | 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 | 56 | ac.output.graphicsUnsupportedWarned.Store(false) | |
| 53 | 57 | // A replacement connection is a different outer terminal. Retire the old | |
| 54 | 58 | // attachment-local state without writing cleanup to the new terminal, then | |
| @@ -49,6 +49,10 @@ func (d *Daemon) reconfigureAttachmentOutput(sess *session, ac *attachedClient, | |||||
| 49 | return | 49 | return | ||
| 50 | } | 50 | } | ||
| 51 | ac.terminalCapabilities.KittyGraphics = h.KittyDirectGraphics | 51 | 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 old | 57 | // A replacement connection is a different outer terminal. Retire the old | ||
| 54 | // attachment-local state without writing cleanup to the new terminal, then | 58 | // 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{} { | |||
| 630 | 630 | ||
| 631 | 631 | type attachClientOptions struct { | |
| 632 | 632 | clientID [16]byte | |
| 633 | + | kittyKeyboard bool | |
| 633 | 634 | resumeCapable bool | |
| 634 | 635 | maxOutputInFlight uint8 | |
| 635 | 636 | terminalCapabilities terminalcap.Capabilities | |
| @@ -719,6 +720,7 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne | |||
| 719 | 720 | output.attachment = ac | |
| 720 | 721 | ac.initOverlays() | |
| 721 | 722 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings) | |
| 723 | + | ac.keys.SetKittyKeyboard(opts.kittyKeyboard) | |
| 722 | 724 | return ac | |
| 723 | 725 | } | |
| 724 | 726 | ||
| @@ -630,6 +630,7 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} { | |||||
| 630 | 630 | ||||
| 631 | type attachClientOptions struct { | 631 | type attachClientOptions struct { | ||
| 632 | clientID [16]byte | 632 | clientID [16]byte | ||
| 633 | + | kittyKeyboard bool | |||
| 633 | resumeCapable bool | 634 | resumeCapable bool | ||
| 634 | maxOutputInFlight uint8 | 635 | maxOutputInFlight uint8 | ||
| 635 | terminalCapabilities terminalcap.Capabilities | 636 | terminalCapabilities terminalcap.Capabilities | ||
| @@ -719,6 +720,7 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne | |||||
| 719 | output.attachment = ac | 720 | 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 ac | 724 | 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 | |||
| 1502 | 1502 | } | |
| 1503 | 1503 | opts := attachClientOptions{ | |
| 1504 | 1504 | clientID: h.ClientID, | |
| 1505 | + | kittyKeyboard: h.KittyKeyboard, | |
| 1505 | 1506 | resumeCapable: true, | |
| 1506 | 1507 | maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight), | |
| 1507 | 1508 | 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 ( | |||
| 7 | 7 | "strconv" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/command" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/keys" | |
| 12 | + | "github.com/bnema/vev/internal/usecase/keys/kittykey" | |
| 11 | 13 | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | 14 | "github.com/bnema/vev/internal/usecase/mouse" | |
| 13 | 15 | ) | |
| @@ -38,7 +40,7 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | |||
| 38 | 40 | ac.mouseScan.Scan(data, | |
| 39 | 41 | func(ev mouse.Event) { d.handleMouse(ac, ev) }, | |
| 40 | 42 | func(b []byte) { | |
| 41 | - | if ac.overlays.HandleInput(d, b) { | |
| 43 | + | if ac.overlays.HandleInput(d, ac.legacyKeys(b)) { | |
| 42 | 44 | return | |
| 43 | 45 | } | |
| 44 | 46 | ac.keys.Route(b) | |
| @@ -64,7 +66,9 @@ func (d *Daemon) handleInputForAttachment(effect *attachmentEffect, data []byte) | |||
| 64 | 66 | if !effect.current() { | |
| 65 | 67 | 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 | 72 | return | |
| 69 | 73 | } | |
| 70 | 74 | if !effect.current() { | |
| @@ -436,11 +440,39 @@ func (h daemonKeyHandler) Forward(data []byte) { | |||
| 436 | 440 | tb.mu.Lock() | |
| 437 | 441 | p := tb.terminalTargetLocked() | |
| 438 | 442 | tb.mu.Unlock() | |
| 443 | + | if h.ac.keys.KittyKeyboard() { | |
| 444 | + | data = paneKeyInput(p, data) | |
| 445 | + | } | |
| 439 | 446 | h.d.writeToPane(sess, p, data) | |
| 440 | 447 | 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 | 476 | func (h daemonKeyHandler) Action(action keys.Action, _ []byte) { | |
| 445 | 477 | sess, effect, owned := h.acquireAttachmentEffect() | |
| 446 | 478 | if sess == nil { | |
| @@ -515,6 +547,14 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) { | |||
| 515 | 547 | runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Left}) | |
| 516 | 548 | case keys.ActionConsumeOrExpelPaneRight: | |
| 517 | 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 | 558 | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | |
| 519 | 559 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | |
| 520 | 560 | 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 | return | 44 | 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 | return | 67 | 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 | return | 72 | 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 nil | 447 | 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) { | |||
| 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 | + | } | |
| @@ -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 @@ | |||
| 1 | 1 | package keys | |
| 2 | 2 | ||
| 3 | - | import "unicode/utf8" | |
| 4 | - | ||
| 5 | 3 | // Bindings maps intercepted Alt keys to actions. A Bindings value is mutable | |
| 6 | 4 | // while being built, then treated as frozen once published through an atomic | |
| 7 | 5 | // pointer; callers must clone before using mutators. | |
| @@ -110,19 +108,12 @@ func (b *Bindings) conflictingAction(spec KeySpec, action Action) (Action, bool) | |||
| 110 | 108 | 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 | 112 | if b == nil { | |
| 115 | 113 | 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 | 115 | action, ok := b.altRunes[key] | |
| 125 | - | return action, size, ok | |
| 116 | + | return action, ok | |
| 126 | 117 | } | |
| 127 | 118 | ||
| 128 | 119 | func (b *Bindings) actionForAltArrow(final byte) (Action, bool) { | |
| @@ -1,7 +1,5 @@ | |||||
| 1 | package keys | 1 | package keys | ||
| 2 | 2 | ||||
| 3 | - | import "unicode/utf8" | |||
| 4 | - | ||||
| 5 | // Bindings maps intercepted Alt keys to actions. A Bindings value is mutable | 3 | // Bindings maps intercepted Alt keys to actions. A Bindings value is mutable | ||
| 6 | // while being built, then treated as frozen once published through an atomic | 4 | // 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, false | 108 | 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 = defaultBindings | 113 | 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, ok | 116 | + | 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 | |||
| 254 | 254 | ||
| 255 | 255 | func assertAltRuneAction(t *testing.T, bindings *Bindings, key rune, want Action) { | |
| 256 | 256 | t.Helper() | |
| 257 | - | got, _, ok := bindings.actionForAltBytes([]byte(string(key))) | |
| 257 | + | got, ok := bindings.actionForAltRune(key) | |
| 258 | 258 | require.True(t, ok) | |
| 259 | 259 | require.Equal(t, want, got) | |
| 260 | 260 | } | |
| 261 | 261 | ||
| 262 | 262 | func assertAltRuneUnbound(t *testing.T, bindings *Bindings, key rune) { | |
| 263 | 263 | t.Helper() | |
| 264 | - | _, _, ok := bindings.actionForAltBytes([]byte(string(key))) | |
| 264 | + | _, ok := bindings.actionForAltRune(key) | |
| 265 | 265 | require.False(t, ok) | |
| 266 | 266 | } | |
| 267 | 267 | ||
| @@ -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 @@ | |||
| 3 | 3 | package keys | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | - | "bytes" | |
| 7 | 6 | "slices" | |
| 8 | 7 | "sync" | |
| 9 | 8 | "sync/atomic" | |
| 10 | 9 | "time" | |
| 10 | + | "unicode" | |
| 11 | 11 | "unicode/utf8" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/vev/internal/ports" | |
| 14 | + | "github.com/bnema/vev/internal/usecase/keys/kittykey" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | const ( | |
| @@ -45,6 +46,19 @@ const ( | |||
| 45 | 46 | ActionEqualizePanes | |
| 46 | 47 | ActionConsumeOrExpelPaneLeft | |
| 47 | 48 | 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 | 64 | // Handler receives router outputs. Forward is called only for bytes that should | |
| @@ -63,6 +77,10 @@ type Router struct { | |||
| 63 | 77 | delay time.Duration | |
| 64 | 78 | h Handler | |
| 65 | 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 | 85 | mu sync.Mutex | |
| 68 | 86 | pending bool | |
| @@ -129,79 +147,53 @@ func (r *Router) route(data []byte, h Handler) { | |||
| 129 | 147 | i++ | |
| 130 | 148 | continue | |
| 131 | 149 | } | |
| 132 | - | if i == len(data)-1 { | |
| 150 | + | tok := r.scan(data[i:]) | |
| 151 | + | if tok.kind == escIncomplete { | |
| 133 | 152 | flush() | |
| 134 | - | r.retainESC(h) | |
| 153 | + | r.retainESC(h, tok.raw[1:]) | |
| 135 | 154 | return | |
| 136 | 155 | } | |
| 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 { | |
| 154 | 157 | 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) | |
| 157 | 160 | continue | |
| 158 | - | } else if partial { | |
| 159 | - | flush() | |
| 160 | - | r.retainESC(h, remaining) | |
| 161 | - | return | |
| 162 | 161 | } | |
| 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++ | |
| 179 | 173 | } | |
| 180 | - | buf = append(buf, ESC) | |
| 181 | - | i++ | |
| 182 | 174 | } | |
| 183 | 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 | 181 | 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 { | |
| 191 | 184 | r.retainESC(h, data) | |
| 192 | 185 | return len(data) | |
| 193 | 186 | } | |
| 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 | |
| 198 | 190 | } | |
| 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 | |
| 202 | 195 | } | |
| 203 | - | _, size := utf8.DecodeRune(data) | |
| 204 | - | if size > 1 { | |
| 196 | + | if _, size := utf8.DecodeRune(data); size > 1 { | |
| 205 | 197 | r.forward(append([]byte{ESC}, data[:size]...), h) | |
| 206 | 198 | return size | |
| 207 | 199 | } | |
| @@ -209,7 +201,7 @@ func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler) | |||
| 209 | 201 | r.forward(append([]byte{ESC}, data[:pendingAltLen]...), h) | |
| 210 | 202 | return pendingAltLen | |
| 211 | 203 | } | |
| 212 | - | if passThroughPrefix(next) { | |
| 204 | + | if next := data[0]; next == '[' || next == 'O' { | |
| 213 | 205 | r.forward([]byte{ESC, next}, h) | |
| 214 | 206 | return 1 | |
| 215 | 207 | } | |
| @@ -217,6 +209,34 @@ func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler) | |||
| 217 | 209 | 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 | 240 | func (r *Router) retainESC(h Handler, altBytes ...[]byte) { | |
| 221 | 241 | r.mu.Lock() | |
| 222 | 242 | 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 | 290 | func (r *Router) forward(data []byte, h Handler) { | |
| 279 | 291 | cp := append([]byte(nil), data...) | |
| 280 | 292 | h.Forward(cp) | |
| @@ -285,8 +297,6 @@ func (r *Router) action(action Action, raw []byte, h Handler) { | |||
| 285 | 297 | h.Action(action, cp) | |
| 286 | 298 | } | |
| 287 | 299 | ||
| 288 | - | func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' } | |
| 289 | - | ||
| 290 | 300 | func (r *Router) currentBindings() *Bindings { | |
| 291 | 301 | if r.bindings == nil { | |
| 292 | 302 | return defaultBindings | |
| @@ -298,44 +308,6 @@ func (r *Router) currentBindings() *Bindings { | |||
| 298 | 308 | 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 | 311 | var topRowDigitAliases = [][]rune{ | |
| 340 | 312 | {'1', '&'}, | |
| 341 | 313 | {'2', 'é'}, | |
| @@ -359,3 +331,34 @@ func topRowDigitIndex(key rune) (int, bool) { | |||
| 359 | 331 | } | |
| 360 | 332 | 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 | + | } | |
| @@ -3,14 +3,15 @@ | |||||
| 3 | package keys | 3 | 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 | ActionEqualizePanes | 46 | ActionEqualizePanes | ||
| 46 | ActionConsumeOrExpelPaneLeft | 47 | ActionConsumeOrExpelPaneLeft | ||
| 47 | ActionConsumeOrExpelPaneRight | 48 | 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 should | 64 | // Handler receives router outputs. Forward is called only for bytes that should | ||
| @@ -63,6 +77,10 @@ type Router struct { | |||||
| 63 | delay time.Duration | 77 | delay time.Duration | ||
| 64 | h Handler | 78 | 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.Mutex | 85 | mu sync.Mutex | ||
| 68 | pending bool | 86 | pending bool | ||
| @@ -129,79 +147,53 @@ func (r *Router) route(data []byte, h Handler) { | |||||
| 129 | i++ | 147 | i++ | ||
| 130 | continue | 148 | 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 | return | 154 | 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 + size | 159 | + | i += len(tok.raw) |
| 157 | continue | 160 | 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 += 2 | 165 | + | // fire; unbound arrows and control prefixes pass through. |
| 167 | - | continue | 166 | + | 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 + size | 171 | + | buf = append(buf, ESC) |
| 173 | - | continue | 172 | + | 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 size | 198 | 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 pendingAltLen | 202 | 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 1 | 206 | return 1 | ||
| 215 | } | 207 | } | ||
| @@ -217,6 +209,34 @@ func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler) | |||||
| 217 | return 0 | 209 | 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 = true | 242 | 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 defaultBindings | 302 | return defaultBindings | ||
| @@ -298,44 +308,6 @@ func (r *Router) currentBindings() *Bindings { | |||||
| 298 | return bindings | 308 | 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, false | 332 | 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) { | |||
| 623 | 623 | require.Empty(t, h.actions) | |
| 624 | 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 | + | } | |
| @@ -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 | + | } | |||