diff --git a/frontier-cli/boxen/input_decoder.c b/frontier-cli/boxen/input_decoder.c index bc74344c6..dce7f5284 100644 --- a/frontier-cli/boxen/input_decoder.c +++ b/frontier-cli/boxen/input_decoder.c @@ -10,6 +10,13 @@ * 2026-06-29 JES #812 M4: bracketed-paste handling (PASTE_ACTIVE state + * heap-grown paste buffer + BOXEN_EV_PASTE emission with CR/LF * normalization, 256 KiB size cap, and BOXEN_LOG_W on truncation). + * 2026-06-29 JES #813 M5: Kitty keyboard protocol -- input_decoder_kitty_enable + * writes "\x1b[=1u" (progressive enhancement: disambiguate escape codes) + * on the TTY fd, and decode_csi gains a 'u' case that decodes the CSI-u + * form (\e[KEYCODE;MODIFIER u) to boxen_event_t. Terminals that don't + * support kitty (Terminal.app, iTerm2) silently ignore the enable and + * send no CSI-u traffic -- the standard CSI path from M2 still handles + * their input correctly. * * Scope of this file at M2: * - Allocate / free the input_decoder_t struct. @@ -432,9 +439,53 @@ void input_decoder_kitty_enable(input_decoder_t *dec) { if (dec == NULL) { return; } + /* 2026-06-29 JES #813 M5: idempotent. If kitty was already enabled + * (a second slash command, a re-init after a SIGWINCH-driven probe, + * etc.) skip the duplicate write so we don't pile up enable sequences + * in the terminal's input queue. The decode path in decode_csi + * doesn't actually consult dec->kitty_enabled -- a terminal that + * sends CSI-u sequences without us asking is decoded just the same -- + * so the bit is documentary plus the gate for the one-shot write + * below. */ + if (dec->kitty_enabled) { + return; + } dec->kitty_enabled = true; - /* M5: write "\e[=1u" to dec->tty_fd; subsequent polls decode the - * \e[CODE;MOD u replies. M1 stub records the bit only. */ + + /* 2026-06-29 JES #813 M5: write the progressive-enhancement enable. + * + * "\x1b[=1u" sets the "disambiguate escape codes" flag, which is the + * minimum kitty progressive-enhancement level that gives us + * unambiguous reports for Escape, Tab, Enter, and the modifier- + * disambiguated keys we care about for issue #805. Higher flag + * values (2 = report event types, 4 = report alternate keys) are + * post-launch -- the M5 decoder only handles the two-part + * KEYCODE;MODIFIER form. Asking for more flags than we can decode + * would be a protocol-conformance footgun: the terminal would send + * the three-part form and the decoder would silently drop it via + * the count==0 / unknown-keycode branches in decode_csi 'u'. + * + * Probe-and-read for terminal acknowledgement (the optional + * \e[=FLAGS u response) is deferred to M6, where the read(2) loop + * lives -- M5 ships only the decoder's ability to handle CSI-u when + * it arrives, per the milestone scope in plan section 7. If the + * terminal does NOT support kitty (Terminal.app, iTerm2 as of this + * writing), the enable sequence is silently ignored by the terminal + * and no CSI-u traffic arrives -- the decoder operates as if + * kitty_enable had never been called. Standard CSI parsing from M2 + * continues to handle the keys those terminals do send. + * + * Test-seam path (tty_fd == -1) skips the write -- mirrors the + * pattern from input_decoder_set_mouse (lines 409..417 above). + * write(2) errors are silently ignored for the same reason as in + * set_mouse: a write failure means the fd is closed and the next + * read will surface the same condition cleanly via the poll loop. + * Short control sequences fit in a single pipe buffer page so + * partial-write handling is not required. */ + if (dec->tty_fd >= 0) { + static const char ENABLE[] = "\x1b[=1u"; + (void)write(dec->tty_fd, ENABLE, sizeof(ENABLE) - 1); + } } bool input_decoder_kitty_enabled(const input_decoder_t *dec) { @@ -1060,10 +1111,91 @@ static bool decode_csi(input_decoder_t *dec, const uint8_t *csi_buf, default: return false; } } + case 'u': { + /* 2026-06-29 JES #813 M5: Kitty keyboard protocol CSI-u decode. + * + * Form: \e[KEYCODE u (no modifier) + * \e[KEYCODE;MODIFIER u (with modifier; 1=none, 2=Shift, + * 3=Alt, 5=Ctrl, ... same encoding + * as the standard CSI cursor-key + * modifier param -- see + * modifier_from_param). + * + * Plan section 3.9: KEYCODE for printable keys is the Unicode + * code point of the character produced ('A' = 65, 'a' = 97, ...). + * Functional identifiers reuse the Unicode control-code values + * for keys that have one: 13 (CR) = Enter, 27 (ESC) = Escape, + * 9 (HT) = Tab, 127 (DEL) = Backspace. This M5 milestone + * implements the two-part form; the three-part form with event + * type and alternate key is post-launch (plan section 3.9, + * "For M5 scope..."). + * + * params[0] is required (no keycode => malformed; drop quietly + * via the false-return + caller's parse_errors bump). params[1] + * may be absent -- modifier_from_param treats 0 / absent as + * BOXEN_MOD_NONE so the no-modifier path coalesces cleanly with + * the single-param form above. */ + if (count == 0) { + return false; + } + uint16_t kitty_mod = (count >= 2) ? modifier_from_param(params[1]) + : BOXEN_MOD_NONE; + int keycode = params[0]; + + /* Functional identifiers first. Each maps to a dedicated + * BOXEN_KEY_* so downstream consumers can keymap them + * separately from the printable form. Codes that aren't on + * this list (and aren't printable) fall through to the + * "unknown -- drop" branch at the bottom; this keeps the M5 + * surface narrow. A future post-launch milestone can extend + * the table with kitty's full functional set (arrows, F-keys, + * etc.) without changing the dispatch shape. */ + switch (keycode) { + case 9: /* Tab */ + emit_key(out, BOXEN_KEY_TAB, 0, kitty_mod); + return true; + case 13: /* Enter */ + emit_key(out, BOXEN_KEY_ENTER, 0, kitty_mod); + return true; + case 27: /* Escape */ + emit_key(out, BOXEN_KEY_ESCAPE, 0, kitty_mod); + return true; + case 127: /* Backspace */ + emit_key(out, BOXEN_KEY_BACKSPACE, 0, kitty_mod); + return true; + default: + break; + } + + /* Printable keycode. Accept any Unicode scalar value in the + * BMP-and-supplementary range, excluding the C0 / C1 control + * ranges that didn't match a functional identifier above. + * Surrogates (U+D800..U+DFFF) and out-of-range codepoints + * (> U+10FFFF) are rejected -- the same UTF-8 validation + * boundary the GROUND-state decoder applies, so the input + * surface stays consistent regardless of which protocol path + * the keystroke arrived on. */ + if (keycode <= 0 || keycode > 0x10FFFF) { + return false; + } + if (keycode >= 0xD800 && keycode <= 0xDFFF) { + return false; + } + if (keycode < 0x20 || (keycode >= 0x7F && keycode < 0xA0)) { + /* Unmapped C0 (other than the functional codes handled + * above) and C1 control range. Reject -- emitting these + * as printable ch would let a terminal smuggle raw + * control bytes through the CSI-u path. */ + return false; + } + emit_key(out, BOXEN_KEY_NONE, (uint32_t)keycode, kitty_mod); + return true; + } default: - /* u (Kitty, M5) and unknown finals land here. - * M / m (mouse) are handled above before reaching this switch. - * Silently discarded -- never emit as printable. */ + /* Unknown finals land here. M / m (mouse) are handled above + * before reaching this switch; u (Kitty) is handled by its own + * case immediately above. Silently discarded -- never emit as + * printable. */ return false; } } diff --git a/frontier-cli/boxen/input_decoder.h b/frontier-cli/boxen/input_decoder.h index e64624e7d..64be1e506 100644 --- a/frontier-cli/boxen/input_decoder.h +++ b/frontier-cli/boxen/input_decoder.h @@ -123,15 +123,23 @@ bool input_decoder_mouse_enabled(const input_decoder_t *dec); * calls (see section 3.12 of the plan: burst-read robustness). */ int input_decoder_poll(input_decoder_t *dec, boxen_event_t *out, int timeout_ms); -/* 2026-06-29 JES #809 M1: Kitty keyboard protocol enable (deferred to M5). - * - * Sends the enable sequence (\e[=1u) and records that progressive - * enhancement was requested. Responses arrive via normal - * input_decoder_poll. Idempotent. No-op if already enabled or if the - * terminal does not respond to the probe. - * - * M1 stub: records the request but emits nothing. M5 wires the write - * and decodes CSI-u replies. */ +/* 2026-06-29 JES #809 M1: Kitty keyboard protocol enable. + * 2026-06-29 JES #813 M5: write side wired -- sends "\x1b[=1u" to tty_fd + * when tty_fd >= 0. Test seam (tty_fd == -1) still flips the bit only. + * + * Sends the enable sequence (\e[=1u: "disambiguate escape codes" flag) and + * records that progressive enhancement was requested. CSI-u replies + * (\e[KEYCODE;MODIFIER u) arrive via normal input_decoder_poll and are + * decoded by the same state machine that handles standard CSI sequences. + * Idempotent -- a second call after the first is a no-op (the bit is + * already set; we skip the duplicate write). + * + * Probe-and-read for the terminal's acknowledgement (\e[=FLAGS u) is + * deferred to M6 where the read(2) loop lives. Terminals that don't + * support kitty (Terminal.app, iTerm2 as of writing) silently ignore the + * enable; no CSI-u traffic arrives and the decoder operates as if + * kitty_enable had never been called -- standard CSI parsing from M2 + * continues to handle their input. */ void input_decoder_kitty_enable(input_decoder_t *dec); /* Query whether kitty_enable has been called on this decoder. Symmetric diff --git a/tests/input_decoder_tests.c b/tests/input_decoder_tests.c index 0b06c76b6..ed8d1cc4f 100644 --- a/tests/input_decoder_tests.c +++ b/tests/input_decoder_tests.c @@ -12,6 +12,10 @@ * ESC-prefix Meta, partial-sequence split, control characters 0x01-0x1A, * UTF-8 multi-byte decode. M3/M4/M5 stubs remain SKIP. * + * 2026-06-29 JES #813 M5: un-SKIP the M5-scope Kitty CSI-u tests -- + * `\e[N u` and `\e[N;Nu` decoding for printable + functional keycodes + * (A, a, Enter, Escape). All SKIP stubs are now behavioral asserts. + * * SKIP mechanism note (no TR_SKIP exists in test_report.h): * The TR_RUN macro records a test as "passed" if its body returns without * firing assert(). Each protocol stub below prints a SKIP line to stderr @@ -70,8 +74,16 @@ * function is named test_skip_* -- a grep over the JSON tally for * "test_skip_" produces the deferred set without parsing stderr. * - * 2026-06-29 JES #809 M1. */ + * 2026-06-29 JES #809 M1. + * + * 2026-06-29 JES #813 M5: M5 un-skipped the last SKIP stub so tr_skip has + * no callers today. Kept in place (marked __attribute__((unused)) so + * -Wunused-function stays clean) because any future deferred test should + * use the same accounting path -- the printf("[skip-summary] ...") in + * main reads g_skip_count. Removing the helper would force the next + * person who needs a SKIP path to recreate the accounting plumbing. */ static int g_skip_count = 0; +__attribute__((unused)) static void tr_skip(const char *reason) { g_skip_count++; fprintf(stderr, "[SKIP] %s\n", reason); @@ -2172,23 +2184,328 @@ static void test_bracketed_paste_split_across_inject(void) { } /* ------------------------------------------------------------------------- - * M5 SKIP stubs -- Kitty keyboard protocol. + * 2026-06-29 JES #813 M5: Kitty keyboard protocol -- CSI-u decode. + * + * Plan section 3.9: terminals that advertise the Kitty progressive-enhancement + * protocol send `\e[KEYCODE;MODIFIERu` for keys (the CSI-u form). KEYCODE is + * the Unicode code point for printable keys (e.g. 65 = 'A') and a small set + * of "functional" identifiers for special keys (13 = Enter, 27 = Escape, etc. + * -- the subset used by M5 tests follows the kitty spec's "key identifier" + * table). MODIFIER, when present, uses the same 1-based encoding as the + * standard CSI cursor-key modifier param (1=none, 2=Shift, 3=Alt, 5=Ctrl, + * ...) so the existing modifier_from_param helper applies unchanged. + * + * M5 scope (per plan section 7): decode the two-part `\e[N;Nu` form + * (keycode + modifier). The three-part form (with event type and + * alternate key) is post-launch. * ---------------------------------------------------------------------- */ -static void test_skip_kitty_csi_u_letter(void) { - tr_skip("M5: \\e[65u -> ch='A', mod=NONE (plan section 3.9)"); +/* `\e[65u` -- 'A' with no modifier. Verifies the simplest CSI-u path: + * single param, no modifier, keycode is a printable Unicode code point that + * passes through as ev.key.ch with key=NONE. */ +static void test_kitty_csi_u_letter_a(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + + static const uint8_t seq[] = {0x1B, '[', '6', '5', 'u'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_NONE); + assert(ev.key.ch == 'A'); + assert(ev.key.mod == BOXEN_MOD_NONE); + + input_decoder_destroy(dec); +} + +/* `\e[65;3u` -- 'A' with Alt. Verifies the two-param form: keycode + + * modifier. Modifier param 3 = Alt per modifier_from_param. */ +static void test_kitty_csi_u_letter_a_with_alt(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + + static const uint8_t seq[] = {0x1B, '[', '6', '5', ';', '3', 'u'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_NONE); + assert(ev.key.ch == 'A'); + assert(ev.key.mod == BOXEN_MOD_ALT); + + input_decoder_destroy(dec); +} + +/* `\e[13;5u` -- Enter with Ctrl. Keycode 13 (0x0D) is the Enter functional + * identifier in the kitty key map; modifier 5 = Ctrl. Verifies that + * functional keycodes route to the matching BOXEN_KEY_* constant rather than + * emitting as a printable ch=13. */ +static void test_kitty_csi_u_enter_with_ctrl(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + + static const uint8_t seq[] = {0x1B, '[', '1', '3', ';', '5', 'u'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_ENTER); + assert(ev.key.ch == 0); + assert(ev.key.mod == BOXEN_MOD_CTRL); + + input_decoder_destroy(dec); +} + +/* `\e[27;3u` -- Escape with Alt. Keycode 27 (0x1B) is the Escape + * functional identifier; modifier 3 = Alt. Verifies the disambiguated + * Escape path: the CSI-u form lets the terminal report "Escape with Alt" + * unambiguously, unlike the legacy ESC-prefix Meta form (\e) which + * cannot represent Escape itself. */ +static void test_kitty_csi_u_escape_with_alt(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + + static const uint8_t seq[] = {0x1B, '[', '2', '7', ';', '3', 'u'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_ESCAPE); + assert(ev.key.ch == 0); + assert(ev.key.mod == BOXEN_MOD_ALT); + + input_decoder_destroy(dec); +} + +/* `\e[97u` -- lowercase 'a' with no modifier. Sanity check that the + * decoder doesn't case-fold or otherwise transform the printable keycode. */ +static void test_kitty_csi_u_lowercase_a(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + + static const uint8_t seq[] = {0x1B, '[', '9', '7', 'u'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_NONE); + assert(ev.key.ch == 'a'); + assert(ev.key.mod == BOXEN_MOD_NONE); + + input_decoder_destroy(dec); +} + +/* Round-trip: kitty_enable + several CSI-u sequences in one inject. + * + * Mirrors the burst-read pattern used by M3 tests. Verifies (a) that + * input_decoder_kitty_enable() does not consume or pollute the byte stream + * (it only flips the bit, since tty_fd == -1 here suppresses the write), + * and (b) that a multi-event inject containing a mix of bare and modified + * CSI-u sequences drains correctly with three separate poll calls. */ +static void test_kitty_enable_then_csi_u_round_trip(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + + input_decoder_kitty_enable(dec); + assert(input_decoder_kitty_enabled(dec) == true); + + /* Three CSI-u sequences concatenated: + * \e[65u -- 'A' + * \e[66;2u -- 'B' with Shift + * \e[13;5u -- Enter with Ctrl + */ + static const uint8_t seq[] = { + 0x1B, '[', '6', '5', 'u', + 0x1B, '[', '6', '6', ';', '2', 'u', + 0x1B, '[', '1', '3', ';', '5', 'u', + }; + size_t accepted = input_decoder_inject_bytes(dec, seq, sizeof(seq)); + assert(accepted == sizeof(seq)); + + boxen_event_t ev; + + memset(&ev, 0x7f, sizeof(ev)); + assert(input_decoder_poll(dec, &ev, 0) == BOXEN_OK); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.ch == 'A'); + assert(ev.key.mod == BOXEN_MOD_NONE); + + memset(&ev, 0x7f, sizeof(ev)); + assert(input_decoder_poll(dec, &ev, 0) == BOXEN_OK); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.ch == 'B'); + assert(ev.key.mod == BOXEN_MOD_SHIFT); + + memset(&ev, 0x7f, sizeof(ev)); + assert(input_decoder_poll(dec, &ev, 0) == BOXEN_OK); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_ENTER); + assert(ev.key.mod == BOXEN_MOD_CTRL); + + /* Buffer fully drained. */ + assert(input_decoder_poll(dec, &ev, 0) == BOXEN_ERR_TIMEOUT); + + input_decoder_destroy(dec); } -static void test_skip_kitty_csi_u_letter_with_alt(void) { - tr_skip("M5: \\e[65;3u -> ch='A', mod=ALT (plan section 3.9)"); +/* 2026-06-29 JES #813 M5 gate-fix: negative-path coverage for the CSI-u + * decoder. Each test injects a malformed or out-of-policy sequence and + * asserts (a) no event is emitted, (b) the parse_errors counter is + * bumped so the silent rejection is observable from tests / metrics. + * Without these, a regression that started emitting events for surrogate + * codepoints, C0 controls, or empty-keycode sequences would silently + * smuggle bytes downstream until a user-facing symptom surfaced. + * + * `parse_errors` is bumped by the CSI-collect loop's "unknown final" path + * when decode_csi returns false -- so all the rejection branches inside + * the 'u' case roll up to one observable counter delta of +1 per + * rejected sequence (same as the rejection bookkeeping the M2 / M3 + * paths use). */ + +/* `\e[u` -- no keycode (count==0). Drop. */ +static void test_kitty_csi_u_no_keycode_rejected(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + size_t pe0 = input_decoder_parse_errors(dec); + + static const uint8_t seq[] = {0x1B, '[', 'u'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_ERR_TIMEOUT, &ev); + + assert(input_decoder_parse_errors(dec) == pe0 + 1); + input_decoder_destroy(dec); } -static void test_skip_kitty_csi_u_enter_with_ctrl(void) { - tr_skip("M5: \\e[13;5u -> KEY_ENTER, mod=CTRL (plan section 3.9)"); +/* `\e[0u` -- keycode 0 (would underflow the printable-range guard). Drop. */ +static void test_kitty_csi_u_zero_keycode_rejected(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + size_t pe0 = input_decoder_parse_errors(dec); + + static const uint8_t seq[] = {0x1B, '[', '0', 'u'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_ERR_TIMEOUT, &ev); + + assert(input_decoder_parse_errors(dec) == pe0 + 1); + input_decoder_destroy(dec); } -static void test_skip_kitty_csi_u_escape_with_alt(void) { - tr_skip("M5: \\e[27;3u -> KEY_ESCAPE, mod=ALT (plan section 3.9)"); +/* `\e[7u` -- C0 control 0x07 (BEL). Not a functional identifier; rejected + * so the CSI-u path can't be used to smuggle raw control bytes that the + * GROUND-state decoder would reject. */ +static void test_kitty_csi_u_c0_control_rejected(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + size_t pe0 = input_decoder_parse_errors(dec); + + static const uint8_t seq[] = {0x1B, '[', '7', 'u'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_ERR_TIMEOUT, &ev); + + assert(input_decoder_parse_errors(dec) == pe0 + 1); + input_decoder_destroy(dec); +} + +/* `\e[55296u` -- surrogate codepoint U+D800. Invalid Unicode scalar value + * per RFC 3629; same rejection boundary as the UTF-8 GROUND validator. */ +static void test_kitty_csi_u_surrogate_rejected(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + size_t pe0 = input_decoder_parse_errors(dec); + + static const uint8_t seq[] = {0x1B, '[', '5', '5', '2', '9', '6', 'u'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_ERR_TIMEOUT, &ev); + + assert(input_decoder_parse_errors(dec) == pe0 + 1); + input_decoder_destroy(dec); +} + +/* `\e[160u` -- U+00A0 NO-BREAK SPACE. Just above the C1 control range + * (0x80..0x9F), should pass through as a printable code point. Pins + * the upper boundary of the C1 rejection so a regression that moved the + * fence to "< 0xA1" instead of "< 0xA0" would fail this test. */ +static void test_kitty_csi_u_nbsp_emits(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + + static const uint8_t seq[] = {0x1B, '[', '1', '6', '0', 'u'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_NONE); + assert(ev.key.ch == 0xA0); + assert(ev.key.mod == BOXEN_MOD_NONE); + + input_decoder_destroy(dec); +} + +/* `\e[128u` -- U+0080 C1 control. Should reject (paired with the NBSP + * test to fence both sides of the C1 range). */ +static void test_kitty_csi_u_c1_control_rejected(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + size_t pe0 = input_decoder_parse_errors(dec); + + static const uint8_t seq[] = {0x1B, '[', '1', '2', '8', 'u'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_ERR_TIMEOUT, &ev); + + assert(input_decoder_parse_errors(dec) == pe0 + 1); + input_decoder_destroy(dec); +} + +/* `\e[9;3u` -- Tab with Alt. Sanity check that the functional-key + * branch applies modifiers correctly. */ +static void test_kitty_csi_u_tab_with_alt(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + + static const uint8_t seq[] = {0x1B, '[', '9', ';', '3', 'u'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_TAB); + assert(ev.key.mod == BOXEN_MOD_ALT); + + input_decoder_destroy(dec); +} + +/* `\e[127;2u` -- Backspace with Shift. Same as above for Backspace. */ +static void test_kitty_csi_u_backspace_with_shift(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + + static const uint8_t seq[] = {0x1B, '[', '1', '2', '7', ';', '2', 'u'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_BACKSPACE); + assert(ev.key.mod == BOXEN_MOD_SHIFT); + + input_decoder_destroy(dec); +} + +/* Idempotent kitty_enable: second call must not flip the bit back and + * must not produce side effects. In the test seam (tty_fd == -1) there's + * no write to observe, but the bit-state invariant is the contract. In + * production the M5 gate (`if (dec->kitty_enabled) return;`) prevents the + * duplicate write -- documented in the function header. */ +static void test_kitty_enable_idempotent(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + + assert(input_decoder_kitty_enabled(dec) == false); + input_decoder_kitty_enable(dec); + assert(input_decoder_kitty_enabled(dec) == true); + input_decoder_kitty_enable(dec); /* second call: no-op */ + assert(input_decoder_kitty_enabled(dec) == true); + + input_decoder_destroy(dec); } /* ------------------------------------------------------------------------- @@ -2351,18 +2668,40 @@ int main(void) { TR_RUN(test_bracketed_paste_empty); TR_RUN(test_bracketed_paste_split_across_inject); - /* M5 SKIP -- Kitty keyboard protocol. */ - TR_RUN(test_skip_kitty_csi_u_letter); - TR_RUN(test_skip_kitty_csi_u_letter_with_alt); - TR_RUN(test_skip_kitty_csi_u_enter_with_ctrl); - TR_RUN(test_skip_kitty_csi_u_escape_with_alt); + /* 2026-06-29 JES #813 M5: Kitty keyboard protocol CSI-u decode. */ + TR_RUN(test_kitty_csi_u_letter_a); + TR_RUN(test_kitty_csi_u_letter_a_with_alt); + TR_RUN(test_kitty_csi_u_enter_with_ctrl); + TR_RUN(test_kitty_csi_u_escape_with_alt); + TR_RUN(test_kitty_csi_u_lowercase_a); + TR_RUN(test_kitty_enable_then_csi_u_round_trip); + + /* 2026-06-29 JES #813 M5 gate-fix: negative-path coverage + boundary + * pins for the CSI-u rejection branches (no-keycode, zero, C0, C1, + * surrogate, NBSP fence) plus functional-modifier coverage and the + * kitty_enable idempotency contract. */ + TR_RUN(test_kitty_csi_u_no_keycode_rejected); + TR_RUN(test_kitty_csi_u_zero_keycode_rejected); + TR_RUN(test_kitty_csi_u_c0_control_rejected); + TR_RUN(test_kitty_csi_u_surrogate_rejected); + TR_RUN(test_kitty_csi_u_nbsp_emits); + TR_RUN(test_kitty_csi_u_c1_control_rejected); + TR_RUN(test_kitty_csi_u_tab_with_alt); + TR_RUN(test_kitty_csi_u_backspace_with_shift); + TR_RUN(test_kitty_enable_idempotent); /* 2026-06-29 JES #809 M1: skip-stub visibility (see file header). * Printed BEFORE TR_SUMMARY so the count appears alongside the green * tally in the test log without polluting the JSON tally itself. * tr_count was updated by TR_RUN as each test ran; g_skip_count tracks - * the subset that called tr_skip() instead of asserting behavior. */ - printf("[skip-summary] %d of %d tests are SKIP stubs deferred to M5\n", + * the subset that called tr_skip() instead of asserting behavior. + * + * 2026-06-29 JES #813 M5: M5 was the last milestone with SKIP stubs; + * with the Kitty CSI-u tests un-skipped the expected count is 0 of N. + * Left the printf in place rather than removing it so any future + * deferred test stub re-using tr_skip() surfaces in CI without an + * extra plumbing change. */ + printf("[skip-summary] %d of %d tests are SKIP stubs\n", g_skip_count, tr_count); TR_SUMMARY();