Commit df3d8a2

Eric Bower  ·  2026-08-19 09:20:06 -0400 EDT
parent 883b02d
feat: term command to print terminal state as json

This dumps the terminal state from `libghostty.Terminal`.  This currently
doesn't perfectly reflect the outer terminal state because `Terminal` only
knows about the modes and states that were passed into it via terminal
bytestream.

`zmx term .`
`zmx term {sesh}`
`zmx term . | jq`
6 files changed,  +371, -11
+1, -0
......@@ -112,6 +112,7 @@ Commands:
112112 set <name> k=v ... Set session labels
113113 [un]set <name> key ... Remove session labels
114114 [cl]ear <name> Clear all session labels
115+ term <name> Get terminal state as JSON
115116 [k]ill <name>... [--force] Kill session and all attached clients
116117 [hi]story <name> [--vt|--html] Output session scrollback
117118 [w]ait <name>... Wait for session tasks to complete
+10, -4
......@@ -32,7 +32,7 @@ const bash_completions =
3232 \\ cur="${COMP_WORDS[COMP_CWORD]}"
3333 \\ prev="${COMP_WORDS[COMP_CWORD-1]}"
3434 \\
35- \\ local commands="attach run send print write detach list kill history get set clear wait tail completions version help"
35+ \\ local commands="attach run send print write detach list kill history get set clear term wait tail completions version help"
3636 \\
3737 \\ if [[ $COMP_CWORD -eq 1 ]]; then
3838 \\ COMPREPLY=($(compgen -W "$commands" -- "$cur"))
......@@ -40,7 +40,7 @@ const bash_completions =
4040 \\ fi
4141 \\
4242 \\ case "$prev" in
43- \\ attach|run|send|print|write|kill|history|get|set|clear|wait|tail)
43+ \\ attach|run|send|print|write|kill|history|get|set|clear|term|wait|tail)
4444 \\ local sessions=$(zmx list --short 2>/dev/null | tr '\n' ' ')
4545 \\ COMPREPLY=($(compgen -W "$sessions" -- "$cur"))
4646 \\ ;;
......@@ -89,6 +89,7 @@ const zsh_completions =
8989 \\ 'get:Get session labels'
9090 \\ 'set:Set session labels'
9191 \\ 'clear:Clear all session labels'
92+ \\ 'term:Get terminal state as JSON'
9293 \\ 'version:Show version'
9394 \\ 'help:Show help message'
9495 \\ )
......@@ -96,7 +97,7 @@ const zsh_completions =
9697 \\ ;;
9798 \\ args)
9899 \\ case $words[2] in
99- \\ attach|a|kill|k|run|r|send|s|print|p|write|wr|history|get|g|set|clear|hi|wait|w|tail|t)
100+ \\ attach|a|kill|k|run|r|send|s|print|p|write|wr|history|get|g|set|clear|term|hi|wait|w|tail|t)
100101 \\ _zmx_sessions
101102 \\ ;;
102103 \\ completions|c)
......@@ -151,10 +152,11 @@ const fish_completions =
151152 \\complete -c zmx -n "__fish_is_nth_token 1" -a get -d 'Get session labels'
152153 \\complete -c zmx -n "__fish_is_nth_token 1" -a set -d 'Set session labels'
153154 \\complete -c zmx -n "__fish_is_nth_token 1" -a clear -d 'Clear all session labels'
155+ \\complete -c zmx -n "__fish_is_nth_token 1" -a term -d 'Get terminal state as JSON'
154156 \\complete -c zmx -n "__fish_is_nth_token 1" -a help -d 'Show help message'
155157 \\
156158 \\# Complete session names and shells
157- \\complete -c zmx -n "__fish_is_nth_token 2; and __fish_seen_subcommand_from a attach r run s send p print wr write hi history g get se set cl clear" -a '(zmx list --short 2>/dev/null)' -d 'Session name'
159+ \\complete -c zmx -n "__fish_is_nth_token 2; and __fish_seen_subcommand_from a attach r run s send p print wr write hi history g get se set cl clear term" -a '(zmx list --short 2>/dev/null)' -d 'Session name'
158160 \\complete -c zmx -n "not __fish_is_nth_token 1; and __fish_seen_subcommand_from k kill w wait t tail" -a '(zmx list --short 2>/dev/null)' -d 'Session name'
159161 \\
160162 \\complete -c zmx -n "__fish_is_nth_token 2; and __fish_seen_subcommand_from c completions" -a 'bash zsh fish nu' -d Shell
......@@ -230,5 +232,9 @@ const nu_completions =
230232 \\ name?: string@"nu-complete zmx sessions"
231233 \\]
232234 \\
235+ \\export extern "zmx term" [
236+ \\ name?: string@"nu-complete zmx sessions"
237+ \\]
238+ \\
233239 \\export extern "zmx help" []
234240 ;
+13, -6
......@@ -23,6 +23,8 @@ pub const Tag = enum(u8) {
2323 LabelClear = 16,
2424 LabelData = 17,
2525 Send = 18,
26+ TermGet = 19,
27+ TermData = 20,
2628 // Non-exhaustive: this enum comes off the wire via bytesToValue and
2729 // @enumFromInt, so out-of-range values are representable
2830 // rather than UB. Switches must handle `_` (unknown tag).
......@@ -320,7 +322,7 @@ test "Tag wire values are frozen" {
320322 .{ Tag.Run, 9 }, .{ Tag.Ack, 10 }, .{ Tag.Switch, 11 },
321323 .{ Tag.Write, 12 }, .{ Tag.TaskComplete, 13 }, .{ Tag.LabelGet, 14 },
322324 .{ Tag.LabelSet, 15 }, .{ Tag.LabelClear, 16 }, .{ Tag.LabelData, 17 },
323- .{ Tag.Send, 18 },
325+ .{ Tag.Send, 18 }, .{ Tag.TermGet, 19 }, .{ Tag.TermData, 20 },
324326 }) |p| try std.testing.expectEqual(@as(u8, p[1]), @intFromEnum(p[0]));
325327 }
326328
......@@ -344,13 +346,18 @@ pub fn roundTripForTag(
344346 var sb = SocketBuffer.init(alloc) catch return error.Unexpected;
345347 defer sb.deinit();
346348
347- const n = sb.read(fd) catch return error.Unexpected;
348- if (n == 0) return error.Unexpected;
349+ while (true) {
350+ const n = sb.read(fd) catch return error.Unexpected;
351+ if (n == 0) return error.Unexpected;
349352
350- while (sb.next()) |msg| {
351- if (msg.header.tag == expected_tag) {
352- return alloc.dupe(u8, msg.payload) catch return error.Unexpected;
353+ while (sb.next()) |msg| {
354+ if (msg.header.tag == expected_tag) {
355+ return alloc.dupe(u8, msg.payload) catch return error.Unexpected;
356+ }
353357 }
358+
359+ const more = lib_posix.poll(&poll_fds, timeout_ms) catch break;
360+ if (more == 0) return error.Timeout;
354361 }
355362 return error.Unexpected;
356363 }
+18, -1
......@@ -11,6 +11,7 @@ const Cfg = @import("cfg.zig");
1111 const signal = @import("signal.zig");
1212 const assert = std.debug.assert;
1313 const daemonize = @import("daemonize.zig");
14+const term_mod = @import("term.zig");
1415 const builtin = @import("builtin");
1516
1617 /// clientLoop sends ipc commands to its corresponding daemon. It uses poll() as its non-blocking
......@@ -473,9 +474,10 @@ fn daemonLoop(daemon: *Daemon, gpa: std.mem.Allocator, io: std.Io, server_sock_f
473474 .LabelGet => try daemon.handleLabelGet(gpa, client),
474475 .LabelSet => try daemon.handleLabelSet(gpa, client, msg.payload),
475476 .LabelClear => try daemon.handleLabelClear(gpa, client),
477+ .TermGet => try daemon.handleTermGet(gpa, client, &term),
476478 .History => try daemon.handleHistory(gpa, client, &term, msg.payload),
477479 .Run => try daemon.handleRun(gpa, io, client, msg.payload),
478- .Ack, .TaskComplete, .LabelData => {},
480+ .Ack, .TaskComplete, .LabelData, .TermData => {},
479481 .Write => try daemon.handleWrite(gpa, client, msg.payload),
480482 _ => std.log.warn(
481483 "ignoring unknown IPC tag={d}",
......@@ -1302,6 +1304,21 @@ pub const Daemon = struct {
13021304 try ipc.appendMessage(gpa, &client.write_buf, .Ack, "");
13031305 client.has_pending_output = true;
13041306 }
1307+
1308+ fn handleTermGet(self: *Daemon, gpa: std.mem.Allocator, client: *Client, term: *ghostty_vt.Terminal) !void {
1309+ self.setPwd(term);
1310+ var info = std.mem.zeroes(ipc.Info);
1311+ info.clients_len = if (self.clients.items.len > 0) self.clients.items.len - 1 else 0;
1312+ info.pid = self.pid;
1313+ info.created_at = self.created_at;
1314+ info.task_ended_at = self.task_ended_at orelse 0;
1315+ info.task_exit_code = self.task_exit_code orelse 0;
1316+
1317+ const json = try term_mod.dumpState(gpa, term, &info);
1318+ defer gpa.free(json);
1319+ try ipc.appendMessage(gpa, &client.write_buf, .TermData, json);
1320+ client.has_pending_output = true;
1321+ }
13051322 };
13061323
13071324 test "send queues PTY input without changing leader" {
+48, -0
......@@ -91,6 +91,12 @@ pub fn main(init: std.process.Init) !void {
9191 const sesh = try socket.resolveSessionOrEnv(gpa, io, sesh_name);
9292 defer gpa.free(sesh);
9393 return labelClear(gpa, io, &cfg, sesh);
94+ } else if (std.mem.eql(u8, cmd, "term")) {
95+ const sesh_name = args.next() orelse return error.SessionNameRequired;
96+ if (detectHelp(sesh_name)) return help(io);
97+ const sesh = try socket.resolveSessionOrEnv(gpa, io, sesh_name);
98+ defer gpa.free(sesh);
99+ return termGet(gpa, io, &cfg, sesh);
94100 } else if (std.mem.eql(u8, cmd, "completions") or std.mem.eql(u8, cmd, "c")) {
95101 const arg = args.next() orelse return;
96102 if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
......@@ -421,6 +427,7 @@ fn help(io: std.Io) !void {
421427 \\ [g]et <name> Get session labels
422428 \\ set <name> k=v ... Set session labels (k= to remove)
423429 \\ [cl]ear <name> Clear all session labels
430+ \\ term <name> Get terminal state as JSON
424431 \\ [k]ill <name>... [--force] Kill session and all attached clients
425432 \\ [hi]story <name> [--vt|--html] Output session scrollback
426433 \\ [w]ait <name>... Wait for session tasks to complete
......@@ -1129,6 +1136,47 @@ fn labelClear(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []c
11291136 };
11301137 }
11311138
1139+fn printTermError(io: std.Io, session_name: []const u8, err: anyerror) noreturn {
1140+ var buf: [4096]u8 = undefined;
1141+ var w = std.Io.File.stderr().writer(io, &buf);
1142+ switch (err) {
1143+ error.Timeout => w.interface.print(
1144+ "error: session \"{s}\" does not support term commands (daemon too old?)\n",
1145+ .{session_name},
1146+ ) catch {},
1147+ error.ConnectionRefused, error.Unexpected => w.interface.print(
1148+ "error: session \"{s}\" not found or unresponsive\n",
1149+ .{session_name},
1150+ ) catch {},
1151+ else => w.interface.print(
1152+ "error: {s}\n",
1153+ .{@errorName(err)},
1154+ ) catch {},
1155+ }
1156+ w.interface.flush() catch {};
1157+ std.process.exit(1);
1158+}
1159+
1160+fn termGet(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8) !void {
1161+ std.log.info("term get session={s}", .{session_name});
1162+
1163+ const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
1164+ error.NameTooLong => return socket.printSessionNameTooLong(io, session_name, cfg.socket_dir),
1165+ error.OutOfMemory => return err,
1166+ };
1167+ defer alloc.free(socket_path);
1168+
1169+ const payload = ipc.roundTripForTag(alloc, socket_path, .TermGet, "", .TermData) catch |err| {
1170+ printTermError(io, session_name, err);
1171+ };
1172+ defer alloc.free(payload);
1173+
1174+ var buf: [4096]u8 = undefined;
1175+ var stdout = std.Io.File.stdout().writer(io, &buf);
1176+ try stdout.interface.print("{s}", .{payload});
1177+ try stdout.interface.flush();
1178+}
1179+
11321180 /// Fetch terminal history from a session socket, returning it as an allocated
11331181 /// string. Caller owns the returned memory and must free it.
11341182 fn fetchHistory(
+281, -0
......@@ -0,0 +1,281 @@
1+const std = @import("std");
2+const ghostty_vt = @import("ghostty-vt");
3+const ipc = @import("ipc.zig");
4+const Allocator = std.mem.Allocator;
5+
6+pub fn dumpState(alloc: Allocator, term: *const ghostty_vt.Terminal, daemon_info: ?*const ipc.Info) ![]u8 {
7+ var out: std.Io.Writer.Allocating = .init(alloc);
8+ defer out.deinit();
9+
10+ var jw: std.json.Stringify = .{
11+ .writer = &out.writer,
12+ .options = .{ .whitespace = .indent_2 },
13+ };
14+
15+ try jw.beginObject();
16+
17+ // meta
18+ try jw.objectField("meta");
19+ try jw.beginObject();
20+ try jw.objectField("title");
21+ try jw.write(term.getTitle() orelse "");
22+ try jw.objectField("pwd");
23+ try jw.write(term.getPwd() orelse "");
24+ if (daemon_info) |info| {
25+ try jw.objectField("pid");
26+ try jw.write(info.pid);
27+ try jw.objectField("clients");
28+ try jw.write(info.clients_len);
29+ }
30+ try jw.endObject();
31+
32+ // geometry
33+ try jw.objectField("geometry");
34+ try jw.beginObject();
35+ try jw.objectField("rows");
36+ try jw.write(term.rows);
37+ try jw.objectField("cols");
38+ try jw.write(term.cols);
39+ try jw.objectField("width_px");
40+ try jw.write(term.width_px);
41+ try jw.objectField("height_px");
42+ try jw.write(term.height_px);
43+ try jw.objectField("scrolling_region");
44+ try jw.beginObject();
45+ try jw.objectField("top");
46+ try jw.write(term.scrolling_region.top);
47+ try jw.objectField("bottom");
48+ try jw.write(term.scrolling_region.bottom);
49+ try jw.endObject();
50+ try jw.endObject();
51+
52+ // cursor
53+ const active_screen = term.screens.active;
54+ try jw.objectField("cursor");
55+ try jw.beginObject();
56+ try jw.objectField("x");
57+ try jw.write(active_screen.cursor.x);
58+ try jw.objectField("y");
59+ try jw.write(active_screen.cursor.y);
60+ try jw.objectField("style");
61+ try jw.write(@tagName(active_screen.cursor.cursor_style));
62+ try jw.objectField("blinking");
63+ try jw.write(term.modes.get(.cursor_blinking));
64+ try jw.objectField("visible");
65+ try jw.write(term.modes.get(.cursor_visible));
66+ try jw.endObject();
67+
68+ // colors
69+ try jw.objectField("colors");
70+ try jw.beginObject();
71+ if (term.colors.background.get()) |bg| {
72+ var buf: [7]u8 = undefined;
73+ const hex = std.fmt.bufPrint(&buf, "#{x:0>2}{x:0>2}{x:0>2}", .{ bg.r, bg.g, bg.b }) catch unreachable;
74+ try jw.objectField("background");
75+ try jw.write(hex);
76+ try jw.objectField("appearance");
77+ const lum = @as(f32, @floatFromInt(bg.r)) * 0.299 +
78+ @as(f32, @floatFromInt(bg.g)) * 0.587 +
79+ @as(f32, @floatFromInt(bg.b)) * 0.114;
80+ if (lum < 128.0) {
81+ try jw.write("dark");
82+ } else {
83+ try jw.write("light");
84+ }
85+ } else {
86+ try jw.objectField("background");
87+ try jw.write(null);
88+ try jw.objectField("appearance");
89+ try jw.write("dark");
90+ }
91+
92+ if (term.colors.foreground.get()) |fg| {
93+ var buf: [7]u8 = undefined;
94+ const hex = std.fmt.bufPrint(&buf, "#{x:0>2}{x:0>2}{x:0>2}", .{ fg.r, fg.g, fg.b }) catch unreachable;
95+ try jw.objectField("foreground");
96+ try jw.write(hex);
97+ } else {
98+ try jw.objectField("foreground");
99+ try jw.write(null);
100+ }
101+
102+ if (term.colors.cursor.get()) |c| {
103+ var buf: [7]u8 = undefined;
104+ const hex = std.fmt.bufPrint(&buf, "#{x:0>2}{x:0>2}{x:0>2}", .{ c.r, c.g, c.b }) catch unreachable;
105+ try jw.objectField("cursor");
106+ try jw.write(hex);
107+ } else {
108+ try jw.objectField("cursor");
109+ try jw.write(null);
110+ }
111+
112+ // palette
113+ try jw.objectField("palette");
114+ try jw.beginArray();
115+ for (term.colors.palette.current) |c| {
116+ var buf: [7]u8 = undefined;
117+ const hex = std.fmt.bufPrint(&buf, "#{x:0>2}{x:0>2}{x:0>2}", .{ c.r, c.g, c.b }) catch unreachable;
118+ try jw.write(hex);
119+ }
120+ try jw.endArray();
121+ try jw.endObject();
122+
123+ // modes
124+ try jw.objectField("modes");
125+ try jw.beginObject();
126+ try jw.objectField("alt_screen");
127+ try jw.write(term.screens.active_key == .alternate);
128+ inline for (std.meta.fields(ghostty_vt.modes.Mode)) |f| {
129+ const mode = @field(ghostty_vt.modes.Mode, f.name);
130+ try jw.objectField(f.name);
131+ try jw.write(term.modes.get(mode));
132+ }
133+ try jw.endObject();
134+
135+ // mouse
136+ try jw.objectField("mouse");
137+ try jw.beginObject();
138+ try jw.objectField("shape");
139+ try jw.write(@tagName(term.mouse_shape));
140+ try jw.objectField("event");
141+ try jw.write(@tagName(term.flags.mouse_event));
142+ try jw.objectField("format");
143+ try jw.write(@tagName(term.flags.mouse_format));
144+ try jw.objectField("shift_capture");
145+ try jw.write(@tagName(term.flags.mouse_shift_capture));
146+ try jw.endObject();
147+
148+ // keyboard
149+ try jw.objectField("keyboard");
150+ try jw.beginObject();
151+ const kflags = active_screen.kitty_keyboard.current();
152+ try jw.objectField("kitty_flags");
153+ try jw.write(kflags.int());
154+ try jw.objectField("kitty_disambiguate");
155+ try jw.write(kflags.disambiguate);
156+ try jw.objectField("kitty_report_events");
157+ try jw.write(kflags.report_events);
158+ try jw.objectField("kitty_report_alternates");
159+ try jw.write(kflags.report_alternates);
160+ try jw.objectField("kitty_report_all");
161+ try jw.write(kflags.report_all);
162+ try jw.objectField("kitty_report_associated");
163+ try jw.write(kflags.report_associated);
164+ try jw.objectField("modify_other_keys_2");
165+ try jw.write(term.flags.modify_other_keys_2);
166+ try jw.endObject();
167+
168+ // buffer
169+ try jw.objectField("buffer");
170+ try jw.beginObject();
171+ var total_rows: usize = 0;
172+ var page_it = active_screen.pages.pages.first;
173+ while (page_it) |node| : (page_it = node.next) {
174+ total_rows += node.rows();
175+ }
176+ const scrollback_rows = total_rows - @min(total_rows, @as(usize, active_screen.pages.rows));
177+ try jw.objectField("scrollback_rows");
178+ try jw.write(scrollback_rows);
179+ try jw.objectField("has_saved_cursor");
180+ try jw.write(active_screen.saved_cursor != null);
181+ if (active_screen.saved_cursor) |sc| {
182+ try jw.objectField("saved_cursor");
183+ try jw.beginObject();
184+ try jw.objectField("x");
185+ try jw.write(sc.x);
186+ try jw.objectField("y");
187+ try jw.write(sc.y);
188+ try jw.objectField("origin");
189+ try jw.write(sc.origin);
190+ try jw.objectField("pending_wrap");
191+ try jw.write(sc.pending_wrap);
192+ try jw.endObject();
193+ }
194+ try jw.objectField("has_selection");
195+ try jw.write(active_screen.selection != null);
196+ try jw.objectField("protected_mode");
197+ try jw.write(@tagName(active_screen.protected_mode));
198+ try jw.endObject();
199+
200+ // shell_integration
201+ try jw.objectField("shell_integration");
202+ try jw.beginObject();
203+ try jw.objectField("semantic_prompt");
204+ try jw.write(active_screen.semantic_prompt.seen);
205+ try jw.objectField("shell_redraws_prompt");
206+ try jw.write(@tagName(term.flags.shell_redraws_prompt));
207+ try jw.objectField("password_input");
208+ try jw.write(term.flags.password_input);
209+ try jw.endObject();
210+
211+ // tabstops
212+ try jw.objectField("tabstops");
213+ try jw.beginArray();
214+ var col: usize = 0;
215+ while (col < term.cols) : (col += 1) {
216+ if (term.tabstops.get(col)) {
217+ try jw.write(col);
218+ }
219+ }
220+ try jw.endArray();
221+
222+ // charsets
223+ try jw.objectField("charsets");
224+ try jw.beginObject();
225+ try jw.objectField("g0");
226+ try jw.write(@tagName(active_screen.charset.charsets.g0));
227+ try jw.objectField("g1");
228+ try jw.write(@tagName(active_screen.charset.charsets.g1));
229+ try jw.objectField("g2");
230+ try jw.write(@tagName(active_screen.charset.charsets.g2));
231+ try jw.objectField("g3");
232+ try jw.write(@tagName(active_screen.charset.charsets.g3));
233+ try jw.objectField("gl");
234+ try jw.write(@tagName(active_screen.charset.gl));
235+ try jw.objectField("gr");
236+ try jw.write(@tagName(active_screen.charset.gr));
237+ try jw.endObject();
238+
239+ // flags
240+ try jw.objectField("flags");
241+ try jw.beginObject();
242+ try jw.objectField("focused");
243+ try jw.write(term.flags.focused);
244+ try jw.endObject();
245+
246+ try jw.endObject();
247+
248+ try out.writer.writeAll("\n");
249+ return alloc.dupe(u8, out.written());
250+}
251+
252+test "dumpState generates valid json with terminal state" {
253+ const testing = std.testing;
254+ const alloc = testing.allocator;
255+ const io = testing.io;
256+
257+ var term = try ghostty_vt.Terminal.init(io, alloc, .{
258+ .cols = 80,
259+ .rows = 24,
260+ });
261+ defer term.deinit(alloc);
262+
263+ try term.setTitle("my window");
264+ try term.setPwd("/home/user");
265+
266+ const json = try dumpState(alloc, &term, null);
267+ defer alloc.free(json);
268+
269+ try testing.expect(std.mem.indexOf(u8, json, "\"title\": \"my window\"") != null);
270+ try testing.expect(std.mem.indexOf(u8, json, "\"pwd\": \"/home/user\"") != null);
271+ try testing.expect(std.mem.indexOf(u8, json, "\"rows\": 24") != null);
272+ try testing.expect(std.mem.indexOf(u8, json, "\"cols\": 80") != null);
273+ try testing.expect(std.mem.indexOf(u8, json, "\"cursor\"") != null);
274+ try testing.expect(std.mem.indexOf(u8, json, "\"colors\"") != null);
275+ try testing.expect(std.mem.indexOf(u8, json, "\"modes\"") != null);
276+ try testing.expect(std.mem.indexOf(u8, json, "\"keyboard\"") != null);
277+ try testing.expect(std.mem.indexOf(u8, json, "\"buffer\"") != null);
278+ try testing.expect(std.mem.indexOf(u8, json, "\"shell_integration\"") != null);
279+ try testing.expect(std.mem.indexOf(u8, json, "\"tabstops\"") != null);
280+ try testing.expect(std.mem.indexOf(u8, json, "\"charsets\"") != null);
281+}