Commit b610ddd

Eric Bower  ·  2026-04-09 11:34:17 -0400 EDT
parent af0e1f3
refactor: set leader by detecting user input with libghostty parser
2 files changed,  +235, -81
+2, -28
......@@ -602,36 +602,10 @@ const Daemon = struct {
602602 return;
603603 }
604604
605- // quick check to see if a newline happened so we can set that client to leader
606- // without creating a ghostty vt
607- const isNewline = std.mem.indexOfScalar(u8, payload, '\r') != null;
608- const isUpArrow = std.mem.eql(u8, payload, "\x1b[A") or util.isUpArrow(payload);
609- if (isNewline or isUpArrow) {
605+ // check if leader needs to be updated by detecting any user input
606+ if (util.isUserInput(payload)) {
610607 try self.setLeader(client);
611608 self.queuePtyInput(payload);
612- return;
613- }
614-
615- // check if leader needs to be updated
616- // this is probably really ineffecient but it was the easiest and most robust way
617- // to strip ansi escape codes and only detect plain text to determine if we need
618- // to set a new leader
619- var termx = try ghostty_vt.Terminal.init(client.alloc, .{
620- .cols = 80,
621- .rows = 24,
622- });
623- defer termx.deinit(client.alloc);
624- var vt_stream = termx.vtStream();
625- defer vt_stream.deinit();
626- try vt_stream.nextSlice(payload);
627- if (util.serializeTerminal(client.alloc, &termx, .plain)) |output| {
628- defer client.alloc.free(output);
629- // if there's no text output then this client is effectively read-only until they type
630- if (output.len > 0) {
631- try self.setLeader(client);
632- // new leader is set to this client so send *entire* payload
633- self.queuePtyInput(payload);
634- }
635609 }
636610 }
637611
+233, -53
......@@ -3,6 +3,7 @@ const posix = std.posix;
33 const ghostty_vt = @import("ghostty-vt");
44 const ipc = @import("ipc.zig");
55 const socket = @import("socket.zig");
6+const testing = std.testing;
67
78 pub const SessionEntry = struct {
89 name: []const u8,
......@@ -301,6 +302,41 @@ fn parseDecimal(buf: []const u8, pos: *usize) ?u32 {
301302 return value;
302303 }
303304
305+/// Detect if the payload contains user input that should be printed to the screen or
306+/// is a key combination like up-arrow, backspace, enter, ctrl+f, etc.
307+pub fn isUserInput(payload: []const u8) bool {
308+ var parser = ghostty_vt.Parser.init();
309+ for (payload) |c| {
310+ const actions = parser.next(c);
311+ for (actions) |action_opt| {
312+ const action = action_opt orelse continue;
313+ switch (action) {
314+ .print => return true, // printable characters
315+ .csi_dispatch => |csi| {
316+ // kitty keyboard: CSI ... u or CSI ... ~
317+ // legacy modified keys: CSI 27 ; ... ~
318+ // arrow/function keys with modifiers: CSI 1 ; <mod> A-D
319+ if (csi.final == 'u' or csi.final == '~') return true;
320+ // modified arrow keys (e.g., Ctrl+F sends CSI 1;5C in legacy mode)
321+ if (csi.final >= 'A' and csi.final <= 'D' and csi.params.len > 1) return true;
322+ // mouse events: CSI M (basic) or CSI < (SGR extended) - EXCLUDE these
323+ // only intentional keyboard input should trigger leader switch
324+ if (csi.final == 'M' or csi.final == '<') return false;
325+ // focus events: CSI I (focus in) or CSI O (focus out) - EXCLUDE these
326+ // these are automatic terminal events, not user typing
327+ if (csi.final == 'I' or csi.final == 'O') return false;
328+ },
329+ .execute => |code| {
330+ // looking for CR, LF, tab, and backspace
331+ if (code == 0x0D or code == 0x0A or code == 0x09 or code == 0x08) return true;
332+ },
333+ else => {},
334+ }
335+ }
336+ }
337+ return false;
338+}
339+
304340 pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 {
305341 var builder: std.Io.Writer.Allocating = .init(alloc);
306342 defer builder.deinit();
......@@ -586,124 +622,124 @@ test "writeSessionLine formats output for current session and short output" {
586622 };
587623
588624 for (cases) |case| {
589- var builder: std.Io.Writer.Allocating = .init(std.testing.allocator);
625+ var builder: std.Io.Writer.Allocating = .init(testing.allocator);
590626 defer builder.deinit();
591627
592628 try writeSessionLine(&builder.writer, case.session, case.short, case.current_session);
593- try std.testing.expectEqualStrings(case.expected, builder.writer.buffered());
629+ try testing.expectEqualStrings(case.expected, builder.writer.buffered());
594630 }
595631 }
596632
597633 test "shellNeedsQuoting" {
598- try std.testing.expect(shellNeedsQuoting(""));
599- try std.testing.expect(shellNeedsQuoting("hello world"));
600- try std.testing.expect(shellNeedsQuoting("hello!"));
601- try std.testing.expect(shellNeedsQuoting("$PATH"));
602- try std.testing.expect(shellNeedsQuoting("it's"));
603- try std.testing.expect(shellNeedsQuoting("a|b"));
604- try std.testing.expect(shellNeedsQuoting("a;b"));
605- try std.testing.expect(!shellNeedsQuoting("hello"));
606- try std.testing.expect(!shellNeedsQuoting("bash"));
607- try std.testing.expect(!shellNeedsQuoting("-c"));
608- try std.testing.expect(!shellNeedsQuoting("/usr/bin/env"));
634+ try testing.expect(shellNeedsQuoting(""));
635+ try testing.expect(shellNeedsQuoting("hello world"));
636+ try testing.expect(shellNeedsQuoting("hello!"));
637+ try testing.expect(shellNeedsQuoting("$PATH"));
638+ try testing.expect(shellNeedsQuoting("it's"));
639+ try testing.expect(shellNeedsQuoting("a|b"));
640+ try testing.expect(shellNeedsQuoting("a;b"));
641+ try testing.expect(!shellNeedsQuoting("hello"));
642+ try testing.expect(!shellNeedsQuoting("bash"));
643+ try testing.expect(!shellNeedsQuoting("-c"));
644+ try testing.expect(!shellNeedsQuoting("/usr/bin/env"));
609645 }
610646
611647 test "shellQuote" {
612- const alloc = std.testing.allocator;
648+ const alloc = testing.allocator;
613649
614650 const empty = try shellQuote(alloc, "");
615651 defer alloc.free(empty);
616- try std.testing.expectEqualStrings("''", empty);
652+ try testing.expectEqualStrings("''", empty);
617653
618654 const space = try shellQuote(alloc, "hello world");
619655 defer alloc.free(space);
620- try std.testing.expectEqualStrings("'hello world'", space);
656+ try testing.expectEqualStrings("'hello world'", space);
621657
622658 const bang = try shellQuote(alloc, "hello!");
623659 defer alloc.free(bang);
624- try std.testing.expectEqualStrings("'hello!'", bang);
660+ try testing.expectEqualStrings("'hello!'", bang);
625661
626662 const dollar = try shellQuote(alloc, "$PATH");
627663 defer alloc.free(dollar);
628- try std.testing.expectEqualStrings("'$PATH'", dollar);
664+ try testing.expectEqualStrings("'$PATH'", dollar);
629665
630666 const sq = try shellQuote(alloc, "it's");
631667 defer alloc.free(sq);
632- try std.testing.expectEqualStrings("'it'\\''s'", sq);
668+ try testing.expectEqualStrings("'it'\\''s'", sq);
633669
634670 const dq = try shellQuote(alloc, "say \"hi\"");
635671 defer alloc.free(dq);
636- try std.testing.expectEqualStrings("'say \"hi\"'", dq);
672+ try testing.expectEqualStrings("'say \"hi\"'", dq);
637673
638674 const both = try shellQuote(alloc, "it's \"cool\"");
639675 defer alloc.free(both);
640- try std.testing.expectEqualStrings("'it'\\''s \"cool\"'", both);
676+ try testing.expectEqualStrings("'it'\\''s \"cool\"'", both);
641677
642678 // just a single quote
643679 const lone_sq = try shellQuote(alloc, "'");
644680 defer alloc.free(lone_sq);
645- try std.testing.expectEqualStrings("''\\'''", lone_sq);
681+ try testing.expectEqualStrings("''\\'''", lone_sq);
646682
647683 // multiple consecutive single quotes
648684 const triple_sq = try shellQuote(alloc, "'''");
649685 defer alloc.free(triple_sq);
650- try std.testing.expectEqualStrings("''\\'''\\'''\\'''", triple_sq);
686+ try testing.expectEqualStrings("''\\'''\\'''\\'''", triple_sq);
651687
652688 // backtick command substitution
653689 const backtick = try shellQuote(alloc, "`whoami`");
654690 defer alloc.free(backtick);
655- try std.testing.expectEqualStrings("'`whoami`'", backtick);
691+ try testing.expectEqualStrings("'`whoami`'", backtick);
656692
657693 // dollar command substitution
658694 const dollar_cmd = try shellQuote(alloc, "$(whoami)");
659695 defer alloc.free(dollar_cmd);
660- try std.testing.expectEqualStrings("'$(whoami)'", dollar_cmd);
696+ try testing.expectEqualStrings("'$(whoami)'", dollar_cmd);
661697
662698 // glob
663699 const glob = try shellQuote(alloc, "*.txt");
664700 defer alloc.free(glob);
665- try std.testing.expectEqualStrings("'*.txt'", glob);
701+ try testing.expectEqualStrings("'*.txt'", glob);
666702
667703 // tilde
668704 const tilde = try shellQuote(alloc, "~/file");
669705 defer alloc.free(tilde);
670- try std.testing.expectEqualStrings("'~/file'", tilde);
706+ try testing.expectEqualStrings("'~/file'", tilde);
671707
672708 // trailing backslash
673709 const trailing_bs = try shellQuote(alloc, "path\\");
674710 defer alloc.free(trailing_bs);
675- try std.testing.expectEqualStrings("'path\\'", trailing_bs);
711+ try testing.expectEqualStrings("'path\\'", trailing_bs);
676712
677713 // semicolon (command injection)
678714 const semi = try shellQuote(alloc, "; rm -rf /");
679715 defer alloc.free(semi);
680- try std.testing.expectEqualStrings("'; rm -rf /'", semi);
716+ try testing.expectEqualStrings("'; rm -rf /'", semi);
681717
682718 // embedded newline
683719 const newline = try shellQuote(alloc, "line1\nline2");
684720 defer alloc.free(newline);
685- try std.testing.expectEqualStrings("'line1\nline2'", newline);
721+ try testing.expectEqualStrings("'line1\nline2'", newline);
686722
687723 // parentheses (subshell)
688724 const parens = try shellQuote(alloc, "(echo hi)");
689725 defer alloc.free(parens);
690- try std.testing.expectEqualStrings("'(echo hi)'", parens);
726+ try testing.expectEqualStrings("'(echo hi)'", parens);
691727
692728 // heredoc marker
693729 const heredoc = try shellQuote(alloc, "<<EOF");
694730 defer alloc.free(heredoc);
695- try std.testing.expectEqualStrings("'<<EOF'", heredoc);
731+ try testing.expectEqualStrings("'<<EOF'", heredoc);
696732
697733 // no quoting needed -- plain word should still be quoted
698734 // (shellQuote is only called when shellNeedsQuoting returns true,
699735 // but verify it produces valid output anyway)
700736 const plain = try shellQuote(alloc, "hello");
701737 defer alloc.free(plain);
702- try std.testing.expectEqualStrings("'hello'", plain);
738+ try testing.expectEqualStrings("'hello'", plain);
703739 }
704740
705741 test "isCtrlBackslash" {
706- const expect = std.testing.expect;
742+ const expect = testing.expect;
707743
708744 // Basic: ctrl only (modifier 5 = 1 + 4)
709745 try expect(isCtrlBackslash("\x1b[92;5u"));
......@@ -794,7 +830,7 @@ test "isCtrlBackslash" {
794830 }
795831
796832 test "serializeTerminalState excludes synchronized output replay" {
797- const alloc = std.testing.allocator;
833+ const alloc = testing.allocator;
798834
799835 var term = try ghostty_vt.Terminal.init(alloc, .{
800836 .cols = 80,
......@@ -809,16 +845,16 @@ test "serializeTerminalState excludes synchronized output replay" {
809845 try stream.nextSlice("\x1b[?2026h"); // Synchronized output
810846 try stream.nextSlice("hello");
811847
812- try std.testing.expect(term.modes.get(.bracketed_paste));
813- try std.testing.expect(term.modes.get(.synchronized_output));
848+ try testing.expect(term.modes.get(.bracketed_paste));
849+ try testing.expect(term.modes.get(.synchronized_output));
814850
815851 const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
816852 defer alloc.free(output);
817853
818854 // The serialized output should contain bracketed paste (DECSET 2004)
819855 // but NOT synchronized output (DECSET 2026)
820- try std.testing.expect(std.mem.indexOf(u8, output, "\x1b[?2004h") != null);
821- try std.testing.expect(std.mem.indexOf(u8, output, "\x1b[?2026h") == null);
856+ try testing.expect(std.mem.indexOf(u8, output, "\x1b[?2004h") != null);
857+ try testing.expect(std.mem.indexOf(u8, output, "\x1b[?2026h") == null);
822858 }
823859
824860 fn testCreateTerminal(alloc: std.mem.Allocator, cols: u16, rows: u16, vt_data: []const u8) !ghostty_vt.Terminal {
......@@ -840,13 +876,13 @@ fn expectScreensMatch(alloc: std.mem.Allocator, expected: *ghostty_vt.Terminal,
840876 defer alloc.free(exp_str);
841877 const act_str = try actual.plainString(alloc);
842878 defer alloc.free(act_str);
843- try std.testing.expectEqualStrings(exp_str, act_str);
879+ try testing.expectEqualStrings(exp_str, act_str);
844880 }
845881
846882 fn expectCursorAt(term: *ghostty_vt.Terminal, row: usize, col: usize) !void {
847883 const cursor = &term.screens.active.cursor;
848- try std.testing.expectEqual(col, cursor.x);
849- try std.testing.expectEqual(row, cursor.y);
884+ try testing.expectEqual(col, cursor.x);
885+ try testing.expectEqual(row, cursor.y);
850886 }
851887
852888 fn serializeRoundtrip(alloc: std.mem.Allocator, source: *ghostty_vt.Terminal) !ghostty_vt.Terminal {
......@@ -872,7 +908,7 @@ fn expectMarkerAtRow(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal, marke
872908 var iter = std.mem.splitScalar(u8, plain, '\n');
873909 while (iter.next()) |line| {
874910 if (std.mem.indexOf(u8, line, marker) != null) {
875- try std.testing.expectEqual(expected_row, row);
911+ try testing.expectEqual(expected_row, row);
876912 return;
877913 }
878914 row += 1;
......@@ -882,7 +918,7 @@ fn expectMarkerAtRow(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal, marke
882918 }
883919
884920 test "serializeTerminalState roundtrip preserves cursor position" {
885- const alloc = std.testing.allocator;
921+ const alloc = testing.allocator;
886922
887923 var term = try testCreateTerminal(alloc, 80, 24, "\x1b[2J" ++ // clear
888924 "\x1b[10;20H" // cursor at row 10, col 20 (1-indexed)
......@@ -898,7 +934,7 @@ test "serializeTerminalState roundtrip preserves cursor position" {
898934 }
899935
900936 test "serializeTerminalState roundtrip preserves CUP-positioned markers" {
901- const alloc = std.testing.allocator;
937+ const alloc = testing.allocator;
902938
903939 var term = try testCreateTerminal(alloc, 80, 24, "\x1b[2J" ++
904940 "\x1b[2;5HMARK_A" ++
......@@ -920,7 +956,7 @@ test "serializeTerminalState roundtrip preserves CUP-positioned markers" {
920956 }
921957
922958 test "serializeTerminalState with scrollback preserves visible content" {
923- const alloc = std.testing.allocator;
959+ const alloc = testing.allocator;
924960
925961 var term = try testCreateTerminal(alloc, 80, 24, "");
926962 defer term.deinit(alloc);
......@@ -945,7 +981,7 @@ test "serializeTerminalState with scrollback preserves visible content" {
945981 // Verify source terminal has scrollback
946982 const pages = &term.screens.active.pages;
947983 const has_scrollback = !pages.getTopLeft(.screen).eql(pages.getTopLeft(.active));
948- try std.testing.expect(has_scrollback);
984+ try testing.expect(has_scrollback);
949985
950986 // Roundtrip: serialize → feed into fresh terminal
951987 var client = try serializeRoundtrip(alloc, &term);
......@@ -962,7 +998,7 @@ test "serializeTerminalState with scrollback preserves visible content" {
962998 test "serializeTerminalState nested roundtrip preserves content" {
963999 // Simulates: inner zmx → serialized state → outer ghostty-vt → serialized again → client
9641000 // This is the exact nested session scenario (zmx → SSH → zmx).
965- const alloc = std.testing.allocator;
1001+ const alloc = testing.allocator;
9661002
9671003 // "Inner" terminal with scrollback + markers
9681004 var inner = try testCreateTerminal(alloc, 80, 24, "");
......@@ -1013,7 +1049,7 @@ test "serializeTerminalState nested roundtrip preserves content" {
10131049 }
10141050
10151051 test "serializeTerminalState alternate screen not leaked" {
1016- const alloc = std.testing.allocator;
1052+ const alloc = testing.allocator;
10171053
10181054 var term = try testCreateTerminal(alloc, 80, 24, "\x1b[?1049h" ++ // enter alt screen
10191055 "\x1b[2J\x1b[3;10HALT_MARK" ++ // write on alt screen
......@@ -1029,12 +1065,12 @@ test "serializeTerminalState alternate screen not leaked" {
10291065
10301066 const plain = try client.plainString(alloc);
10311067 defer alloc.free(plain);
1032- try std.testing.expect(std.mem.indexOf(u8, plain, "ALT_MARK") == null);
1033- try std.testing.expect(std.mem.indexOf(u8, plain, "MAIN_MARK") != null);
1068+ try testing.expect(std.mem.indexOf(u8, plain, "ALT_MARK") == null);
1069+ try testing.expect(std.mem.indexOf(u8, plain, "MAIN_MARK") != null);
10341070 }
10351071
10361072 test "serializeTerminalState size mismatch roundtrip" {
1037- const alloc = std.testing.allocator;
1073+ const alloc = testing.allocator;
10381074
10391075 var term = try testCreateTerminal(alloc, 80, 30, "\x1b[2J" ++
10401076 "\x1b[3;10HSIZE_A" ++
......@@ -1054,7 +1090,7 @@ test "serializeTerminalState size mismatch roundtrip" {
10541090 }
10551091
10561092 test "serializeTerminalState scrollback + size mismatch nested roundtrip" {
1057- const alloc = std.testing.allocator;
1093+ const alloc = testing.allocator;
10581094
10591095 var inner = try testCreateTerminal(alloc, 80, 30, "");
10601096 defer inner.deinit(alloc);
......@@ -1098,3 +1134,147 @@ test "serializeTerminalState scrollback + size mismatch nested roundtrip" {
10981134 try expectScreensMatch(alloc, &inner, &client);
10991135 try expectCursorAt(&client, inner_cursor_y, inner_cursor_x);
11001136 }
1137+
1138+test "isUserInput: printable characters" {
1139+ // Regular text should be detected as user input
1140+ try testing.expect(isUserInput("hello"));
1141+ try testing.expect(isUserInput("Hello World!"));
1142+ try testing.expect(isUserInput("12345"));
1143+ try testing.expect(isUserInput("!@#$%^&*()"));
1144+}
1145+
1146+test "isUserInput: whitespace characters" {
1147+ // Space character is printable
1148+ try testing.expect(isUserInput(" "));
1149+ try testing.expect(isUserInput(" "));
1150+}
1151+
1152+test "isUserInput: line feed (LF)" {
1153+ // LF triggers .execute action
1154+ try testing.expect(isUserInput("\n"));
1155+ try testing.expect(isUserInput("test\n"));
1156+}
1157+
1158+test "isUserInput: carriage return (CR)" {
1159+ // CR triggers .execute action
1160+ try testing.expect(isUserInput("\r"));
1161+ try testing.expect(isUserInput("test\r"));
1162+}
1163+
1164+test "isUserInput: tab" {
1165+ // Tab triggers .execute action
1166+ try testing.expect(isUserInput("\t"));
1167+ try testing.expect(isUserInput("col1\tcol2"));
1168+}
1169+
1170+test "isUserInput: backspace" {
1171+ // Backspace triggers .execute action
1172+ try testing.expect(isUserInput("\x08"));
1173+ try testing.expect(isUserInput("test\x08"));
1174+}
1175+
1176+test "isUserInput: arrow keys (CSI ~)" {
1177+ // Arrow keys use CSI with ~ - these have params
1178+ try testing.expect(isUserInput("\x1b[3~")); // delete
1179+ try testing.expect(isUserInput("\x1b[5~")); // page up
1180+ try testing.expect(isUserInput("\x1b[6~")); // page down
1181+}
1182+
1183+test "isUserInput: modified arrow keys with CSI u" {
1184+ // Modified arrow keys with CSI ... u
1185+ try testing.expect(isUserInput("\x1bOA")); // up with modifier
1186+ try testing.expect(isUserInput("\x1bOB")); // down with modifier
1187+ try testing.expect(isUserInput("\x1bOC")); // right with modifier
1188+ try testing.expect(isUserInput("\x1bOD")); // left with modifier
1189+}
1190+
1191+test "isUserInput: up arrow legacy" {
1192+ // Legacy up arrow: CSI A (with params for kitty-style)
1193+ try testing.expect(isUserInput("\x1b[1;1A")); // kitty-style legacy
1194+}
1195+
1196+test "isUserInput: up arrow kitty" {
1197+ // Kitty keyboard up arrow: CSI 1;1;1A (no colon format supported by parser)
1198+ try testing.expect(isUserInput("\x1b[1;1;1A")); // kitty up arrow
1199+}
1200+
1201+test "isUserInput: arrow keys with modifier params CSI A-D" {
1202+ // Modified arrow keys like Ctrl+Up: CSI 1;5A
1203+ try testing.expect(isUserInput("\x1b[1;5A")); // Ctrl+Up
1204+ try testing.expect(isUserInput("\x1b[1;5B")); // Ctrl+Down
1205+ try testing.expect(isUserInput("\x1b[1;5C")); // Ctrl+Right
1206+ try testing.expect(isUserInput("\x1b[1;5D")); // Ctrl+Left
1207+ try testing.expect(isUserInput("\x1b[1;3A")); // Alt+Up
1208+ try testing.expect(isUserInput("\x1b[1;3B")); // Alt+Down
1209+}
1210+
1211+test "isUserInput: function keys with modifiers CSI 27 ; ~" {
1212+ // Legacy modified keys: CSI 27 ; ... ~
1213+ try testing.expect(isUserInput("\x1b[15;2~")); // F4 with modifier
1214+ try testing.expect(isUserInput("\x1b[17;2~")); // F5 with modifier
1215+ try testing.expect(isUserInput("\x1b[18;2~")); // F6 with modifier
1216+}
1217+
1218+test "isUserInput: enter key" {
1219+ // Enter is LF (0x0A)
1220+ try testing.expect(isUserInput("\x0A"));
1221+}
1222+
1223+test "isUserInput: mixed content" {
1224+ // Mix of printable and control sequences
1225+ try testing.expect(isUserInput("hello\nworld"));
1226+ try testing.expect(isUserInput("\x1b[3~\x1b[6~")); // multiple CSI ~ sequences
1227+ try testing.expect(isUserInput("abc\x1b[3~def")); // text with CSI ~
1228+}
1229+
1230+test "isUserInput: non-user input (escape sequences only)" {
1231+ // Cursor movement without user input
1232+ try testing.expect(!isUserInput("\x1b[2;1H")); // CSI H cursor home
1233+ // SGR color set (no printing)
1234+ try testing.expect(!isUserInput("\x1b[0m"));
1235+ // Cursor position report query
1236+ try testing.expect(!isUserInput("\x1b[6n"));
1237+}
1238+
1239+test "isUserInput: empty string" {
1240+ try testing.expect(!isUserInput(""));
1241+}
1242+
1243+test "isUserInput: only whitespace controls" {
1244+ // Multiple control chars should return true
1245+ try testing.expect(isUserInput("\n\r\t"));
1246+}
1247+
1248+test "isUserInput: kitty keyboard sequences" {
1249+ // Kitty keyboard protocol uses CSI u
1250+ try testing.expect(isUserInput("\x1b[11;2u")); // F1 with modifier
1251+ try testing.expect(isUserInput("\x1b[12;2u")); // F2 with modifier
1252+}
1253+
1254+test "isUserInput: mouse events (CSI M) excluded" {
1255+ // Basic mouse tracking (SGR disabled): CSI M Cb Cx Cy
1256+ // Mouse events should NOT trigger leader switch
1257+ try testing.expect(!isUserInput("\x1b[M@ 0 0")); // button 0, pos 0,0
1258+ try testing.expect(!isUserInput("\x1b[M@ 1 1")); // button 1, pos 1,1
1259+}
1260+
1261+test "isUserInput: mouse events SGR mode CSI < excluded" {
1262+ // SGR extended mouse tracking: CSI < Cb;Cx;Y M
1263+ // Mouse events should NOT trigger leader switch
1264+ try testing.expect(!isUserInput("\x1b[<0;1;1M")); // button release
1265+ try testing.expect(!isUserInput("\x1b[<64;1;1M")); // button press
1266+}
1267+
1268+test "isUserInput: focus events excluded" {
1269+ // Focus in/out are automatic terminal events, not user typing
1270+ try testing.expect(!isUserInput("\x1b[I")); // focus in
1271+ try testing.expect(!isUserInput("\x1b[O")); // focus out
1272+}
1273+
1274+test "isUserInput: bracketed paste included" {
1275+ // Bracketed paste start/end are user-initiated paste operations
1276+ try testing.expect(isUserInput("\x1b[200~")); // paste start
1277+ try testing.expect(isUserInput("\x1b[201~")); // paste end
1278+ // Content between start/end is also user input
1279+ try testing.expect(isUserInput("\x1b[200~hello\x1b[201~"));
1280+}