From d0ac04eac99a41a06a7f87f12566af2f3f093b35 Mon Sep 17 00:00:00 2001 From: James Ainslie <42301770+jamesainslie@users.noreply.github.com> Date: Fri, 20 Mar 2026 07:45:23 -0400 Subject: [PATCH] feat: add tmux control mode embedder API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add C API and internal plumbing for tmux control mode events to reach the embedder (cmux). The Ghostty Viewer state machine already parses the tmux protocol; this change surfaces those events across the Zig→C→Swift boundary. Changes: - viewer.zig: Add .pane_output Action variant, emit for tracked panes - action.zig: Add TmuxControl struct with Event enum and C extern type - surface.zig: Add tmux_control surface message with WriteReq data - stream_handler.zig: Wire .exit, .pane_output, .windows through surface messages; add JSON serialization for windows payload - layout.zig: Add jsonStringify for recursive Layout tree serialization - Surface.zig: Handle tmux_control message, convert to performAction - ghostty.h: Add ghostty_tmux_event_e, ghostty_action_tmux_control_s, GHOSTTY_ACTION_TMUX_CONTROL --- include/ghostty.h | 24 ++++++++++ src/Surface.zig | 13 ++++++ src/apprt/action.zig | 52 +++++++++++++++++++++ src/apprt/surface.zig | 16 +++++++ src/terminal/tmux/layout.zig | 37 +++++++++++++++ src/terminal/tmux/viewer.zig | 50 +++++++++++++++++---- src/termio/stream_handler.zig | 85 ++++++++++++++++++++++++++++++++--- 7 files changed, 262 insertions(+), 15 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 65b1cdc5a45..f6b648f8b0e 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -858,6 +858,28 @@ typedef struct { uint64_t len; } ghostty_action_scrollbar_s; +// apprt.action.TmuxControl.Event +typedef enum { + GHOSTTY_TMUX_ENTER = 0, + GHOSTTY_TMUX_EXIT = 1, + GHOSTTY_TMUX_WINDOWS_CHANGED = 2, + GHOSTTY_TMUX_PANE_OUTPUT = 3, + GHOSTTY_TMUX_LAYOUT_CHANGE = 4, + GHOSTTY_TMUX_WINDOW_ADD = 5, + GHOSTTY_TMUX_WINDOW_CLOSE = 6, + GHOSTTY_TMUX_WINDOW_RENAMED = 7, + GHOSTTY_TMUX_SESSION_CHANGED = 8, + GHOSTTY_TMUX_SESSION_RENAMED = 9, +} ghostty_tmux_event_e; + +// apprt.action.TmuxControl.C +typedef struct { + ghostty_tmux_event_e event; + uint32_t id; + const uint8_t *data; + uintptr_t data_len; +} ghostty_action_tmux_control_s; + // apprt.Action.Key typedef enum { GHOSTTY_ACTION_QUIT, @@ -924,6 +946,7 @@ typedef enum { GHOSTTY_ACTION_SEARCH_SELECTED, GHOSTTY_ACTION_READONLY, GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD, + GHOSTTY_ACTION_TMUX_CONTROL, } ghostty_action_tag_e; typedef union { @@ -964,6 +987,7 @@ typedef union { ghostty_action_search_total_s search_total; ghostty_action_search_selected_s search_selected; ghostty_action_readonly_e readonly; + ghostty_action_tmux_control_s tmux_control; } ghostty_action_u; typedef struct { diff --git a/src/Surface.zig b/src/Surface.zig index 50e55e722a0..4c055169c44 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1181,6 +1181,19 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { .{ .selected = v }, ); }, + + .tmux_control => |v| { + defer v.data.deinit(); + _ = try self.rt_app.performAction( + .{ .surface = self }, + .tmux_control, + .{ + .event = v.event, + .id = v.id, + .data = v.data.slice(), + }, + ); + }, } } diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 55e80a70063..52ccd81ee15 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -340,6 +340,11 @@ pub const Action = union(Key) { /// otherwise the terminal-set title. copy_title_to_clipboard, + /// A tmux control mode event from the Viewer. The embedder uses + /// this to create/destroy native windows and panes that mirror + /// the tmux server state. + tmux_control: TmuxControl, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -406,6 +411,7 @@ pub const Action = union(Key) { search_selected, readonly, copy_title_to_clipboard, + tmux_control, test "ghostty.h Action.Key" { try lib.checkGhosttyHEnum(Key, "GHOSTTY_ACTION_"); @@ -998,6 +1004,52 @@ pub const SearchSelected = struct { } }; +/// Tmux control mode event from the Viewer state machine. +/// The embedder receives these to manage native windows/panes +/// that mirror the tmux server state. +pub const TmuxControl = struct { + event: Event, + /// Contextual ID: pane_id for pane_output, window_id for window_* + /// and layout_change events. Unused for enter/exit/session events. + id: u32 = 0, + data: []const u8 = &.{}, + + pub const Event = enum(c_int) { + enter = 0, + exit = 1, + windows_changed = 2, + pane_output = 3, + layout_change = 4, + window_add = 5, + window_close = 6, + window_renamed = 7, + session_changed = 8, + session_renamed = 9, + + // Sync with: ghostty_tmux_event_e + test "ghostty.h TmuxControl.Event" { + try lib.checkGhosttyHEnum(Event, "GHOSTTY_TMUX_"); + } + }; + + // Sync with: ghostty_action_tmux_control_s + pub const C = extern struct { + event: Event, + id: u32, + data: [*]const u8, + data_len: usize, + }; + + pub fn cval(self: TmuxControl) C { + return .{ + .event = self.event, + .id = self.id, + .data = self.data.ptr, + .data_len = self.data.len, + }; + } +}; + test { _ = std.testing.refAllDeclsRecursive(@This()); } diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 5c25281c8d0..38036b43276 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -1,6 +1,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; +const action = @import("action.zig"); const apprt = @import("../apprt.zig"); const build_config = @import("../build_config.zig"); const App = @import("../App.zig"); @@ -108,6 +109,21 @@ pub const Message = union(enum) { /// Selected search index change search_selected: ?usize, + /// A tmux control mode event from the Viewer. Carries the event type + /// and associated data from the I/O thread to the app thread, where + /// it will be converted to an action for the embedder. + tmux_control: TmuxControlMsg, + + pub const TmuxControlMsg = struct { + event: action.TmuxControl.Event, + /// Contextual ID: pane_id for pane_output, window_id for window_* + /// and layout_change events. Unused for enter/exit/session events. + id: u32 = 0, + /// Event-specific data. Uses WriteReq for safe cross-thread transfer + /// (data is either inline or heap-allocated, never a borrowed pointer). + data: WriteReq = .{ .small = .{ .data = undefined, .len = 0 } }, + }; + pub const ReportTitleStyle = enum { csi_21_t, diff --git a/src/terminal/tmux/layout.zig b/src/terminal/tmux/layout.zig index df1a53917dd..765573432c7 100644 --- a/src/terminal/tmux/layout.zig +++ b/src/terminal/tmux/layout.zig @@ -28,6 +28,43 @@ pub const Layout = struct { vertical: []const Layout, }; + /// Custom JSON serialization: flattens width/height/x/y + content + /// into a single JSON object (e.g. {"width":80,"height":24,"x":0,"y":0,"pane":0}). + pub fn jsonStringify(self: Layout, jw: anytype) !void { + try jw.beginObject(); + try jw.objectField("width"); + try jw.write(self.width); + try jw.objectField("height"); + try jw.write(self.height); + try jw.objectField("x"); + try jw.write(self.x); + try jw.objectField("y"); + try jw.write(self.y); + switch (self.content) { + .pane => |id| { + try jw.objectField("pane"); + try jw.write(id); + }, + .horizontal => |children| { + try jw.objectField("horizontal"); + try jw.beginArray(); + for (children) |child| { + try child.jsonStringify(jw); + } + try jw.endArray(); + }, + .vertical => |children| { + try jw.objectField("vertical"); + try jw.beginArray(); + for (children) |child| { + try child.jsonStringify(jw); + } + try jw.endArray(); + }, + } + try jw.endObject(); + } + pub const ParseError = Allocator.Error || error{SyntaxError}; /// Parse a layout string that includes a 4-character checksum prefix. diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 62a0f1d00ce..8f4a13f2798 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -211,6 +211,13 @@ pub const Viewer = struct { /// never reuses window IDs within a server process lifetime. windows: []const Window, + /// Pane output data. The embedder should feed this data into its + /// own virtual terminal surface for the given pane. The Viewer + /// also feeds this data into its internal Terminal, so both the + /// Viewer's canonical state and the embedder's rendering surface + /// receive the same output stream. + pane_output: PaneOutput, + pub fn format(self: Action, writer: *std.Io.Writer) !void { const T = Action; const info = @typeInfo(T).@"union"; @@ -253,6 +260,11 @@ pub const Viewer = struct { } }; + pub const PaneOutput = struct { + pane_id: usize, + data: []const u8, + }; + pub const Pane = struct { terminal: Terminal, @@ -459,14 +471,29 @@ pub const Viewer = struct { command_consumed = true; }, - .output => |out| self.receivedOutput( - out.pane_id, - out.data, - ) catch |err| { - log.warn( - "failed to process output for pane id={}: {}", - .{ out.pane_id, err }, - ); + .output => |out| { + // Feed output into the Viewer's internal Terminal (canonical state). + self.receivedOutput( + out.pane_id, + out.data, + ) catch |err| { + log.warn( + "failed to process output for pane id={}: {}", + .{ out.pane_id, err }, + ); + }; + + // Also emit a pane_output action for the embedder, but only + // for tracked panes. Untracked pane output is silently dropped + // by receivedOutput above and should not be forwarded. + if (self.panes.contains(out.pane_id)) { + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + actions.append( + arena.allocator(), + .{ .pane_output = .{ .pane_id = out.pane_id, .data = out.data } }, + ) catch return self.defunct(); + } }, // Session changed means we switched to a different tmux session. @@ -1767,7 +1794,12 @@ test "initial flow" { .input = .{ .tmux = .{ .output = .{ .pane_id = 0, .data = "new output" } } }, .check = (struct { fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void { - try testing.expectEqual(0, actions.len); + // Expect .pane_output action for tracked pane + try testing.expectEqual(1, actions.len); + try testing.expect(actions[0] == .pane_output); + try testing.expectEqual(0, actions[0].pane_output.pane_id); + try testing.expectEqualStrings("new output", actions[0].pane_output.data); + // Also verify the internal terminal received the data const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr; const screen: *Screen = pane.terminal.screens.active; const str = try screen.dumpStringAlloc( diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 8c1b5b8abd3..60199650345 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -428,10 +428,11 @@ pub const StreamHandler = struct { log.info("tmux viewer action={f}", .{action}); switch (action) { .exit => { - // We ignore this because we will fully exit when - // our DCS connection ends. We may want to handle - // this in the future to notify our GUI we're - // disconnected though. + self.surfaceMessageWriter(.{ + .tmux_control = .{ + .event = .exit, + }, + }); }, .command => |command| { @@ -443,8 +444,37 @@ pub const StreamHandler = struct { )); }, - .windows => { - // TODO + .windows => |windows| { + const json = serializeTmuxWindows( + self.alloc, + viewer, + windows, + ) catch |err| { + log.warn("failed to serialize tmux windows: {}", .{err}); + break :tmux; + }; + self.surfaceMessageWriter(.{ + .tmux_control = .{ + .event = .windows_changed, + .data = try apprt.surface.Message.WriteReq.init( + self.alloc, + json, + ), + }, + }); + }, + + .pane_output => |out| { + self.surfaceMessageWriter(.{ + .tmux_control = .{ + .event = .pane_output, + .id = @intCast(out.pane_id), + .data = try apprt.surface.Message.WriteReq.init( + self.alloc, + out.data, + ), + }, + }); }, } } @@ -1549,3 +1579,46 @@ pub const StreamHandler = struct { self.surfaceMessageWriter(.{ .progress_report = report }); } }; + +/// Serialize tmux Viewer windows state to JSON for the embedder. +/// Produces: {"session_id":N,"tmux_version":"X.Y","windows":[...]} +/// Caller owns the returned slice and must free it with `alloc`. +fn serializeTmuxWindows( + alloc: Allocator, + viewer: *const terminal.tmux.Viewer, + windows: []const terminal.tmux.Viewer.Window, +) Allocator.Error![]const u8 { + const WindowJson = struct { + id: usize, + width: usize, + height: usize, + layout: terminal.tmux.Layout, + }; + + const Payload = struct { + session_id: usize, + tmux_version: []const u8, + windows: []const WindowJson, + }; + + // Build the JSON-friendly window array. We use a temporary allocation + // for the wrapper structs (they reference Layout by value which has + // jsonStringify for custom serialization). + const json_windows = try alloc.alloc(WindowJson, windows.len); + defer alloc.free(json_windows); + + for (windows, 0..) |win, i| { + json_windows[i] = .{ + .id = win.id, + .width = win.width, + .height = win.height, + .layout = win.layout, + }; + } + + return std.json.Stringify.valueAlloc(alloc, Payload{ + .session_id = viewer.session_id, + .tmux_version = viewer.tmux_version, + .windows = json_windows, + }, .{}); +}