repos / zmx

session persistence for terminal processes
git clone https://github.com/neurosnap/zmx.git

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
M src/main.zig
+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 
M src/util.zig
+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+}