gitstack

grindlemire/go-tui code browser

2.4 KB Go 81 lines 2026-03-28 · 7746d43 raw
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
//go:build unix

package tui

import (
	"syscall"
	"time"

	"github.com/grindlemire/go-tui/internal/debug"
)

// NegotiateKittyKeyboard attempts to enable Kitty keyboard protocol (flag 1 =
// disambiguate). Uses push/pop stack semantics so nested TUI apps coexist.
//
// Sequence:
//  1. Push flag 1: CSI > 1 u
//  2. Query current mode: CSI ? u
//  3. Poll stdin (50ms timeout) for response: CSI ? flags u
//  4. If response includes flag 1, success. Otherwise pop: CSI < u
func (t *ANSITerminal) NegotiateKittyKeyboard() bool {
	// Push disambiguate mode onto the keyboard stack and query
	t.esc.Reset()
	t.esc.KittyKeyboardPush(1)
	t.esc.KittyKeyboardQuery()
	t.out.Write(t.esc.Bytes())

	// Read the response byte-by-byte to avoid consuming extra stdin data
	// (e.g. keystrokes typed during startup). The expected response is
	// CSI ? <digits> u, so we stop as soon as we see the 'u' terminator
	// after the CSI ? prefix.
	var resp [32]byte
	n := 0
	seenCSIQuestion := false
	deadline := time.Now().Add(50 * time.Millisecond)

	for n < len(resp) {
		remaining := time.Until(deadline)
		if remaining <= 0 {
			break
		}
		ready, err := selectWithTimeout(int(t.inFd), remaining)
		if err != nil || !ready {
			break
		}
		var b [1]byte
		nr, err := syscall.Read(int(t.inFd), b[:])
		if err != nil || nr == 0 {
			break
		}
		resp[n] = b[0]
		n++
		// Track whether we've seen the CSI ? prefix
		if n >= 3 && resp[n-3] == 0x1b && resp[n-2] == '[' && resp[n-1] == '?' {
			seenCSIQuestion = true
		} else if seenCSIQuestion && b[0] != 'u' && !(b[0] >= '0' && b[0] <= '9') {
			// Reset if we see a byte that can't be part of CSI ? <digits> u.
			// This prevents user keystrokes containing ESC [ ? from
			// prematurely terminating the read loop.
			seenCSIQuestion = false
		}
		// Stop at 'u' terminator only after seeing the response prefix
		if seenCSIQuestion && b[0] == 'u' {
			break
		}
	}

	if n > 0 && parseKittyQueryResponse(resp[:n]) {
		t.kittyKeyboard = true
		t.caps.KittyKeyboard = true
		debug.Topic("keys", "KittyKeyboard: negotiated successfully (response %d bytes: %x)", n, resp[:n])
		return true
	}

	// Always pop to undo the push. A pop on a terminal that does not
	// support the protocol is silently ignored, so this is safe. Skipping
	// the pop when n == 0 would leak a stack entry if the terminal accepted
	// the push but responded after the deadline.
	t.popKittyKeyboard()
	debug.Topic("keys", "KittyKeyboard: negotiation failed (response %d bytes)", n)
	return false
}