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