aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/termio
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-04 19:22:04 +0300
committerCan Celik <ogulcancelik@gmail.com>2026-04-05 01:23:06 +0300
commit41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch)
tree3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/termio
parent55c138263e190a1e79d4b997bfb9397306c0ec1f (diff)
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/termio')
-rw-r--r--vendor/libghostty-vt/src/termio/Exec.zig1761
-rw-r--r--vendor/libghostty-vt/src/termio/Options.zig41
-rw-r--r--vendor/libghostty-vt/src/termio/Termio.zig773
-rw-r--r--vendor/libghostty-vt/src/termio/Thread.zig531
-rw-r--r--vendor/libghostty-vt/src/termio/backend.zig129
-rw-r--r--vendor/libghostty-vt/src/termio/mailbox.zig106
-rw-r--r--vendor/libghostty-vt/src/termio/message.zig108
-rw-r--r--vendor/libghostty-vt/src/termio/shell_integration.zig1032
-rw-r--r--vendor/libghostty-vt/src/termio/stream_handler.zig1556
9 files changed, 6037 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/termio/Exec.zig b/vendor/libghostty-vt/src/termio/Exec.zig
new file mode 100644
index 00000000..0f35b578
--- /dev/null
+++ b/vendor/libghostty-vt/src/termio/Exec.zig
@@ -0,0 +1,1761 @@
+//! Exec implements the logic for starting and stopping a subprocess with a
+//! pty as well as spinning up the necessary read thread to read from the
+//! pty and forward it to the Termio instance.
+const Exec = @This();
+
+const std = @import("std");
+const builtin = @import("builtin");
+const assert = @import("../quirks.zig").inlineAssert;
+const Allocator = std.mem.Allocator;
+const ArenaAllocator = std.heap.ArenaAllocator;
+const posix = std.posix;
+const xev = @import("../global.zig").xev;
+const apprt = @import("../apprt.zig");
+const build_config = @import("../build_config.zig");
+const configpkg = @import("../config.zig");
+const crash = @import("../crash/main.zig");
+const fastmem = @import("../fastmem.zig");
+const internal_os = @import("../os/main.zig");
+const renderer = @import("../renderer.zig");
+const shell_integration = @import("shell_integration.zig");
+const terminal = @import("../terminal/main.zig");
+const termio = @import("../termio.zig");
+const Command = @import("../Command.zig");
+const SegmentedPool = @import("../datastruct/main.zig").SegmentedPool;
+const ptypkg = @import("../pty.zig");
+const Pty = ptypkg.Pty;
+const EnvMap = std.process.EnvMap;
+const PasswdEntry = internal_os.passwd.Entry;
+const windows = internal_os.windows;
+const ProcessInfo = @import("../pty.zig").ProcessInfo;
+
+const log = std.log.scoped(.io_exec);
+
+/// The termios poll rate in milliseconds.
+const TERMIOS_POLL_MS = 200;
+
+/// If we build with flatpak support then we have to keep track of
+/// a potential execution on the host.
+const FlatpakHostCommand = if (!build_config.flatpak) struct {
+ pub const Completion = struct {};
+} else internal_os.FlatpakHostCommand;
+
+/// The subprocess state for our exec backend.
+subprocess: Subprocess,
+
+/// Initialize the exec state. This will NOT start it, this only sets
+/// up the internal state necessary to start it later.
+pub fn init(
+ alloc: Allocator,
+ cfg: Config,
+) !Exec {
+ var subprocess = try Subprocess.init(alloc, cfg);
+ errdefer subprocess.deinit();
+
+ return .{ .subprocess = subprocess };
+}
+
+pub fn deinit(self: *Exec) void {
+ self.subprocess.deinit();
+}
+
+/// Call to initialize the terminal state as necessary for this backend.
+/// This is called before any termio begins. This should not be called
+/// after termio begins because it may put the internal terminal state
+/// into a bad state.
+pub fn initTerminal(self: *Exec, term: *terminal.Terminal) void {
+ // If we have an initial pwd requested by the subprocess, then we
+ // set that on the terminal now. This allows rapidly initializing
+ // new surfaces to use the proper pwd.
+ if (self.subprocess.cwd) |cwd| term.setPwd(cwd) catch |err| {
+ log.warn("error setting initial pwd err={}", .{err});
+ };
+
+ // Setup our initial grid/screen size from the terminal. This
+ // can't fail because the pty should not exist at this point.
+ self.resize(.{
+ .columns = term.cols,
+ .rows = term.rows,
+ }, .{
+ .width = term.width_px,
+ .height = term.height_px,
+ }) catch unreachable;
+}
+
+pub fn threadEnter(
+ self: *Exec,
+ alloc: Allocator,
+ io: *termio.Termio,
+ td: *termio.Termio.ThreadData,
+) !void {
+ // Start our subprocess
+ const pty_fds = self.subprocess.start(alloc) catch |err| {
+ // If we specifically got this error then we are in the forked
+ // process and our child failed to execute. If we DIDN'T
+ // get this specific error then we're in the parent and
+ // we need to bubble it up.
+ if (err != error.ExecFailedInChild) return err;
+
+ // We're in the child. Nothing more we can do but abnormal exit.
+ // The Command will output some additional information.
+ posix.exit(1);
+ };
+ errdefer self.subprocess.stop();
+
+ // Watcher to detect subprocess exit
+ var process: ?xev.Process = if (self.subprocess.process) |v| switch (v) {
+ .fork_exec => |cmd| try xev.Process.init(
+ cmd.pid orelse return error.ProcessNoPid,
+ ),
+
+ // If we're executing via Flatpak then we can't do
+ // traditional process watching (its implemented
+ // as a special case in os/flatpak.zig) since the
+ // command is on the host.
+ .flatpak => null,
+ } else return error.ProcessNotStarted;
+ errdefer if (process) |*p| p.deinit();
+
+ // Track our process start time for abnormal exits
+ const process_start = try std.time.Instant.now();
+
+ // Create our pipe that we'll use to kill our read thread.
+ // pipe[0] is the read end, pipe[1] is the write end.
+ const pipe = try internal_os.pipe();
+ errdefer posix.close(pipe[0]);
+ errdefer posix.close(pipe[1]);
+
+ // Setup our stream so that we can write.
+ var stream = xev.Stream.initFd(pty_fds.write);
+ errdefer stream.deinit();
+
+ // Start our timer to read termios state changes. This is used
+ // to detect things such as when password input is being done
+ // so we can render the terminal in a different way.
+ var termios_timer = try xev.Timer.init();
+ errdefer termios_timer.deinit();
+
+ // Start our read thread
+ const read_thread = try std.Thread.spawn(
+ .{},
+ if (builtin.os.tag == .windows) ReadThread.threadMainWindows else ReadThread.threadMainPosix,
+ .{ pty_fds.read, io, pipe[0] },
+ );
+ read_thread.setName("io-reader") catch {};
+
+ // Setup our threadata backend state to be our own
+ td.backend = .{ .exec = .{
+ .start = process_start,
+ .write_stream = stream,
+ .process = process,
+ .read_thread = read_thread,
+ .read_thread_pipe = pipe[1],
+ .read_thread_fd = pty_fds.read,
+ .termios_timer = termios_timer,
+ } };
+
+ // Start our process watcher. If we have an xev.Process use it.
+ if (process) |*p| p.wait(
+ td.loop,
+ &td.backend.exec.process_wait_c,
+ termio.Termio.ThreadData,
+ td,
+ processExit,
+ ) else if (comptime build_config.flatpak) flatpak: {
+ switch (self.subprocess.process orelse break :flatpak) {
+ // If we're in flatpak and we have a flatpak command
+ // then we can run the special flatpak logic for watching.
+ .flatpak => |*c| c.waitXev(
+ td.loop,
+ &td.backend.exec.flatpak_wait_c,
+ termio.Termio.ThreadData,
+ td,
+ flatpakExit,
+ ),
+
+ .fork_exec => {},
+ }
+ }
+
+ // Start our termios timer. We don't support this on Windows.
+ // Fundamentally, we could support this on Windows so we're just
+ // waiting for someone to implement it.
+ if (comptime builtin.os.tag != .windows) {
+ termios_timer.run(
+ td.loop,
+ &td.backend.exec.termios_timer_c,
+ TERMIOS_POLL_MS,
+ termio.Termio.ThreadData,
+ td,
+ termiosTimer,
+ );
+ }
+}
+
+pub fn threadExit(self: *Exec, td: *termio.Termio.ThreadData) void {
+ assert(td.backend == .exec);
+ const exec = &td.backend.exec;
+
+ if (exec.exited) self.subprocess.externalExit();
+ self.subprocess.stop();
+
+ // Quit our read thread after exiting the subprocess so that
+ // we don't get stuck waiting for data to stop flowing if it is
+ // a particularly noisy process.
+ _ = posix.write(exec.read_thread_pipe, "x") catch |err| switch (err) {
+ // BrokenPipe means that our read thread is closed already,
+ // which is completely fine since that is what we were trying
+ // to achieve.
+ error.BrokenPipe => {},
+
+ else => log.warn(
+ "error writing to read thread quit pipe err={}",
+ .{err},
+ ),
+ };
+
+ if (comptime builtin.os.tag == .windows) {
+ // Interrupt the blocking read so the thread can see the quit message
+ if (windows.kernel32.CancelIoEx(exec.read_thread_fd, null) == 0) {
+ switch (windows.kernel32.GetLastError()) {
+ .NOT_FOUND => {},
+ else => |err| log.warn("error interrupting read thread err={}", .{err}),
+ }
+ }
+ }
+
+ exec.read_thread.join();
+}
+
+pub fn focusGained(
+ self: *Exec,
+ td: *termio.Termio.ThreadData,
+ focused: bool,
+) !void {
+ _ = self;
+
+ assert(td.backend == .exec);
+ const execdata = &td.backend.exec;
+
+ if (!focused) {
+ // Flag the timer to end on the next iteration. This is
+ // a lot cheaper than doing full timer cancellation.
+ execdata.termios_timer_running = false;
+ } else {
+ // Always set this to true. There is a race condition if we lose
+ // focus and regain focus before the termios timer ticks where
+ // if we don't set this unconditionally the timer will end on
+ // the next iteration.
+ execdata.termios_timer_running = true;
+
+ // If we're focused, we want to start our termios timer. We
+ // only do this if it isn't already running. We use the termios
+ // callback because that'll trigger an immediate state check AND
+ // start the timer.
+ if (execdata.termios_timer_c.state() != .active) {
+ _ = termiosTimer(td, undefined, undefined, {});
+ }
+ }
+}
+
+pub fn resize(
+ self: *Exec,
+ grid_size: renderer.GridSize,
+ screen_size: renderer.ScreenSize,
+) !void {
+ return try self.subprocess.resize(grid_size, screen_size);
+}
+
+fn processExitCommon(td: *termio.Termio.ThreadData, exit_code: u32) void {
+ assert(td.backend == .exec);
+ const execdata = &td.backend.exec;
+ execdata.exited = true;
+
+ // Determine how long the process was running for.
+ const runtime_ms: ?u64 = runtime: {
+ const process_end = std.time.Instant.now() catch break :runtime null;
+ const runtime_ns = process_end.since(execdata.start);
+ const runtime_ms = runtime_ns / std.time.ns_per_ms;
+ break :runtime runtime_ms;
+ };
+ log.debug(
+ "child process exited status={} runtime={}ms",
+ .{ exit_code, runtime_ms orelse 0 },
+ );
+
+ // We always notify the surface immediately that the child has
+ // exited and some metadata about the exit.
+ _ = td.surface_mailbox.push(.{
+ .child_exited = .{
+ .exit_code = exit_code,
+ .runtime_ms = runtime_ms orelse 0,
+ },
+ }, .{ .forever = {} });
+}
+
+fn processExit(
+ td_: ?*termio.Termio.ThreadData,
+ _: *xev.Loop,
+ _: *xev.Completion,
+ r: xev.Process.WaitError!u32,
+) xev.CallbackAction {
+ const exit_code = r catch unreachable;
+ processExitCommon(td_.?, exit_code);
+ return .disarm;
+}
+
+fn flatpakExit(
+ td_: ?*termio.Termio.ThreadData,
+ _: *xev.Loop,
+ _: *FlatpakHostCommand.Completion,
+ r: FlatpakHostCommand.WaitError!u8,
+) void {
+ const exit_code = r catch unreachable;
+ processExitCommon(td_.?, exit_code);
+}
+
+fn termiosTimer(
+ td_: ?*termio.Termio.ThreadData,
+ _: *xev.Loop,
+ _: *xev.Completion,
+ r: xev.Timer.RunError!void,
+) xev.CallbackAction {
+ // log.debug("termios timer fired", .{});
+
+ // This should never happen because we guard starting our
+ // timer on windows but we want this assertion to fire if
+ // we ever do start the timer on windows.
+ // TODO: support on windows
+ if (comptime builtin.os.tag == .windows) {
+ @panic("termios timer not implemented on Windows");
+ }
+
+ _ = r catch |err| switch (err) {
+ // This is sent when our timer is canceled. That's fine.
+ error.Canceled => return .disarm,
+
+ else => {
+ log.warn("error in termios timer callback err={}", .{err});
+ @panic("crash in termios timer callback");
+ },
+ };
+
+ const td = td_.?;
+ assert(td.backend == .exec);
+ const exec = &td.backend.exec;
+
+ // This is kind of hacky but we rebuild a Pty struct to get the
+ // termios data.
+ const mode: ptypkg.Mode = (Pty{
+ .master = exec.read_thread_fd,
+ .slave = undefined,
+ }).getMode() catch |err| err: {
+ log.warn("error getting termios mode err={}", .{err});
+
+ // If we have an error we return the default mode values
+ // which are the likely values.
+ break :err .{};
+ };
+
+ // If the mode changed, then we process it.
+ if (!std.meta.eql(mode, exec.termios_mode)) mode_change: {
+ log.debug("termios change mode={}", .{mode});
+ exec.termios_mode = mode;
+
+ // We assume we're in some sort of password input if we're
+ // in canonical mode and not echoing. This is a heuristic.
+ const password_input = mode.canonical and !mode.echo;
+
+ // If our password input state changed on the terminal then
+ // we notify the surface.
+ {
+ td.renderer_state.mutex.lock();
+ defer td.renderer_state.mutex.unlock();
+ const t = td.renderer_state.terminal;
+ if (t.flags.password_input == password_input) {
+ break :mode_change;
+ }
+ }
+
+ // We have to notify the surface that we're in password input.
+ // We must block on this because the balanced true/false state
+ // of this is critical to apprt behavior.
+ _ = td.surface_mailbox.push(.{
+ .password_input = password_input,
+ }, .{ .forever = {} });
+ }
+
+ // Repeat the timer
+ if (exec.termios_timer_running) {
+ exec.termios_timer.run(
+ td.loop,
+ &exec.termios_timer_c,
+ TERMIOS_POLL_MS,
+ termio.Termio.ThreadData,
+ td,
+ termiosTimer,
+ );
+ }
+
+ return .disarm;
+}
+
+pub fn queueWrite(
+ self: *Exec,
+ alloc: Allocator,
+ td: *termio.Termio.ThreadData,
+ data: []const u8,
+ linefeed: bool,
+) !void {
+ _ = self;
+ const exec = &td.backend.exec;
+
+ // If our process is exited then we don't send any more writes.
+ if (exec.exited) return;
+
+ // We go through and chunk the data if necessary to fit into
+ // our cached buffers that we can queue to the stream.
+ var i: usize = 0;
+ while (i < data.len) {
+ const req = try exec.write_req_pool.getGrow(alloc);
+ const buf = try exec.write_buf_pool.getGrow(alloc);
+ const slice = slice: {
+ // The maximum end index is either the end of our data or
+ // the end of our buffer, whichever is smaller.
+ const max = @min(data.len, i + buf.len);
+
+ // Fast
+ if (!linefeed) {
+ fastmem.copy(u8, buf, data[i..max]);
+ const len = max - i;
+ i = max;
+ break :slice buf[0..len];
+ }
+
+ // Slow, have to replace \r with \r\n
+ var buf_i: usize = 0;
+ while (i < data.len and buf_i < buf.len - 1) {
+ const ch = data[i];
+ i += 1;
+
+ if (ch != '\r') {
+ buf[buf_i] = ch;
+ buf_i += 1;
+ continue;
+ }
+
+ // CRLF
+ buf[buf_i] = '\r';
+ buf[buf_i + 1] = '\n';
+ buf_i += 2;
+ }
+
+ break :slice buf[0..buf_i];
+ };
+
+ //for (slice) |b| log.warn("write: {x}", .{b});
+
+ exec.write_stream.queueWrite(
+ td.loop,
+ &exec.write_queue,
+ req,
+ .{ .slice = slice },
+ termio.Exec.ThreadData,
+ exec,
+ ttyWrite,
+ );
+ }
+}
+
+fn ttyWrite(
+ td_: ?*ThreadData,
+ _: *xev.Loop,
+ _: *xev.Completion,
+ _: xev.Stream,
+ _: xev.WriteBuffer,
+ r: xev.WriteError!usize,
+) xev.CallbackAction {
+ const td = td_.?;
+ td.write_req_pool.put();
+ td.write_buf_pool.put();
+
+ const d = r catch |err| {
+ log.err("write error: {}", .{err});
+ return .disarm;
+ };
+ _ = d;
+ //log.info("WROTE: {d}", .{d});
+
+ return .disarm;
+}
+
+/// The thread local data for the exec implementation.
+pub const ThreadData = struct {
+ // The preallocation size for the write request pool. This should be big
+ // enough to satisfy most write requests. It must be a power of 2.
+ const WRITE_REQ_PREALLOC = std.math.pow(usize, 2, 5);
+
+ /// Process start time and boolean of whether its already exited.
+ start: std.time.Instant,
+ exited: bool = false,
+
+ /// The data stream is the main IO for the pty.
+ write_stream: xev.Stream,
+
+ /// The process watcher
+ process: ?xev.Process,
+
+ /// This is the pool of available (unused) write requests. If you grab
+ /// one from the pool, you must put it back when you're done!
+ write_req_pool: SegmentedPool(xev.WriteRequest, WRITE_REQ_PREALLOC) = .{},
+
+ /// The pool of available buffers for writing to the pty.
+ write_buf_pool: SegmentedPool([64]u8, WRITE_REQ_PREALLOC) = .{},
+
+ /// The write queue for the data stream.
+ write_queue: xev.WriteQueue = .{},
+
+ /// This is used for both waiting for the process to exit and then
+ /// subsequently to wait for the data_stream to close.
+ process_wait_c: xev.Completion = .{},
+
+ // The completion specific to Flatpak process waiting. If
+ // we aren't compiling with Flatpak support this is zero-sized.
+ flatpak_wait_c: FlatpakHostCommand.Completion = .{},
+
+ /// Reader thread state
+ read_thread: std.Thread,
+ read_thread_pipe: posix.fd_t,
+ read_thread_fd: posix.fd_t,
+
+ /// The timer to detect termios state changes.
+ termios_timer: xev.Timer,
+ termios_timer_c: xev.Completion = .{},
+ termios_timer_running: bool = true,
+
+ /// The last known termios mode. Used for change detection
+ /// to prevent unnecessary locking of expensive mutexes.
+ termios_mode: ptypkg.Mode = .{},
+
+ pub fn deinit(self: *ThreadData, alloc: Allocator) void {
+ posix.close(self.read_thread_pipe);
+
+ // Clear our write pools. We know we aren't ever going to do
+ // any more IO since we stop our data stream below so we can just
+ // drop this.
+ self.write_req_pool.deinit(alloc);
+ self.write_buf_pool.deinit(alloc);
+
+ // Stop our process watcher
+ if (self.process) |*p| p.deinit();
+
+ // Stop our write stream
+ self.write_stream.deinit();
+
+ // Stop our termios timer
+ self.termios_timer.deinit();
+ }
+};
+
+pub const Config = struct {
+ command: ?configpkg.Command = null,
+ env: EnvMap,
+ env_override: configpkg.RepeatableStringMap = .{},
+ shell_integration: configpkg.Config.ShellIntegration = .detect,
+ shell_integration_features: configpkg.Config.ShellIntegrationFeatures = .{},
+ cursor_blink: ?bool = null,
+ working_directory: ?[]const u8 = null,
+ resources_dir: ?[]const u8,
+ term: []const u8,
+
+ rt_pre_exec_info: Command.RtPreExecInfo,
+ rt_post_fork_info: Command.RtPostForkInfo,
+};
+
+const Subprocess = struct {
+ const c = @cImport({
+ @cInclude("errno.h");
+ @cInclude("signal.h");
+ @cInclude("unistd.h");
+ });
+
+ arena: std.heap.ArenaAllocator,
+ cwd: ?[:0]const u8,
+ env: ?EnvMap,
+ args: []const [:0]const u8,
+ grid_size: renderer.GridSize,
+ screen_size: renderer.ScreenSize,
+ pty: ?Pty = null,
+ process: ?Process = null,
+
+ rt_pre_exec_info: Command.RtPreExecInfo,
+ rt_post_fork_info: Command.RtPostForkInfo,
+
+ /// Union that represents the running process type.
+ const Process = union(enum) {
+ /// Standard POSIX fork/exec
+ fork_exec: Command,
+
+ /// Flatpak DBus command
+ flatpak: FlatpakHostCommand,
+ };
+
+ const ArgsFormatter = struct {
+ args: []const [:0]const u8,
+
+ pub fn format(this: @This(), writer: *std.Io.Writer) std.Io.Writer.Error!void {
+ for (this.args, 0..) |a, i| {
+ if (i > 0) try writer.writeAll(", ");
+ try writer.print("`{s}`", .{a});
+ }
+ }
+ };
+
+ /// Initialize the subprocess. This will NOT start it, this only sets
+ /// up the internal state necessary to start it later.
+ pub fn init(gpa: Allocator, cfg: Config) !Subprocess {
+ // We have a lot of maybe-allocations that all share the same lifetime
+ // so use an arena so we don't end up in an accounting nightmare.
+ var arena = std.heap.ArenaAllocator.init(gpa);
+ errdefer arena.deinit();
+ const alloc = arena.allocator();
+
+ // Get our env. If a default env isn't provided by the caller
+ // then we get it ourselves.
+ var env = cfg.env;
+
+ // If we have a resources dir then set our env var
+ if (cfg.resources_dir) |dir| {
+ log.info("found Ghostty resources dir: {s}", .{dir});
+ try env.put("GHOSTTY_RESOURCES_DIR", dir);
+ }
+
+ // Set our TERM var. This is a bit complicated because we want to use
+ // the ghostty TERM value but we want to only do that if we have
+ // ghostty in the TERMINFO database.
+ //
+ // For now, we just look up a bundled dir but in the future we should
+ // also load the terminfo database and look for it.
+ if (cfg.resources_dir) |base| {
+ try env.put("TERM", cfg.term);
+ try env.put("COLORTERM", "truecolor");
+
+ // Assume that the resources directory is adjacent to the terminfo
+ // database
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ const dir = try std.fmt.bufPrint(&buf, "{s}/terminfo", .{
+ std.fs.path.dirname(base) orelse unreachable,
+ });
+ try env.put("TERMINFO", dir);
+ } else {
+ if (comptime builtin.target.os.tag.isDarwin()) {
+ log.warn("ghostty terminfo not found, using xterm-256color", .{});
+ log.warn("the terminfo SHOULD exist on macos, please ensure", .{});
+ log.warn("you're using a valid app bundle.", .{});
+ }
+
+ try env.put("TERM", "xterm-256color");
+ try env.put("COLORTERM", "truecolor");
+ }
+
+ // Add our binary to the path if we can find it.
+ ghostty_path: {
+ // Skip this for flatpak since host cannot reach them
+ if ((comptime build_config.flatpak) and
+ internal_os.isFlatpak())
+ {
+ break :ghostty_path;
+ }
+
+ var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const exe_bin_path = std.fs.selfExePath(&exe_buf) catch |err| {
+ log.warn("failed to get ghostty exe path err={}", .{err});
+ break :ghostty_path;
+ };
+ const exe_dir = std.fs.path.dirname(exe_bin_path) orelse break :ghostty_path;
+ log.debug("appending ghostty bin to path dir={s}", .{exe_dir});
+
+ // We always set this so that if the shell overwrites the path
+ // scripts still have a way to find the Ghostty binary when
+ // running in Ghostty.
+ try env.put("GHOSTTY_BIN_DIR", exe_dir);
+
+ // Append if we have a path. We want to append so that ghostty is
+ // the last priority in the path. If we don't have a path set
+ // then we just set it to the directory of the binary.
+ if (env.get("PATH")) |path| {
+ // Verify that our path doesn't already contain this entry
+ var it = std.mem.tokenizeScalar(u8, path, std.fs.path.delimiter);
+ while (it.next()) |entry| {
+ if (std.mem.eql(u8, entry, exe_dir)) break :ghostty_path;
+ }
+
+ try env.put(
+ "PATH",
+ try internal_os.appendEnv(alloc, path, exe_dir),
+ );
+ } else {
+ try env.put("PATH", exe_dir);
+ }
+ }
+
+ // On macOS, export additional data directories from our
+ // application bundle.
+ if (comptime builtin.target.os.tag.isDarwin()) darwin: {
+ const resources_dir = cfg.resources_dir orelse break :darwin;
+
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+
+ const xdg_data_dir_key = "XDG_DATA_DIRS";
+ if (std.fmt.bufPrint(&buf, "{s}/..", .{resources_dir})) |data_dir| {
+ try env.put(
+ xdg_data_dir_key,
+ try internal_os.appendEnv(
+ alloc,
+ env.get(xdg_data_dir_key) orelse "/usr/local/share:/usr/share",
+ data_dir,
+ ),
+ );
+ } else |err| {
+ log.warn("error building {s}; err={}", .{ xdg_data_dir_key, err });
+ }
+
+ const manpath_key = "MANPATH";
+ if (std.fmt.bufPrint(&buf, "{s}/../man", .{resources_dir})) |man_dir| {
+ // Always append with colon in front, as it mean that if
+ // `MANPATH` is empty, then it should be treated as an extra
+ // path instead of overriding all paths set by OS.
+ try env.put(
+ manpath_key,
+ try internal_os.appendEnvAlways(
+ alloc,
+ env.get(manpath_key) orelse "",
+ man_dir,
+ ),
+ );
+ } else |err| {
+ log.warn("error building {s}; man pages may not be available; err={}", .{ manpath_key, err });
+ }
+ }
+
+ // Set environment variables used by some programs (such as neovim) to detect
+ // which terminal emulator and version they're running under.
+ try env.put("TERM_PROGRAM", "ghostty");
+ try env.put("TERM_PROGRAM_VERSION", build_config.version_string);
+
+ // VTE_VERSION is set by gnome-terminal and other VTE-based terminals.
+ // We don't want our child processes to think we're running under VTE.
+ // This is not apprt-specific, so we do it here.
+ env.remove("VTE_VERSION");
+
+ // Setup our shell integration, if we can.
+ const shell_command: configpkg.Command = shell: {
+ const default_shell_command: configpkg.Command =
+ cfg.command orelse .{ .shell = switch (builtin.os.tag) {
+ .windows => "cmd.exe",
+ else => "sh",
+ } };
+
+ // Always set up shell features (GHOSTTY_SHELL_FEATURES). These are
+ // used by both automatic and manual shell integrations.
+ try shell_integration.setupFeatures(
+ &env,
+ cfg.shell_integration_features,
+ cfg.cursor_blink orelse true,
+ );
+
+ const force: ?shell_integration.Shell = switch (cfg.shell_integration) {
+ .none => {
+ // This is a source of confusion for users despite being
+ // opt-in since it results in some Ghostty features not
+ // working. We always want to log it.
+ log.info("shell integration disabled by configuration", .{});
+ break :shell default_shell_command;
+ },
+
+ .detect => null,
+ .bash => .bash,
+ .elvish => .elvish,
+ .fish => .fish,
+ .nushell => .nushell,
+ .zsh => .zsh,
+ };
+
+ const dir = cfg.resources_dir orelse {
+ log.warn("no resources dir set, shell integration disabled", .{});
+ break :shell default_shell_command;
+ };
+
+ const integration = try shell_integration.setup(
+ alloc,
+ dir,
+ default_shell_command,
+ &env,
+ force,
+ ) orelse {
+ log.warn("shell could not be detected, no automatic shell integration will be injected", .{});
+ break :shell default_shell_command;
+ };
+
+ log.info(
+ "shell integration automatically injected shell={}",
+ .{integration.shell},
+ );
+
+ break :shell integration.command;
+ };
+
+ // Add the environment variables that override any others.
+ {
+ var it = cfg.env_override.iterator();
+ while (it.next()) |entry| try env.put(
+ entry.key_ptr.*,
+ entry.value_ptr.*,
+ );
+ }
+
+ // Build our args list
+ const args: []const [:0]const u8 = execCommand(
+ alloc,
+ shell_command,
+ internal_os.passwd,
+ ) catch |err| switch (err) {
+ // If we fail to allocate space for the command we want to
+ // execute, we'd still like to try to run something so
+ // Ghostty can launch (and maybe the user can debug this further).
+ // Realistically, if you're getting OOM, I think other stuff is
+ // about to crash, but we can try.
+ error.OutOfMemory => oom: {
+ log.warn("failed to allocate space for command args, falling back to basic shell", .{});
+
+ // The comptime here is important to ensure the full slice
+ // is put into the binary data and not the stack.
+ break :oom comptime switch (builtin.os.tag) {
+ .windows => &.{"cmd.exe"},
+ else => &.{"/bin/sh"},
+ };
+ },
+
+ // This logs on its own, this is a bad error.
+ error.SystemError => return err,
+ };
+
+ // We have to copy the cwd because there is no guarantee that
+ // pointers in full_config remain valid.
+ const cwd: ?[:0]u8 = if (cfg.working_directory) |cwd|
+ try alloc.dupeZ(u8, cwd)
+ else
+ null;
+
+ // Propagate the current working directory (CWD) to the shell, enabling
+ // the shell to display the current directory name rather than the
+ // resolved path for symbolic links. This is important and based
+ // on the same behavior in Konsole and Kitty (see the linked issues):
+ // https://bugs.kde.org/show_bug.cgi?id=242114
+ // https://github.com/kovidgoyal/kitty/issues/1595
+ // https://github.com/ghostty-org/ghostty/discussions/7769
+ if (cwd) |pwd| try env.put("PWD", pwd);
+
+ return .{
+ .arena = arena,
+ .env = env,
+ .cwd = cwd,
+ .args = args,
+
+ .rt_pre_exec_info = cfg.rt_pre_exec_info,
+ .rt_post_fork_info = cfg.rt_post_fork_info,
+
+ // Should be initialized with initTerminal call.
+ .grid_size = .{},
+ .screen_size = .{ .width = 1, .height = 1 },
+ };
+ }
+
+ /// Clean up the subprocess. This will stop the subprocess if it is started.
+ pub fn deinit(self: *Subprocess) void {
+ self.stop();
+ if (self.pty) |*pty| pty.deinit();
+ if (self.env) |*env| env.deinit();
+ self.arena.deinit();
+ self.* = undefined;
+ }
+
+ /// Start the subprocess. If the subprocess is already started this
+ /// will crash.
+ pub fn start(self: *Subprocess, alloc: Allocator) !struct {
+ read: Pty.Fd,
+ write: Pty.Fd,
+ } {
+ assert(self.pty == null and self.process == null);
+
+ // This function is funny because on POSIX systems it can
+ // fail in the forked process. This is flipped to true if
+ // we're in an error state in the forked process (child
+ // process).
+ var in_child: bool = false;
+
+ // Create our pty
+ var pty = try Pty.open(.{
+ .ws_row = @intCast(self.grid_size.rows),
+ .ws_col = @intCast(self.grid_size.columns),
+ .ws_xpixel = @intCast(self.screen_size.width),
+ .ws_ypixel = @intCast(self.screen_size.height),
+ });
+ self.pty = pty;
+ errdefer if (!in_child) {
+ if (comptime builtin.os.tag != .windows) {
+ _ = posix.close(pty.slave);
+ }
+
+ pty.deinit();
+ self.pty = null;
+ };
+
+ // Cleanup we only run in our parent when we successfully start
+ // the process.
+ defer if (!in_child and self.process != null) {
+ if (comptime builtin.os.tag != .windows) {
+ // Once our subcommand is started we can close the slave
+ // side. This prevents the slave fd from being leaked to
+ // future children.
+ _ = posix.close(pty.slave);
+ }
+
+ // Successful start we can clear out some memory.
+ if (self.env) |*env| {
+ env.deinit();
+ self.env = null;
+ }
+ };
+
+ log.debug("starting command command={f}", .{ArgsFormatter{ .args = self.args }});
+
+ // If we can't access the cwd, then don't set any cwd and inherit.
+ // This is important because our cwd can be set by the shell (OSC 7)
+ // and we don't want to break new windows.
+ const cwd: ?[:0]const u8 = if (self.cwd) |proposed| cwd: {
+ if ((comptime build_config.flatpak) and internal_os.isFlatpak()) {
+ // Flatpak sandboxing prevents access to certain reserved paths
+ // regardless of configured permissions. Perform a test spawn
+ // to get around this problem
+ //
+ // https://docs.flatpak.org/en/latest/sandbox-permissions.html#reserved-paths
+ log.info("flatpak detected, will use host command to verify cwd access", .{});
+ const dev_null = try std.fs.cwd().openFile("/dev/null", .{ .mode = .read_write });
+ defer dev_null.close();
+ var cmd: internal_os.FlatpakHostCommand = .{
+ .argv = &[_][]const u8{
+ "/bin/sh",
+ "-c",
+ ":",
+ },
+ .cwd = proposed,
+ .stdin = dev_null.handle,
+ .stdout = dev_null.handle,
+ .stderr = dev_null.handle,
+ };
+ _ = cmd.spawn(alloc) catch |err| {
+ log.warn("cannot spawn command at cwd, ignoring: {}", .{err});
+ break :cwd null;
+ };
+ _ = try cmd.wait();
+
+ break :cwd proposed;
+ }
+
+ if (std.fs.cwd().access(proposed, .{})) {
+ break :cwd proposed;
+ } else |err| {
+ log.warn("cannot access cwd, ignoring: {}", .{err});
+ break :cwd null;
+ }
+ } else null;
+
+ // In flatpak, we use the HostCommand to execute our shell.
+ if (internal_os.isFlatpak()) flatpak: {
+ if (comptime !build_config.flatpak) {
+ log.warn("flatpak detected, but flatpak support not built-in", .{});
+ break :flatpak;
+ }
+
+ // Flatpak command must have a stable pointer.
+ self.process = .{ .flatpak = .{
+ .argv = self.args,
+ .cwd = cwd,
+ .env = if (self.env) |*env| env else null,
+ .stdin = pty.slave,
+ .stdout = pty.slave,
+ .stderr = pty.slave,
+ } };
+ var cmd = &self.process.?.flatpak;
+ const pid = try cmd.spawn(alloc);
+ errdefer killCommandFlatpak(cmd);
+
+ log.info("started subcommand on host via flatpak API path={s} pid={}", .{
+ self.args[0],
+ pid,
+ });
+
+ return .{
+ .read = pty.master,
+ .write = pty.master,
+ };
+ }
+
+ // Build our subcommand
+ var cmd: Command = .{
+ .path = self.args[0],
+ .args = self.args,
+ .env = if (self.env) |*env| env else null,
+ .cwd = cwd,
+ .stdin = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave },
+ .stdout = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave },
+ .stderr = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave },
+ .pseudo_console = if (builtin.os.tag == .windows) pty.pseudo_console else {},
+ .os_pre_exec = switch (comptime builtin.os.tag) {
+ .windows => null,
+ else => f: {
+ const f = struct {
+ fn callback(cmd: *Command) ?u8 {
+ const sp = cmd.getData(Subprocess) orelse unreachable;
+ sp.childPreExec() catch |err| log.err(
+ "error initializing child: {}",
+ .{err},
+ );
+ return null;
+ }
+ };
+ break :f f.callback;
+ },
+ },
+ .rt_pre_exec = if (comptime @hasDecl(apprt.runtime, "pre_exec")) apprt.runtime.pre_exec.preExec else null,
+ .rt_pre_exec_info = self.rt_pre_exec_info,
+ .rt_post_fork = if (comptime @hasDecl(apprt.runtime, "post_fork")) apprt.runtime.post_fork.postFork else null,
+ .rt_post_fork_info = self.rt_post_fork_info,
+ .data = self,
+ };
+
+ cmd.start(alloc) catch |err| {
+ // We have to do this because start on Windows can't
+ // ever return ExecFailedInChild
+ const StartError = error{ExecFailedInChild} || @TypeOf(err);
+ switch (@as(StartError, err)) {
+ // If we fail in our child we need to flag it so our
+ // errdefers don't run.
+ error.ExecFailedInChild => {
+ in_child = true;
+ return err;
+ },
+
+ else => return err,
+ }
+ };
+ errdefer killCommand(&cmd) catch |err| {
+ log.warn("error killing command during cleanup err={}", .{err});
+ };
+ log.info("started subcommand path={s} pid={?}", .{ self.args[0], cmd.pid });
+
+ self.process = .{ .fork_exec = cmd };
+ return switch (builtin.os.tag) {
+ .windows => .{
+ .read = pty.out_pipe,
+ .write = pty.in_pipe,
+ },
+
+ else => .{
+ .read = pty.master,
+ .write = pty.master,
+ },
+ };
+ }
+
+ /// This should be called after fork but before exec in the child process.
+ /// To repeat: this function RUNS IN THE FORKED CHILD PROCESS before
+ /// exec is called; it does NOT run in the main Ghostty process.
+ fn childPreExec(self: *Subprocess) !void {
+ // Setup our pty
+ try self.pty.?.childPreExec();
+ }
+
+ /// Called to notify that we exited externally so we can unset our
+ /// running state.
+ pub fn externalExit(self: *Subprocess) void {
+ self.process = null;
+ }
+
+ /// Stop the subprocess. This is safe to call anytime. This will wait
+ /// for the subprocess to register that it has been signalled, but not
+ /// for it to terminate, so it will not block.
+ /// This does not close the pty.
+ pub fn stop(self: *Subprocess) void {
+ switch (self.process orelse return) {
+ .fork_exec => |*cmd| {
+ // Note: this will also wait for the command to exit, so
+ // DO NOT call cmd.wait
+ killCommand(cmd) catch |err|
+ log.err("error sending SIGHUP to command, may hang: {}", .{err});
+ },
+
+ .flatpak => |*cmd| if (comptime build_config.flatpak) {
+ killCommandFlatpak(cmd) catch |err|
+ log.err("error sending SIGHUP to command, may hang: {}", .{err});
+ _ = cmd.wait() catch |err|
+ log.err("error waiting for command to exit: {}", .{err});
+ },
+ }
+
+ self.process = null;
+ }
+
+ /// Resize the pty subprocess. This is safe to call anytime.
+ pub fn resize(
+ self: *Subprocess,
+ grid_size: renderer.GridSize,
+ screen_size: renderer.ScreenSize,
+ ) !void {
+ self.grid_size = grid_size;
+ self.screen_size = screen_size;
+
+ if (self.pty) |*pty| {
+ // It is theoretically possible for the grid or screen size to
+ // exceed u16, although the terminal in that case isn't very
+ // usable. This should be protected upstream but we still clamp
+ // in case there is a bad caller which has happened before.
+ try pty.setSize(.{
+ .ws_row = std.math.cast(u16, grid_size.rows) orelse std.math.maxInt(u16),
+ .ws_col = std.math.cast(u16, grid_size.columns) orelse std.math.maxInt(u16),
+ .ws_xpixel = std.math.cast(u16, screen_size.width) orelse std.math.maxInt(u16),
+ .ws_ypixel = std.math.cast(u16, screen_size.height) orelse std.math.maxInt(u16),
+ });
+ }
+ }
+
+ /// Kill the underlying subprocess. This sends a SIGHUP to the child
+ /// process. This also waits for the command to exit and will return the
+ /// exit code.
+ fn killCommand(command: *Command) !void {
+ if (command.pid) |pid| {
+ switch (builtin.os.tag) {
+ .windows => {
+ if (windows.kernel32.TerminateProcess(pid, 0) == 0) {
+ return windows.unexpectedError(windows.kernel32.GetLastError());
+ }
+
+ _ = try command.wait(false);
+ },
+
+ else => try killPid(pid),
+ }
+ }
+ }
+
+ fn killPid(pid: c.pid_t) !void {
+ const pgid = getpgid(pid) orelse return;
+
+ // It is possible to send a killpg between the time that
+ // our child process calls setsid but before or simultaneous
+ // to calling execve. In this case, the direct child dies
+ // but grandchildren survive. To work around this, we loop
+ // and repeatedly kill the process group until all
+ // descendents are well and truly dead. We will not rest
+ // until the entire family tree is obliterated.
+ while (true) {
+ switch (posix.errno(c.killpg(pgid, c.SIGHUP))) {
+ .SUCCESS => log.debug("process group killed pgid={}", .{pgid}),
+ else => |err| killpg: {
+ if ((comptime builtin.target.os.tag.isDarwin()) and
+ err == .PERM)
+ {
+ log.debug("killpg failed with EPERM, expected on Darwin and ignoring", .{});
+ break :killpg;
+ }
+
+ log.warn("error killing process group pgid={} err={}", .{ pgid, err });
+ return error.KillFailed;
+ },
+ }
+
+ // See Command.zig wait for why we specify WNOHANG.
+ // The gist is that it lets us detect when children
+ // are still alive without blocking so that we can
+ // kill them again.
+ const res = posix.waitpid(pid, std.c.W.NOHANG);
+ log.debug("waitpid result={}", .{res.pid});
+ if (res.pid != 0) break;
+ std.Thread.sleep(10 * std.time.ns_per_ms);
+ }
+ }
+
+ fn getpgid(pid: c.pid_t) ?c.pid_t {
+ // Get our process group ID. Before the child pid calls setsid
+ // the pgid will be ours because we forked it. Its possible that
+ // we may be calling this before setsid if we are killing a surface
+ // VERY quickly after starting it.
+ const my_pgid = c.getpgid(0);
+
+ // We loop while pgid == my_pgid. The expectation if we have a valid
+ // pid is that setsid will eventually be called because it is the
+ // FIRST thing the child process does and as far as I can tell,
+ // setsid cannot fail. I'm sure that's not true, but I'd rather
+ // have a bug reported than defensively program against it now.
+ while (true) {
+ const pgid = c.getpgid(pid);
+ if (pgid == my_pgid) {
+ log.warn("pgid is our own, retrying", .{});
+ std.Thread.sleep(10 * std.time.ns_per_ms);
+ continue;
+ }
+
+ // Don't know why it would be zero but its not a valid pid
+ if (pgid == 0) return null;
+
+ // If the pid doesn't exist then... we're done!
+ if (pgid == c.ESRCH) return null;
+
+ // If we have an error we're done.
+ if (pgid < 0) {
+ log.warn("error getting pgid for kill", .{});
+ return null;
+ }
+
+ return pgid;
+ }
+ }
+
+ /// Kill the underlying process started via Flatpak host command.
+ /// This sends a signal via the Flatpak API.
+ fn killCommandFlatpak(command: *FlatpakHostCommand) !void {
+ try command.signal(c.SIGHUP, true);
+ }
+
+ /// Get information about the process(es) running within the subprocess.
+ /// Returns `null` if there was an error getting the information or the
+ /// information is not available on a particular platform.
+ pub fn getProcessInfo(self: *Subprocess, comptime info: ProcessInfo) ?ProcessInfo.Type(info) {
+ const pty = &(self.pty orelse return null);
+ return pty.getProcessInfo(info);
+ }
+};
+
+/// The read thread sits in a loop doing the following pseudo code:
+///
+/// while (true) { blocking_read(); exit_if_eof(); process(); }
+///
+/// Almost all terminal-modifying activity is from the pty read, so
+/// putting this on a dedicated thread keeps performance very predictable
+/// while also almost optimal. "Locking is fast, lock contention is slow."
+/// and since we rarely have contention, this is fast.
+///
+/// This is also empirically fast compared to putting the read into
+/// an async mechanism like io_uring/epoll because the reads are generally
+/// small.
+///
+/// We use a basic poll syscall here because we are only monitoring two
+/// fds and this is still much faster and lower overhead than any async
+/// mechanism.
+pub const ReadThread = struct {
+ fn threadMainPosix(fd: posix.fd_t, io: *termio.Termio, quit: posix.fd_t) void {
+ // Always close our end of the pipe when we exit.
+ defer posix.close(quit);
+
+ // Right now, on Darwin, `std.Thread.setName` can only name the current
+ // thread, and we have no way to get the current thread from within it,
+ // so instead we use this code to name the thread instead.
+ if (builtin.os.tag.isDarwin()) {
+ internal_os.macos.pthread_setname_np(&"io-reader".*);
+ }
+
+ // Setup our crash metadata
+ crash.sentry.thread_state = .{
+ .type = .io,
+ .surface = io.surface_mailbox.surface,
+ };
+ defer crash.sentry.thread_state = null;
+
+ // First thing, we want to set the fd to non-blocking. We do this
+ // so that we can try to read from the fd in a tight loop and only
+ // check the quit fd occasionally.
+ if (posix.fcntl(fd, posix.F.GETFL, 0)) |flags| {
+ _ = posix.fcntl(
+ fd,
+ posix.F.SETFL,
+ flags | @as(u32, @bitCast(posix.O{ .NONBLOCK = true })),
+ ) catch |err| {
+ log.warn("read thread failed to set flags err={}", .{err});
+ log.warn("this isn't a fatal error, but may cause performance issues", .{});
+ };
+ } else |err| {
+ log.warn("read thread failed to get flags err={}", .{err});
+ log.warn("this isn't a fatal error, but may cause performance issues", .{});
+ }
+
+ // Build up the list of fds we're going to poll. We are looking
+ // for data on the pty and our quit notification.
+ var pollfds: [2]posix.pollfd = .{
+ .{ .fd = fd, .events = posix.POLL.IN, .revents = undefined },
+ .{ .fd = quit, .events = posix.POLL.IN, .revents = undefined },
+ };
+
+ var buf: [1024]u8 = undefined;
+ while (true) {
+ // We try to read from the file descriptor as long as possible
+ // to maximize performance. We only check the quit fd if the
+ // main fd blocks. This optimizes for the realistic scenario that
+ // the data will eventually stop while we're trying to quit. This
+ // is always true because we kill the process.
+ while (true) {
+ const n = posix.read(fd, &buf) catch |err| {
+ switch (err) {
+ // This means our pty is closed. We're probably
+ // gracefully shutting down.
+ error.NotOpenForReading,
+ error.InputOutput,
+ => {
+ log.info("io reader exiting", .{});
+ return;
+ },
+
+ // No more data, fall back to poll and check for
+ // exit conditions.
+ error.WouldBlock => break,
+
+ else => {
+ log.err("io reader error err={}", .{err});
+ unreachable;
+ },
+ }
+ };
+
+ // This happens on macOS instead of WouldBlock when the
+ // child process dies. To be safe, we just break the loop
+ // and let our poll happen.
+ if (n == 0) break;
+
+ // log.info("DATA: {d}", .{n});
+ @call(.always_inline, termio.Termio.processOutput, .{ io, buf[0..n] });
+ }
+
+ // Wait for data.
+ _ = posix.poll(&pollfds, -1) catch |err| {
+ log.warn("poll failed on read thread, exiting early err={}", .{err});
+ return;
+ };
+
+ // If our quit fd is set, we're done.
+ if (pollfds[1].revents & posix.POLL.IN != 0) {
+ log.info("read thread got quit signal", .{});
+ return;
+ }
+
+ // If our pty fd is closed, then we're also done with our
+ // read thread.
+ if (pollfds[0].revents & posix.POLL.HUP != 0) {
+ log.info("pty fd closed, read thread exiting", .{});
+ return;
+ }
+ }
+ }
+
+ fn threadMainWindows(fd: posix.fd_t, io: *termio.Termio, quit: posix.fd_t) void {
+ // Always close our end of the pipe when we exit.
+ defer posix.close(quit);
+
+ // Setup our crash metadata
+ crash.sentry.thread_state = .{
+ .type = .io,
+ .surface = io.surface_mailbox.surface,
+ };
+ defer crash.sentry.thread_state = null;
+
+ var buf: [1024]u8 = undefined;
+ while (true) {
+ while (true) {
+ var n: windows.DWORD = 0;
+ if (windows.kernel32.ReadFile(fd, &buf, buf.len, &n, null) == 0) {
+ const err = windows.kernel32.GetLastError();
+ switch (err) {
+ // Check for a quit signal
+ .OPERATION_ABORTED => break,
+
+ else => {
+ log.err("io reader error err={}", .{err});
+ unreachable;
+ },
+ }
+ }
+
+ @call(.always_inline, termio.Termio.processOutput, .{ io, buf[0..n] });
+ }
+
+ var quit_bytes: windows.DWORD = 0;
+ if (windows.exp.kernel32.PeekNamedPipe(quit, null, 0, null, &quit_bytes, null) == 0) {
+ const err = windows.kernel32.GetLastError();
+ log.err("quit pipe reader error err={}", .{err});
+ unreachable;
+ }
+
+ if (quit_bytes > 0) {
+ log.info("read thread got quit signal", .{});
+ return;
+ }
+ }
+ }
+};
+
+/// Builds the argv array for the process we should exec for the
+/// configured command. This isn't as straightforward as it seems since
+/// we deal with shell-wrapping, macOS login shells, etc.
+///
+/// The passwdpkg comptime argument is expected to have a single function
+/// `get(Allocator)` that returns a passwd entry. This is used by macOS
+/// to determine the username and home directory for the login shell.
+/// It is unused on other platforms.
+///
+/// Memory ownership:
+///
+/// The allocator should be an arena, since the returned value may or
+/// may not be allocated and args may or may not be allocated (or copied).
+/// Pointers in the return value may point to pointers in the command
+/// struct.
+fn execCommand(
+ alloc: Allocator,
+ command: configpkg.Command,
+ comptime passwdpkg: type,
+) (Allocator.Error || error{SystemError})![]const [:0]const u8 {
+ // If we're on macOS, we have to use `login(1)` to get all of
+ // the proper environment variables set, a login shell, and proper
+ // hushlogin behavior.
+ if (comptime builtin.target.os.tag.isDarwin()) darwin: {
+ const passwd = passwdpkg.get(alloc) catch |err| {
+ log.warn("failed to read passwd, not using a login shell err={}", .{err});
+ break :darwin;
+ };
+
+ const username = passwd.name orelse {
+ log.warn("failed to get username, not using a login shell", .{});
+ break :darwin;
+ };
+
+ const hush = if (passwd.home) |home| hush: {
+ var dir = std.fs.openDirAbsolute(home, .{}) catch |err| {
+ log.warn(
+ "failed to open home dir, not checking for hushlogin err={}",
+ .{err},
+ );
+ break :hush false;
+ };
+ defer dir.close();
+
+ break :hush if (dir.access(".hushlogin", .{})) true else |_| false;
+ } else false;
+
+ // If we made it this far we're going to start building
+ // the actual command.
+ var args: std.ArrayList([:0]const u8) = try .initCapacity(
+ alloc,
+
+ // This capacity is chosen based on what we'd need to
+ // execute a shell command (very common). We can/will
+ // grow if necessary for a longer command (uncommon).
+ 9,
+ );
+ defer args.deinit(alloc);
+
+ // The reason for executing login this way is unclear. This
+ // comment will attempt to explain but prepare for a truly
+ // unhinged reality.
+ //
+ // The first major issue is that on macOS, a lot of users
+ // put shell configurations in ~/.bash_profile instead of
+ // ~/.bashrc (or equivalent for another shell). This file is only
+ // loaded for a login shell so macOS users expect all their terminals
+ // to be login shells. No other platform behaves this way and its
+ // totally braindead but somehow the entire dev community on
+ // macOS has cargo culted their way to this reality so we have to
+ // do it...
+ //
+ // To get a login shell, you COULD just prepend argv0 with a `-`
+ // but that doesn't fully work because `getlogin()` C API will
+ // return the wrong value, SHELL won't be set, and various
+ // other login behaviors that macOS users expect.
+ //
+ // The proper way is to use `login(1)`. But login(1) forces
+ // the working directory to change to the home directory,
+ // which we may not want. If we specify "-l" then we can avoid
+ // this behavior but now the shell isn't a login shell.
+ //
+ // There is another issue: `login(1)` on macOS 14.3 and earlier
+ // checked for ".hushlogin" in the working directory. This means
+ // that if we specify "-l" then we won't get hushlogin honored
+ // if its in the home directory (which is standard). To get
+ // around this, we check for hushlogin ourselves and if present
+ // specify the "-q" flag to login(1).
+ //
+ // So to get all the behaviors we want, we specify "-l" but
+ // execute "bash" (which is built-in to macOS). We then use
+ // the bash builtin "exec" to replace the process with a login
+ // shell ("-l" on exec) with the command we really want.
+ //
+ // We use "bash" instead of other shells that ship with macOS
+ // because as of macOS Sonoma, we found with a microbenchmark
+ // that bash can `exec` into the desired command ~2x faster
+ // than zsh.
+ //
+ // To figure out a lot of this logic I read the login.c
+ // source code in the OSS distribution Apple provides for
+ // macOS.
+ //
+ // Awesome.
+ try args.append(alloc, "/usr/bin/login");
+ if (hush) try args.append(alloc, "-q");
+ try args.append(alloc, "-flp");
+ try args.append(alloc, username);
+
+ switch (command) {
+ // Direct args can be passed directly to login, since
+ // login uses execvp we don't need to worry about PATH
+ // searching.
+ .direct => |v| try args.appendSlice(alloc, v),
+
+ .shell => |v| {
+ // Use "exec" to replace the bash process with
+ // our intended command so we don't have a parent
+ // process hanging around.
+ const cmd = try std.fmt.allocPrintSentinel(
+ alloc,
+ "exec -l {s}",
+ .{v},
+ 0,
+ );
+
+ // We execute bash with "--noprofile --norc" so that it doesn't
+ // load startup files so that (1) our shell integration doesn't
+ // break and (2) user configuration doesn't mess this process
+ // up.
+ try args.append(alloc, "/bin/bash");
+ try args.append(alloc, "--noprofile");
+ try args.append(alloc, "--norc");
+ try args.append(alloc, "-c");
+ try args.append(alloc, cmd);
+ },
+ }
+
+ return try args.toOwnedSlice(alloc);
+ }
+
+ return switch (command) {
+ // We need to clone the command since there's no guarantee the config remains valid.
+ .direct => |_| (try command.clone(alloc)).direct,
+
+ .shell => |v| shell: {
+ var args: std.ArrayList([:0]const u8) = try .initCapacity(alloc, 4);
+ defer args.deinit(alloc);
+
+ if (comptime builtin.os.tag == .windows) {
+ // We run our shell wrapped in `cmd.exe` so that we don't have
+ // to parse the command line ourselves if it has arguments.
+
+ // Note we don't free any of the memory below since it is
+ // allocated in the arena.
+ const windir = std.process.getEnvVarOwned(
+ alloc,
+ "WINDIR",
+ ) catch |err| {
+ log.warn("failed to get WINDIR, cannot run shell command err={}", .{err});
+ return error.SystemError;
+ };
+ const cmd = try std.fs.path.joinZ(alloc, &[_][]const u8{
+ windir,
+ "System32",
+ "cmd.exe",
+ });
+
+ try args.append(alloc, cmd);
+ try args.append(alloc, "/C");
+ } else {
+ // We run our shell wrapped in `/bin/sh` so that we don't have
+ // to parse the command line ourselves if it has arguments.
+ // Additionally, some environments (NixOS, I found) use /bin/sh
+ // to setup some environment variables that are important to
+ // have set.
+ try args.append(alloc, "/bin/sh");
+ if (internal_os.isFlatpak()) try args.append(alloc, "-l");
+ try args.append(alloc, "-c");
+ }
+
+ try args.append(alloc, v);
+ break :shell try args.toOwnedSlice(alloc);
+ },
+ };
+}
+
+/// Get information about the process(es) running within the backend. Returns
+/// `null` if there was an error getting the information or the information is
+/// not available on a particular platform.
+pub fn getProcessInfo(self: *Exec, comptime info: ProcessInfo) ?ProcessInfo.Type(info) {
+ return self.subprocess.getProcessInfo(info);
+}
+
+test "execCommand darwin: shell command" {
+ if (comptime !builtin.os.tag.isDarwin()) return error.SkipZigTest;
+
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ const result = try execCommand(alloc, .{ .shell = "foo bar baz" }, struct {
+ fn get(_: Allocator) !PasswdEntry {
+ return .{
+ .name = "testuser",
+ };
+ }
+ });
+
+ try testing.expectEqual(8, result.len);
+ try testing.expectEqualStrings(result[0], "/usr/bin/login");
+ try testing.expectEqualStrings(result[1], "-flp");
+ try testing.expectEqualStrings(result[2], "testuser");
+ try testing.expectEqualStrings(result[3], "/bin/bash");
+ try testing.expectEqualStrings(result[4], "--noprofile");
+ try testing.expectEqualStrings(result[5], "--norc");
+ try testing.expectEqualStrings(result[6], "-c");
+ try testing.expectEqualStrings(result[7], "exec -l foo bar baz");
+}
+
+test "execCommand darwin: direct command" {
+ if (comptime !builtin.os.tag.isDarwin()) return error.SkipZigTest;
+
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ const result = try execCommand(alloc, .{ .direct = &.{
+ "foo",
+ "bar baz",
+ } }, struct {
+ fn get(_: Allocator) !PasswdEntry {
+ return .{
+ .name = "testuser",
+ };
+ }
+ });
+
+ try testing.expectEqual(5, result.len);
+ try testing.expectEqualStrings(result[0], "/usr/bin/login");
+ try testing.expectEqualStrings(result[1], "-flp");
+ try testing.expectEqualStrings(result[2], "testuser");
+ try testing.expectEqualStrings(result[3], "foo");
+ try testing.expectEqualStrings(result[4], "bar baz");
+}
+
+test "execCommand: shell command, empty passwd" {
+ if (comptime builtin.os.tag == .windows) return error.SkipZigTest;
+
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ const result = try execCommand(
+ alloc,
+ .{ .shell = "foo bar baz" },
+ struct {
+ fn get(_: Allocator) !PasswdEntry {
+ // Empty passwd entry means we can't construct a macOS
+ // login command and falls back to POSIX behavior.
+ return .{};
+ }
+ },
+ );
+
+ try testing.expectEqual(3, result.len);
+ try testing.expectEqualStrings(result[0], "/bin/sh");
+ try testing.expectEqualStrings(result[1], "-c");
+ try testing.expectEqualStrings(result[2], "foo bar baz");
+}
+
+test "execCommand: shell command, error passwd" {
+ if (comptime builtin.os.tag == .windows) return error.SkipZigTest;
+
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ const result = try execCommand(
+ alloc,
+ .{ .shell = "foo bar baz" },
+ struct {
+ fn get(_: Allocator) !PasswdEntry {
+ // Failed passwd entry means we can't construct a macOS
+ // login command and falls back to POSIX behavior.
+ return error.Fail;
+ }
+ },
+ );
+
+ try testing.expectEqual(3, result.len);
+ try testing.expectEqualStrings(result[0], "/bin/sh");
+ try testing.expectEqualStrings(result[1], "-c");
+ try testing.expectEqualStrings(result[2], "foo bar baz");
+}
+
+test "execCommand: direct command, error passwd" {
+ if (comptime builtin.os.tag == .windows) return error.SkipZigTest;
+
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ const result = try execCommand(alloc, .{
+ .direct = &.{
+ "foo",
+ "bar baz",
+ },
+ }, struct {
+ fn get(_: Allocator) !PasswdEntry {
+ // Failed passwd entry means we can't construct a macOS
+ // login command and falls back to POSIX behavior.
+ return error.Fail;
+ }
+ });
+
+ try testing.expectEqual(2, result.len);
+ try testing.expectEqualStrings(result[0], "foo");
+ try testing.expectEqualStrings(result[1], "bar baz");
+}
+
+test "execCommand: direct command, config freed" {
+ if (comptime builtin.os.tag == .windows) return error.SkipZigTest;
+
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var command_arena = ArenaAllocator.init(testing.allocator);
+ const command_alloc = command_arena.allocator();
+ const command = try (configpkg.Command{
+ .direct = &.{
+ "foo",
+ "bar baz",
+ },
+ }).clone(command_alloc);
+
+ const result = try execCommand(alloc, command, struct {
+ fn get(_: Allocator) !PasswdEntry {
+ // Failed passwd entry means we can't construct a macOS
+ // login command and falls back to POSIX behavior.
+ return error.Fail;
+ }
+ });
+
+ command_arena.deinit();
+
+ try testing.expectEqual(2, result.len);
+ try testing.expectEqualStrings(result[0], "foo");
+ try testing.expectEqualStrings(result[1], "bar baz");
+}
diff --git a/vendor/libghostty-vt/src/termio/Options.zig b/vendor/libghostty-vt/src/termio/Options.zig
new file mode 100644
index 00000000..a6bf8c4d
--- /dev/null
+++ b/vendor/libghostty-vt/src/termio/Options.zig
@@ -0,0 +1,41 @@
+//! The options that are used to configure a terminal IO implementation.
+
+const xev = @import("../global.zig").xev;
+const apprt = @import("../apprt.zig");
+const renderer = @import("../renderer.zig");
+const Config = @import("../config.zig").Config;
+const termio = @import("../termio.zig");
+
+/// All size metrics for the terminal.
+size: renderer.Size,
+
+/// The full app configuration. This is only available during initialization.
+/// The memory it points to is NOT stable after the init call so any values
+/// in here must be copied.
+full_config: *const Config,
+
+/// The derived configuration for this termio implementation.
+config: termio.Termio.DerivedConfig,
+
+/// The backend for termio that implements where reads/writes are sourced.
+backend: termio.Backend,
+
+/// The mailbox for the terminal. This is how messages are delivered.
+/// If you're using termio.Thread this MUST be "mailbox".
+mailbox: termio.Mailbox,
+
+/// The render state. The IO implementation can modify anything here. The
+/// surface thread will setup the initial "terminal" pointer but the IO impl
+/// is free to change that if that is useful (i.e. doing some sort of dual
+/// terminal implementation.)
+renderer_state: *renderer.State,
+
+/// A handle to wake up the renderer. This hints to the renderer that
+/// a repaint should happen.
+renderer_wakeup: xev.Async,
+
+/// The mailbox for renderer messages.
+renderer_mailbox: *renderer.Thread.Mailbox,
+
+/// The mailbox for sending the surface messages.
+surface_mailbox: apprt.surface.Mailbox,
diff --git a/vendor/libghostty-vt/src/termio/Termio.zig b/vendor/libghostty-vt/src/termio/Termio.zig
new file mode 100644
index 00000000..75ccb94b
--- /dev/null
+++ b/vendor/libghostty-vt/src/termio/Termio.zig
@@ -0,0 +1,773 @@
+//! Primary terminal IO ("termio") state. This maintains the terminal state,
+//! pty, subprocess, etc. This is flexible enough to be used in environments
+//! that don't have a pty and simply provides the input/output using raw
+//! bytes.
+pub const Termio = @This();
+
+const std = @import("std");
+const assert = @import("../quirks.zig").inlineAssert;
+const Allocator = std.mem.Allocator;
+const ArenaAllocator = std.heap.ArenaAllocator;
+const EnvMap = std.process.EnvMap;
+const posix = std.posix;
+const termio = @import("../termio.zig");
+const StreamHandler = @import("stream_handler.zig").StreamHandler;
+const terminalpkg = @import("../terminal/main.zig");
+const xev = @import("../global.zig").xev;
+const renderer = @import("../renderer.zig");
+const apprt = @import("../apprt.zig");
+const internal_os = @import("../os/main.zig");
+const windows = internal_os.windows;
+const configpkg = @import("../config.zig");
+const ProcessInfo = @import("../pty.zig").ProcessInfo;
+
+const log = std.log.scoped(.io_exec);
+
+/// Mutex state argument for queueMessage.
+pub const MutexState = enum { locked, unlocked };
+
+/// Allocator
+alloc: Allocator,
+
+/// This is the implementation responsible for io.
+backend: termio.Backend,
+
+/// The derived configuration for this termio implementation.
+config: DerivedConfig,
+
+/// The terminal emulator internal state. This is the abstract "terminal"
+/// that manages input, grid updating, etc. and is renderer-agnostic. It
+/// just stores internal state about a grid.
+terminal: terminalpkg.Terminal,
+
+/// The shared render state
+renderer_state: *renderer.State,
+
+/// A handle to wake up the renderer. This hints to the renderer that
+/// a repaint should happen.
+renderer_wakeup: xev.Async,
+
+/// The mailbox for notifying the renderer of things.
+renderer_mailbox: *renderer.Thread.Mailbox,
+
+/// The mailbox for communicating with the surface.
+surface_mailbox: apprt.surface.Mailbox,
+
+/// The cached size info
+size: renderer.Size,
+
+/// The mailbox implementation to use.
+mailbox: termio.Mailbox,
+
+/// The stream parser. This parses the stream of escape codes and so on
+/// from the child process and calls callbacks in the stream handler.
+terminal_stream: StreamHandler.Stream,
+
+/// Last time the cursor was reset. This is used to prevent message
+/// flooding with cursor resets.
+last_cursor_reset: ?std.time.Instant = null,
+
+/// State we have for thread enter. This may be null if we don't need
+/// to keep track of any state or if its already been freed.
+thread_enter_state: ?*ThreadEnterState = null,
+
+/// The state we need to keep around only until we enter the IO
+/// thread. Then we can throw it all away.
+const ThreadEnterState = struct {
+ arena: ArenaAllocator,
+
+ /// Initial input to send to the subprocess after starting. This
+ /// memory is freed once the subprocess start is attempted, even
+ /// if it fails, because Exec only starts once.
+ input: configpkg.io.RepeatableReadableIO,
+
+ pub fn create(
+ alloc: Allocator,
+ config: *const configpkg.Config,
+ ) !?*ThreadEnterState {
+ // If we have no input then we have no thread enter state
+ if (config.input.list.items.len == 0) return null;
+
+ // Create our arena allocator
+ var arena = ArenaAllocator.init(alloc);
+ errdefer arena.deinit();
+ const arena_alloc = arena.allocator();
+
+ // Allocate our ThreadEnterState
+ const ptr = try arena_alloc.create(ThreadEnterState);
+
+ // Copy the input from the config
+ const input = try config.input.cloneParsed(arena_alloc);
+
+ // Return the initialized state
+ ptr.* = .{
+ .arena = arena,
+ .input = input,
+ };
+ return ptr;
+ }
+
+ pub fn destroy(self: *ThreadEnterState) void {
+ self.arena.deinit();
+ }
+
+ /// Prepare the inputs for use. Allocations happen on the arena.
+ pub fn prepareInput(
+ self: *ThreadEnterState,
+ ) (Allocator.Error || error{InputNotFound})![]const Input {
+ const alloc = self.arena.allocator();
+
+ var input = try alloc.alloc(
+ Input,
+ self.input.list.items.len,
+ );
+ for (self.input.list.items, 0..) |item, i| {
+ input[i] = switch (item) {
+ .raw => |v| .{ .string = try alloc.dupe(u8, v) },
+ .path => |path| file: {
+ const f = std.fs.cwd().openFile(
+ path,
+ .{},
+ ) catch |err| {
+ log.warn("failed to open input file={s} err={}", .{
+ path,
+ err,
+ });
+ return error.InputNotFound;
+ };
+
+ break :file .{ .file = f };
+ },
+ };
+ }
+
+ return input;
+ }
+
+ const Input = union(enum) {
+ string: []const u8,
+ file: std.fs.File,
+ };
+};
+
+/// The configuration for this IO that is derived from the main
+/// configuration. This must be exported so that we don't need to
+/// pass around Config pointers which makes memory management a pain.
+pub const DerivedConfig = struct {
+ arena: ArenaAllocator,
+
+ palette: terminalpkg.color.Palette,
+ image_storage_limit: usize,
+ cursor_style: terminalpkg.CursorStyle,
+ cursor_blink: ?bool,
+ cursor_color: ?configpkg.Config.TerminalColor,
+ foreground: configpkg.Config.Color,
+ background: configpkg.Config.Color,
+ osc_color_report_format: configpkg.Config.OSCColorReportFormat,
+ clipboard_write: configpkg.ClipboardAccess,
+ enquiry_response: []const u8,
+ conditional_state: configpkg.ConditionalState,
+
+ pub fn init(
+ alloc_gpa: Allocator,
+ config: *const configpkg.Config,
+ ) !DerivedConfig {
+ var arena = ArenaAllocator.init(alloc_gpa);
+ errdefer arena.deinit();
+ const alloc = arena.allocator();
+
+ const palette: terminalpkg.color.Palette = palette: {
+ if (config.@"palette-generate") generate: {
+ if (config.palette.mask.findFirstSet() == null) {
+ // If the user didn't set any values manually, then
+ // we're using the default palette and we don't need
+ // to apply the generation code to it.
+ break :generate;
+ }
+
+ break :palette terminalpkg.color.generate256Color(config.palette.value, config.palette.mask, config.background.toTerminalRGB(), config.foreground.toTerminalRGB(), config.@"palette-harmonious");
+ }
+
+ break :palette config.palette.value;
+ };
+
+ return .{
+ .palette = palette,
+ .image_storage_limit = config.@"image-storage-limit",
+ .cursor_style = config.@"cursor-style",
+ .cursor_blink = config.@"cursor-style-blink",
+ .cursor_color = config.@"cursor-color",
+ .foreground = config.foreground,
+ .background = config.background,
+ .osc_color_report_format = config.@"osc-color-report-format",
+ .clipboard_write = config.@"clipboard-write",
+ .enquiry_response = try alloc.dupe(u8, config.@"enquiry-response"),
+ .conditional_state = config._conditional_state,
+
+ // This has to be last so that we copy AFTER the arena allocations
+ // above happen (Zig assigns in order).
+ .arena = arena,
+ };
+ }
+
+ pub fn deinit(self: *DerivedConfig) void {
+ self.arena.deinit();
+ }
+};
+
+/// Initialize the termio state.
+///
+/// This will also start the child process if the termio is configured
+/// to run a child process.
+pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void {
+ // The default terminal modes based on our config.
+ const default_modes: terminalpkg.ModePacked = modes: {
+ var modes: terminalpkg.ModePacked = .{};
+
+ // Setup our initial grapheme cluster support if enabled. We use a
+ // switch to ensure we get a compiler error if more cases are added.
+ switch (opts.full_config.@"grapheme-width-method") {
+ .unicode => modes.grapheme_cluster = true,
+ .legacy => {},
+ }
+
+ // Set default cursor blink settings
+ modes.cursor_blinking = opts.config.cursor_blink orelse true;
+
+ break :modes modes;
+ };
+
+ // Create our terminal
+ var term = try terminalpkg.Terminal.init(alloc, opts: {
+ const grid_size = opts.size.grid();
+ break :opts .{
+ .cols = grid_size.columns,
+ .rows = grid_size.rows,
+ .max_scrollback = opts.full_config.@"scrollback-limit",
+ .default_modes = default_modes,
+ .colors = .{
+ .background = .init(opts.config.background.toTerminalRGB()),
+ .foreground = .init(opts.config.foreground.toTerminalRGB()),
+ .cursor = cursor: {
+ const color = opts.config.cursor_color orelse break :cursor .unset;
+ const rgb = color.toTerminalRGB() orelse break :cursor .unset;
+ break :cursor .init(rgb);
+ },
+ .palette = .init(opts.config.palette),
+ },
+ };
+ });
+ errdefer term.deinit(alloc);
+
+ // Set the image size limits
+ var it = term.screens.all.iterator();
+ while (it.next()) |entry| {
+ const screen: *terminalpkg.Screen = entry.value.*;
+ try screen.kitty_images.setLimit(
+ alloc,
+ screen,
+ opts.config.image_storage_limit,
+ );
+ }
+
+ // Set our default cursor style
+ term.screens.active.cursor.cursor_style = opts.config.cursor_style;
+
+ // Setup our terminal size in pixels for certain requests.
+ term.width_px = term.cols * opts.size.cell.width;
+ term.height_px = term.rows * opts.size.cell.height;
+
+ // Setup our backend.
+ var backend = opts.backend;
+ backend.initTerminal(&term);
+
+ // Create our stream handler. This points to memory in self so it
+ // isn't safe to use until self.* is set.
+ const handler: StreamHandler = .{
+ .alloc = alloc,
+ .termio_mailbox = &self.mailbox,
+ .surface_mailbox = opts.surface_mailbox,
+ .renderer_state = opts.renderer_state,
+ .renderer_wakeup = opts.renderer_wakeup,
+ .renderer_mailbox = opts.renderer_mailbox,
+ .size = &self.size,
+ .terminal = &self.terminal,
+ .osc_color_report_format = opts.config.osc_color_report_format,
+ .clipboard_write = opts.config.clipboard_write,
+ .enquiry_response = opts.config.enquiry_response,
+ .default_cursor_style = opts.config.cursor_style,
+ .default_cursor_blink = opts.config.cursor_blink,
+ };
+
+ const thread_enter_state = try ThreadEnterState.create(
+ alloc,
+ opts.full_config,
+ );
+
+ self.* = .{
+ .alloc = alloc,
+ .terminal = term,
+ .config = opts.config,
+ .renderer_state = opts.renderer_state,
+ .renderer_wakeup = opts.renderer_wakeup,
+ .renderer_mailbox = opts.renderer_mailbox,
+ .surface_mailbox = opts.surface_mailbox,
+ .size = opts.size,
+ .backend = backend,
+ .mailbox = opts.mailbox,
+ .terminal_stream = .initAlloc(alloc, handler),
+ .thread_enter_state = thread_enter_state,
+ };
+}
+
+pub fn deinit(self: *Termio) void {
+ self.backend.deinit();
+ self.terminal.deinit(self.alloc);
+ self.config.deinit();
+ self.mailbox.deinit(self.alloc);
+
+ // Clear any StreamHandler state
+ self.terminal_stream.deinit();
+
+ // Clear any initial state if we have it
+ if (self.thread_enter_state) |v| v.destroy();
+}
+
+pub fn threadEnter(
+ self: *Termio,
+ thread: *termio.Thread,
+ data: *ThreadData,
+) !void {
+ // Always free our thread enter state when we're done.
+ defer if (self.thread_enter_state) |v| {
+ v.destroy();
+ self.thread_enter_state = null;
+ };
+
+ // If we have thread enter state then we're going to validate
+ // and set that all up now so that we can error before we actually
+ // start the command and pty.
+ const inputs: ?[]const ThreadEnterState.Input = if (self.thread_enter_state) |v|
+ try v.prepareInput()
+ else
+ null;
+
+ data.* = .{
+ .alloc = self.alloc,
+ .loop = &thread.loop,
+ .renderer_state = self.renderer_state,
+ .surface_mailbox = self.surface_mailbox,
+ .mailbox = &self.mailbox,
+ .backend = undefined, // Backend must replace this on threadEnter
+ };
+
+ // Setup our backend
+ try self.backend.threadEnter(self.alloc, self, data);
+ errdefer self.backend.threadExit(data);
+
+ // If we have inputs, then queue them all up.
+ for (inputs orelse &.{}) |input| switch (input) {
+ .string => |v| self.queueWrite(data, v, false) catch |err| {
+ log.warn("failed to queue input string err={}", .{err});
+ return error.InputFailed;
+ },
+ .file => |f| self.queueWrite(
+ data,
+ f.readToEndAlloc(
+ self.alloc,
+ 10 * 1024 * 1024, // 10 MiB max
+ ) catch |err| {
+ log.warn("failed to read input file err={}", .{err});
+ return error.InputFailed;
+ },
+ false,
+ ) catch |err| {
+ log.warn("failed to queue input file err={}", .{err});
+ return error.InputFailed;
+ },
+ };
+}
+
+pub fn threadExit(self: *Termio, data: *ThreadData) void {
+ self.backend.threadExit(data);
+}
+
+/// Send a message to the mailbox. Depending on the mailbox type in use
+/// this may process now or it may just enqueue and process later.
+///
+/// This will also notify the mailbox thread to process the message. If
+/// you're sending a lot of messages, it may be more efficient to use
+/// the mailbox directly and then call notify separately.
+pub fn queueMessage(
+ self: *Termio,
+ msg: termio.Message,
+ mutex: MutexState,
+) void {
+ self.mailbox.send(msg, switch (mutex) {
+ .locked => self.renderer_state.mutex,
+ .unlocked => null,
+ });
+ self.mailbox.notify();
+}
+
+/// Queue a write directly to the pty.
+///
+/// If you're using termio.Thread, this must ONLY be called from the
+/// mailbox thread. If you're not on the thread, use queueMessage with
+/// mailbox messages instead.
+///
+/// If you're not using termio.Thread, this is not threadsafe.
+pub inline fn queueWrite(
+ self: *Termio,
+ td: *ThreadData,
+ data: []const u8,
+ linefeed: bool,
+) !void {
+ try self.backend.queueWrite(self.alloc, td, data, linefeed);
+}
+
+/// Update the configuration.
+pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !void {
+ // The remainder of this function is modifying terminal state or
+ // the read thread data, all of which requires holding the renderer
+ // state lock.
+ self.renderer_state.mutex.lock();
+ defer self.renderer_state.mutex.unlock();
+
+ // Deinit our old config. We do this in the lock because the
+ // stream handler may be referencing the old config (i.e. enquiry resp)
+ self.config.deinit();
+ self.config = config.*;
+
+ // Update our stream handler. The stream handler uses the same
+ // renderer mutex so this is safe to do despite being executed
+ // from another thread.
+ self.terminal_stream.handler.changeConfig(&self.config);
+ td.backend.changeConfig(&self.config);
+
+ // Update the configuration that we know about.
+ //
+ // Specific things we don't update:
+ // - command, working-directory: we never restart the underlying
+ // process so we don't care or need to know about these.
+
+ // Update the default palette.
+ self.terminal.colors.palette.changeDefault(config.palette);
+ self.terminal.flags.dirty.palette = true;
+
+ // Update all our other colors
+ self.terminal.colors.background.default = config.background.toTerminalRGB();
+ self.terminal.colors.foreground.default = config.foreground.toTerminalRGB();
+ self.terminal.colors.cursor.default = cursor: {
+ const color = config.cursor_color orelse break :cursor null;
+ break :cursor color.toTerminalRGB() orelse break :cursor null;
+ };
+
+ // Set the image size limits
+ var it = self.terminal.screens.all.iterator();
+ while (it.next()) |entry| {
+ const screen: *terminalpkg.Screen = entry.value.*;
+ try screen.kitty_images.setLimit(
+ self.alloc,
+ screen,
+ config.image_storage_limit,
+ );
+ }
+}
+
+/// Resize the terminal.
+pub fn resize(
+ self: *Termio,
+ td: *ThreadData,
+ size: renderer.Size,
+) !void {
+ self.size = size;
+ const grid_size = size.grid();
+
+ // Update the size of our pty.
+ try self.backend.resize(grid_size, size.terminal());
+
+ // Enter the critical area that we want to keep small
+ {
+ self.renderer_state.mutex.lock();
+ defer self.renderer_state.mutex.unlock();
+
+ // Update the size of our terminal state
+ try self.terminal.resize(
+ self.alloc,
+ grid_size.columns,
+ grid_size.rows,
+ );
+
+ // Update our pixel sizes
+ self.terminal.width_px = grid_size.columns * self.size.cell.width;
+ self.terminal.height_px = grid_size.rows * self.size.cell.height;
+
+ // Disable synchronized output mode so that we show changes
+ // immediately for a resize. This is allowed by the spec.
+ self.terminal.modes.set(.synchronized_output, false);
+
+ // If we have size reporting enabled we need to send a report.
+ if (self.terminal.modes.get(.in_band_size_reports)) {
+ try self.sizeReportLocked(td, .mode_2048);
+ }
+ }
+
+ // Mail the renderer so that it can update the GPU and re-render
+ _ = self.renderer_mailbox.push(.{ .resize = size }, .{ .forever = {} });
+ self.renderer_wakeup.notify() catch {};
+}
+
+/// Make a size report.
+pub fn sizeReport(self: *Termio, td: *ThreadData, style: termio.Message.SizeReport) !void {
+ self.renderer_state.mutex.lock();
+ defer self.renderer_state.mutex.unlock();
+ try self.sizeReportLocked(td, style);
+}
+
+fn sizeReportLocked(self: *Termio, td: *ThreadData, style: termio.Message.SizeReport) !void {
+ const grid_size = self.size.grid();
+ const report_size: terminalpkg.size_report.Size = .{
+ .rows = grid_size.rows,
+ .columns = grid_size.columns,
+ .cell_width = self.size.cell.width,
+ .cell_height = self.size.cell.height,
+ };
+
+ // 1024 bytes should be enough for size report since report
+ // in columns and pixels.
+ var buf: [1024]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ try terminalpkg.size_report.encode(
+ &writer,
+ style,
+ report_size,
+ );
+
+ try self.queueWrite(td, writer.buffered(), false);
+}
+
+/// Reset the synchronized output mode. This is usually called by timer
+/// expiration from the termio thread.
+pub fn resetSynchronizedOutput(self: *Termio) void {
+ self.renderer_state.mutex.lock();
+ defer self.renderer_state.mutex.unlock();
+ self.terminal.modes.set(.synchronized_output, false);
+ self.renderer_wakeup.notify() catch {};
+}
+
+/// Clear the screen.
+pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void {
+ {
+ self.renderer_state.mutex.lock();
+ defer self.renderer_state.mutex.unlock();
+
+ // If we're on the alternate screen, we do not clear. Since this is an
+ // emulator-level screen clear, this messes up the running programs
+ // knowledge of where the cursor is and causes rendering issues. So,
+ // for alt screen, we do nothing.
+ if (self.terminal.screens.active_key == .alternate) return;
+
+ // Clear our selection
+ self.terminal.screens.active.clearSelection();
+
+ // Clear our scrollback
+ if (history) self.terminal.eraseDisplay(.scrollback, false);
+
+ // If we're not at a prompt, we just delete above the cursor.
+ if (!self.terminal.cursorIsAtPrompt()) {
+ if (self.terminal.screens.active.cursor.y > 0) {
+ self.terminal.screens.active.eraseActive(
+ self.terminal.screens.active.cursor.y - 1,
+ );
+ }
+
+ // Clear all Kitty graphics state for this screen. This copies
+ // Kitty's behavior when Cmd+K deletes all Kitty graphics. I
+ // didn't spend time researching whether it only deletes Kitty
+ // graphics that are placed above the cursor or if it deletes
+ // all of them. We delete all of them for now but if this behavior
+ // isn't fully correct we should fix this later.
+ self.terminal.screens.active.kitty_images.delete(
+ self.terminal.screens.active.alloc,
+ &self.terminal,
+ .{ .all = true },
+ );
+
+ return;
+ }
+
+ // At a prompt, we want to first fully clear the screen, and then after
+ // send a FF (0x0C) to the shell so that it can repaint the screen.
+ // Mark the current row as a not a prompt so we can properly
+ // clear the full screen in the next eraseDisplay call.
+ // TODO: fix this
+ // self.terminal.markSemanticPrompt(.command);
+ // assert(!self.terminal.cursorIsAtPrompt());
+ self.terminal.eraseDisplay(.complete, false);
+ }
+
+ // If we reached here it means we're at a prompt, so we send a form-feed.
+ try self.queueWrite(td, &[_]u8{0x0C}, false);
+}
+
+/// Scroll the viewport
+pub fn scrollViewport(
+ self: *Termio,
+ scroll: terminalpkg.Terminal.ScrollViewport,
+) void {
+ self.renderer_state.mutex.lock();
+ defer self.renderer_state.mutex.unlock();
+ self.terminal.scrollViewport(scroll);
+}
+
+/// Jump the viewport to the prompt.
+pub fn jumpToPrompt(self: *Termio, delta: isize) !void {
+ {
+ self.renderer_state.mutex.lock();
+ defer self.renderer_state.mutex.unlock();
+ self.terminal.screens.active.scroll(.{ .delta_prompt = delta });
+ }
+
+ try self.renderer_wakeup.notify();
+}
+
+/// Called when focus is gained or lost (when focus events are enabled)
+pub fn focusGained(self: *Termio, td: *ThreadData, focused: bool) !void {
+ self.renderer_state.mutex.lock();
+ const focus_event = self.renderer_state.terminal.modes.get(.focus_event);
+ self.renderer_state.mutex.unlock();
+
+ // If we have focus events enabled, we send the focus event.
+ if (focus_event) {
+ var buf: [terminalpkg.focus.max_encode_size]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ terminalpkg.focus.encode(&writer, if (focused) .gained else .lost) catch |err| {
+ log.err("error encoding focus event err={}", .{err});
+ return;
+ };
+ try self.queueWrite(td, writer.buffered(), false);
+ }
+
+ // We always notify our backend of focus changes.
+ try self.backend.focusGained(td, focused);
+}
+
+/// Process output from the pty. This is the manual API that users can
+/// call with pty data but it is also called by the read thread when using
+/// an exec subprocess.
+pub fn processOutput(self: *Termio, buf: []const u8) void {
+ // We are modifying terminal state from here on out and we need
+ // the lock to grab our read data.
+ self.renderer_state.mutex.lock();
+ defer self.renderer_state.mutex.unlock();
+ self.processOutputLocked(buf);
+}
+
+/// Process output from readdata but the lock is already held.
+fn processOutputLocked(self: *Termio, buf: []const u8) void {
+ // Schedule a render. We can call this first because we have the lock.
+ self.terminal_stream.handler.queueRender() catch unreachable;
+
+ // Whenever a character is typed, we ensure the cursor is in the
+ // non-blink state so it is rendered if visible. If we're under
+ // HEAVY read load, we don't want to send a ton of these so we
+ // use a timer under the covers
+ if (std.time.Instant.now()) |now| cursor_reset: {
+ if (self.last_cursor_reset) |last| {
+ if (now.since(last) <= (500 * std.time.ns_per_ms)) {
+ break :cursor_reset;
+ }
+ }
+
+ self.last_cursor_reset = now;
+ _ = self.renderer_mailbox.push(.{
+ .reset_cursor_blink = {},
+ }, .{ .instant = {} });
+ } else |err| {
+ log.warn("failed to get current time err={}", .{err});
+ }
+
+ // If we have an inspector, we enter SLOW MODE because we need to
+ // process a byte at a time alternating between the inspector handler
+ // and the termio handler. This is very slow compared to our optimizations
+ // below but at least users only pay for it if they're using the inspector.
+ if (self.renderer_state.inspector) |insp| {
+ for (buf, 0..) |byte, i| {
+ insp.recordPtyRead(
+ self.alloc,
+ &self.terminal,
+ buf[i .. i + 1],
+ ) catch |err| {
+ log.err("error recording pty read in inspector err={}", .{err});
+ };
+
+ self.terminal_stream.next(byte);
+ }
+ } else {
+ self.terminal_stream.nextSlice(buf);
+ }
+
+ // If our stream handling caused messages to be sent to the mailbox
+ // thread, then we need to wake it up so that it processes them.
+ if (self.terminal_stream.handler.termio_messaged) {
+ self.terminal_stream.handler.termio_messaged = false;
+ self.mailbox.notify();
+ }
+}
+
+/// Sends a DSR response for the current color scheme to the pty.
+pub fn colorSchemeReport(self: *Termio, td: *ThreadData, force: bool) !void {
+ self.renderer_state.mutex.lock();
+ defer self.renderer_state.mutex.unlock();
+
+ try self.colorSchemeReportLocked(td, force);
+}
+
+pub fn colorSchemeReportLocked(self: *Termio, td: *ThreadData, force: bool) !void {
+ if (!force and !self.renderer_state.terminal.modes.get(.report_color_scheme)) {
+ return;
+ }
+ const output = switch (self.config.conditional_state.theme) {
+ .light => "\x1B[?997;2n",
+ .dark => "\x1B[?997;1n",
+ };
+ try self.queueWrite(td, output, false);
+}
+
+/// ThreadData is the data created and stored in the termio thread
+/// when the thread is started and destroyed when the thread is
+/// stopped.
+///
+/// All of the fields in this struct should only be read/written by
+/// the termio thread. As such, a lock is not necessary.
+pub const ThreadData = struct {
+ /// Allocator used for the event data
+ alloc: Allocator,
+
+ /// The event loop associated with this thread. This is owned by
+ /// the Thread but we have a pointer so we can queue new work to it.
+ loop: *xev.Loop,
+
+ /// The shared render state
+ renderer_state: *renderer.State,
+
+ /// Mailboxes for different threads
+ surface_mailbox: apprt.surface.Mailbox,
+
+ /// Data associated with the backend implementation (i.e. pty/exec state)
+ backend: termio.backend.ThreadData,
+ mailbox: *termio.Mailbox,
+
+ pub fn deinit(self: *ThreadData) void {
+ self.backend.deinit(self.alloc);
+ self.* = undefined;
+ }
+};
+
+/// Get information about the process(es) attached to the backend. Returns
+/// `null` if there was an error getting the information or the information is
+/// not available on a particular platform.
+pub fn getProcessInfo(self: *Termio, comptime info: ProcessInfo) ?ProcessInfo.Type(info) {
+ return self.backend.getProcessInfo(info);
+}
diff --git a/vendor/libghostty-vt/src/termio/Thread.zig b/vendor/libghostty-vt/src/termio/Thread.zig
new file mode 100644
index 00000000..ce4c1f4a
--- /dev/null
+++ b/vendor/libghostty-vt/src/termio/Thread.zig
@@ -0,0 +1,531 @@
+//! Represents the "writer" thread for terminal IO. The reader side is
+//! handled by the Termio struct itself and dependent on the underlying
+//! implementation (i.e. if its a pty, manual, etc.).
+//!
+//! The writer thread does handle writing bytes to the pty but also handles
+//! different events such as starting synchronized output, changing some
+//! modes (like linefeed), etc. The goal is to offload as much from the
+//! reader thread as possible since it is the hot path in parsing VT
+//! sequences and updating terminal state.
+//!
+//! This thread state can only be used by one thread at a time.
+pub const Thread = @This();
+
+const std = @import("std");
+const ArenaAllocator = std.heap.ArenaAllocator;
+const builtin = @import("builtin");
+const xev = @import("../global.zig").xev;
+const crash = @import("../crash/main.zig");
+const internal_os = @import("../os/main.zig");
+const termio = @import("../termio.zig");
+const renderer = @import("../renderer.zig");
+
+const Allocator = std.mem.Allocator;
+const log = std.log.scoped(.io_thread);
+
+/// This stores the information that is coalesced.
+const Coalesce = struct {
+ /// The number of milliseconds to coalesce certain messages like resize for.
+ /// Not all message types are coalesced.
+ const min_ms = 25;
+
+ resize: ?renderer.Size = null,
+};
+
+/// The number of milliseconds before we reset the synchronized output flag
+/// if the running program hasn't already.
+const sync_reset_ms = 1000;
+
+/// The number of milliseconds between each movement during selection scrolling.
+const selection_scroll_ms = 15;
+
+/// Allocator used for some state
+alloc: std.mem.Allocator,
+
+/// The main event loop for the thread. The user data of this loop
+/// is always the allocator used to create the loop. This is a convenience
+/// so that users of the loop always have an allocator.
+loop: xev.Loop,
+
+/// The completion to use for the wakeup async handle that is present
+/// on the termio.Writer.
+wakeup_c: xev.Completion = .{},
+
+/// This can be used to stop the thread on the next loop iteration.
+stop: xev.Async,
+stop_c: xev.Completion = .{},
+
+/// This is used for timer-based selection scrolling.
+scroll: xev.Timer,
+scroll_c: xev.Completion = .{},
+scroll_active: bool = false,
+
+/// This is used to coalesce resize events.
+coalesce: xev.Timer,
+coalesce_c: xev.Completion = .{},
+coalesce_cancel_c: xev.Completion = .{},
+coalesce_data: Coalesce = .{},
+
+/// This timer is used to reset synchronized output modes so that
+/// the terminal doesn't freeze with a bad actor.
+sync_reset: xev.Timer,
+sync_reset_c: xev.Completion = .{},
+sync_reset_cancel_c: xev.Completion = .{},
+
+flags: packed struct {
+ /// This is set to true only when an abnormal exit is detected. It
+ /// tells our mailbox system to drain and ignore all messages.
+ drain: bool = false,
+
+ /// True if linefeed mode is enabled. This is duplicated here so that the
+ /// write thread doesn't need to grab a lock to check this on every write.
+ linefeed_mode: bool = false,
+
+ /// This is true when the inspector is active.
+ has_inspector: bool = false,
+} = .{},
+
+/// Initialize the thread. This does not START the thread. This only sets
+/// up all the internal state necessary prior to starting the thread. It
+/// is up to the caller to start the thread with the threadMain entrypoint.
+pub fn init(
+ alloc: Allocator,
+) !Thread {
+ // Create our event loop.
+ var loop = try xev.Loop.init(.{});
+ errdefer loop.deinit();
+
+ // This async handle is used to stop the loop and force the thread to end.
+ var stop_h = try xev.Async.init();
+ errdefer stop_h.deinit();
+
+ // This timer is used for selection scrolling.
+ var scroll_h = try xev.Timer.init();
+ errdefer scroll_h.deinit();
+
+ // This timer is used to coalesce resize events.
+ var coalesce_h = try xev.Timer.init();
+ errdefer coalesce_h.deinit();
+
+ // This timer is used to reset synchronized output modes.
+ var sync_reset_h = try xev.Timer.init();
+ errdefer sync_reset_h.deinit();
+
+ return Thread{
+ .alloc = alloc,
+ .loop = loop,
+ .stop = stop_h,
+ .scroll = scroll_h,
+ .coalesce = coalesce_h,
+ .sync_reset = sync_reset_h,
+ };
+}
+
+/// Clean up the thread. This is only safe to call once the thread
+/// completes executing; the caller must join prior to this.
+pub fn deinit(self: *Thread) void {
+ self.scroll.deinit();
+ self.coalesce.deinit();
+ self.sync_reset.deinit();
+ self.stop.deinit();
+ self.loop.deinit();
+}
+
+/// The main entrypoint for the thread.
+pub fn threadMain(self: *Thread, io: *termio.Termio) void {
+ // Call child function so we can use errors...
+ self.threadMain_(io) catch |err| {
+ log.warn("error in io thread err={}", .{err});
+
+ // Use an arena to simplify memory management below
+ var arena = ArenaAllocator.init(self.alloc);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ // If there is an error, we replace our terminal screen with
+ // the error message. It might be better in the future to send
+ // the error to the surface thread and let the apprt deal with it
+ // in some way but this works for now. Without this, the user would
+ // just see a blank terminal window.
+ io.renderer_state.mutex.lock();
+ defer io.renderer_state.mutex.unlock();
+ const t = io.renderer_state.terminal;
+
+ // Hide the cursor
+ t.modes.set(.cursor_visible, false);
+
+ // This is weird but just ensures that no matter what our underlying
+ // implementation we have the errors below. For example, Windows doesn't
+ // have "OpenptyFailed".
+ const Err = @TypeOf(err) || error{
+ OpenptyFailed,
+ InputNotFound,
+ InputFailed,
+ };
+
+ switch (@as(Err, @errorCast(err))) {
+ error.OpenptyFailed => {
+ const str =
+ \\Your system cannot allocate any more pty devices.
+ \\
+ \\Ghostty requires a pty device to launch a new terminal.
+ \\This error is usually due to having too many terminal
+ \\windows open or having another program that is using too
+ \\many pty devices.
+ \\
+ \\Please free up some pty devices and try again.
+ ;
+
+ t.eraseDisplay(.complete, false);
+ t.printString(str) catch {};
+ },
+
+ error.InputNotFound,
+ error.InputFailed,
+ => {
+ const str =
+ \\A configured `input` path was not found, was not readable,
+ \\was too large, or the underlying pty failed to accept
+ \\the write.
+ \\
+ \\Ghostty can't continue since it can't guarantee that
+ \\initial terminal state will be as desired. Please review
+ \\the value of `input` in your configuration file and
+ \\ensure that all the path values exist and are readable.
+ ;
+
+ t.eraseDisplay(.complete, false);
+ t.printString(str) catch {};
+ },
+
+ else => {
+ const str = std.fmt.allocPrint(
+ alloc,
+ \\error starting IO thread: {}
+ \\
+ \\The underlying shell or command was unable to be started.
+ \\This error is usually due to exhausting a system resource.
+ \\If this looks like a bug, please report it.
+ \\
+ \\This terminal is non-functional. Please close it and try again.
+ ,
+ .{err},
+ ) catch
+ \\Out of memory. This terminal is non-functional. Please close it and try again.
+ ;
+
+ t.eraseDisplay(.complete, false);
+ t.printString(str) catch {};
+ },
+ }
+ };
+
+ // If our loop is not stopped, then we need to keep running so that
+ // messages are drained and we can wait for the surface to send a stop
+ // message.
+ if (!self.loop.stopped()) {
+ log.warn("abrupt io thread exit detected, starting xev to drain mailbox", .{});
+ defer log.debug("io thread fully exiting after abnormal failure", .{});
+ self.flags.drain = true;
+ self.loop.run(.until_done) catch |err| {
+ log.err("failed to start xev loop for draining err={}", .{err});
+ };
+ }
+}
+
+fn threadMain_(self: *Thread, io: *termio.Termio) !void {
+ defer log.debug("IO thread exited", .{});
+
+ // Right now, on Darwin, `std.Thread.setName` can only name the current
+ // thread, and we have no way to get the current thread from within it,
+ // so instead we use this code to name the thread instead.
+ if (builtin.os.tag.isDarwin()) {
+ internal_os.macos.pthread_setname_np(&"io".*);
+ }
+
+ // Setup our crash metadata
+ crash.sentry.thread_state = .{
+ .type = .io,
+ .surface = io.surface_mailbox.surface,
+ };
+ defer crash.sentry.thread_state = null;
+
+ // Get the mailbox. This must be an SPSC mailbox for threading.
+ const mailbox = switch (io.mailbox) {
+ .spsc => |*v| v,
+ // else => return error.TermioUnsupportedMailbox,
+ };
+
+ // This is the data sent to xev callbacks. We want a pointer to both
+ // ourselves and the thread data so we can thread that through (pun intended).
+ var cb: CallbackData = .{ .self = self, .io = io };
+
+ // Run our thread start/end callbacks. This allows the implementation
+ // to hook into the event loop as needed. The thread data is created
+ // on the stack here so that it has a stable pointer throughout the
+ // lifetime of the thread.
+ try io.threadEnter(self, &cb.data);
+ defer cb.data.deinit();
+ defer io.threadExit(&cb.data);
+
+ // Start the async handlers.
+ mailbox.wakeup.wait(&self.loop, &self.wakeup_c, CallbackData, &cb, wakeupCallback);
+ self.stop.wait(&self.loop, &self.stop_c, CallbackData, &cb, stopCallback);
+
+ // Run
+ log.debug("starting IO thread", .{});
+ defer log.debug("starting IO thread shutdown", .{});
+ try self.loop.run(.until_done);
+}
+
+/// This is the data passed to xev callbacks on the thread.
+const CallbackData = struct {
+ self: *Thread,
+ io: *termio.Termio,
+ data: termio.Termio.ThreadData = undefined,
+};
+
+/// Drain the mailbox, handling all the messages in our terminal implementation.
+fn drainMailbox(
+ self: *Thread,
+ cb: *CallbackData,
+) !void {
+ // We assert when starting the thread that this is the state
+ const mailbox = cb.io.mailbox.spsc.queue;
+ const io = cb.io;
+ const data = &cb.data;
+
+ // If we're draining, we just drain the mailbox and return.
+ if (self.flags.drain) {
+ while (mailbox.pop()) |_| {}
+ return;
+ }
+
+ // This holds the mailbox lock for the duration of the drain. The
+ // expectation is that all our message handlers will be non-blocking
+ // ENOUGH to not mess up throughput on producers.
+ var redraw: bool = false;
+ while (mailbox.pop()) |message| {
+ // If we have a message we always redraw
+ redraw = true;
+
+ log.debug("mailbox message={s}", .{@tagName(message)});
+ switch (message) {
+ .color_scheme_report => |v| try io.colorSchemeReport(data, v.force),
+ .crash => @panic("crash request, crashing intentionally"),
+ .change_config => |config| {
+ defer config.alloc.destroy(config.ptr);
+ try io.changeConfig(data, config.ptr);
+ },
+ .inspector => |v| self.flags.has_inspector = v,
+ .resize => |v| self.handleResize(cb, v),
+ .size_report => |v| try io.sizeReport(data, v),
+ .clear_screen => |v| try io.clearScreen(data, v.history),
+ .scroll_viewport => |v| io.scrollViewport(v),
+ .selection_scroll => |v| {
+ if (v) {
+ self.startScrollTimer(cb);
+ } else {
+ self.stopScrollTimer();
+ }
+ },
+ .jump_to_prompt => |v| try io.jumpToPrompt(v),
+ .start_synchronized_output => self.startSynchronizedOutput(cb),
+ .linefeed_mode => |v| self.flags.linefeed_mode = v,
+ .focused => |v| try io.focusGained(data, v),
+ .write_small => |v| try io.queueWrite(
+ data,
+ v.data[0..v.len],
+ self.flags.linefeed_mode,
+ ),
+ .write_stable => |v| try io.queueWrite(
+ data,
+ v,
+ self.flags.linefeed_mode,
+ ),
+ .write_alloc => |v| {
+ defer v.alloc.free(v.data);
+ try io.queueWrite(
+ data,
+ v.data,
+ self.flags.linefeed_mode,
+ );
+ },
+ }
+ }
+
+ // Trigger a redraw after we've drained so we don't waste cyces
+ // messaging a redraw.
+ if (redraw) {
+ try io.renderer_wakeup.notify();
+ }
+}
+
+fn startSynchronizedOutput(self: *Thread, cb: *CallbackData) void {
+ self.sync_reset.reset(
+ &self.loop,
+ &self.sync_reset_c,
+ &self.sync_reset_cancel_c,
+ sync_reset_ms,
+ CallbackData,
+ cb,
+ syncResetCallback,
+ );
+}
+
+fn handleResize(self: *Thread, cb: *CallbackData, resize: renderer.Size) void {
+ self.coalesce_data.resize = resize;
+
+ // If the timer is already active we just return. In the future we want
+ // to reset the timer up to a maximum wait time but for now this ensures
+ // relatively smooth resizing.
+ if (self.coalesce_c.state() == .active) return;
+
+ self.coalesce.reset(
+ &self.loop,
+ &self.coalesce_c,
+ &self.coalesce_cancel_c,
+ Coalesce.min_ms,
+ CallbackData,
+ cb,
+ coalesceCallback,
+ );
+}
+
+fn syncResetCallback(
+ cb_: ?*CallbackData,
+ _: *xev.Loop,
+ _: *xev.Completion,
+ r: xev.Timer.RunError!void,
+) xev.CallbackAction {
+ _ = r catch |err| switch (err) {
+ error.Canceled => {},
+ else => {
+ log.warn("error during sync reset callback err={}", .{err});
+ return .disarm;
+ },
+ };
+
+ const cb = cb_ orelse return .disarm;
+ cb.io.resetSynchronizedOutput();
+ return .disarm;
+}
+
+fn coalesceCallback(
+ cb_: ?*CallbackData,
+ _: *xev.Loop,
+ _: *xev.Completion,
+ r: xev.Timer.RunError!void,
+) xev.CallbackAction {
+ _ = r catch |err| switch (err) {
+ error.Canceled => {},
+ else => {
+ log.warn("error during coalesce callback err={}", .{err});
+ return .disarm;
+ },
+ };
+
+ const cb = cb_ orelse return .disarm;
+
+ if (cb.self.coalesce_data.resize) |v| {
+ cb.self.coalesce_data.resize = null;
+ cb.io.resize(&cb.data, v) catch |err| {
+ log.warn("error during resize err={}", .{err});
+ };
+ }
+
+ return .disarm;
+}
+
+fn wakeupCallback(
+ cb_: ?*CallbackData,
+ _: *xev.Loop,
+ _: *xev.Completion,
+ r: xev.Async.WaitError!void,
+) xev.CallbackAction {
+ _ = r catch |err| {
+ log.err("error in wakeup err={}", .{err});
+ return .rearm;
+ };
+
+ // When we wake up, we check the mailbox. Mailbox producers should
+ // wake up our thread after publishing.
+ const cb = cb_ orelse return .rearm;
+ cb.self.drainMailbox(cb) catch |err|
+ log.err("error draining mailbox err={}", .{err});
+
+ return .rearm;
+}
+
+fn stopCallback(
+ cb_: ?*CallbackData,
+ _: *xev.Loop,
+ _: *xev.Completion,
+ r: xev.Async.WaitError!void,
+) xev.CallbackAction {
+ _ = r catch unreachable;
+ cb_.?.self.loop.stop();
+ return .disarm;
+}
+
+fn startScrollTimer(self: *Thread, cb: *CallbackData) void {
+ self.scroll_active = true;
+
+ switch (self.scroll_c.state()) {
+ // If it is already active, e.g. startScrollTimer is called multiple
+ // times, then we just return. We can't simply check `scroll_active`
+ // because its possible that `stopScrollTimer` was called but there
+ // was no loop tick between then and now to halt out completion.
+ .active => return,
+
+ // If the completion is not active then we need to start it.
+ .dead => self.scroll.run(
+ &self.loop,
+ &self.scroll_c,
+ selection_scroll_ms,
+ CallbackData,
+ cb,
+ selectionScrollCallback,
+ ),
+ }
+}
+
+fn stopScrollTimer(self: *Thread) void {
+ // This will stop the scrolling on the next iteration.
+ self.scroll_active = false;
+}
+
+fn selectionScrollCallback(
+ cb_: ?*CallbackData,
+ _: *xev.Loop,
+ _: *xev.Completion,
+ r: xev.Timer.RunError!void,
+) xev.CallbackAction {
+ _ = r catch |err| switch (err) {
+ error.Canceled => {},
+ else => {
+ log.warn("error during selection scroll callback err={}", .{err});
+ return .disarm;
+ },
+ };
+
+ const cb = cb_ orelse return .disarm;
+ const self = cb.self;
+
+ // Send the tick to the main surface
+ _ = cb.io.surface_mailbox.push(
+ .{ .selection_scroll_tick = self.scroll_active },
+ .{ .instant = {} },
+ );
+
+ if (self.scroll_active) self.scroll.run(
+ &self.loop,
+ &self.scroll_c,
+ selection_scroll_ms,
+ CallbackData,
+ cb,
+ selectionScrollCallback,
+ );
+
+ return .disarm;
+}
diff --git a/vendor/libghostty-vt/src/termio/backend.zig b/vendor/libghostty-vt/src/termio/backend.zig
new file mode 100644
index 00000000..c29009ac
--- /dev/null
+++ b/vendor/libghostty-vt/src/termio/backend.zig
@@ -0,0 +1,129 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const posix = std.posix;
+const renderer = @import("../renderer.zig");
+const terminal = @import("../terminal/main.zig");
+const termio = @import("../termio.zig");
+const ProcessInfo = @import("../pty.zig").ProcessInfo;
+
+// The preallocation size for the write request pool. This should be big
+// enough to satisfy most write requests. It must be a power of 2.
+const WRITE_REQ_PREALLOC = std.math.pow(usize, 2, 5);
+
+/// The kinds of backends.
+pub const Kind = enum { exec };
+
+/// Configuration for the various backend types.
+pub const Config = union(Kind) {
+ /// Exec uses posix exec to run a command with a pty.
+ exec: termio.Exec.Config,
+};
+
+/// Backend implementations. A backend is responsible for owning the pty
+/// behavior and providing read/write capabilities.
+pub const Backend = union(Kind) {
+ exec: termio.Exec,
+
+ pub fn deinit(self: *Backend) void {
+ switch (self.*) {
+ .exec => |*exec| exec.deinit(),
+ }
+ }
+
+ pub fn initTerminal(self: *Backend, t: *terminal.Terminal) void {
+ switch (self.*) {
+ .exec => |*exec| exec.initTerminal(t),
+ }
+ }
+
+ pub fn threadEnter(
+ self: *Backend,
+ alloc: Allocator,
+ io: *termio.Termio,
+ td: *termio.Termio.ThreadData,
+ ) !void {
+ switch (self.*) {
+ .exec => |*exec| try exec.threadEnter(alloc, io, td),
+ }
+ }
+
+ pub fn threadExit(self: *Backend, td: *termio.Termio.ThreadData) void {
+ switch (self.*) {
+ .exec => |*exec| exec.threadExit(td),
+ }
+ }
+
+ pub fn focusGained(
+ self: *Backend,
+ td: *termio.Termio.ThreadData,
+ focused: bool,
+ ) !void {
+ switch (self.*) {
+ .exec => |*exec| try exec.focusGained(td, focused),
+ }
+ }
+
+ pub fn resize(
+ self: *Backend,
+ grid_size: renderer.GridSize,
+ screen_size: renderer.ScreenSize,
+ ) !void {
+ switch (self.*) {
+ .exec => |*exec| try exec.resize(grid_size, screen_size),
+ }
+ }
+
+ pub fn queueWrite(
+ self: *Backend,
+ alloc: Allocator,
+ td: *termio.Termio.ThreadData,
+ data: []const u8,
+ linefeed: bool,
+ ) !void {
+ switch (self.*) {
+ .exec => |*exec| try exec.queueWrite(alloc, td, data, linefeed),
+ }
+ }
+
+ pub fn childExitedAbnormally(
+ self: *Backend,
+ gpa: Allocator,
+ t: *terminal.Terminal,
+ exit_code: u32,
+ runtime_ms: u64,
+ ) !void {
+ switch (self.*) {
+ .exec => |*exec| try exec.childExitedAbnormally(
+ gpa,
+ t,
+ exit_code,
+ runtime_ms,
+ ),
+ }
+ }
+
+ /// Get information about the process(es) attached to the backend. Returns
+ /// `null` if there was an error getting the information or the information
+ /// is not available on a particular platform.
+ pub fn getProcessInfo(self: *Backend, comptime info: ProcessInfo) ?ProcessInfo.Type(info) {
+ return switch (self.*) {
+ .exec => |*exec| exec.getProcessInfo(info),
+ };
+ }
+};
+
+/// Termio thread data. See termio.ThreadData for docs.
+pub const ThreadData = union(Kind) {
+ exec: termio.Exec.ThreadData,
+
+ pub fn deinit(self: *ThreadData, alloc: Allocator) void {
+ switch (self.*) {
+ .exec => |*exec| exec.deinit(alloc),
+ }
+ }
+
+ pub fn changeConfig(self: *ThreadData, config: *termio.DerivedConfig) void {
+ _ = self;
+ _ = config;
+ }
+};
diff --git a/vendor/libghostty-vt/src/termio/mailbox.zig b/vendor/libghostty-vt/src/termio/mailbox.zig
new file mode 100644
index 00000000..2725d024
--- /dev/null
+++ b/vendor/libghostty-vt/src/termio/mailbox.zig
@@ -0,0 +1,106 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const xev = @import("../global.zig").xev;
+const renderer = @import("../renderer.zig");
+const termio = @import("../termio.zig");
+const BlockingQueue = @import("../datastruct/main.zig").BlockingQueue;
+
+const log = std.log.scoped(.io_writer);
+
+/// A queue used for storing messages that is periodically drained.
+/// Typically used by a multi-threaded application. The capacity is
+/// hardcoded to a value that empirically has made sense for Ghostty usage
+/// but I'm open to changing it with good arguments.
+const Queue = BlockingQueue(termio.Message, 64);
+
+/// The location to where write-related messages are sent.
+pub const Mailbox = union(enum) {
+ // /// Write messages to an unbounded list backed by an allocator.
+ // /// This is useful for single-threaded applications where you're not
+ // /// afraid of running out of memory. You should be careful that you're
+ // /// processing this in a timely manner though since some heavy workloads
+ // /// will produce a LOT of messages.
+ // ///
+ // /// At the time of authoring this, the primary use case for this is
+ // /// testing more than anything, but it probably will have a use case
+ // /// in libghostty eventually.
+ // unbounded: std.ArrayList(termio.Message),
+
+ /// Write messages to a SPSC queue for multi-threaded applications.
+ spsc: struct {
+ queue: *Queue,
+ wakeup: xev.Async,
+ },
+
+ /// Init the SPSC writer.
+ pub fn initSPSC(alloc: Allocator) !Mailbox {
+ var queue = try Queue.create(alloc);
+ errdefer queue.destroy(alloc);
+
+ var wakeup = try xev.Async.init();
+ errdefer wakeup.deinit();
+
+ return .{ .spsc = .{ .queue = queue, .wakeup = wakeup } };
+ }
+
+ pub fn deinit(self: *Mailbox, alloc: Allocator) void {
+ switch (self.*) {
+ .spsc => |*v| {
+ v.queue.destroy(alloc);
+ v.wakeup.deinit();
+ },
+ }
+ }
+
+ /// Sends the given message without notifying there are messages.
+ ///
+ /// If the optional mutex is given, it must already be LOCKED. If the
+ /// send would block, we'll unlock this mutex, resend the message, and
+ /// lock it again. This handles an edge case where queues are full.
+ /// This may not apply to all writer types.
+ pub fn send(
+ self: *Mailbox,
+ msg: termio.Message,
+ mutex: ?*std.Thread.Mutex,
+ ) void {
+ switch (self.*) {
+ .spsc => |*mb| send: {
+ // Try to write to the queue with an instant timeout. This is the
+ // fast path because we can queue without a lock.
+ if (mb.queue.push(msg, .{ .instant = {} }) > 0) break :send;
+
+ // If we enter this conditional, the queue is full. We wake up
+ // the writer thread so that it can process messages to clear up
+ // space. However, the writer thread may require the renderer
+ // lock so we need to unlock.
+ mb.wakeup.notify() catch |err| {
+ log.warn("failed to wake up writer, data will be dropped err={}", .{err});
+ return;
+ };
+
+ // Unlock the renderer state so the writer thread can acquire it.
+ // Then try to queue our message before continuing. This is a very
+ // slow path because we are having a lot of contention for data.
+ // But this only gets triggered in certain pathological cases.
+ //
+ // Note that writes themselves don't require a lock, but there
+ // are other messages in the writer queue (resize, focus) that
+ // could acquire the lock. This is why we have to release our lock
+ // here.
+ if (mutex) |m| m.unlock();
+ defer if (mutex) |m| m.lock();
+ _ = mb.queue.push(msg, .{ .forever = {} });
+ },
+ }
+ }
+
+ /// Notify that there are new messages. This may be a noop depending
+ /// on the writer type.
+ pub fn notify(self: *Mailbox) void {
+ switch (self.*) {
+ .spsc => |*v| v.wakeup.notify() catch |err| {
+ log.warn("failed to notify writer, data will be dropped err={}", .{err});
+ },
+ }
+ }
+};
diff --git a/vendor/libghostty-vt/src/termio/message.zig b/vendor/libghostty-vt/src/termio/message.zig
new file mode 100644
index 00000000..4ee7f245
--- /dev/null
+++ b/vendor/libghostty-vt/src/termio/message.zig
@@ -0,0 +1,108 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const renderer = @import("../renderer.zig");
+const terminal = @import("../terminal/main.zig");
+const termio = @import("../termio.zig");
+const MessageData = @import("../datastruct/main.zig").MessageData;
+
+/// The messages that can be sent to an IO thread.
+///
+/// This is not a tiny structure (~40 bytes at the time of writing this comment),
+/// but the messages are IO thread sends are also very few. At the current size
+/// we can queue 26,000 messages before consuming a MB of RAM.
+pub const Message = union(enum) {
+ /// Represents a write request. Magic number comes from the largest
+ /// other union value. It can be upped if we add a larger union member
+ /// in the future.
+ pub const WriteReq = MessageData(u8, 38);
+
+ /// Request a color scheme report is sent to the pty.
+ color_scheme_report: struct {
+ /// Force write the current color scheme
+ force: bool,
+ },
+
+ /// Purposely crash the renderer. This is used for testing and debugging.
+ /// See the "crash" binding action.
+ crash: void,
+
+ /// The derived configuration to update the implementation with. This
+ /// is allocated via the allocator and is expected to be freed when done.
+ change_config: struct {
+ alloc: Allocator,
+ ptr: *termio.Termio.DerivedConfig,
+ },
+
+ /// Activate or deactivate the inspector.
+ inspector: bool,
+
+ /// Resize the window.
+ resize: renderer.Size,
+
+ /// Request a size report is sent to the pty ([in-band
+ /// size report, mode 2048](https://gist.github.com/rockorager/e695fb2924d36b2bcf1fff4a3704bd83) and
+ /// [XTWINOPS](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h4-Functions-using-CSI-_-ordered-by-the-final-character-lparen-s-rparen:CSI-Ps;Ps;Ps-t.1EB0)).
+ size_report: SizeReport,
+
+ /// Clear the screen.
+ clear_screen: struct {
+ /// Include clearing the history
+ history: bool,
+ },
+
+ /// Scroll the viewport
+ scroll_viewport: terminal.Terminal.ScrollViewport,
+
+ /// Selection scrolling. If this is set to true then the termio
+ /// thread starts a timer that will trigger a `selection_scroll_tick`
+ /// message back to the surface. This ping/pong is because the
+ /// surface thread doesn't have access to an event loop from libghostty.
+ selection_scroll: bool,
+
+ /// Jump forward/backward n prompts.
+ jump_to_prompt: isize,
+
+ /// Send this when a synchronized output mode is started. This will
+ /// start the timer so that the output mode is disabled after a
+ /// period of time so that a bad actor can't hang the terminal.
+ start_synchronized_output: void,
+
+ /// Enable or disable linefeed mode (mode 20).
+ linefeed_mode: bool,
+
+ /// The surface gained or lost focus.
+ focused: bool,
+
+ /// Write where the data fits in the union.
+ write_small: WriteReq.Small,
+
+ /// Write where the data pointer is stable.
+ write_stable: WriteReq.Stable,
+
+ /// Write where the data is allocated and must be freed.
+ write_alloc: WriteReq.Alloc,
+
+ /// Return a write request for the given data. This will use
+ /// write_small if it fits or write_alloc otherwise. This should NOT
+ /// be used for stable pointers which can be manually set to write_stable.
+ pub fn writeReq(alloc: Allocator, data: anytype) !Message {
+ return switch (try WriteReq.init(alloc, data)) {
+ .stable => unreachable,
+ .small => |v| Message{ .write_small = v },
+ .alloc => |v| Message{ .write_alloc = v },
+ };
+ }
+
+ /// The types of size reports that we support.
+ pub const SizeReport = terminal.size_report.Style;
+};
+
+test {
+ std.testing.refAllDecls(@This());
+}
+
+test {
+ // Ensure we don't grow our IO message size without explicitly wanting to.
+ const testing = std.testing;
+ try testing.expectEqual(@as(usize, 40), @sizeOf(Message));
+}
diff --git a/vendor/libghostty-vt/src/termio/shell_integration.zig b/vendor/libghostty-vt/src/termio/shell_integration.zig
new file mode 100644
index 00000000..2dd09ee2
--- /dev/null
+++ b/vendor/libghostty-vt/src/termio/shell_integration.zig
@@ -0,0 +1,1032 @@
+const std = @import("std");
+const builtin = @import("builtin");
+const Allocator = std.mem.Allocator;
+const ArenaAllocator = std.heap.ArenaAllocator;
+const EnvMap = std.process.EnvMap;
+const config = @import("../config.zig");
+const homedir = @import("../os/homedir.zig");
+const internal_os = @import("../os/main.zig");
+
+const log = std.log.scoped(.shell_integration);
+
+/// Shell types we support
+pub const Shell = enum {
+ bash,
+ elvish,
+ fish,
+ nushell,
+ zsh,
+};
+
+/// The result of setting up a shell integration.
+pub const ShellIntegration = struct {
+ /// The successfully-integrated shell.
+ shell: Shell,
+
+ /// The command to use to start the shell with the integration.
+ /// In most cases this is identical to the command given but for
+ /// bash in particular it may be different.
+ ///
+ /// The memory is allocated in the arena given to setup.
+ command: config.Command,
+};
+
+/// Set up the command execution environment for automatic
+/// integrated shell integration and return a ShellIntegration
+/// struct describing the integration. If integration fails
+/// (shell type couldn't be detected, etc.), this will return null.
+///
+/// The allocator is used for temporary values and to allocate values
+/// in the ShellIntegration result. It is expected to be an arena to
+/// simplify cleanup.
+pub fn setup(
+ alloc_arena: Allocator,
+ resource_dir: []const u8,
+ command: config.Command,
+ env: *EnvMap,
+ force_shell: ?Shell,
+) !?ShellIntegration {
+ const shell: Shell = force_shell orelse
+ try detectShell(alloc_arena, command) orelse
+ return null;
+
+ const new_command: config.Command = switch (shell) {
+ .bash => try setupBash(
+ alloc_arena,
+ command,
+ resource_dir,
+ env,
+ ),
+
+ .nushell => try setupNushell(
+ alloc_arena,
+ command,
+ resource_dir,
+ env,
+ ),
+
+ .zsh => try setupZsh(
+ alloc_arena,
+ command,
+ resource_dir,
+ env,
+ ),
+
+ .elvish, .fish => xdg: {
+ if (!try setupXdgDataDirs(alloc_arena, resource_dir, env)) return null;
+ break :xdg try command.clone(alloc_arena);
+ },
+ } orelse return null;
+
+ return .{
+ .shell = shell,
+ .command = new_command,
+ };
+}
+
+test "force shell" {
+ const testing = std.testing;
+
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ inline for (@typeInfo(Shell).@"enum".fields) |field| {
+ const shell = @field(Shell, field.name);
+
+ var res: TmpResourcesDir = try .init(alloc, shell);
+ defer res.deinit();
+
+ const result = try setup(
+ alloc,
+ res.path,
+ .{ .shell = "sh" },
+ &env,
+ shell,
+ );
+ try testing.expectEqual(shell, result.?.shell);
+ }
+}
+
+test "shell integration failure" {
+ const testing = std.testing;
+
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ const result = try setup(
+ alloc,
+ "/nonexistent",
+ .{ .shell = "sh" },
+ &env,
+ null,
+ );
+
+ try testing.expect(result == null);
+ try testing.expectEqual(0, env.count());
+}
+
+fn detectShell(alloc: Allocator, command: config.Command) !?Shell {
+ var arg_iter = try command.argIterator(alloc);
+ defer arg_iter.deinit();
+
+ const arg0 = arg_iter.next() orelse return null;
+ const exe = std.fs.path.basename(arg0);
+
+ if (std.mem.eql(u8, "bash", exe)) {
+ // Apple distributes their own patched version of Bash 3.2
+ // on macOS that disables the ENV-based POSIX startup path.
+ // This means we're unable to perform our automatic shell
+ // integration sequence in this specific environment.
+ //
+ // If we're running "/bin/bash" on Darwin, we can assume
+ // we're using Apple's Bash because /bin is non-writable
+ // on modern macOS due to System Integrity Protection.
+ if (comptime builtin.target.os.tag.isDarwin()) {
+ if (std.mem.eql(u8, "/bin/bash", arg0)) {
+ return null;
+ }
+ }
+ return .bash;
+ }
+
+ if (std.mem.eql(u8, "elvish", exe)) return .elvish;
+ if (std.mem.eql(u8, "fish", exe)) return .fish;
+ if (std.mem.eql(u8, "nu", exe)) return .nushell;
+ if (std.mem.eql(u8, "zsh", exe)) return .zsh;
+
+ return null;
+}
+
+test detectShell {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ try testing.expect(try detectShell(alloc, .{ .shell = "sh" }) == null);
+ try testing.expectEqual(.bash, try detectShell(alloc, .{ .shell = "bash" }));
+ try testing.expectEqual(.elvish, try detectShell(alloc, .{ .shell = "elvish" }));
+ try testing.expectEqual(.fish, try detectShell(alloc, .{ .shell = "fish" }));
+ try testing.expectEqual(.nushell, try detectShell(alloc, .{ .shell = "nu" }));
+ try testing.expectEqual(.zsh, try detectShell(alloc, .{ .shell = "zsh" }));
+
+ if (comptime builtin.target.os.tag.isDarwin()) {
+ try testing.expect(try detectShell(alloc, .{ .shell = "/bin/bash" }) == null);
+ }
+
+ try testing.expectEqual(.bash, try detectShell(alloc, .{ .shell = "bash -c 'command'" }));
+ try testing.expectEqual(.bash, try detectShell(alloc, .{ .shell = "\"/a b/bash\"" }));
+}
+
+/// Set up the shell integration features environment variable.
+pub fn setupFeatures(
+ env: *EnvMap,
+ features: config.ShellIntegrationFeatures,
+ cursor_blink: bool,
+) !void {
+ const fields = @typeInfo(@TypeOf(features)).@"struct".fields;
+ const capacity: usize = capacity: {
+ comptime var n: usize = fields.len - 1; // commas
+ inline for (fields) |field| n += field.name.len;
+ n += ":steady".len; // cursor value
+ break :capacity n;
+ };
+
+ var buf: [capacity]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+
+ // Sort the fields so that the output is deterministic. This is
+ // done at comptime so it has no runtime cost
+ const fields_sorted: [fields.len][]const u8 = comptime fields: {
+ var fields_sorted: [fields.len][]const u8 = undefined;
+ for (fields, 0..) |field, i| fields_sorted[i] = field.name;
+ std.mem.sortUnstable(
+ []const u8,
+ &fields_sorted,
+ {},
+ (struct {
+ fn lessThan(_: void, lhs: []const u8, rhs: []const u8) bool {
+ return std.ascii.orderIgnoreCase(lhs, rhs) == .lt;
+ }
+ }).lessThan,
+ );
+ break :fields fields_sorted;
+ };
+
+ inline for (fields_sorted) |name| {
+ if (@field(features, name)) {
+ if (writer.end > 0) try writer.writeByte(',');
+ try writer.writeAll(name);
+
+ if (std.mem.eql(u8, name, "cursor")) {
+ try writer.writeAll(if (cursor_blink) ":blink" else ":steady");
+ }
+ }
+ }
+
+ if (writer.end > 0) {
+ try env.put("GHOSTTY_SHELL_FEATURES", buf[0..writer.end]);
+ }
+}
+
+test "setup features" {
+ const testing = std.testing;
+
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ // Test: all features enabled
+ {
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ try setupFeatures(&env, .{ .cursor = true, .sudo = true, .title = true, .@"ssh-env" = true, .@"ssh-terminfo" = true, .path = true }, true);
+ try testing.expectEqualStrings("cursor:blink,path,ssh-env,ssh-terminfo,sudo,title", env.get("GHOSTTY_SHELL_FEATURES").?);
+ }
+
+ // Test: all features disabled
+ {
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ try setupFeatures(&env, std.mem.zeroes(config.ShellIntegrationFeatures), true);
+ try testing.expect(env.get("GHOSTTY_SHELL_FEATURES") == null);
+ }
+
+ // Test: mixed features
+ {
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ try setupFeatures(&env, .{ .cursor = false, .sudo = true, .title = false, .@"ssh-env" = true, .@"ssh-terminfo" = false, .path = false }, true);
+ try testing.expectEqualStrings("ssh-env,sudo", env.get("GHOSTTY_SHELL_FEATURES").?);
+ }
+
+ // Test: blinking cursor
+ {
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+ try setupFeatures(&env, .{ .cursor = true, .sudo = false, .title = false, .@"ssh-env" = false, .@"ssh-terminfo" = false, .path = false }, true);
+ try testing.expectEqualStrings("cursor:blink", env.get("GHOSTTY_SHELL_FEATURES").?);
+ }
+
+ // Test: steady cursor
+ {
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+ try setupFeatures(&env, .{ .cursor = true, .sudo = false, .title = false, .@"ssh-env" = false, .@"ssh-terminfo" = false, .path = false }, false);
+ try testing.expectEqualStrings("cursor:steady", env.get("GHOSTTY_SHELL_FEATURES").?);
+ }
+}
+
+/// Setup the bash automatic shell integration. This works by
+/// starting bash in POSIX mode and using the ENV environment
+/// variable to load our bash integration script. This prevents
+/// bash from loading its normal startup files, which becomes
+/// our script's responsibility (along with disabling POSIX
+/// mode).
+///
+/// This returns a new (allocated) shell command string that
+/// enables the integration or null if integration failed.
+fn setupBash(
+ alloc: Allocator,
+ command: config.Command,
+ resource_dir: []const u8,
+ env: *EnvMap,
+) !?config.Command {
+ var stack_fallback = std.heap.stackFallback(4096, alloc);
+ var cmd = internal_os.shell.ShellCommandBuilder.init(stack_fallback.get());
+ defer cmd.deinit();
+
+ // Iterator that yields each argument in the original command line.
+ // This will allocate once proportionate to the command line length.
+ var iter = try command.argIterator(alloc);
+ defer iter.deinit();
+
+ // Start accumulating arguments with the executable and initial flags.
+ if (iter.next()) |exe| {
+ try cmd.appendArg(exe);
+ } else return null;
+ try cmd.appendArg("--posix");
+
+ // Stores the list of intercepted command line flags that will be passed
+ // to our shell integration script: --norc --noprofile
+ // We always include at least "1" so the script can differentiate between
+ // being manually sourced or automatically injected (from here).
+ var buf: [32]u8 = undefined;
+ var inject: std.Io.Writer = .fixed(&buf);
+ try inject.writeAll("1");
+
+ // Walk through the rest of the given arguments. If we see an option that
+ // would require complex or unsupported integration behavior, we bail out
+ // and skip loading our shell integration. Users can still manually source
+ // the shell integration script.
+ //
+ // Unsupported options:
+ // -c -c is always non-interactive
+ // --posix POSIX mode (a la /bin/sh)
+ var rcfile: ?[]const u8 = null;
+ while (iter.next()) |arg| {
+ if (std.mem.eql(u8, arg, "--posix")) {
+ return null;
+ } else if (std.mem.eql(u8, arg, "--norc")) {
+ try inject.writeAll(" --norc");
+ } else if (std.mem.eql(u8, arg, "--noprofile")) {
+ try inject.writeAll(" --noprofile");
+ } else if (std.mem.eql(u8, arg, "--rcfile") or std.mem.eql(u8, arg, "--init-file")) {
+ rcfile = iter.next();
+ } else if (arg.len > 1 and arg[0] == '-' and arg[1] != '-') {
+ // '-c command' is always non-interactive
+ if (std.mem.indexOfScalar(u8, arg, 'c') != null) {
+ return null;
+ }
+ try cmd.appendArg(arg);
+ } else if (std.mem.eql(u8, arg, "-") or std.mem.eql(u8, arg, "--")) {
+ // All remaining arguments should be passed directly to the shell
+ // command. We shouldn't perform any further option processing.
+ try cmd.appendArg(arg);
+ while (iter.next()) |remaining_arg| {
+ try cmd.appendArg(remaining_arg);
+ }
+ break;
+ } else {
+ try cmd.appendArg(arg);
+ }
+ }
+
+ // Preserve an existing ENV value. We're about to overwrite it.
+ if (env.get("ENV")) |v| {
+ try env.put("GHOSTTY_BASH_ENV", v);
+ }
+
+ // Set our new ENV to point to our integration script.
+ var script_path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const script_path = try std.fmt.bufPrint(
+ &script_path_buf,
+ "{s}/shell-integration/bash/ghostty.bash",
+ .{resource_dir},
+ );
+ if (std.fs.openFileAbsolute(script_path, .{})) |file| {
+ file.close();
+ try env.put("ENV", script_path);
+ } else |err| {
+ log.warn("unable to open {s}: {}", .{ script_path, err });
+ env.remove("GHOSTTY_BASH_ENV");
+ return null;
+ }
+
+ try env.put("GHOSTTY_BASH_INJECT", buf[0..inject.end]);
+ if (rcfile) |v| {
+ try env.put("GHOSTTY_BASH_RCFILE", v);
+ }
+
+ // In POSIX mode, HISTFILE defaults to ~/.sh_history, so unless we're
+ // staying in POSIX mode (--posix), change it back to ~/.bash_history.
+ if (env.get("HISTFILE") == null) {
+ var home_buf: [1024]u8 = undefined;
+ if (try homedir.home(&home_buf)) |home| {
+ var histfile_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const histfile = try std.fmt.bufPrint(
+ &histfile_buf,
+ "{s}/.bash_history",
+ .{home},
+ );
+ try env.put("HISTFILE", histfile);
+ try env.put("GHOSTTY_BASH_UNEXPORT_HISTFILE", "1");
+ }
+ }
+
+ // Return a copy of our modified command line to use as the shell command.
+ return .{ .shell = try alloc.dupeZ(u8, try cmd.toOwnedSlice()) };
+}
+
+test "bash" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var res: TmpResourcesDir = try .init(alloc, .bash);
+ defer res.deinit();
+
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ const command = try setupBash(alloc, .{ .shell = "bash" }, res.path, &env);
+ try testing.expectEqualStrings("bash --posix", command.?.shell);
+ try testing.expectEqualStrings("1", env.get("GHOSTTY_BASH_INJECT").?);
+
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ try testing.expectEqualStrings(
+ try std.fmt.bufPrint(&path_buf, "{s}/ghostty.bash", .{res.shell_path}),
+ env.get("ENV").?,
+ );
+}
+
+test "bash: unsupported options" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var res: TmpResourcesDir = try .init(alloc, .bash);
+ defer res.deinit();
+
+ const cmdlines = [_][:0]const u8{
+ "bash --posix",
+ "bash --rcfile script.sh --posix",
+ "bash --init-file script.sh --posix",
+ "bash -c script.sh",
+ "bash -ic script.sh",
+ };
+
+ for (cmdlines) |cmdline| {
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ try testing.expect(try setupBash(alloc, .{ .shell = cmdline }, res.path, &env) == null);
+ try testing.expectEqual(0, env.count());
+ }
+}
+
+test "bash: inject flags" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var res: TmpResourcesDir = try .init(alloc, .bash);
+ defer res.deinit();
+
+ // bash --norc
+ {
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ const command = try setupBash(alloc, .{ .shell = "bash --norc" }, res.path, &env);
+ try testing.expectEqualStrings("bash --posix", command.?.shell);
+ try testing.expectEqualStrings("1 --norc", env.get("GHOSTTY_BASH_INJECT").?);
+ }
+
+ // bash --noprofile
+ {
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ const command = try setupBash(alloc, .{ .shell = "bash --noprofile" }, res.path, &env);
+ try testing.expectEqualStrings("bash --posix", command.?.shell);
+ try testing.expectEqualStrings("1 --noprofile", env.get("GHOSTTY_BASH_INJECT").?);
+ }
+}
+
+test "bash: rcfile" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var res: TmpResourcesDir = try .init(alloc, .bash);
+ defer res.deinit();
+
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ // bash --rcfile
+ {
+ const command = try setupBash(alloc, .{ .shell = "bash --rcfile profile.sh" }, res.path, &env);
+ try testing.expectEqualStrings("bash --posix", command.?.shell);
+ try testing.expectEqualStrings("profile.sh", env.get("GHOSTTY_BASH_RCFILE").?);
+ }
+
+ // bash --init-file
+ {
+ const command = try setupBash(alloc, .{ .shell = "bash --init-file profile.sh" }, res.path, &env);
+ try testing.expectEqualStrings("bash --posix", command.?.shell);
+ try testing.expectEqualStrings("profile.sh", env.get("GHOSTTY_BASH_RCFILE").?);
+ }
+}
+
+test "bash: HISTFILE" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var res: TmpResourcesDir = try .init(alloc, .bash);
+ defer res.deinit();
+
+ // HISTFILE unset
+ {
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ _ = try setupBash(alloc, .{ .shell = "bash" }, res.path, &env);
+ try testing.expect(std.mem.endsWith(u8, env.get("HISTFILE").?, ".bash_history"));
+ try testing.expectEqualStrings("1", env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE").?);
+ }
+
+ // HISTFILE set
+ {
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ try env.put("HISTFILE", "my_history");
+
+ _ = try setupBash(alloc, .{ .shell = "bash" }, res.path, &env);
+ try testing.expectEqualStrings("my_history", env.get("HISTFILE").?);
+ try testing.expect(env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE") == null);
+ }
+}
+
+test "bash: ENV" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var res: TmpResourcesDir = try .init(alloc, .bash);
+ defer res.deinit();
+
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ try env.put("ENV", "env.sh");
+
+ _ = try setupBash(alloc, .{ .shell = "bash" }, res.path, &env);
+ try testing.expectEqualStrings("env.sh", env.get("GHOSTTY_BASH_ENV").?);
+
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ try testing.expectEqualStrings(
+ try std.fmt.bufPrint(&path_buf, "{s}/ghostty.bash", .{res.shell_path}),
+ env.get("ENV").?,
+ );
+}
+
+test "bash: additional arguments" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var res: TmpResourcesDir = try .init(alloc, .bash);
+ defer res.deinit();
+
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ // "-" argument separator
+ {
+ const command = try setupBash(alloc, .{ .shell = "bash - --arg file1 file2" }, res.path, &env);
+ try testing.expectEqualStrings("bash --posix - --arg file1 file2", command.?.shell);
+ }
+
+ // "--" argument separator
+ {
+ const command = try setupBash(alloc, .{ .shell = "bash -- --arg file1 file2" }, res.path, &env);
+ try testing.expectEqualStrings("bash --posix -- --arg file1 file2", command.?.shell);
+ }
+}
+
+test "bash: missing resources" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var tmp_dir = testing.tmpDir(.{});
+ defer tmp_dir.cleanup();
+
+ const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, ".");
+ defer alloc.free(resources_dir);
+
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ try testing.expect(try setupBash(alloc, .{ .shell = "bash" }, resources_dir, &env) == null);
+ try testing.expectEqual(0, env.count());
+}
+
+/// Setup automatic shell integration for shells that include
+/// their modules from paths in `XDG_DATA_DIRS` env variable.
+///
+/// The shell-integration path is prepended to `XDG_DATA_DIRS`.
+/// It is also saved in the `GHOSTTY_SHELL_INTEGRATION_XDG_DIR` variable
+/// so that the shell can refer to it and safely remove this directory
+/// from `XDG_DATA_DIRS` when integration is complete.
+fn setupXdgDataDirs(
+ alloc: Allocator,
+ resource_dir: []const u8,
+ env: *EnvMap,
+) !bool {
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+
+ // Get our path to the shell integration directory.
+ const integ_path = try std.fmt.bufPrint(
+ &path_buf,
+ "{s}/shell-integration",
+ .{resource_dir},
+ );
+ var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch |err| {
+ log.warn("unable to open {s}: {}", .{ integ_path, err });
+ return false;
+ };
+ integ_dir.close();
+
+ // Set an env var so we can remove this from XDG_DATA_DIRS later.
+ // This happens in the shell integration config itself. We do this
+ // so that our modifications don't interfere with other commands.
+ try env.put("GHOSTTY_SHELL_INTEGRATION_XDG_DIR", integ_path);
+
+ // We attempt to avoid allocating by using the stack up to 4K.
+ // Max stack size is considerably larger on mac
+ // 4K is a reasonable size for this for most cases. However, env
+ // vars can be significantly larger so if we have to we fall
+ // back to a heap allocated value.
+ var stack_alloc_state = std.heap.stackFallback(4096, alloc);
+ const stack_alloc = stack_alloc_state.get();
+
+ // If no XDG_DATA_DIRS set use the default value as specified.
+ // This ensures that the default directories aren't lost by setting
+ // our desired integration dir directly. See #2711.
+ // <https://specifications.freedesktop.org/basedir-spec/0.6/#variables>
+ const xdg_data_dirs_key = "XDG_DATA_DIRS";
+ try env.put(
+ xdg_data_dirs_key,
+ try internal_os.prependEnv(
+ stack_alloc,
+ env.get(xdg_data_dirs_key) orelse "/usr/local/share:/usr/share",
+ integ_path,
+ ),
+ );
+
+ return true;
+}
+
+test "xdg: empty XDG_DATA_DIRS" {
+ if (builtin.os.tag == .windows) return error.SkipZigTest;
+
+ const testing = std.testing;
+
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var res: TmpResourcesDir = try .init(alloc, .fish);
+ defer res.deinit();
+
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ try testing.expect(try setupXdgDataDirs(alloc, res.path, &env));
+
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ try testing.expectEqualStrings(
+ try std.fmt.bufPrint(&path_buf, "{s}/shell-integration", .{res.path}),
+ env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR").?,
+ );
+ try testing.expectEqualStrings(
+ try std.fmt.bufPrint(&path_buf, "{s}/shell-integration:/usr/local/share:/usr/share", .{res.path}),
+ env.get("XDG_DATA_DIRS").?,
+ );
+}
+
+test "xdg: existing XDG_DATA_DIRS" {
+ if (builtin.os.tag == .windows) return error.SkipZigTest;
+
+ const testing = std.testing;
+
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var res: TmpResourcesDir = try .init(alloc, .fish);
+ defer res.deinit();
+
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ try env.put("XDG_DATA_DIRS", "/opt/share");
+
+ try testing.expect(try setupXdgDataDirs(alloc, res.path, &env));
+
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ try testing.expectEqualStrings(
+ try std.fmt.bufPrint(&path_buf, "{s}/shell-integration", .{res.path}),
+ env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR").?,
+ );
+ try testing.expectEqualStrings(
+ try std.fmt.bufPrint(&path_buf, "{s}/shell-integration:/opt/share", .{res.path}),
+ env.get("XDG_DATA_DIRS").?,
+ );
+}
+
+test "xdg: missing resources" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var tmp_dir = testing.tmpDir(.{});
+ defer tmp_dir.cleanup();
+
+ const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, ".");
+ defer alloc.free(resources_dir);
+
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ try testing.expect(!try setupXdgDataDirs(alloc, resources_dir, &env));
+ try testing.expectEqual(0, env.count());
+}
+
+/// Set up automatic Nushell shell integration. This works by adding our
+/// shell resource directory to the `XDG_DATA_DIRS` environment variable,
+/// which Nushell will use to load `nushell/vendor/autoload/ghostty.nu`.
+///
+/// We then add `--execute 'use ghostty ...'` to the nu command line to
+/// automatically enable our shelll features.
+fn setupNushell(
+ alloc: Allocator,
+ command: config.Command,
+ resource_dir: []const u8,
+ env: *EnvMap,
+) !?config.Command {
+ // Add our XDG_DATA_DIRS entry (for nushell/vendor/autoload/). This
+ // makes our 'ghostty' module automatically available, even if any
+ // of the later checks abort the rest of our automatic integration.
+ if (!try setupXdgDataDirs(alloc, resource_dir, env)) return null;
+
+ var stack_fallback = std.heap.stackFallback(4096, alloc);
+ var cmd = internal_os.shell.ShellCommandBuilder.init(stack_fallback.get());
+ defer cmd.deinit();
+
+ // Iterator that yields each argument in the original command line.
+ // This will allocate once proportionate to the command line length.
+ var iter = try command.argIterator(alloc);
+ defer iter.deinit();
+
+ // Start accumulating arguments with the executable and initial flags.
+ if (iter.next()) |exe| {
+ try cmd.appendArg(exe);
+ } else return null;
+
+ // Tell nu to immediately "use" all of the exported functions in our
+ // 'ghostty' module.
+ //
+ // We can consider making this more specific based on the set of
+ // enabled shell features (e.g. `use ghostty sudo`). At the moment,
+ // shell features are all runtime-guarded in the nushell script.
+ try cmd.appendArg("--execute 'use ghostty *'");
+
+ // Walk through the rest of the given arguments. If we see an option that
+ // would require complex or unsupported integration behavior, we bail out
+ // and skip loading our shell integration. Users can still manually source
+ // the shell integration module.
+ //
+ // Unsupported options:
+ // -c / --command -c is always non-interactive
+ // --lsp --lsp starts the language server
+ while (iter.next()) |arg| {
+ if (std.mem.eql(u8, arg, "--command") or std.mem.eql(u8, arg, "--lsp")) {
+ return null;
+ } else if (arg.len > 1 and arg[0] == '-' and arg[1] != '-') {
+ if (std.mem.indexOfScalar(u8, arg, 'c') != null) {
+ return null;
+ }
+ try cmd.appendArg(arg);
+ } else if (std.mem.eql(u8, arg, "-") or std.mem.eql(u8, arg, "--")) {
+ // All remaining arguments should be passed directly to the shell
+ // command. We shouldn't perform any further option processing.
+ try cmd.appendArg(arg);
+ while (iter.next()) |remaining_arg| {
+ try cmd.appendArg(remaining_arg);
+ }
+ break;
+ } else {
+ try cmd.appendArg(arg);
+ }
+ }
+
+ // Return a copy of our modified command line to use as the shell command.
+ return .{ .shell = try alloc.dupeZ(u8, try cmd.toOwnedSlice()) };
+}
+
+test "nushell" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var res: TmpResourcesDir = try .init(alloc, .nushell);
+ defer res.deinit();
+
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ const command = try setupNushell(alloc, .{ .shell = "nu" }, res.path, &env);
+ try testing.expectEqualStrings("nu --execute 'use ghostty *'", command.?.shell);
+
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ try testing.expectEqualStrings(
+ try std.fmt.bufPrint(&path_buf, "{s}/shell-integration", .{res.path}),
+ env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR").?,
+ );
+ try testing.expectStringStartsWith(
+ env.get("XDG_DATA_DIRS").?,
+ try std.fmt.bufPrint(&path_buf, "{s}/shell-integration", .{res.path}),
+ );
+}
+
+test "nushell: unsupported options" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var res: TmpResourcesDir = try .init(alloc, .nushell);
+ defer res.deinit();
+
+ const cmdlines = [_][:0]const u8{
+ "nu --command exit",
+ "nu --lsp",
+ "nu -c script.sh",
+ "nu -ic script.sh",
+ };
+
+ for (cmdlines) |cmdline| {
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ try testing.expect(try setupNushell(alloc, .{ .shell = cmdline }, res.path, &env) == null);
+ try testing.expect(env.get("XDG_DATA_DIRS") != null);
+ try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR") != null);
+ }
+}
+
+test "nushell: missing resources" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var tmp_dir = testing.tmpDir(.{});
+ defer tmp_dir.cleanup();
+
+ const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, ".");
+ defer alloc.free(resources_dir);
+
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ try testing.expect(try setupNushell(alloc, .{ .shell = "nu" }, resources_dir, &env) == null);
+ try testing.expectEqual(0, env.count());
+}
+
+/// Setup the zsh automatic shell integration. This works by setting
+/// ZDOTDIR to our resources dir so that zsh will load our config. This
+/// config then loads the true user config.
+fn setupZsh(
+ alloc: Allocator,
+ command: config.Command,
+ resource_dir: []const u8,
+ env: *EnvMap,
+) !?config.Command {
+ // Preserve an existing ZDOTDIR value. We're about to overwrite it.
+ if (env.get("ZDOTDIR")) |old| {
+ try env.put("GHOSTTY_ZSH_ZDOTDIR", old);
+ }
+
+ // Set our new ZDOTDIR to point to our shell resource directory.
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const integ_path = try std.fmt.bufPrint(
+ &path_buf,
+ "{s}/shell-integration/zsh",
+ .{resource_dir},
+ );
+ var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch |err| {
+ log.warn("unable to open {s}: {}", .{ integ_path, err });
+ return null;
+ };
+ integ_dir.close();
+ try env.put("ZDOTDIR", integ_path);
+
+ return try command.clone(alloc);
+}
+
+test "zsh" {
+ const testing = std.testing;
+
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var res: TmpResourcesDir = try .init(testing.allocator, .zsh);
+ defer res.deinit();
+
+ var env = EnvMap.init(testing.allocator);
+ defer env.deinit();
+
+ const command = try setupZsh(alloc, .{ .shell = "zsh" }, res.path, &env);
+ try testing.expectEqualStrings("zsh", command.?.shell);
+ try testing.expectEqualStrings(res.shell_path, env.get("ZDOTDIR").?);
+ try testing.expect(env.get("GHOSTTY_ZSH_ZDOTDIR") == null);
+}
+
+test "zsh: ZDOTDIR" {
+ const testing = std.testing;
+
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var res: TmpResourcesDir = try .init(testing.allocator, .zsh);
+ defer res.deinit();
+
+ var env = EnvMap.init(testing.allocator);
+ defer env.deinit();
+
+ try env.put("ZDOTDIR", "$HOME/.config/zsh");
+
+ const command = try setupZsh(alloc, .{ .shell = "zsh" }, res.path, &env);
+ try testing.expectEqualStrings("zsh", command.?.shell);
+ try testing.expectEqualStrings(res.shell_path, env.get("ZDOTDIR").?);
+ try testing.expectEqualStrings("$HOME/.config/zsh", env.get("GHOSTTY_ZSH_ZDOTDIR").?);
+}
+
+test "zsh: missing resources" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var tmp_dir = testing.tmpDir(.{});
+ defer tmp_dir.cleanup();
+
+ const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, ".");
+ defer alloc.free(resources_dir);
+
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ try testing.expect(try setupZsh(alloc, .{ .shell = "zsh" }, resources_dir, &env) == null);
+ try testing.expectEqual(0, env.count());
+}
+
+/// Test helper that creates a temporary resources directory with shell integration paths.
+const TmpResourcesDir = struct {
+ allocator: Allocator,
+ tmp_dir: std.testing.TmpDir,
+ path: []const u8,
+ shell_path: []const u8,
+
+ fn init(allocator: Allocator, shell: Shell) !TmpResourcesDir {
+ var tmp_dir = std.testing.tmpDir(.{});
+ errdefer tmp_dir.cleanup();
+
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const relative_shell_path = try std.fmt.bufPrint(
+ &path_buf,
+ "shell-integration/{s}",
+ .{@tagName(shell)},
+ );
+ try tmp_dir.dir.makePath(relative_shell_path);
+
+ const path = try tmp_dir.dir.realpathAlloc(allocator, ".");
+ errdefer allocator.free(path);
+
+ const shell_path = try std.fmt.allocPrint(
+ allocator,
+ "{s}/{s}",
+ .{ path, relative_shell_path },
+ );
+ errdefer allocator.free(shell_path);
+
+ switch (shell) {
+ .bash => try tmp_dir.dir.writeFile(.{
+ .sub_path = "shell-integration/bash/ghostty.bash",
+ .data = "",
+ }),
+ else => {},
+ }
+
+ return .{
+ .allocator = allocator,
+ .tmp_dir = tmp_dir,
+ .path = path,
+ .shell_path = shell_path,
+ };
+ }
+
+ fn deinit(self: *TmpResourcesDir) void {
+ self.allocator.free(self.shell_path);
+ self.allocator.free(self.path);
+ self.tmp_dir.cleanup();
+ }
+};
diff --git a/vendor/libghostty-vt/src/termio/stream_handler.zig b/vendor/libghostty-vt/src/termio/stream_handler.zig
new file mode 100644
index 00000000..fb3a6b3f
--- /dev/null
+++ b/vendor/libghostty-vt/src/termio/stream_handler.zig
@@ -0,0 +1,1556 @@
+const std = @import("std");
+const builtin = @import("builtin");
+const assert = @import("../quirks.zig").inlineAssert;
+const Allocator = std.mem.Allocator;
+const xev = @import("../global.zig").xev;
+const apprt = @import("../apprt.zig");
+const build_config = @import("../build_config.zig");
+const configpkg = @import("../config.zig");
+const internal_os = @import("../os/main.zig");
+const renderer = @import("../renderer.zig");
+const termio = @import("../termio.zig");
+const terminal = @import("../terminal/main.zig");
+const terminfo = @import("../terminfo/main.zig");
+const posix = std.posix;
+
+const log = std.log.scoped(.io_handler);
+
+/// This is used as the handler for the terminal.Stream type. This is
+/// stateful and is expected to live for the entire lifetime of the terminal.
+/// It is NOT VALID to stop a stream handler, create a new one, and use that
+/// unless all of the member fields are copied.
+pub const StreamHandler = struct {
+ alloc: Allocator,
+ size: *renderer.Size,
+ terminal: *terminal.Terminal,
+
+ /// Mailbox for data to the termio thread.
+ termio_mailbox: *termio.Mailbox,
+
+ /// Mailbox for the surface.
+ surface_mailbox: apprt.surface.Mailbox,
+
+ /// The shared render state
+ renderer_state: *renderer.State,
+
+ /// The mailbox for notifying the renderer of things.
+ renderer_mailbox: *renderer.Thread.Mailbox,
+
+ /// A handle to wake up the renderer. This hints to the renderer that
+ /// a repaint should happen.
+ renderer_wakeup: xev.Async,
+
+ /// The default cursor state. This is used with CSI q. This is
+ /// set to true when we're currently in the default cursor state.
+ default_cursor: bool = true,
+ default_cursor_style: terminal.CursorStyle,
+ default_cursor_blink: ?bool,
+
+ /// The response to use for ENQ requests. The memory is owned by
+ /// whoever owns StreamHandler.
+ enquiry_response: []const u8,
+
+ /// The color reporting format for OSC requests.
+ osc_color_report_format: configpkg.Config.OSCColorReportFormat,
+
+ /// The clipboard write access configuration.
+ clipboard_write: configpkg.ClipboardAccess,
+
+ //---------------------------------------------------------------
+ // Internal state
+
+ /// The APC command handler maintains the APC state. APC is like
+ /// CSI or OSC, but it is a private escape sequence that is used
+ /// to send commands to the terminal emulator. This is used by
+ /// the kitty graphics protocol.
+ apc: terminal.apc.Handler = .{},
+
+ /// The DCS handler maintains DCS state. DCS is like CSI or OSC,
+ /// but requires more stateful parsing. This is used by functionality
+ /// such as XTGETTCAP.
+ dcs: terminal.dcs.Handler = .{},
+
+ /// The tmux control mode viewer state.
+ tmux_viewer: if (tmux_enabled) ?*terminal.tmux.Viewer else void = if (tmux_enabled) null else {},
+
+ /// This is set to true when a message was written to the termio
+ /// mailbox. This can be used by callers to determine if they need
+ /// to wake up the termio thread.
+ termio_messaged: bool = false,
+
+ /// This is set to true when we've seen a title escape sequence. We use
+ /// this to determine if we need to default the window title.
+ seen_title: bool = false,
+
+ pub const Stream = terminal.Stream(StreamHandler);
+
+ /// True if we have tmux control mode built in.
+ pub const tmux_enabled = terminal.options.tmux_control_mode;
+
+ pub fn deinit(self: *StreamHandler) void {
+ self.apc.deinit();
+ self.dcs.deinit();
+ if (comptime tmux_enabled) tmux: {
+ const viewer = self.tmux_viewer orelse break :tmux;
+ viewer.deinit();
+ self.alloc.destroy(viewer);
+ self.tmux_viewer = null;
+ }
+ }
+
+ /// This queues a render operation with the renderer thread. The render
+ /// isn't guaranteed to happen immediately but it will happen as soon as
+ /// practical.
+ pub inline fn queueRender(self: *StreamHandler) !void {
+ try self.renderer_wakeup.notify();
+ }
+
+ /// Change the configuration for this handler.
+ pub fn changeConfig(self: *StreamHandler, config: *termio.DerivedConfig) void {
+ self.osc_color_report_format = config.osc_color_report_format;
+ self.clipboard_write = config.clipboard_write;
+ self.enquiry_response = config.enquiry_response;
+ self.default_cursor_style = config.cursor_style;
+ self.default_cursor_blink = config.cursor_blink;
+
+ // If our cursor is the default, then we update it immediately.
+ if (self.default_cursor) self.setCursorStyle(.default) catch |err| {
+ log.warn("failed to set default cursor style: {}", .{err});
+ };
+
+ // The config could have changed any of our colors so update mode 2031
+ self.messageWriter(.{ .color_scheme_report = .{ .force = false } });
+ }
+
+ inline fn surfaceMessageWriter(
+ self: *StreamHandler,
+ msg: apprt.surface.Message,
+ ) void {
+ // See messageWriter which has similar logic and explains why
+ // we may have to do this.
+ if (self.surface_mailbox.push(msg, .{ .instant = {} }) == 0) {
+ self.renderer_state.mutex.unlock();
+ defer self.renderer_state.mutex.lock();
+ _ = self.surface_mailbox.push(msg, .{ .forever = {} });
+ }
+ }
+
+ inline fn messageWriter(self: *StreamHandler, msg: termio.Message) void {
+ self.termio_mailbox.send(msg, self.renderer_state.mutex);
+ self.termio_messaged = true;
+ }
+
+ /// Send a renderer message and unlock the renderer state mutex
+ /// if necessary to ensure we don't deadlock.
+ ///
+ /// This assumes the renderer state mutex is locked.
+ inline fn rendererMessageWriter(
+ self: *StreamHandler,
+ msg: renderer.Message,
+ ) void {
+ // See termio.Mailbox.send for more details on how this works.
+
+ // Try instant first. If it works then we can return.
+ if (self.renderer_mailbox.push(msg, .{ .instant = {} }) > 0) {
+ return;
+ }
+
+ // Instant would have blocked. Release the renderer mutex,
+ // wake up the renderer to allow it to process the message,
+ // and then try again.
+ self.renderer_state.mutex.unlock();
+ defer self.renderer_state.mutex.lock();
+ self.renderer_wakeup.notify() catch |err| {
+ // This is an EXTREMELY unlikely case. We still don't return
+ // and attempt to send the message because its most likely
+ // that everything is fine, but log in case a freeze happens.
+ log.warn(
+ "failed to notify renderer, may deadlock err={}",
+ .{err},
+ );
+ };
+ _ = self.renderer_mailbox.push(msg, .{ .forever = {} });
+ }
+
+ pub fn vt(
+ self: *StreamHandler,
+ comptime action: Stream.Action.Tag,
+ value: Stream.Action.Value(action),
+ ) void {
+ self.vtFallible(action, value) catch |err| {
+ log.warn("error handling VT action action={} err={}", .{ action, err });
+ };
+ }
+
+ inline fn vtFallible(
+ self: *StreamHandler,
+ comptime action: Stream.Action.Tag,
+ value: Stream.Action.Value(action),
+ ) !void {
+ // The branch hints here are based on real world data
+ // which indicates that the most common actions are:
+ //
+ // 1. print
+ // 2. set_attribute
+ // 3. carriage_return
+ // 4. line_feed
+ // 5. cursor_pos
+ //
+ // Together, these 5 actions make up nearly 98% of
+ // all actions encountered in real world scenarios.
+ //
+ // ref: https://github.com/qwerasd205/asciinema-stats
+ switch (action) {
+ .print => {
+ @branchHint(.likely);
+ try self.terminal.print(value.cp);
+ },
+ .print_repeat => try self.terminal.printRepeat(value),
+ .bell => self.bell(),
+ .backspace => self.terminal.backspace(),
+ .horizontal_tab => self.horizontalTab(value),
+ .horizontal_tab_back => self.horizontalTabBack(value),
+ .linefeed => {
+ @branchHint(.likely);
+ try self.linefeed();
+ },
+ .carriage_return => {
+ @branchHint(.likely);
+ self.terminal.carriageReturn();
+ },
+ .enquiry => try self.enquiry(),
+ .invoke_charset => self.terminal.invokeCharset(value.bank, value.charset, value.locking),
+ .cursor_up => self.terminal.cursorUp(value.value),
+ .cursor_down => self.terminal.cursorDown(value.value),
+ .cursor_left => self.terminal.cursorLeft(value.value),
+ .cursor_right => self.terminal.cursorRight(value.value),
+ .cursor_pos => {
+ @branchHint(.likely);
+ self.terminal.setCursorPos(value.row, value.col);
+ },
+ .cursor_col => self.terminal.setCursorPos(self.terminal.screens.active.cursor.y + 1, value.value),
+ .cursor_row => self.terminal.setCursorPos(value.value, self.terminal.screens.active.cursor.x + 1),
+ .cursor_col_relative => self.terminal.setCursorPos(
+ self.terminal.screens.active.cursor.y + 1,
+ self.terminal.screens.active.cursor.x + 1 +| value.value,
+ ),
+ .cursor_row_relative => self.terminal.setCursorPos(
+ self.terminal.screens.active.cursor.y + 1 +| value.value,
+ self.terminal.screens.active.cursor.x + 1,
+ ),
+ .cursor_style => try self.setCursorStyle(value),
+ .erase_display_below => self.terminal.eraseDisplay(.below, value),
+ .erase_display_above => self.terminal.eraseDisplay(.above, value),
+ .erase_display_complete => {
+ self.terminal.scrollViewport(.{ .bottom = {} });
+ self.terminal.eraseDisplay(.complete, value);
+ },
+ .erase_display_scrollback => self.terminal.eraseDisplay(.scrollback, value),
+ .erase_display_scroll_complete => self.terminal.eraseDisplay(.scroll_complete, value),
+ .erase_line_right => self.terminal.eraseLine(.right, value),
+ .erase_line_left => self.terminal.eraseLine(.left, value),
+ .erase_line_complete => self.terminal.eraseLine(.complete, value),
+ .erase_line_right_unless_pending_wrap => self.terminal.eraseLine(.right_unless_pending_wrap, value),
+ .delete_chars => self.terminal.deleteChars(value),
+ .erase_chars => self.terminal.eraseChars(value),
+ .insert_lines => self.terminal.insertLines(value),
+ .insert_blanks => self.terminal.insertBlanks(value),
+ .delete_lines => self.terminal.deleteLines(value),
+ .scroll_up => try self.terminal.scrollUp(value),
+ .scroll_down => self.terminal.scrollDown(value),
+ .tab_clear_current => self.terminal.tabClear(.current),
+ .tab_clear_all => self.terminal.tabClear(.all),
+ .tab_set => self.terminal.tabSet(),
+ .tab_reset => self.terminal.tabReset(),
+ .index => try self.index(),
+ .next_line => try self.nextLine(),
+ .reverse_index => try self.reverseIndex(),
+ .full_reset => try self.fullReset(),
+ .set_mode => try self.setMode(value.mode, true),
+ .reset_mode => try self.setMode(value.mode, false),
+ .save_mode => self.terminal.modes.save(value.mode),
+ .restore_mode => {
+ // For restore mode we have to restore but if we set it, we
+ // always have to call setMode because setting some modes have
+ // side effects and we want to make sure we process those.
+ const v = self.terminal.modes.restore(value.mode);
+ try self.setMode(value.mode, v);
+ },
+ .request_mode => try self.requestMode(value.mode),
+ .request_mode_unknown => try self.requestModeUnknown(value.mode, value.ansi),
+ .top_and_bottom_margin => self.terminal.setTopAndBottomMargin(value.top_left, value.bottom_right),
+ .left_and_right_margin => self.terminal.setLeftAndRightMargin(value.top_left, value.bottom_right),
+ .left_and_right_margin_ambiguous => {
+ if (self.terminal.modes.get(.enable_left_and_right_margin)) {
+ self.terminal.setLeftAndRightMargin(0, 0);
+ } else {
+ self.terminal.saveCursor();
+ }
+ },
+ .save_cursor => try self.saveCursor(),
+ .restore_cursor => try self.restoreCursor(),
+ .modify_key_format => try self.setModifyKeyFormat(value),
+ .protected_mode_off => self.terminal.setProtectedMode(.off),
+ .protected_mode_iso => self.terminal.setProtectedMode(.iso),
+ .protected_mode_dec => self.terminal.setProtectedMode(.dec),
+ .mouse_shift_capture => self.terminal.flags.mouse_shift_capture = if (value) .true else .false,
+ .size_report => self.sendSizeReport(value),
+ .xtversion => try self.reportXtversion(),
+ .device_attributes => try self.deviceAttributes(value),
+ .device_status => try self.deviceStatusReport(value.request),
+ .kitty_keyboard_query => try self.queryKittyKeyboard(),
+ .kitty_keyboard_push => {
+ log.debug("pushing kitty keyboard mode: {}", .{value.flags});
+ self.terminal.screens.active.kitty_keyboard.push(value.flags);
+ },
+ .kitty_keyboard_pop => {
+ log.debug("popping kitty keyboard mode n={}", .{value});
+ self.terminal.screens.active.kitty_keyboard.pop(@intCast(value));
+ },
+ .kitty_keyboard_set => {
+ log.debug("setting kitty keyboard mode: set {}", .{value.flags});
+ self.terminal.screens.active.kitty_keyboard.set(.set, value.flags);
+ },
+ .kitty_keyboard_set_or => {
+ log.debug("setting kitty keyboard mode: or {}", .{value.flags});
+ self.terminal.screens.active.kitty_keyboard.set(.@"or", value.flags);
+ },
+ .kitty_keyboard_set_not => {
+ log.debug("setting kitty keyboard mode: not {}", .{value.flags});
+ self.terminal.screens.active.kitty_keyboard.set(.not, value.flags);
+ },
+ .kitty_color_report => try self.kittyColorReport(value),
+ .color_operation => try self.colorOperation(value.op, &value.requests, value.terminator),
+ .end_hyperlink => try self.endHyperlink(),
+ .active_status_display => self.terminal.status_display = value,
+ .decaln => try self.decaln(),
+ .window_title => try self.windowTitle(value.title),
+ .report_pwd => try self.reportPwd(value.url),
+ .show_desktop_notification => try self.showDesktopNotification(value.title, value.body),
+ .progress_report => self.progressReport(value),
+ .start_hyperlink => try self.startHyperlink(value.uri, value.id),
+ .clipboard_contents => try self.clipboardContents(value.kind, value.data),
+ .semantic_prompt => try self.semanticPrompt(value),
+ .mouse_shape => try self.setMouseShape(value),
+ .configure_charset => self.configureCharset(value.slot, value.charset),
+ .set_attribute => {
+ @branchHint(.likely);
+ switch (value) {
+ .unknown => |unk| {
+ // We optimize for the happy path scenario here, since
+ // unknown/invalid SGRs aren't that common in the wild.
+ @branchHint(.unlikely);
+ log.warn("unimplemented or unknown SGR attribute: {any}", .{unk});
+ },
+ else => {
+ @branchHint(.likely);
+ self.terminal.setAttribute(value) catch |err| {
+ @branchHint(.cold);
+ log.warn("error setting attribute {}: {}", .{ value, err });
+ };
+ },
+ }
+ },
+ .dcs_hook => try self.dcsHook(value),
+ .dcs_put => try self.dcsPut(value),
+ .dcs_unhook => try self.dcsUnhook(),
+ .apc_start => self.apc.start(),
+ .apc_end => try self.apcEnd(),
+ .apc_put => self.apc.feed(self.alloc, value),
+
+ // Unimplemented
+ .title_push,
+ .title_pop,
+ => {},
+ }
+ }
+
+ pub inline fn dcsHook(self: *StreamHandler, dcs: terminal.DCS) !void {
+ var cmd = self.dcs.hook(self.alloc, dcs) orelse return;
+ defer cmd.deinit();
+ try self.dcsCommand(&cmd);
+ }
+
+ pub inline fn dcsPut(self: *StreamHandler, byte: u8) !void {
+ var cmd = self.dcs.put(byte) orelse return;
+ defer cmd.deinit();
+ try self.dcsCommand(&cmd);
+ }
+
+ pub inline fn dcsUnhook(self: *StreamHandler) !void {
+ var cmd = self.dcs.unhook() orelse return;
+ defer cmd.deinit();
+ try self.dcsCommand(&cmd);
+ }
+
+ fn dcsCommand(self: *StreamHandler, cmd: *terminal.dcs.Command) !void {
+ // log.warn("DCS command: {}", .{cmd});
+ switch (cmd.*) {
+ .tmux => |tmux| tmux: {
+ // If tmux control mode is disabled at the build level,
+ // then this whole block shouldn't be analyzed.
+ if (comptime !tmux_enabled) break :tmux;
+ log.info("tmux control mode event cmd={f}", .{tmux});
+
+ switch (tmux) {
+ .enter => {
+ // Setup our viewer state
+ assert(self.tmux_viewer == null);
+ const viewer = try self.alloc.create(terminal.tmux.Viewer);
+ errdefer self.alloc.destroy(viewer);
+ viewer.* = try .init(self.alloc);
+ errdefer viewer.deinit();
+ self.tmux_viewer = viewer;
+ break :tmux;
+ },
+
+ .exit => {
+ // Free our viewer state if we have one
+ if (self.tmux_viewer) |viewer| {
+ viewer.deinit();
+ self.alloc.destroy(viewer);
+ self.tmux_viewer = null;
+ }
+
+ // And always break since we assert below
+ // that we're not handling an exit command.
+ break :tmux;
+ },
+
+ else => {},
+ }
+
+ assert(tmux != .enter);
+ assert(tmux != .exit);
+
+ const viewer = self.tmux_viewer orelse {
+ // This can only really happen if we failed to
+ // initialize the viewer on enter.
+ log.info(
+ "received tmux control mode command without viewer: {f}",
+ .{tmux},
+ );
+
+ break :tmux;
+ };
+
+ for (viewer.next(.{ .tmux = tmux })) |action| {
+ log.info("tmux viewer action={f}", .{action});
+ switch (action) {
+ .exit => {
+ // We ignore this because we will fully exit when
+ // our DCS connection ends. We may want to handle
+ // this in the future to notify our GUI we're
+ // disconnected though.
+ },
+
+ .command => |command| {
+ assert(command.len > 0);
+ assert(command[command.len - 1] == '\n');
+ self.messageWriter(try termio.Message.writeReq(
+ self.alloc,
+ command,
+ ));
+ },
+
+ .windows => {
+ // TODO
+ },
+ }
+ }
+ },
+
+ .xtgettcap => |*gettcap| {
+ const map = comptime terminfo.ghostty.xtgettcapMap();
+ while (gettcap.next()) |key| {
+ const response = map.get(key) orelse continue;
+ self.messageWriter(.{ .write_stable = response });
+ }
+ },
+
+ .decrqss => |decrqss| {
+ var response: [128]u8 = undefined;
+ var stream = std.io.fixedBufferStream(&response);
+ const writer = stream.writer();
+
+ // Offset the stream position to just past the response prefix.
+ // We will write the "payload" (if any) below. If no payload is
+ // written then we send an invalid DECRPSS response.
+ const prefix_fmt = "\x1bP{d}$r";
+ const prefix_len = std.fmt.comptimePrint(prefix_fmt, .{0}).len;
+ stream.pos = prefix_len;
+
+ switch (decrqss) {
+ // Invalid or unhandled request
+ .none => {},
+
+ .sgr => {
+ const buf = try self.terminal.printAttributes(stream.buffer[stream.pos..]);
+
+ // printAttributes wrote into our buffer, so adjust the stream
+ // position
+ stream.pos += buf.len;
+
+ try writer.writeByte('m');
+ },
+
+ .decscusr => {
+ const blink = self.terminal.modes.get(.cursor_blinking);
+ const style: u8 = switch (self.terminal.screens.active.cursor.cursor_style) {
+ .block => if (blink) 1 else 2,
+ .underline => if (blink) 3 else 4,
+ .bar => if (blink) 5 else 6,
+
+ // Below here, the cursor styles aren't represented by
+ // DECSCUSR so we map it to some other style.
+ .block_hollow => if (blink) 1 else 2,
+ };
+ try writer.print("{d} q", .{style});
+ },
+
+ .decstbm => {
+ try writer.print("{d};{d}r", .{
+ self.terminal.scrolling_region.top + 1,
+ self.terminal.scrolling_region.bottom + 1,
+ });
+ },
+
+ .decslrm => {
+ // We only send a valid response when left and right
+ // margin mode (DECLRMM) is enabled.
+ if (self.terminal.modes.get(.enable_left_and_right_margin)) {
+ try writer.print("{d};{d}s", .{
+ self.terminal.scrolling_region.left + 1,
+ self.terminal.scrolling_region.right + 1,
+ });
+ }
+ },
+ }
+
+ // Our response is valid if we have a response payload
+ const valid = stream.pos > prefix_len;
+
+ // Write the terminator
+ try writer.writeAll("\x1b\\");
+
+ // Write the response prefix into the buffer
+ _ = try std.fmt.bufPrint(response[0..prefix_len], prefix_fmt, .{@intFromBool(valid)});
+ const msg = try termio.Message.writeReq(self.alloc, response[0..stream.pos]);
+ self.messageWriter(msg);
+ },
+ }
+ }
+
+ pub fn apcEnd(self: *StreamHandler) !void {
+ var cmd = self.apc.end() orelse return;
+ defer cmd.deinit(self.alloc);
+
+ // log.warn("APC command: {}", .{cmd});
+ switch (cmd) {
+ .kitty => |*kitty_cmd| {
+ if (self.terminal.kittyGraphics(self.alloc, kitty_cmd)) |resp| {
+ var buf: [1024]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ try resp.encode(&writer);
+ const final = writer.buffered();
+ if (final.len > 2) {
+ log.debug("kitty graphics response: {x}", .{final});
+ self.messageWriter(try termio.Message.writeReq(self.alloc, final));
+ }
+ }
+ },
+ }
+ }
+
+ inline fn bell(self: *StreamHandler) void {
+ self.surfaceMessageWriter(.ring_bell);
+ }
+
+ inline fn horizontalTab(self: *StreamHandler, count: u16) void {
+ for (0..count) |_| {
+ const x = self.terminal.screens.active.cursor.x;
+ self.terminal.horizontalTab();
+ if (x == self.terminal.screens.active.cursor.x) break;
+ }
+ }
+
+ inline fn horizontalTabBack(self: *StreamHandler, count: u16) void {
+ for (0..count) |_| {
+ const x = self.terminal.screens.active.cursor.x;
+ self.terminal.horizontalTabBack();
+ if (x == self.terminal.screens.active.cursor.x) break;
+ }
+ }
+
+ inline fn linefeed(self: *StreamHandler) !void {
+ // Small optimization: call index instead of linefeed because they're
+ // identical and this avoids one layer of function call overhead.
+ try self.terminal.index();
+ }
+
+ pub inline fn reverseIndex(self: *StreamHandler) !void {
+ self.terminal.reverseIndex();
+ }
+
+ pub inline fn index(self: *StreamHandler) !void {
+ try self.terminal.index();
+ }
+
+ pub inline fn nextLine(self: *StreamHandler) !void {
+ try self.terminal.index();
+ self.terminal.carriageReturn();
+ }
+
+ pub fn setModifyKeyFormat(self: *StreamHandler, format: terminal.ModifyKeyFormat) !void {
+ self.terminal.flags.modify_other_keys_2 = false;
+ switch (format) {
+ .other_keys_numeric => self.terminal.flags.modify_other_keys_2 = true,
+ else => {},
+ }
+ }
+
+ fn requestMode(self: *StreamHandler, mode: terminal.Mode) !void {
+ self.sendModeReport(self.terminal.modes.getReport(.fromMode(mode)));
+ }
+
+ fn requestModeUnknown(self: *StreamHandler, mode_raw: u16, ansi: bool) !void {
+ self.sendModeReport(self.terminal.modes.getReport(.{ .value = @truncate(mode_raw), .ansi = ansi }));
+ }
+
+ fn sendModeReport(self: *StreamHandler, report: terminal.modes.Report) void {
+ var data: termio.Message.WriteReq.Small.Array = undefined;
+ var writer: std.Io.Writer = .fixed(&data);
+ report.encode(&writer) catch |err| {
+ log.err("error encoding mode report err={}", .{err});
+ return;
+ };
+ self.messageWriter(.{ .write_small = .{
+ .data = data,
+ .len = @intCast(writer.buffered().len),
+ } });
+ }
+
+ pub fn setMode(self: *StreamHandler, mode: terminal.Mode, enabled: bool) !void {
+ // Note: this function doesn't need to grab the render state or
+ // terminal locks because it is only called from process() which
+ // grabs the lock.
+
+ // If we are setting cursor blinking, we ignore it if we have
+ // a default cursor blink setting set. This is a really weird
+ // behavior so this comment will go deep into trying to explain it.
+ //
+ // There are two ways to set cursor blinks: DECSCUSR (CSI _ q)
+ // and DEC mode 12. DECSCUSR is the modern approach and has a
+ // way to revert to the "default" (as defined by the terminal)
+ // cursor style and blink by doing "CSI 0 q". DEC mode 12 controls
+ // blinking and is either on or off and has no way to set a
+ // default. DEC mode 12 is also the more antiquated approach.
+ //
+ // The problem is that if the user specifies a desired default
+ // cursor blink with `cursor-style-blink`, the moment a running
+ // program uses DEC mode 12, the cursor blink can never be reset
+ // to the default without an explicit DECSCUSR. But if a program
+ // is using mode 12, it is by definition not using DECSCUSR.
+ // This makes for somewhat annoying interactions where a poorly
+ // (or legacy) behaved program will stop blinking, and it simply
+ // never restarts.
+ //
+ // To get around this, we have a special case where if the user
+ // specifies some explicit default cursor blink desire, we ignore
+ // DEC mode 12. We allow DECSCUSR to still set the cursor blink
+ // because programs using DECSCUSR usually are well behaved and
+ // reset the cursor blink to the default when they exit.
+ //
+ // To be extra safe, users can also add a manual `CSI 0 q` to
+ // their shell config when they render prompts to ensure the
+ // cursor is exactly as they request.
+ if (mode == .cursor_blinking and
+ self.default_cursor_blink != null)
+ {
+ return;
+ }
+
+ // We first always set the raw mode on our mode state.
+ self.terminal.modes.set(mode, enabled);
+
+ // And then some modes require additional processing.
+ switch (mode) {
+ // Just noting here that autorepeat has no effect on
+ // the terminal. xterm ignores this mode and so do we.
+ // We know about just so that we don't log that it is
+ // an unknown mode.
+ .autorepeat => {},
+
+ // Schedule a render since we changed colors
+ .reverse_colors => self.terminal.flags.dirty.reverse_colors = true,
+
+ // Origin resets cursor pos. This is called whether or not
+ // we're enabling or disabling origin mode and whether or
+ // not the value changed.
+ .origin => self.terminal.setCursorPos(1, 1),
+
+ .enable_left_and_right_margin => if (!enabled) {
+ // When we disable left/right margin mode we need to
+ // reset the left/right margins.
+ self.terminal.scrolling_region.left = 0;
+ self.terminal.scrolling_region.right = self.terminal.cols - 1;
+ },
+
+ .alt_screen_legacy => {
+ try self.terminal.switchScreenMode(.@"47", enabled);
+ },
+
+ .alt_screen => {
+ try self.terminal.switchScreenMode(.@"1047", enabled);
+ },
+
+ .alt_screen_save_cursor_clear_enter => {
+ try self.terminal.switchScreenMode(.@"1049", enabled);
+ },
+
+ // Mode 1048 is xterm's conditional save cursor depending
+ // on if alt screen is enabled or not (at the terminal emulator
+ // level). Alt screen is always enabled for us so this just
+ // does a save/restore cursor.
+ .save_cursor => {
+ if (enabled) {
+ self.terminal.saveCursor();
+ } else {
+ self.terminal.restoreCursor();
+ }
+ },
+
+ // Force resize back to the window size
+ .enable_mode_3 => {
+ const grid_size = self.size.grid();
+ self.terminal.resize(
+ self.alloc,
+ grid_size.columns,
+ grid_size.rows,
+ ) catch |err| {
+ log.err("error updating terminal size: {}", .{err});
+ };
+ },
+
+ .@"132_column" => try self.terminal.deccolm(
+ self.alloc,
+ if (enabled) .@"132_cols" else .@"80_cols",
+ ),
+
+ // We need to start a timer to prevent the emulator being hung
+ // forever.
+ .synchronized_output => {
+ if (enabled) self.messageWriter(.{ .start_synchronized_output = {} });
+ },
+
+ .linefeed => {
+ self.messageWriter(.{ .linefeed_mode = enabled });
+ },
+
+ .in_band_size_reports => if (enabled) self.messageWriter(.{
+ .size_report = .mode_2048,
+ }),
+
+ .focus_event => if (enabled) self.messageWriter(.{
+ .focused = self.terminal.flags.focused,
+ }),
+
+ .mouse_event_x10 => {
+ if (enabled) {
+ self.terminal.flags.mouse_event = .x10;
+ try self.setMouseShape(.default);
+ } else {
+ self.terminal.flags.mouse_event = .none;
+ try self.setMouseShape(.text);
+ }
+ },
+ .mouse_event_normal => {
+ if (enabled) {
+ self.terminal.flags.mouse_event = .normal;
+ try self.setMouseShape(.default);
+ } else {
+ self.terminal.flags.mouse_event = .none;
+ try self.setMouseShape(.text);
+ }
+ },
+ .mouse_event_button => {
+ if (enabled) {
+ self.terminal.flags.mouse_event = .button;
+ try self.setMouseShape(.default);
+ } else {
+ self.terminal.flags.mouse_event = .none;
+ try self.setMouseShape(.text);
+ }
+ },
+ .mouse_event_any => {
+ if (enabled) {
+ self.terminal.flags.mouse_event = .any;
+ try self.setMouseShape(.default);
+ } else {
+ self.terminal.flags.mouse_event = .none;
+ try self.setMouseShape(.text);
+ }
+ },
+
+ .mouse_format_utf8 => self.terminal.flags.mouse_format = if (enabled) .utf8 else .x10,
+ .mouse_format_sgr => self.terminal.flags.mouse_format = if (enabled) .sgr else .x10,
+ .mouse_format_urxvt => self.terminal.flags.mouse_format = if (enabled) .urxvt else .x10,
+ .mouse_format_sgr_pixels => self.terminal.flags.mouse_format = if (enabled) .sgr_pixels else .x10,
+
+ else => {},
+ }
+ }
+
+ inline fn startHyperlink(self: *StreamHandler, uri: []const u8, id: ?[]const u8) !void {
+ try self.terminal.screens.active.startHyperlink(uri, id);
+ }
+
+ pub inline fn endHyperlink(self: *StreamHandler) !void {
+ self.terminal.screens.active.endHyperlink();
+ }
+
+ pub fn deviceAttributes(
+ self: *StreamHandler,
+ req: terminal.DeviceAttributeReq,
+ ) !void {
+ // For the below, we quack as a VT220. We don't quack as
+ // a 420 because we don't support DCS sequences.
+ switch (req) {
+ .primary => self.messageWriter(.{
+ // 62 = Level 2 conformance
+ // 22 = Color text
+ // 52 = Clipboard access
+ .write_stable = if (self.clipboard_write != .deny)
+ "\x1B[?62;22;52c"
+ else
+ "\x1B[?62;22c",
+ }),
+
+ .secondary => self.messageWriter(.{
+ .write_stable = "\x1B[>1;10;0c",
+ }),
+
+ else => log.warn("unimplemented device attributes req: {}", .{req}),
+ }
+ }
+
+ pub fn deviceStatusReport(
+ self: *StreamHandler,
+ req: terminal.device_status.Request,
+ ) !void {
+ switch (req) {
+ .operating_status => self.messageWriter(.{ .write_stable = "\x1B[0n" }),
+
+ .cursor_position => {
+ const pos: struct {
+ x: usize,
+ y: usize,
+ } = if (self.terminal.modes.get(.origin)) .{
+ .x = self.terminal.screens.active.cursor.x -| self.terminal.scrolling_region.left,
+ .y = self.terminal.screens.active.cursor.y -| self.terminal.scrolling_region.top,
+ } else .{
+ .x = self.terminal.screens.active.cursor.x,
+ .y = self.terminal.screens.active.cursor.y,
+ };
+
+ // Response always is at least 4 chars, so this leaves the
+ // remainder for the row/column as base-10 numbers. This
+ // will support a very large terminal.
+ var msg: termio.Message = .{ .write_small = .{} };
+ const resp = try std.fmt.bufPrint(&msg.write_small.data, "\x1B[{};{}R", .{
+ pos.y + 1,
+ pos.x + 1,
+ });
+ msg.write_small.len = @intCast(resp.len);
+
+ self.messageWriter(msg);
+ },
+
+ .color_scheme => self.messageWriter(.{ .color_scheme_report = .{ .force = true } }),
+ }
+ }
+
+ pub fn setCursorStyle(
+ self: *StreamHandler,
+ style: terminal.CursorStyleReq,
+ ) !void {
+ // Assume we're setting to a non-default.
+ self.default_cursor = false;
+
+ switch (style) {
+ .default => {
+ self.default_cursor = true;
+ self.terminal.screens.active.cursor.cursor_style = self.default_cursor_style;
+ self.terminal.modes.set(
+ .cursor_blinking,
+ self.default_cursor_blink orelse true,
+ );
+ },
+
+ .blinking_block => {
+ self.terminal.screens.active.cursor.cursor_style = .block;
+ self.terminal.modes.set(.cursor_blinking, true);
+ },
+
+ .steady_block => {
+ self.terminal.screens.active.cursor.cursor_style = .block;
+ self.terminal.modes.set(.cursor_blinking, false);
+ },
+
+ .blinking_underline => {
+ self.terminal.screens.active.cursor.cursor_style = .underline;
+ self.terminal.modes.set(.cursor_blinking, true);
+ },
+
+ .steady_underline => {
+ self.terminal.screens.active.cursor.cursor_style = .underline;
+ self.terminal.modes.set(.cursor_blinking, false);
+ },
+
+ .blinking_bar => {
+ self.terminal.screens.active.cursor.cursor_style = .bar;
+ self.terminal.modes.set(.cursor_blinking, true);
+ },
+
+ .steady_bar => {
+ self.terminal.screens.active.cursor.cursor_style = .bar;
+ self.terminal.modes.set(.cursor_blinking, false);
+ },
+ }
+ }
+
+ pub inline fn decaln(self: *StreamHandler) !void {
+ try self.terminal.decaln();
+ }
+
+ pub inline fn saveCursor(self: *StreamHandler) !void {
+ self.terminal.saveCursor();
+ }
+
+ pub inline fn restoreCursor(self: *StreamHandler) !void {
+ self.terminal.restoreCursor();
+ }
+
+ pub fn enquiry(self: *StreamHandler) !void {
+ log.debug("sending enquiry response={s}", .{self.enquiry_response});
+ self.messageWriter(try termio.Message.writeReq(self.alloc, self.enquiry_response));
+ }
+
+ fn configureCharset(
+ self: *StreamHandler,
+ slot: terminal.CharsetSlot,
+ set: terminal.Charset,
+ ) void {
+ self.terminal.configureCharset(slot, set);
+ }
+
+ pub fn fullReset(
+ self: *StreamHandler,
+ ) !void {
+ self.terminal.fullReset();
+ try self.setMouseShape(.text);
+
+ // Reset resets our palette so we report it for mode 2031.
+ self.messageWriter(.{ .color_scheme_report = .{ .force = false } });
+
+ // Clear the progress bar
+ self.progressReport(.{ .state = .remove });
+ }
+
+ pub fn queryKittyKeyboard(self: *StreamHandler) !void {
+ log.debug("querying kitty keyboard mode", .{});
+ var data: termio.Message.WriteReq.Small.Array = undefined;
+ const resp = try std.fmt.bufPrint(&data, "\x1b[?{}u", .{
+ self.terminal.screens.active.kitty_keyboard.current().int(),
+ });
+
+ self.messageWriter(.{
+ .write_small = .{
+ .data = data,
+ .len = @intCast(resp.len),
+ },
+ });
+ }
+
+ pub fn reportXtversion(
+ self: *StreamHandler,
+ ) !void {
+ log.debug("reporting XTVERSION: ghostty {s}", .{build_config.version_string});
+ var buf: [288]u8 = undefined;
+ const resp = try std.fmt.bufPrint(
+ &buf,
+ "\x1BP>|{s} {s}\x1B\\",
+ .{
+ "ghostty",
+ build_config.version_string,
+ },
+ );
+ const msg = try termio.Message.writeReq(self.alloc, resp);
+ self.messageWriter(msg);
+ }
+
+ //-------------------------------------------------------------------------
+ // OSC
+
+ fn windowTitle(self: *StreamHandler, title: []const u8) !void {
+ var buf: [256]u8 = undefined;
+ if (title.len >= buf.len) {
+ log.warn("change title requested larger than our buffer size, ignoring", .{});
+ return;
+ }
+
+ // Set the title on the terminal state. We ignore any errors since
+ // we can continue to operate just fine without it.
+ self.terminal.setTitle(title) catch |err| {
+ log.warn("error setting title in terminal state: {}", .{err});
+ };
+
+ @memcpy(buf[0..title.len], title);
+ buf[title.len] = 0;
+
+ // Special handling for the empty title. We treat the empty title
+ // as resetting to as if we never saw a title. Other terminals
+ // behave this way too (e.g. iTerm2).
+ if (title.len == 0) {
+ // If we have a pwd then we set the title as the pwd else
+ // we just set it to blank.
+ if (self.terminal.getPwd()) |pwd| pwd: {
+ if (pwd.len >= buf.len) break :pwd;
+ @memcpy(buf[0..pwd.len], pwd);
+ buf[pwd.len] = 0;
+ }
+
+ self.surfaceMessageWriter(.{ .set_title = buf });
+ self.seen_title = false;
+ return;
+ }
+
+ self.seen_title = true;
+ self.surfaceMessageWriter(.{ .set_title = buf });
+ }
+
+ inline fn setMouseShape(
+ self: *StreamHandler,
+ shape: terminal.MouseShape,
+ ) !void {
+ // Avoid changing the shape if it is already set to avoid excess
+ // cross-thread messaging.
+ if (self.terminal.mouse_shape == shape) return;
+
+ self.terminal.mouse_shape = shape;
+ self.surfaceMessageWriter(.{ .set_mouse_shape = shape });
+ }
+
+ fn clipboardContents(self: *StreamHandler, kind: u8, data: []const u8) !void {
+ // Note: we ignore the "kind" field and always use the standard clipboard.
+ // iTerm also appears to do this but other terminals seem to only allow
+ // certain. Let's investigate more.
+
+ const clipboard_type: apprt.Clipboard = switch (kind) {
+ 'c' => .standard,
+ 's' => .selection,
+ 'p' => .primary,
+ else => .standard,
+ };
+
+ // Get clipboard contents
+ if (data.len == 1 and data[0] == '?') {
+ self.surfaceMessageWriter(.{ .clipboard_read = clipboard_type });
+ return;
+ }
+
+ // Write clipboard contents
+ self.surfaceMessageWriter(.{
+ .clipboard_write = .{
+ .req = try apprt.surface.Message.WriteReq.init(
+ self.alloc,
+ data,
+ ),
+ .clipboard_type = clipboard_type,
+ },
+ });
+ }
+
+ fn semanticPrompt(
+ self: *StreamHandler,
+ cmd: Stream.Action.SemanticPrompt,
+ ) !void {
+ switch (cmd.action) {
+ .end_input_start_output => {
+ self.surfaceMessageWriter(.start_command);
+ },
+
+ .end_command => {
+ // The specification seems to not specify the type but
+ // other terminals accept 32-bits, but exit codes are really
+ // bytes, so we just do our best here.
+ const code: u8 = code: {
+ const raw: i32 = cmd.readOption(.exit_code) orelse 0;
+ break :code std.math.cast(u8, raw) orelse 1;
+ };
+
+ self.surfaceMessageWriter(.{ .stop_command = code });
+ },
+
+ // Handled by Terminal, no special handling by us
+ .end_prompt_start_input,
+ .end_prompt_start_input_terminate_eol,
+ .fresh_line,
+ .fresh_line_new_prompt,
+ .new_command,
+ .prompt_start,
+ => {},
+ }
+
+ // We do this last so failures are still processed correctly
+ // above.
+ try self.terminal.semanticPrompt(cmd);
+ }
+
+ fn reportPwd(self: *StreamHandler, url: []const u8) !void {
+ // Special handling for the empty URL. We treat the empty URL
+ // as resetting the pwd as if we never saw a pwd. I can't find any
+ // other terminal that does this but it seems like a reasonable
+ // behavior that enables some useful features. For example, the macOS
+ // proxy icon can be hidden when a program reports it doesn't know
+ // the pwd rather than showing a stale pwd.
+ if (url.len == 0) {
+ // Blank value can never fail because no allocs happen.
+ self.terminal.setPwd("") catch unreachable;
+
+ // If we haven't seen a title, we're using the pwd as our title.
+ // Set it to blank which will reset our title behavior.
+ if (!self.seen_title) {
+ try self.windowTitle("");
+ assert(!self.seen_title);
+ }
+
+ // Report the change.
+ self.surfaceMessageWriter(.{ .pwd_change = .{ .stable = "" } });
+ return;
+ }
+
+ if (builtin.os.tag == .windows) {
+ log.warn("reportPwd unimplemented on windows", .{});
+ return;
+ }
+
+ // Attempt to parse this file-style URI using options appropriate
+ // for this OSC 7 context (e.g. kitty-shell-cwd expects the full,
+ // unencoded path).
+ const uri: std.Uri = internal_os.uri.parse(url, .{
+ .mac_address = comptime builtin.os.tag != .macos,
+ .raw_path = std.mem.startsWith(u8, url, "kitty-shell-cwd://"),
+ }) catch |e| {
+ log.warn("invalid url in OSC 7: {}", .{e});
+ return;
+ };
+
+ if (!std.mem.eql(u8, "file", uri.scheme) and
+ !std.mem.eql(u8, "kitty-shell-cwd", uri.scheme))
+ {
+ log.warn("OSC 7 scheme must be file or kitty-shell-cwd, got: {s}", .{uri.scheme});
+ return;
+ }
+
+ var host_buffer: [std.Uri.host_name_max]u8 = undefined;
+ const host = uri.getHost(&host_buffer) catch |err| switch (err) {
+ error.UriMissingHost => {
+ log.warn("OSC 7 uri must contain a hostname: {}", .{err});
+ return;
+ },
+ error.UriHostTooLong => {
+ log.warn("failed to get full hostname for OSC 7 validation: {}", .{err});
+ return;
+ },
+ };
+
+ // OSC 7 is a little sketchy because anyone can send any value from
+ // any host (such an SSH session). The best practice terminals follow
+ // is to valid the hostname to be local.
+ const host_valid = internal_os.hostname.isLocal(host) catch |err| switch (err) {
+ error.PermissionDenied,
+ error.Unexpected,
+ => {
+ log.warn("failed to get hostname for OSC 7 validation: {}", .{err});
+ return;
+ },
+ };
+ if (!host_valid) {
+ log.warn("OSC 7 host ({s}) must be local", .{host});
+ return;
+ }
+
+ // We need the raw path, which might require unescaping. We try to
+ // avoid making any heap allocations by using the stack first.
+ var arena_alloc: std.heap.ArenaAllocator = .init(self.alloc);
+ var stack_alloc = std.heap.stackFallback(1024, arena_alloc.allocator());
+ defer arena_alloc.deinit();
+ const path = try uri.path.toRawMaybeAlloc(stack_alloc.get());
+
+ log.debug("terminal pwd: {s}", .{path});
+ try self.terminal.setPwd(path);
+
+ // Report it to the surface. If creating our write request fails
+ // then we just ignore it.
+ if (apprt.surface.Message.WriteReq.init(self.alloc, path)) |req| {
+ self.surfaceMessageWriter(.{ .pwd_change = req });
+ } else |err| {
+ log.warn("error notifying surface of pwd change err={}", .{err});
+ }
+
+ // If we haven't seen a title, use our pwd as the title.
+ if (!self.seen_title) {
+ try self.windowTitle(path);
+ self.seen_title = false;
+ }
+ }
+
+ fn colorOperation(
+ self: *StreamHandler,
+ op: terminal.osc.color.Operation,
+ requests: *const terminal.osc.color.List,
+ terminator: terminal.osc.Terminator,
+ ) !void {
+ // We'll need op one day if we ever implement reporting special colors.
+ _ = op;
+
+ // return early if there is nothing to do
+ if (requests.count() == 0) return;
+
+ var buffer: [1024]u8 = undefined;
+ var fba: std.heap.FixedBufferAllocator = .init(&buffer);
+ const alloc = fba.allocator();
+
+ var response: std.ArrayListUnmanaged(u8) = .empty;
+ const writer = response.writer(alloc);
+
+ var it = requests.constIterator(0);
+ while (it.next()) |req| {
+ switch (req.*) {
+ .set => |set| {
+ switch (set.target) {
+ .palette => |i| {
+ self.terminal.flags.dirty.palette = true;
+ self.terminal.colors.palette.set(i, set.color);
+ },
+ .dynamic => |dynamic| switch (dynamic) {
+ .foreground => self.terminal.colors.foreground.set(set.color),
+ .background => self.terminal.colors.background.set(set.color),
+ .cursor => self.terminal.colors.cursor.set(set.color),
+ .pointer_foreground,
+ .pointer_background,
+ .tektronix_foreground,
+ .tektronix_background,
+ .highlight_background,
+ .tektronix_cursor,
+ .highlight_foreground,
+ => log.info("setting dynamic color {s} not implemented", .{
+ @tagName(dynamic),
+ }),
+ },
+ .special => log.info("setting special colors not implemented", .{}),
+ }
+
+ // Notify the surface of the color change
+ self.surfaceMessageWriter(.{ .color_change = .{
+ .target = set.target,
+ .color = set.color,
+ } });
+ },
+
+ .reset => |target| switch (target) {
+ .palette => |i| {
+ self.terminal.flags.dirty.palette = true;
+ self.terminal.colors.palette.reset(i);
+
+ self.surfaceMessageWriter(.{
+ .color_change = .{
+ .target = target,
+ .color = self.terminal.colors.palette.current[i],
+ },
+ });
+ },
+ .dynamic => |dynamic| switch (dynamic) {
+ .foreground => {
+ self.terminal.colors.foreground.reset();
+
+ if (self.terminal.colors.foreground.default) |c| {
+ self.surfaceMessageWriter(.{ .color_change = .{
+ .target = target,
+ .color = c,
+ } });
+ }
+ },
+ .background => {
+ self.terminal.colors.background.reset();
+
+ if (self.terminal.colors.background.default) |c| {
+ self.surfaceMessageWriter(.{ .color_change = .{
+ .target = target,
+ .color = c,
+ } });
+ }
+ },
+ .cursor => {
+ self.terminal.colors.cursor.reset();
+
+ if (self.terminal.colors.cursor.default) |c| {
+ self.surfaceMessageWriter(.{ .color_change = .{
+ .target = target,
+ .color = c,
+ } });
+ }
+ },
+ .pointer_foreground,
+ .pointer_background,
+ .tektronix_foreground,
+ .tektronix_background,
+ .highlight_background,
+ .tektronix_cursor,
+ .highlight_foreground,
+ => log.warn("resetting dynamic color {s} not implemented", .{
+ @tagName(dynamic),
+ }),
+ },
+ .special => log.info("resetting special colors not implemented", .{}),
+ },
+
+ .reset_palette => {
+ const mask = &self.terminal.colors.palette.mask;
+ var mask_it = mask.iterator(.{});
+ while (mask_it.next()) |i| {
+ self.terminal.flags.dirty.palette = true;
+ self.terminal.colors.palette.reset(@intCast(i));
+ self.surfaceMessageWriter(.{
+ .color_change = .{
+ .target = .{ .palette = @intCast(i) },
+ .color = self.terminal.colors.palette.current[i],
+ },
+ });
+ }
+ mask.* = .initEmpty();
+ },
+
+ .reset_special => log.warn(
+ "resetting all special colors not implemented",
+ .{},
+ ),
+
+ .query => |kind| report: {
+ if (self.osc_color_report_format == .none) break :report;
+
+ const color = switch (kind) {
+ .palette => |i| self.terminal.colors.palette.current[i],
+ .dynamic => |dynamic| switch (dynamic) {
+ .foreground => self.terminal.colors.foreground.get().?,
+ .background => self.terminal.colors.background.get().?,
+ .cursor => self.terminal.colors.cursor.get() orelse
+ self.terminal.colors.foreground.get().?,
+ .pointer_foreground,
+ .pointer_background,
+ .tektronix_foreground,
+ .tektronix_background,
+ .highlight_background,
+ .tektronix_cursor,
+ .highlight_foreground,
+ => {
+ log.info(
+ "reporting dynamic color {s} not implemented",
+ .{@tagName(dynamic)},
+ );
+ break :report;
+ },
+ },
+ .special => {
+ log.info("reporting special colors not implemented", .{});
+ break :report;
+ },
+ };
+
+ switch (self.osc_color_report_format) {
+ .@"16-bit" => switch (kind) {
+ .palette => |i| try writer.print(
+ "\x1b]4;{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}",
+ .{
+ i,
+ @as(u16, color.r) * 257,
+ @as(u16, color.g) * 257,
+ @as(u16, color.b) * 257,
+ },
+ ),
+ .dynamic => |dynamic| try writer.print(
+ "\x1b]{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}",
+ .{
+ @intFromEnum(dynamic),
+ @as(u16, color.r) * 257,
+ @as(u16, color.g) * 257,
+ @as(u16, color.b) * 257,
+ },
+ ),
+ .special => unreachable,
+ },
+
+ .@"8-bit" => switch (kind) {
+ .palette => |i| try writer.print(
+ "\x1b]4;{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}",
+ .{
+ i,
+ @as(u16, color.r),
+ @as(u16, color.g),
+ @as(u16, color.b),
+ },
+ ),
+ .dynamic => |dynamic| try writer.print(
+ "\x1b]{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}",
+ .{
+ @intFromEnum(dynamic),
+ @as(u16, color.r),
+ @as(u16, color.g),
+ @as(u16, color.b),
+ },
+ ),
+ .special => unreachable,
+ },
+
+ .none => unreachable,
+ }
+
+ try writer.writeAll(terminator.string());
+ },
+ }
+ }
+
+ if (response.items.len > 0) {
+ // If any of the operations were reports, finalize the report
+ // string and send it to the terminal.
+ const msg = try termio.Message.writeReq(self.alloc, response.items);
+ self.messageWriter(msg);
+ }
+ }
+
+ fn showDesktopNotification(
+ self: *StreamHandler,
+ title: []const u8,
+ body: []const u8,
+ ) !void {
+ var message = apprt.surface.Message{ .desktop_notification = undefined };
+
+ const title_len = @min(title.len, message.desktop_notification.title.len);
+ @memcpy(message.desktop_notification.title[0..title_len], title[0..title_len]);
+ message.desktop_notification.title[title_len] = 0;
+
+ const body_len = @min(body.len, message.desktop_notification.body.len);
+ @memcpy(message.desktop_notification.body[0..body_len], body[0..body_len]);
+ message.desktop_notification.body[body_len] = 0;
+
+ self.surfaceMessageWriter(message);
+ }
+
+ /// Send a report to the pty.
+ pub fn sendSizeReport(self: *StreamHandler, style: terminal.SizeReportStyle) void {
+ switch (style) {
+ .csi_14_t => self.messageWriter(.{ .size_report = .csi_14_t }),
+ .csi_16_t => self.messageWriter(.{ .size_report = .csi_16_t }),
+ .csi_18_t => self.messageWriter(.{ .size_report = .csi_18_t }),
+ .csi_21_t => self.surfaceMessageWriter(.{ .report_title = .csi_21_t }),
+ }
+ }
+
+ fn kittyColorReport(
+ self: *StreamHandler,
+ request: terminal.kitty.color.OSC,
+ ) !void {
+ var stream: std.Io.Writer.Allocating = .init(self.alloc);
+ defer stream.deinit();
+ const writer = &stream.writer;
+
+ for (request.list.items) |item| {
+ switch (item) {
+ .query => |key| {
+ // If the writer buffer is empty, we need to write our prefix
+ if (stream.written().len == 0) try writer.writeAll("\x1b]21");
+
+ const color: terminal.color.RGB = switch (key) {
+ .palette => |palette| self.terminal.colors.palette.current[palette],
+ .special => |special| switch (special) {
+ .foreground => self.terminal.colors.foreground.get(),
+ .background => self.terminal.colors.background.get(),
+ .cursor => self.terminal.colors.cursor.get(),
+ else => {
+ log.warn("ignoring unsupported kitty color protocol key: {f}", .{key});
+ continue;
+ },
+ },
+ } orelse {
+ try writer.print(";{f}=", .{key});
+ continue;
+ };
+
+ try writer.print(
+ ";{f}=rgb:{x:0>2}/{x:0>2}/{x:0>2}",
+ .{ key, color.r, color.g, color.b },
+ );
+ },
+ .set => |v| switch (v.key) {
+ .palette => |palette| {
+ self.terminal.flags.dirty.palette = true;
+ self.terminal.colors.palette.set(palette, v.color);
+ },
+
+ .special => |special| switch (special) {
+ .foreground => self.terminal.colors.foreground.set(v.color),
+ .background => self.terminal.colors.background.set(v.color),
+ .cursor => self.terminal.colors.cursor.set(v.color),
+ else => {
+ log.warn(
+ "ignoring unsupported kitty color protocol key: {f}",
+ .{v.key},
+ );
+ continue;
+ },
+ },
+ },
+ .reset => |key| switch (key) {
+ .palette => |palette| {
+ self.terminal.flags.dirty.palette = true;
+ self.terminal.colors.palette.reset(palette);
+ },
+
+ .special => |special| switch (special) {
+ .foreground => self.terminal.colors.foreground.reset(),
+ .background => self.terminal.colors.background.reset(),
+ .cursor => self.terminal.colors.cursor.reset(),
+ else => {
+ log.warn(
+ "ignoring unsupported kitty color protocol key: {f}",
+ .{key},
+ );
+ continue;
+ },
+ },
+ },
+ }
+ }
+
+ // If we had any writes to our buffer, we queue them now
+ if (stream.written().len > 0) {
+ try writer.writeAll(request.terminator.string());
+ self.messageWriter(.{
+ .write_alloc = .{
+ .alloc = self.alloc,
+ .data = try stream.toOwnedSlice(),
+ },
+ });
+ }
+
+ // Note: we don't have to do a queueRender here because every
+ // processed stream will queue a render once it is done processing
+ // the read() syscall.
+ }
+
+ /// Display a GUI progress report.
+ fn progressReport(self: *StreamHandler, report: terminal.osc.Command.ProgressReport) void {
+ self.surfaceMessageWriter(.{ .progress_report = report });
+ }
+};