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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// 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);}