repos / zmx

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

commit
c6956f8
parent
a65e500
author
Eric Bower
date
2026-04-04 22:22:24 -0400 EDT
chore(leader): up arrow can claim client leader
2 files changed,  +63, -56
M src/main.zig
+5, -2
 1@@ -586,6 +586,7 @@ const Daemon = struct {
 2     }
 3 
 4     pub fn handleInput(self: *Daemon, client: *Client, payload: []const u8) !void {
 5+        std.log.debug("buffering pty input data={x}", .{payload});
 6         // client is leader, send entire payload (ansi escape codes + text)
 7         if (self.leader_client_fd == client.socket_fd) {
 8             self.queuePtyInput(payload);
 9@@ -594,7 +595,9 @@ const Daemon = struct {
10 
11         // quick check to see if a newline happened so we can set that client to leader
12         // without creating a ghostty vt
13-        if (std.mem.indexOfScalar(u8, payload, '\r')) |_| {
14+        const isNewline = std.mem.indexOfScalar(u8, payload, '\r') != null;
15+        const isUpArrow = std.mem.eql(u8, payload, "\x1b[A") or util.isUpArrow(payload);
16+        if (isNewline or isUpArrow) {
17             std.log.info(
18                 "setting new leader session={s} client_fd={d}",
19                 .{ self.session_name, client.socket_fd },
20@@ -1486,7 +1489,7 @@ fn clientLoop(client_sock_fd: i32) !void {
21             if (n_opt) |n| {
22                 if (n > 0) {
23                     // Check for detach sequences (ctrl+\ as first byte or Kitty escape sequence)
24-                    if (buf[0] == 0x1C or util.isKittyCtrlBackslash(buf[0..n])) {
25+                    if (util.isCtrlBackslash(buf[0..n])) {
26                         try ipc.appendMessage(alloc, &sock_write_buf, .Detach, "");
27                     } else {
28                         try ipc.appendMessage(alloc, &sock_write_buf, .Input, buf[0..n]);
M src/util.zig
+58, -54
  1@@ -211,31 +211,35 @@ pub fn findTaskExitMarker(output: []const u8) ?u8 {
  2 }
  3 
  4 /// Detects Kitty keyboard protocol escape sequence for Ctrl+\.
  5-/// Parses the general CSI u form:
  6-///   CSI key-code[:alternates] ; modifiers[:event-type] [; text-codepoints] u
  7-///
  8-/// Matches when key-code is 92 (backslash), ctrl bit is set in modifiers,
  9-/// and event type is press (1 or absent) or repeat (2). Rejects release (3).
 10-/// Tolerates additional modifiers (caps_lock, num_lock)
 11-/// and alternate key sub-fields from the kitty protocol's progressive
 12-/// enhancement flags.
 13-pub fn isKittyCtrlBackslash(buf: []const u8) bool {
 14-    return detectCsiKeyPress(buf, 92, 0b100);
 15+pub fn isCtrlBackslash(buf: []const u8) bool {
 16+    if (buf.len == 0) return false;
 17+    return buf[0] == 0x1C or isKeyPressed(buf, 0x5c, 0b100);
 18+}
 19+
 20+/// Detects vt100 or kitty keyboard protocol escape sequence for up arrow.
 21+pub fn isUpArrow(buf: []const u8) bool {
 22+    return std.mem.eql(u8, buf, "\x1b[A") or std.mem.eql(u8, buf, "\x1b[26;1u");
 23 }
 24 
 25-fn detectCsiKeyPress(buf: []const u8, expected_key: u32, expected_mods: u32) bool {
 26-    // Scan for any CSI u sequence encoding the given Ctrl+key in the buffer.
 27-    // The sequence can appear at any offset (e.g. preceded by other input).
 28+fn isKeyPressed(buf: []const u8, expected_key: u32, expected_mods: u32) bool {
 29+    // Scan for any CSI u sequence encoding in the buffer.
 30     var i: usize = 0;
 31     while (i + 2 < buf.len) : (i += 1) {
 32         if (buf[i] == 0x1b and buf[i + 1] == '[') {
 33-            if (isKeyPressed(buf[i + 2 ..], expected_key, expected_mods)) return true;
 34+            if (keypressWithMod(buf[i + 2 ..], expected_key, expected_mods)) return true;
 35         }
 36     }
 37     return false;
 38 }
 39 
 40-fn isKeyPressed(buf: []const u8, expected_key: u32, expected_mods: u32) bool {
 41+/// Parses the general CSI u form:
 42+///   CSI key-code[:alternates] ; modifiers[:event-type] [; text-codepoints] u
 43+///
 44+/// Event type is press (1 or absent) or repeat (2). Rejects release (3).
 45+/// Tolerates additional modifiers (caps_lock, num_lock)
 46+/// and alternate key sub-fields from the kitty protocol's progressive
 47+/// enhancement flags.
 48+fn keypressWithMod(buf: []const u8, expected_key: u32, expected_mods: u32) bool {
 49     var pos: usize = 0;
 50 
 51     // 1. Parse key code.
 52@@ -261,7 +265,7 @@ fn isKeyPressed(buf: []const u8, expected_key: u32, expected_mods: u32) bool {
 53     //    (caps_lock=0b1000000, num_lock=0b10000000) are tolerated because
 54     //    they are ambient state, not deliberate key combinations.
 55     const intentional_mods = mod_raw & 0b00111111;
 56-    if (intentional_mods != expected_mods) return false;
 57+    if (expected_mods > 0 and expected_mods != intentional_mods) return false;
 58 
 59     // 6. Parse optional event type after ':'.
 60     if (pos < buf.len and buf[pos] == ':') {
 61@@ -634,95 +638,95 @@ test "shellQuote" {
 62     try std.testing.expectEqualStrings("'hello'", plain);
 63 }
 64 
 65-test "isKittyCtrlBackslash" {
 66+test "isCtrlBackslash" {
 67     const expect = std.testing.expect;
 68 
 69     // Basic: ctrl only (modifier 5 = 1 + 4)
 70-    try expect(isKittyCtrlBackslash("\x1b[92;5u"));
 71+    try expect(isCtrlBackslash("\x1b[92;5u"));
 72 
 73     // Explicit press event type (:1)
 74-    try expect(isKittyCtrlBackslash("\x1b[92;5:1u"));
 75+    try expect(isCtrlBackslash("\x1b[92;5:1u"));
 76 
 77     // Repeat event (:2) -- user holding Ctrl+\
 78-    try expect(isKittyCtrlBackslash("\x1b[92;5:2u"));
 79+    try expect(isCtrlBackslash("\x1b[92;5:2u"));
 80 
 81     // Release event (:3) -- must NOT trigger detach
 82-    try expect(!isKittyCtrlBackslash("\x1b[92;5:3u"));
 83+    try expect(!isCtrlBackslash("\x1b[92;5:3u"));
 84 
 85     // Lock modifiers: caps_lock (bit 6) changes modifier value
 86     // ctrl + caps_lock = 1 + (4 + 64) = 69
 87-    try expect(isKittyCtrlBackslash("\x1b[92;69u"));
 88-    try expect(isKittyCtrlBackslash("\x1b[92;69:1u"));
 89-    try expect(!isKittyCtrlBackslash("\x1b[92;69:3u"));
 90+    try expect(isCtrlBackslash("\x1b[92;69u"));
 91+    try expect(isCtrlBackslash("\x1b[92;69:1u"));
 92+    try expect(!isCtrlBackslash("\x1b[92;69:3u"));
 93 
 94     // ctrl + num_lock = 1 + (4 + 128) = 133
 95-    try expect(isKittyCtrlBackslash("\x1b[92;133u"));
 96+    try expect(isCtrlBackslash("\x1b[92;133u"));
 97 
 98     // ctrl + caps_lock + num_lock = 1 + (4 + 64 + 128) = 197
 99-    try expect(isKittyCtrlBackslash("\x1b[92;197u"));
100+    try expect(isCtrlBackslash("\x1b[92;197u"));
101 
102     // Combined intentional modifiers -- must NOT match (ctrl+\ is the
103     // detach key, not ctrl+shift+\ or ctrl+alt+\)
104     // ctrl + shift = 1 + (4 + 1) = 6
105-    try expect(!isKittyCtrlBackslash("\x1b[92;6u"));
106+    try expect(!isCtrlBackslash("\x1b[92;6u"));
107 
108     // ctrl + alt = 1 + (4 + 2) = 7
109-    try expect(!isKittyCtrlBackslash("\x1b[92;7u"));
110+    try expect(!isCtrlBackslash("\x1b[92;7u"));
111 
112     // ctrl + super = 1 + (4 + 8) = 13
113-    try expect(!isKittyCtrlBackslash("\x1b[92;13u"));
114+    try expect(!isCtrlBackslash("\x1b[92;13u"));
115 
116     // ctrl + shift + caps_lock = 1 + (1 + 4 + 64) = 70 -- shift is intentional
117-    try expect(!isKittyCtrlBackslash("\x1b[92;70u"));
118+    try expect(!isCtrlBackslash("\x1b[92;70u"));
119 
120     // ctrl + shift + num_lock = 1 + (1 + 4 + 128) = 134 -- shift is intentional
121-    try expect(!isKittyCtrlBackslash("\x1b[92;134u"));
122+    try expect(!isCtrlBackslash("\x1b[92;134u"));
123 
124     // Modifier without ctrl bit -- must NOT match
125     // shift only = 1 + 1 = 2
126-    try expect(!isKittyCtrlBackslash("\x1b[92;1u"));
127-    try expect(!isKittyCtrlBackslash("\x1b[92;2u"));
128+    try expect(!isCtrlBackslash("\x1b[92;1u"));
129+    try expect(!isCtrlBackslash("\x1b[92;2u"));
130 
131     // Alternate key sub-fields (report_alternates flag)
132     // shifted key | (124): \x1b[92:124;5u
133-    try expect(isKittyCtrlBackslash("\x1b[92:124;5u"));
134+    try expect(isCtrlBackslash("\x1b[92:124;5u"));
135 
136     // base layout key only (non-US keyboard): \x1b[92::92;5u
137-    try expect(isKittyCtrlBackslash("\x1b[92::92;5u"));
138+    try expect(isCtrlBackslash("\x1b[92::92;5u"));
139 
140     // both shifted and base layout: \x1b[92:124:92;5u
141-    try expect(isKittyCtrlBackslash("\x1b[92:124:92;5u"));
142+    try expect(isCtrlBackslash("\x1b[92:124:92;5u"));
143 
144     // Alternate keys + lock modifiers + event type
145-    try expect(isKittyCtrlBackslash("\x1b[92:124;69:1u"));
146-    try expect(!isKittyCtrlBackslash("\x1b[92:124;69:3u"));
147+    try expect(isCtrlBackslash("\x1b[92:124;69:1u"));
148+    try expect(!isCtrlBackslash("\x1b[92:124;69:3u"));
149 
150     // Text codepoints section (flag 0b10000) -- tolerated and skipped
151     // Even though ctrl+\ text is typically empty, terminals may vary
152-    try expect(isKittyCtrlBackslash("\x1b[92;5;28u"));
153-    try expect(isKittyCtrlBackslash("\x1b[92;5;28:92u"));
154+    try expect(isCtrlBackslash("\x1b[92;5;28u"));
155+    try expect(isCtrlBackslash("\x1b[92;5;28:92u"));
156 
157     // Wrong key code -- must NOT match
158-    try expect(!isKittyCtrlBackslash("\x1b[91;5u"));
159-    try expect(!isKittyCtrlBackslash("\x1b[93;5u"));
160-    try expect(!isKittyCtrlBackslash("\x1b[9;5u"));
161-    try expect(!isKittyCtrlBackslash("\x1b[920;5u"));
162+    try expect(!isCtrlBackslash("\x1b[91;5u"));
163+    try expect(!isCtrlBackslash("\x1b[93;5u"));
164+    try expect(!isCtrlBackslash("\x1b[9;5u"));
165+    try expect(!isCtrlBackslash("\x1b[920;5u"));
166 
167     // Sequence embedded in larger buffer (e.g., preceded by other input)
168-    try expect(isKittyCtrlBackslash("abc\x1b[92;5u"));
169-    try expect(isKittyCtrlBackslash("\x1b[A\x1b[92;5u"));
170+    try expect(isCtrlBackslash("abc\x1b[92;5u"));
171+    try expect(isCtrlBackslash("\x1b[A\x1b[92;5u"));
172 
173     // Garbage / malformed inputs
174-    try expect(!isKittyCtrlBackslash("garbage"));
175-    try expect(!isKittyCtrlBackslash(""));
176-    try expect(!isKittyCtrlBackslash("\x1b["));
177-    try expect(!isKittyCtrlBackslash("\x1b[92"));
178-    try expect(!isKittyCtrlBackslash("\x1b[92;"));
179-    try expect(!isKittyCtrlBackslash("\x1b[92;u"));
180-    try expect(!isKittyCtrlBackslash("\x1b[;5u"));
181+    try expect(!isCtrlBackslash("garbage"));
182+    try expect(!isCtrlBackslash(""));
183+    try expect(!isCtrlBackslash("\x1b["));
184+    try expect(!isCtrlBackslash("\x1b[92"));
185+    try expect(!isCtrlBackslash("\x1b[92;"));
186+    try expect(!isCtrlBackslash("\x1b[92;u"));
187+    try expect(!isCtrlBackslash("\x1b[;5u"));
188 
189     // Other CSI u sequences that happen to contain '92' elsewhere
190-    try expect(!isKittyCtrlBackslash("\x1b[65;92u"));
191+    try expect(!isCtrlBackslash("\x1b[65;92u"));
192 }
193 
194 test "serializeTerminalState excludes synchronized output replay" {