summaryrefslogtreecommitdiff
path: root/src/tui_input.zig
blob: b05131cb3fa889909e1274f1cce065c13735f21b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
//! Input layer for the TUI: raw stdin bytes -> `Key` values.
//!
//! Scope (P1, intentionally minimal but correct):
//!   - printable chars (UTF-8, multi-byte)
//!   - enter, tab, backspace, delete
//!   - arrows, home, end, page up/down (CSI + a couple legacy forms)
//!   - Esc (standalone), Ctrl+C, Ctrl+D, and other Ctrl+<letter>
//!   - bracketed paste: enable/disable + recognizing begin/end markers and
//!     surfacing pasted bytes as one literal-text `Key` rather than parsing
//!     each byte as a keypress.
//!
//! Deferred to a later phase (documented, not done here):
//!   - Full Kitty keyboard-protocol disambiguation (key release, exact
//!     modifier reporting, super/hyper, alternate-key reporting). We *send* a
//!     modest Kitty enable+query during negotiation but degrade gracefully:
//!     nothing here requires a response, and unrecognized sequences are
//!     consumed and dropped rather than mis-decoded.
//!
//! Shift+Enter: in the bare legacy protocol Enter and Shift+Enter send the same
//! byte (`\r`), so they're indistinguishable. To recover the distinction we
//! negotiate at startup (see `negotiate_query`): we push the Kitty keyboard
//! protocol (flags 1|4 — disambiguate + report-alternates; NOT report-events,
//! see `enable_kitty_keyboard`) and query the terminal; if it confirms Kitty we use
//! that, otherwise we fall back to xterm modifyOtherKeys mode 2. Either way:
//!   - Kitty-protocol terminals (ghostty/kitty/foot/…) send `\x1b[13;2u`.
//!   - xterm and tmux (which does NOT honor the Kitty push but DOES forward
//!     modifyOtherKeys with `extended-keys on`) send `\x1b[27;2;13~`.
//! This decoder handles BOTH forms, setting `mods.shift` on an `.enter`. The
//! input-box component keys off `mods.shift` to insert a newline vs submit. On
//! terminals that support neither (e.g. macOS Terminal.app), the two remain
//! indistinguishable and Enter submits; a separate binding must insert newline.
//!
//! The splitter (`decodeOne`) takes a byte buffer and returns the next decoded
//! key plus how many bytes it consumed, so a batched read of several escape
//! sequences is split into individual `Key`s and a partial trailing sequence
//! can be retained for the next read.

const std = @import("std");
const key = @import("tui_key.zig");
const Key = key.Key;
const KeyCode = key.KeyCode;

// ---- Global Kitty-protocol state -----------------------------------------
//
// Whether the Kitty keyboard protocol is confirmed active for this session.
// Set by the app after the startup negotiation handshake resolves (see
// `negotiate_query` / `Decoded.negotiation`). A few decode decisions depend on
// it — most notably, when Kitty is active some terminals (Ghostty) deliver
// Shift+Enter as a bare `\n`, whereas in the legacy path `\n` is plain Enter.
//
// This mirrors pi's `setKittyProtocolActive`. It is process-global and
// single-threaded (the TUI runs one terminal); tests set/reset it explicitly.
var _kitty_active: bool = false;

/// Mark the Kitty keyboard protocol active/inactive. The app calls this once
/// the negotiation handshake resolves, and again (false) on teardown.
pub fn setKittyActive(active: bool) void {
    _kitty_active = active;
}

/// Whether the Kitty keyboard protocol is currently believed active.
pub fn kittyActive() bool {
    return _kitty_active;
}
const Mods = key.Mods;

// ---- Terminal control sequences this layer owns --------------------------

/// Enable bracketed paste.
pub const enable_bracketed_paste = "\x1b[?2004h";
/// Disable bracketed paste.
pub const disable_bracketed_paste = "\x1b[?2004l";

/// Bracketed-paste begin / end markers.
pub const paste_begin = "\x1b[200~";
pub const paste_end = "\x1b[201~";

/// Kitty keyboard protocol: push a flags entry that asks for:
///   flag 1 (0b001) disambiguate escape codes,
///   flag 4 (0b100) report alternate keys (shifted/base-layout key).
/// 1|4 = 5. Flag 1 makes the terminal report modified special keys — including
/// Shift+Enter — as distinct CSI-u sequences (`\x1b[13;2u`), which is exactly
/// what we need.
///
/// We deliberately do NOT set flag 2 (report event types / press-repeat-
/// release). Nothing in the TUI consumes key-release events (no component sets
/// `Component.wantsKeyRelease`), and requesting them makes the terminal emit a
/// release sequence for every key. Under flag 2 a single arrow press produces
/// BOTH a press and a release event; since the editor treated each decoded
/// arrow as a motion, the cursor moved twice per physical press (once on key-
/// down, once on key-up). Dropping flag 2 removes the release events at the
/// source. `applyKey` still drops any `.release` it sees as defense-in-depth.
///
/// We also do NOT set flag 8 (report-all-keys), since that suppresses normal
/// text input and forces every printable key through the escape-sequence path.
/// We push (not set) so teardown can pop cleanly. Best-effort; unsupported
/// terminals ignore it and we fall back to modifyOtherKeys / legacy decoding.
pub const enable_kitty_keyboard = "\x1b[>5u";
/// Query the terminal's currently-active Kitty flags: it replies `\x1b[?<n>u`.
pub const query_kitty_flags = "\x1b[?u";
/// Primary Device Attributes query. Every terminal answers this (`\x1b[?...c`),
/// so it acts as a sentinel: if the DA reply arrives WITHOUT a preceding Kitty
/// flags reply, the terminal does not support the Kitty protocol and we fall
/// back to modifyOtherKeys — no startup timeout needed.
pub const query_primary_device_attributes = "\x1b[c";
/// Pop the Kitty keyboard flags entry we pushed.
pub const disable_kitty_keyboard = "\x1b[<u";

