// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! One terminal: its session, and what the window keeps about it between //! frames — what was last drawn of it and its rows shaped, the selection //! being made in it, its title, where it is laid out, and whether it has //! finished. //! //! A tab holds one or more, split side by side or one above another. Only //! the shown tab's are drawn; the others go on running, their programs' //! output going into their own terminals, and are caught up the next time //! they are shown. const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const linux = std.os.linux; const vt = @import("ghostty-vt"); const App = @import("App.zig"); const Config = @import("config/Config.zig"); const Session = @import("termio/Session.zig"); const Backend = @import("backend/Backend.zig"); const req = @import("request.zig"); const frame = @import("renderer/frame.zig"); const kitty_images = @import("renderer/images.zig"); const prog_status = @import("status.zig"); const l10n = @import("l10n.zig"); const Search = @import("Search.zig"); const loc = @import("location.zig"); const host = @import("os/host.zig"); const Confirmation = @import("Confirmation.zig"); const Toast = @import("Toast.zig"); const PaneConfigs = @import("PaneConfigs.zig"); const fg_mod = @import("os/foreground.zig"); const Pane = @This(); gpa: Allocator, /// Unique for as long as the program runs, wherever the pane is moved: what /// a search result names it by. id: u64, session: *Session, /// What was last drawn of the terminal, so that a frame redraws only what /// changed since. state: vt.RenderState = .empty, /// Its rows, shaped. rows: frame.RowCache = .{}, /// Whether its terminal had mode 2027 on when `state` was taken: how text /// being composed at its cursor is laid out. grapheme_cluster: bool = true, /// A search of its terminal, while the search field is open. search: ?Search = null, /// Where its programs' images are, as of the last frame. images: kitty_images.State = .{}, /// Where its viewport is in its scrollback, as of the last frame. scrollbar: vt.PageList.Scrollbar = .zero, /// The pointer is at its scrollbar. scrollbar_hover: bool = false, /// Where it is in the window, padding and all, in physical pixels; empty /// while it is not shown — a pane hidden by another one zoomed. rect: Rect = .{}, gesture: vt.SelectionGesture = .init, /// Scroll smaller than a line, kept until it adds up to one. scroll_rest: f32 = 0, /// The program's title, or the backend's description until it sets one. title: std.ArrayList(u8) = .empty, /// The profile it was opened from, by name: what a tab or split made /// from it runs again, and whose colors it has. profile: ?[]const u8 = null, /// Whether it runs the profile's program or port, rather than the shell. profile_program: bool = true, /// The backend is gone. exited: bool = false, /// Whether to stay once the backend is gone; see `Request.Hold`. hold: req.Request.Hold = .never, /// Staying with nothing left in it, until a key is pressed. held: bool = false, /// To be closed at the end of this turn of the loop. closing: bool = false, /// A notification from its program is showing, to be withdrawn once the /// pane is looked at. notified: bool = false, /// What the session said that could not be taken yet, its terminal being /// busy — reflowing its scrollback to a new width, say — and is taken on a /// later turn rather than waited for. deferred: Session.Events = .{}, /// Where the program was last found to be: what `workingDirectory` says /// when told not to wait. last_cwd: std.ArrayList(u8) = .empty, /// Its location, for the subtitle: where its shell last said it is, or /// until it says, where `/proc` finds the program on the pty. Kept when the /// program has gone: a held pane shows where it ended. location: ?loc.Location = null, /// Its shell has said where it is at least once, with OSC 7; from then on /// only what it says counts, since `/proc` knows nothing of an ssh's far /// end and would name the directory ssh was started in. location_reported: bool = false, /// When its bell last rang, so that a flood of rings counts as one. bell_at: ?std.Io.Timestamp = null, /// Its bell rang while it was not being looked at, and it has not been /// since. rung: bool = false, /// It is lit for its bell until then. flash_until: ?std.Io.Timestamp = null, /// What harrier is asking about it, waiting for an answer. confirmation: ?Confirmation = null, /// It has been asking while not being looked at, and has not been looked /// at since: its tab and window are marked. asking: bool = false, /// The user has let its program read, or write, the clipboard for as long /// as it is open. clipboard_allowed: struct { read: bool = false, write: bool = false } = .{}, /// Its program accepts drops, and offers drags, with kitty's protocol. dnd_accepting: bool = false, dnd_offers: bool = false, /// It has been drawn: a size it is given from now on is a change, and /// shown as one. drawn: bool = false, /// Its grid's size is shown over it until then, after a change. size_shown_until: ?std.Io.Timestamp = null, /// What has just happened in it, shown over it until the toast's time is /// up. toast: ?Toast = null, /// What its program last said of its progress, shown as a bar along its /// top; null when there is none to show. progress: ?Progress = null, /// Its program status records (OSC 7501) as of the last change, in the /// order a tree shows them, their strings `status_arena`'s. status: []prog_status.Record = &.{}, status_arena: ?std.heap.ArenaAllocator = null, /// The OSC 9;4 report its bar may show, kept for the status popup. progress94: ?Session.ProgressRecord = null, /// What its terminal last belonged to: the foreground program, as the /// configuration sees it, its strings `gpa`'s; and what says whether that /// may have changed. Null until it is looked for, which is only for a /// configuration that reads `ctx.foreground`. foreground: ?Config.Foreground = null, fg_identity: ?fg_mod.Identity = null, /// The configuration as its foreground program sees it, held from /// `configs`; null for the app's. pane_config: ?*PaneConfigs.Entry = null, configs: ?*PaneConfigs = null, /// When its last few notices went out, to keep a busy program from /// flooding the desktop with them. notice_times: [max_notices]std.Io.Timestamp = @splat(.zero), notice_next: usize = 0, /// What the pane's progress bar shows: the most recent report still /// standing, whether its program made it with OSC 9;4 or OSC 7501. pub const Progress = struct { state: State, /// Percent done; null where nothing says — a bar that sweeps rather /// than fills, while `normal`, and a full one otherwise. percent: ?u8, /// When it was reported. at: std.Io.Timestamp, /// Whether it goes once `progress_timeout_ms` has passed without /// another: an OSC 9;4 report, whose program may have been killed /// mid-way and never say it is done, and finished work. A program /// working or waiting by OSC 7501 says so until it says otherwise, and /// its shell's next prompt ends it. expires: bool, pub const State = enum { normal, paused, blocked, @"error", done }; }; /// The bar for the OSC 9;4 report `bar94` and the most recent status record /// `latest`: whichever came last. An idle program shows none. pub fn barOf(bar94: ?Session.ProgressRecord, latest: ?*const prog_status.Record) ?Progress { if (bar94) |p| if (latest == null or p.seq > latest.?.seq) return .{ .state = switch (p.state) { .pause => .paused, .@"error" => .@"error", else => .normal, }, .percent = p.percent, .at = p.at, .expires = true, }; const r = latest orelse return null; return switch (r.state) { .idle, .clear => null, .working => .{ .state = .normal, .percent = r.progress, .at = r.at, .expires = false }, .blocked => .{ .state = .blocked, .percent = r.progress, .at = r.at, .expires = false }, .done => .{ .state = .done, .percent = null, .at = r.at, .expires = true }, .@"error" => .{ .state = .@"error", .percent = null, .at = r.at, .expires = true }, }; } /// How long a progress bar stays without word from its program, as in /// Ghostty: a program killed mid-way never says it is done. pub const progress_timeout_ms = 15_000; pub const Options = struct { size: Backend.Size, }; pub const Rect = struct { x: u32 = 0, y: u32 = 0, w: u32 = 0, h: u32 = 0, pub fn contains(r: Rect, x: f32, y: f32) bool { return x >= @as(f32, @floatFromInt(r.x)) and x < @as(f32, @floatFromInt(r.x + r.w)) and y >= @as(f32, @floatFromInt(r.y)) and y < @as(f32, @floatFromInt(r.y + r.h)); } pub fn empty(r: Rect) bool { return r.w == 0 or r.h == 0; } }; pub fn create(app: *App, request: *const req.Request, options: Options) !*Pane { const gpa = app.gpa; const config = app.config(); var backend = app.makeBackend(options.size, request) catch |err| switch (request.hold) { .never => return err, .failure, .always, .unsuccessful => try failureBackend(app.program(request), request.cwd, err), }; errdefer backend.deinit(); const session = try Session.create(gpa, app.io, backend, .{ .size = options.size, .scrollback_bytes = config.scrollback_bytes, .grapheme_cluster = graphemeWidth(config, request.profile) == .unicode, .machine_id = app.machine_id, .notify = app.session_notify, }); errdefer session.destroy(); const p = try gpa.create(Pane); errdefer gpa.destroy(p); p.* = .{ .gpa = gpa, .id = app.nextPaneId(), .session = session, .hold = request.hold, .configs = &app.pane_configs }; errdefer p.title.deinit(gpa); if (request.profile) |e| p.profile = try gpa.dupe(u8, e); errdefer if (p.profile) |e| gpa.free(e); p.profile_program = request.profile_program; if (request.title) |title| { try p.title.appendSlice(gpa, title); } else { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); session.backend.describe(&w) catch {}; try p.title.appendSlice(gpa, w.buffered()); } // The configuration's colors and cursor, before the program says // anything; those for its foreground program, its own until it starts // another, once there is one to look at. app.applyTerminal(p); try session.start(); _ = app.checkForeground(p, true); return p; } /// The configuration's `grapheme_width`, or the profile's in its place. pub fn graphemeWidth(config: *const Config, profile: ?[]const u8) Config.GraphemeWidth { const e = config.profile(profile orelse return config.grapheme_width) orelse return config.grapheme_width; return e.grapheme_width orelse config.grapheme_width; } /// Whether the program may read, or write, the clipboard: the profile's /// setting, else the configuration's; and once the user has allowed it for /// the pane, without asking again. /// /// `config` is the app's, which has the profiles, and `own` the pane's, /// for its foreground program. pub fn clipboardAccess(p: *const Pane, config: *const Config, own: *const Config, comptime which: enum { read, write }) Config.ClipboardAccess { const e = if (p.profile) |name| config.profile(name) else null; const set = switch (which) { .read => if (e) |x| x.clipboard_read orelse own.clipboard.read else own.clipboard.read, .write => if (e) |x| x.clipboard_write orelse own.clipboard.write else own.clipboard.write, }; const allowed = switch (which) { .read => p.clipboard_allowed.read, .write => p.clipboard_allowed.write, }; return if (set == .ask and allowed) .allow else set; } /// At most this many notices from a pane in `notice_window_ms`. pub const max_notices = 4; pub const notice_window_ms = 10_000; /// Whether a notice may go out now, and counts it if so. pub fn mayNotice(p: *Pane, now: std.Io.Timestamp) bool { const oldest = p.notice_times[p.notice_next]; if (oldest.nanoseconds != 0 and now.compare(.lt, oldest.addDuration(.fromMilliseconds(notice_window_ms)))) return false; p.notice_times[p.notice_next] = now; p.notice_next = (p.notice_next + 1) % max_notices; return true; } pub fn destroy(p: *Pane) void { if (p.pane_config) |e| p.configs.?.release(e); if (p.foreground) |f| fg_mod.free(p.gpa, f); if (p.status_arena) |*a| a.deinit(); if (p.confirmation) |*c| c.deinit(p.gpa); p.session.lock(); p.gesture.deinit(&p.session.terminal); if (p.search) |*s| s.deinit(&p.session.terminal); p.session.unlock(); p.session.destroy(); p.state.deinit(p.gpa); p.rows.deinit(p.gpa); p.images.deinit(p.gpa); p.last_cwd.deinit(p.gpa); p.title.deinit(p.gpa); if (p.profile) |e| p.gpa.free(e); if (p.location) |*l| l.deinit(p.gpa); p.gpa.destroy(p); } /// What changed in the pane since the last turn of the loop, for its window. pub const Changes = struct { /// Something to draw, if the pane is shown. dirty: bool = false, title: bool = false, /// Its location changed. location: bool = false, /// Its bell rang, rings close together counted once. bell: bool = false, /// Text the program asked to be put on a clipboard. Owned by the caller. clipboard: ?Session.Clipboard = null, /// Reads of a clipboard the program asked for, each to be answered /// once. The slice is owned by the caller. clipboard_reads: []Session.ClipboardRead = &.{}, /// Drag and drop events from its program. Owned by the caller. dnd: ?Session.DndBatch = null, /// Desktop notifications its program asked for. Owned by the caller, /// with the session's allocator. notifications: []Session.Notification = &.{}, /// Its program reported its progress, or its status. progress: bool = false, /// Records that came into a state worth telling the user about. Owned /// by the caller, with the session's allocator. notices: []prog_status.Notice = &.{}, }; /// Takes what the session has said since the last turn of the loop. /// /// Never waits on the terminal: a terminal busy reflowing a long scrollback /// to a new width holds its lock for as long as that takes, and a window /// that waited would stop drawing — a split dragged across would go in /// jumps. What needs the lock while it is held is left for a later turn, /// which the session's next event brings. pub fn update(p: *Pane) Changes { const e: Session.Events = @bitCast(@as(u32, @bitCast(p.session.takeEvents())) | @as(u32, @bitCast(p.deferred))); p.deferred = .{}; var c: Changes = .{ .dirty = e.dirty }; if (e.title) title: { if (!p.session.tryLock()) { p.deferred.title = true; break :title; } defer p.session.unlock(); if (p.session.terminal.getTitle()) |title| { p.title.clearRetainingCapacity(); p.title.appendSlice(p.gpa, title) catch {}; c.title = true; } } if (e.pwd) pwd: { if (!p.session.tryLock()) { p.deferred.pwd = true; break :pwd; } defer p.session.unlock(); const url = p.session.terminal.getPwd() orelse break :pwd; var host_buf: [256]u8 = undefined; const found = loc.Location.ofUrl(p.gpa, url, host.name(&host_buf)) catch break :pwd; p.location_reported = true; c.location = p.setLocation(found orelse break :pwd); } if (e.bell) { const now = std.Io.Clock.awake.now(p.session.io); c.bell = newRing(p.bell_at, now); if (c.bell) p.bell_at = now; } if (e.clipboard) c.clipboard = p.session.takeClipboard(); if (e.clipboard_read) c.clipboard_reads = p.session.takeClipboardReads(); if (e.dnd) c.dnd = p.session.takeDnd(); if (e.notify) c.notifications = p.session.takeNotifications(); if (e.progress or e.status) status: { if (!p.session.tryLock()) { p.deferred.progress = e.progress; p.deferred.status = e.status; break :status; } defer p.session.unlock(); p.refreshStatus(); c.notices = p.session.takeStatusNotices(); c.dirty = true; c.progress = true; } if (e.exited and !p.exited) exited: { if (!p.session.tryLock()) { p.deferred.exited = true; break :exited; } p.session.unlock(); p.exited = true; // Whatever it said it was doing, it is not doing any more. { p.session.lock(); defer p.session.unlock(); if (p.session.status.endWork()) { p.refreshStatus(); c.dirty = true; c.progress = true; } } // A message is always held: it is there to be read. if (p.hold == .always or p.session.backend == .message or (p.hold == .unsuccessful and p.unsuccessful())) { p.holdOpen(); c.dirty = true; } else p.closing = true; } return c; } /// Takes the session's progress and status records as they are now, for /// its bar and the status popup. With the session's lock held. fn refreshStatus(p: *Pane) void { const s = p.session; p.progress94 = s.progress; p.progress = barOf(s.progress, s.status.latest()); var arena: std.heap.ArenaAllocator = .init(p.gpa); const snap = s.status.snapshot(arena.allocator()) catch { arena.deinit(); return; }; if (p.status_arena) |*old| old.deinit(); p.status_arena = arena; p.status = snap; } /// Whether the backend, gone, went with an error: its program exited with a /// status other than 0 or was killed, or its port went away. The program is /// waited for a moment, its end being seen a little before it can be /// collected; one that cannot be is taken to have done well. fn unsuccessful(p: *Pane) bool { p.session.lock(); defer p.session.unlock(); switch (p.session.backend) { .pty => |*pty| { for (0..50) |_| { if (pty.exitStatus() != null) break; p.session.io.sleep(.fromMilliseconds(1), .awake) catch break; } const st = pty.exitStatus() orelse return false; return !st.successful(); }, .serial => return true, .message => return true, } } /// The backend is gone, but the pane stays: says how it ended, and waits for /// a key. fn holdOpen(p: *Pane) void { p.held = true; var buf: [256]u8 = undefined; var out: std.Io.Writer = .fixed(&buf); p.session.lock(); defer p.session.unlock(); switch (p.session.backend) { .pty => |*pty| { var reason_buf: [128]u8 = undefined; var reason: std.Io.Writer = .fixed(&reason_buf); switch (pty.exitStatus() orelse .other) { .exited => |code| l10n.write(&reason, "pane-exited-status", &.{l10n.num("status", code)}) catch {}, .signaled => |sig| l10n.write(&reason, "pane-killed", &.{l10n.num("signal", sig)}) catch {}, .other => reason.writeAll(l10n.text("pane-exited")) catch {}, } out.writeAll("\r\n\x1b[0;2m") catch {}; l10n.write(&out, "pane-hold", &.{l10n.str("reason", reason.buffered())}) catch {}; out.writeAll("\x1b[0m") catch {}; }, .serial => { out.writeAll("\r\n\x1b[0;2m") catch {}; l10n.write(&out, "pane-hold", &.{l10n.str("reason", l10n.text("pane-port-gone"))}) catch {}; out.writeAll("\x1b[0m") catch {}; }, .message => { out.writeAll("\r\n\x1b[0;2m") catch {}; out.writeAll(l10n.text("pane-hold-only")) catch {}; out.writeAll("\x1b[0m") catch {}; }, } p.session.stream.nextSlice(out.buffered()); p.session.terminal.scrollViewport(.bottom); } /// How long after a ring another counts as the same one: `cat` on a binary /// file, or a key held at a prompt, rings far faster than that. pub const bell_collapse_ms = 250; /// Whether a ring at `now` is a new one, the last having been at `last`. fn newRing(last: ?std.Io.Timestamp, now: std.Io.Timestamp) bool { const l = last orelse return true; return !now.compare(.lt, l.addDuration(.fromMilliseconds(bell_collapse_ms))); } /// Takes `l` as the pane's location; true if it is a change. fn setLocation(p: *Pane, l: loc.Location) bool { var new = l; if (p.location) |*old| { if (old.eql(new)) { new.deinit(p.gpa); return false; } old.deinit(p.gpa); } p.location = new; return true; } /// Looks again at where the program on the pty is, for a pane whose shell /// has not said with OSC 7; true if its location changed. Linux only: /// elsewhere nothing can ask another process where it is. A program that /// has gone leaves the location as it was. pub fn refreshLocation(p: *Pane) bool { if (p.location_reported or builtin.os.tag != .linux) return false; const pid = switch (p.session.backend) { .pty => |*pty| pty.pid, else => return false, }; var link_buf: [64]u8 = undefined; const link = std.mem.printSentinel(&link_buf, "/proc/{d}/cwd", .{pid}, 0) catch return false; var buf: [std.fs.max_path_bytes]u8 = undefined; const rc = linux.readlink(link, &buf, buf.len); if (linux.errno(rc) != .SUCCESS) return false; if (p.location) |l| if (l.host.len == 0 and std.mem.eql(u8, l.path, buf[0..rc])) return false; const found = loc.Location.ofPath(p.gpa, buf[0..rc]) catch return false; return p.setLocation(found); } /// Where the program in the pane is now, for a new tab or split to start /// in: what the shell last reported with OSC 7, or failing that the /// directory the program on the pty is in. Null for a serial port or a /// message. /// /// Without `wait`, a terminal busy reflowing is not waited for: what was /// last found is the answer. pub fn workingDirectory(p: *Pane, gpa: Allocator, wait: bool) ?[]u8 { const found = p.findWorkingDirectory(gpa, wait) orelse return null; if (found.len > 0) { p.last_cwd.clearRetainingCapacity(); p.last_cwd.appendSlice(p.gpa, found) catch {}; } return found; } fn findWorkingDirectory(p: *Pane, gpa: Allocator, wait: bool) ?[]u8 { if (wait) p.session.lock() else if (!p.session.tryLock()) return if (p.last_cwd.items.len > 0) gpa.dupe(u8, p.last_cwd.items) catch null else null; { defer p.session.unlock(); if (p.session.terminal.getPwd()) |url| if (pathOfUrl(gpa, url)) |path| return path; } // Windows has no asking another process where it is: there, only what // the shell says. if (builtin.os.tag != .linux) return null; const pid = switch (p.session.backend) { .pty => |*pty| pty.pid, else => return null, }; var link_buf: [64]u8 = undefined; const link = std.mem.printSentinel(&link_buf, "/proc/{d}/cwd", .{pid}, 0) catch return null; var buf: [std.fs.max_path_bytes]u8 = undefined; const rc = linux.readlink(link, &buf, buf.len); if (linux.errno(rc) != .SUCCESS) return null; return gpa.dupe(u8, buf[0..rc]) catch null; } /// The path in an OSC 7 report, as this OS writes one. A host other than /// this one's is still taken: for a new tab to start in, the path is the /// best guess there is. fn pathOfUrl(gpa: Allocator, url: []const u8) ?[]u8 { var scratch: [std.fs.max_path_bytes]u8 = undefined; const parts = loc.split(url, &scratch) orelse return null; return loc.nativePath(gpa, parts.path) catch null; } /// A pane's worth of text saying that `program` could not be started in /// `cwd`, and why. fn failureBackend(program: App.Program, cwd: ?[]const u8, err: anyerror) !Backend.Backend { var buf: [1024]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); var target_buf: [512]u8 = undefined; var target: std.Io.Writer = .fixed(&target_buf); const what: []const u8 = if (program.serial) |p| blk: { target.writeAll(p.path) catch {}; break :blk "serial"; } else if (program.command) |c| blk: { for (c, 0..) |arg, i| { if (i > 0) target.writeAll(" ") catch {}; target.writeAll(arg) catch {}; } break :blk "command"; } else "shell"; w.writeAll("\x1b[1;31mharrier:\x1b[0m ") catch {}; l10n.write(&w, "start-failed", &.{ l10n.str("what", what), l10n.str("target", target.buffered()), l10n.str("directory", cwd orelse "none"), l10n.str("reason", describeError(err)), }) catch {}; w.writeAll("\r\n") catch {}; return .{ .message = try .init(w.buffered()) }; } fn describeError(err: anyerror) []const u8 { return switch (err) { error.FileNotFound => l10n.text("start-error-not-found"), error.AccessDenied => l10n.text("start-error-access-denied"), error.SystemResources => l10n.text("start-error-system-resources"), error.OutOfMemory => l10n.text("start-error-out-of-memory"), else => @errorName(err), }; } // --------------------------------------------------------------------------- const testing = std.testing; test "OSC 7's path, from either scheme" { const a = testing.allocator; const windows = builtin.os.tag == .windows; const p1 = pathOfUrl(a, "file://host/home/me/My%20Files").?; defer a.free(p1); try testing.expectEqualStrings(if (windows) "\\home\\me\\My Files" else "/home/me/My Files", p1); const p2 = pathOfUrl(a, "file:///tmp").?; defer a.free(p2); try testing.expectEqualStrings(if (windows) "\\tmp" else "/tmp", p2); const p3 = pathOfUrl(a, "kitty-shell-cwd://host/a%20b").?; defer a.free(p3); try testing.expectEqualStrings(if (windows) "\\a%20b" else "/a%20b", p3); try testing.expect(pathOfUrl(a, "http://host/x") == null); try testing.expect(pathOfUrl(a, "file://host") == null); if (windows) { const p4 = pathOfUrl(a, "file://host/C:/Users/me").?; defer a.free(p4); try testing.expectEqualStrings("C:\\Users\\me", p4); } } test "rings close together count as one" { const t0: std.Io.Timestamp = .zero; try testing.expect(newRing(null, t0)); try testing.expect(!newRing(t0, t0.addDuration(.fromMilliseconds(100)))); try testing.expect(!newRing(t0, t0.addDuration(.fromMilliseconds(bell_collapse_ms - 1)))); try testing.expect(newRing(t0, t0.addDuration(.fromMilliseconds(bell_collapse_ms)))); } test "the bar shows the most recent report, OSC 9;4 or OSC 7501" { const zero: std.Io.Timestamp = .zero; const working: prog_status.Record = .{ .id = "", .state = .working, .progress = 30, .seq = 2, .at = zero }; const older94: Session.ProgressRecord = .{ .state = .set, .percent = 80, .seq = 1, .at = zero }; const newer94: Session.ProgressRecord = .{ .state = .pause, .percent = 50, .seq = 3, .at = zero }; // The status record is newer: it shows, and lasts until it changes. const a = barOf(older94, &working).?; try testing.expectEqual(Progress.State.normal, a.state); try testing.expectEqual(@as(?u8, 30), a.percent); try testing.expect(!a.expires); // A newer OSC 9;4 report takes over, and expires as they do. const b = barOf(newer94, &working).?; try testing.expectEqual(Progress.State.paused, b.state); try testing.expect(b.expires); // Done is a full bar that goes in time; idle is none. const done: prog_status.Record = .{ .id = "", .state = .done, .seq = 4, .at = zero }; const c = barOf(newer94, &done).?; try testing.expectEqual(Progress.State.done, c.state); try testing.expectEqual(@as(?u8, null), c.percent); try testing.expect(c.expires); const idle: prog_status.Record = .{ .id = "", .state = .idle, .seq = 5, .at = zero }; try testing.expect(barOf(newer94, &idle) == null); const blocked: prog_status.Record = .{ .id = "x", .state = .blocked, .kind = .auth, .seq = 6, .at = zero }; try testing.expectEqual(Progress.State.blocked, barOf(null, &blocked).?.state); }