inspector: add copy and export for terminal IO events

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.

Export is wired up through a new export_terminal_io apprt action,
handled with a native save dialog on both macOS and GTK.
This commit is contained in:
Uzair Aftab
2026-07-30 08:04:07 +02:00
parent 96e39f8235
commit ad96613a8c
6 changed files with 263 additions and 3 deletions

View File

@@ -645,6 +645,12 @@ typedef enum {
GHOSTTY_INSPECTOR_HIDE,
} ghostty_action_inspector_e;
// apprt.action.ExportTerminalIO.C
typedef struct {
const char* contents;
size_t len;
} ghostty_action_export_terminal_io_s;
// apprt.action.QuitTimer
typedef enum {
GHOSTTY_QUIT_TIMER_START,
@@ -914,6 +920,7 @@ typedef enum {
GHOSTTY_ACTION_INSPECTOR,
GHOSTTY_ACTION_SHOW_GTK_INSPECTOR,
GHOSTTY_ACTION_RENDER_INSPECTOR,
GHOSTTY_ACTION_EXPORT_TERMINAL_IO,
GHOSTTY_ACTION_DESKTOP_NOTIFICATION,
GHOSTTY_ACTION_SET_TITLE,
GHOSTTY_ACTION_SET_TAB_TITLE,
@@ -964,6 +971,7 @@ typedef union {
ghostty_action_cell_size_s cell_size;
ghostty_action_scrollbar_s scrollbar;
ghostty_action_inspector_e inspector;
ghostty_action_export_terminal_io_s export_terminal_io;
ghostty_action_desktop_notification_s desktop_notification;
ghostty_action_set_title_s set_title;
ghostty_action_set_title_s set_tab_title;

View File

@@ -1,4 +1,5 @@
import SwiftUI
import UniformTypeIdentifiers
import UserNotifications
import GhosttyKit
@@ -539,6 +540,9 @@ extension Ghostty {
case GHOSTTY_ACTION_RENDER_INSPECTOR:
renderInspector(app, target: target)
case GHOSTTY_ACTION_EXPORT_TERMINAL_IO:
return exportTerminalIO(app, target: target, v: action.action.export_terminal_io)
case GHOSTTY_ACTION_DESKTOP_NOTIFICATION:
showDesktopNotification(app, target: target, n: action.action.desktop_notification)
@@ -1394,6 +1398,41 @@ extension Ghostty {
}
}
private static func exportTerminalIO(
_ app: ghostty_app_t,
target: ghostty_target_s,
v: ghostty_action_export_terminal_io_s
) -> Bool {
guard target.tag == GHOSTTY_TARGET_SURFACE,
let surface = target.target.surface,
let surfaceView = self.surfaceView(from: surface),
let window = surfaceView.window,
let contents = v.contents
else { return false }
// The action data is borrowed for the duration of this callback,
// so copy it before presenting the asynchronous save panel.
let data = Data(bytes: contents, count: v.len)
DispatchQueue.main.async {
let panel = NSSavePanel()
panel.allowedContentTypes = [.plainText]
panel.canCreateDirectories = true
panel.nameFieldStringValue = "ghostty-terminal-io.txt"
panel.beginSheetModal(for: window) { response in
guard response == .OK, let url = panel.url else { return }
do {
try data.write(to: url, options: .atomic)
} catch {
Ghostty.logger.error(
"Failed to export terminal IO events: \(error, privacy: .public)"
)
}
}
}
return true
}
private static func showDesktopNotification(
_ app: ghostty_app_t,
target: ghostty_target_s,

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,99 @@ 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");
const native = dialog.as(gtk.NativeDialog);
_ = gtk.NativeDialog.signals.response.connect(
native,
*ExportTerminalIORequest,
ExportTerminalIORequest.response,
request,
.{ .destroyData = ExportTerminalIORequest.destroy },
);
native.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.NativeDialog,
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.