// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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]); } }