//! One terminal session: a libghostty-vt terminal fed by a PTY. //! //! The PTY is read on the GLib main loop via a unix fd watch rather than a //! dedicated IO thread. That keeps VT state single-threaded, so the renderer //! can read the screen directly with no locking. A real terminal would want //! Ghostty's threaded IO, but for a proof of concept this is much simpler and //! is plenty fast for interactive use. const std = @import("std"); const glib = @import("glib"); const glibunix = @import("glibunix"); const vt = @import("ghostty-vt"); const Pty = @import("Pty.zig"); const Session = @This(); /// Read buffer size. Large enough that a `cat` of a big file doesn't spend /// all its time bouncing through the main loop. const read_buf_size = 64 * 1024; alloc: std.mem.Allocator, /// The terminal emulator state. Must be the first field referenced by /// `fromTerminal` below, which recovers the Session from stream callbacks. term: vt.Terminal, /// Persistent parser state. Escape sequences can and do get split across /// read boundaries, so this must outlive individual reads. stream: vt.TerminalStream, pty: Pty, /// GLib source ID for the PTY read watch, so we can cancel it on teardown. watch: c_uint = 0, /// True once the child process has exited and the PTY hung up. exited: bool = false, /// A script to run once the shell is ready, from the layout this pane came /// from. Held rather than written at spawn time — see `flushStartupCommand`. startup_command: ?[]u8 = null, /// Called after terminal state changes, so the owner can queue a redraw. on_damage: *const fn (ctx: ?*anyopaque) void, /// Called when the terminal title changes (OSC 0/2). on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void, /// Called when the child process exits. on_exit: *const fn (ctx: ?*anyopaque) void, ctx: ?*anyopaque = null, pub const Callbacks = struct { on_damage: *const fn (ctx: ?*anyopaque) void, on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void, on_exit: *const fn (ctx: ?*anyopaque) void, ctx: ?*anyopaque, }; /// What a layout can ask for beyond a plain shell. Both are empty for a /// terminal opened the ordinary way. pub const Options = struct { /// Directory the shell starts in. cwd: []const u8 = "", /// Script fed to the shell once it is up. command: []const u8 = "", }; pub fn create( alloc: std.mem.Allocator, cols: u16, rows: u16, opts: Options, cbs: Callbacks, ) !*Session { const self = try alloc.create(Session); errdefer alloc.destroy(self); const tio: vt.TinyIo = .init; self.* = .{ .alloc = alloc, .term = try .init(tio.io(), alloc, .{ .cols = cols, .rows = rows, .max_scrollback_bytes = 8 * 1024 * 1024, }), .stream = undefined, .pty = undefined, .on_damage = cbs.on_damage, .on_title = cbs.on_title, .on_exit = cbs.on_exit, .ctx = cbs.ctx, }; errdefer self.term.deinit(alloc); self.stream = self.term.vtStream(); errdefer self.stream.deinit(); // Wire up the side effects we care about. `readonly` handles terminal // state but silently drops anything that needs to talk back to the // child; we need the writes so that queries (cursor position, device // attributes, in-band resize) get answers. var effects = vt.TerminalStream.Handler.Effects.readonly; effects.write_pty = &effectWritePty; effects.title_changed = &effectTitleChanged; self.stream.handler.effects = effects; const shell = try defaultShell(alloc); defer alloc.free(shell); // A leading '-' in argv[0] tells the shell to start as a login shell, // so it reads the user's profile. const argv0 = try std.fmt.allocPrintSentinel(alloc, "-{s}", .{ std.fs.path.basename(shell), }, 0); defer alloc.free(argv0); const cwd_z: ?[:0]const u8 = if (opts.cwd.len > 0) try alloc.dupeZ(u8, opts.cwd) else null; defer if (cwd_z) |z| alloc.free(z); self.pty = try .create(alloc, shell, &.{argv0}, cwd_z, .{ .ws_row = rows, .ws_col = cols, }); errdefer self.pty.deinit(); if (opts.command.len > 0) { self.startup_command = try alloc.dupe(u8, opts.command); } self.watch = glibunix.fdAdd( self.pty.master, .{ .in = true, .hup = true, .err = true }, &onReadable, self, ); return self; } pub fn destroy(self: *Session) void { if (self.startup_command) |cmd| self.alloc.free(cmd); if (self.watch != 0) _ = glib.Source.remove(self.watch); self.pty.deinit(); self.stream.deinit(); self.term.deinit(self.alloc); self.alloc.destroy(self); } /// Resolve the shell to run, most authoritative source first. /// /// The passwd entry comes first deliberately. $SHELL is only a description of /// the shell that happened to launch us, and plenty of environments overwrite /// it: `nix develop` replaces it with its own bash, and Flatpak pins it to /// /bin/sh. The passwd entry is what the user actually configured, so it is /// the better answer to "the user's default shell". $SHELL remains a fallback /// for systems with no usable passwd entry, such as minimal containers. fn defaultShell(alloc: std.mem.Allocator) ![:0]const u8 { var buf: [std.fs.max_path_bytes]u8 = undefined; if (Pty.loginShell(&buf)) |shell| return alloc.dupeZ(u8, shell); if (std.c.getenv("SHELL")) |sh| { const span = std.mem.span(sh); if (span.len > 0) return alloc.dupeZ(u8, span); } return alloc.dupeZ(u8, "/bin/sh"); } /// Main loop callback: the PTY has data (or hung up). fn onReadable( fd: c_int, condition: glib.IOCondition, data: ?*anyopaque, ) callconv(.c) c_int { const self: *Session = @ptrCast(@alignCast(data.?)); _ = fd; if (condition.in) { var buf: [read_buf_size]u8 = undefined; if (self.pty.read(&buf)) |n| { self.stream.nextSlice(buf[0..n]); self.flushStartupCommand(); self.on_damage(self.ctx); return 1; } } // Zero-length read, HUP or error all mean the child is gone. self.exited = true; self.watch = 0; self.on_exit(self.ctx); return 0; } /// Type a layout's script into the shell, once, as soon as the shell has /// shown that it is alive. /// /// The wait matters. Writing at spawn time puts the script into the tty input /// buffer before the shell has started, and shells that set up line editing /// (zsh's ZLE, bash's readline) can discard whatever was buffered while they /// were initializing, so the command silently vanishes. The shell's first /// output — its prompt — is proof that it has finished starting and is reading /// input, which is exactly the moment this becomes safe. /// /// It is typed rather than executed for us, so the shell is still there /// afterwards with the script sitting in its history. fn flushStartupCommand(self: *Session) void { const command = self.startup_command orelse return; self.startup_command = null; defer self.alloc.free(command); self.pty.writeAll(command); self.pty.writeAll("\n"); } /// Effect callback: the terminal wants to send bytes back to the child. fn effectWritePty(handler: *vt.TerminalStream.Handler, data: [:0]const u8) void { const self = fromHandler(handler); self.pty.writeAll(data); } /// Effect callback: OSC 0/2 changed the window title. fn effectTitleChanged(handler: *vt.TerminalStream.Handler) void { const self = fromHandler(handler); self.on_title(self.ctx, self.term.title.items); } /// Recover the owning Session from a stream callback. The handler holds a /// pointer to our embedded `term` field, so we can walk back from it. fn fromHandler(handler: *vt.TerminalStream.Handler) *Session { return @fieldParentPtr("term", handler.terminal); } /// Send user input to the child. pub fn write(self: *Session, bytes: []const u8) void { self.pty.writeAll(bytes); } /// Resize the terminal grid and tell the child about it. pub fn resize(self: *Session, cols: u16, rows: u16, cell_w: u32, cell_h: u32) !void { if (cols == self.term.cols and rows == self.term.rows) return; try self.stream.handler.resize(.{ .cols = cols, .rows = rows, .cell_size_px = .{ .width = cell_w, .height = cell_h }, }); self.pty.setSize(.{ .ws_row = rows, .ws_col = cols, .ws_xpixel = @intCast(cell_w * cols), .ws_ypixel = @intCast(cell_h * rows), }); }