/// xterm `modifyOtherKeys` mode 2. The OLDER, pre-Kitty mechanism that xterm,
/// mintty, and — critically — tmux understand. tmux does NOT honor the Kitty
/// `>u` push, but with `extended-keys` on it forwards modifyOtherKeys sequences
/// (`\x1b[27;2;13~` for Shift+Enter). We enable this ONLY as a fallback when the
/// startup handshake shows the Kitty protocol is unavailable — enabling both at
/// once can perturb Kitty's state (Kitty treats `>4;Nm` as an alias for its own
/// flags) and trigger parse-complaints on some terminals.
pub const enable_modify_other_keys = "\x1b[>4;2m";
/// Reset modifyOtherKeys back to the default (mode 0).
pub const disable_modify_other_keys = "\x1b[>4;0m";

/// Startup negotiation to write once raw mode is engaged. We:
///   1. enable bracketed paste,
///   2. push the Kitty flags we want (1|4 = disambiguate + report-alternates;
///      deliberately NOT flag 2 report-events, see `enable_kitty_keyboard`),
///   3. query the now-active Kitty flags, then
///   4. send a Primary Device Attributes query as a sentinel.
/// The app then reads the responses (`Decoded.negotiation`): a non-zero Kitty
/// flags reply confirms the protocol (→ `setKittyActive(true)`); a DA reply with
/// no preceding Kitty reply means fall back to `enable_modify_other_keys`.
pub const negotiate_query =
    enable_bracketed_paste ++
    enable_kitty_keyboard ++
    query_kitty_flags ++
    query_primary_device_attributes;

/// Base teardown common to every exit path: pop the Kitty push and disable
/// bracketed paste. The app additionally writes `disable_modify_other_keys`
/// when it enabled the fallback. (Popping the Kitty stack is harmless even if
/// the push was ignored by a non-Kitty terminal.)
pub const negotiate_teardown = disable_kitty_keyboard ++ disable_bracketed_paste;

// ---- Decode results -------------------------------------------------------

/// A keyboard-protocol negotiation response from the terminal, surfaced so the
/// app's startup handshake can resolve which protocol to use. These are NOT
/// keypresses and must not reach the input box.
pub const Negotiation = union(enum) {
    /// Terminal's reply to `\x1b[?u`: its currently-active Kitty flags. A
    /// non-zero value confirms the Kitty keyboard protocol is active.
    kitty_flags: u32,
    /// Terminal's reply to `\x1b[c` (Primary Device Attributes). Used purely as
    /// the handshake sentinel — its payload is irrelevant to us.
    device_attributes,
};

/// What a single decode step produced.
pub const Decoded = union(enum) {
    /// A decoded key.
    key: Key,
    /// A run of pasted text (between bracketed-paste markers), surfaced
    /// literally. `text` borrows from the input buffer.
    paste: []const u8,
    /// A keyboard-protocol negotiation response (consumed by the app, never
    /// routed to components).
    negotiation: Negotiation,
};

/// Result of one decode step: what was produced, and how many input bytes it
/// consumed. `null` from `decodeOne` means "need more bytes" (a partial
/// sequence at the end of the buffer); the caller should retain the unconsumed
/// tail and append the next read.
pub const Step = struct {
    decoded: Decoded,
    consumed: usize,
};

// ---- The splitter / decoder ----------------------------------------------

/// Decode the next key or paste run from the front of `buf`.
///
/// Returns:
///   - `Step` with `consumed > 0` on success.
///   - `null` when `buf` holds only a partial/incomplete sequence and more
///     bytes are needed (retain the tail and read more). An empty `buf` also
///     returns `null`.
///
/// Borrowing: any `[]const u8` in the result (printable `Key.text`, paste
/// text) points into `buf`. Copy before the next read overwrites the buffer.
pub fn decodeOne(buf: []const u8) ?Step {
    if (buf.len == 0) return null;

    const b = buf[0];

    // ESC: could be a CSI/SS3 sequence, a bracketed paste, an Alt+<key>, or a
    // lone Escape.
    if (b == 0x1b) return decodeEscape(buf);

    // Control bytes (C0).
    switch (b) {
        '\r' => return keyStep(.{ .code = .enter }, 1),
        // `\n` (0x0a): plain Enter in the legacy path. But when the Kitty
        // protocol is active, a bare `\n` is some terminals' Shift+Enter
        // mapping (notably Ghostty's `keybind = shift+enter=text:\n`), since
        // real Enter then arrives as `\x1b[13u`. Mirror pi's handling.
        '\n' => return keyStep(.{
            .code = .enter,
            .mods = .{ .shift = _kitty_active },
        }, 1),
        '\t' => return keyStep(.{ .code = .tab }, 1),
        0x7f, 0x08 => return keyStep(.{ .code = .backspace }, 1),
        0x03 => return keyStep(.{ .code = .{ .char = 'c' }, .mods = .{ .ctrl = true } }, 1), // Ctrl+C
        0x04 => return keyStep(.{ .code = .{ .char = 'd' }, .mods = .{ .ctrl = true } }, 1), // Ctrl+D
        else => {},
    }

    // Other C0 control bytes => Ctrl+<letter>. 0x01..0x1a map to a..z.
    if (b >= 0x01 and b <= 0x1a) {
        const letter: u21 = @intCast('a' + (b - 1));
        return keyStep(.{ .code = .{ .char = letter }, .mods = .{ .ctrl = true } }, 1);
    }

    // Printable: decode one UTF-8 codepoint.
    const seq_len = std.unicode.utf8ByteSequenceLength(b) catch {
        // Invalid lead byte — consume it as a Latin-1-ish fallback so we never
        // wedge on garbage input.
        return keyStep(.{ .code = .{ .char = b }, .text = buf[0..1] }, 1);
    };
    if (buf.len < seq_len) return null; // partial UTF-8 at buffer end
    const cp = std.unicode.utf8Decode(buf[0..seq_len]) catch {
        return keyStep(.{ .code = .{ .char = b }, .text = buf[0..1] }, 1);
    };
    return keyStep(.{ .code = .{ .char = cp }, .text = buf[0..seq_len] }, seq_len);
}

