Something went wrong. Try again.
A GPU terminal emulator for Wayland and Windows on libghostty-vt, drawing with Vulkan, running a program on a pty or a serial port.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The program status records of one terminal (OSC 7501): what its program//! says it is doing — idle, working, done, blocked on the user, or failed —//! for itself and for each piece of work it names.//!//! A record is addressed by its `id`, segments joined by `/`: `build/test`//! is a child of `build`, which is a child of the root record, the one with//! no id, the program itself. A report replaces its record whole. `clear`//! removes the record it addresses and everything beneath it, and every//! record without an id. `working` and `blocked` records last only until//! the program exits or the shell shows its next prompt; `done` and `error`//! outlast both. A full reset removes them all, through a `clear` with no//! id. libghostty-vt parses and checks each report; keeping them is here.
const std = @import("std");const l10n = @import("l10n.zig");const Allocator = std.mem.Allocator;const vt = @import("ghostty-vt");
const program_status = vt.osc.program_status;pub const Report = program_status.Report;pub const State = program_status.State;pub const Kind = program_status.Kind;
/// The most records a terminal keeps; the specification asks for at least/// 64. A report for a new record past it takes the place of the record/// reported least recently, as the specification says.pub const max_records = 256;
/// One record, as its last report left it. Every string is owned.pub const Record = struct { /// Empty for the root record. id: []const u8, state: State, /// What a `blocked` record is waiting for. kind: ?Kind = null, /// Percent done, for `working` and `blocked`; null when it cannot say. progress: ?u8 = null, /// The program, as a name for machines; a record without one takes its /// nearest ancestor's. See `appOf`. app: ?[]const u8 = null, /// A short label, and a line saying what it is doing, for people. title: ?[]const u8 = null, msg: ?[]const u8 = null, /// When it was reported, in the order reports came: which is the most /// recent, and when it was. seq: u64, at: std.Io.Timestamp,
fn deinit(r: Record, gpa: Allocator) void { gpa.free(r.id); if (r.app) |s| gpa.free(s); if (r.title) |s| gpa.free(s); if (r.msg) |s| gpa.free(s); }
/// A copy whose strings are `a`'s. pub fn clone(r: Record, a: Allocator) Allocator.Error!Record { var c = r; c.id = try a.dupe(u8, r.id); c.app = if (r.app) |s| try a.dupe(u8, s) else null; c.title = if (r.title) |s| try a.dupe(u8, s) else null; c.msg = if (r.msg) |s| try a.dupe(u8, s) else null; return c; }
/// How deep in the tree its id puts it: 0 for the root record, 1 for /// `build`, 2 for `build/test`. pub fn depth(r: Record) usize { if (r.id.len == 0) return 0; return std.mem.count(u8, r.id, "/") + 1; }
/// The last segment of its id: `test` for `build/test`. pub fn name(r: Record) []const u8 { const slash = std.mem.findScalarLast(u8, r.id, '/') orelse return r.id; return r.id[slash + 1 ..]; }};
/// A record that has come into a state worth telling the user about:/// working, blocked or done. Every string is owned.pub const Notice = struct { state: State, kind: ?Kind, progress: ?u8, app: ?[]const u8, title: ?[]const u8, msg: ?[]const u8,
pub fn deinit(n: Notice, gpa: Allocator) void { if (n.app) |s| gpa.free(s); if (n.title) |s| gpa.free(s); if (n.msg) |s| gpa.free(s); }};
pub const Tree = struct { gpa: Allocator, records: std.ArrayList(Record) = .empty, /// The `seq` the next report gets. Shared with OSC 9;4 reports, so that /// either can be the most recent. next_seq: u64 = 1,
pub fn init(gpa: Allocator) Tree { return .{ .gpa = gpa }; }
pub fn deinit(t: *Tree) void { for (t.records.items) |r| r.deinit(t.gpa); t.records.deinit(t.gpa); }
/// The next report's place in the order. pub fn takeSeq(t: *Tree) u64 { defer t.next_seq += 1; return t.next_seq; }
/// Applies `report`, made at `at`. Says whether anything changed, and /// puts in `notice` what the user should be told, if anything: a /// record that has come into `working`, `blocked` or `done` from some /// other state, or from not being there at all. pub fn apply(t: *Tree, report: Report, at: std.Io.Timestamp, notice: *?Notice) Allocator.Error!bool { const id = report.readOption(.id) orelse ""; if (report.state == .clear) return t.clear(id);
var title_buf: [program_status.max_msg_bytes]u8 = undefined; var title_w: std.Io.Writer = .fixed(&title_buf); report.writeText(.title, &title_w) catch {}; var msg_buf: [program_status.max_msg_bytes]u8 = undefined; var msg_w: std.Io.Writer = .fixed(&msg_buf); report.writeText(.msg, &msg_w) catch {};
const gpa = t.gpa; var r: Record = .{ .id = try gpa.dupe(u8, id), .state = report.state, .kind = report.readOption(.kind), .progress = report.readOption(.progress), .app = null, .title = null, .msg = null, .seq = t.takeSeq(), .at = at, }; errdefer r.deinit(gpa); if (report.readOption(.app)) |s| r.app = try gpa.dupe(u8, s); if (title_w.buffered().len > 0) r.title = try gpa.dupe(u8, title_w.buffered()); if (msg_w.buffered().len > 0) r.msg = try gpa.dupe(u8, msg_w.buffered());
const old: ?State = if (t.find(id)) |i| t.records.items[i].state else null; if (t.find(id)) |i| { t.records.items[i].deinit(gpa); t.records.items[i] = r; } else { if (t.records.items.len >= max_records) { const i = t.leastRecent(); t.records.items[i].deinit(gpa); _ = t.records.orderedRemove(i); } try t.records.append(gpa, r); }
if (old != r.state) switch (r.state) { .working, .blocked, .done => notice.* = try t.noticeOf(t.records.items[t.find(id).?]), else => {}, }; return true; }
fn noticeOf(t: *const Tree, r: Record) Allocator.Error!Notice { const gpa = t.gpa; var n: Notice = .{ .state = r.state, .kind = r.kind, .progress = r.progress, .app = null, .title = null, .msg = null }; errdefer n.deinit(gpa); if (appOf(t.records.items, r)) |s| n.app = try gpa.dupe(u8, s); if (r.title) |s| n.title = try gpa.dupe(u8, s); if (r.msg) |s| n.msg = try gpa.dupe(u8, s); return n; }
/// Removes the record `id` and everything beneath it; everything for /// an empty id. fn clear(t: *Tree, id: []const u8) bool { var changed = false; var i: usize = 0; while (i < t.records.items.len) { const r = t.records.items[i]; if (within(r.id, id)) { r.deinit(t.gpa); _ = t.records.orderedRemove(i); changed = true; } else i += 1; } return changed; }
/// The program exited, or the shell is showing its prompt: whatever was /// still going on is not any more. Done and failed work stays. pub fn endWork(t: *Tree) bool { var changed = false; var i: usize = 0; while (i < t.records.items.len) { const r = t.records.items[i]; if (r.state == .working or r.state == .blocked) { r.deinit(t.gpa); _ = t.records.orderedRemove(i); changed = true; } else i += 1; } return changed; }
/// The record reported least recently. fn leastRecent(t: *const Tree) usize { var best: usize = 0; for (t.records.items, 0..) |r, i| { if (r.seq < t.records.items[best].seq) best = i; } return best; }
fn find(t: *const Tree, id: []const u8) ?usize { for (t.records.items, 0..) |r, i| if (std.mem.eql(u8, r.id, id)) return i; return null; }
/// The record reported most recently. pub fn latest(t: *const Tree) ?*const Record { var best: ?*const Record = null; for (t.records.items) |*r| { if (best == null or r.seq > best.?.seq) best = r; } return best; }
/// Every record, its strings `a`'s, in the order a tree shows them: a /// record before its children, siblings by name. pub fn snapshot(t: *const Tree, a: Allocator) Allocator.Error![]Record { const out = try a.alloc(Record, t.records.items.len); for (t.records.items, out) |r, *o| o.* = try r.clone(a); std.mem.sortUnstable(Record, out, {}, treeOrder); return out; }};
/// Whether `id` is `under` or beneath it; everything is beneath the root.fn within(id: []const u8, under: []const u8) bool { if (under.len == 0) return true; if (!std.mem.startsWith(u8, id, under)) return false; return id.len == under.len or id[under.len] == '/';}
/// Segment by segment, so that `a/b` follows `a` and comes before `a-c`.fn treeOrder(_: void, x: Record, y: Record) bool { var xs = std.mem.splitScalar(u8, x.id, '/'); var ys = std.mem.splitScalar(u8, y.id, '/'); if (x.id.len == 0) return y.id.len != 0; if (y.id.len == 0) return false; while (true) { const a = xs.next() orelse return ys.peek() != null; const b = ys.next() orelse return false; switch (std.mem.order(u8, a, b)) { .lt => return true, .gt => return false, .eq => {}, } }}
/// What a record in `state` is doing, in a few words, in the user's/// language: what a notification or the status window says of it.pub fn describe(state: State, kind: ?Kind) []const u8 { if (state == .clear) return ""; return l10n.choice("status-state", "state", stateWord(state, kind));}
/// The `$state` the `status-state` message picks its words by.pub fn stateWord(state: State, kind: ?Kind) []const u8 { return switch (state) { .idle => "idle", .working => "working", .done => "done", .@"error" => "failed", .clear => "", .blocked => if (kind) |k| switch (k) { .permission => "permission", .question => "question", .auth => "auth", } else "waiting", };}
/// `r`'s program: its own `app`, or its nearest ancestor's in `records`.pub fn appOf(records: []const Record, r: Record) ?[]const u8 { if (r.app) |a| return a; var id = r.id; while (id.len > 0) { id = if (std.mem.findScalarLast(u8, id, '/')) |i| id[0..i] else ""; for (records) |o| if (std.mem.eql(u8, o.id, id)) if (o.app) |a| return a; } return null;}
/// How many of `records`' ancestors of `r` are there: how far in a tree/// shows it, beneath its pane.pub fn shownDepth(records: []const Record, r: Record) usize { var n: usize = 0; var id = r.id; while (id.len > 0) { id = if (std.mem.findScalarLast(u8, id, '/')) |i| id[0..i] else ""; for (records) |o| if (std.mem.eql(u8, o.id, id)) { n += 1; break; }; } return n;}
/// The lines that draw a tree written out depth first, `depths` being each/// entry's depth: entry `i`'s prefix, into `out`. Three cells a level below/// the first: `│ ` where a line runs on down past it to a later sibling of/// an ancestor, then `├─ `, or `└─ ` for the last of its siblings.pub fn treePrefix(depths: []const u8, i: usize, out: []u21) []u21 { const d = depths[i]; if (d == 0) return out[0..0]; var n: usize = 0; var level: u8 = 1; while (level < d) : (level += 1) { // The nearest entry above at this level is the ancestor there. var a = i; while (a > 0) { a -= 1; if (depths[a] == level) break; } const runs = depths[a] == level and !isLast(depths, a); if (n + 3 > out.len) return out[0..n]; out[n] = if (runs) 0x2502 else ' '; out[n + 1] = ' '; out[n + 2] = ' '; n += 3; } if (n + 3 > out.len) return out[0..n]; out[n] = if (isLast(depths, i)) 0x2514 else 0x251c; out[n + 1] = 0x2500; out[n + 2] = ' '; return out[0 .. n + 3];}
/// Whether entry `i` is the last of its siblings: nothing more at its depth/// before the tree climbs back above it.fn isLast(depths: []const u8, i: usize) bool { for (depths[i + 1 ..]) |d| { if (d < depths[i]) return true; if (d == depths[i]) return false; } return true;}
const testing = std.testing;
/// A report as the parser would give it, its `data` being everything after/// `7501;`.fn rep(comptime data: []const u8) Report { const state_at = comptime std.mem.find(u8, data, "state=").? + "state=".len; const end = comptime std.mem.findScalarPos(u8, data, state_at, ':') orelse data.len; return .{ .state = comptime std.meta.stringToEnum(State, data[state_at..end]).?, .data = data };}
const zero: std.Io.Timestamp = .zero;
test "a report replaces its record, and says when it starts something" { var t: Tree = .init(testing.allocator); defer t.deinit(); var n: ?Notice = null; try testing.expect(try t.apply(rep("state=working:app=cargo:progress=40"), zero, &n)); try testing.expect(n != null); n.?.deinit(testing.allocator); n = null; // Still working: more progress is not news. try testing.expect(try t.apply(rep("state=working:app=cargo:progress=60"), zero, &n)); try testing.expect(n == null); try testing.expectEqual(@as(usize, 1), t.records.items.len); try testing.expectEqual(@as(?u8, 60), t.records.items[0].progress); // Done is. try testing.expect(try t.apply(rep("state=done:app=cargo:msg=QnVpbHQ="), zero, &n)); try testing.expectEqual(State.done, n.?.state); try testing.expectEqualStrings("Built", n.?.msg.?); try testing.expectEqualStrings("cargo", n.?.app.?); n.?.deinit(testing.allocator); // A report replaces the whole record: no progress now. try testing.expectEqual(@as(?u8, null), t.records.items[0].progress);}
test "past the most records, a new one takes the place of the least recent" { var t: Tree = .init(testing.allocator); defer t.deinit(); var n: ?Notice = null; var buf: [32]u8 = undefined; for (0..max_records) |i| { const data = try std.fmt.bufPrint(&buf, "state=idle:id=r{d}", .{i}); _ = try t.apply(.{ .state = .idle, .data = data }, zero, &n); } // r0 is reported again, so r1 is now the least recent. _ = try t.apply(rep("state=idle:id=r0"), zero, &n); try testing.expect(try t.apply(rep("state=idle:id=new"), zero, &n)); try testing.expectEqual(max_records, t.records.items.len); try testing.expect(t.find("new") != null); try testing.expect(t.find("r0") != null); try testing.expect(t.find("r1") == null);}
test "children take their program from their parents, and go with them" { var t: Tree = .init(testing.allocator); defer t.deinit(); var n: ?Notice = null; _ = try t.apply(rep("state=working:app=deploy"), zero, &n); if (n) |x| x.deinit(testing.allocator); n = null; _ = try t.apply(rep("state=working:id=us-east:progress=40"), zero, &n); try testing.expectEqualStrings("deploy", n.?.app.?); n.?.deinit(testing.allocator); n = null; _ = try t.apply(rep("state=done:id=us-east/web"), zero, &n); if (n) |x| x.deinit(testing.allocator); n = null; _ = try t.apply(rep("state=done:id=us-west"), zero, &n); if (n) |x| x.deinit(testing.allocator); try testing.expectEqual(@as(usize, 4), t.records.items.len); // Clearing us-east takes us-east/web with it, and leaves us-west. try testing.expect(try t.apply(rep("state=clear:id=us-east"), zero, &n)); try testing.expectEqual(@as(usize, 2), t.records.items.len); // Clearing with no id clears everything. try testing.expect(try t.apply(rep("state=clear"), zero, &n)); try testing.expectEqual(@as(usize, 0), t.records.items.len);}
test "a prompt ends what was going on, and keeps what is done" { var t: Tree = .init(testing.allocator); defer t.deinit(); var n: ?Notice = null; inline for (.{ "state=working:id=a", "state=blocked:id=b:kind=auth", "state=done:id=c", "state=error:id=d", "state=idle:id=e" }) |d| { _ = try t.apply(rep(d), zero, &n); if (n) |x| x.deinit(testing.allocator); n = null; } try testing.expect(t.endWork()); var ids: [3][]const u8 = undefined; for (t.records.items, &ids) |rec, *id| id.* = rec.id; try testing.expectEqualDeep(&[_][]const u8{ "c", "d", "e" }, &ids); try testing.expect(!t.endWork());}
test "a tree shows parents before children, siblings by name" { var t: Tree = .init(testing.allocator); defer t.deinit(); var n: ?Notice = null; inline for (.{ "state=working:id=b-c", "state=working:id=b/z", "state=working", "state=working:id=b", "state=working:id=a" }) |d| { _ = try t.apply(rep(d), zero, &n); if (n) |x| x.deinit(testing.allocator); n = null; } var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const snap = try t.snapshot(arena.allocator()); var ids: [5][]const u8 = undefined; for (snap, &ids) |rec, *id| id.* = rec.id; try testing.expectEqualDeep(&[_][]const u8{ "", "a", "b", "b/z", "b-c" }, &ids); try testing.expectEqual(@as(usize, 2), shownDepth(snap, snap[3])); // b-c's parent is not there: it hangs from the root. try testing.expectEqual(@as(usize, 1), shownDepth(snap, snap[4]));}
test "the most recent report is the latest" { var t: Tree = .init(testing.allocator); defer t.deinit(); var n: ?Notice = null; inline for (.{ "state=working:id=a", "state=working:id=b", "state=done:id=a" }) |d| { _ = try t.apply(rep(d), zero, &n); if (n) |x| x.deinit(testing.allocator); n = null; } try testing.expectEqualStrings("a", t.latest().?.id); try testing.expectEqual(State.done, t.latest().?.state);}
test "a tree's lines run on to later siblings and stop at the last" { // tab // ├─ pane // │ └─ build // │ ├─ test // │ └─ lint // └─ other pane const depths = [_]u8{ 0, 1, 2, 3, 3, 1 }; const want = [_][]const u8{ "", "├─ ", "│ └─ ", "│ ├─ ", "│ └─ ", "└─ " }; for (want, 0..) |w, i| { var buf: [32]u21 = undefined; const got = treePrefix(&depths, i, &buf); var text: [96]u8 = undefined; var n: usize = 0; for (got) |cp| n += std.unicode.utf8Encode(cp, text[n..]) catch unreachable; try testing.expectEqualStrings(w, text[0..n]); }}