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.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! One terminal: libghostty-vt's `Terminal`, the stream that feeds it, the//! backend on the other side, and the thread that moves bytes between them.//!//! ## Threads//!//! The IO thread is the only one that reads the backend and the only one that//! writes it. It waits on the backend's handle and on `wake`, feeds what it//! reads through the stream with `mutex` held, and signals `notify` so that//! whoever owns the session — a window, on the main thread — knows to look.//!//! Anything that has to go to the backend — keystrokes from the main thread,//! replies the terminal makes to queries on the IO thread — goes into//! `outbox` under `outbox_mutex`, and the IO thread writes it out when the//! handle is writable. So no thread but the IO thread ever blocks on the//! backend, which matters for a serial port at 300 baud.//!//! The renderer takes `mutex` for as long as it takes to copy what it needs//! out of the terminal (`RenderState.update`), and no longer.//!//! ## Lifetime//!//! The stream's handler holds a pointer to `terminal`, and the effect//! callbacks find the session from the handler by field offset, so a session//! never moves: `create` allocates it and `destroy` frees it.
const std = @import("std");const builtin = @import("builtin");const linux = std.os.linux;const Allocator = std.mem.Allocator;const Io = std.Io;const vt = @import("ghostty-vt");
const backend_mod = @import("../backend/Backend.zig");const Backend = backend_mod.Backend;const Size = @import("../backend/Backend.zig").Size;const Wake = @import("../os/Wake.zig");const prog_status = @import("../status.zig");
const Session = @This();
const log = std.log.scoped(.session);
gpa: Allocator,io: Io,
/// Guards `terminal`, `stream` and everything below that says so.mutex: Io.Mutex = .init,terminal: vt.Terminal,stream: vt.TerminalStream,/// The grid's size, for size reports. Guarded by `mutex`; the IO thread's/// to change.size: Size,/// The size last asked for with `resize`: the owner's, on its thread alone,/// and what the grid will be once the IO thread gets to it.wanted: Size,/// A resize the IO thread has yet to make, the latest asked for. Guarded/// by `outbox_mutex`.pending_resize: ?PendingResize = null,/// Cell size in pixels, for size reports. Guarded by `mutex`.cell_width: u32 = 0,cell_height: u32 = 0,/// The last write the program made to a clipboard, waiting for the owner/// to take it with `takeClipboard`. Guarded by `mutex`.clipboard: ?Clipboard = null,/// Reads of a clipboard the program asked for, oldest first, each to be/// answered once with `answerClipboardRead`; waiting for the owner to take/// them with `takeClipboardReads`. Guarded by `mutex`.clipboard_reads: std.ArrayList(ClipboardRead) = .empty,/// Desktop notifications the program asked for (OSC 9, OSC 777), waiting/// for the owner to take them with `takeNotifications`. Guarded by `mutex`.notifications: std.ArrayList(Notification) = .empty,/// The progress the program last reported (OSC 9;4); null once it said it/// was done, or never said, or once it has reported with OSC 7501./// Guarded by `mutex`.progress: ?ProgressRecord = null,/// The program has reported with OSC 7501 since the last full reset: its/// OSC 9;4 reports are not kept, as the specification asks of a terminal/// that has both. Guarded by `mutex`.program_status_seen: bool = false,/// Its program status records (OSC 7501). Guarded by `mutex`.status: prog_status.Tree,/// Records that came into a state worth telling the user about, waiting for/// the owner to take them with `takeStatusNotices`. Guarded by `mutex`.status_notices: std.ArrayList(prog_status.Notice) = .empty,/// Drag and drop events the program caused, copied into `dnd_arena`,/// waiting for the owner to take them with `takeDnd`. Guarded by `mutex`.dnd_events: std.ArrayList(Dnd) = .empty,dnd_arena: std.heap.ArenaAllocator,
backend: Backend,outbox_mutex: Io.Mutex = .init,outbox: std.ArrayList(u8) = .empty,
/// Wakes the IO thread.wake: Wake,/// Wakes the owner. Poll `notify.handle`, then call `takeEvents`. Its own,/// or one the owner shares between sessions; see `Options.notify`.notify: Wake,owns_notify: bool,events: std.atomic.Value(u32) = .init(0),stopping: std.atomic.Value(bool) = .init(false),thread: ?std.Thread = null,
pub const Clipboard = struct { location: vt.clipboard.Location, /// Every Clipboard type written, with its bytes, in the order written. /// Owned by the session's allocator; whoever takes it frees it with /// `deinit`. contents: []vt.clipboard.Content, /// To be answered once, with `answerClipboardWrite`, when the write /// is done or refused: kitty's protocol waits for that. reply: vt.clipboard.Write.Deferred,
pub fn deinit(c: Clipboard, gpa: Allocator) void { freeContents(gpa, c.contents); }
/// The first text written, if any was. pub fn text(c: Clipboard) ?[]const u8 { for (c.contents) |x| if (vt.clipboard.isTextMime(x.mime)) return x.data; return null; }};
/// A copy of `contents`, every type and its bytes, freed with/// `freeContents`.pub fn dupeContents(gpa: Allocator, contents: []const vt.clipboard.Content) Allocator.Error![]vt.clipboard.Content { const out = try gpa.alloc(vt.clipboard.Content, contents.len); var n: usize = 0; errdefer { for (out[0..n]) |c| { gpa.free(c.mime); gpa.free(c.data); } gpa.free(out); } for (contents) |c| { const mime = try gpa.dupe(u8, c.mime); const data = gpa.dupe(u8, c.data) catch |err| { gpa.free(mime); return err; }; out[n] = .{ .mime = mime, .data = data }; n += 1; } return out;}
pub fn freeContents(gpa: Allocator, contents: []const vt.clipboard.Content) void { for (contents) |c| { gpa.free(c.mime); gpa.free(c.data); } gpa.free(contents);}
/// A program asking to read a clipboard, with OSC 52 or kitty's clipboard/// protocol (OSC 5522): for the owner to answer, once, with/// `answerClipboardRead`.pub const ClipboardRead = struct { reply: vt.clipboard.Read.Deferred, /// The user has said yes already: the read is a program fetching what /// the user pasted, which it was told of with a kitty paste event /// (mode 5522) rather than sent. granted: bool, /// It asks only which types the clipboard has, kitty's listing, and /// none of what is on it: kitty answers that without asking. listing: bool,};
/// What a clipboard read is answered with.pub const ClipboardAnswer = union(enum) { /// Nothing, as a refusal: OSC 52 hears an empty clipboard, and kitty's /// protocol EPERM. denied, /// What is on the clipboard. contents: Contents,
pub const Contents = struct { /// Each Clipboard type read, with its bytes. A type asked for and /// not here is answered as not on the clipboard. read: []const vt.clipboard.Content = &.{}, /// Every type the clipboard has, for kitty's listing. available: []const []const u8 = &.{}, };};
/// What happened since the owner last looked.pub const Events = packed struct(u32) { /// The screen changed and wants drawing. dirty: bool = false, /// The backend is gone: the program exited or the port went away. exited: bool = false, title: bool = false, bell: bool = false, clipboard: bool = false, /// The program asked to read a clipboard. clipboard_read: bool = false, /// A synchronized-output hold began (`true` in `holding`) or ended. hold: bool = false, holding: bool = false, /// Drag and drop events are waiting; see `takeDnd`. dnd: bool = false, /// Desktop notifications are waiting; see `takeNotifications`. notify: bool = false, /// The program reported its progress; see `progress`. progress: bool = false, /// Its status records changed; see `status`, and `takeStatusNotices`. status: bool = false, /// The shell said where it is, with OSC 7; see `Terminal.getPwd`. pwd: bool = false, _: u19 = 0,};
pub const Progress = vt.osc.Command.ProgressReport;
/// An OSC 9;4 report as kept: with the amount an error or a pause that gives/// none carries over, and its place among the status reports.pub const ProgressRecord = struct { state: Progress.State, /// Percent done; null where nothing says. percent: ?u8, seq: u64, at: Io.Timestamp,};
/// A desktop notification a program asked for. Owned by the session's/// allocator; whoever takes it frees it with `deinit`.pub const Notification = struct { title: []u8, body: []u8,
pub fn deinit(n: Notification, gpa: Allocator) void { gpa.free(n.title); gpa.free(n.body); }};
/// libghostty-vt's protocol-independent drag and drop types, and its/// machine ID for kitty's protocol.pub const dnd = vt.dnd;const InputError = vt.kitty.dnd.InputError;
/// A drag and drop event, as `takeDnd` hands it over.pub const Dnd = union(enum) { drop: dnd.DropEvent, drag: dnd.DragEvent,};
/// What `takeDnd` hands over: the events, and the arena they are in.pub const DndBatch = struct { arena: std.heap.ArenaAllocator, events: []const Dnd,
pub fn deinit(b: *DndBatch) void { b.arena.deinit(); }};
const PendingResize = struct { size: Size, cell_width: u32, cell_height: u32 };
pub const Options = struct { size: Size, scrollback_bytes: ?usize = 10 * 1024 * 1024, colors: vt.Terminal.Colors = .default, /// Whether a grapheme takes the cells Unicode says, rather than those /// of its first code point, until the program says otherwise: mode /// 2027's default. grapheme_cluster: bool = true, /// This machine, for kitty's drag and drop protocol; see `dnd.machineId`. machine_id: ?dnd.MachineId = null, /// A wakeup shared with other sessions, which the owner waits on once /// for all of them and then asks each for its events; null for one of /// the session's own. Windows waits on at most 64 handles at a time, /// which would otherwise be 63 panes. notify: ?Wake = null,};
/// Takes ownership of `backend`, also on error.pub fn create(gpa: Allocator, io: Io, backend: Backend, options: Options) !*Session { var b = backend; errdefer b.deinit();
const s = try gpa.create(Session); errdefer gpa.destroy(s);
var wake: Wake = try .init(); errdefer wake.deinit(); var notify: Wake = options.notify orelse try .init(); errdefer if (options.notify == null) notify.deinit();
s.* = .{ .gpa = gpa, .io = io, .terminal = try .init(io, gpa, .{ .cols = options.size.cols, .rows = options.size.rows, .max_scrollback_bytes = options.scrollback_bytes, .colors = options.colors, .default_modes = .{ .grapheme_cluster = options.grapheme_cluster }, }), .stream = undefined, .size = options.size, .wanted = options.size, .backend = b, .wake = wake, .notify = notify, .owns_notify = options.notify == null, .dnd_arena = .init(gpa), .status = .init(gpa), }; var handler: vt.TerminalStream.Handler = .init(&s.terminal); handler.effects = effects; handler.dnd_machine_id = options.machine_id; s.stream = .init(.{ .handler = handler, .allocator = gpa, // OSC 52 payloads can be large, so they need the allocator; a // sequence split across two reads needs nothing, since the stream // keeps its parser state between calls. }); return s;}
/// Starts the IO thread.pub fn start(s: *Session) std.Thread.SpawnError!void { s.thread = try std.Thread.spawn(.{}, ioLoop, .{s});}
pub fn destroy(s: *Session) void { s.stopping.store(true, .release); s.wake.signal(); if (s.thread) |t| t.join();
s.stream.deinit(); s.terminal.deinit(s.gpa); s.backend.deinit(); if (s.clipboard) |c| c.deinit(s.gpa); s.clipboard_reads.deinit(s.gpa); s.dnd_events.deinit(s.gpa); for (s.notifications.items) |n| n.deinit(s.gpa); s.notifications.deinit(s.gpa); s.status.deinit(); for (s.status_notices.items) |n| n.deinit(s.gpa); s.status_notices.deinit(s.gpa); s.dnd_arena.deinit(); s.outbox.deinit(s.gpa); s.wake.deinit(); if (s.owns_notify) s.notify.deinit(); s.gpa.destroy(s);}
pub fn lock(s: *Session) void { s.mutex.lockUncancelable(s.io);}
pub fn unlock(s: *Session) void { s.mutex.unlock(s.io);}
/// Queues bytes for the backend: keystrokes, pastes. Safe from any thread.pub fn write(s: *Session, bytes: []const u8) void { s.outbox_mutex.lockUncancelable(s.io); defer s.outbox_mutex.unlock(s.io); s.outbox.appendSlice(s.gpa, bytes) catch { log.warn("dropped {d} bytes of input: out of memory", .{bytes.len}); return; }; s.wake.signal();}
/// Resizes the grid and tells the backend: on the IO thread, which may be/// a while about it — reflowing a long scrollback to a new width takes tens/// of milliseconds — so this only says what size is wanted and returns./// Asked again before the IO thread has got to it, only the latest is made:/// dragging a divider across forty columns reflows a few times, not forty./// From the owner's thread.pub fn resize(s: *Session, size: Size, cell_width: u32, cell_height: u32) void { s.wanted = size; { s.outbox_mutex.lockUncancelable(s.io); defer s.outbox_mutex.unlock(s.io); s.pending_resize = .{ .size = size, .cell_width = cell_width, .cell_height = cell_height }; } s.wake.signal();}
/// Makes the resize last asked for, if there is one. On the IO thread.fn applyResize(s: *Session) void { const r = blk: { s.outbox_mutex.lockUncancelable(s.io); defer s.outbox_mutex.unlock(s.io); const r = s.pending_resize orelse return; s.pending_resize = null; break :blk r; }; { s.lock(); defer s.unlock(); if (r.size.cols == s.size.cols and r.size.rows == s.size.rows and r.cell_width == s.cell_width and r.cell_height == s.cell_height) return; s.size = r.size; s.cell_width = r.cell_width; s.cell_height = r.cell_height; s.stream.handler.resize(.{ .cols = r.size.cols, .rows = r.size.rows, .cell_size_px = if (r.cell_width > 0 and r.cell_height > 0) .{ .width = r.cell_width, .height = r.cell_height } else null, }) catch |err| log.warn("resize to {d}x{d}: {t}", .{ r.size.cols, r.size.rows, err }); } // The backend's own resize is a plain ioctl, but it happens outside the // lock so that a program redrawing in answer to SIGWINCH is not held up. s.backend.resize(r.size); s.raise(.{ .dirty = true });}
/// Takes the lock if nobody holds it: for drawing, which would rather show/// what it last copied than wait out a reflow.pub fn tryLock(s: *Session) bool { return s.mutex.tryLock();}
/// Returns and clears what has happened since the last call.pub fn takeEvents(s: *Session) Events { _ = s.notify.drain(); return @bitCast(s.events.swap(0, .acq_rel));}
/// Takes what the program last wrote to a clipboard. The caller frees it/// with `Clipboard.deinit` and the session's allocator.pub fn takeClipboard(s: *Session) ?Clipboard { s.lock(); defer s.unlock(); const c = s.clipboard; s.clipboard = null; return c;}
/// Takes the program's waiting clipboard reads, oldest first. The caller/// frees the slice, and answers each, once.pub fn takeClipboardReads(s: *Session) []ClipboardRead { s.lock(); defer s.unlock(); return s.clipboard_reads.toOwnedSlice(s.gpa) catch &.{};}
/// Answers a clipboard write with how it went.pub fn answerClipboardWrite(s: *Session, reply: *const vt.clipboard.Write.Deferred, result: vt.clipboard.Write.Result) void { s.lock(); defer s.unlock(); s.stream.handler.replyClipboardWrite(reply, result);}
/// Answers a clipboard read: the program is waiting for it, whatever it/// is. Each type the program asked for is served what was read of it, and/// a text type whatever text was read, under whichever name.pub fn answerClipboardRead(s: *Session, read: *const vt.clipboard.Read.Deferred, answer: ClipboardAnswer) void { s.lock(); defer s.unlock(); var buf: [vt.kitty.clipboard.max_read_mimes]vt.clipboard.Content = undefined; s.stream.handler.replyClipboardRead(read, clipboardResult(read, answer, &buf));}
/// `answer` as the terminal's reply to `read`, the contents in `buf`.fn clipboardResult( read: *const vt.clipboard.Read.Deferred, answer: ClipboardAnswer, buf: *[vt.kitty.clipboard.max_read_mimes]vt.clipboard.Content,) vt.clipboard.Read.Result { const contents = switch (answer) { .denied => return .denied, .contents => |c| c, }; // OSC 52 takes the first text there is. // By pointer: the types handed back are slices of `read`'s own. const k = if (read.kitty) |*k| k else return .{ .success = .{ .contents = contents.read } }; var n: usize = 0; for (0..k.mimes_len) |i| if (servedAs(contents.read, k.mime(i))) |data| { buf[n] = .{ .mime = k.mime(i), .data = data }; n += 1; }; return .{ .success = .{ .contents = buf[0..n], .available = contents.available } };}
/// What of `read` answers a request for `mime`: its own type's bytes, or/// for a text type, text read under any name.fn servedAs(read: []const vt.clipboard.Content, mime: []const u8) ?[]const u8 { for (read) |c| if (std.mem.eql(u8, c.mime, mime)) return c.data; if (!vt.clipboard.isTextMime(mime)) return null; for (read) |c| if (vt.clipboard.isTextMime(c.mime)) return c.data; return null;}
/// Whether the program may take part in drag and drop with kitty's/// protocol (OSC 72). Off, its commands are ignored, as by a terminal that/// has never heard of it. With the lock held.pub fn setDragAndDrop(s: *Session, on: bool) void { const h = &s.stream.handler; h.effects.drop = if (on) &dropEffect else null; h.effects.drag = if (on) &dragEffect else null;}
/// Takes the notifications the program has asked for since the last call,/// in order. The caller frees each, and the slice, with the session's/// allocator.pub fn takeNotifications(s: *Session) []Notification { s.lock(); defer s.unlock(); return s.notifications.toOwnedSlice(s.gpa) catch { for (s.notifications.items) |n| n.deinit(s.gpa); s.notifications.clearRetainingCapacity(); return &.{}; };}
/// Takes the drag and drop events the program has caused since the last/// call, in order. The caller frees the batch.pub fn takeDnd(s: *Session) ?DndBatch { s.lock(); defer s.unlock(); if (s.dnd_events.items.len == 0) return null; // The events go with their arena, and the session starts another. defer s.dnd_events.clearRetainingCapacity(); const events = s.dnd_arena.allocator().dupe(Dnd, s.dnd_events.items) catch { _ = s.dnd_arena.reset(.free_all); return null; }; const batch: DndBatch = .{ .arena = s.dnd_arena, .events = events }; s.dnd_arena = .init(s.gpa); return batch;}
/// Reports native drop activity to the program accepting drops, and/// returns what it wants next, copied into `arena`: the next data request,/// once one is answered. `error.Inactive` when it accepts none.pub fn dropInput(s: *Session, arena: Allocator, input: dnd.DropInput) (InputError || Allocator.Error)!?dnd.DropEvent { s.lock(); defer s.unlock(); const state = s.terminal.kitty_dnd orelse return error.Inactive; var aw: std.Io.Writer.Allocating = .init(s.gpa); defer aw.deinit(); const next = try vt.kitty.dnd.dropInput(state, s.terminal.gpa(), &aw.writer, input); if (aw.written().len > 0) s.write(aw.written()); return if (next) |e| try e.dupe(arena) else null;}
/// Reports native activity for the drag the program offers./// `error.Inactive` when it offers none.pub fn dragInput(s: *Session, input: dnd.DragInput) InputError!void { s.lock(); defer s.unlock(); const state = s.terminal.kitty_dnd orelse return error.Inactive; var aw: std.Io.Writer.Allocating = .init(s.gpa); defer aw.deinit(); try vt.kitty.dnd.dragInput(state, s.terminal.gpa(), &aw.writer, input); if (aw.written().len > 0) s.write(aw.written());}
fn raise(s: *Session, e: Events) void { _ = s.events.fetchOr(@bitCast(e), .acq_rel); s.notify.signal();}
// ---------------------------------------------------------------------------// The IO thread// ---------------------------------------------------------------------------
fn ioLoop(s: *Session) void { switch (builtin.os.tag) { .windows => ioLoopWindows(s), else => ioLoopPoll(s), }}
/// Windows' loop: waits on the backend's handles and `wake` — or, for a/// backend with no handles, looks every so often — and reads whatever/// there is. Writes are made whole, so the outbox is sent before waiting.fn ioLoopWindows(s: *Session) void { const win32 = @import("win32").everything; var buf: [64 * 1024]u8 = undefined; while (!s.stopping.load(.acquire)) { s.applyResize(); s.flushOutbox() catch { s.raise(.{ .exited = true }); return; };
var handles: [backend_mod.max_wait_handles + 1]backend_mod.Handle = undefined; const backend_handles = s.backend.waitHandles(handles[0..backend_mod.max_wait_handles]); handles[backend_handles.len] = s.wake.handle; const n = backend_handles.len + 1; const timeout = s.backend.pollInterval() orelse win32.INFINITE; _ = win32.WaitForMultipleObjects(@intCast(n), @ptrCast(&handles), win32.FALSE, timeout); if (s.wake.drain()) s.applyResize();
if (!s.readAvailable(&buf)) return; // Replies the terminal just queued go out now rather than after // another trip round the loop. s.flushOutbox() catch { s.raise(.{ .exited = true }); return; }; }}
/// Reads what there is, a chunk under the lock at a time, so that the/// renderer can get in between chunks of a flood. False once the other/// side has gone.fn readAvailable(s: *Session, buf: []u8) bool { while (true) { const n = s.backend.read(buf) catch |err| switch (err) { error.WouldBlock => return true, error.Unexpected => 0, }; if (n == 0) { s.raise(.{ .exited = true, .dirty = true }); return false; } { s.lock(); defer s.unlock(); s.stream.nextSlice(buf[0..n]); } s.raise(.{ .dirty = true }); }}
/// The loop where the backend has a file descriptor to poll.fn ioLoopPoll(s: *Session) void { var buf: [64 * 1024]u8 = undefined; const fd = s.backend.handle();
while (!s.stopping.load(.acquire)) { // Before anything is written, so that what follows a resize is // read by a program that has been told of it. s.applyResize(); const want_write = blk: { s.outbox_mutex.lockUncancelable(s.io); defer s.outbox_mutex.unlock(s.io); break :blk s.outbox.items.len > 0; };
var fds = [_]linux.pollfd{ .{ .fd = fd, .events = if (want_write) linux.POLL.IN | linux.POLL.OUT else linux.POLL.IN, .revents = 0 }, .{ .fd = s.wake.handle, .events = linux.POLL.IN, .revents = 0 }, }; const rc = linux.poll(&fds, fds.len, -1); if (linux.errno(rc) == .INTR) continue; if (linux.errno(rc) != .SUCCESS) { log.err("poll: {t}", .{linux.errno(rc)}); break; } if (fds[1].revents != 0) { _ = s.wake.drain(); s.applyResize(); }
if (fds[0].revents & linux.POLL.OUT != 0) s.flushOutbox() catch { s.raise(.{ .exited = true }); return; };
if (fds[0].revents & (linux.POLL.IN | linux.POLL.HUP | linux.POLL.ERR) != 0) { if (!s.readAvailable(&buf)) return; // Replies the terminal just queued go out now rather than after // another trip round the loop. s.flushOutbox() catch { s.raise(.{ .exited = true }); return; }; } }}
fn flushOutbox(s: *Session) error{Closed}!void { s.outbox_mutex.lockUncancelable(s.io); defer s.outbox_mutex.unlock(s.io); var sent: usize = 0; defer { const rest = s.outbox.items.len - sent; std.mem.copyForwards(u8, s.outbox.items[0..rest], s.outbox.items[sent..]); s.outbox.shrinkRetainingCapacity(rest); } while (sent < s.outbox.items.len) { sent += s.backend.write(s.outbox.items[sent..]) catch |err| switch (err) { error.WouldBlock => return, error.Closed => return error.Closed, error.Unexpected => return error.Closed, }; }}
// ---------------------------------------------------------------------------// Effects: what the terminal asks of the world. All of these run on the IO// thread with `mutex` held, from inside `nextSlice`.// ---------------------------------------------------------------------------
const effects: vt.TerminalStream.Handler.Effects = effects: { var e: vt.TerminalStream.Handler.Effects = .readonly; e.write_pty = &writePty; e.bell = &bell; e.title_changed = &titleChanged; e.pwd_changed = &pwdChanged; e.size = &sizeReport; e.device_attributes = &deviceAttributes; e.xtversion = &xtversion; e.clipboard_write = &clipboardWrite; // OSC 52, and kitty's clipboard protocol (OSC 5522) with its paste // events: both asked about, or not, as `clipboard.read` says. e.clipboard_read = &clipboardRead; e.kitty_clipboard_read = true; e.render_hold = &renderHold; e.desktop_notification = &desktopNotification; e.progress_report = &progressReport; e.program_status = &programStatus; e.semantic_prompt = &semanticPrompt; e.reset = &reset; break :effects e;};
fn fromHandler(h: *vt.TerminalStream.Handler) *Session { const stream: *vt.TerminalStream = @fieldParentPtr("handler", h); // The stream is always a Session's, which timestamps align more // strictly than the stream itself. return @alignCast(@fieldParentPtr("stream", stream));}
fn writePty(h: *vt.TerminalStream.Handler, bytes: []const u8) void { // Only valid during the call, so it is copied into the outbox. fromHandler(h).write(bytes);}
fn bell(h: *vt.TerminalStream.Handler) void { fromHandler(h).raise(.{ .bell = true });}
fn titleChanged(h: *vt.TerminalStream.Handler) void { fromHandler(h).raise(.{ .title = true });}
fn pwdChanged(h: *vt.TerminalStream.Handler) void { fromHandler(h).raise(.{ .pwd = true });}
fn reset(h: *vt.TerminalStream.Handler) void { const s = fromHandler(h); // OSC 9;4 counts again until the program reports with OSC 7501. s.program_status_seen = false; // A full reset clears the title without calling `title_changed`. s.raise(.{ .title = true, .dirty = true });}
fn sizeReport(h: *vt.TerminalStream.Handler) ?vt.size_report.Size { const s = fromHandler(h); if (s.cell_width == 0 or s.cell_height == 0) return null; return .{ .rows = s.size.rows, .columns = s.size.cols, .cell_width = s.cell_width, .cell_height = s.cell_height, };}
fn deviceAttributes(h: *vt.TerminalStream.Handler) vt.device_attributes.Attributes { _ = h; return .{};}
fn xtversion(h: *vt.TerminalStream.Handler) []const u8 { _ = h; return "harrier " ++ @import("build_options").version;}
/// Every type written, and only the newest write: what a program writes,/// with OSC 52 or kitty's clipboard protocol, is handed to the window,/// which puts it on the real clipboard, or asks first, as/// `clipboard.write` says, and answers it once it is done or refused. A/// write replaced before the window took it is refused, as one whose/// confirmation is replaced is. The primary selection holds only text,/// and a write to it without any is refused.fn clipboardWrite(h: *vt.TerminalStream.Handler, req: vt.clipboard.Write) void { const s = fromHandler(h); const text_only = req.location == .primary or req.location == .selection; const has_text = for (req.contents) |c| { if (vt.clipboard.isTextMime(c.mime)) break true; } else false; if (req.contents.len == 0 or (text_only and !has_text)) return req.reply(.unsupported); const contents = dupeContents(s.gpa, req.contents) catch return req.reply(.io_error); const later = req.deferReply() orelse { freeContents(s.gpa, contents); return req.reply(.denied); }; if (s.clipboard) |old| { h.replyClipboardWrite(&old.reply, .denied); old.deinit(s.gpa); } s.clipboard = .{ .location = req.location, .contents = contents, .reply = later }; s.raise(.{ .clipboard = true });}
/// A read of a clipboard, handed to the owner to answer — at once, or once/// the user has said — since only it can reach the clipboard. One that/// cannot wait is refused.fn clipboardRead(h: *vt.TerminalStream.Handler, req: vt.clipboard.Read) void { const s = fromHandler(h); const later = req.deferReply() orelse return req.reply(.denied); s.clipboard_reads.append(s.gpa, .{ .reply = later, .granted = req.granted, .listing = req.list and req.mimes.len == 0, }) catch return h.replyClipboardRead(&later, .denied); s.raise(.{ .clipboard_read = true });}
/// Drag and drop: copied, for the owner to act on from its own thread./// Answering can wait, since the protocol is asynchronous throughout.fn dropEffect(h: *vt.TerminalStream.Handler, e: dnd.DropEvent) void { queueDnd(fromHandler(h), .drop, e);}
fn dragEffect(h: *vt.TerminalStream.Handler, e: dnd.DragEvent) void { queueDnd(fromHandler(h), .drag, e);}
fn queueDnd(s: *Session, comptime which: std.meta.Tag(Dnd), e: anytype) void { const copy = e.dupe(s.dnd_arena.allocator()) catch { log.warn("a drag and drop event lost: out of memory", .{}); return; }; s.dnd_events.append(s.gpa, @unionInit(Dnd, @tagName(which), copy)) catch { log.warn("a drag and drop event lost: out of memory", .{}); return; }; s.raise(.{ .dnd = true });}
/// The most a program may have waiting, so that one printing OSC 9 in a/// loop cannot pile them up faster than they are taken.const max_notifications = 16;
/// A desktop notification (OSC 9, OSC 777): copied, for the owner to show.fn desktopNotification(h: *vt.TerminalStream.Handler, n: vt.StreamAction.ShowDesktopNotification) void { const s = fromHandler(h); if (s.notifications.items.len >= max_notifications) return; const title = s.gpa.dupe(u8, n.title) catch return; const body = s.gpa.dupe(u8, n.body) catch { s.gpa.free(title); return; }; s.notifications.append(s.gpa, .{ .title = title, .body = body }) catch { s.gpa.free(title); s.gpa.free(body); return; }; s.raise(.{ .notify = true });}
/// A progress report (OSC 9;4), kept for the owner to show, unless the/// program reports with OSC 7501 now; a full reset removes it, through/// here.fn progressReport(h: *vt.TerminalStream.Handler, report: Progress) void { const s = fromHandler(h); if (s.program_status_seen) return; s.progress = if (report.state == .remove) null else .{ .state = report.state, // Error and pause keep the last amount, unless they give one; // indeterminate has none. .percent = switch (report.state) { .indeterminate => null, .set => report.progress orelse 0, else => report.progress orelse if (s.progress) |old| old.percent else null, }, .seq = s.status.takeSeq(), .at = Io.Clock.awake.now(s.io), }; s.raise(.{ .progress = true });}
/// The most notices a program may have waiting, as with notifications.const max_status_notices = 16;
/// A program status report (OSC 7501): its record kept, and the user to be/// told if it has started work, is waiting on them, or is done.fn programStatus(h: *vt.TerminalStream.Handler, report: prog_status.Report) void { const s = fromHandler(h); // OSC 7501 takes over from OSC 9;4 until a full reset, which clears // this after the `clear` it sends through here. s.program_status_seen = true; if (s.progress != null) { s.progress = null; s.raise(.{ .progress = true }); } var notice: ?prog_status.Notice = null; const changed = s.status.apply(report, Io.Clock.awake.now(s.io), ¬ice) catch return; if (notice) |n| { if (s.status_notices.items.len < max_status_notices) { s.status_notices.append(s.gpa, n) catch n.deinit(s.gpa); } else n.deinit(s.gpa); } if (changed) s.raise(.{ .status = true });}
/// The shell is showing its prompt: what was going on is over.fn semanticPrompt(h: *vt.TerminalStream.Handler, event: vt.TerminalStream.Handler.SemanticPrompt) void { if (event.kind != .prompt_start) return; const s = fromHandler(h); if (s.status.endWork()) s.raise(.{ .status = true });}
/// Takes the notices since the last call, in order. The caller frees each,/// and the slice, with the session's allocator. With the lock held.pub fn takeStatusNotices(s: *Session) []prog_status.Notice { return s.status_notices.toOwnedSlice(s.gpa) catch { for (s.status_notices.items) |n| n.deinit(s.gpa); s.status_notices.clearRetainingCapacity(); return &.{}; };}
fn renderHold(h: *vt.TerminalStream.Handler, holding: bool) void { fromHandler(h).raise(.{ .hold = true, .holding = holding });}
// ---------------------------------------------------------------------------
const testing = std.testing;const Pty = @import("../backend/Pty.zig");
fn testSession(argv: []const []const u8) !*Session { // The programs these run are a POSIX shell's. if (builtin.os.tag == .windows) return error.SkipZigTest; var env: std.process.Environ.Map = .init(testing.allocator); defer env.deinit(); // The test's own PATH: in a build sandbox there is no /usr/bin. const path = testing.environ.getAlloc(testing.allocator, "PATH") catch try testing.allocator.dupe(u8, "/usr/bin:/bin"); defer testing.allocator.free(path); try env.put("PATH", path); try env.put("TERM", "xterm-256color"); const pty = try Pty.spawn(testing.allocator, .{ .argv = argv, .env = &env, .size = .{ .cols = 80, .rows = 24 }, }); const s = try create(testing.allocator, testing.io, .{ .pty = pty }, .{ .size = .{ .cols = 80, .rows = 24 } }); errdefer s.destroy(); try s.start(); return s;}
/// Waits, on `notify`, until the backend has gone.fn waitForExit(s: *Session) !Events { var seen: Events = .{}; while (!seen.exited) { try waitNotify(s.notify); const e = s.takeEvents(); seen = @bitCast(@as(u32, @bitCast(seen)) | @as(u32, @bitCast(e))); } return seen;}
/// Waits up to ten seconds for `notify` to be signaled.fn waitNotify(notify: Wake) !void { if (builtin.os.tag == .windows) { const win32 = @import("win32").everything; if (win32.WaitForSingleObject(notify.handle, 10_000) != win32.WAIT_OBJECT_0) return error.Timeout; } else { var fds = [_]linux.pollfd{.{ .fd = notify.handle, .events = linux.POLL.IN, .revents = 0 }}; if (linux.poll(&fds, 1, 10_000) == 0) return error.Timeout; }}
test "sessions sharing a wakeup each report their own end" { var shared: Wake = try .init(); defer shared.deinit(); var sessions: [2]*Session = undefined; for (&sessions, 0..) |*s, i| { const backend: Backend = .{ .message = try .init(if (i == 0) "one\r\n" else "two\r\n") }; s.* = try create(testing.allocator, testing.io, backend, .{ .size = .{ .cols = 20, .rows = 2 }, .notify = shared }); } defer for (sessions) |s| s.destroy(); for (sessions) |s| try s.start();
// Woken once for both, or once each: either way, each says it ended, // and asking one for its events takes nothing from the other. var ended = [2]bool{ false, false }; while (!(ended[0] and ended[1])) { try waitNotify(shared); for (sessions, &ended) |s, *e| { if (s.takeEvents().exited) e.* = true; } } for (sessions, [_][]const u8{ "one", "two" }) |s, want| { const text = try screenText(s); defer testing.allocator.free(text); try testing.expectEqualStrings(want, text); }}
test "an emoji asked for with a variation selector is wide, unless mode 2027 is off" { for ([_]bool{ true, false }) |clustered| { // ➡️ — ➡, which alone is narrow, asking to be an emoji — then X. const backend: Backend = .{ .message = try .init("\u{27a1}\u{fe0f}X") }; const s = try create(testing.allocator, testing.io, backend, .{ .size = .{ .cols = 20, .rows = 2 }, .grapheme_cluster = clustered }); defer s.destroy(); try s.start(); _ = try waitForExit(s); s.lock(); defer s.unlock(); const t = &s.terminal; try testing.expectEqual(clustered, t.modes.get(.grapheme_cluster)); const first = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?.cell; try testing.expectEqual(@as(u21, 0x27a1), first.codepoint()); try testing.expectEqual(if (clustered) vt.page.Cell.Wide.wide else .narrow, first.wide); const x = t.screens.active.pages.getCell(.{ .active = .{ .x = if (clustered) 2 else 1, .y = 0 } }).?.cell; try testing.expectEqual(@as(u21, 'X'), x.codepoint()); }}
fn screenText(s: *Session) ![]const u8 { s.lock(); defer s.unlock(); return s.terminal.plainString(testing.allocator);}
test "a program's output lands on the screen" { const s = try testSession(&.{ "sh", "-c", "printf '\\033[1mbold\\033[0m plain\\r\\nsecond line'" }); defer s.destroy(); const events = try waitForExit(s); try testing.expect(events.dirty); const text = try screenText(s); defer testing.allocator.free(text); try testing.expectEqualStrings("bold plain\nsecond line", text);}
test "a query is answered back through the backend" { // The program asks for the primary device attributes, then reads the // first three bytes of the answer and prints them where they can be // seen: the round trip from the program, through the terminal's reply, // back to the program. const s = try testSession(&.{ "sh", "-c", "stty raw -echo; printf '\\033[c'; x=$(dd bs=1 count=3 2>/dev/null | od -An -c); stty sane; printf 'got:%s' \"$x\"", }); defer s.destroy(); _ = try waitForExit(s); const text = try screenText(s); defer testing.allocator.free(text); try testing.expect(std.mem.find(u8, text, "got:") != null); try testing.expect(std.mem.find(u8, text, "033 [ ?") != null);}
test "the screen's size in characters is reported (CSI 19 t)" { // The program waits for the grid to be sized, asks, and prints what // the answer starts with: CSI 9 ; rows ; columns t, as xterm has it. const s = try testSession(&.{ "sh", "-c", "sleep 0.3; stty raw -echo; printf '\\033[19t'; x=$(dd bs=1 count=9 2>/dev/null | od -An -c); stty sane; printf 'got:%s' \"$x\"", }); defer s.destroy(); s.resize(.{ .cols = 80, .rows = 24 }, 10, 20); _ = try waitForExit(s); const text = try screenText(s); defer testing.allocator.free(text); try testing.expect(std.mem.find(u8, text, "033 [ 9 ; 2 4 ; 8 0") != null);}
test "where the shell says it is reaches the owner" { const s = try testSession(&.{ "sh", "-c", "printf '\\033]7;file://router/etc\\007'" }); defer s.destroy(); const events = try waitForExit(s); try testing.expect(events.pwd); s.lock(); defer s.unlock(); try testing.expectEqualStrings("file://router/etc", s.terminal.getPwd().?);}
test "the title and the bell reach the owner" { const s = try testSession(&.{ "sh", "-c", "printf '\\033]2;hello title\\007\\007'" }); defer s.destroy(); const events = try waitForExit(s); try testing.expect(events.title); try testing.expect(events.bell); s.lock(); defer s.unlock(); try testing.expectEqualStrings("hello title", s.terminal.getTitle().?);}
test "an OSC 52 write is handed over as text" { // "copied" in base64. const s = try testSession(&.{ "sh", "-c", "printf '\\033]52;c;Y29waWVk\\007'" }); defer s.destroy(); const events = try waitForExit(s); try testing.expect(events.clipboard); const c = s.takeClipboard().?; defer c.deinit(testing.allocator); try testing.expectEqualStrings("copied", c.text().?);}
test "a kitty clipboard write is answered once the owner says how it went" { // The program writes "hi" as text, and prints what comes back. const s = try testSession(&.{ "sh", "-c", "stty raw -echo; printf '\\033]5522;type=write:id=W\\033\\\\\\033]5522;type=wdata:mime=dGV4dC9wbGFpbg==;aGk=\\033\\\\\\033]5522;type=wdata\\033\\\\'; x=$(dd bs=1 count=37 2>/dev/null | od -An -c | tr -d ' \\n'); stty sane; printf 'got:%s' \"$x\"", }); defer s.destroy(); while (!s.takeEvents().clipboard) try waitNotify(s.notify); const c = s.takeClipboard().?; defer c.deinit(testing.allocator); try testing.expectEqualStrings("hi", c.text().?); // Nothing is answered until the owner does. { s.lock(); defer s.unlock(); const text = try s.terminal.plainString(testing.allocator); defer testing.allocator.free(text); try testing.expect(std.mem.find(u8, text, "got:") == null); } s.answerClipboardWrite(&c.reply, .denied); _ = try waitForExit(s); const text = try screenText(s); defer testing.allocator.free(text); try testing.expect(std.mem.find(u8, text, "got:033]5522;type=write:status=EPERM:id=W") != null);}
test "a kitty clipboard write replaced before it is taken is refused" { const backend: Backend = .{ .message = try .init("\x1b]5522;type=write:id=a\x1b\\\x1b]5522;type=wdata:mime=dGV4dC9wbGFpbg==;aGk=\x1b\\\x1b]5522;type=wdata\x1b\\" ++ "\x1b]5522;type=write:id=b\x1b\\\x1b]5522;type=wdata:mime=dGV4dC9wbGFpbg==;aG8=\x1b\\\x1b]5522;type=wdata\x1b\\") }; const s = try create(testing.allocator, testing.io, backend, .{ .size = .{ .cols = 20, .rows = 2 } }); defer s.destroy(); try s.start(); const events = try waitForExit(s); try testing.expect(events.clipboard); const c = s.takeClipboard().?; defer c.deinit(testing.allocator); try testing.expectEqualStrings("ho", c.text().?); try testing.expectEqualStrings("b", c.reply.kitty.?.id());}
test "kitty clipboard reads reach the owner, to be answered later" { // A read of text, and one asking only which types there are: "text/plain" // and "." in base64. const backend: Backend = .{ .message = try .init("\x1b]5522;type=read:id=a;dGV4dC9wbGFpbg==\x1b\\" ++ "\x1b]5522;type=read:id=b;Lg==\x1b\\") }; const s = try create(testing.allocator, testing.io, backend, .{ .size = .{ .cols = 20, .rows = 2 } }); defer s.destroy(); try s.start(); const events = try waitForExit(s); try testing.expect(events.clipboard_read); const reads = s.takeClipboardReads(); defer testing.allocator.free(reads); try testing.expectEqual(@as(usize, 2), reads.len); for (reads) |r| try testing.expect(!r.granted); const text = reads[0].reply.kitty.?; try testing.expectEqual(@as(u8, 1), text.mimes_len); try testing.expectEqualStrings("text/plain", text.mime(0)); try testing.expect(!reads[0].listing); try testing.expect(reads[1].listing); for (reads) |*r| s.answerClipboardRead(&r.reply, .denied);}
test "a kitty clipboard read's answer reaches the program" { // The program asks for the list of types, reads what comes back until // the last packet, and prints it where it can be seen. const s = try testSession(&.{ "sh", "-c", "stty raw -echo; printf '\\033]5522;type=read:id=L;Lg==\\033\\\\'; x=$(dd bs=1 count=60 2>/dev/null | od -An -c | tr -d ' \\n'); stty sane; printf 'got:%s' \"$x\"", }); defer s.destroy(); var reads: []ClipboardRead = &.{}; while (reads.len == 0) { try waitNotify(s.notify); if (s.takeEvents().clipboard_read) reads = s.takeClipboardReads(); } defer testing.allocator.free(reads); try testing.expect(reads[0].listing); s.answerClipboardRead(&reads[0].reply, .{ .contents = .{ .read = &.{.{ .mime = "text/plain", .data = "hi" }}, .available = &.{"text/plain"}, } }); _ = try waitForExit(s); const text = try screenText(s); defer testing.allocator.free(text); try testing.expect(std.mem.find(u8, text, "got:033]5522;type=read:status=OK:id=L") != null);}
test "a clipboard read is answered with text under each text type asked for" { var buf: [vt.kitty.clipboard.max_read_mimes]vt.clipboard.Content = undefined; const kitty: vt.clipboard.Read.Deferred = .{ .location = .standard, .terminator = .st, .kitty = .init(&.{ .mimes = &.{ "image/png", "UTF8_STRING", "text/plain" }, .list = true }, ""), }; const r = clipboardResult(&kitty, .{ .contents = .{ .read = &.{.{ .mime = "text/plain;charset=utf-8", .data = "hi" }}, .available = &.{"text/plain"}, } }, &buf).success; try testing.expectEqual(@as(usize, 2), r.contents.len); try testing.expectEqualStrings("UTF8_STRING", r.contents[0].mime); try testing.expectEqualStrings("text/plain", r.contents[1].mime); try testing.expectEqualStrings("hi", r.contents[1].data); try testing.expectEqualStrings("text/plain", r.available[0]);
// Nothing on the clipboard is no refusal; a refusal is. const empty = clipboardResult(&kitty, .{ .contents = .{} }, &buf).success; try testing.expectEqual(@as(usize, 0), empty.contents.len); try testing.expectEqual(@as(usize, 0), empty.available.len); try testing.expect(clipboardResult(&kitty, .denied, &buf) == .denied);
// OSC 52 carries text alone. const osc52: vt.clipboard.Read.Deferred = .{ .location = .standard, .terminator = .st }; const t = clipboardResult(&osc52, .{ .contents = .{ .read = &.{.{ .mime = "text/plain", .data = "hi" }}, } }, &buf).success; try testing.expectEqualStrings("text/plain", t.contents[0].mime);}
test "a clipboard read is answered with each type read, in the order asked" { var buf: [vt.kitty.clipboard.max_read_mimes]vt.clipboard.Content = undefined; const kitty: vt.clipboard.Read.Deferred = .{ .location = .standard, .terminator = .st, .kitty = .init(&.{ .mimes = &.{ "image/png", "text/html", "text/plain" }, .list = true }, ""), }; const r = clipboardResult(&kitty, .{ .contents = .{ .read = &.{ .{ .mime = "text/plain", .data = "hi" }, .{ .mime = "image/png", .data = "\x89PNG" }, }, .available = &.{ "image/png", "text/plain", "application/x-other" }, } }, &buf).success; // text/html was asked for and not read, so it is not served at all. try testing.expectEqual(@as(usize, 2), r.contents.len); try testing.expectEqualStrings("image/png", r.contents[0].mime); try testing.expectEqualStrings("\x89PNG", r.contents[0].data); try testing.expectEqualStrings("text/plain", r.contents[1].mime); try testing.expectEqualStrings("hi", r.contents[1].data); try testing.expectEqual(@as(usize, 3), r.available.len); try testing.expectEqualStrings("application/x-other", r.available[2]);}
test "progress reports reach the owner, the last one kept" { const s = try testSession(&.{ "sh", "-c", "printf '\\033]9;4;1;20\\007\\033]9;4;2;65\\007'" }); defer s.destroy(); const events = try waitForExit(s); try testing.expect(events.progress); s.lock(); defer s.unlock(); try testing.expectEqual(Progress.State.@"error", s.progress.?.state); try testing.expectEqual(@as(?u8, 65), s.progress.?.percent);}
test "a progress report saying it is done takes the bar away" { const s = try testSession(&.{ "sh", "-c", "printf '\\033]9;4;3\\007\\033]9;4;0\\007'" }); defer s.destroy(); const events = try waitForExit(s); try testing.expect(events.progress); s.lock(); defer s.unlock(); try testing.expect(s.progress == null);}
test "once a program reports with OSC 7501, its OSC 9;4 reports are not kept, until a full reset" { const s = try testSession(&.{ "sh", "-c", "printf '" ++ "\\033]9;4;1;20\\007" ++ "\\033]7501;state=working\\033\\\\" ++ "\\033]9;4;1;50\\007" ++ "\\033c" ++ "\\033]9;4;1;70\\007" ++ "'" }); defer s.destroy(); const events = try waitForExit(s); try testing.expect(events.progress); s.lock(); defer s.unlock(); // The 20 went with the first OSC 7501 report, and the 50 was not kept; // after the full reset, the 70 is. try testing.expectEqual(@as(?u8, 70), s.progress.?.percent); try testing.expect(!s.program_status_seen);}
test "a program's OSC 7501 report takes the place of its OSC 9;4 one" { const s = try testSession(&.{ "sh", "-c", "printf '" ++ "\\033]9;4;1;20\\007" ++ "\\033]7501;state=working\\033\\\\" ++ "\\033]9;4;1;50\\007" ++ "'" }); defer s.destroy(); _ = try waitForExit(s); s.lock(); defer s.unlock(); try testing.expect(s.progress == null); try testing.expect(s.program_status_seen); try testing.expectEqual(@as(usize, 1), s.status.records.items.len);}
test "status reports are kept as records, and say what to tell the user" { // Working on a deploy, one region of it working and another done. const s = try testSession(&.{ "sh", "-c", "printf '" ++ "\\033]7501;state=working:app=deploy\\033\\\\" ++ "\\033]7501;state=working:id=us-east:progress=40\\033\\\\" ++ "\\033]7501;state=done:id=us-west:msg=SGVhbHRoeQ==\\033\\\\" ++ "'" }); defer s.destroy(); const events = try waitForExit(s); try testing.expect(events.status); s.lock(); defer s.unlock(); try testing.expectEqual(@as(usize, 3), s.status.records.items.len); try testing.expectEqualStrings("us-west", s.status.latest().?.id); const notices = s.takeStatusNotices(); defer { for (notices) |n| n.deinit(testing.allocator); testing.allocator.free(notices); } try testing.expectEqual(@as(usize, 3), notices.len); try testing.expectEqual(prog_status.State.done, notices[2].state); try testing.expectEqualStrings("Healthy", notices[2].msg.?); try testing.expectEqualStrings("deploy", notices[2].app.?); // The program's exit, or the next prompt, ends what was still going // on, and leaves what is done. try testing.expect(s.status.endWork()); try testing.expectEqual(@as(usize, 1), s.status.records.items.len);}
test "a prompt after a working report ends it, as in Claude Code's turns" { // What Claude Code sends as one of its turns goes on, in screen reader // mode: its working report, then the prompt it marks for each step of // the turn, then the step's end. The specification has a prompt end // what was going on, so the report is gone; Claude Code sends a report // again only when it changes, so until then nothing says it is working. const s = try testSession(&.{ "sh", "-c", "printf '" ++ "\\033]7501;state=working:app=claude-code\\007" ++ "\\033]133;A;redraw=0\\007" ++ "\\033]133;C\\007\\033]133;D\\007" ++ "'" }); defer s.destroy(); _ = try waitForExit(s); s.lock(); defer s.unlock(); try testing.expectEqual(@as(usize, 0), s.status.records.items.len);}
test "keystrokes written by the owner reach the program" { const s = try testSession(&.{ "sh", "-c", "read line; printf 'echo:%s' \"$line\"" }); defer s.destroy(); s.write("typed\r"); _ = try waitForExit(s); const text = try screenText(s); defer testing.allocator.free(text); try testing.expect(std.mem.find(u8, text, "echo:typed") != null);}
test "resizes asked for in a row end at the last" { const s = try testSession(&.{ "sh", "-c", "read x; stty size" }); defer s.destroy(); s.resize(.{ .cols = 100, .rows = 40 }, 9, 18); s.resize(.{ .cols = 70, .rows = 20 }, 9, 18); s.resize(.{ .cols = 90, .rows = 30 }, 9, 18); try testing.expectEqual(@as(u16, 90), s.wanted.cols); s.write("\r"); _ = try waitForExit(s); const text = try screenText(s); defer testing.allocator.free(text); try testing.expect(std.mem.find(u8, text, "30 90") != null); try testing.expectEqual(@as(u16, 90), s.terminal.cols);}
test "a resize reaches both the grid and the program" { const s = try testSession(&.{ "sh", "-c", "read x; stty size" }); defer s.destroy(); s.resize(.{ .cols = 100, .rows = 40 }, 9, 18); s.write("\r"); _ = try waitForExit(s); const text = try screenText(s); defer testing.allocator.free(text); try testing.expect(std.mem.find(u8, text, "40 100") != null); try testing.expectEqual(@as(u16, 100), s.terminal.cols);}