fn keyStep(k: Key, consumed: usize) Step {
    return .{ .decoded = .{ .key = k }, .consumed = consumed };
}

/// Decode a sequence beginning with ESC (`buf[0] == 0x1b`).
fn decodeEscape(buf: []const u8) ?Step {
    std.debug.assert(buf[0] == 0x1b);

    // Lone ESC with nothing after it: ambiguous (could be the start of a
    // longer sequence). We only commit to "Escape key" if we can see it's not
    // the start of CSI/SS3. With just one byte we can't tell -> need more.
    if (buf.len == 1) return null;

    const c1 = buf[1];

    // CSI: ESC [
    if (c1 == '[') return decodeCSI(buf);
    // SS3: ESC O  (some terminals send arrows/home/end as SS3 in app mode)
    if (c1 == 'O') return decodeSS3(buf);

    // ESC followed by anything else: treat as Alt+<that key>. Decode the rest
    // recursively and OR in alt. (A real lone-Escape keypress is followed by
    // nothing more, handled below.)
    if (decodeOne(buf[1..])) |inner| {
        switch (inner.decoded) {
            .key => |ik| {
                var k = ik;
                k.mods.alt = true;
                return .{ .decoded = .{ .key = k }, .consumed = inner.consumed + 1 };
            },
            // A paste / negotiation reply right after a lone ESC is
            // nonsensical; fall through to reporting Escape.
            .paste, .negotiation => {},
        }
    }

    // Couldn't make sense of what follows: report a lone Escape, consuming
    // just the ESC byte.
    return keyStep(.{ .code = .escape }, 1);
}

/// Decode an SS3 sequence: ESC O <final>. Used by some terminals for arrows,
/// home/end and F1..F4 in application-cursor mode.
fn decodeSS3(buf: []const u8) ?Step {
    // buf[0]=ESC buf[1]='O'
    if (buf.len < 3) return null; // need the final byte
    const final = buf[2];
    const code: ?KeyCode = switch (final) {
        'A' => .up,
        'B' => .down,
        'C' => .right,
        'D' => .left,
        'H' => .home,
        'F' => .end,
        'P' => .f1,
        'Q' => .f2,
        'R' => .f3,
        'S' => .f4,
        else => null,
    };
    if (code) |c| return keyStep(.{ .code = c }, 3);
    // Unknown SS3 final: consume the 3 bytes and drop (degrade gracefully).
    return keyStep(.{ .code = .escape }, 3);
}

