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 { | |
| 602 | 602 | return; | |
| 603 | 603 | } | |
| 604 | 604 | ||
| 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)) { | |
| 610 | 607 | try self.setLeader(client); | |
| 611 | 608 | 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 | - | } | |
| 635 | 609 | } | |
| 636 | 610 | } | |
| 637 | 611 |
+233,
-53
| ... | ... | @@ -3,6 +3,7 @@ const posix = std.posix; | |
| 3 | 3 | const ghostty_vt = @import("ghostty-vt"); | |
| 4 | 4 | const ipc = @import("ipc.zig"); | |
| 5 | 5 | const socket = @import("socket.zig"); | |
| 6 | + | const testing = std.testing; | |
| 6 | 7 | ||
| 7 | 8 | pub const SessionEntry = struct { | |
| 8 | 9 | name: []const u8, |
| ... | ... | @@ -301,6 +302,41 @@ fn parseDecimal(buf: []const u8, pos: *usize) ?u32 { | |
| 301 | 302 | return value; | |
| 302 | 303 | } | |
| 303 | 304 | ||
| 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 | + | ||
| 304 | 340 | pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 { | |
| 305 | 341 | var builder: std.Io.Writer.Allocating = .init(alloc); | |
| 306 | 342 | defer builder.deinit(); |
| ... | ... | @@ -586,124 +622,124 @@ test "writeSessionLine formats output for current session and short output" { | |
| 586 | 622 | }; | |
| 587 | 623 | ||
| 588 | 624 | for (cases) |case| { | |
| 589 | - | var builder: std.Io.Writer.Allocating = .init(std.testing.allocator); | |
| 625 | + | var builder: std.Io.Writer.Allocating = .init(testing.allocator); | |
| 590 | 626 | defer builder.deinit(); | |
| 591 | 627 | ||
| 592 | 628 | 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()); | |
| 594 | 630 | } | |
| 595 | 631 | } | |
| 596 | 632 | ||
| 597 | 633 | 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")); | |
| 609 | 645 | } | |
| 610 | 646 | ||
| 611 | 647 | test "shellQuote" { | |
| 612 | - | const alloc = std.testing.allocator; | |
| 648 | + | const alloc = testing.allocator; | |
| 613 | 649 | ||
| 614 | 650 | const empty = try shellQuote(alloc, ""); | |
| 615 | 651 | defer alloc.free(empty); | |
| 616 | - | try std.testing.expectEqualStrings("''", empty); | |
| 652 | + | try testing.expectEqualStrings("''", empty); | |
| 617 | 653 | ||
| 618 | 654 | const space = try shellQuote(alloc, "hello world"); | |
| 619 | 655 | defer alloc.free(space); | |
| 620 | - | try std.testing.expectEqualStrings("'hello world'", space); | |
| 656 | + | try testing.expectEqualStrings("'hello world'", space); | |
| 621 | 657 | ||
| 622 | 658 | const bang = try shellQuote(alloc, "hello!"); | |
| 623 | 659 | defer alloc.free(bang); | |
| 624 | - | try std.testing.expectEqualStrings("'hello!'", bang); | |
| 660 | + | try testing.expectEqualStrings("'hello!'", bang); | |
| 625 | 661 | ||
| 626 | 662 | const dollar = try shellQuote(alloc, "$PATH"); | |
| 627 | 663 | defer alloc.free(dollar); | |
| 628 | - | try std.testing.expectEqualStrings("'$PATH'", dollar); | |
| 664 | + | try testing.expectEqualStrings("'$PATH'", dollar); | |
| 629 | 665 | ||
| 630 | 666 | const sq = try shellQuote(alloc, "it's"); | |
| 631 | 667 | defer alloc.free(sq); | |
| 632 | - | try std.testing.expectEqualStrings("'it'\\''s'", sq); | |
| 668 | + | try testing.expectEqualStrings("'it'\\''s'", sq); | |
| 633 | 669 | ||
| 634 | 670 | const dq = try shellQuote(alloc, "say \"hi\""); | |
| 635 | 671 | defer alloc.free(dq); | |
| 636 | - | try std.testing.expectEqualStrings("'say \"hi\"'", dq); | |
| 672 | + | try testing.expectEqualStrings("'say \"hi\"'", dq); | |
| 637 | 673 | ||
| 638 | 674 | const both = try shellQuote(alloc, "it's \"cool\""); | |
| 639 | 675 | defer alloc.free(both); | |
| 640 | - | try std.testing.expectEqualStrings("'it'\\''s \"cool\"'", both); | |
| 676 | + | try testing.expectEqualStrings("'it'\\''s \"cool\"'", both); | |
| 641 | 677 | ||
| 642 | 678 | // just a single quote | |
| 643 | 679 | const lone_sq = try shellQuote(alloc, "'"); | |
| 644 | 680 | defer alloc.free(lone_sq); | |
| 645 | - | try std.testing.expectEqualStrings("''\\'''", lone_sq); | |
| 681 | + | try testing.expectEqualStrings("''\\'''", lone_sq); | |
| 646 | 682 | ||
| 647 | 683 | // multiple consecutive single quotes | |
| 648 | 684 | const triple_sq = try shellQuote(alloc, "'''"); | |
| 649 | 685 | defer alloc.free(triple_sq); | |
| 650 | - | try std.testing.expectEqualStrings("''\\'''\\'''\\'''", triple_sq); | |
| 686 | + | try testing.expectEqualStrings("''\\'''\\'''\\'''", triple_sq); | |
| 651 | 687 | ||
| 652 | 688 | // backtick command substitution | |
| 653 | 689 | const backtick = try shellQuote(alloc, "`whoami`"); | |
| 654 | 690 | defer alloc.free(backtick); | |
| 655 | - | try std.testing.expectEqualStrings("'`whoami`'", backtick); | |
| 691 | + | try testing.expectEqualStrings("'`whoami`'", backtick); | |
| 656 | 692 | ||
| 657 | 693 | // dollar command substitution | |
| 658 | 694 | const dollar_cmd = try shellQuote(alloc, "$(whoami)"); | |
| 659 | 695 | defer alloc.free(dollar_cmd); | |
| 660 | - | try std.testing.expectEqualStrings("'$(whoami)'", dollar_cmd); | |
| 696 | + | try testing.expectEqualStrings("'$(whoami)'", dollar_cmd); | |
| 661 | 697 | ||
| 662 | 698 | // glob | |
| 663 | 699 | const glob = try shellQuote(alloc, "*.txt"); | |
| 664 | 700 | defer alloc.free(glob); | |
| 665 | - | try std.testing.expectEqualStrings("'*.txt'", glob); | |
| 701 | + | try testing.expectEqualStrings("'*.txt'", glob); | |
| 666 | 702 | ||
| 667 | 703 | // tilde | |
| 668 | 704 | const tilde = try shellQuote(alloc, "~/file"); | |
| 669 | 705 | defer alloc.free(tilde); | |
| 670 | - | try std.testing.expectEqualStrings("'~/file'", tilde); | |
| 706 | + | try testing.expectEqualStrings("'~/file'", tilde); | |
| 671 | 707 | ||
| 672 | 708 | // trailing backslash | |
| 673 | 709 | const trailing_bs = try shellQuote(alloc, "path\\"); | |
| 674 | 710 | defer alloc.free(trailing_bs); | |
| 675 | - | try std.testing.expectEqualStrings("'path\\'", trailing_bs); | |
| 711 | + | try testing.expectEqualStrings("'path\\'", trailing_bs); | |
| 676 | 712 | ||
| 677 | 713 | // semicolon (command injection) | |
| 678 | 714 | const semi = try shellQuote(alloc, "; rm -rf /"); | |
| 679 | 715 | defer alloc.free(semi); | |
| 680 | - | try std.testing.expectEqualStrings("'; rm -rf /'", semi); | |
| 716 | + | try testing.expectEqualStrings("'; rm -rf /'", semi); | |
| 681 | 717 | ||
| 682 | 718 | // embedded newline | |
| 683 | 719 | const newline = try shellQuote(alloc, "line1\nline2"); | |
| 684 | 720 | defer alloc.free(newline); | |
| 685 | - | try std.testing.expectEqualStrings("'line1\nline2'", newline); | |
| 721 | + | try testing.expectEqualStrings("'line1\nline2'", newline); | |
| 686 | 722 | ||
| 687 | 723 | // parentheses (subshell) | |
| 688 | 724 | const parens = try shellQuote(alloc, "(echo hi)"); | |
| 689 | 725 | defer alloc.free(parens); | |
| 690 | - | try std.testing.expectEqualStrings("'(echo hi)'", parens); | |
| 726 | + | try testing.expectEqualStrings("'(echo hi)'", parens); | |
| 691 | 727 | ||
| 692 | 728 | // heredoc marker | |
| 693 | 729 | const heredoc = try shellQuote(alloc, "<<EOF"); | |
| 694 | 730 | defer alloc.free(heredoc); | |
| 695 | - | try std.testing.expectEqualStrings("'<<EOF'", heredoc); | |
| 731 | + | try testing.expectEqualStrings("'<<EOF'", heredoc); | |
| 696 | 732 | ||
| 697 | 733 | // no quoting needed -- plain word should still be quoted | |
| 698 | 734 | // (shellQuote is only called when shellNeedsQuoting returns true, | |
| 699 | 735 | // but verify it produces valid output anyway) | |
| 700 | 736 | const plain = try shellQuote(alloc, "hello"); | |
| 701 | 737 | defer alloc.free(plain); | |
| 702 | - | try std.testing.expectEqualStrings("'hello'", plain); | |
| 738 | + | try testing.expectEqualStrings("'hello'", plain); | |
| 703 | 739 | } | |
| 704 | 740 | ||
| 705 | 741 | test "isCtrlBackslash" { | |
| 706 | - | const expect = std.testing.expect; | |
| 742 | + | const expect = testing.expect; | |
| 707 | 743 | ||
| 708 | 744 | // Basic: ctrl only (modifier 5 = 1 + 4) | |
| 709 | 745 | try expect(isCtrlBackslash("\x1b[92;5u")); |
| ... | ... | @@ -794,7 +830,7 @@ test "isCtrlBackslash" { | |
| 794 | 830 | } | |
| 795 | 831 | ||
| 796 | 832 | test "serializeTerminalState excludes synchronized output replay" { | |
| 797 | - | const alloc = std.testing.allocator; | |
| 833 | + | const alloc = testing.allocator; | |
| 798 | 834 | ||
| 799 | 835 | var term = try ghostty_vt.Terminal.init(alloc, .{ | |
| 800 | 836 | .cols = 80, |
| ... | ... | @@ -809,16 +845,16 @@ test "serializeTerminalState excludes synchronized output replay" { | |
| 809 | 845 | try stream.nextSlice("\x1b[?2026h"); // Synchronized output | |
| 810 | 846 | try stream.nextSlice("hello"); | |
| 811 | 847 | ||
| 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)); | |
| 814 | 850 | ||
| 815 | 851 | const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull; | |
| 816 | 852 | defer alloc.free(output); | |
| 817 | 853 | ||
| 818 | 854 | // The serialized output should contain bracketed paste (DECSET 2004) | |
| 819 | 855 | // 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); | |
| 822 | 858 | } | |
| 823 | 859 | ||
| 824 | 860 | 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, | |
| 840 | 876 | defer alloc.free(exp_str); | |
| 841 | 877 | const act_str = try actual.plainString(alloc); | |
| 842 | 878 | defer alloc.free(act_str); | |
| 843 | - | try std.testing.expectEqualStrings(exp_str, act_str); | |
| 879 | + | try testing.expectEqualStrings(exp_str, act_str); | |
| 844 | 880 | } | |
| 845 | 881 | ||
| 846 | 882 | fn expectCursorAt(term: *ghostty_vt.Terminal, row: usize, col: usize) !void { | |
| 847 | 883 | 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); | |
| 850 | 886 | } | |
| 851 | 887 | ||
| 852 | 888 | 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 | |
| 872 | 908 | var iter = std.mem.splitScalar(u8, plain, '\n'); | |
| 873 | 909 | while (iter.next()) |line| { | |
| 874 | 910 | if (std.mem.indexOf(u8, line, marker) != null) { | |
| 875 | - | try std.testing.expectEqual(expected_row, row); | |
| 911 | + | try testing.expectEqual(expected_row, row); | |
| 876 | 912 | return; | |
| 877 | 913 | } | |
| 878 | 914 | row += 1; |
| ... | ... | @@ -882,7 +918,7 @@ fn expectMarkerAtRow(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal, marke | |
| 882 | 918 | } | |
| 883 | 919 | ||
| 884 | 920 | test "serializeTerminalState roundtrip preserves cursor position" { | |
| 885 | - | const alloc = std.testing.allocator; | |
| 921 | + | const alloc = testing.allocator; | |
| 886 | 922 | ||
| 887 | 923 | var term = try testCreateTerminal(alloc, 80, 24, "\x1b[2J" ++ // clear | |
| 888 | 924 | "\x1b[10;20H" // cursor at row 10, col 20 (1-indexed) |
| ... | ... | @@ -898,7 +934,7 @@ test "serializeTerminalState roundtrip preserves cursor position" { | |
| 898 | 934 | } | |
| 899 | 935 | ||
| 900 | 936 | test "serializeTerminalState roundtrip preserves CUP-positioned markers" { | |
| 901 | - | const alloc = std.testing.allocator; | |
| 937 | + | const alloc = testing.allocator; | |
| 902 | 938 | ||
| 903 | 939 | var term = try testCreateTerminal(alloc, 80, 24, "\x1b[2J" ++ | |
| 904 | 940 | "\x1b[2;5HMARK_A" ++ |
| ... | ... | @@ -920,7 +956,7 @@ test "serializeTerminalState roundtrip preserves CUP-positioned markers" { | |
| 920 | 956 | } | |
| 921 | 957 | ||
| 922 | 958 | test "serializeTerminalState with scrollback preserves visible content" { | |
| 923 | - | const alloc = std.testing.allocator; | |
| 959 | + | const alloc = testing.allocator; | |
| 924 | 960 | ||
| 925 | 961 | var term = try testCreateTerminal(alloc, 80, 24, ""); | |
| 926 | 962 | defer term.deinit(alloc); |
| ... | ... | @@ -945,7 +981,7 @@ test "serializeTerminalState with scrollback preserves visible content" { | |
| 945 | 981 | // Verify source terminal has scrollback | |
| 946 | 982 | const pages = &term.screens.active.pages; | |
| 947 | 983 | const has_scrollback = !pages.getTopLeft(.screen).eql(pages.getTopLeft(.active)); | |
| 948 | - | try std.testing.expect(has_scrollback); | |
| 984 | + | try testing.expect(has_scrollback); | |
| 949 | 985 | ||
| 950 | 986 | // Roundtrip: serialize → feed into fresh terminal | |
| 951 | 987 | var client = try serializeRoundtrip(alloc, &term); |
| ... | ... | @@ -962,7 +998,7 @@ test "serializeTerminalState with scrollback preserves visible content" { | |
| 962 | 998 | test "serializeTerminalState nested roundtrip preserves content" { | |
| 963 | 999 | // Simulates: inner zmx → serialized state → outer ghostty-vt → serialized again → client | |
| 964 | 1000 | // This is the exact nested session scenario (zmx → SSH → zmx). | |
| 965 | - | const alloc = std.testing.allocator; | |
| 1001 | + | const alloc = testing.allocator; | |
| 966 | 1002 | ||
| 967 | 1003 | // "Inner" terminal with scrollback + markers | |
| 968 | 1004 | var inner = try testCreateTerminal(alloc, 80, 24, ""); |
| ... | ... | @@ -1013,7 +1049,7 @@ test "serializeTerminalState nested roundtrip preserves content" { | |
| 1013 | 1049 | } | |
| 1014 | 1050 | ||
| 1015 | 1051 | test "serializeTerminalState alternate screen not leaked" { | |
| 1016 | - | const alloc = std.testing.allocator; | |
| 1052 | + | const alloc = testing.allocator; | |
| 1017 | 1053 | ||
| 1018 | 1054 | var term = try testCreateTerminal(alloc, 80, 24, "\x1b[?1049h" ++ // enter alt screen | |
| 1019 | 1055 | "\x1b[2J\x1b[3;10HALT_MARK" ++ // write on alt screen |
| ... | ... | @@ -1029,12 +1065,12 @@ test "serializeTerminalState alternate screen not leaked" { | |
| 1029 | 1065 | ||
| 1030 | 1066 | const plain = try client.plainString(alloc); | |
| 1031 | 1067 | 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); | |
| 1034 | 1070 | } | |
| 1035 | 1071 | ||
| 1036 | 1072 | test "serializeTerminalState size mismatch roundtrip" { | |
| 1037 | - | const alloc = std.testing.allocator; | |
| 1073 | + | const alloc = testing.allocator; | |
| 1038 | 1074 | ||
| 1039 | 1075 | var term = try testCreateTerminal(alloc, 80, 30, "\x1b[2J" ++ | |
| 1040 | 1076 | "\x1b[3;10HSIZE_A" ++ |
| ... | ... | @@ -1054,7 +1090,7 @@ test "serializeTerminalState size mismatch roundtrip" { | |
| 1054 | 1090 | } | |
| 1055 | 1091 | ||
| 1056 | 1092 | test "serializeTerminalState scrollback + size mismatch nested roundtrip" { | |
| 1057 | - | const alloc = std.testing.allocator; | |
| 1093 | + | const alloc = testing.allocator; | |
| 1058 | 1094 | ||
| 1059 | 1095 | var inner = try testCreateTerminal(alloc, 80, 30, ""); | |
| 1060 | 1096 | defer inner.deinit(alloc); |
| ... | ... | @@ -1098,3 +1134,147 @@ test "serializeTerminalState scrollback + size mismatch nested roundtrip" { | |
| 1098 | 1134 | try expectScreensMatch(alloc, &inner, &client); | |
| 1099 | 1135 | try expectCursorAt(&client, inner_cursor_y, inner_cursor_x); | |
| 1100 | 1136 | } | |
| 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 | + | } |