├── logo.png ├── kitty_gfx.sh ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── flake.nix ├── AGENTS.md ├── flake.lock ├── src ├── log.zig ├── ipc.zig └── main.zig ├── docs └── index.html └── README.md /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neurosnap/zmx/HEAD/logo.png -------------------------------------------------------------------------------- /kitty_gfx.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # transmit a PNG (format=100 → PNG) 4 | data=$(base64 -w0 ./logo.png) 5 | printf '\033_Ga=T,f=100;%s\033\\' "$data" 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | .zig-cache/ 4 | zig-cache/ 5 | zig-out/ 6 | Session*.*vim 7 | commit_msg 8 | *.sw? 9 | zig_std_src/ 10 | ghostty_src/ 11 | prior_art/ 12 | .beads/ 13 | 14 | # Nix 15 | result 16 | result-* 17 | .direnv/ 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | Use spec: https://common-changelog.org/ 4 | 5 | ## v0.1.1 - 2025-12-16 6 | 7 | ### Changed 8 | 9 | - `zmx list`: sort by session name 10 | 11 | ### Fixed 12 | 13 | - Send SIGWINCH to PTY on re-attach 14 | - Use default terminal size if cols and rows are 0 15 | 16 | ## v0.1.0 - 2025-12-09 17 | 18 | ### Changed 19 | 20 | - **Breaking:** unix socket and log files have been moved from `/tmp/zmx` to `/tmp/zmx-{uid}` with folder/file perms set to user 21 | 22 | If you upgraded and need to kill your previous sessions, run `ZMX_DIR=/tmp/zmx zmx kill {sesion}` for each session. 23 | 24 | ### Added 25 | 26 | - Use `TMPDIR` environment variable instead of `/tmp` 27 | - Use `ZMX_DIR` environment variable instead of `/tmp/zmx-{uid}` 28 | - `zmx version` prints the current version of `zmx` and `ghostty-vt` 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Eric Bower 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "zmx - session persistence for terminal processes"; 3 | 4 | inputs = { 5 | zig2nix.url = "github:Cloudef/zig2nix"; 6 | }; 7 | 8 | outputs = 9 | { zig2nix, ... }: 10 | let 11 | flake-utils = zig2nix.inputs.flake-utils; 12 | in 13 | (flake-utils.lib.eachSystem [ "x86_64-linux" "aarch64-linux" ] ( 14 | system: 15 | let 16 | env = zig2nix.outputs.zig-env.${system} { 17 | zig = zig2nix.outputs.packages.${system}.zig-0_15_2; 18 | }; 19 | in 20 | with builtins; 21 | with env.pkgs.lib; 22 | let 23 | zmx-package = env.package { 24 | src = cleanSource ./.; 25 | zigBuildFlags = [ "-Doptimize=ReleaseSafe" ]; 26 | zigPreferMusl = true; 27 | }; 28 | in 29 | { 30 | packages = { 31 | zmx = zmx-package; 32 | default = zmx-package; 33 | }; 34 | 35 | apps = { 36 | zmx = { 37 | type = "app"; 38 | program = "${zmx-package}/bin/zmx"; 39 | }; 40 | default = { 41 | type = "app"; 42 | program = "${zmx-package}/bin/zmx"; 43 | }; 44 | 45 | build = env.app [ ] "zig build \"$@\""; 46 | 47 | test = env.app [ ] "zig build test -- \"$@\""; 48 | }; 49 | 50 | devShells.default = env.mkShell { 51 | }; 52 | } 53 | )); 54 | } 55 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # zmx 2 | 3 | The goal of this project is to create a way to attach and detach terminal sessions without killing the underlying linux process. 4 | 5 | When researching `zmx`, also read the @README.md in the root of this project directory to learn more about the features, documentation, prior art, etc. 6 | 7 | ## tech stack 8 | 9 | - `zig` v0.15.1 10 | - `libghostty-vt` for terminal escape codes and terminal state management 11 | 12 | ## commands 13 | 14 | - **Build:** `zig build` 15 | - **Build Check (Zig)**: `zig build check` 16 | - **Test (Zig):** `zig build test` 17 | - **Test filter (Zig)**: `zig build test -Dtest-filter=` 18 | - **Formatting (Zig)**: `zig fmt .` 19 | 20 | ## find any library API definitions 21 | 22 | Before trying anything else, run the `zigdoc` command to find an API with documentation: 23 | 24 | ``` 25 | zigdoc {symbol} 26 | # examples 27 | zigdoc ghostty-vt 28 | zigdoc std.ArrayList 29 | zigdoc std.mem.Allocator 30 | zigdoc std.http.Server 31 | ``` 32 | 33 | Only if that doesn't work should you grep the project dir. 34 | 35 | ## find zig std library source code 36 | 37 | To inspect the source code for zig's standard library, look inside the `zig_std_src` folder. 38 | 39 | ## find ghostty library source code 40 | 41 | To inspect the source code for zig's standard library, look inside the `ghostty_src` folder. 42 | 43 | ## Issue Tracking 44 | 45 | We use bd (beads, https://github.com/steveyegge/beads) for issue tracking instead of Markdown TODOs or external tools. 46 | 47 | Run `bd quickstart` to learn how to use it. 48 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1764635402, 24 | "narHash": "sha256-6rYcajRLe2C5ZYnV1HYskJl+QAkhvseWTzbdQiTN9OI=", 25 | "owner": "nixos", 26 | "repo": "nixpkgs", 27 | "rev": "5f53b0d46d320352684242d000b36dcfbbf7b0bc", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "nixos", 32 | "repo": "nixpkgs", 33 | "type": "github" 34 | } 35 | }, 36 | "root": { 37 | "inputs": { 38 | "zig2nix": "zig2nix" 39 | } 40 | }, 41 | "systems": { 42 | "locked": { 43 | "lastModified": 1681028828, 44 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 45 | "owner": "nix-systems", 46 | "repo": "default", 47 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 48 | "type": "github" 49 | }, 50 | "original": { 51 | "owner": "nix-systems", 52 | "repo": "default", 53 | "type": "github" 54 | } 55 | }, 56 | "zig2nix": { 57 | "inputs": { 58 | "flake-utils": "flake-utils", 59 | "nixpkgs": "nixpkgs" 60 | }, 61 | "locked": { 62 | "lastModified": 1764678235, 63 | "narHash": "sha256-NNQWR3DAufaH7fs6ZplfAv1xPHEc0Ne3Z0v4MNHCqSw=", 64 | "owner": "Cloudef", 65 | "repo": "zig2nix", 66 | "rev": "8b6ec85bccdf6b91ded19e9ef671205937e271e6", 67 | "type": "github" 68 | }, 69 | "original": { 70 | "owner": "Cloudef", 71 | "repo": "zig2nix", 72 | "type": "github" 73 | } 74 | } 75 | }, 76 | "root": "root", 77 | "version": 7 78 | } 79 | -------------------------------------------------------------------------------- /src/log.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | pub const LogSystem = struct { 4 | file: ?std.fs.File = null, 5 | mutex: std.Thread.Mutex = .{}, 6 | current_size: u64 = 0, 7 | max_size: u64 = 5 * 1024 * 1024, // 5MB 8 | path: []const u8 = "", 9 | alloc: std.mem.Allocator = undefined, 10 | 11 | pub fn init(self: *LogSystem, alloc: std.mem.Allocator, path: []const u8) !void { 12 | self.alloc = alloc; 13 | self.path = try alloc.dupe(u8, path); 14 | 15 | const file = std.fs.openFileAbsolute(path, .{ .mode = .read_write }) catch |err| switch (err) { 16 | error.FileNotFound => try std.fs.createFileAbsolute(path, .{ .read = true, .mode = 0o640 }), 17 | else => return err, 18 | }; 19 | 20 | const end_pos = try file.getEndPos(); 21 | try file.seekTo(end_pos); 22 | self.current_size = end_pos; 23 | self.file = file; 24 | } 25 | 26 | pub fn deinit(self: *LogSystem) void { 27 | if (self.file) |f| f.close(); 28 | if (self.path.len > 0) self.alloc.free(self.path); 29 | } 30 | 31 | pub fn log(self: *LogSystem, comptime level: std.log.Level, comptime scope: @Type(.enum_literal), comptime format: []const u8, args: anytype) void { 32 | self.mutex.lock(); 33 | defer self.mutex.unlock(); 34 | 35 | if (self.file == null) { 36 | std.log.defaultLog(level, scope, format, args); 37 | return; 38 | } 39 | 40 | if (self.current_size >= self.max_size) { 41 | self.rotate() catch |err| { 42 | std.debug.print("Log rotation failed: {s}\n", .{@errorName(err)}); 43 | }; 44 | } 45 | 46 | const now = std.time.milliTimestamp(); 47 | const prefix = "[{d}] [{s}] ({s}): "; 48 | const scope_name = @tagName(scope); 49 | const level_name = level.asText(); 50 | 51 | const prefix_args = .{ 52 | now, 53 | level_name, 54 | scope_name, 55 | }; 56 | 57 | if (self.file) |f| { 58 | const prefix_len = std.fmt.count(prefix, prefix_args); 59 | const msg_len = std.fmt.count(format, args); 60 | const newline_len = 1; 61 | const total_len = prefix_len + msg_len + newline_len; 62 | self.current_size += total_len; 63 | 64 | var buf: [4096]u8 = undefined; 65 | var w = f.writerStreaming(&buf); 66 | w.interface.print(prefix ++ format ++ "\n", prefix_args ++ args) catch {}; 67 | w.interface.flush() catch {}; 68 | } 69 | } 70 | 71 | fn rotate(self: *LogSystem) !void { 72 | if (self.file) |f| { 73 | f.close(); 74 | self.file = null; 75 | } 76 | 77 | const old_path = try std.fmt.allocPrint(self.alloc, "{s}.old", .{self.path}); 78 | defer self.alloc.free(old_path); 79 | 80 | std.fs.renameAbsolute(self.path, old_path) catch |err| switch (err) { 81 | error.FileNotFound => {}, 82 | else => return err, 83 | }; 84 | 85 | self.file = try std.fs.createFileAbsolute(self.path, .{ .truncate = true, .read = true, .mode = 0o640 }); 86 | self.current_size = 0; 87 | } 88 | }; 89 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | zmx - session persistence for terminal processes 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 36 | 37 |
38 |