/// Decode a CSI sequence: ESC [ <params> <final>.
///
/// Handles:
///   - arrows / home / end / page up-down (with optional `1;<mods>` modifier)
///   - the `~`-terminated numeric forms (Home=1~/7~, End=4~/8~, Ins, Del=3~,
///     PgUp=5~, PgDn=6~, F5..F12)
///   - the `[A`..`[D` arrows without params
///   - bracketed paste begin (`200~`) — returns a `.paste` run spanning to the
///     end marker
///   - CSI-u (Kitty) for the keys we care about (notably Shift+Enter =
///     `13;2u`)
///
/// Unknown but well-formed CSI sequences are consumed and dropped.
fn decodeCSI(buf: []const u8) ?Step {
    // buf[0]=ESC buf[1]='['
    // Find the final byte: the first byte in 0x40..0x7e after the params.
    // Params/intermediates are 0x20..0x3f. If we run off the end, need more.
    var i: usize = 2;
    while (i < buf.len and isCSIParamOrIntermediate(buf[i])) : (i += 1) {}
    if (i >= buf.len) return null; // incomplete: no final byte yet
    const final = buf[i];
    const params = buf[2..i];
    const total = i + 1;

    // Keyboard-protocol negotiation responses (private `?` marker):
    //   - Kitty active flags reply:  CSI ? <n> u
    //   - Primary Device Attributes: CSI ? <...> c   (handshake sentinel)
    // Surface these as `.negotiation` so the app handshake consumes them; they
    // are never keypresses.
    if (params.len >= 1 and params[0] == '?') {
        const body = params[1..];
        if (final == 'u') {
            const flags = std.fmt.parseInt(u32, body, 10) catch 0;
            return .{ .decoded = .{ .negotiation = .{ .kitty_flags = flags } }, .consumed = total };
        }
        if (final == 'c') {
            return .{ .decoded = .{ .negotiation = .device_attributes }, .consumed = total };
        }
        // Some other `?`-prefixed report (e.g. mode status): consume + drop.
        return keyStep(.{ .code = .escape }, total);
    }

    // Bracketed paste begin: CSI 200 ~  -> scan to the end marker.
    if (final == '~' and std.mem.eql(u8, params, "200")) {
        return decodePaste(buf, total);
    }

    // CSI-u (Kitty): final 'u', params = "<codepoint>[;<mods>[:<event>]]".
    if (final == 'u') return decodeKittyU(params, total);

    // xterm modifyOtherKeys (formatOtherKeys=0): CSI 27 ; <mods> ; <cp> ~
    // This is the LEGACY fallback that tmux + xterm emit (with modifyOtherKeys
    // mode 2) when the Kitty protocol is not active. Notably tmux sends Shift+
    // Enter as "\x1b[27;2;13~" — NOT the Kitty "\x1b[13;2u" form — so we must
    // decode it here or Shift+Enter is lost inside tmux. (Must be checked before
    // the generic tilde-numeric handling, which assumes a different layout.)
    if (final == '~') {
        if (decodeModifyOtherKeys(params, total)) |step| return step;
    }

    // Modifier suffix: many sequences are "1;<mods><final>" or
    // "<num>;<mods>~". Split params on ';'.
    var first: []const u8 = params;
    var mod_field: ?[]const u8 = null;
    if (std.mem.indexOfScalar(u8, params, ';')) |semi| {
        first = params[0..semi];
        mod_field = params[semi + 1 ..];
    }
    const mods = parseMods(mod_field);

    // Letter-final forms: A/B/C/D arrows, H home, F end.
    const letter_code: ?KeyCode = switch (final) {
        'A' => .up,
        'B' => .down,
        'C' => .right,
        'D' => .left,
        'H' => .home,
        'F' => .end,
        'P' => .f1,
        'Q' => .f2,
        'R' => .f3,
        'S' => .f4,
        else => null,
    };
    if (letter_code) |c| return keyStep(.{ .code = c, .mods = mods }, total);

    // Tilde-final numeric forms.
    if (final == '~') {
        const n = std.fmt.parseInt(u16, first, 10) catch return keyStep(.{ .code = .escape }, total);
        const code: ?KeyCode = switch (n) {
            1, 7 => .home,
            2 => null, // Insert — not in our KeyCode set; drop
            3 => .delete,
            4, 8 => .end,
            5 => .page_up,
            6 => .page_down,
            11 => .f1,
            12 => .f2,
            13 => .f3,
            14 => .f4,
            15 => .f5,
            17 => .f6,
            18 => .f7,
            19 => .f8,
            20 => .f9,
            21 => .f10,
            23 => .f11,
            24 => .f12,
            else => null,
        };
        if (code) |c| return keyStep(.{ .code = c, .mods = mods }, total);
        return keyStep(.{ .code = .escape }, total); // unknown ~ form: drop
    }

    // Well-formed but unhandled CSI: consume and drop.
    return keyStep(.{ .code = .escape }, total);
}

/// Scan a bracketed-paste body starting after the `CSI 200~` begin marker
/// (which spans `[0..begin_len)`), up to the `CSI 201~` end marker. Returns a
/// `.paste` step covering the text between the markers. If the end marker
/// isn't present yet, returns null (need more bytes).
fn decodePaste(buf: []const u8, begin_len: usize) ?Step {
    const body = buf[begin_len..];
    const end_idx = std.mem.indexOf(u8, body, paste_end) orelse return null;
    const text = body[0..end_idx];
    const consumed = begin_len + end_idx + paste_end.len;
    return .{ .decoded = .{ .paste = text }, .consumed = consumed };
}

