inspector: add copy and export for terminal IO events (#13519)

Adds "Copy" and "Export to file" buttons to the Terminal IO inspector so
recorded VT events can be saved outside the app for sharing or analysis.

Found myself needing/wishing for this while I was debugging my tmux fork
with libghostty-vt.


Disclaimer: I haven't considered performance at all, so please lmk if
there are anything here you would like me to optimize.
This commit is contained in:
Jeffrey C. Ollie
2026-08-01 10:54:10 -05:00
committed by GitHub
38 changed files with 806 additions and 259 deletions

View File

@@ -196,6 +196,9 @@ pub const Action = union(Key) {
/// rendered at the next opportunity.
render_inspector,
/// Export the Terminal IO inspector event log.
export_terminal_io: ExportTerminalIO,
/// Show a desktop notification.
desktop_notification: DesktopNotification,
@@ -381,6 +384,7 @@ pub const Action = union(Key) {
inspector,
show_gtk_inspector,
render_inspector,
export_terminal_io,
desktop_notification,
set_title,
set_tab_title,
@@ -612,6 +616,37 @@ pub const Inspector = enum(c_int) {
}
};
/// Terminal IO inspector contents to export. The contents are only valid for
/// the duration of the action callback.
pub const ExportTerminalIO = struct {
contents: []const u8,
// Sync with: ghostty_action_export_terminal_io_s
pub const C = extern struct {
contents: [*]const u8,
len: usize,
};
pub fn cval(self: ExportTerminalIO) C {
return .{
.contents = self.contents.ptr,
.len = self.contents.len,
};
}
pub fn format(
value: @This(),
comptime _: []const u8,
_: std.fmt.Options,
writer: *std.Io.Writer,
) !void {
try writer.print(
"{s}{{ contents: {d} bytes }}",
.{ @typeName(@This()), value.contents.len },
);
}
};
pub const QuitTimer = enum(c_int) {
start,
stop,

View File

@@ -703,6 +703,11 @@ pub const Application = extern struct {
.initial_size => return Action.initialSize(target, value),
.inspector => return Action.controlInspector(target, value),
.export_terminal_io => return try Action.exportTerminalIO(
self,
target,
value,
),
.key_sequence => return Action.keySequence(target, value),
.key_table => return Action.keyTable(target, value),
@@ -1997,6 +2002,98 @@ const Action = struct {
};
}
pub fn exportTerminalIO(
self: *Application,
target: apprt.Target,
value: apprt.Action.Value(.export_terminal_io),
) Allocator.Error!bool {
const surface = switch (target) {
.app => return false,
.surface => |v| v.rt_surface.gobj(),
};
const alloc = self.allocator();
const contents = try alloc.dupe(u8, value.contents);
errdefer alloc.free(contents);
const request = try alloc.create(ExportTerminalIORequest);
errdefer alloc.destroy(request);
request.* = .{
.alloc = alloc,
.contents = contents,
};
const parent = ext.getAncestor(gtk.Window, surface.as(gtk.Widget));
const dialog = gtk.FileChooserNative.new(
i18n._("Export Terminal IO Events"),
parent,
.save,
i18n._("Export"),
i18n._("Cancel"),
);
const chooser = dialog.as(gtk.FileChooser);
chooser.setCreateFolders(1);
chooser.setCurrentName("ghostty-terminal-io.txt");
_ = gtk.NativeDialog.signals.response.connect(
dialog,
*ExportTerminalIORequest,
ExportTerminalIORequest.response,
request,
.{ .destroyData = ExportTerminalIORequest.destroy },
);
dialog.as(gtk.NativeDialog).show();
return true;
}
const ExportTerminalIORequest = struct {
alloc: Allocator,
contents: []u8,
fn destroy(self: *ExportTerminalIORequest) callconv(.c) void {
self.alloc.free(self.contents);
self.alloc.destroy(self);
}
fn response(
dialog: *gtk.FileChooserNative,
response_id: c_int,
self: *ExportTerminalIORequest,
) callconv(.c) void {
defer dialog.unref();
if (response_id != @intFromEnum(gtk.ResponseType.accept)) return;
const file = dialog.as(gtk.FileChooser).getFile() orelse {
log.warn("inspector export dialog returned no file", .{});
return;
};
defer file.unref();
var gerr: ?*glib.Error = null;
const replaced = file.replaceContents(
self.contents.ptr,
self.contents.len,
null,
0,
.{},
null,
null,
&gerr,
);
if (gerr) |err| {
defer err.free();
log.err(
"failed to export terminal IO events err={s}",
.{err.f_message orelse "(unknown)"},
);
return;
}
if (replaced == 0) {
log.err("failed to export terminal IO events", .{});
}
}
};
pub fn configChange(
self: *Application,
target: apprt.Target,

View File

@@ -47,7 +47,7 @@ pub const Inspector = struct {
pub fn draw(
self: *Inspector,
surface: *const Surface,
surface: *Surface,
mouse: Mouse,
) void {
// Create our dockspace first. If we had to setup our dockspace,
@@ -111,7 +111,7 @@ pub const Inspector = struct {
defer cimgui.c.ImGui_End();
if (open) {
self.vt_stream.draw(
surface.alloc,
surface,
&t.colors.palette.current,
);
}

View File

@@ -7,6 +7,8 @@ const CircBuf = @import("../../datastruct/main.zig").CircBuf;
const Surface = @import("../../Surface.zig");
const screen = @import("screen.zig");
const log = std.log.scoped(.inspector_terminal_io);
/// VT event stream inspector widget.
pub const Stream = struct {
events: VTEvent.Ring,
@@ -59,9 +61,10 @@ pub const Stream = struct {
pub fn draw(
self: *Stream,
alloc: Allocator,
surface: *Surface,
palette: *const terminal.color.Palette,
) void {
const alloc = surface.alloc;
const events = &self.events;
const handler = &self.parser_stream.handler;
const popup_filter = "Filter";
@@ -93,6 +96,16 @@ pub const Stream = struct {
handler.current_seq = 1;
}
cimgui.c.ImGui_SameLineEx(0, cimgui.c.ImGui_GetStyle().*.ItemInnerSpacing.x);
if (cimgui.c.ImGui_Button("Copy")) {
self.copyEvents(surface);
}
cimgui.c.ImGui_SameLineEx(0, cimgui.c.ImGui_GetStyle().*.ItemInnerSpacing.x);
if (cimgui.c.ImGui_Button("Export to file")) {
self.exportEvents(surface);
}
}
}
@@ -330,6 +343,73 @@ pub const Stream = struct {
}
} // filter popup
}
/// Copy all recorded events to the standard clipboard in chronological
/// order.
fn copyEvents(
self: *const Stream,
surface: *Surface,
) void {
const alloc = surface.alloc;
const contents = self.formatEvents(alloc) catch |err| {
log.err("failed to format terminal IO events for copy err={}", .{err});
return;
};
defer alloc.free(contents);
surface.rt_surface.setClipboard(.standard, &.{.{
.mime = "text/plain",
.data = contents,
}}, false) catch |err| {
log.err("failed to copy terminal IO events err={}", .{err});
};
}
/// Ask the application runtime to save all recorded events to a file.
fn exportEvents(
self: *const Stream,
surface: *Surface,
) void {
const alloc = surface.alloc;
const contents = self.formatEvents(alloc) catch |err| {
log.err("failed to format terminal IO events for export err={}", .{err});
return;
};
defer alloc.free(contents);
const handled = surface.rt_app.performAction(
.{ .surface = surface },
.export_terminal_io,
.{ .contents = contents },
) catch |err| {
log.err("failed to export terminal IO events err={}", .{err});
return;
};
if (!handled) {
log.warn("application runtime does not support exporting terminal IO events", .{});
}
}
/// Format all recorded events as tab-separated text. Events are exported
/// oldest-first even though the inspector displays the newest event first.
fn formatEvents(
self: *const Stream,
alloc: Allocator,
) ![:0]u8 {
var buf: std.Io.Writer.Allocating = .init(alloc);
errdefer buf.deinit();
try buf.writer.writeAll("Seq\tKind\tDescription\n");
var it = self.events.iterator(.forward);
while (it.next()) |ev| {
try buf.writer.print(
"{d}\t{s}\t{s}\n",
.{ ev.seq, @tagName(ev.kind), ev.raw_description },
);
}
return try buf.toOwnedSliceSentinel(0);
}
};
/// Helper function to check keyboard state and determine navigation action.