zmx

39 |
session persistence for terminal processes
40 |
41 | 42 |

features

43 | 52 | 53 |

install

54 | 55 |

binaries

56 | 62 | 63 |

homebrew

64 |
brew tap neurosnap/tap
65 | brew install zmx
66 | 67 |

posts

68 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /src/ipc.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const posix = std.posix; 3 | 4 | pub const Tag = enum(u8) { 5 | Input = 0, 6 | Output = 1, 7 | Resize = 2, 8 | Detach = 3, 9 | DetachAll = 4, 10 | Kill = 5, 11 | Info = 6, 12 | Init = 7, 13 | }; 14 | 15 | pub const Header = packed struct { 16 | tag: Tag, 17 | len: u32, 18 | }; 19 | 20 | pub const Resize = packed struct { 21 | rows: u16, 22 | cols: u16, 23 | }; 24 | 25 | pub const Info = packed struct { 26 | clients_len: usize, 27 | pid: i32, 28 | }; 29 | 30 | pub fn expectedLength(data: []const u8) ?usize { 31 | if (data.len < @sizeOf(Header)) return null; 32 | const header = std.mem.bytesToValue(Header, data[0..@sizeOf(Header)]); 33 | return @sizeOf(Header) + header.len; 34 | } 35 | 36 | pub fn send(fd: i32, tag: Tag, data: []const u8) !void { 37 | const header = Header{ 38 | .tag = tag, 39 | .len = @intCast(data.len), 40 | }; 41 | const header_bytes = std.mem.asBytes(&header); 42 | try writeAll(fd, header_bytes); 43 | if (data.len > 0) { 44 | try writeAll(fd, data); 45 | } 46 | } 47 | 48 | pub fn appendMessage(alloc: std.mem.Allocator, list: *std.ArrayList(u8), tag: Tag, data: []const u8) !void { 49 | const header = Header{ 50 | .tag = tag, 51 | .len = @intCast(data.len), 52 | }; 53 | try list.appendSlice(alloc, std.mem.asBytes(&header)); 54 | if (data.len > 0) { 55 | try list.appendSlice(alloc, data); 56 | } 57 | } 58 | 59 | fn writeAll(fd: i32, data: []const u8) !void { 60 | var index: usize = 0; 61 | while (index < data.len) { 62 | const n = try posix.write(fd, data[index..]); 63 | if (n == 0) return error.DiskQuota; 64 | index += n; 65 | } 66 | } 67 | 68 | pub const Message = struct { 69 | tag: Tag, 70 | data: []u8, 71 | 72 | pub fn deinit(self: Message, alloc: std.mem.Allocator) void { 73 | if (self.data.len > 0) { 74 | alloc.free(self.data); 75 | } 76 | } 77 | }; 78 | 79 | pub const SocketMsg = struct { 80 | header: Header, 81 | payload: []const u8, 82 | }; 83 | 84 | pub const SocketBuffer = struct { 85 | buf: std.ArrayList(u8), 86 | alloc: std.mem.Allocator, 87 | head: usize, 88 | 89 | pub fn init(alloc: std.mem.Allocator) !SocketBuffer { 90 | return .{ 91 | .buf = try std.ArrayList(u8).initCapacity(alloc, 4096), 92 | .alloc = alloc, 93 | .head = 0, 94 | }; 95 | } 96 | 97 | pub fn deinit(self: *SocketBuffer) void { 98 | self.buf.deinit(self.alloc); 99 | } 100 | 101 | /// Reads from fd into buffer. 102 | /// Returns number of bytes read. 103 | /// Propagates error.WouldBlock and other errors to caller. 104 | /// Returns 0 on EOF. 105 | pub fn read(self: *SocketBuffer, fd: i32) !usize { 106 | if (self.head > 0) { 107 | const remaining = self.buf.items.len - self.head; 108 | if (remaining > 0) { 109 | std.mem.copyForwards(u8, self.buf.items[0..remaining], self.buf.items[self.head..]); 110 | self.buf.items.len = remaining; 111 | } else { 112 | self.buf.clearRetainingCapacity(); 113 | } 114 | self.head = 0; 115 | } 116 | 117 | var tmp: [4096]u8 = undefined; 118 | const n = try posix.read(fd, &tmp); 119 | if (n > 0) { 120 | try self.buf.appendSlice(self.alloc, tmp[0..n]); 121 | } 122 | return n; 123 | } 124 | 125 | /// Returns the next complete message or `null` when none available. 126 | /// `buf` is advanced automatically; caller keeps the returned slices 127 | /// valid until the following `next()` (or `deinit`). 128 | pub fn next(self: *SocketBuffer) ?SocketMsg { 129 | const available = self.buf.items[self.head..]; 130 | const total = expectedLength(available) orelse return null; 131 | if (available.len < total) return null; 132 | 133 | const hdr = std.mem.bytesToValue(Header, available[0..@sizeOf(Header)]); 134 | const pay = available[@sizeOf(Header)..total]; 135 | 136 | self.head += total; 137 | return .{ .header = hdr, .payload = pay }; 138 | } 139 | }; 140 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # zmx 4 | 5 | session persistence for terminal processes 6 | 7 | Reason for this tool: [You might not need `tmux`](https://bower.sh/you-might-not-need-tmux) 8 | 9 | ## features 10 | 11 | - Persist terminal shell sessions (pty processes) 12 | - Ability to attach and detach from a shell session without killing it 13 | - Native terminal scrollback 14 | - Multiple clients can connect to the same session 15 | - Re-attaching to a session restores previous terminal state and output 16 | - Works on mac and linux 17 | - This project does **NOT** provide windows, tabs, or splits 18 | 19 | ## install 20 | 21 | ### binaries 22 | 23 | - https://zmx.sh/a/zmx-0.1.1-linux-aarch64.tar.gz 24 | - https://zmx.sh/a/zmx-0.1.1-linux-x86_64.tar.gz 25 | - https://zmx.sh/a/zmx-0.1.1-macos-aarch64.tar.gz 26 | - https://zmx.sh/a/zmx-0.1.1-macos-x86_64.tar.gz 27 | 28 | ### homebrew 29 | 30 | ```bash 31 | brew tap neurosnap/tap 32 | brew install zmx 33 | ``` 34 | 35 | ### src 36 | 37 | - Requires zig `v0.15` 38 | - Clone the repo 39 | - Run build cmd 40 | 41 | ```bash 42 | zig build -Doptimize=ReleaseSafe --prefix ~/.local 43 | # be sure to add ~/.local/bin to your PATH 44 | ``` 45 | 46 | ## usage 47 | 48 | > [!IMPORTANT] 49 | > Press `ctrl+\` to detach from the session. 50 | 51 | ``` 52 | Usage: zmx [args] 53 | 54 | Commands: 55 | [a]ttach [command...] Create or attach to a session 56 | [d]etach Detach all clients from current session (ctrl+\ for current client) 57 | [l]ist List active sessions 58 | [k]ill Kill a session and all attached clients 59 | [v]ersion Show version information 60 | [h]elp Show this help message 61 | ``` 62 | 63 | ### examples 64 | 65 | ```bash 66 | zmx attach dev # start a shell session 67 | zmx attach dev nvim . # start nvim in a persistent session 68 | zmx attach build make -j8 # run a build, reattach to check progress 69 | zmx attach mux dvtm # run a multiplexer inside zmx 70 | ``` 71 | 72 | ## shell prompt 73 | 74 | When you attach to a zmx session, we don't provide any indication that you are inside `zmx`. We do provide an environment variable `ZMX_SESSION` which contains the session name. 75 | 76 | We recommend checking for that env var inside your prompt and displaying some indication there. 77 | 78 | ### fish 79 | 80 | Place this file in `~/.config/fish/config.fish`: 81 | 82 | ```fish 83 | functions -c fish_prompt _original_fish_prompt 2>/dev/null 84 | 85 | function fish_prompt --description 'Write out the prompt' 86 | if set -q ZMX_SESSION 87 | echo -n "[$ZMX_SESSION] " 88 | end 89 | _original_fish_prompt 90 | end 91 | ``` 92 | 93 | ### bash 94 | 95 | todo. 96 | 97 | ### zsh 98 | 99 | Place this in `.zshrc`, update current `$PROMPT/$PS1` to `BASE_PROMPT` 100 | 101 | ```zsh 102 | BASE_PROMPT=$PS1/$PROMPT 103 | PROMPT="${ZMX_SESSION:+[$ZMX_SESSION]} $BASE_PROMPT" 104 | ``` 105 | 106 | ## philosophy 107 | 108 | The entire argument for `zmx` instead of something like `tmux` that has windows, panes, splits, etc. is that job should be handled by your os window manager. By using something like `tmux` you now have redundent functionality in your dev stack: a window manager for your os and a window manager for your terminal. Further, in order to use modern terminal features, your terminal emulator **and** `tmux` need to have support for them. This holds back the terminal enthusiast community and feature development. 109 | 110 | Instead, this tool specifically focuses on session persistence and defers window management to your os wm. 111 | 112 | ## ssh workflow 113 | 114 | Using `zmx` with `ssh` is a first-class citizen. Instead of `ssh`ing into your remote system with a single terminal and `n` tmux panes, you open `n` terminals and run `ssh` for all of them. This might sound tedious, but there are tools to make this a delightful workflow. 115 | 116 | First, create an `ssh` config entry for your remote dev server: 117 | 118 | ```bash 119 | Host = d.* 120 | HostName 192.168.1.xxx 121 | 122 | RemoteCommand zmx attach %k 123 | RequestTTY yes 124 | ControlPath ~/.ssh/cm-%r@%h:%p 125 | ControlMaster auto 126 | ControlPersist 10m 127 | ``` 128 | 129 | Now you can spawn as many terminal sessions as you'd like: 130 | 131 | ```bash 132 | ssh d.term 133 | ssh d.irc 134 | ssh d.pico 135 | ssh d.dotfiles 136 | ``` 137 | 138 | This will create or attach to each session and since we are using `ControlMaster` the same `ssh` connection is reused for every call to `ssh` for near-instant connection times. 139 | 140 | Now you can use the [`autossh`](https://linux.die.net/man/1/autossh) tool to make your ssh connections auto-reconnect. For example, if you have a laptop and close/open your laptop lid it will automatically reconnect all your ssh connections: 141 | 142 | ```bash 143 | autossh -M 0 -q d.term 144 | ``` 145 | 146 | Or create an `alias`/`abbr`: 147 | 148 | ```fish 149 | abbr -a ash "autossh -M 0 -q" 150 | ``` 151 | 152 | ```bash 153 | ash d.term 154 | ash d.irc 155 | ash d.pico 156 | ash d.dotifles 157 | ``` 158 | 159 | Wow! Now you can setup all your os tiling windows how you like them for your project and have as many windows as you'd like, almost replicating exactly what `tmux` does but with native windows, tabs, splits, and scrollback! It also has the added benefit of supporting all the terminal features your emulator supports, no longer restricted by what `tmux` supports. 160 | 161 | ## socket file location 162 | 163 | Each session gets its own unix socket file. Right now, the default location is `/tmp/zmx-{uid}`. You can configure this using environment variables: 164 | 165 | - `TMPDIR` => overrides `/tmp` 166 | - `ZMX_DIR` => overrides `/tmp/zmx-{uid}` 167 | 168 | ## debugging 169 | 170 | We store global logs for cli commands in `/tmp/zmx-{uid}/logs/zmx.log`. We store session-specific logs in `/tmp/zmx-{uid}/logs/{session_name}.log`. These logs rotate to `.old` after 5MB. 171 | 172 | ## a note on configuration 173 | 174 | We are evaluating what should be configurable and what should not. Every configuration option is a burden for us maintainers. For example, being able to change the default detach shortcut is difficult in a terminal environment. 175 | 176 | ## a smol contract 177 | 178 | - Write programs that solve a well defined problem. 179 | - Write programs that behave the way most users expect them to behave. 180 | - Write programs that a single person can maintain. 181 | - Write programs that compose with other smol tools. 182 | - Write programs that can be finished. 183 | 184 | ## impl 185 | 186 | - The `daemon` and client processes communicate via a unix socket 187 | - Both `daemon` and `client` loops leverage `poll()` 188 | - Each session creates its own unix socket file 189 | - We restore terminal state and output using `libghostty-vt` 190 | 191 | ### libghostty-vt 192 | 193 | We use `libghostty-vt` to restore the previous state of the terminal when a client re-attaches to a session. 194 | 195 | How it works: 196 | 197 | - user creates session `zmx attach term` 198 | - user interacts with terminal stdin 199 | - stdin gets sent to pty via daemon 200 | - daemon sends pty output to client *and* `ghostty-vt` 201 | - `ghostty-vt` holds terminal state and scrollback 202 | - user disconnects 203 | - user re-attaches to session 204 | - `ghostty-vt` sends terminal snapshot to client stdout 205 | 206 | In this way, `ghostty-vt` doesn't sit in the middle of an active terminal session, it simply receives all the same data the client receives so it can re-hydrate clients that connect to the session. This enables users to pick up where they left off as if they didn't disconnect from the terminal session at all. It also has the added benefit of being very fast, the only thing sitting in-between you and your PTY is a unix socket. 207 | 208 | ## prior art 209 | 210 | Below is a list of projects that inspired me to build this project. 211 | 212 | ### shpool 213 | 214 | You can find the source code at this repo: https://github.com/shell-pool/shpool 215 | 216 | `shpool` is a service that enables session persistence by allowing the creation of named shell sessions owned by `shpool` so that the session is not lost if the connection drops. 217 | 218 | `shpool` can be thought of as a lighter weight alternative to tmux or GNU screen. While tmux and screen take over the whole terminal and provide window splitting and tiling features, `shpool` only provides persistent sessions. 219 | 220 | The biggest advantage of this approach is that `shpool` does not break native scrollback or copy-paste. 221 | 222 | ### abduco 223 | 224 | You can find the source code at this repo: https://github.com/martanne/abduco 225 | 226 | abduco provides session management i.e. it allows programs to be run independently from its controlling terminal. That is programs can be detached - run in the background - and then later reattached. Together with dvtm it provides a simpler and cleaner alternative to tmux or screen. 227 | 228 | ### dtach 229 | 230 | You can find the source code at this repo: https://github.com/crigler/dtach 231 | 232 | A simple program that emulates the detach feature of screen. 233 | 234 | dtach is a program written in C that emulates the detach feature of screen, which allows a program to be executed in an environment that is protected from the controlling terminal. For instance, the program under the control of dtach would not be affected by the terminal being disconnected for some reason. 235 | 236 | ## comparison 237 | 238 | | Feature | zmx | shpool | abduco | dtach | tmux | 239 | | ------------------------------ | --- | ------ | ------ | ----- | ---- | 240 | | 1:1 Terminal emulator features | ✓ | ✓ | ✓ | ✓ | ✗ | 241 | | Terminal state restore | ✓ | ✓ | ✗ | ✗ | ✓ | 242 | | Window management | ✗ | ✗ | ✗ | ✗ | ✓ | 243 | | Multiple clients per session | ✓ | ✗ | ✓ | ✓ | ✓ | 244 | | Native scrollback | ✓ | ✓ | ✓ | ✓ | ✗ | 245 | | Configurable detach key | ✗ | ✓ | ✓ | ✓ | ✓ | 246 | | Auto-daemonize | ✓ | ✓ | ✓ | ✓ | ✓ | 247 | | Daemon per session | ✓ | ✗ | ✓ | ✓ | ✗ | 248 | | Session listing | ✓ | ✓ | ✓ | ✗ | ✓ | 249 | -------------------------------------------------------------------------------- /src/main.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const posix = std.posix; 3 | const builtin = @import("builtin"); 4 | const build_options = @import("build_options"); 5 | const ghostty_vt = @import("ghostty-vt"); 6 | const ipc = @import("ipc.zig"); 7 | const log = @import("log.zig"); 8 | 9 | pub const version = build_options.version; 10 | pub const ghostty_version = build_options.ghostty_version; 11 | 12 | var log_system = log.LogSystem{}; 13 | 14 | pub const std_options: std.Options = .{ 15 | .logFn = zmxLogFn, 16 | .log_level = .debug, 17 | }; 18 | 19 | fn zmxLogFn( 20 | comptime level: std.log.Level, 21 | comptime scope: @Type(.enum_literal), 22 | comptime format: []const u8, 23 | args: anytype, 24 | ) void { 25 | log_system.log(level, scope, format, args); 26 | } 27 | 28 | const c = switch (builtin.os.tag) { 29 | .macos => @cImport({ 30 | @cInclude("sys/ioctl.h"); // ioctl and constants 31 | @cInclude("termios.h"); 32 | @cInclude("stdlib.h"); 33 | @cInclude("unistd.h"); 34 | }), 35 | .freebsd => @cImport({ 36 | @cInclude("termios.h"); // ioctl and constants 37 | @cInclude("libutil.h"); // openpty() 38 | @cInclude("stdlib.h"); 39 | @cInclude("unistd.h"); 40 | }), 41 | else => @cImport({ 42 | @cInclude("sys/ioctl.h"); // ioctl and constants 43 | @cInclude("pty.h"); 44 | @cInclude("stdlib.h"); 45 | @cInclude("unistd.h"); 46 | }), 47 | }; 48 | 49 | // Manually declare forkpty for macOS since util.h is not available during cross-compilation 50 | const forkpty = if (builtin.os.tag == .macos) 51 | struct { 52 | extern "c" fn forkpty(master_fd: *c_int, name: ?[*:0]u8, termp: ?*const c.struct_termios, winp: ?*const c.struct_winsize) c_int; 53 | }.forkpty 54 | else 55 | c.forkpty; 56 | 57 | var sigwinch_received: std.atomic.Value(bool) = std.atomic.Value(bool).init(false); 58 | 59 | const Client = struct { 60 | alloc: std.mem.Allocator, 61 | socket_fd: i32, 62 | has_pending_output: bool = false, 63 | read_buf: ipc.SocketBuffer, 64 | write_buf: std.ArrayList(u8), 65 | 66 | pub fn deinit(self: *Client) void { 67 | posix.close(self.socket_fd); 68 | self.read_buf.deinit(); 69 | self.write_buf.deinit(self.alloc); 70 | } 71 | }; 72 | 73 | const Cfg = struct { 74 | socket_dir: []const u8, 75 | log_dir: []const u8, 76 | max_scrollback: usize = 10_000_000, 77 | 78 | pub fn init(alloc: std.mem.Allocator) !Cfg { 79 | const tmpdir = posix.getenv("TMPDIR") orelse "/tmp"; 80 | const uid = posix.getuid(); 81 | 82 | var socket_dir: []const u8 = ""; 83 | if (posix.getenv("ZMX_DIR")) |zmxdir| { 84 | socket_dir = try alloc.dupe(u8, zmxdir); 85 | } else { 86 | socket_dir = try std.fmt.allocPrint(alloc, "{s}/zmx-{d}", .{ tmpdir, uid }); 87 | } 88 | errdefer alloc.free(socket_dir); 89 | 90 | const log_dir = try std.fmt.allocPrint(alloc, "{s}/logs", .{socket_dir}); 91 | errdefer alloc.free(log_dir); 92 | 93 | var cfg = Cfg{ 94 | .socket_dir = socket_dir, 95 | .log_dir = log_dir, 96 | }; 97 | 98 | try cfg.mkdir(); 99 | 100 | return cfg; 101 | } 102 | 103 | pub fn deinit(self: *Cfg, alloc: std.mem.Allocator) void { 104 | if (self.socket_dir.len > 0) alloc.free(self.socket_dir); 105 | if (self.log_dir.len > 0) alloc.free(self.log_dir); 106 | } 107 | 108 | pub fn mkdir(self: *Cfg) !void { 109 | posix.mkdirat(posix.AT.FDCWD, self.socket_dir, 0o750) catch |err| switch (err) { 110 | error.PathAlreadyExists => {}, 111 | else => return err, 112 | }; 113 | 114 | posix.mkdirat(posix.AT.FDCWD, self.log_dir, 0o750) catch |err| switch (err) { 115 | error.PathAlreadyExists => {}, 116 | else => return err, 117 | }; 118 | } 119 | }; 120 | 121 | const Daemon = struct { 122 | cfg: *Cfg, 123 | alloc: std.mem.Allocator, 124 | clients: std.ArrayList(*Client), 125 | session_name: []const u8, 126 | socket_path: []const u8, 127 | running: bool, 128 | pid: i32, 129 | command: ?[]const []const u8 = null, 130 | has_pty_output: bool = false, 131 | 132 | pub fn deinit(self: *Daemon) void { 133 | self.clients.deinit(self.alloc); 134 | self.alloc.free(self.socket_path); 135 | } 136 | 137 | pub fn shutdown(self: *Daemon) void { 138 | std.log.info("shutting down daemon session_name={s}", .{self.session_name}); 139 | self.running = false; 140 | 141 | for (self.clients.items) |client| { 142 | client.deinit(); 143 | self.alloc.destroy(client); 144 | } 145 | self.clients.clearRetainingCapacity(); 146 | } 147 | 148 | pub fn closeClient(self: *Daemon, client: *Client, i: usize, shutdown_on_last: bool) bool { 149 | const fd = client.socket_fd; 150 | client.deinit(); 151 | self.alloc.destroy(client); 152 | _ = self.clients.orderedRemove(i); 153 | std.log.info("client disconnected fd={d} remaining={d}", .{ fd, self.clients.items.len }); 154 | if (shutdown_on_last and self.clients.items.len == 0) { 155 | self.shutdown(); 156 | return true; 157 | } 158 | return false; 159 | } 160 | }; 161 | 162 | pub fn main() !void { 163 | // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking 164 | const alloc = std.heap.c_allocator; 165 | 166 | var args = try std.process.argsWithAllocator(alloc); 167 | defer args.deinit(); 168 | _ = args.skip(); // skip program name 169 | 170 | var cfg = try Cfg.init(alloc); 171 | defer cfg.deinit(alloc); 172 | 173 | const log_path = try std.fs.path.join(alloc, &.{ cfg.log_dir, "zmx.log" }); 174 | defer alloc.free(log_path); 175 | try log_system.init(alloc, log_path); 176 | defer log_system.deinit(); 177 | 178 | const cmd = args.next() orelse { 179 | return list(&cfg); 180 | }; 181 | 182 | if (std.mem.eql(u8, cmd, "version") or std.mem.eql(u8, cmd, "v") or std.mem.eql(u8, cmd, "-v") or std.mem.eql(u8, cmd, "--version")) { 183 | return printVersion(); 184 | } else if (std.mem.eql(u8, cmd, "help") or std.mem.eql(u8, cmd, "h") or std.mem.eql(u8, cmd, "-h")) { 185 | return help(); 186 | } else if (std.mem.eql(u8, cmd, "list") or std.mem.eql(u8, cmd, "l")) { 187 | return list(&cfg); 188 | } else if (std.mem.eql(u8, cmd, "detach") or std.mem.eql(u8, cmd, "d")) { 189 | return detachAll(&cfg); 190 | } else if (std.mem.eql(u8, cmd, "kill") or std.mem.eql(u8, cmd, "k")) { 191 | const session_name = args.next() orelse { 192 | return error.SessionNameRequired; 193 | }; 194 | return kill(&cfg, session_name); 195 | } else if (std.mem.eql(u8, cmd, "attach") or std.mem.eql(u8, cmd, "a")) { 196 | const session_name = args.next() orelse { 197 | return error.SessionNameRequired; 198 | }; 199 | 200 | var command_args: std.ArrayList([]const u8) = .empty; 201 | defer command_args.deinit(alloc); 202 | while (args.next()) |arg| { 203 | try command_args.append(alloc, arg); 204 | } 205 | 206 | const clients = try std.ArrayList(*Client).initCapacity(alloc, 10); 207 | var command: ?[][]const u8 = null; 208 | if (command_args.items.len > 0) { 209 | command = command_args.items; 210 | } 211 | var daemon = Daemon{ 212 | .running = true, 213 | .cfg = &cfg, 214 | .alloc = alloc, 215 | .clients = clients, 216 | .session_name = session_name, 217 | .socket_path = undefined, 218 | .pid = undefined, 219 | .command = command, 220 | }; 221 | daemon.socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name); 222 | std.log.info("socket path={s}", .{daemon.socket_path}); 223 | return attach(&daemon); 224 | } else { 225 | return help(); 226 | } 227 | } 228 | 229 | fn printVersion() !void { 230 | var buf: [256]u8 = undefined; 231 | var w = std.fs.File.stdout().writer(&buf); 232 | try w.interface.print("zmx {s}\nghostty-vt {s}\n", .{ version, ghostty_version }); 233 | try w.interface.flush(); 234 | } 235 | 236 | fn help() !void { 237 | const help_text = 238 | \\zmx - session persistence for terminal processes 239 | \\ 240 | \\Usage: zmx [args] 241 | \\ 242 | \\Commands: 243 | \\ [a]ttach [command...] Create or attach to a session 244 | \\ [d]etach Detach all clients from current session (ctrl+\ for current client) 245 | \\ [l]ist List active sessions 246 | \\ [k]ill Kill a session and all attached clients 247 | \\ [v]ersion Show version information 248 | \\ [h]elp Show this help message 249 | \\ 250 | ; 251 | var buf: [4096]u8 = undefined; 252 | var w = std.fs.File.stdout().writer(&buf); 253 | try w.interface.print(help_text, .{}); 254 | try w.interface.flush(); 255 | } 256 | 257 | const SessionEntry = struct { 258 | name: []const u8, 259 | pid: ?i32, 260 | clients_len: ?usize, 261 | is_error: bool, 262 | error_name: ?[]const u8, 263 | 264 | fn lessThan(_: void, a: SessionEntry, b: SessionEntry) bool { 265 | return std.mem.order(u8, a.name, b.name) == .lt; 266 | } 267 | }; 268 | 269 | fn list(cfg: *Cfg) !void { 270 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 271 | defer _ = gpa.deinit(); 272 | const alloc = gpa.allocator(); 273 | 274 | var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{ .iterate = true }); 275 | defer dir.close(); 276 | var iter = dir.iterate(); 277 | var buf: [4096]u8 = undefined; 278 | var w = std.fs.File.stdout().writer(&buf); 279 | 280 | var sessions = try std.ArrayList(SessionEntry).initCapacity(alloc, 16); 281 | defer { 282 | for (sessions.items) |session| { 283 | alloc.free(session.name); 284 | } 285 | sessions.deinit(alloc); 286 | } 287 | 288 | while (try iter.next()) |entry| { 289 | const exists = sessionExists(dir, entry.name) catch continue; 290 | if (exists) { 291 | const name = try alloc.dupe(u8, entry.name); 292 | errdefer alloc.free(name); 293 | 294 | const socket_path = try getSocketPath(alloc, cfg.socket_dir, entry.name); 295 | defer alloc.free(socket_path); 296 | 297 | const result = probeSession(alloc, socket_path) catch |err| { 298 | try sessions.append(alloc, .{ 299 | .name = name, 300 | .pid = null, 301 | .clients_len = null, 302 | .is_error = true, 303 | .error_name = @errorName(err), 304 | }); 305 | cleanupStaleSocket(dir, entry.name); 306 | continue; 307 | }; 308 | posix.close(result.fd); 309 | 310 | try sessions.append(alloc, .{ 311 | .name = name, 312 | .pid = result.info.pid, 313 | .clients_len = result.info.clients_len, 314 | .is_error = false, 315 | .error_name = null, 316 | }); 317 | } 318 | } 319 | 320 | if (sessions.items.len == 0) { 321 | try w.interface.print("no sessions found in {s}\n", .{cfg.socket_dir}); 322 | try w.interface.flush(); 323 | return; 324 | } 325 | 326 | std.mem.sort(SessionEntry, sessions.items, {}, SessionEntry.lessThan); 327 | 328 | for (sessions.items) |session| { 329 | if (session.is_error) { 330 | try w.interface.print("session_name={s}\tstatus={s}\t(cleaning up)\n", .{ session.name, session.error_name.? }); 331 | } else { 332 | try w.interface.print("session_name={s}\tpid={d}\tclients={d}\n", .{ session.name, session.pid.?, session.clients_len.? }); 333 | } 334 | try w.interface.flush(); 335 | } 336 | } 337 | 338 | fn detachAll(cfg: *Cfg) !void { 339 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 340 | defer _ = gpa.deinit(); 341 | const alloc = gpa.allocator(); 342 | const session_name = std.process.getEnvVarOwned(alloc, "ZMX_SESSION") catch |err| switch (err) { 343 | error.EnvironmentVariableNotFound => { 344 | std.log.err("ZMX_SESSION env var not found: are you inside a zmx session?", .{}); 345 | return; 346 | }, 347 | else => return err, 348 | }; 349 | defer alloc.free(session_name); 350 | 351 | var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{}); 352 | defer dir.close(); 353 | 354 | const socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name); 355 | defer alloc.free(socket_path); 356 | const result = probeSession(alloc, socket_path) catch |err| { 357 | std.log.err("session unresponsive: {s}", .{@errorName(err)}); 358 | cleanupStaleSocket(dir, session_name); 359 | return; 360 | }; 361 | defer posix.close(result.fd); 362 | ipc.send(result.fd, .DetachAll, "") catch |err| switch (err) { 363 | error.BrokenPipe, error.ConnectionResetByPeer => return, 364 | else => return err, 365 | }; 366 | } 367 | 368 | fn kill(cfg: *Cfg, session_name: []const u8) !void { 369 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 370 | defer _ = gpa.deinit(); 371 | const alloc = gpa.allocator(); 372 | 373 | var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{}); 374 | defer dir.close(); 375 | 376 | const exists = try sessionExists(dir, session_name); 377 | if (!exists) { 378 | std.log.err("cannot kill session because it does not exist session_name={s}", .{session_name}); 379 | return; 380 | } 381 | 382 | const socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name); 383 | defer alloc.free(socket_path); 384 | const result = probeSession(alloc, socket_path) catch |err| { 385 | std.log.err("session unresponsive: {s}", .{@errorName(err)}); 386 | cleanupStaleSocket(dir, session_name); 387 | var buf: [4096]u8 = undefined; 388 | var w = std.fs.File.stdout().writer(&buf); 389 | w.interface.print("cleaned up stale session {s}\n", .{session_name}) catch {}; 390 | w.interface.flush() catch {}; 391 | return; 392 | }; 393 | defer posix.close(result.fd); 394 | ipc.send(result.fd, .Kill, "") catch |err| switch (err) { 395 | error.BrokenPipe, error.ConnectionResetByPeer => return, 396 | else => return err, 397 | }; 398 | 399 | var buf: [4096]u8 = undefined; 400 | var w = std.fs.File.stdout().writer(&buf); 401 | try w.interface.print("killed session {s}\n", .{session_name}); 402 | try w.interface.flush(); 403 | } 404 | 405 | fn attach(daemon: *Daemon) !void { 406 | if (std.posix.getenv("ZMX_SESSION")) |_| { 407 | return error.CannotAttachToSessionInSession; 408 | } 409 | 410 | var dir = try std.fs.openDirAbsolute(daemon.cfg.socket_dir, .{}); 411 | defer dir.close(); 412 | 413 | const exists = try sessionExists(dir, daemon.session_name); 414 | var should_create = !exists; 415 | 416 | if (exists) { 417 | if (probeSession(daemon.alloc, daemon.socket_path)) |result| { 418 | posix.close(result.fd); 419 | if (daemon.command != null) { 420 | std.log.warn("session already exists, ignoring command session={s}", .{daemon.session_name}); 421 | } 422 | } else |_| { 423 | cleanupStaleSocket(dir, daemon.session_name); 424 | should_create = true; 425 | } 426 | } 427 | 428 | if (should_create) { 429 | std.log.info("creating session={s}", .{daemon.session_name}); 430 | const server_sock_fd = try createSocket(daemon.socket_path); 431 | 432 | const pid = try posix.fork(); 433 | if (pid == 0) { // child 434 | _ = try posix.setsid(); 435 | 436 | log_system.deinit(); 437 | const session_log_name = try std.fmt.allocPrint(daemon.alloc, "{s}.log", .{daemon.session_name}); 438 | defer daemon.alloc.free(session_log_name); 439 | const session_log_path = try std.fs.path.join(daemon.alloc, &.{ daemon.cfg.log_dir, session_log_name }); 440 | defer daemon.alloc.free(session_log_path); 441 | try log_system.init(daemon.alloc, session_log_path); 442 | 443 | errdefer { 444 | posix.close(server_sock_fd); 445 | dir.deleteFile(daemon.session_name) catch {}; 446 | } 447 | const pty_fd = try spawnPty(daemon); 448 | defer { 449 | posix.close(pty_fd); 450 | posix.close(server_sock_fd); 451 | std.log.info("deleting socket file session_name={s}", .{daemon.session_name}); 452 | dir.deleteFile(daemon.session_name) catch |err| { 453 | std.log.warn("failed to delete socket file err={s}", .{@errorName(err)}); 454 | }; 455 | } 456 | try daemonLoop(daemon, server_sock_fd, pty_fd); 457 | // Reap PTY child to prevent zombie 458 | _ = posix.waitpid(daemon.pid, 0); 459 | daemon.deinit(); 460 | return; 461 | } 462 | posix.close(server_sock_fd); 463 | std.Thread.sleep(10 * std.time.ns_per_ms); 464 | } 465 | 466 | const client_sock = try sessionConnect(daemon.socket_path); 467 | std.log.info("attached session={s}", .{daemon.session_name}); 468 | // this is typically used with tcsetattr() to modify terminal settings. 469 | // - you first get the current settings with tcgetattr() 470 | // - modify the desired attributes in the termios structure 471 | // - then apply the changes with tcsetattr(). 472 | // This prevents unintended side effects by preserving other settings. 473 | var orig_termios: c.termios = undefined; 474 | _ = c.tcgetattr(posix.STDIN_FILENO, &orig_termios); 475 | 476 | // restore stdin fd to its original state after exiting. 477 | // Use TCSAFLUSH to discard any unread input, preventing stale input after detach. 478 | defer { 479 | _ = c.tcsetattr(posix.STDIN_FILENO, c.TCSAFLUSH, &orig_termios); 480 | // Clear screen and show cursor on detach 481 | const restore_seq = "\x1b[?25h\x1b[2J\x1b[H"; 482 | _ = posix.write(posix.STDOUT_FILENO, restore_seq) catch {}; 483 | } 484 | 485 | var raw_termios = orig_termios; 486 | // set raw mode after successful connection. 487 | // disables canonical mode (line buffering), input echoing, signal generation from 488 | // control characters (like Ctrl+C), and flow control. 489 | c.cfmakeraw(&raw_termios); 490 | 491 | // Additional granular raw mode settings for precise control 492 | // (matches what abduco and shpool do) 493 | raw_termios.c_cc[c.VLNEXT] = c._POSIX_VDISABLE; // Disable literal-next (Ctrl-V) 494 | // We want to intercept Ctrl+\ (SIGQUIT) so we can use it as a detach key 495 | raw_termios.c_cc[c.VQUIT] = c._POSIX_VDISABLE; // Disable SIGQUIT (Ctrl+\) 496 | raw_termios.c_cc[c.VMIN] = 1; // Minimum chars to read: return after 1 byte 497 | raw_termios.c_cc[c.VTIME] = 0; // Read timeout: no timeout, return immediately 498 | 499 | _ = c.tcsetattr(posix.STDIN_FILENO, c.TCSANOW, &raw_termios); 500 | 501 | // Clear screen and move cursor to home before attaching 502 | const clear_seq = "\x1b[2J\x1b[H"; 503 | _ = try posix.write(posix.STDOUT_FILENO, clear_seq); 504 | 505 | try clientLoop(daemon.cfg, client_sock); 506 | } 507 | 508 | fn clientLoop(_: *Cfg, client_sock_fd: i32) !void { 509 | // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking 510 | const alloc = std.heap.c_allocator; 511 | defer posix.close(client_sock_fd); 512 | 513 | setupSigwinchHandler(); 514 | 515 | // Send init message with terminal size 516 | const size = getTerminalSize(posix.STDOUT_FILENO); 517 | ipc.send(client_sock_fd, .Init, std.mem.asBytes(&size)) catch {}; 518 | 519 | var poll_fds = try std.ArrayList(posix.pollfd).initCapacity(alloc, 2); 520 | defer poll_fds.deinit(alloc); 521 | 522 | var read_buf = try ipc.SocketBuffer.init(alloc); 523 | defer read_buf.deinit(); 524 | 525 | var stdout_buf = try std.ArrayList(u8).initCapacity(alloc, 4096); 526 | defer stdout_buf.deinit(alloc); 527 | 528 | const stdin_fd = posix.STDIN_FILENO; 529 | 530 | // Make stdin non-blocking 531 | const flags = try posix.fcntl(stdin_fd, posix.F.GETFL, 0); 532 | _ = try posix.fcntl(stdin_fd, posix.F.SETFL, flags | posix.SOCK.NONBLOCK); 533 | 534 | while (true) { 535 | // Check for pending SIGWINCH 536 | if (sigwinch_received.swap(false, .acq_rel)) { 537 | const next_size = getTerminalSize(posix.STDOUT_FILENO); 538 | ipc.send(client_sock_fd, .Resize, std.mem.asBytes(&next_size)) catch |err| switch (err) { 539 | error.BrokenPipe, error.ConnectionResetByPeer => return, 540 | else => return err, 541 | }; 542 | } 543 | 544 | poll_fds.clearRetainingCapacity(); 545 | 546 | try poll_fds.append(alloc, .{ 547 | .fd = stdin_fd, 548 | .events = posix.POLL.IN, 549 | .revents = 0, 550 | }); 551 | 552 | try poll_fds.append(alloc, .{ 553 | .fd = client_sock_fd, 554 | .events = posix.POLL.IN, 555 | .revents = 0, 556 | }); 557 | 558 | if (stdout_buf.items.len > 0) { 559 | try poll_fds.append(alloc, .{ 560 | .fd = posix.STDOUT_FILENO, 561 | .events = posix.POLL.OUT, 562 | .revents = 0, 563 | }); 564 | } 565 | 566 | _ = posix.poll(poll_fds.items, -1) catch |err| { 567 | if (err == error.Interrupted) continue; // EINTR from signal, loop again 568 | return err; 569 | }; 570 | 571 | // Handle stdin -> socket (Input) 572 | if (poll_fds.items[0].revents & (posix.POLL.IN | posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) { 573 | var buf: [4096]u8 = undefined; 574 | const n_opt: ?usize = posix.read(stdin_fd, &buf) catch |err| blk: { 575 | if (err == error.WouldBlock) break :blk null; 576 | return err; 577 | }; 578 | 579 | if (n_opt) |n| { 580 | if (n > 0) { 581 | // Check for Kitty keyboard protocol escape sequence for Ctrl+\ 582 | // Format: CSI 92 ; u where modifiers has Ctrl bit (bit 2) set 583 | // Examples: \e[92;5u (basic), \e[92;133u (with event flags) 584 | if (isKittyCtrlBackslash(buf[0..n])) { 585 | ipc.send(client_sock_fd, .Detach, "") catch |err| switch (err) { 586 | error.BrokenPipe, error.ConnectionResetByPeer => return, 587 | else => return err, 588 | }; 589 | continue; 590 | } 591 | 592 | var i: usize = 0; 593 | while (i < n) : (i += 1) { 594 | if (buf[i] == 0x1C) { // Ctrl+\ (File Separator) 595 | ipc.send(client_sock_fd, .Detach, "") catch |err| switch (err) { 596 | error.BrokenPipe, error.ConnectionResetByPeer => return, 597 | else => return err, 598 | }; 599 | } else { 600 | const payload = buf[i .. i + 1]; 601 | ipc.send(client_sock_fd, .Input, payload) catch |err| switch (err) { 602 | error.BrokenPipe, error.ConnectionResetByPeer => return, 603 | else => return err, 604 | }; 605 | } 606 | } 607 | } else { 608 | // EOF on stdin 609 | return; 610 | } 611 | } 612 | } 613 | 614 | // Handle socket -> stdout (Output) 615 | if (poll_fds.items[1].revents & posix.POLL.IN != 0) { 616 | const n = read_buf.read(client_sock_fd) catch |err| { 617 | if (err == error.WouldBlock) continue; 618 | if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) { 619 | return; 620 | } 621 | std.log.err("daemon read err={s}", .{@errorName(err)}); 622 | return err; 623 | }; 624 | if (n == 0) { 625 | return; // Server closed connection 626 | } 627 | 628 | while (read_buf.next()) |msg| { 629 | switch (msg.header.tag) { 630 | .Output => { 631 | if (msg.payload.len > 0) { 632 | try stdout_buf.appendSlice(alloc, msg.payload); 633 | } 634 | }, 635 | else => {}, 636 | } 637 | } 638 | } 639 | 640 | if (stdout_buf.items.len > 0) { 641 | const n = posix.write(posix.STDOUT_FILENO, stdout_buf.items) catch |err| blk: { 642 | if (err == error.WouldBlock) break :blk 0; 643 | return err; 644 | }; 645 | if (n > 0) { 646 | try stdout_buf.replaceRange(alloc, 0, n, &[_]u8{}); 647 | } 648 | } 649 | 650 | if (poll_fds.items[1].revents & (posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) { 651 | return; 652 | } 653 | } 654 | } 655 | 656 | fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void { 657 | std.log.info("daemon started session={s} pty_fd={d}", .{ daemon.session_name, pty_fd }); 658 | var should_exit = false; 659 | var poll_fds = try std.ArrayList(posix.pollfd).initCapacity(daemon.alloc, 8); 660 | defer poll_fds.deinit(daemon.alloc); 661 | 662 | const init_size = getTerminalSize(pty_fd); 663 | var term = try ghostty_vt.Terminal.init(daemon.alloc, .{ 664 | .cols = init_size.cols, 665 | .rows = init_size.rows, 666 | .max_scrollback = daemon.cfg.max_scrollback, 667 | }); 668 | defer term.deinit(daemon.alloc); 669 | var vt_stream = term.vtStream(); 670 | defer vt_stream.deinit(); 671 | 672 | while (!should_exit and daemon.running) { 673 | poll_fds.clearRetainingCapacity(); 674 | 675 | try poll_fds.append(daemon.alloc, .{ 676 | .fd = server_sock_fd, 677 | .events = posix.POLL.IN, 678 | .revents = 0, 679 | }); 680 | 681 | try poll_fds.append(daemon.alloc, .{ 682 | .fd = pty_fd, 683 | .events = posix.POLL.IN, 684 | .revents = 0, 685 | }); 686 | 687 | for (daemon.clients.items) |client| { 688 | var events: i16 = posix.POLL.IN; 689 | if (client.has_pending_output) { 690 | events |= posix.POLL.OUT; 691 | } 692 | try poll_fds.append(daemon.alloc, .{ 693 | .fd = client.socket_fd, 694 | .events = events, 695 | .revents = 0, 696 | }); 697 | } 698 | 699 | _ = posix.poll(poll_fds.items, -1) catch |err| { 700 | return err; 701 | }; 702 | 703 | if (poll_fds.items[0].revents & (posix.POLL.ERR | posix.POLL.HUP | posix.POLL.NVAL) != 0) { 704 | std.log.err("server socket error revents={d}", .{poll_fds.items[0].revents}); 705 | should_exit = true; 706 | } else if (poll_fds.items[0].revents & posix.POLL.IN != 0) { 707 | const client_fd = try posix.accept(server_sock_fd, null, null, posix.SOCK.NONBLOCK | posix.SOCK.CLOEXEC); 708 | const client = try daemon.alloc.create(Client); 709 | client.* = Client{ 710 | .alloc = daemon.alloc, 711 | .socket_fd = client_fd, 712 | .read_buf = try ipc.SocketBuffer.init(daemon.alloc), 713 | .write_buf = undefined, 714 | }; 715 | client.write_buf = try std.ArrayList(u8).initCapacity(client.alloc, 4096); 716 | try daemon.clients.append(daemon.alloc, client); 717 | std.log.info("client connected fd={d} total={d}", .{ client_fd, daemon.clients.items.len }); 718 | } 719 | 720 | if (poll_fds.items[1].revents & (posix.POLL.IN | posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) { 721 | // Read from PTY 722 | var buf: [4096]u8 = undefined; 723 | const n_opt: ?usize = posix.read(pty_fd, &buf) catch |err| blk: { 724 | if (err == error.WouldBlock) break :blk null; 725 | break :blk 0; 726 | }; 727 | 728 | if (n_opt) |n| { 729 | if (n == 0) { 730 | // EOF: Shell exited 731 | std.log.info("shell exited pty_fd={d}", .{pty_fd}); 732 | should_exit = true; 733 | } else { 734 | // Feed PTY output to terminal emulator for state tracking 735 | try vt_stream.nextSlice(buf[0..n]); 736 | daemon.has_pty_output = true; 737 | 738 | // Broadcast data to all clients 739 | for (daemon.clients.items) |client| { 740 | ipc.appendMessage(daemon.alloc, &client.write_buf, .Output, buf[0..n]) catch |err| { 741 | std.log.warn("failed to buffer output for client err={s}", .{@errorName(err)}); 742 | continue; 743 | }; 744 | client.has_pending_output = true; 745 | } 746 | } 747 | } 748 | } 749 | 750 | var i: usize = daemon.clients.items.len; 751 | // Only iterate over clients that were present when poll_fds was constructed 752 | // poll_fds contains [server, pty, client0, client1, ...] 753 | // So number of clients in poll_fds is poll_fds.items.len - 2 754 | const num_polled_clients = poll_fds.items.len - 2; 755 | if (i > num_polled_clients) { 756 | // If we have more clients than polled (i.e. we just accepted one), start from the polled ones 757 | i = num_polled_clients; 758 | } 759 | 760 | clients_loop: while (i > 0) { 761 | i -= 1; 762 | const client = daemon.clients.items[i]; 763 | const revents = poll_fds.items[i + 2].revents; 764 | 765 | if (revents & posix.POLL.IN != 0) { 766 | const n = client.read_buf.read(client.socket_fd) catch |err| { 767 | if (err == error.WouldBlock) continue; 768 | std.log.debug("client read err={s} fd={d}", .{ @errorName(err), client.socket_fd }); 769 | const last = daemon.closeClient(client, i, false); 770 | if (last) should_exit = true; 771 | continue; 772 | }; 773 | 774 | if (n == 0) { 775 | // Client closed connection 776 | const last = daemon.closeClient(client, i, false); 777 | if (last) should_exit = true; 778 | continue; 779 | } 780 | 781 | while (client.read_buf.next()) |msg| { 782 | switch (msg.header.tag) { 783 | .Input => { 784 | if (msg.payload.len > 0) { 785 | _ = try posix.write(pty_fd, msg.payload); 786 | } 787 | }, 788 | .Init => { 789 | if (msg.payload.len == @sizeOf(ipc.Resize)) { 790 | const resize = std.mem.bytesToValue(ipc.Resize, msg.payload); 791 | var ws: c.struct_winsize = .{ 792 | .ws_row = resize.rows, 793 | .ws_col = resize.cols, 794 | .ws_xpixel = 0, 795 | .ws_ypixel = 0, 796 | }; 797 | _ = c.ioctl(pty_fd, c.TIOCSWINSZ, &ws); 798 | try term.resize(daemon.alloc, resize.cols, resize.rows); 799 | 800 | std.log.debug("init resize rows={d} cols={d}", .{ resize.rows, resize.cols }); 801 | 802 | // Only send terminal state if there's been PTY output (skip on first attach) 803 | if (daemon.has_pty_output) { 804 | var builder: std.Io.Writer.Allocating = .init(daemon.alloc); 805 | defer builder.deinit(); 806 | var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(&term, .vt); 807 | term_formatter.content = .{ .selection = null }; 808 | term_formatter.extra = .{ 809 | .palette = false, // Don't override host terminal's palette 810 | .modes = true, 811 | .scrolling_region = true, 812 | .tabstops = true, 813 | .pwd = true, 814 | .keyboard = true, 815 | .screen = .all, 816 | }; 817 | term_formatter.format(&builder.writer) catch |err| { 818 | std.log.warn("failed to format terminal state err={s}", .{@errorName(err)}); 819 | }; 820 | const term_output = builder.writer.buffered(); 821 | if (term_output.len > 0) { 822 | ipc.appendMessage(daemon.alloc, &client.write_buf, .Output, term_output) catch |err| { 823 | std.log.warn("failed to buffer terminal state for client err={s}", .{@errorName(err)}); 824 | }; 825 | client.has_pending_output = true; 826 | } 827 | } 828 | } 829 | }, 830 | .Resize => { 831 | if (msg.payload.len == @sizeOf(ipc.Resize)) { 832 | const resize = std.mem.bytesToValue(ipc.Resize, msg.payload); 833 | var ws: c.struct_winsize = .{ 834 | .ws_row = resize.rows, 835 | .ws_col = resize.cols, 836 | .ws_xpixel = 0, 837 | .ws_ypixel = 0, 838 | }; 839 | _ = c.ioctl(pty_fd, c.TIOCSWINSZ, &ws); 840 | try term.resize(daemon.alloc, resize.cols, resize.rows); 841 | std.log.debug("resize rows={d} cols={d}", .{ resize.rows, resize.cols }); 842 | } 843 | }, 844 | .Detach => { 845 | std.log.info("client detach fd={d}", .{client.socket_fd}); 846 | _ = daemon.closeClient(client, i, false); 847 | break :clients_loop; 848 | }, 849 | .DetachAll => { 850 | std.log.info("detach all clients={d}", .{daemon.clients.items.len}); 851 | for (daemon.clients.items) |client_to_close| { 852 | client_to_close.deinit(); 853 | daemon.alloc.destroy(client_to_close); 854 | } 855 | daemon.clients.clearRetainingCapacity(); 856 | break :clients_loop; 857 | }, 858 | .Kill => { 859 | std.log.info("kill received session={s}", .{daemon.session_name}); 860 | posix.kill(daemon.pid, posix.SIG.TERM) catch |err| { 861 | std.log.warn("failed to send SIGTERM to pty child err={s}", .{@errorName(err)}); 862 | }; 863 | daemon.shutdown(); 864 | should_exit = true; 865 | break :clients_loop; 866 | }, 867 | .Info => { 868 | // subtract current client since it's just fetching info 869 | const clients_len = daemon.clients.items.len - 1; 870 | const info = ipc.Info{ 871 | .clients_len = clients_len, 872 | .pid = daemon.pid, 873 | }; 874 | try ipc.appendMessage(daemon.alloc, &client.write_buf, .Info, std.mem.asBytes(&info)); 875 | client.has_pending_output = true; 876 | }, 877 | .Output => {}, // Clients shouldn't send output 878 | } 879 | } 880 | } 881 | 882 | if (revents & posix.POLL.OUT != 0) { 883 | // Flush pending output buffers 884 | const n = posix.write(client.socket_fd, client.write_buf.items) catch |err| blk: { 885 | if (err == error.WouldBlock) break :blk 0; 886 | // Error on write, close client 887 | const last = daemon.closeClient(client, i, false); 888 | if (last) should_exit = true; 889 | continue; 890 | }; 891 | 892 | if (n > 0) { 893 | client.write_buf.replaceRange(daemon.alloc, 0, n, &[_]u8{}) catch unreachable; 894 | } 895 | 896 | if (client.write_buf.items.len == 0) { 897 | client.has_pending_output = false; 898 | } 899 | } 900 | 901 | if (revents & (posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) { 902 | const last = daemon.closeClient(client, i, false); 903 | if (last) should_exit = true; 904 | } 905 | } 906 | } 907 | } 908 | 909 | fn spawnPty(daemon: *Daemon) !c_int { 910 | const size = getTerminalSize(posix.STDOUT_FILENO); 911 | var ws: c.struct_winsize = .{ 912 | .ws_row = size.rows, 913 | .ws_col = size.cols, 914 | .ws_xpixel = 0, 915 | .ws_ypixel = 0, 916 | }; 917 | 918 | var master_fd: c_int = undefined; 919 | const pid = forkpty(&master_fd, null, null, &ws); 920 | if (pid < 0) { 921 | return error.ForkPtyFailed; 922 | } 923 | 924 | if (pid == 0) { // child pid code path 925 | const session_env = try std.fmt.allocPrint(daemon.alloc, "ZMX_SESSION={s}\x00", .{daemon.session_name}); 926 | _ = c.putenv(@ptrCast(session_env.ptr)); 927 | 928 | if (daemon.command) |cmd_args| { 929 | const alloc = std.heap.c_allocator; 930 | var argv_buf: [64:null]?[*:0]const u8 = undefined; 931 | for (cmd_args, 0..) |arg, i| { 932 | argv_buf[i] = alloc.dupeZ(u8, arg) catch { 933 | std.posix.exit(1); 934 | }; 935 | } 936 | argv_buf[cmd_args.len] = null; 937 | const argv: [*:null]const ?[*:0]const u8 = &argv_buf; 938 | const err = std.posix.execvpeZ(argv_buf[0].?, argv, std.c.environ); 939 | std.log.err("execvpe failed: cmd={s} err={s}", .{ cmd_args[0], @errorName(err) }); 940 | std.posix.exit(1); 941 | } else { 942 | const shell = std.posix.getenv("SHELL") orelse "/bin/sh"; 943 | const argv = [_:null]?[*:0]const u8{ shell, null }; 944 | const err = std.posix.execveZ(shell, &argv, std.c.environ); 945 | std.log.err("execve failed: err={s}", .{@errorName(err)}); 946 | std.posix.exit(1); 947 | } 948 | } 949 | // master pid code path 950 | daemon.pid = pid; 951 | std.log.info("pty spawned session={s} pid={d}", .{ daemon.session_name, pid }); 952 | 953 | // make pty non-blocking 954 | const flags = try posix.fcntl(master_fd, posix.F.GETFL, 0); 955 | _ = try posix.fcntl(master_fd, posix.F.SETFL, flags | @as(u32, 0o4000)); 956 | return master_fd; 957 | } 958 | 959 | fn sessionConnect(fname: []const u8) !i32 { 960 | var unix_addr = try std.net.Address.initUnix(fname); 961 | const socket_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.CLOEXEC, 0); 962 | errdefer posix.close(socket_fd); 963 | try posix.connect(socket_fd, &unix_addr.any, unix_addr.getOsSockLen()); 964 | return socket_fd; 965 | } 966 | 967 | const SessionProbeError = error{ 968 | Timeout, 969 | ConnectionRefused, 970 | Unexpected, 971 | }; 972 | 973 | const SessionProbeResult = struct { 974 | fd: i32, 975 | info: ipc.Info, 976 | }; 977 | 978 | fn probeSession(alloc: std.mem.Allocator, socket_path: []const u8) SessionProbeError!SessionProbeResult { 979 | const timeout_ms = 1000; 980 | const fd = sessionConnect(socket_path) catch |err| switch (err) { 981 | error.ConnectionRefused => return error.ConnectionRefused, 982 | else => return error.Unexpected, 983 | }; 984 | errdefer posix.close(fd); 985 | 986 | ipc.send(fd, .Info, "") catch return error.Unexpected; 987 | 988 | var poll_fds = [_]posix.pollfd{.{ .fd = fd, .events = posix.POLL.IN, .revents = 0 }}; 989 | const poll_result = posix.poll(&poll_fds, timeout_ms) catch return error.Unexpected; 990 | if (poll_result == 0) { 991 | return error.Timeout; 992 | } 993 | 994 | var sb = ipc.SocketBuffer.init(alloc) catch return error.Unexpected; 995 | defer sb.deinit(); 996 | 997 | const n = sb.read(fd) catch return error.Unexpected; 998 | if (n == 0) return error.Unexpected; 999 | 1000 | while (sb.next()) |msg| { 1001 | if (msg.header.tag == .Info) { 1002 | if (msg.payload.len == @sizeOf(ipc.Info)) { 1003 | return .{ 1004 | .fd = fd, 1005 | .info = std.mem.bytesToValue(ipc.Info, msg.payload[0..@sizeOf(ipc.Info)]), 1006 | }; 1007 | } 1008 | } 1009 | } 1010 | return error.Unexpected; 1011 | } 1012 | 1013 | fn cleanupStaleSocket(dir: std.fs.Dir, session_name: []const u8) void { 1014 | std.log.warn("stale socket found, cleaning up session={s}", .{session_name}); 1015 | dir.deleteFile(session_name) catch |err| { 1016 | std.log.warn("failed to delete stale socket err={s}", .{@errorName(err)}); 1017 | }; 1018 | } 1019 | 1020 | fn sessionExists(dir: std.fs.Dir, name: []const u8) !bool { 1021 | const stat = dir.statFile(name) catch |err| switch (err) { 1022 | error.FileNotFound => return false, 1023 | else => return err, 1024 | }; 1025 | if (stat.kind != .unix_domain_socket) { 1026 | return error.FileNotUnixSocket; 1027 | } 1028 | return true; 1029 | } 1030 | 1031 | fn createSocket(fname: []const u8) !i32 { 1032 | // AF.UNIX: Unix domain socket for local IPC with client processes 1033 | // SOCK.STREAM: Reliable, bidirectional communication 1034 | // SOCK.NONBLOCK: Set socket to non-blocking 1035 | const fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.NONBLOCK | posix.SOCK.CLOEXEC, 0); 1036 | errdefer posix.close(fd); 1037 | 1038 | var unix_addr = try std.net.Address.initUnix(fname); 1039 | try posix.bind(fd, &unix_addr.any, unix_addr.getOsSockLen()); 1040 | try posix.listen(fd, 128); 1041 | return fd; 1042 | } 1043 | 1044 | pub fn getSocketPath(alloc: std.mem.Allocator, socket_dir: []const u8, session_name: []const u8) ![]const u8 { 1045 | const dir = socket_dir; 1046 | const fname = try alloc.alloc(u8, dir.len + session_name.len + 1); 1047 | @memcpy(fname[0..dir.len], dir); 1048 | @memcpy(fname[dir.len .. dir.len + 1], "/"); 1049 | @memcpy(fname[dir.len + 1 ..], session_name); 1050 | return fname; 1051 | } 1052 | 1053 | fn handleSigwinch(_: i32, _: *const posix.siginfo_t, _: ?*anyopaque) callconv(.c) void { 1054 | sigwinch_received.store(true, .release); 1055 | } 1056 | 1057 | fn setupSigwinchHandler() void { 1058 | const act: posix.Sigaction = .{ 1059 | .handler = .{ .sigaction = handleSigwinch }, 1060 | .mask = posix.sigemptyset(), 1061 | .flags = posix.SA.SIGINFO, 1062 | }; 1063 | posix.sigaction(posix.SIG.WINCH, &act, null); 1064 | } 1065 | 1066 | fn getTerminalSize(fd: i32) ipc.Resize { 1067 | var ws: c.struct_winsize = undefined; 1068 | if (c.ioctl(fd, c.TIOCGWINSZ, &ws) == 0 and ws.ws_row > 0 and ws.ws_col > 0) { 1069 | return .{ .rows = ws.ws_row, .cols = ws.ws_col }; 1070 | } 1071 | return .{ .rows = 24, .cols = 80 }; 1072 | } 1073 | 1074 | /// Detects Kitty keyboard protocol escape sequence for Ctrl+\ 1075 | /// Common sequences: \e[92;5u (basic), \e[92;133u (with event flags) 1076 | fn isKittyCtrlBackslash(buf: []const u8) bool { 1077 | return std.mem.indexOf(u8, buf, "\x1b[92;5u") != null or 1078 | std.mem.indexOf(u8, buf, "\x1b[92;133u") != null; 1079 | } 1080 | 1081 | test "isKittyCtrlBackslash" { 1082 | try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5u")); 1083 | try std.testing.expect(isKittyCtrlBackslash("\x1b[92;133u")); 1084 | try std.testing.expect(!isKittyCtrlBackslash("\x1b[92;1u")); 1085 | try std.testing.expect(!isKittyCtrlBackslash("\x1b[93;5u")); 1086 | try std.testing.expect(!isKittyCtrlBackslash("garbage")); 1087 | } 1088 | --------------------------------------------------------------------------------