/// Decode a Kitty CSI-u sequence's params (without the trailing `u`). We only
/// resolve the subset P1 needs: a printable codepoint, Enter (13), Tab (9),
/// Backspace (127), and Escape (27), with modifiers and (optionally) the
/// release event. Anything else is consumed and dropped.
fn decodeKittyU(params: []const u8, total: usize) ?Step {
    // params: "<cp>[:<alt-cp>][;<mods>[:<event>]]"
    var cp_field: []const u8 = params;
    var rest: ?[]const u8 = null;
    if (std.mem.indexOfScalar(u8, params, ';')) |semi| {
        cp_field = params[0..semi];
        rest = params[semi + 1 ..];
    }
    // The codepoint field may carry alternates after ':'; take the first.
    if (std.mem.indexOfScalar(u8, cp_field, ':')) |colon| cp_field = cp_field[0..colon];

    const cp = std.fmt.parseInt(u21, cp_field, 10) catch return keyStep(.{ .code = .escape }, total);

    // After the codepoint, params are "<mods>[:<event>][;<text>]". With flag 16
    // (report associated text) active, the text section carries the literal
    // codepoint(s) the key produces, colon-separated decimal. Split off that
    // text section (after the SECOND ';') before parsing mods/event.
    var mod_event_field: ?[]const u8 = rest;
    var text_field: ?[]const u8 = null;
    if (rest) |r| {
        if (std.mem.indexOfScalar(u8, r, ';')) |semi2| {
            mod_event_field = r[0..semi2];
            text_field = r[semi2 + 1 ..];
        }
    }

    // Modifier + event live in `mod_event_field` as "<mods>[:<event>]".
    var mod_field: ?[]const u8 = mod_event_field;
    var event = key.KeyEvent.press;
    if (mod_event_field) |r| {
        if (std.mem.indexOfScalar(u8, r, ':')) |colon| {
            mod_field = r[0..colon];
            event = parseKittyEvent(r[colon + 1 ..]);
        }
    }
    const mods = parseMods(mod_field);

    // Kitty encodes special keys with either legacy codepoints (13/9/127/27)
    // or functional-key numbers in the Unicode PUA (57344+). With flag 8 active
    // we also receive standalone modifier-key events (left_shift, etc.) in that
    // PUA range — those are not text and must be dropped, not inserted.
    const code: KeyCode = switch (cp) {
        13, 57345 => .enter,
        9, 57346 => .tab,
        127, 57347 => .backspace,
        27, 57344 => .escape,
        57349 => .delete,
        57350 => .left,
        57351 => .right,
        57352 => .up,
        57353 => .down,
        57354 => .page_up,
        57355 => .page_down,
        57356 => .home,
        57357 => .end,
        // Modifier keys and other functional keys we don't model: drop the
        // event by consuming the bytes and reporting nothing insertable.
        57358...57363, 57441...57452 => return keyStep(.{ .code = .escape, .event = event }, total),
        else => .{ .char = cp },
    };

    // Resolve associated text into the per-decode static buffer so the input
    // box inserts exactly what the terminal reported (rather than re-encoding
    // the keycap codepoint, which can differ for shifted/alternate layouts).
    const text: ?[]const u8 = if (text_field) |tf| decodeKittyText(tf) else null;

    return .{ .decoded = .{ .key = .{ .code = code, .mods = mods, .event = event, .text = text } }, .consumed = total };
}

/// Decode a Kitty associated-text field (colon-separated decimal Unicode
/// codepoints) into UTF-8. Returns a slice into a per-call static buffer, valid
/// until the next `decodeKittyText` call. Single-threaded use only — the caller
/// consumes `Key.text` before the next decode. Returns null if empty or invalid.
fn decodeKittyText(field: []const u8) ?[]const u8 {
    const S = struct {
        var buf: [64]u8 = undefined;
    };
    if (field.len == 0) return null;
    var len: usize = 0;
    var it = std.mem.splitScalar(u8, field, ':');
    while (it.next()) |part| {
        if (part.len == 0) continue;
        const cp = std.fmt.parseInt(u21, part, 10) catch return null;
        var tmp: [4]u8 = undefined;
        const n = std.unicode.utf8Encode(cp, &tmp) catch return null;
        if (len + n > S.buf.len) break; // overflow: truncate defensively
        @memcpy(S.buf[len .. len + n], tmp[0..n]);
        len += n;
    }
    if (len == 0) return null;
    return S.buf[0..len];
}

/// Decode an xterm modifyOtherKeys sequence: params are "27;<mods>;<cp>" with a
/// trailing '~' (already consumed by the caller). The middle field is the CSI
/// modifier code (1 + bitmask); the last is the key codepoint. Returns null if
/// the params don't match this exact 3-field shape (so the caller falls through
/// to other tilde forms).
fn decodeModifyOtherKeys(params: []const u8, total: usize) ?Step {
    var it = std.mem.splitScalar(u8, params, ';');
    const lead = it.next() orelse return null;
    if (!std.mem.eql(u8, lead, "27")) return null;
    const mod_str = it.next() orelse return null;
    const cp_str = it.next() orelse return null;
    if (it.next() != null) return null; // more than 3 fields: not this form

    const cp = std.fmt.parseInt(u21, cp_str, 10) catch return null;
    const mods = parseMods(mod_str);
    const code: KeyCode = switch (cp) {
        13 => .enter,
        9 => .tab,
        127, 8 => .backspace,
        27 => .escape,
        else => .{ .char = cp },
    };
    return keyStep(.{ .code = code, .mods = mods }, total);
}

fn parseKittyEvent(field: []const u8) key.KeyEvent {
    const n = std.fmt.parseInt(u8, field, 10) catch return .press;
    return switch (n) {
        1 => .press,
        2 => .repeat,
        3 => .release,
        else => .press,
    };
}

/// Parse a CSI modifier field. The terminal encoding is `1 + bitmask` where
/// the bitmask is shift=1, alt=2, ctrl=4, super=8. A null/empty field means no
/// modifiers.
fn parseMods(field: ?[]const u8) Mods {
    const f = field orelse return .{};
    if (f.len == 0) return .{};
    const raw = std.fmt.parseInt(u16, f, 10) catch return .{};
    if (raw == 0) return .{};
    const bits = raw - 1;
    return .{
        .shift = (bits & 1) != 0,
        .alt = (bits & 2) != 0,
        .ctrl = (bits & 4) != 0,
        .super = (bits & 8) != 0,
    };
}

fn isCSIParamOrIntermediate(c: u8) bool {
    return c >= 0x20 and c <= 0x3f;
}

