//! 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+ //! - 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|2|4) 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 2 (0b010) report event types (press/repeat/release), /// flag 4 (0b100) report alternate keys (shifted/base-layout key). /// 1|2|4 = 7. This matches what pi requests. 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 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[>7u"; /// Query the terminal's currently-active Kitty flags: it replies `\x1b[?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` 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|2|4), /// 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+, 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+. 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+. 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 . 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 [ . /// /// Handles: /// - arrows / home / end / page up-down (with optional `1;` 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 ? 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 = "[;[:]]". if (final == 'u') return decodeKittyU(params, total); // xterm modifyOtherKeys (formatOtherKeys=0): CSI 27 ; ; ~ // 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;" or // ";~". 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: "[:][;[:]]" 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 "[:][;]". 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 "[:]". 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;;" 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 "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 "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); }