package app import ( "strconv" "strings" ) // csiLen returns the byte length of the CSI sequence starting at // chunk[i], or 0 if chunk[i:] doesn't begin a complete CSI. A CSI is // ESC '[' followed by parameter bytes (0x30..0x3F), intermediate bytes // (0x20..0x2F), and one final byte (0x40..0x7E). func csiLen(chunk []byte, i int) int { if i+1 >= len(chunk) || chunk[i] != 0x1b || chunk[i+1] != '[' { return 0 } end := i + 2 for end < len(chunk) && chunk[end] >= 0x30 && chunk[end] <= 0x3F { end++ } for end < len(chunk) && chunk[end] >= 0x20 && chunk[end] <= 0x2F { end++ } if end >= len(chunk) { return 0 } if final := chunk[end]; final < 0x40 || final > 0x7E { return 0 } return end - i + 1 } // csiuKey is the decoded form of a CSI u key event. key is the kitty // keycode (the unshifted unicode codepoint for character keys, or a // kitty functional-key constant). mods is the kitty modifier value // (1 + bitfield: shift=1, alt=2, ctrl=4, super=8, …). event is the // event type (1=press, 2=repeat, 3=release). type csiuKey struct { key int mods int event int } // parseSGRMouseWheel decodes the parameter run from an SGR-encoded // mouse press (`CSI < button ; col ; row M`) and returns a row delta // when the event is a scroll wheel. Wheel-up returns -wheelStep, // wheel-down returns +wheelStep. Modifier bits in the button code // (shift=4, alt=8, ctrl=16) are stripped before matching, so e.g. // shift+wheel still scrolls. Non-wheel buttons return false. func parseSGRMouseWheel(params []byte) (int, bool) { const wheelStep = 3 // Button code runs up to the first ';'. end := 0 for end < len(params) && params[end] != ';' { end++ } if end == 0 { return 0, false } btn, err := strconv.Atoi(string(params[:end])) if err != nil { return 0, false } if btn&64 == 0 { return 0, false } // Bit 0 selects up (0) vs down (1) for wheel events. if btn&1 == 0 { return -wheelStep, true } return wheelStep, true } // decodeCSIu parses the parameter string of a `CSI ... u` sequence. // The kitty shape is: // // [:[:]] [;[:][;...]] // // Unspecified groups default to mods=1, event=1. func decodeCSIu(params string) (csiuKey, bool) { parts := strings.SplitN(params, ";", 3) keyGroup := parts[0] if i := strings.IndexByte(keyGroup, ':'); i >= 0 { keyGroup = keyGroup[:i] } if keyGroup == "" { return csiuKey{}, false } key, err := strconv.Atoi(keyGroup) if err != nil { return csiuKey{}, false } mods, event := 1, 1 if len(parts) > 1 { modGroup := parts[1] eventGroup := "" if i := strings.IndexByte(modGroup, ':'); i >= 0 { eventGroup = modGroup[i+1:] modGroup = modGroup[:i] } if modGroup != "" { m, err := strconv.Atoi(modGroup) if err != nil { return csiuKey{}, false } mods = m } if eventGroup != "" { e, err := strconv.Atoi(eventGroup) if err != nil { return csiuKey{}, false } event = e } } return csiuKey{key: key, mods: mods, event: event}, true } // matchCtrlK reports whether chunk[i:] starts with a Ctrl-K keystroke // in any of the encodings we accept on input, and returns the number of // bytes consumed. // // Three encodings are recognised: // // - Legacy: the single byte 0x0B. // - Kitty keyboard CSI u: ESC '[' 107 ';' 5 'u' (optionally with sub- // parameters and trailing groups, see [kitty]). The kitty protocol // fires when a child PTY pushes it onto the host terminal's flag // stack; codex/ratatui does this on startup, which is what motivated // this matcher. // - xterm modifyOtherKeys: ESC '[' 27 ';' 5 ';' 107 '~'. // // Only an unmodified Ctrl-K (modifier value exactly 5 — i.e. Ctrl with // no Shift/Alt/Meta) and a key-press event (event-type 1 or omitted) // match. That mirrors the legacy 0x0B byte, which only fires on plain // Ctrl-K too. // // [kitty]: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ func matchCtrlK(chunk []byte, i int) (matched bool, advance int) { if i >= len(chunk) { return false, 0 } if chunk[i] == keyCtrlK { return true, 1 } n := csiLen(chunk, i) if n == 0 { return false, 0 } final := chunk[i+n-1] params := string(chunk[i+2 : i+n-1]) switch final { case 'u': k, ok := decodeCSIu(params) if ok && k.key == 107 && k.mods == 5 && k.event == 1 { return true, n } case '~': if isModifyOtherKeysCtrlK(params) { return true, n } } return false, 0 } // isModifyOtherKeysCtrlK parses xterm's CSI 27;;~ form. func isModifyOtherKeysCtrlK(params string) bool { parts := strings.Split(params, ";") if len(parts) != 3 { return false } return parts[0] == "27" && parts[1] == "5" && parts[2] == "107" } // matchCtrlChar reports whether chunk[i:] starts with Ctrl+ where // ch is a lowercase ASCII letter. Recognises the same three encodings // as matchCtrlK: legacy single byte (Ctrl-A = 0x01 .. Ctrl-Z = 0x1A), // kitty CSI u with mods=5, and xterm modifyOtherKeys CSI 27;5;~. // Only unmodified Ctrl (no Shift/Alt/Meta) and a press event match. func matchCtrlChar(chunk []byte, i int, ch byte) (matched bool, advance int) { if i >= len(chunk) || ch < 'a' || ch > 'z' { return false, 0 } legacy := ch - 'a' + 1 if chunk[i] == legacy { return true, 1 } n := csiLen(chunk, i) if n == 0 { return false, 0 } final := chunk[i+n-1] params := string(chunk[i+2 : i+n-1]) switch final { case 'u': k, ok := decodeCSIu(params) if ok && k.key == int(ch) && k.mods == 5 && k.event == 1 { return true, n } case '~': parts := strings.Split(params, ";") if len(parts) == 3 && parts[0] == "27" && parts[1] == "5" && parts[2] == strconv.Itoa(int(ch)) { return true, n } } return false, 0 }