// ---- Tests ----------------------------------------------------------------

test "printable ascii" {
    const s = decodeOne("a").?;
    try std.testing.expectEqual(@as(usize, 1), s.consumed);
    try std.testing.expectEqual(@as(u21, 'a'), s.decoded.key.code.char);
    try std.testing.expectEqualStrings("a", s.decoded.key.text.?);
}

test "multibyte utf8 printable" {
    // é = U+00E9 = 0xC3 0xA9
    const s = decodeOne("\xc3\xa9rest").?;
    try std.testing.expectEqual(@as(usize, 2), s.consumed);
    try std.testing.expectEqual(@as(u21, 0xe9), s.decoded.key.code.char);
}

test "partial utf8 needs more bytes" {
    try std.testing.expect(decodeOne("\xc3") == null);
}

test "enter, tab, backspace" {
    try std.testing.expectEqual(KeyCode.enter, decodeOne("\r").?.decoded.key.code);
    try std.testing.expectEqual(KeyCode.enter, decodeOne("\n").?.decoded.key.code);
    try std.testing.expectEqual(KeyCode.tab, decodeOne("\t").?.decoded.key.code);
    try std.testing.expectEqual(KeyCode.backspace, decodeOne("\x7f").?.decoded.key.code);
}

test "ctrl-c and ctrl-d" {
    const c = decodeOne("\x03").?.decoded.key;
    try std.testing.expect(c.isCtrl('c'));
    const d = decodeOne("\x04").?.decoded.key;
    try std.testing.expect(d.isCtrl('d'));
}

test "csi arrows" {
    try std.testing.expectEqual(KeyCode.up, decodeOne("\x1b[A").?.decoded.key.code);
    try std.testing.expectEqual(KeyCode.down, decodeOne("\x1b[B").?.decoded.key.code);
    try std.testing.expectEqual(KeyCode.right, decodeOne("\x1b[C").?.decoded.key.code);
    try std.testing.expectEqual(KeyCode.left, decodeOne("\x1b[D").?.decoded.key.code);
}

test "csi home/end and modified arrow" {
    try std.testing.expectEqual(KeyCode.home, decodeOne("\x1b[H").?.decoded.key.code);
    try std.testing.expectEqual(KeyCode.end, decodeOne("\x1b[F").?.decoded.key.code);
    // Ctrl+Right = CSI 1;5C
    const s = decodeOne("\x1b[1;5C").?.decoded.key;
    try std.testing.expectEqual(KeyCode.right, s.code);
    try std.testing.expect(s.mods.ctrl);
}

test "modified arrows: alt and ctrl, legacy CSI 1;<mods> form" {
    // Alt+Left = CSI 1;3D  (mods field 3 = 1 + alt)
    const al = decodeOne("\x1b[1;3D").?.decoded.key;
    try std.testing.expectEqual(KeyCode.left, al.code);
    try std.testing.expect(al.mods.alt);
    try std.testing.expect(!al.mods.ctrl);
    // Alt+Right = CSI 1;3C
    const ar = decodeOne("\x1b[1;3C").?.decoded.key;
    try std.testing.expectEqual(KeyCode.right, ar.code);
    try std.testing.expect(ar.mods.alt);
    // Ctrl+Left = CSI 1;5D (tmux/modifyOtherKeys word-motion path)
    const cl = decodeOne("\x1b[1;5D").?.decoded.key;
    try std.testing.expectEqual(KeyCode.left, cl.code);
    try std.testing.expect(cl.mods.ctrl);
    try std.testing.expect(!cl.mods.alt);
}

test "modified arrows: kitty functional CSU-u form (alt/ctrl)" {
    // Some terminals (Kitty/Ghostty under disambiguate) report arrows as the
    // functional-key codepoints 57350..57353 with a `;<mods>` field.
    // Alt+Left = CSI 57350 ; 3 u
    const al = decodeOne("\x1b[57350;3u").?.decoded.key;
    try std.testing.expectEqual(KeyCode.left, al.code);
    try std.testing.expect(al.mods.alt);
    // Ctrl+Right = CSI 57351 ; 5 u
    const cr = decodeOne("\x1b[57351;5u").?.decoded.key;
    try std.testing.expectEqual(KeyCode.right, cr.code);
    try std.testing.expect(cr.mods.ctrl);
    // Plain functional Left = CSI 57350 u (no mods).
    const pl = decodeOne("\x1b[57350u").?.decoded.key;
    try std.testing.expectEqual(KeyCode.left, pl.code);
    try std.testing.expect(!pl.mods.any());
    try std.testing.expectEqual(key.KeyEvent.press, pl.event);
}

test "modified arrows: ESC-prefixed alt forms" {
    // A terminal can emit alt+arrow as ESC + the arrow sequence. The recursive
    // alt-prefix path in decodeEscape must OR in alt.
    // ESC ESC [ D  (alt + legacy left)
    const a1 = decodeOne("\x1b\x1b[D").?.decoded.key;
    try std.testing.expectEqual(KeyCode.left, a1.code);
    try std.testing.expect(a1.mods.alt);
    // ESC ESC O C  (alt + SS3 right)
    const a2 = decodeOne("\x1b\x1bOC").?.decoded.key;
    try std.testing.expectEqual(KeyCode.right, a2.code);
    try std.testing.expect(a2.mods.alt);
}

