summaryrefslogtreecommitdiff
path: root/src/tui_app.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-08 12:24:09 -0600
committert <t@tjp.lol>2026-06-08 12:25:55 -0600
commitb5eb3f1776a540a55d5675f786a4421c49a6283d (patch)
treea6ba0a59d7dc736f833b69280b089563ee93ca64 /src/tui_app.zig
parente5ed00c52bb10aec811734c5568c881c41e58474 (diff)
proper terminal capability negotiation
determine support for the kitty protocol and then pick output sequences accordingly. tested on ghostty (supports kitty) and tmux-in-ghostty (tmux does not support kitty protocol) with shift+enter as newline.
Diffstat (limited to 'src/tui_app.zig')
-rw-r--r--src/tui_app.zig62
1 files changed, 57 insertions, 5 deletions
diff --git a/src/tui_app.zig b/src/tui_app.zig
index 03b32c5..788595a 100644
--- a/src/tui_app.zig
+++ b/src/tui_app.zig
@@ -480,10 +480,29 @@ pub const RunOptions = struct {
/// 3. Decode buffered bytes -> keys -> the focused input box.
/// 4. On a submitted line: drive a turn (or dispatch a slash command),
/// pumping the stream's events into component state.
+/// Keyboard-protocol handshake state for one session. Resolved from the
+/// terminal's replies to the startup `negotiate_query`.
+const Handshake = struct {
+ /// Kitty keyboard protocol confirmed active (non-zero flags reply seen).
+ kitty: bool = false,
+ /// We enabled the modifyOtherKeys fallback (must reset it on teardown).
+ mok_enabled: bool = false,
+ /// Handshake has resolved (a DA sentinel reply was seen).
+ resolved: bool = false,
+};
+
pub fn runLoop(app: *App, term: *Terminal, opts: RunOptions) !void {
- // Negotiate bracketed paste (+ opportunistic Kitty). Teardown on exit.
- term.writeAll(input_mod.negotiate_setup);
- defer term.writeAll(input_mod.negotiate_teardown);
+ var hs: Handshake = .{};
+ // Start the keyboard-protocol handshake: enable bracketed paste, push the
+ // Kitty flags we want, then query (Kitty flags + DA sentinel). The replies
+ // are consumed in `handleBytes`, which enables the modifyOtherKeys fallback
+ // iff the terminal turns out not to support Kitty.
+ term.writeAll(input_mod.negotiate_query);
+ defer {
+ input_mod.setKittyActive(false);
+ if (hs.mok_enabled) term.writeAll(input_mod.disable_modify_other_keys);
+ term.writeAll(input_mod.negotiate_teardown);
+ }
term.hideCursor();
defer term.showCursor();
@@ -522,7 +541,7 @@ pub fn runLoop(app: *App, term: *Terminal, opts: RunOptions) !void {
// 3. Decode. Prepend any retained tail, decode all complete sequences,
// retain the unconsumed tail for the next read.
try tail.appendSlice(app.alloc, read_buf[0..n]);
- const consumed = try handleBytes(app, tail.items, opts);
+ const consumed = try handleBytes(app, term, &hs, tail.items, opts);
// Keep the unconsumed tail.
const leftover = tail.items.len - consumed;
std.mem.copyForwards(u8, tail.items[0..leftover], tail.items[consumed..]);
@@ -537,7 +556,7 @@ pub fn runLoop(app: *App, term: *Terminal, opts: RunOptions) !void {
/// level, feed the rest to the focused input box, and act on any submitted
/// line. Returns the number of bytes consumed (the unconsumed partial tail is
/// retained by the caller).
-fn handleBytes(app: *App, bytes: []const u8, opts: RunOptions) !usize {
+fn handleBytes(app: *App, term: *Terminal, hs: *Handshake, bytes: []const u8, opts: RunOptions) !usize {
var off: usize = 0;
while (off < bytes.len) {
const step = input_mod.decodeOne(bytes[off..]) orelse break; // partial tail
@@ -555,6 +574,11 @@ fn handleBytes(app: *App, bytes: []const u8, opts: RunOptions) !usize {
.paste => {
app.input_box.comp().handleInput(bytes[off .. off + step.consumed]);
},
+ .negotiation => |neg| {
+ handleNegotiation(term, hs, neg);
+ off += step.consumed;
+ continue; // not a keypress; no render / submit check
+ },
}
off += step.consumed;
app.scheduler.requestRender();
@@ -570,6 +594,34 @@ fn handleBytes(app: *App, bytes: []const u8, opts: RunOptions) !usize {
return off;
}
+/// React to a keyboard-protocol negotiation reply from the terminal.
+///
+/// - A non-zero Kitty flags reply confirms the Kitty protocol: mark it active
+/// and do NOT enable modifyOtherKeys (they can conflict).
+/// - The DA reply is the handshake sentinel: it always arrives. If we reach it
+/// without having confirmed Kitty, the terminal lacks Kitty support, so we
+/// enable the modifyOtherKeys fallback (e.g. for tmux/xterm).
+fn handleNegotiation(term: *Terminal, hs: *Handshake, neg: input_mod.Negotiation) void {
+ switch (neg) {
+ .kitty_flags => |flags| {
+ if (flags != 0 and !hs.kitty) {
+ hs.kitty = true;
+ input_mod.setKittyActive(true);
+ term.caps.kitty_keyboard = true;
+ }
+ },
+ .device_attributes => {
+ if (hs.resolved) return;
+ hs.resolved = true;
+ if (!hs.kitty and !hs.mok_enabled) {
+ term.writeAll(input_mod.enable_modify_other_keys);
+ hs.mok_enabled = true;
+ term.caps.kitty_keyboard = false;
+ }
+ },
+ }
+}
+
/// Handle a submitted input line: slash command vs. model turn.
fn handleSubmittedLine(app: *App, line: []const u8, opts: RunOptions) !void {
if (line.len == 0) return;