- commit
- b610ddd
- parent
- af0e1f3
- author
- Eric Bower
- date
- 2026-04-09 11:34:17 -0400 EDT
refactor: set leader by detecting user input with libghostty parser
2 files changed,
+235,
-81
+2,
-28
1@@ -602,36 +602,10 @@ const Daemon = struct {
2 return;
3 }
4
5- // quick check to see if a newline happened so we can set that client to leader
6- // without creating a ghostty vt
7- const isNewline = std.mem.indexOfScalar(u8, payload, '\r') != null;
8- const isUpArrow = std.mem.eql(u8, payload, "\x1b[A") or util.isUpArrow(payload);
9- if (isNewline or isUpArrow) {
10+ // check if leader needs to be updated by detecting any user input
11+ if (util.isUserInput(payload)) {
12 try self.setLeader(client);
13 self.queuePtyInput(payload);
14- return;
15- }
16-
17- // check if leader needs to be updated
18- // this is probably really ineffecient but it was the easiest and most robust way
19- // to strip ansi escape codes and only detect plain text to determine if we need
20- // to set a new leader
21- var termx = try ghostty_vt.Terminal.init(client.alloc, .{
22- .cols = 80,
23- .rows = 24,
24- });
25- defer termx.deinit(client.alloc);
26- var vt_stream = termx.vtStream();
27- defer vt_stream.deinit();
28- try vt_stream.nextSlice(payload);
29- if (util.serializeTerminal(client.alloc, &termx, .plain)) |output| {
30- defer client.alloc.free(output);
31- // if there's no text output then this client is effectively read-only until they type
32- if (output.len > 0) {
33- try self.setLeader(client);
34- // new leader is set to this client so send *entire* payload
35- self.queuePtyInput(payload);
36- }
37 }
38 }
39
+233,
-53
1@@ -3,6 +3,7 @@ const posix = std.posix;
2 const ghostty_vt = @import("ghostty-vt");
3 const ipc = @import("ipc.zig");
4 const socket = @import("socket.zig");
5+const testing = std.testing;
6
7 pub const SessionEntry = struct {
8 name: []const u8,
9@@ -301,6 +302,41 @@ fn parseDecimal(buf: []const u8, pos: *usize) ?u32 {
10 return value;
11 }
12
13+/// Detect if the payload contains user input that should be printed to the screen or
14+/// is a key combination like up-arrow, backspace, enter, ctrl+f, etc.
15+pub fn isUserInput(payload: []const u8) bool {
16+ var parser = ghostty_vt.Parser.init();
17+ for (payload) |c| {
18+ const actions = parser.next(c);
19+ for (actions) |action_opt| {
20+ const action = action_opt orelse continue;
21+ switch (action) {
22+ .print => return true, // printable characters
23+ .csi_dispatch => |csi| {
24+ // kitty keyboard: CSI ... u or CSI ... ~
25+ // legacy modified keys: CSI 27 ; ... ~
26+ // arrow/function keys with modifiers: CSI 1 ; <mod> A-D
27+ if (csi.final == 'u' or csi.final == '~') return true;
28+ // modified arrow keys (e.g., Ctrl+F sends CSI 1;5C in legacy mode)
29+ if (csi.final >= 'A' and csi.final <= 'D' and csi.params.len > 1) return true;
30+ // mouse events: CSI M (basic) or CSI < (SGR extended) - EXCLUDE these
31+ // only intentional keyboard input should trigger leader switch
32+ if (csi.final == 'M' or csi.final == '<') return false;
33+ // focus events: CSI I (focus in) or CSI O (focus out) - EXCLUDE these
34+ // these are automatic terminal events, not user typing
35+ if (csi.final == 'I' or csi.final == 'O') return false;
36+ },
37+ .execute => |code| {
38+ // looking for CR, LF, tab, and backspace
39+ if (code == 0x0D or code == 0x0A or code == 0x09 or code == 0x08) return true;
40+ },
41+ else => {},
42+ }
43+ }
44+ }
45+ return false;
46+}
47+
48 pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 {
49 var builder: std.Io.Writer.Allocating = .init(alloc);
50 defer builder.deinit();
51@@ -586,124 +622,124 @@ test "writeSessionLine formats output for current session and short output" {
52 };
53
54 for (cases) |case| {
55- var builder: std.Io.Writer.Allocating = .init(std.testing.allocator);
56+ var builder: std.Io.Writer.Allocating = .init(testing.allocator);
57 defer builder.deinit();
58
59 try writeSessionLine(&builder.writer, case.session, case.short, case.current_session);
60- try std.testing.expectEqualStrings(case.expected, builder.writer.buffered());
61+ try testing.expectEqualStrings(case.expected, builder.writer.buffered());
62 }
63 }
64
65 test "shellNeedsQuoting" {
66- try std.testing.expect(shellNeedsQuoting(""));
67- try std.testing.expect(shellNeedsQuoting("hello world"));
68- try std.testing.expect(shellNeedsQuoting("hello!"));
69- try std.testing.expect(shellNeedsQuoting("$PATH"));
70- try std.testing.expect(shellNeedsQuoting("it's"));
71- try std.testing.expect(shellNeedsQuoting("a|b"));
72- try std.testing.expect(shellNeedsQuoting("a;b"));
73- try std.testing.expect(!shellNeedsQuoting("hello"));
74- try std.testing.expect(!shellNeedsQuoting("bash"));
75- try std.testing.expect(!shellNeedsQuoting("-c"));
76- try std.testing.expect(!shellNeedsQuoting("/usr/bin/env"));
77+ try testing.expect(shellNeedsQuoting(""));
78+ try testing.expect(shellNeedsQuoting("hello world"));
79+ try testing.expect(shellNeedsQuoting("hello!"));
80+ try testing.expect(shellNeedsQuoting("$PATH"));
81+ try testing.expect(shellNeedsQuoting("it's"));
82+ try testing.expect(shellNeedsQuoting("a|b"));
83+ try testing.expect(shellNeedsQuoting("a;b"));
84+ try testing.expect(!shellNeedsQuoting("hello"));
85+ try testing.expect(!shellNeedsQuoting("bash"));
86+ try testing.expect(!shellNeedsQuoting("-c"));
87+ try testing.expect(!shellNeedsQuoting("/usr/bin/env"));
88 }
89
90 test "shellQuote" {
91- const alloc = std.testing.allocator;
92+ const alloc = testing.allocator;
93
94 const empty = try shellQuote(alloc, "");
95 defer alloc.free(empty);
96- try std.testing.expectEqualStrings("''", empty);
97+ try testing.expectEqualStrings("''", empty);
98
99 const space = try shellQuote(alloc, "hello world");
100 defer alloc.free(space);
101- try std.testing.expectEqualStrings("'hello world'", space);
102+ try testing.expectEqualStrings("'hello world'", space);
103
104 const bang = try shellQuote(alloc, "hello!");
105 defer alloc.free(bang);
106- try std.testing.expectEqualStrings("'hello!'", bang);
107+ try testing.expectEqualStrings("'hello!'", bang);
108
109 const dollar = try shellQuote(alloc, "$PATH");
110 defer alloc.free(dollar);
111- try std.testing.expectEqualStrings("'$PATH'", dollar);
112+ try testing.expectEqualStrings("'$PATH'", dollar);
113
114 const sq = try shellQuote(alloc, "it's");
115 defer alloc.free(sq);
116- try std.testing.expectEqualStrings("'it'\\''s'", sq);
117+ try testing.expectEqualStrings("'it'\\''s'", sq);
118
119 const dq = try shellQuote(alloc, "say \"hi\"");
120 defer alloc.free(dq);
121- try std.testing.expectEqualStrings("'say \"hi\"'", dq);
122+ try testing.expectEqualStrings("'say \"hi\"'", dq);
123
124 const both = try shellQuote(alloc, "it's \"cool\"");
125 defer alloc.free(both);
126- try std.testing.expectEqualStrings("'it'\\''s \"cool\"'", both);
127+ try testing.expectEqualStrings("'it'\\''s \"cool\"'", both);
128
129 // just a single quote
130 const lone_sq = try shellQuote(alloc, "'");
131 defer alloc.free(lone_sq);
132- try std.testing.expectEqualStrings("''\\'''", lone_sq);
133+ try testing.expectEqualStrings("''\\'''", lone_sq);
134
135 // multiple consecutive single quotes
136 const triple_sq = try shellQuote(alloc, "'''");
137 defer alloc.free(triple_sq);
138- try std.testing.expectEqualStrings("''\\'''\\'''\\'''", triple_sq);
139+ try testing.expectEqualStrings("''\\'''\\'''\\'''", triple_sq);
140
141 // backtick command substitution
142 const backtick = try shellQuote(alloc, "`whoami`");
143 defer alloc.free(backtick);
144- try std.testing.expectEqualStrings("'`whoami`'", backtick);
145+ try testing.expectEqualStrings("'`whoami`'", backtick);
146
147 // dollar command substitution
148 const dollar_cmd = try shellQuote(alloc, "$(whoami)");
149 defer alloc.free(dollar_cmd);
150- try std.testing.expectEqualStrings("'$(whoami)'", dollar_cmd);
151+ try testing.expectEqualStrings("'$(whoami)'", dollar_cmd);
152
153 // glob
154 const glob = try shellQuote(alloc, "*.txt");
155 defer alloc.free(glob);
156- try std.testing.expectEqualStrings("'*.txt'", glob);
157+ try testing.expectEqualStrings("'*.txt'", glob);
158
159 // tilde
160 const tilde = try shellQuote(alloc, "~/file");
161 defer alloc.free(tilde);
162- try std.testing.expectEqualStrings("'~/file'", tilde);
163+ try testing.expectEqualStrings("'~/file'", tilde);
164
165 // trailing backslash
166 const trailing_bs = try shellQuote(alloc, "path\\");
167 defer alloc.free(trailing_bs);
168- try std.testing.expectEqualStrings("'path\\'", trailing_bs);
169+ try testing.expectEqualStrings("'path\\'", trailing_bs);
170
171 // semicolon (command injection)
172 const semi = try shellQuote(alloc, "; rm -rf /");
173 defer alloc.free(semi);
174- try std.testing.expectEqualStrings("'; rm -rf /'", semi);
175+ try testing.expectEqualStrings("'; rm -rf /'", semi);
176
177 // embedded newline
178 const newline = try shellQuote(alloc, "line1\nline2");
179 defer alloc.free(newline);
180- try std.testing.expectEqualStrings("'line1\nline2'", newline);
181+ try testing.expectEqualStrings("'line1\nline2'", newline);
182
183 // parentheses (subshell)
184 const parens = try shellQuote(alloc, "(echo hi)");
185 defer alloc.free(parens);
186- try std.testing.expectEqualStrings("'(echo hi)'", parens);
187+ try testing.expectEqualStrings("'(echo hi)'", parens);
188
189 // heredoc marker
190 const heredoc = try shellQuote(alloc, "<<EOF");
191 defer alloc.free(heredoc);
192- try std.testing.expectEqualStrings("'<<EOF'", heredoc);
193+ try testing.expectEqualStrings("'<<EOF'", heredoc);
194
195 // no quoting needed -- plain word should still be quoted
196 // (shellQuote is only called when shellNeedsQuoting returns true,
197 // but verify it produces valid output anyway)
198 const plain = try shellQuote(alloc, "hello");
199 defer alloc.free(plain);
200- try std.testing.expectEqualStrings("'hello'", plain);
201+ try testing.expectEqualStrings("'hello'", plain);
202 }
203
204 test "isCtrlBackslash" {
205- const expect = std.testing.expect;
206+ const expect = testing.expect;
207
208 // Basic: ctrl only (modifier 5 = 1 + 4)
209 try expect(isCtrlBackslash("\x1b[92;5u"));
210@@ -794,7 +830,7 @@ test "isCtrlBackslash" {
211 }
212
213 test "serializeTerminalState excludes synchronized output replay" {
214- const alloc = std.testing.allocator;
215+ const alloc = testing.allocator;
216
217 var term = try ghostty_vt.Terminal.init(alloc, .{
218 .cols = 80,
219@@ -809,16 +845,16 @@ test "serializeTerminalState excludes synchronized output replay" {
220 try stream.nextSlice("\x1b[?2026h"); // Synchronized output
221 try stream.nextSlice("hello");
222
223- try std.testing.expect(term.modes.get(.bracketed_paste));
224- try std.testing.expect(term.modes.get(.synchronized_output));
225+ try testing.expect(term.modes.get(.bracketed_paste));
226+ try testing.expect(term.modes.get(.synchronized_output));
227
228 const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
229 defer alloc.free(output);
230
231 // The serialized output should contain bracketed paste (DECSET 2004)
232 // but NOT synchronized output (DECSET 2026)
233- try std.testing.expect(std.mem.indexOf(u8, output, "\x1b[?2004h") != null);
234- try std.testing.expect(std.mem.indexOf(u8, output, "\x1b[?2026h") == null);
235+ try testing.expect(std.mem.indexOf(u8, output, "\x1b[?2004h") != null);
236+ try testing.expect(std.mem.indexOf(u8, output, "\x1b[?2026h") == null);
237 }
238
239 fn testCreateTerminal(alloc: std.mem.Allocator, cols: u16, rows: u16, vt_data: []const u8) !ghostty_vt.Terminal {
240@@ -840,13 +876,13 @@ fn expectScreensMatch(alloc: std.mem.Allocator, expected: *ghostty_vt.Terminal,
241 defer alloc.free(exp_str);
242 const act_str = try actual.plainString(alloc);
243 defer alloc.free(act_str);
244- try std.testing.expectEqualStrings(exp_str, act_str);
245+ try testing.expectEqualStrings(exp_str, act_str);
246 }
247
248 fn expectCursorAt(term: *ghostty_vt.Terminal, row: usize, col: usize) !void {
249 const cursor = &term.screens.active.cursor;
250- try std.testing.expectEqual(col, cursor.x);
251- try std.testing.expectEqual(row, cursor.y);
252+ try testing.expectEqual(col, cursor.x);
253+ try testing.expectEqual(row, cursor.y);
254 }
255
256 fn serializeRoundtrip(alloc: std.mem.Allocator, source: *ghostty_vt.Terminal) !ghostty_vt.Terminal {
257@@ -872,7 +908,7 @@ fn expectMarkerAtRow(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal, marke
258 var iter = std.mem.splitScalar(u8, plain, '\n');
259 while (iter.next()) |line| {
260 if (std.mem.indexOf(u8, line, marker) != null) {
261- try std.testing.expectEqual(expected_row, row);
262+ try testing.expectEqual(expected_row, row);
263 return;
264 }
265 row += 1;
266@@ -882,7 +918,7 @@ fn expectMarkerAtRow(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal, marke
267 }
268
269 test "serializeTerminalState roundtrip preserves cursor position" {
270- const alloc = std.testing.allocator;
271+ const alloc = testing.allocator;
272
273 var term = try testCreateTerminal(alloc, 80, 24, "\x1b[2J" ++ // clear
274 "\x1b[10;20H" // cursor at row 10, col 20 (1-indexed)
275@@ -898,7 +934,7 @@ test "serializeTerminalState roundtrip preserves cursor position" {
276 }
277
278 test "serializeTerminalState roundtrip preserves CUP-positioned markers" {
279- const alloc = std.testing.allocator;
280+ const alloc = testing.allocator;
281
282 var term = try testCreateTerminal(alloc, 80, 24, "\x1b[2J" ++
283 "\x1b[2;5HMARK_A" ++
284@@ -920,7 +956,7 @@ test "serializeTerminalState roundtrip preserves CUP-positioned markers" {
285 }
286
287 test "serializeTerminalState with scrollback preserves visible content" {
288- const alloc = std.testing.allocator;
289+ const alloc = testing.allocator;
290
291 var term = try testCreateTerminal(alloc, 80, 24, "");
292 defer term.deinit(alloc);
293@@ -945,7 +981,7 @@ test "serializeTerminalState with scrollback preserves visible content" {
294 // Verify source terminal has scrollback
295 const pages = &term.screens.active.pages;
296 const has_scrollback = !pages.getTopLeft(.screen).eql(pages.getTopLeft(.active));
297- try std.testing.expect(has_scrollback);
298+ try testing.expect(has_scrollback);
299
300 // Roundtrip: serialize → feed into fresh terminal
301 var client = try serializeRoundtrip(alloc, &term);
302@@ -962,7 +998,7 @@ test "serializeTerminalState with scrollback preserves visible content" {
303 test "serializeTerminalState nested roundtrip preserves content" {
304 // Simulates: inner zmx → serialized state → outer ghostty-vt → serialized again → client
305 // This is the exact nested session scenario (zmx → SSH → zmx).
306- const alloc = std.testing.allocator;
307+ const alloc = testing.allocator;
308
309 // "Inner" terminal with scrollback + markers
310 var inner = try testCreateTerminal(alloc, 80, 24, "");
311@@ -1013,7 +1049,7 @@ test "serializeTerminalState nested roundtrip preserves content" {
312 }
313
314 test "serializeTerminalState alternate screen not leaked" {
315- const alloc = std.testing.allocator;
316+ const alloc = testing.allocator;
317
318 var term = try testCreateTerminal(alloc, 80, 24, "\x1b[?1049h" ++ // enter alt screen
319 "\x1b[2J\x1b[3;10HALT_MARK" ++ // write on alt screen
320@@ -1029,12 +1065,12 @@ test "serializeTerminalState alternate screen not leaked" {
321
322 const plain = try client.plainString(alloc);
323 defer alloc.free(plain);
324- try std.testing.expect(std.mem.indexOf(u8, plain, "ALT_MARK") == null);
325- try std.testing.expect(std.mem.indexOf(u8, plain, "MAIN_MARK") != null);
326+ try testing.expect(std.mem.indexOf(u8, plain, "ALT_MARK") == null);
327+ try testing.expect(std.mem.indexOf(u8, plain, "MAIN_MARK") != null);
328 }
329
330 test "serializeTerminalState size mismatch roundtrip" {
331- const alloc = std.testing.allocator;
332+ const alloc = testing.allocator;
333
334 var term = try testCreateTerminal(alloc, 80, 30, "\x1b[2J" ++
335 "\x1b[3;10HSIZE_A" ++
336@@ -1054,7 +1090,7 @@ test "serializeTerminalState size mismatch roundtrip" {
337 }
338
339 test "serializeTerminalState scrollback + size mismatch nested roundtrip" {
340- const alloc = std.testing.allocator;
341+ const alloc = testing.allocator;
342
343 var inner = try testCreateTerminal(alloc, 80, 30, "");
344 defer inner.deinit(alloc);
345@@ -1098,3 +1134,147 @@ test "serializeTerminalState scrollback + size mismatch nested roundtrip" {
346 try expectScreensMatch(alloc, &inner, &client);
347 try expectCursorAt(&client, inner_cursor_y, inner_cursor_x);
348 }
349+
350+test "isUserInput: printable characters" {
351+ // Regular text should be detected as user input
352+ try testing.expect(isUserInput("hello"));
353+ try testing.expect(isUserInput("Hello World!"));
354+ try testing.expect(isUserInput("12345"));
355+ try testing.expect(isUserInput("!@#$%^&*()"));
356+}
357+
358+test "isUserInput: whitespace characters" {
359+ // Space character is printable
360+ try testing.expect(isUserInput(" "));
361+ try testing.expect(isUserInput(" "));
362+}
363+
364+test "isUserInput: line feed (LF)" {
365+ // LF triggers .execute action
366+ try testing.expect(isUserInput("\n"));
367+ try testing.expect(isUserInput("test\n"));
368+}
369+
370+test "isUserInput: carriage return (CR)" {
371+ // CR triggers .execute action
372+ try testing.expect(isUserInput("\r"));
373+ try testing.expect(isUserInput("test\r"));
374+}
375+
376+test "isUserInput: tab" {
377+ // Tab triggers .execute action
378+ try testing.expect(isUserInput("\t"));
379+ try testing.expect(isUserInput("col1\tcol2"));
380+}
381+
382+test "isUserInput: backspace" {
383+ // Backspace triggers .execute action
384+ try testing.expect(isUserInput("\x08"));
385+ try testing.expect(isUserInput("test\x08"));
386+}
387+
388+test "isUserInput: arrow keys (CSI ~)" {
389+ // Arrow keys use CSI with ~ - these have params
390+ try testing.expect(isUserInput("\x1b[3~")); // delete
391+ try testing.expect(isUserInput("\x1b[5~")); // page up
392+ try testing.expect(isUserInput("\x1b[6~")); // page down
393+}
394+
395+test "isUserInput: modified arrow keys with CSI u" {
396+ // Modified arrow keys with CSI ... u
397+ try testing.expect(isUserInput("\x1bOA")); // up with modifier
398+ try testing.expect(isUserInput("\x1bOB")); // down with modifier
399+ try testing.expect(isUserInput("\x1bOC")); // right with modifier
400+ try testing.expect(isUserInput("\x1bOD")); // left with modifier
401+}
402+
403+test "isUserInput: up arrow legacy" {
404+ // Legacy up arrow: CSI A (with params for kitty-style)
405+ try testing.expect(isUserInput("\x1b[1;1A")); // kitty-style legacy
406+}
407+
408+test "isUserInput: up arrow kitty" {
409+ // Kitty keyboard up arrow: CSI 1;1;1A (no colon format supported by parser)
410+ try testing.expect(isUserInput("\x1b[1;1;1A")); // kitty up arrow
411+}
412+
413+test "isUserInput: arrow keys with modifier params CSI A-D" {
414+ // Modified arrow keys like Ctrl+Up: CSI 1;5A
415+ try testing.expect(isUserInput("\x1b[1;5A")); // Ctrl+Up
416+ try testing.expect(isUserInput("\x1b[1;5B")); // Ctrl+Down
417+ try testing.expect(isUserInput("\x1b[1;5C")); // Ctrl+Right
418+ try testing.expect(isUserInput("\x1b[1;5D")); // Ctrl+Left
419+ try testing.expect(isUserInput("\x1b[1;3A")); // Alt+Up
420+ try testing.expect(isUserInput("\x1b[1;3B")); // Alt+Down
421+}
422+
423+test "isUserInput: function keys with modifiers CSI 27 ; ~" {
424+ // Legacy modified keys: CSI 27 ; ... ~
425+ try testing.expect(isUserInput("\x1b[15;2~")); // F4 with modifier
426+ try testing.expect(isUserInput("\x1b[17;2~")); // F5 with modifier
427+ try testing.expect(isUserInput("\x1b[18;2~")); // F6 with modifier
428+}
429+
430+test "isUserInput: enter key" {
431+ // Enter is LF (0x0A)
432+ try testing.expect(isUserInput("\x0A"));
433+}
434+
435+test "isUserInput: mixed content" {
436+ // Mix of printable and control sequences
437+ try testing.expect(isUserInput("hello\nworld"));
438+ try testing.expect(isUserInput("\x1b[3~\x1b[6~")); // multiple CSI ~ sequences
439+ try testing.expect(isUserInput("abc\x1b[3~def")); // text with CSI ~
440+}
441+
442+test "isUserInput: non-user input (escape sequences only)" {
443+ // Cursor movement without user input
444+ try testing.expect(!isUserInput("\x1b[2;1H")); // CSI H cursor home
445+ // SGR color set (no printing)
446+ try testing.expect(!isUserInput("\x1b[0m"));
447+ // Cursor position report query
448+ try testing.expect(!isUserInput("\x1b[6n"));
449+}
450+
451+test "isUserInput: empty string" {
452+ try testing.expect(!isUserInput(""));
453+}
454+
455+test "isUserInput: only whitespace controls" {
456+ // Multiple control chars should return true
457+ try testing.expect(isUserInput("\n\r\t"));
458+}
459+
460+test "isUserInput: kitty keyboard sequences" {
461+ // Kitty keyboard protocol uses CSI u
462+ try testing.expect(isUserInput("\x1b[11;2u")); // F1 with modifier
463+ try testing.expect(isUserInput("\x1b[12;2u")); // F2 with modifier
464+}
465+
466+test "isUserInput: mouse events (CSI M) excluded" {
467+ // Basic mouse tracking (SGR disabled): CSI M Cb Cx Cy
468+ // Mouse events should NOT trigger leader switch
469+ try testing.expect(!isUserInput("\x1b[M@ 0 0")); // button 0, pos 0,0
470+ try testing.expect(!isUserInput("\x1b[M@ 1 1")); // button 1, pos 1,1
471+}
472+
473+test "isUserInput: mouse events SGR mode CSI < excluded" {
474+ // SGR extended mouse tracking: CSI < Cb;Cx;Y M
475+ // Mouse events should NOT trigger leader switch
476+ try testing.expect(!isUserInput("\x1b[<0;1;1M")); // button release
477+ try testing.expect(!isUserInput("\x1b[<64;1;1M")); // button press
478+}
479+
480+test "isUserInput: focus events excluded" {
481+ // Focus in/out are automatic terminal events, not user typing
482+ try testing.expect(!isUserInput("\x1b[I")); // focus in
483+ try testing.expect(!isUserInput("\x1b[O")); // focus out
484+}
485+
486+test "isUserInput: bracketed paste included" {
487+ // Bracketed paste start/end are user-initiated paste operations
488+ try testing.expect(isUserInput("\x1b[200~")); // paste start
489+ try testing.expect(isUserInput("\x1b[201~")); // paste end
490+ // Content between start/end is also user input
491+ try testing.expect(isUserInput("\x1b[200~hello\x1b[201~"));
492+}