test "arrow release events are tagged .release (so the editor can drop them)" {
    // Even though we no longer REQUEST release events (flag 2 dropped), the
    // decoder must still classify a release form correctly if one arrives, so
    // applyKey's release guard is effective. Functional Left release:
    //   CSI 57350 ; 1 : 3 u   (mods field 1 = none, event 3 = release)
    const rel = decodeOne("\x1b[57350;1:3u").?.decoded.key;
    try std.testing.expectEqual(KeyCode.left, rel.code);
    try std.testing.expectEqual(key.KeyEvent.release, rel.event);
    // A press (event 1) is a press.
    const pre = decodeOne("\x1b[57350;1:1u").?.decoded.key;
    try std.testing.expectEqual(key.KeyEvent.press, pre.event);
}

test "csi tilde delete / pageup / pagedown" {
    try std.testing.expectEqual(KeyCode.delete, decodeOne("\x1b[3~").?.decoded.key.code);
    try std.testing.expectEqual(KeyCode.page_up, decodeOne("\x1b[5~").?.decoded.key.code);
    try std.testing.expectEqual(KeyCode.page_down, decodeOne("\x1b[6~").?.decoded.key.code);
}

test "ss3 arrows and f-keys" {
    try std.testing.expectEqual(KeyCode.up, decodeOne("\x1bOA").?.decoded.key.code);
    try std.testing.expectEqual(KeyCode.f1, decodeOne("\x1bOP").?.decoded.key.code);
}

test "lone escape" {
    // ESC followed by a non-sequence byte: Alt+x is decoded preferentially.
    const alt = decodeOne("\x1bx").?.decoded.key;
    try std.testing.expect(alt.mods.alt);
    try std.testing.expectEqual(@as(u21, 'x'), alt.code.char);
}

test "incomplete csi needs more bytes" {
    try std.testing.expect(decodeOne("\x1b[1;5") == null);
    try std.testing.expect(decodeOne("\x1b[") == null);
}

test "kitty shift+enter distinguishes from plain enter" {
    // CSI 13 ; 2 u  -> Enter with shift.
    const s = decodeOne("\x1b[13;2u").?.decoded.key;
    try std.testing.expectEqual(KeyCode.enter, s.code);
    try std.testing.expect(s.mods.shift);
    // Plain enter has no shift.
    try std.testing.expect(!decodeOne("\r").?.decoded.key.mods.shift);
}

test "kitty release event" {
    // CSI 97 ; 1 : 3 u  -> 'a' release.
    const s = decodeOne("\x1b[97;1:3u").?.decoded.key;
    try std.testing.expectEqual(@as(u21, 'a'), s.code.char);
    try std.testing.expectEqual(key.KeyEvent.release, s.event);
}

test "negotiate_query pushes Kitty flags >5u (1|4, NOT report-events flag 2)" {
    // Root-cause regression guard for the arrow double-move bug: we must NOT
    // request flag 2 (report event types), or the terminal emits a key-RELEASE
    // for every press and arrows move twice. The pushed flag set is
    // disambiguate (1) | report-alternates (4) = 5, encoded as `\x1b[>5u`.
    try std.testing.expectEqualStrings("\x1b[>5u", enable_kitty_keyboard);
    // It must appear verbatim inside the startup negotiation string.
    try std.testing.expect(std.mem.indexOf(u8, negotiate_query, "\x1b[>5u") != null);
    // And the report-events form (>7u, i.e. 1|2|4) must NOT be pushed.
    try std.testing.expect(std.mem.indexOf(u8, negotiate_query, "\x1b[>7u") == null);
    // Sanity: the numeric flag set is exactly 1|4 with bit 2 (0b010) clear.
    const flags: u8 = 1 | 4;
    try std.testing.expectEqual(@as(u8, 5), flags);
    try std.testing.expectEqual(@as(u8, 0), flags & 2);
}

test "negotiation: kitty flags reply" {
    const s = decodeOne("\x1b[?7u").?;
    try std.testing.expectEqual(@as(usize, 5), s.consumed);
    switch (s.decoded) {
        .negotiation => |n| switch (n) {
            .kitty_flags => |f| try std.testing.expectEqual(@as(u32, 7), f),
            else => return error.WrongNegotiation,
        },
        else => return error.NotNegotiation,
    }
    // Zero flags = Kitty present but no enhancements active.
    const z = decodeOne("\x1b[?0u").?.decoded;
    try std.testing.expectEqual(@as(u32, 0), z.negotiation.kitty_flags);
}

test "negotiation: device attributes sentinel" {
    // A typical DA reply: CSI ? 62 ; 22 c
    const s = decodeOne("\x1b[?62;22c").?;
    switch (s.decoded) {
        .negotiation => |n| try std.testing.expect(n == .device_attributes),
        else => return error.NotNegotiation,
    }
}

test "negotiation: incomplete reply needs more bytes" {
    try std.testing.expect(decodeOne("\x1b[?7") == null);
    try std.testing.expect(decodeOne("\x1b[?") == null);
}

test "newline is shift+enter only when kitty active" {
    setKittyActive(false);
    try std.testing.expect(!decodeOne("\n").?.decoded.key.mods.shift);
    setKittyActive(true);
    {
        const k = decodeOne("\n").?.decoded.key;
        try std.testing.expectEqual(KeyCode.enter, k.code);
        try std.testing.expect(k.mods.shift);
    }
    // \r is always plain Enter regardless of protocol state.
    try std.testing.expect(!decodeOne("\r").?.decoded.key.mods.shift);
    setKittyActive(false); // reset global for other tests
}

