This batches the in-flight [Unreleased] block from CHANGELOG.md into a single commit. Highlights: - Real MCP protocol layer (initialize / tools/list / tools/call) so vendor MCP clients can complete the handshake against the per-PID socket. Legacy direct-dispatch preserved for the harness. - New mcp_injection kinds — cli_override for codex, config_env for opencode — joining the existing env-var and config_file paths so patterm can slot into more agents without touching their real config or auth. - Ctrl+A/D and Ctrl+W/S focus navigation across tabs and intra-tab process lists, recognised in legacy / kitty CSI u / xterm modifyOtherKeys encodings. - Palette macros (sw / k / sp ) and reordering so open sessions surface above spawn-new entries. - Two-row tab bar, sidebar/tabbar/status chrome cache, viewport-wipe on agent spawn, CR-terminated orchestrator injections, and split- Enter PTY writes so paste-detecting TUIs see Enter as a key event. Also fixes the bug logged in TODO: claude's Ctrl+O tool-call expansion emits CSI 0 J, which the viewport renderer was forwarding verbatim — wiping the sidebar to the right of the cursor and leaving the chrome cache convinced nothing had changed. CSI 0 J and CSI 1 J are now translated into per-row ECH sequences clamped to the viewport, same as CSI 2 J and CSI K already were. Agent guides (CLAUDE.md / AGENTS.md) now spell out the TODO->CHANGELOG workflow so completed items land in the changelog rather than as ticked entries left behind in TODO.
179 lines
4.7 KiB
Go
179 lines
4.7 KiB
Go
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
|
|
}
|
|
|
|
// decodeCSIu parses the parameter string of a `CSI ... u` sequence.
|
|
// The kitty shape is:
|
|
//
|
|
// <key>[:<shifted>[:<base>]] [;<mods>[:<event>][;<text>...]]
|
|
//
|
|
// 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;<mods>;<key>~ 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+<ch> 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;<key>~.
|
|
// 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
|
|
}
|