vtabs: terminal with vertical tabs on libghostty-vt + GTK4

Uses libghostty-vt (the API Ghostty documents for external embedders) for
the terminal core. Ghostty's other C API, ghostty.h, exposes a full terminal
surface but only supports macOS and iOS platform tags, so it cannot be
embedded on Linux.

We supply the layers libghostty-vt deliberately leaves out: PTY and process
management, a Cairo/Pango cell renderer, and a GTK4/libadwaita UI with a
Zen-style vertical tab sidebar.

Nix pins the whole toolchain (Zig 0.16 via zig-overlay, GTK 4.22, libadwaita)
so no host setup is needed.
This commit is contained in:
Greyson Parrelli
2026-08-11 08:36:10 -04:00
commit 738cead680
16 changed files with 2359 additions and 0 deletions
+203
View File
@@ -0,0 +1,203 @@
//! One terminal session: a libghostty-vt terminal fed by a PTY.
//!
//! The PTY is read on the GLib main loop via a unix fd watch rather than a
//! dedicated IO thread. That keeps VT state single-threaded, so the renderer
//! can read the screen directly with no locking. A real terminal would want
//! Ghostty's threaded IO, but for a proof of concept this is much simpler and
//! is plenty fast for interactive use.
const std = @import("std");
const glib = @import("glib");
const glibunix = @import("glibunix");
const vt = @import("ghostty-vt");
const Pty = @import("Pty.zig");
const Session = @This();
/// Read buffer size. Large enough that a `cat` of a big file doesn't spend
/// all its time bouncing through the main loop.
const read_buf_size = 64 * 1024;
alloc: std.mem.Allocator,
/// The terminal emulator state. Must be the first field referenced by
/// `fromTerminal` below, which recovers the Session from stream callbacks.
term: vt.Terminal,
/// Persistent parser state. Escape sequences can and do get split across
/// read boundaries, so this must outlive individual reads.
stream: vt.TerminalStream,
pty: Pty,
/// GLib source ID for the PTY read watch, so we can cancel it on teardown.
watch: c_uint = 0,
/// True once the child process has exited and the PTY hung up.
exited: bool = false,
/// Called after terminal state changes, so the owner can queue a redraw.
on_damage: *const fn (ctx: ?*anyopaque) void,
/// Called when the terminal title changes (OSC 0/2).
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
/// Called when the child process exits.
on_exit: *const fn (ctx: ?*anyopaque) void,
ctx: ?*anyopaque = null,
pub const Callbacks = struct {
on_damage: *const fn (ctx: ?*anyopaque) void,
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
on_exit: *const fn (ctx: ?*anyopaque) void,
ctx: ?*anyopaque,
};
pub fn create(
alloc: std.mem.Allocator,
cols: u16,
rows: u16,
cbs: Callbacks,
) !*Session {
const self = try alloc.create(Session);
errdefer alloc.destroy(self);
const tio: vt.TinyIo = .init;
self.* = .{
.alloc = alloc,
.term = try .init(tio.io(), alloc, .{
.cols = cols,
.rows = rows,
.max_scrollback_bytes = 8 * 1024 * 1024,
}),
.stream = undefined,
.pty = undefined,
.on_damage = cbs.on_damage,
.on_title = cbs.on_title,
.on_exit = cbs.on_exit,
.ctx = cbs.ctx,
};
errdefer self.term.deinit(alloc);
self.stream = self.term.vtStream();
errdefer self.stream.deinit();
// Wire up the side effects we care about. `readonly` handles terminal
// state but silently drops anything that needs to talk back to the
// child; we need the writes so that queries (cursor position, device
// attributes, in-band resize) get answers.
var effects = vt.TerminalStream.Handler.Effects.readonly;
effects.write_pty = &effectWritePty;
effects.title_changed = &effectTitleChanged;
self.stream.handler.effects = effects;
const shell = try defaultShell(alloc);
defer alloc.free(shell);
// A leading '-' in argv[0] tells the shell to start as a login shell.
const argv0 = try std.fmt.allocPrintSentinel(alloc, "-{s}", .{
std.fs.path.basename(shell),
}, 0);
defer alloc.free(argv0);
self.pty = try .create(alloc, &.{ shell, argv0 }, .{
.ws_row = rows,
.ws_col = cols,
});
errdefer self.pty.deinit();
self.watch = glibunix.fdAdd(
self.pty.master,
.{ .in = true, .hup = true, .err = true },
&onReadable,
self,
);
return self;
}
pub fn destroy(self: *Session) void {
if (self.watch != 0) _ = glib.Source.remove(self.watch);
self.pty.deinit();
self.stream.deinit();
self.term.deinit(self.alloc);
self.alloc.destroy(self);
}
/// Resolve the user's shell, falling back to something that always exists.
fn defaultShell(alloc: std.mem.Allocator) ![:0]const u8 {
if (std.c.getenv("SHELL")) |sh| {
const span = std.mem.span(sh);
if (span.len > 0) return alloc.dupeZ(u8, span);
}
return alloc.dupeZ(u8, "/bin/sh");
}
/// Main loop callback: the PTY has data (or hung up).
fn onReadable(
fd: c_int,
condition: glib.IOCondition,
data: ?*anyopaque,
) callconv(.c) c_int {
const self: *Session = @ptrCast(@alignCast(data.?));
_ = fd;
if (condition.in) {
var buf: [read_buf_size]u8 = undefined;
if (self.pty.read(&buf)) |n| {
self.stream.nextSlice(buf[0..n]);
self.on_damage(self.ctx);
return 1;
}
}
// Zero-length read, HUP or error all mean the child is gone.
self.exited = true;
self.watch = 0;
self.on_exit(self.ctx);
return 0;
}
/// Effect callback: the terminal wants to send bytes back to the child.
fn effectWritePty(handler: *vt.TerminalStream.Handler, data: [:0]const u8) void {
const self = fromHandler(handler);
self.pty.writeAll(data);
}
/// Effect callback: OSC 0/2 changed the window title.
fn effectTitleChanged(handler: *vt.TerminalStream.Handler) void {
const self = fromHandler(handler);
self.on_title(self.ctx, self.term.title.items);
}
/// Recover the owning Session from a stream callback. The handler holds a
/// pointer to our embedded `term` field, so we can walk back from it.
fn fromHandler(handler: *vt.TerminalStream.Handler) *Session {
return @fieldParentPtr("term", handler.terminal);
}
/// Send user input to the child.
pub fn write(self: *Session, bytes: []const u8) void {
self.pty.writeAll(bytes);
}
/// Resize the terminal grid and tell the child about it.
pub fn resize(self: *Session, cols: u16, rows: u16, cell_w: u32, cell_h: u32) !void {
if (cols == self.term.cols and rows == self.term.rows) return;
try self.stream.handler.resize(.{
.cols = cols,
.rows = rows,
.cell_size_px = .{ .width = cell_w, .height = cell_h },
});
self.pty.setSize(.{
.ws_row = rows,
.ws_col = cols,
.ws_xpixel = @intCast(cell_w * cols),
.ws_ypixel = @intCast(cell_h * rows),
});
}