test "modifyOtherKeys tilde form: shift+enter (tmux)" {
    // tmux with modifyOtherKeys mode 2 sends Shift+Enter as CSI 27;2;13~
    const s = decodeOne("\x1b[27;2;13~").?.decoded.key;
    try std.testing.expectEqual(KeyCode.enter, s.code);
    try std.testing.expect(s.mods.shift);
    // Plain Enter (no mods) is unaffected.
    try std.testing.expect(!decodeOne("\r").?.decoded.key.mods.shift);
}

test "modifyOtherKeys tilde form: ctrl+enter and a printable" {
    const ce = decodeOne("\x1b[27;5;13~").?.decoded.key;
    try std.testing.expectEqual(KeyCode.enter, ce.code);
    try std.testing.expect(ce.mods.ctrl);
    // 'A' with ctrl: CSI 27;5;97~
    const ca = decodeOne("\x1b[27;5;97~").?.decoded.key;
    try std.testing.expectEqual(@as(u21, 97), ca.code.char);
    try std.testing.expect(ca.mods.ctrl);
}

test "tilde numeric forms still work alongside modifyOtherKeys" {
    // Ensure the new 27;... branch didn't shadow normal ~ forms.
    try std.testing.expectEqual(KeyCode.delete, decodeOne("\x1b[3~").?.decoded.key.code);
    try std.testing.expectEqual(KeyCode.page_up, decodeOne("\x1b[5~").?.decoded.key.code);
    try std.testing.expectEqual(KeyCode.f5, decodeOne("\x1b[15~").?.decoded.key.code);
}

test "modifyOtherKeys ctrl+c via csi-u still recognized as ctrl" {
    // Under modifyOtherKeys mode 2 (tmux), Ctrl+C arrives as CSI 99 ; 5 u, not
    // raw 0x03. It must still decode as .char 'c' + ctrl so app quit works.
    const s = decodeOne("\x1b[99;5u").?.decoded.key;
    try std.testing.expectEqual(@as(u21, 'c'), s.code.char);
    try std.testing.expect(s.mods.ctrl);
    try std.testing.expect(s.isCtrl('c'));
}

test "kitty pua enter and shift+enter" {
    // Under flag 8, Enter reports as functional-key 57345 (not legacy 13).
    const e = decodeOne("\x1b[57345u").?.decoded.key;
    try std.testing.expectEqual(KeyCode.enter, e.code);
    try std.testing.expect(!e.mods.shift);
    // Shift+Enter via PUA codepoint + shift modifier.
    const se = decodeOne("\x1b[57345;2u").?.decoded.key;
    try std.testing.expectEqual(KeyCode.enter, se.code);
    try std.testing.expect(se.mods.shift);
}

test "kitty associated text populates key.text" {
    // 'a' pressed with flag 16: CSI 97 ; 1 ; 97 u -> code 'a', text "a".
    const s = decodeOne("\x1b[97;1;97u").?.decoded.key;
    try std.testing.expectEqual(@as(u21, 'a'), s.code.char);
    try std.testing.expect(s.text != null);
    try std.testing.expectEqualStrings("a", s.text.?);
}

test "kitty associated text with multibyte codepoint" {
    // U+00E9 (é) = 233 decimal -> 2-byte UTF-8.
    const s = decodeOne("\x1b[233;1;233u").?.decoded.key;
    try std.testing.expectEqualStrings("\xc3\xa9", s.text.?);
}

test "kitty modifier-only keypress is dropped, not inserted" {
    // left_shift press (57441) under flag 8 must not produce an insertable char.
    const s = decodeOne("\x1b[57441u").?.decoded.key;
    try std.testing.expectEqual(KeyCode.escape, s.code); // sentinel "drop"
    // The input box ignores .escape, so nothing is inserted.
    switch (s.code) {
        .char => return error.ModifierLeakedAsChar,
        else => {},
    }
}

test "bracketed paste surfaces literal text" {
    const input = paste_begin ++ "hello\nworld" ++ paste_end ++ "x";
    const s = decodeOne(input).?;
    try std.testing.expectEqualStrings("hello\nworld", s.decoded.paste);
    // Next decode should land on the trailing 'x'.
    const rest = input[s.consumed..];
    try std.testing.expectEqual(@as(u21, 'x'), decodeOne(rest).?.decoded.key.code.char);
}

test "incomplete paste needs more bytes" {
    const input = paste_begin ++ "partial";
    try std.testing.expect(decodeOne(input) == null);
}

test "splitter: batched read yields individual keys" {
    // Up, Down, 'a' in one buffer.
    const input = "\x1b[A\x1b[Ba";
    var off: usize = 0;
    const s1 = decodeOne(input[off..]).?;
    try std.testing.expectEqual(KeyCode.up, s1.decoded.key.code);
    off += s1.consumed;
    const s2 = decodeOne(input[off..]).?;
    try std.testing.expectEqual(KeyCode.down, s2.decoded.key.code);
    off += s2.consumed;
    const s3 = decodeOne(input[off..]).?;
    try std.testing.expectEqual(@as(u21, 'a'), s3.decoded.key.code.char);
    off += s3.consumed;
    try std.testing.expectEqual(input.len, off);
}