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 committed 2026-08-11 08:36:10 -04:00
commit 738cead680
16 files changed
+2359

No files matched your search

+205
View File
@@ -0,0 +1,205 @@
//! A pseudoterminal pair with a child process (the shell) running on the
//! slave side. libghostty-vt deliberately has no opinion about process
//! management, so this is ours to own.
//!
//! The libc calls are declared directly rather than going through std.posix.
//! PTY setup is inherently POSIX-specific, and std.posix has been churning,
//! so explicit externs keep this module readable and stable.
const std = @import("std");
const Pty = @This();
// Linux ioctl request numbers. This app is Linux-only (GTK4/Wayland), so
// inlining these is simpler than chasing them through std across versions.
const TIOCSCTTY = 0x540E;
const TIOCSWINSZ = 0x5414;
const fd_t = std.c.fd_t;
const pid_t = std.c.pid_t;
/// The libc surface this module needs, kept in its own namespace so the
/// names don't collide with our own methods.
const c = struct {
extern "c" fn posix_openpt(flags: c_int) c_int;
extern "c" fn grantpt(fd: c_int) c_int;
extern "c" fn unlockpt(fd: c_int) c_int;
extern "c" fn ptsname_r(fd: c_int, buf: [*]u8, buflen: usize) c_int;
extern "c" fn setsid() pid_t;
extern "c" fn fork() pid_t;
extern "c" fn close(fd: fd_t) c_int;
extern "c" fn dup2(old: fd_t, new: fd_t) c_int;
extern "c" fn open(path: [*:0]const u8, flags: c_int, ...) c_int;
extern "c" fn ioctl(fd: fd_t, request: c_ulong, ...) c_int;
extern "c" fn write(fd: fd_t, buf: [*]const u8, count: usize) isize;
extern "c" fn read(fd: fd_t, buf: [*]u8, count: usize) isize;
extern "c" fn execvpe(
file: [*:0]const u8,
argv: [*:null]const ?[*:0]const u8,
envp: [*:null]const ?[*:0]const u8,
) c_int;
extern "c" fn _exit(code: c_int) noreturn;
extern "c" fn kill(pid: pid_t, sig: c_int) c_int;
extern "c" fn waitpid(pid: pid_t, status: ?*c_int, options: c_int) pid_t;
};
const O_RDWR = 0x0002;
const O_NOCTTY = 0x0100;
const SIGHUP = 1;
pub const Winsize = extern struct {
ws_row: u16,
ws_col: u16,
ws_xpixel: u16 = 0,
ws_ypixel: u16 = 0,
};
/// The master side of the pair. Read terminal output from it, write user
/// input to it.
master: fd_t,
/// PID of the child process on the slave side.
pid: pid_t,
pub const Error = error{
OpenPtFailed,
GrantPtFailed,
UnlockPtFailed,
PtsNameFailed,
ForkFailed,
};
/// Open a PTY pair and fork `argv` onto the slave side. The child gets its
/// own session with the slave as controlling terminal, so job control and
/// signals (Ctrl-C, SIGWINCH) work the way a shell expects.
pub fn create(
alloc: std.mem.Allocator,
argv: []const [:0]const u8,
size: Winsize,
) !Pty {
const master = c.posix_openpt(O_RDWR | O_NOCTTY);
if (master < 0) return Error.OpenPtFailed;
errdefer _ = c.close(master);
if (c.grantpt(master) != 0) return Error.GrantPtFailed;
if (c.unlockpt(master) != 0) return Error.UnlockPtFailed;
var name_buf: [128]u8 = undefined;
if (c.ptsname_r(master, &name_buf, name_buf.len) != 0) return Error.PtsNameFailed;
const slave_path = std.mem.sliceTo(&name_buf, 0);
_ = c.ioctl(master, TIOCSWINSZ, &size);
// Everything the child needs must be allocated before the fork. After
// forking, only async-signal-safe work is legal in the child.
const argv_z = try alloc.allocSentinel(?[*:0]const u8, argv.len, null);
defer alloc.free(argv_z);
for (argv, 0..) |arg, i| argv_z[i] = arg.ptr;
const envp_z = try buildEnv(alloc);
defer freeEnv(alloc, envp_z);
const slave_path_z = try alloc.dupeZ(u8, slave_path);
defer alloc.free(slave_path_z);
const pid = c.fork();
if (pid < 0) return Error.ForkFailed;
if (pid == 0) {
childExec(master, slave_path_z, argv_z, envp_z);
// childExec only returns on failure, and a forked child has no
// sensible way to report that back to us.
c._exit(127);
}
return .{ .master = master, .pid = pid };
}
/// The child half of the fork. Only returns if something failed.
fn childExec(
master: fd_t,
slave_path: [:0]const u8,
argv: [:null]const ?[*:0]const u8,
envp: [:null]const ?[*:0]const u8,
) void {
_ = c.close(master);
// A new session detaches us from the parent's controlling terminal so
// that we can claim the slave as our own below.
if (c.setsid() < 0) return;
const slave = c.open(slave_path.ptr, O_RDWR);
if (slave < 0) return;
// Claim the slave as this session's controlling terminal. Without this
// the shell has no way to deliver SIGINT to foreground jobs.
if (c.ioctl(slave, TIOCSCTTY, @as(c_int, 0)) < 0) return;
if (c.dup2(slave, 0) < 0) return;
if (c.dup2(slave, 1) < 0) return;
if (c.dup2(slave, 2) < 0) return;
if (slave > 2) _ = c.close(slave);
_ = c.execvpe(argv[0].?, argv.ptr, envp.ptr);
}
/// Copy the current environment, forcing the variables that describe what
/// kind of terminal we are. We advertise xterm-256color rather than
/// ghostty's own terminfo because we don't install a terminfo entry.
fn buildEnv(alloc: std.mem.Allocator) ![:null]?[*:0]const u8 {
var list: std.ArrayListUnmanaged([*:0]const u8) = .empty;
defer list.deinit(alloc);
errdefer for (list.items) |item| alloc.free(std.mem.span(item));
var i: usize = 0;
while (std.c.environ[i]) |entry| : (i += 1) {
const span = std.mem.span(entry);
// Drop the variables we're about to define ourselves.
if (std.mem.startsWith(u8, span, "TERM=")) continue;
if (std.mem.startsWith(u8, span, "COLORTERM=")) continue;
try list.append(alloc, (try alloc.dupeZ(u8, span)).ptr);
}
try list.append(alloc, (try alloc.dupeZ(u8, "TERM=xterm-256color")).ptr);
try list.append(alloc, (try alloc.dupeZ(u8, "COLORTERM=truecolor")).ptr);
const result = try alloc.allocSentinel(?[*:0]const u8, list.items.len, null);
for (list.items, 0..) |item, idx| result[idx] = item;
return result;
}
fn freeEnv(alloc: std.mem.Allocator, envp: [:null]?[*:0]const u8) void {
for (envp) |entry| if (entry) |e| alloc.free(std.mem.span(e));
alloc.free(envp);
}
/// Tell the child its window changed size. This both updates the kernel's
/// idea of the terminal size and delivers SIGWINCH to the foreground group.
pub fn setSize(self: Pty, size: Winsize) void {
_ = c.ioctl(self.master, TIOCSWINSZ, &size);
}
/// Returns the number of bytes read, or null if the PTY hung up.
pub fn read(self: Pty, buf: []u8) ?usize {
const n = c.read(self.master, buf.ptr, buf.len);
if (n <= 0) return null;
return @intCast(n);
}
pub fn writeAll(self: Pty, bytes: []const u8) void {
var off: usize = 0;
while (off < bytes.len) {
const n = c.write(self.master, bytes.ptr + off, bytes.len - off);
// Best-effort: if the child has exited the write fails with EPIPE
// and there is nothing useful to do about it here.
if (n <= 0) return;
off += @intCast(n);
}
}
pub fn deinit(self: *Pty) void {
_ = c.close(self.master);
// Closing the master sends SIGHUP to the child's session. Reap it so we
// don't leave a zombie behind.
_ = c.kill(self.pid, SIGHUP);
_ = c.waitpid(self.pid, null, 0);
self.* = undefined;
}