diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-04 19:22:04 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 01:23:06 +0300 |
| commit | 41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch) | |
| tree | 3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/termio | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (diff) | |
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/termio')
| -rw-r--r-- | vendor/libghostty-vt/src/termio/Exec.zig | 1761 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/termio/Options.zig | 41 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/termio/Termio.zig | 773 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/termio/Thread.zig | 531 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/termio/backend.zig | 129 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/termio/mailbox.zig | 106 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/termio/message.zig | 108 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/termio/shell_integration.zig | 1032 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/termio/stream_handler.zig | 1556 |
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 }); + } +}; |
