aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/termio
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-10 21:51:57 +0300
committerGitHub <noreply@github.com>2026-09-10 21:51:57 +0300
commit425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch)
treee52c4383a6d69216cbe2a32638bf90841d7e2189 /vendor/libghostty-vt/src/termio
parent90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff)
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'vendor/libghostty-vt/src/termio')
-rw-r--r--vendor/libghostty-vt/src/termio/Exec.zig320
-rw-r--r--vendor/libghostty-vt/src/termio/Termio.zig206
-rw-r--r--vendor/libghostty-vt/src/termio/Thread.zig24
-rw-r--r--vendor/libghostty-vt/src/termio/mailbox.zig15
-rw-r--r--vendor/libghostty-vt/src/termio/message.zig46
-rw-r--r--vendor/libghostty-vt/src/termio/shell_integration.zig110
-rw-r--r--vendor/libghostty-vt/src/termio/stream_handler.zig732
7 files changed, 1024 insertions, 429 deletions
diff --git a/vendor/libghostty-vt/src/termio/Exec.zig b/vendor/libghostty-vt/src/termio/Exec.zig
index 0894f87b..01629171 100644
--- a/vendor/libghostty-vt/src/termio/Exec.zig
+++ b/vendor/libghostty-vt/src/termio/Exec.zig
@@ -9,7 +9,8 @@ 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 global = @import("../global.zig");
+const xev = global.xev;
const apprt = @import("../apprt.zig");
const build_config = @import("../build_config.zig");
const configpkg = @import("../config.zig");
@@ -21,13 +22,13 @@ 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 EnvMap = std.process.Environ.Map;
const PasswdEntry = internal_os.passwd.Entry;
const windows = internal_os.windows;
const ProcessInfo = @import("../pty.zig").ProcessInfo;
+const compat_fd = @import("../lib/compat/fd.zig");
const log = std.log.scoped(.io_exec);
@@ -98,7 +99,7 @@ pub fn threadEnter(
// We're in the child. Nothing more we can do but abnormal exit.
// The Command will output some additional information.
- posix.exit(1);
+ std.process.exit(1);
};
errdefer self.subprocess.stop();
@@ -117,13 +118,13 @@ pub fn threadEnter(
errdefer if (process) |*p| p.deinit();
// Track our process start time for abnormal exits
- const process_start = try std.time.Instant.now();
+ const process_start: std.Io.Timestamp = .now(global.io(), .awake);
// 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]);
+ errdefer _ = posix.system.close(pipe[0]);
+ errdefer _ = posix.system.close(pipe[1]);
// Setup our stream so that we can write.
var stream = xev.Stream.initFd(pty_fds.write);
@@ -141,7 +142,7 @@ pub fn threadEnter(
if (builtin.os.tag == .windows) ReadThread.threadMainWindows else ReadThread.threadMainPosix,
.{ pty_fds.read, io, pipe[0] },
);
- read_thread.setName("io-reader") catch {};
+ read_thread.setName(global.io(), "io-reader") catch {};
// Setup our threadata backend state to be our own
td.backend = .{ .exec = .{
@@ -202,22 +203,23 @@ pub fn threadExit(self: *Exec, td: *termio.Termio.ThreadData) void {
// 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},
+ switch (posix.errno(posix.system.write(exec.read_thread_pipe, "x", 1))) {
+ .SUCCESS => {},
+
+ // EPIPE means that our read thread is closed already, which is
+ // completely fine since that is what we were trying to achieve.
+ .PIPE => {},
+
+ else => |e| log.warn(
+ "error writing to read thread quit pipe err=E{s}",
+ .{@tagName(e)},
),
- };
+ }
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()) {
+ if (windows.exp.kernel32.CancelIoEx(exec.read_thread_fd, null) == windows.FALSE) {
+ switch (windows.GetLastError()) {
.NOT_FOUND => {},
else => |err| log.warn("error interrupting read thread err={}", .{err}),
}
@@ -275,23 +277,18 @@ fn processExitCommon(td: *termio.Termio.ThreadData, exit_code: u32) void {
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 },
+ const runtime_ms: u64 = @max(
+ 0,
+ execdata.start.untilNow(global.io(), .awake).toMilliseconds(),
);
+ log.debug("child process exited status={} runtime={}ms", .{ exit_code, runtime_ms });
// 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,
+ .runtime_ms = runtime_ms,
},
}, .{ .forever = {} });
}
@@ -372,8 +369,8 @@ fn termiosTimer(
// 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();
+ td.renderer_state.mutex.lockUncancelable(global.io());
+ defer td.renderer_state.mutex.unlock(global.io());
const t = td.renderer_state.terminal;
if (t.flags.password_input == password_input) {
break :mode_change;
@@ -420,8 +417,9 @@ pub fn queueWrite(
// 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 w = try exec.write_pool.create(alloc);
+ w.td = exec;
+ const buf = &w.buf;
const slice = slice: {
// The maximum end index is either the end of our data or
// the end of our buffer, whichever is smaller.
@@ -461,26 +459,25 @@ pub fn queueWrite(
exec.write_stream.queueWrite(
td.loop,
&exec.write_queue,
- req,
+ &w.req,
.{ .slice = slice },
- termio.Exec.ThreadData,
- exec,
+ ThreadData.Write,
+ w,
ttyWrite,
);
}
}
fn ttyWrite(
- td_: ?*ThreadData,
+ w_: ?*ThreadData.Write,
_: *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 w = w_.?;
+ w.td.write_pool.destroy(w);
const d = r catch |err| {
log.err("write error: {}", .{err});
@@ -494,12 +491,24 @@ fn ttyWrite(
/// 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);
+ /// The state for a single queued pty write. The write request and
+ /// the buffer it writes from must both remain pointer-stable until
+ /// the write completes, so they're pooled together and checked out
+ /// per write.
+ pub const Write = struct {
+ /// Backpointer to the thread data so the write completion
+ /// callback can put this back into the pool.
+ td: *ThreadData,
+
+ /// The libxev write request.
+ req: xev.WriteRequest,
+
+ /// The buffer for the data being written.
+ buf: [64]u8,
+ };
/// Process start time and boolean of whether its already exited.
- start: std.time.Instant,
+ start: std.Io.Timestamp,
exited: bool = false,
/// The data stream is the main IO for the pty.
@@ -508,12 +517,9 @@ pub const ThreadData = struct {
/// The process watcher
process: ?xev.Process,
- /// This is the pool of available (unused) write requests. If you grab
+ /// This is the pool of available (unused) write states. 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) = .{},
+ write_pool: std.heap.MemoryPool(Write) = .empty,
/// The write queue for the data stream.
write_queue: xev.WriteQueue = .{},
@@ -541,13 +547,12 @@ pub const ThreadData = struct {
termios_mode: ptypkg.Mode = .{},
pub fn deinit(self: *ThreadData, alloc: Allocator) void {
- posix.close(self.read_thread_pipe);
+ _ = posix.system.close(self.read_thread_pipe);
- // Clear our write pools. We know we aren't ever going to do
+ // Clear our write pool. 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);
+ self.write_pool.deinit(alloc);
// Stop our process watcher
if (self.process) |*p| p.deinit();
@@ -671,10 +676,13 @@ const Subprocess = struct {
}
var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
- const exe_bin_path = std.fs.selfExePath(&exe_buf) catch |err| {
+ const exe_bin_path = exe_buf[0 .. std.process.executablePath(
+ global.io(),
+ &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});
@@ -695,7 +703,7 @@ const Subprocess = struct {
try env.put(
"PATH",
- try internal_os.appendEnv(alloc, path, exe_dir),
+ try appendEnv(alloc, path, exe_dir),
);
} else {
try env.put("PATH", exe_dir);
@@ -713,7 +721,7 @@ const Subprocess = struct {
if (std.fmt.bufPrint(&buf, "{s}/..", .{resources_dir})) |data_dir| {
try env.put(
xdg_data_dir_key,
- try internal_os.appendEnv(
+ try appendEnv(
alloc,
env.get(xdg_data_dir_key) orelse "/usr/local/share:/usr/share",
data_dir,
@@ -730,7 +738,7 @@ const Subprocess = struct {
// path instead of overriding all paths set by OS.
try env.put(
manpath_key,
- try internal_os.appendEnvAlways(
+ try appendEnvAlways(
alloc,
env.get(manpath_key) orelse "",
man_dir,
@@ -749,7 +757,7 @@ const Subprocess = struct {
// 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");
+ _ = env.orderedRemove("VTE_VERSION");
// Setup our shell integration, if we can.
const shell_command: configpkg.Command = shell: {
@@ -907,7 +915,7 @@ const Subprocess = struct {
self.pty = pty;
errdefer if (!in_child) {
if (comptime builtin.os.tag != .windows) {
- _ = posix.close(pty.slave);
+ _ = posix.system.close(pty.slave);
}
pty.deinit();
@@ -921,7 +929,7 @@ const Subprocess = struct {
// 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);
+ _ = posix.system.close(pty.slave);
}
// Successful start we can clear out some memory.
@@ -944,8 +952,12 @@ const Subprocess = struct {
//
// 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();
+ const dev_null = try std.Io.Dir.cwd().openFile(
+ global.io(),
+ "/dev/null",
+ .{ .mode = .read_write },
+ );
+ defer dev_null.close(global.io());
var cmd: internal_os.FlatpakHostCommand = .{
.argv = &[_][]const u8{
"/bin/sh",
@@ -966,7 +978,7 @@ const Subprocess = struct {
break :cwd proposed;
}
- if (std.fs.cwd().access(proposed, .{})) {
+ if (std.Io.Dir.cwd().access(global.io(), proposed, .{})) {
break :cwd proposed;
} else |err| {
log.warn("cannot access cwd, ignoring: {}", .{err});
@@ -1011,9 +1023,18 @@ const Subprocess = struct {
.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 },
+ .stdin = if (builtin.os.tag == .windows) null else .{
+ .handle = pty.slave,
+ .flags = .{ .nonblocking = false },
+ },
+ .stdout = if (builtin.os.tag == .windows) null else .{
+ .handle = pty.slave,
+ .flags = .{ .nonblocking = false },
+ },
+ .stderr = if (builtin.os.tag == .windows) null else .{
+ .handle = pty.slave,
+ .flags = .{ .nonblocking = false },
+ },
.pseudo_console = if (builtin.os.tag == .windows) pty.pseudo_console else {},
.os_pre_exec = switch (comptime builtin.os.tag) {
.windows => null,
@@ -1140,8 +1161,8 @@ const Subprocess = struct {
if (command.pid) |pid| {
switch (builtin.os.tag) {
.windows => {
- if (windows.kernel32.TerminateProcess(pid, 0) == 0) {
- return windows.unexpectedError(windows.kernel32.GetLastError());
+ if (windows.exp.kernel32.TerminateProcess(pid, 0) == windows.FALSE) {
+ return windows.unexpectedError(windows.GetLastError());
}
_ = try command.wait(false);
@@ -1182,10 +1203,14 @@ const Subprocess = struct {
// 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);
+ const res_pid = while (true) {
+ const rc = posix.system.waitpid(pid, null, std.c.W.NOHANG);
+ if (posix.errno(rc) == .INTR) continue;
+ break rc;
+ };
+ log.debug("waitpid result={}", .{res_pid});
+ if (res_pid != 0) break;
+ try std.Io.sleep(global.io(), .fromMilliseconds(10), .awake);
}
}
@@ -1205,7 +1230,7 @@ const Subprocess = struct {
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);
+ std.Io.sleep(global.io(), .fromMilliseconds(10), .awake) catch {};
continue;
}
@@ -1336,15 +1361,15 @@ pub const ReadThread = struct {
/// stage at a time, so buffer contents need no locking. Only the
/// ring metadata is guarded by the mutex.
const Pipeline = struct {
- mutex: std.Thread.Mutex = .{},
+ mutex: std.Io.Mutex = .init,
/// Signaled when a batch is published or the gather stage is
/// done. Waited on by the parse stage.
- batch_ready: std.Thread.Condition = .{},
+ batch_ready: std.Io.Condition = .init,
/// Signaled when a batch has been consumed. Waited on by the
/// gather stage when all buffers are in flight (backpressure).
- slot_free: std.Thread.Condition = .{},
+ slot_free: std.Io.Condition = .init,
/// The number of valid bytes in each buffer. Set at publish
/// time by the gather stage, read by the parse stage.
@@ -1385,7 +1410,7 @@ 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);
+ defer _ = posix.system.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,
@@ -1419,7 +1444,7 @@ pub const ReadThread = struct {
// can't create it we still run correctly, bridge polls are
// just bounded by their timeout instead of being interrupted
// when the parse stage goes idle.
- if (posix.pipe2(.{
+ if (compat_fd.pipe2(.{
.CLOEXEC = true,
.NONBLOCK = true,
})) |fds| {
@@ -1429,8 +1454,8 @@ pub const ReadThread = struct {
log.warn("read thread failed to create idle pipe err={}", .{err});
}
defer if (pipeline.idle_read_fd >= 0) {
- posix.close(pipeline.idle_read_fd);
- posix.close(pipeline.idle_write_fd);
+ compat_fd.close(pipeline.idle_read_fd);
+ compat_fd.close(pipeline.idle_write_fd);
};
const gather_thread = std.Thread.spawn(
@@ -1446,7 +1471,7 @@ pub const ReadThread = struct {
};
defer gather_thread.join();
if (comptime !builtin.os.tag.isDarwin()) {
- gather_thread.setName("io-gather") catch {};
+ gather_thread.setName(global.io(), "io-gather") catch {};
}
// This thread is the parse stage. We consume batches in ring
@@ -1454,11 +1479,11 @@ pub const ReadThread = struct {
// the ring is drained.
while (true) {
const batch: []const u8 = batch: {
- pipeline.mutex.lock();
- defer pipeline.mutex.unlock();
+ pipeline.mutex.lockUncancelable(global.io());
+ defer pipeline.mutex.unlock(global.io());
while (pipeline.count == 0) {
if (pipeline.done) return;
- pipeline.batch_ready.wait(&pipeline.mutex);
+ pipeline.batch_ready.waitUncancelable(global.io(), &pipeline.mutex);
}
const slot = pipeline.tail;
break :batch pipeline.bufs[slot][0..pipeline.lens[slot]];
@@ -1469,27 +1494,27 @@ pub const ReadThread = struct {
io.processOutput(batch);
{
- pipeline.mutex.lock();
+ pipeline.mutex.lockUncancelable(global.io());
pipeline.tail = (pipeline.tail + 1) % buffer_count;
pipeline.count -= 1;
const wake = pipeline.count == 0 and
pipeline.bridging and
pipeline.idle_write_fd >= 0;
- pipeline.mutex.unlock();
- pipeline.slot_free.signal();
+ pipeline.mutex.unlock(global.io());
+ pipeline.slot_free.signal(global.io());
// We ran out of batches while the gather stage is
// bridging a refill gap: interrupt its poll so it
// delivers what it has instead of sleeping out the
// timeout while we sit idle.
if (wake) {
- _ = posix.write(pipeline.idle_write_fd, "i") catch {};
+ _ = posix.system.write(pipeline.idle_write_fd, "i", 1);
}
}
// Batch boundary: hand the renderer state mutex off if
// the renderer is waiting. See renderer.State.lockDemand.
- io.renderer_state.yieldToDemand();
+ io.renderer_state.yieldToDemand(global.io());
}
}
@@ -1506,10 +1531,10 @@ pub const ReadThread = struct {
// However we exit, tell the parse stage the stream is over so
// it drains the ring and joins us.
defer {
- pipeline.mutex.lock();
+ pipeline.mutex.lockUncancelable(global.io());
pipeline.done = true;
- pipeline.mutex.unlock();
- pipeline.batch_ready.signal();
+ pipeline.mutex.unlock(global.io());
+ pipeline.batch_ready.signal(global.io());
}
// The fds we poll: data on the pty, our quit notification,
@@ -1528,16 +1553,16 @@ pub const ReadThread = struct {
// we should stop reading and let the kernel queue exert
// backpressure on the child.
const buf: *[buffer_capacity]u8 = buf: {
- pipeline.mutex.lock();
- defer pipeline.mutex.unlock();
+ pipeline.mutex.lockUncancelable(global.io());
+ defer pipeline.mutex.unlock(global.io());
while (pipeline.count == buffer_count) {
- pipeline.slot_free.wait(&pipeline.mutex);
+ pipeline.slot_free.waitUncancelable(global.io(), &pipeline.mutex);
}
break :buf &pipeline.bufs[pipeline.head];
};
var total: usize = 0;
- var bridge_start: ?std.time.Instant = null;
+ var bridge_start: ?std.Io.Timestamp = null;
var spins: usize = 0;
var fatal = false;
@@ -1566,10 +1591,9 @@ pub const ReadThread = struct {
// Still dry, so we want to sleep in poll for
// the next refill, within our latency budget.
- const now = std.time.Instant.now() catch
- break :gather;
+ const now = std.Io.Timestamp.now(global.io(), .awake);
if (bridge_start) |start| {
- if (now.since(start) >= gather_budget_ns)
+ if (start.durationTo(now).toNanoseconds() >= gather_budget_ns)
break :gather;
} else bridge_start = now;
@@ -1587,8 +1611,8 @@ pub const ReadThread = struct {
// the idle wake so it can interrupt our poll
// the moment that changes.
{
- pipeline.mutex.lock();
- defer pipeline.mutex.unlock();
+ pipeline.mutex.lockUncancelable(global.io());
+ defer pipeline.mutex.unlock(global.io());
if (pipeline.count == 0) break :gather;
pipeline.bridging = true;
}
@@ -1671,12 +1695,12 @@ pub const ReadThread = struct {
// Publish the batch (if any) to the parse stage and rotate
// to the next buffer.
if (total > 0) {
- pipeline.mutex.lock();
+ pipeline.mutex.lockUncancelable(global.io());
pipeline.lens[pipeline.head] = total;
pipeline.head = (pipeline.head + 1) % buffer_count;
pipeline.count += 1;
- pipeline.mutex.unlock();
- pipeline.batch_ready.signal();
+ pipeline.mutex.unlock(global.io());
+ pipeline.batch_ready.signal(global.io());
}
if (fatal) return;
@@ -1710,8 +1734,8 @@ pub const ReadThread = struct {
/// Clears the bridging flag armed before a bridge poll, closing
/// the window in which the parse stage writes idle wakes.
fn clearBridging(pipeline: *Pipeline) void {
- pipeline.mutex.lock();
- defer pipeline.mutex.unlock();
+ pipeline.mutex.lockUncancelable(global.io());
+ defer pipeline.mutex.unlock(global.io());
pipeline.bridging = false;
}
@@ -1730,30 +1754,29 @@ pub const ReadThread = struct {
/// Sets the fd to non-blocking mode. Returns false on failure.
fn setNonblock(fd: posix.fd_t) bool {
- const flags = posix.fcntl(
- fd,
- posix.F.GETFL,
- 0,
- ) catch |err| {
- log.warn("read thread failed to get flags err={}", .{err});
+ const flags = posix.system.fcntl(fd, posix.F.GETFL);
+ if (flags == -1) {
+ log.warn("read thread failed to get flags err=E{s}", .{@tagName(posix.errno(-1))});
return false;
- };
+ }
- _ = posix.fcntl(
+ switch (posix.errno(posix.system.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});
- return false;
- };
-
+ ))) {
+ .SUCCESS => {},
+ else => |e| {
+ log.warn("read thread failed to set flags err=E{s}", .{@tagName(e)});
+ return false;
+ },
+ }
return true;
}
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);
+ defer _ = posix.system.close(quit);
// Setup our crash metadata
crash.sentry.thread_state = .{
@@ -1766,8 +1789,8 @@ pub const ReadThread = struct {
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();
+ if (windows.exp.kernel32.ReadFile(fd, &buf, buf.len, &n, null) == windows.FALSE) {
+ const err = windows.GetLastError();
switch (err) {
// Check for a quit signal
.OPERATION_ABORTED => break,
@@ -1784,12 +1807,12 @@ pub const ReadThread = struct {
// See threadMainPosix: hand the renderer state mutex
// off if the renderer is waiting, since this loop
// would otherwise starve it under heavy output.
- io.renderer_state.yieldToDemand();
+ io.renderer_state.yieldToDemand(global.io());
}
var quit_bytes: windows.DWORD = 0;
- if (windows.exp.kernel32.PeekNamedPipe(quit, null, 0, null, &quit_bytes, null) == 0) {
- const err = windows.kernel32.GetLastError();
+ if (windows.exp.kernel32.PeekNamedPipe(quit, null, 0, null, &quit_bytes, null) == windows.FALSE) {
+ const err = windows.GetLastError();
log.err("quit pipe reader error err={}", .{err});
unreachable;
}
@@ -1837,16 +1860,16 @@ fn execCommand(
};
const hush = if (passwd.home) |home| hush: {
- var dir = std.fs.openDirAbsolute(home, .{}) catch |err| {
+ var dir = std.Io.Dir.openDirAbsolute(global.io(), home, .{}) catch |err| {
log.warn(
"failed to open home dir, not checking for hushlogin err={}",
.{err},
);
break :hush false;
};
- defer dir.close();
+ defer dir.close(global.io());
- break :hush if (dir.access(".hushlogin", .{})) true else |_| false;
+ break :hush if (dir.access(global.io(), ".hushlogin", .{})) true else |_| false;
} else false;
// If we made it this far we're going to start building
@@ -1945,7 +1968,7 @@ fn execCommand(
return switch (command) {
// We need to clone the command since there's no guarantee the config remains valid.
- .direct => |_| (try command.clone(alloc)).direct,
+ .direct => (try command.clone(alloc)).direct,
.shell => |v| shell: {
var args: std.ArrayList([:0]const u8) = try .initCapacity(alloc, 4);
@@ -1973,7 +1996,7 @@ fn execCommand(
// Other values are passed as-is and resolved by
// `internal_os.path.expand` in Command.startWindows.
const argv0 = if (std.ascii.eqlIgnoreCase(v, "cmd.exe"))
- std.process.getEnvVarOwned(alloc, "COMSPEC") catch
+ global.environ().getAlloc(alloc, "COMSPEC") catch
try alloc.dupe(u8, v)
else
try alloc.dupe(u8, v);
@@ -2002,6 +2025,37 @@ fn execCommand(
};
}
+/// Append a value to an environment variable such as PATH.
+/// The returned value is always allocated so it must be freed.
+fn appendEnv(
+ alloc: Allocator,
+ current: []const u8,
+ value: []const u8,
+) Allocator.Error![]u8 {
+ // If there is no prior value, we return it as-is
+ if (current.len == 0) return try alloc.dupe(u8, value);
+
+ // Otherwise we must prefix.
+ return try appendEnvAlways(alloc, current, value);
+}
+
+/// Always append value to environment, even when it is empty.
+/// This is useful because some env vars (like MANPATH) want there
+/// to be an empty prefix to preserve existing values.
+///
+/// The returned value is always allocated so it must be freed.
+fn appendEnvAlways(
+ alloc: Allocator,
+ current: []const u8,
+ value: []const u8,
+) Allocator.Error![]u8 {
+ return try std.fmt.allocPrint(alloc, "{s}{c}{s}", .{
+ current,
+ std.fs.path.delimiter,
+ value,
+ });
+}
+
/// 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.
@@ -2190,7 +2244,7 @@ test "execCommand windows: bare cmd.exe resolves via COMSPEC" {
try testing.expectEqual(1, result.len);
// Expect COMSPEC if available, otherwise the documented fallback.
- const expected = std.process.getEnvVarOwned(alloc, "COMSPEC") catch
+ const expected = testing.environ.getAlloc(alloc, "COMSPEC") catch
try alloc.dupe(u8, "C:\\Windows\\System32\\cmd.exe");
try testing.expectEqualStrings(expected, result[0]);
}
diff --git a/vendor/libghostty-vt/src/termio/Termio.zig b/vendor/libghostty-vt/src/termio/Termio.zig
index 1b08c5e9..d08f2e5f 100644
--- a/vendor/libghostty-vt/src/termio/Termio.zig
+++ b/vendor/libghostty-vt/src/termio/Termio.zig
@@ -8,18 +8,20 @@ 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 EnvMap = std.process.Environ.Map;
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 global = @import("../global.zig");
+const xev = global.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 compat_file = @import("../lib/compat/file.zig");
const log = std.log.scoped(.io_exec);
@@ -65,7 +67,7 @@ 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,
+last_cursor_reset: ?std.Io.Timestamp = 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.
@@ -117,15 +119,18 @@ const ThreadEnterState = struct {
) (Allocator.Error || error{InputNotFound})![]const Input {
const alloc = self.arena.allocator();
- var input = try alloc.alloc(
- Input,
+ var inputs: std.ArrayList(Input) = try .initCapacity(
+ alloc,
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) },
+ errdefer for (inputs.items) |item| item.deinit();
+
+ for (self.input.list.items) |item| {
+ inputs.appendAssumeCapacity(switch (item) {
+ .raw => |v| .{ .string = v },
.path => |path| file: {
- const f = std.fs.cwd().openFile(
+ const f = std.Io.Dir.cwd().openFile(
+ global.io(),
path,
.{},
) catch |err| {
@@ -138,15 +143,22 @@ const ThreadEnterState = struct {
break :file .{ .file = f };
},
- };
+ });
}
- return input;
+ return inputs.items;
}
const Input = union(enum) {
string: []const u8,
- file: std.fs.File,
+ file: std.Io.File,
+
+ fn deinit(self: Input) void {
+ switch (self) {
+ .string => {},
+ .file => |f| f.close(global.io()),
+ }
+ }
};
};
@@ -165,6 +177,7 @@ pub const DerivedConfig = struct {
background: configpkg.Config.Color,
osc_color_report_format: configpkg.Config.OSCColorReportFormat,
clipboard_write: configpkg.ClipboardAccess,
+ clipboard_write_limit: usize,
enquiry_response: []const u8,
conditional_state: configpkg.ConditionalState,
@@ -201,6 +214,7 @@ pub const DerivedConfig = struct {
.background = config.background,
.osc_color_report_format = config.@"osc-color-report-format",
.clipboard_write = config.@"clipboard-write",
+ .clipboard_write_limit = config.@"clipboard-write-limit-bytes".value,
.enquiry_response = try alloc.dupe(u8, config.@"enquiry-response"),
.conditional_state = config._conditional_state,
@@ -238,13 +252,16 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void {
};
// Create our terminal
- var term = try terminalpkg.Terminal.init(alloc, opts: {
+ var term = try terminalpkg.Terminal.init(global.io(), 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",
+ .max_scrollback_bytes = opts.full_config.@"scrollback-limit-bytes".optional(),
+ .max_scrollback_lines = opts.full_config.@"scrollback-limit-lines".optional(),
.default_modes = default_modes,
+ .default_cursor_style = opts.config.cursor_style,
+ .default_cursor_blink = opts.config.cursor_blink,
.colors = .{
.background = .init(opts.config.background.toTerminalRGB()),
.foreground = .init(opts.config.foreground.toTerminalRGB()),
@@ -253,16 +270,17 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void {
const rgb = color.toTerminalRGB() orelse break :cursor .unset;
break :cursor .init(rgb);
},
- .palette = .init(opts.config.palette),
+ .palette = .default,
},
.kitty_image_storage_limit = opts.config.image_storage_limit,
- .kitty_image_loading_limits = .all,
+ .kitty_image_loading_limits = .allWithTempDir(global.tmpDirPath()),
};
});
errdefer term.deinit(alloc);
- // Set our default cursor style
- term.screens.active.cursor.cursor_style = opts.config.cursor_style;
+ // The default palette may be an allocator-owned copy, so it is set
+ // once the terminal owns its memory and can release it on deinit.
+ try term.colors.palette.changeDefault(alloc, opts.config.palette);
// Setup our terminal size in pixels for certain requests.
term.width_px = term.cols * opts.size.cell.width;
@@ -285,9 +303,8 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void {
.terminal = &self.terminal,
.osc_color_report_format = opts.config.osc_color_report_format,
.clipboard_write = opts.config.clipboard_write,
+ .clipboard_write_limit = opts.config.clipboard_write_limit,
.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(
@@ -306,7 +323,10 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void {
.size = opts.size,
.backend = backend,
.mailbox = opts.mailbox,
- .terminal_stream = .initAlloc(alloc, handler),
+ .terminal_stream = .init(.{
+ .allocator = alloc,
+ .handler = handler,
+ }),
.thread_enter_state = thread_enter_state,
};
}
@@ -342,6 +362,9 @@ pub fn threadEnter(
try v.prepareInput()
else
null;
+ defer if (inputs) |items| {
+ for (items) |input| input.deinit();
+ };
data.* = .{
.alloc = self.alloc,
@@ -362,19 +385,21 @@ pub fn threadEnter(
log.warn("failed to queue input string err={}", .{err});
return error.InputFailed;
},
- .file => |f| self.queueWrite(
- data,
- f.readToEndAlloc(
+ .file => |f| {
+ const contents = compat_file.readToEndAlloc(
+ f,
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;
+ };
+ defer self.alloc.free(contents);
+
+ self.queueWrite(data, contents, false) catch |err| {
+ log.warn("failed to queue input file err={}", .{err});
+ return error.InputFailed;
+ };
},
};
}
@@ -422,8 +447,8 @@ pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !voi
// 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();
+ self.renderer_state.mutex.lockUncancelable(global.io());
+ defer self.renderer_state.mutex.unlock(global.io());
// Deinit our old config. We do this in the lock because the
// stream handler may be referencing the old config (i.e. enquiry resp)
@@ -442,8 +467,16 @@ pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !voi
// - 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);
+ // Update the default palette. A config change must not fail here, so
+ // if we can't allocate the copy of the configured palette we fall back
+ // to the built-in default, which never allocates.
+ self.terminal.colors.palette.changeDefault(
+ self.alloc,
+ config.palette,
+ ) catch |err| {
+ log.warn("error changing default palette, using built-in default err={}", .{err});
+ self.terminal.colors.palette.resetDefault(self.alloc);
+ };
self.terminal.flags.dirty.palette = true;
// Update all our other colors
@@ -455,8 +488,8 @@ pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !voi
};
// Set the image limits
- try self.terminal.setKittyGraphicsSizeLimit(self.alloc, config.image_storage_limit);
- self.terminal.setKittyGraphicsLoadingLimits(.all);
+ self.terminal.setKittyGraphicsSizeLimit(self.alloc, config.image_storage_limit);
+ self.terminal.setKittyGraphicsLoadingLimits(.allWithTempDir(global.tmpDirPath()));
}
/// Resize the terminal.
@@ -473,24 +506,22 @@ pub fn resize(
// Enter the critical area that we want to keep small
{
- self.renderer_state.mutex.lock();
- defer self.renderer_state.mutex.unlock();
+ self.renderer_state.mutex.lockUncancelable(global.io());
+ defer self.renderer_state.mutex.unlock(global.io());
// Update the size of our terminal state
try self.terminal.resize(
self.alloc,
- grid_size.columns,
- grid_size.rows,
+ .{
+ .cols = grid_size.columns,
+ .rows = grid_size.rows,
+ .cell_size_px = .{
+ .width = self.size.cell.width,
+ .height = self.size.cell.height,
+ },
+ },
);
- // 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);
@@ -498,14 +529,14 @@ pub fn resize(
}
// Mail the renderer so that it can update the GPU and re-render
- _ = self.renderer_mailbox.push(.{ .resize = size }, .{ .forever = {} });
+ _ = self.renderer_mailbox.push(global.io(), .{ .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();
+ self.renderer_state.mutex.lockUncancelable(global.io());
+ defer self.renderer_state.mutex.unlock(global.io());
try self.sizeReportLocked(td, style);
}
@@ -534,8 +565,8 @@ fn sizeReportLocked(self: *Termio, td: *ThreadData, style: termio.Message.SizeRe
/// 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.renderer_state.mutex.lockUncancelable(global.io());
+ defer self.renderer_state.mutex.unlock(global.io());
self.terminal.modes.set(.synchronized_output, false);
self.renderer_wakeup.notify() catch {};
}
@@ -543,8 +574,8 @@ pub fn resetSynchronizedOutput(self: *Termio) void {
/// Clear the screen.
pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void {
{
- self.renderer_state.mutex.lock();
- defer self.renderer_state.mutex.unlock();
+ self.renderer_state.mutex.lockUncancelable(global.io());
+ defer self.renderer_state.mutex.unlock(global.io());
// 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
@@ -573,6 +604,7 @@ pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void {
// 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.io(),
self.terminal.screens.active.alloc,
&self.terminal,
.{ .all = true },
@@ -600,16 +632,16 @@ pub fn scrollViewport(
self: *Termio,
scroll: terminalpkg.Terminal.ScrollViewport,
) void {
- self.renderer_state.mutex.lock();
- defer self.renderer_state.mutex.unlock();
+ self.renderer_state.mutex.lockUncancelable(global.io());
+ defer self.renderer_state.mutex.unlock(global.io());
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.renderer_state.mutex.lockUncancelable(global.io());
+ defer self.renderer_state.mutex.unlock(global.io());
self.terminal.screens.active.scroll(.{ .delta_prompt = delta });
}
@@ -618,9 +650,9 @@ pub fn jumpToPrompt(self: *Termio, delta: isize) !void {
/// 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();
+ self.renderer_state.mutex.lockUncancelable(global.io());
const focus_event = self.renderer_state.terminal.modes.get(.focus_event);
- self.renderer_state.mutex.unlock();
+ self.renderer_state.mutex.unlock(global.io());
// If we have focus events enabled, we send the focus event.
if (focus_event) {
@@ -643,8 +675,8 @@ pub fn focusGained(self: *Termio, td: *ThreadData, focused: bool) !void {
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.renderer_state.mutex.lockUncancelable(global.io());
+ defer self.renderer_state.mutex.unlock(global.io());
self.processOutputLocked(buf);
}
@@ -657,19 +689,18 @@ fn processOutputLocked(self: *Termio, buf: []const u8) void {
// 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: {
+ const now = std.Io.Timestamp.now(global.io(), .awake);
+ cursor_reset: {
if (self.last_cursor_reset) |last| {
- if (now.since(last) <= (500 * std.time.ns_per_ms)) {
+ if (last.durationTo(now).toMilliseconds() <= 500) {
break :cursor_reset;
}
}
self.last_cursor_reset = now;
- _ = self.renderer_mailbox.push(.{
+ _ = self.renderer_mailbox.push(global.io(), .{
.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
@@ -701,9 +732,22 @@ fn processOutputLocked(self: *Termio, buf: []const u8) void {
}
/// Sends a DSR response for the current color scheme to the pty.
+/// Record a Kitty clipboard protocol session grant so future requests
+/// carrying the password skip the permission prompt.
+pub fn kittyClipboardGrant(
+ self: *Termio,
+ pw: []const u8,
+ dir: terminalpkg.kitty.clipboard.Grants.Direction,
+) error{OutOfMemory}!void {
+ self.renderer_state.mutex.lockUncancelable(global.io());
+ defer self.renderer_state.mutex.unlock(global.io());
+
+ try self.terminal_stream.handler.kittyClipboardGrant(pw, dir);
+}
+
pub fn colorSchemeReport(self: *Termio, td: *ThreadData, force: bool) !void {
- self.renderer_state.mutex.lock();
- defer self.renderer_state.mutex.unlock();
+ self.renderer_state.mutex.lockUncancelable(global.io());
+ defer self.renderer_state.mutex.unlock(global.io());
try self.colorSchemeReportLocked(td, force);
}
@@ -723,6 +767,30 @@ pub fn colorSchemeReportLocked(self: *Termio, td: *ThreadData, force: bool) !voi
try self.queueWrite(td, writer.buffered(), false);
}
+/// Sends a visibility report to the pty. Unforced reports are only sent while
+/// DEC mode 2033 is enabled.
+pub fn visibilityReport(
+ self: *Termio,
+ td: *ThreadData,
+ visible: bool,
+ force: bool,
+) !void {
+ self.renderer_state.mutex.lockUncancelable(global.io());
+ defer self.renderer_state.mutex.unlock(global.io());
+
+ if (!force and !self.renderer_state.terminal.modes.get(.report_visibility)) {
+ return;
+ }
+
+ var buf: [terminalpkg.device_status.max_visibility_report_encode_size]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ try terminalpkg.device_status.encodeVisibilityReport(
+ &writer,
+ if (visible) .potentially_visible else .not_visible,
+ );
+ try self.queueWrite(td, writer.buffered(), false);
+}
+
/// ThreadData is the data created and stored in the termio thread
/// when the thread is started and destroyed when the thread is
/// stopped.
diff --git a/vendor/libghostty-vt/src/termio/Thread.zig b/vendor/libghostty-vt/src/termio/Thread.zig
index ce4c1f4a..de268350 100644
--- a/vendor/libghostty-vt/src/termio/Thread.zig
+++ b/vendor/libghostty-vt/src/termio/Thread.zig
@@ -14,7 +14,8 @@ pub const Thread = @This();
const std = @import("std");
const ArenaAllocator = std.heap.ArenaAllocator;
const builtin = @import("builtin");
-const xev = @import("../global.zig").xev;
+const global = @import("../global.zig");
+const xev = global.xev;
const crash = @import("../crash/main.zig");
const internal_os = @import("../os/main.zig");
const termio = @import("../termio.zig");
@@ -147,8 +148,8 @@ pub fn threadMain(self: *Thread, io: *termio.Termio) void {
// 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();
+ io.renderer_state.mutex.lockUncancelable(global.io());
+ defer io.renderer_state.mutex.unlock(global.io());
const t = io.renderer_state.terminal;
// Hide the cursor
@@ -297,7 +298,7 @@ fn drainMailbox(
// If we're draining, we just drain the mailbox and return.
if (self.flags.drain) {
- while (mailbox.pop()) |_| {}
+ while (mailbox.pop(global.io())) |msg| msg.deinit();
return;
}
@@ -305,13 +306,18 @@ fn drainMailbox(
// 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| {
+ while (mailbox.pop(global.io())) |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),
+ .visibility_report => |v| try io.visibilityReport(
+ data,
+ v.visible,
+ v.force,
+ ),
.crash => @panic("crash request, crashing intentionally"),
.change_config => |config| {
defer config.alloc.destroy(config.ptr);
@@ -330,6 +336,14 @@ fn drainMailbox(
}
},
.jump_to_prompt => |v| try io.jumpToPrompt(v),
+ .kitty_clipboard_grant_read => |v| {
+ defer v.alloc.free(v.pw);
+ try io.kittyClipboardGrant(v.pw, .read);
+ },
+ .kitty_clipboard_grant_write => |v| {
+ defer v.alloc.free(v.pw);
+ try io.kittyClipboardGrant(v.pw, .write);
+ },
.start_synchronized_output => self.startSynchronizedOutput(cb),
.linefeed_mode => |v| self.flags.linefeed_mode = v,
.focused => |v| try io.focusGained(data, v),
diff --git a/vendor/libghostty-vt/src/termio/mailbox.zig b/vendor/libghostty-vt/src/termio/mailbox.zig
index 2725d024..a053aaab 100644
--- a/vendor/libghostty-vt/src/termio/mailbox.zig
+++ b/vendor/libghostty-vt/src/termio/mailbox.zig
@@ -1,6 +1,7 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
-const xev = @import("../global.zig").xev;
+const global = @import("../global.zig");
+const xev = global.xev;
const renderer = @import("../renderer.zig");
const termio = @import("../termio.zig");
const BlockingQueue = @import("../datastruct/main.zig").BlockingQueue;
@@ -46,6 +47,7 @@ pub const Mailbox = union(enum) {
pub fn deinit(self: *Mailbox, alloc: Allocator) void {
switch (self.*) {
.spsc => |*v| {
+ while (v.queue.pop(global.io())) |msg| msg.deinit();
v.queue.destroy(alloc);
v.wakeup.deinit();
},
@@ -61,13 +63,13 @@ pub const Mailbox = union(enum) {
pub fn send(
self: *Mailbox,
msg: termio.Message,
- mutex: ?*std.Thread.Mutex,
+ mutex: ?*std.Io.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 (mb.queue.push(global.io(), 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
@@ -75,6 +77,7 @@ pub const Mailbox = union(enum) {
// lock so we need to unlock.
mb.wakeup.notify() catch |err| {
log.warn("failed to wake up writer, data will be dropped err={}", .{err});
+ msg.deinit();
return;
};
@@ -87,9 +90,9 @@ pub const Mailbox = union(enum) {
// 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 = {} });
+ if (mutex) |m| m.unlock(global.io());
+ defer if (mutex) |m| m.lockUncancelable(global.io());
+ if (mb.queue.push(global.io(), msg, .{ .forever = {} }) == 0) msg.deinit();
},
}
}
diff --git a/vendor/libghostty-vt/src/termio/message.zig b/vendor/libghostty-vt/src/termio/message.zig
index 4ee7f245..24168740 100644
--- a/vendor/libghostty-vt/src/termio/message.zig
+++ b/vendor/libghostty-vt/src/termio/message.zig
@@ -8,20 +8,29 @@ 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.
+/// but the number of messages we send to the IO thread is also very small.
+/// 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.
+ /// Request a color scheme report is sent to the PTY.
color_scheme_report: struct {
/// Force write the current color scheme
force: bool,
},
+ /// Request a visibility report is sent to the PTY.
+ visibility_report: struct {
+ /// The visibility state to report.
+ visible: bool,
+
+ /// Send the report even when mode 2033 is disabled.
+ force: bool,
+ },
+
/// Purposely crash the renderer. This is used for testing and debugging.
/// See the "crash" binding action.
crash: void,
@@ -39,7 +48,7 @@ pub const Message = union(enum) {
/// Resize the window.
resize: renderer.Size,
- /// Request a size report is sent to the pty ([in-band
+ /// 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,
@@ -73,6 +82,12 @@ pub const Message = union(enum) {
/// The surface gained or lost focus.
focused: bool,
+ /// Record a Kitty clipboard protocol session grant for a password
+ /// so future requests carrying it skip the permission prompt, for
+ /// reads and writes respectively.
+ kitty_clipboard_grant_read: KittyClipboardGrant,
+ kitty_clipboard_grant_write: KittyClipboardGrant,
+
/// Write where the data fits in the union.
write_small: WriteReq.Small,
@@ -82,6 +97,13 @@ pub const Message = union(enum) {
/// Write where the data is allocated and must be freed.
write_alloc: WriteReq.Alloc,
+ /// The payload of the kitty_clipboard_grant_* messages. The
+ /// password is allocated and must be freed.
+ pub const KittyClipboardGrant = struct {
+ alloc: Allocator,
+ pw: []const u8,
+ };
+
/// 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.
@@ -93,6 +115,22 @@ pub const Message = union(enum) {
};
}
+ /// Free resources owned by a message that will not be processed.
+ /// The message is invalid after this call.
+ pub fn deinit(self: *const Message) void {
+ switch (self.*) {
+ .change_config => |v| {
+ v.ptr.deinit();
+ v.alloc.destroy(v.ptr);
+ },
+ .write_alloc => |v| v.alloc.free(v.data),
+ .kitty_clipboard_grant_read,
+ .kitty_clipboard_grant_write,
+ => |v| v.alloc.free(v.pw),
+ else => {},
+ }
+ }
+
/// The types of size reports that we support.
pub const SizeReport = terminal.size_report.Style;
};
diff --git a/vendor/libghostty-vt/src/termio/shell_integration.zig b/vendor/libghostty-vt/src/termio/shell_integration.zig
index 2dd09ee2..d56a84cc 100644
--- a/vendor/libghostty-vt/src/termio/shell_integration.zig
+++ b/vendor/libghostty-vt/src/termio/shell_integration.zig
@@ -2,10 +2,11 @@ const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
-const EnvMap = std.process.EnvMap;
+const EnvMap = std.process.Environ.Map;
const config = @import("../config.zig");
const homedir = @import("../os/homedir.zig");
const internal_os = @import("../os/main.zig");
+const global = @import("../global.zig");
const log = std.log.scoped(.shell_integration);
@@ -97,7 +98,7 @@ test "force shell" {
inline for (@typeInfo(Shell).@"enum".fields) |field| {
const shell = @field(Shell, field.name);
- var res: TmpResourcesDir = try .init(alloc, shell);
+ var res: TmpResourcesDir = try .init(shell);
defer res.deinit();
const result = try setup(
@@ -373,12 +374,12 @@ fn setupBash(
"{s}/shell-integration/bash/ghostty.bash",
.{resource_dir},
);
- if (std.fs.openFileAbsolute(script_path, .{})) |file| {
- file.close();
+ if (std.Io.Dir.openFileAbsolute(global.io(), script_path, .{})) |file| {
+ file.close(global.io());
try env.put("ENV", script_path);
} else |err| {
log.warn("unable to open {s}: {}", .{ script_path, err });
- env.remove("GHOSTTY_BASH_ENV");
+ _ = env.swapRemove("GHOSTTY_BASH_ENV");
return null;
}
@@ -390,8 +391,10 @@ fn setupBash(
// 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 environ_map = try global.environMap();
+ defer environ_map.deinit();
var home_buf: [1024]u8 = undefined;
- if (try homedir.home(&home_buf)) |home| {
+ if (try homedir.home(global.io(), &environ_map, &home_buf)) |home| {
var histfile_buf: [std.fs.max_path_bytes]u8 = undefined;
const histfile = try std.fmt.bufPrint(
&histfile_buf,
@@ -404,7 +407,7 @@ fn setupBash(
}
// Return a copy of our modified command line to use as the shell command.
- return .{ .shell = try alloc.dupeZ(u8, try cmd.toOwnedSlice()) };
+ return .{ .shell = try alloc.dupeZ(u8, cmd.buffer.written()) };
}
test "bash" {
@@ -413,7 +416,7 @@ test "bash" {
defer arena.deinit();
const alloc = arena.allocator();
- var res: TmpResourcesDir = try .init(alloc, .bash);
+ var res: TmpResourcesDir = try .init(.bash);
defer res.deinit();
var env = EnvMap.init(alloc);
@@ -436,7 +439,7 @@ test "bash: unsupported options" {
defer arena.deinit();
const alloc = arena.allocator();
- var res: TmpResourcesDir = try .init(alloc, .bash);
+ var res: TmpResourcesDir = try .init(.bash);
defer res.deinit();
const cmdlines = [_][:0]const u8{
@@ -462,7 +465,7 @@ test "bash: inject flags" {
defer arena.deinit();
const alloc = arena.allocator();
- var res: TmpResourcesDir = try .init(alloc, .bash);
+ var res: TmpResourcesDir = try .init(.bash);
defer res.deinit();
// bash --norc
@@ -492,7 +495,7 @@ test "bash: rcfile" {
defer arena.deinit();
const alloc = arena.allocator();
- var res: TmpResourcesDir = try .init(alloc, .bash);
+ var res: TmpResourcesDir = try .init(.bash);
defer res.deinit();
var env = EnvMap.init(alloc);
@@ -519,7 +522,7 @@ test "bash: HISTFILE" {
defer arena.deinit();
const alloc = arena.allocator();
- var res: TmpResourcesDir = try .init(alloc, .bash);
+ var res: TmpResourcesDir = try .init(.bash);
defer res.deinit();
// HISTFILE unset
@@ -551,7 +554,7 @@ test "bash: ENV" {
defer arena.deinit();
const alloc = arena.allocator();
- var res: TmpResourcesDir = try .init(alloc, .bash);
+ var res: TmpResourcesDir = try .init(.bash);
defer res.deinit();
var env = EnvMap.init(alloc);
@@ -575,7 +578,7 @@ test "bash: additional arguments" {
defer arena.deinit();
const alloc = arena.allocator();
- var res: TmpResourcesDir = try .init(alloc, .bash);
+ var res: TmpResourcesDir = try .init(.bash);
defer res.deinit();
var env = EnvMap.init(alloc);
@@ -603,7 +606,7 @@ test "bash: missing resources" {
var tmp_dir = testing.tmpDir(.{});
defer tmp_dir.cleanup();
- const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, ".");
+ const resources_dir = try tmp_dir.dir.realPathFileAlloc(testing.io, ".", alloc);
defer alloc.free(resources_dir);
var env = EnvMap.init(alloc);
@@ -633,11 +636,15 @@ fn setupXdgDataDirs(
"{s}/shell-integration",
.{resource_dir},
);
- var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch |err| {
+ var integ_dir = std.Io.Dir.openDirAbsolute(
+ global.io(),
+ integ_path,
+ .{},
+ ) catch |err| {
log.warn("unable to open {s}: {}", .{ integ_path, err });
return false;
};
- integ_dir.close();
+ integ_dir.close(global.io());
// 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
@@ -659,7 +666,7 @@ fn setupXdgDataDirs(
const xdg_data_dirs_key = "XDG_DATA_DIRS";
try env.put(
xdg_data_dirs_key,
- try internal_os.prependEnv(
+ try prependEnv(
stack_alloc,
env.get(xdg_data_dirs_key) orelse "/usr/local/share:/usr/share",
integ_path,
@@ -669,6 +676,23 @@ fn setupXdgDataDirs(
return true;
}
+/// Prepend a value to an environment variable such as PATH.
+/// The returned value is always allocated so it must be freed.
+fn prependEnv(
+ alloc: Allocator,
+ current: []const u8,
+ value: []const u8,
+) Allocator.Error![]u8 {
+ // If there is no prior value, we return it as-is
+ if (current.len == 0) return try alloc.dupe(u8, value);
+
+ return try std.fmt.allocPrint(alloc, "{s}{c}{s}", .{
+ value,
+ std.fs.path.delimiter,
+ current,
+ });
+}
+
test "xdg: empty XDG_DATA_DIRS" {
if (builtin.os.tag == .windows) return error.SkipZigTest;
@@ -678,7 +702,7 @@ test "xdg: empty XDG_DATA_DIRS" {
defer arena.deinit();
const alloc = arena.allocator();
- var res: TmpResourcesDir = try .init(alloc, .fish);
+ var res: TmpResourcesDir = try .init(.fish);
defer res.deinit();
var env = EnvMap.init(alloc);
@@ -706,7 +730,7 @@ test "xdg: existing XDG_DATA_DIRS" {
defer arena.deinit();
const alloc = arena.allocator();
- var res: TmpResourcesDir = try .init(alloc, .fish);
+ var res: TmpResourcesDir = try .init(.fish);
defer res.deinit();
var env = EnvMap.init(alloc);
@@ -736,7 +760,7 @@ test "xdg: missing resources" {
var tmp_dir = testing.tmpDir(.{});
defer tmp_dir.cleanup();
- const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, ".");
+ const resources_dir = try tmp_dir.dir.realPathFileAlloc(testing.io, ".", alloc);
defer alloc.free(resources_dir);
var env = EnvMap.init(alloc);
@@ -815,7 +839,7 @@ fn setupNushell(
}
// Return a copy of our modified command line to use as the shell command.
- return .{ .shell = try alloc.dupeZ(u8, try cmd.toOwnedSlice()) };
+ return .{ .shell = try alloc.dupeZ(u8, cmd.buffer.written()) };
}
test "nushell" {
@@ -824,7 +848,7 @@ test "nushell" {
defer arena.deinit();
const alloc = arena.allocator();
- var res: TmpResourcesDir = try .init(alloc, .nushell);
+ var res: TmpResourcesDir = try .init(.nushell);
defer res.deinit();
var env = EnvMap.init(alloc);
@@ -850,7 +874,7 @@ test "nushell: unsupported options" {
defer arena.deinit();
const alloc = arena.allocator();
- var res: TmpResourcesDir = try .init(alloc, .nushell);
+ var res: TmpResourcesDir = try .init(.nushell);
defer res.deinit();
const cmdlines = [_][:0]const u8{
@@ -879,7 +903,7 @@ test "nushell: missing resources" {
var tmp_dir = testing.tmpDir(.{});
defer tmp_dir.cleanup();
- const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, ".");
+ const resources_dir = try tmp_dir.dir.realPathFileAlloc(testing.io, ".", alloc);
defer alloc.free(resources_dir);
var env = EnvMap.init(alloc);
@@ -910,11 +934,15 @@ fn setupZsh(
"{s}/shell-integration/zsh",
.{resource_dir},
);
- var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch |err| {
+ var integ_dir = std.Io.Dir.openDirAbsolute(
+ global.io(),
+ integ_path,
+ .{},
+ ) catch |err| {
log.warn("unable to open {s}: {}", .{ integ_path, err });
return null;
};
- integ_dir.close();
+ integ_dir.close(global.io());
try env.put("ZDOTDIR", integ_path);
return try command.clone(alloc);
@@ -927,7 +955,7 @@ test "zsh" {
defer arena.deinit();
const alloc = arena.allocator();
- var res: TmpResourcesDir = try .init(testing.allocator, .zsh);
+ var res: TmpResourcesDir = try .init(.zsh);
defer res.deinit();
var env = EnvMap.init(testing.allocator);
@@ -946,7 +974,7 @@ test "zsh: ZDOTDIR" {
defer arena.deinit();
const alloc = arena.allocator();
- var res: TmpResourcesDir = try .init(testing.allocator, .zsh);
+ var res: TmpResourcesDir = try .init(.zsh);
defer res.deinit();
var env = EnvMap.init(testing.allocator);
@@ -969,7 +997,7 @@ test "zsh: missing resources" {
var tmp_dir = testing.tmpDir(.{});
defer tmp_dir.cleanup();
- const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, ".");
+ const resources_dir = try tmp_dir.dir.realPathFileAlloc(testing.io, ".", alloc);
defer alloc.free(resources_dir);
var env = EnvMap.init(alloc);
@@ -981,12 +1009,11 @@ test "zsh: missing resources" {
/// 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,
+ path: [:0]const u8,
shell_path: []const u8,
- fn init(allocator: Allocator, shell: Shell) !TmpResourcesDir {
+ fn init(shell: Shell) !TmpResourcesDir {
var tmp_dir = std.testing.tmpDir(.{});
errdefer tmp_dir.cleanup();
@@ -996,20 +1023,20 @@ const TmpResourcesDir = struct {
"shell-integration/{s}",
.{@tagName(shell)},
);
- try tmp_dir.dir.makePath(relative_shell_path);
+ try tmp_dir.dir.createDirPath(std.testing.io, relative_shell_path);
- const path = try tmp_dir.dir.realpathAlloc(allocator, ".");
- errdefer allocator.free(path);
+ const path = try tmp_dir.dir.realPathFileAlloc(std.testing.io, ".", std.testing.allocator);
+ errdefer std.testing.allocator.free(path);
const shell_path = try std.fmt.allocPrint(
- allocator,
+ std.testing.allocator,
"{s}/{s}",
.{ path, relative_shell_path },
);
- errdefer allocator.free(shell_path);
+ errdefer std.testing.allocator.free(shell_path);
switch (shell) {
- .bash => try tmp_dir.dir.writeFile(.{
+ .bash => try tmp_dir.dir.writeFile(std.testing.io, .{
.sub_path = "shell-integration/bash/ghostty.bash",
.data = "",
}),
@@ -1017,7 +1044,6 @@ const TmpResourcesDir = struct {
}
return .{
- .allocator = allocator,
.tmp_dir = tmp_dir,
.path = path,
.shell_path = shell_path,
@@ -1025,8 +1051,8 @@ const TmpResourcesDir = struct {
}
fn deinit(self: *TmpResourcesDir) void {
- self.allocator.free(self.shell_path);
- self.allocator.free(self.path);
+ std.testing.allocator.free(self.shell_path);
+ std.testing.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
index 43c5d611..27e06dcb 100644
--- a/vendor/libghostty-vt/src/termio/stream_handler.zig
+++ b/vendor/libghostty-vt/src/termio/stream_handler.zig
@@ -2,7 +2,8 @@ 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 global = @import("../global.zig");
+const xev = global.xev;
const apprt = @import("../apprt.zig");
const build_config = @import("../build_config.zig");
const configpkg = @import("../config.zig");
@@ -40,12 +41,6 @@ pub const StreamHandler = struct {
/// 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,
@@ -56,6 +51,10 @@ pub const StreamHandler = struct {
/// The clipboard write access configuration.
clipboard_write: configpkg.ClipboardAccess,
+ /// Maximum total decoded bytes per Kitty clipboard protocol
+ /// (OSC 5522) write transaction; exceeding it aborts with EFBIG.
+ clipboard_write_limit: usize,
+
//---------------------------------------------------------------
// Internal state
@@ -73,6 +72,14 @@ pub const StreamHandler = struct {
/// The tmux control mode viewer state.
tmux_viewer: if (tmux_enabled) ?*terminal.tmux.Viewer else void = if (tmux_enabled) null else {},
+ /// Session password grants for the Kitty clipboard protocol.
+ /// Requests carrying a granted password skip the permission prompt.
+ kitty_clipboard_grants: terminal.kitty.clipboard.Grants = .{},
+
+ /// The in-flight Kitty clipboard protocol (OSC 5522) write
+ /// transaction, if any.
+ kitty_clipboard_write: ?*terminal.kitty.clipboard.WriteState = null,
+
/// 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.
@@ -90,6 +97,8 @@ pub const StreamHandler = struct {
pub fn deinit(self: *StreamHandler) void {
self.apc.deinit();
self.dcs.deinit();
+ self.kittyClipboardWriteAbort();
+ self.kitty_clipboard_grants.deinit(self.alloc);
if (comptime tmux_enabled) tmux: {
const viewer = self.tmux_viewer orelse break :tmux;
viewer.deinit();
@@ -109,14 +118,10 @@ pub const StreamHandler = struct {
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.clipboard_write_limit = config.clipboard_write_limit;
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});
- };
+ self.terminal.setDefaultCursorStyle(config.cursor_style);
+ self.terminal.setDefaultCursorBlink(config.cursor_blink);
// The config could have changed any of our colors so update mode 2031
self.messageWriter(.{ .color_scheme_report = .{ .force = false } });
@@ -129,8 +134,8 @@ pub const StreamHandler = struct {
// 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.renderer_state.mutex.unlock(global.io());
+ defer self.renderer_state.mutex.lockUncancelable(global.io());
_ = self.surface_mailbox.push(msg, .{ .forever = {} });
}
}
@@ -158,8 +163,8 @@ pub const StreamHandler = struct {
// 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_state.mutex.unlock(global.io());
+ defer self.renderer_state.mutex.lockUncancelable(global.io());
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
@@ -242,7 +247,7 @@ pub const StreamHandler = struct {
self.terminal.screens.active.cursor.y + 1 +| value.value,
self.terminal.screens.active.cursor.x + 1,
),
- .cursor_style => try self.setCursorStyle(value),
+ .cursor_style => self.terminal.setCursorStyle(value),
.erase_display_below => self.terminal.eraseDisplay(.below, value),
.erase_display_above => self.terminal.eraseDisplay(.above, value),
.erase_display_complete => {
@@ -362,10 +367,12 @@ pub const StreamHandler = struct {
.apc_end => try self.apcEnd(),
.apc_put => self.apc.feed(self.alloc, value),
.apc_put_slice => self.apc.feedSlice(self.alloc, value.bytes),
+ .kitty_clipboard => try self.kittyClipboard(value),
// Unimplemented
.title_push,
.title_pop,
+ .kitty_dnd,
=> {},
}
}
@@ -403,7 +410,7 @@ pub const StreamHandler = struct {
assert(self.tmux_viewer == null);
const viewer = try self.alloc.create(terminal.tmux.Viewer);
errdefer self.alloc.destroy(viewer);
- viewer.* = try .init(self.alloc);
+ viewer.* = try .init(global.io(), self.alloc);
errdefer viewer.deinit();
self.tmux_viewer = viewer;
break :tmux;
@@ -474,86 +481,26 @@ pub const StreamHandler = struct {
},
.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]);
+ var response: [terminal.dcs.Command.DECRQSS.max_response_bytes]u8 = undefined;
+ const encoded = try decrqss.encode(self.terminal, &response);
+ const msg = try termio.Message.writeReq(
+ self.alloc,
+ encoded,
+ );
self.messageWriter(msg);
},
}
}
pub fn apcEnd(self: *StreamHandler) !void {
- var cmd = self.apc.end() orelse return;
- defer cmd.deinit(self.alloc);
+ var result = self.apc.end() orelse return;
+ defer result.deinit(self.alloc);
- // log.warn("APC command: {}", .{cmd});
- switch (cmd) {
+ // log.warn("APC command: {}", .{result});
+ switch (result) {
+ .unknown => return,
.kitty => |*kitty_cmd| {
- if (self.terminal.kittyGraphics(self.alloc, kitty_cmd)) |resp| {
+ if (self.terminal.kittyGraphics(global.io(), self.alloc, kitty_cmd)) |resp| {
var buf: [1024]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try resp.encode(&writer);
@@ -687,7 +634,7 @@ pub const StreamHandler = struct {
// 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)
+ self.terminal.cursor.default_blink != null)
{
return;
}
@@ -747,8 +694,10 @@ pub const StreamHandler = struct {
const grid_size = self.size.grid();
self.terminal.resize(
self.alloc,
- grid_size.columns,
- grid_size.rows,
+ .{
+ .cols = grid_size.columns,
+ .rows = grid_size.rows,
+ },
) catch |err| {
log.err("error updating terminal size: {}", .{err});
};
@@ -773,6 +722,13 @@ pub const StreamHandler = struct {
.size_report = .mode_2048,
}),
+ .report_visibility => if (enabled) self.messageWriter(.{
+ .visibility_report = .{
+ .visible = self.terminal.flags.visible,
+ .force = true,
+ },
+ }),
+
.focus_event => if (enabled) self.messageWriter(.{
.focused = self.terminal.flags.focused,
}),
@@ -889,55 +845,11 @@ pub const StreamHandler = struct {
},
.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);
- },
+ .visibility => self.messageWriter(.{ .visibility_report = .{
+ .visible = self.terminal.flags.visible,
+ .force = true,
+ } }),
}
}
@@ -972,6 +884,10 @@ pub const StreamHandler = struct {
self.terminal.fullReset();
try self.setMouseShape(.text);
+ // Full reset clears Kitty clipboard session grants.
+ self.kitty_clipboard_grants.deinit(self.alloc);
+ self.kitty_clipboard_grants = .{};
+
// Reset resets our palette so we report it for mode 2031.
self.messageWriter(.{ .color_scheme_report = .{ .force = false } });
@@ -979,6 +895,16 @@ pub const StreamHandler = struct {
self.progressReport(.{ .state = .remove });
}
+ /// Record a Kitty clipboard protocol session grant so future
+ /// requests with this password skip the permission prompt.
+ pub fn kittyClipboardGrant(
+ self: *StreamHandler,
+ pw: []const u8,
+ dir: terminal.kitty.clipboard.Grants.Direction,
+ ) error{OutOfMemory}!void {
+ try self.kitty_clipboard_grants.grant(self.alloc, pw, dir, false);
+ }
+
pub fn queryKittyKeyboard(self: *StreamHandler) !void {
log.debug("querying kitty keyboard mode", .{});
var data: termio.Message.WriteReq.Small.Array = undefined;
@@ -1093,6 +1019,410 @@ pub const StreamHandler = struct {
});
}
+ /// Handle one Kitty clipboard protocol (OSC 5522) packet.
+ fn kittyClipboard(
+ self: *StreamHandler,
+ v: terminal.osc.Command.KittyClipboardProtocol,
+ ) error{ OutOfMemory, WriteFailed }!void {
+ const kitty_clipboard = terminal.kitty.clipboard;
+
+ // Decode and validate the metadata. Malformed structure drops
+ // the packet without a response. Invalid decoded text on a write
+ // data or alias packet aborts an in-flight transaction.
+ var arena: std.heap.ArenaAllocator = .init(self.alloc);
+ defer arena.deinit();
+ const meta = (kitty_clipboard.Metadata.parse(
+ arena.allocator(),
+ v.metadata,
+ ) catch |err| switch (err) {
+ error.OutOfMemory => return error.OutOfMemory,
+ error.InvalidValue => {
+ const state = self.kitty_clipboard_write orelse return;
+ switch (kitty_clipboard.Metadata.operation(v.metadata) orelse return) {
+ .wdata, .walias => try self.kittyClipboardWriteFinish(
+ state,
+ .EINVAL,
+ v.terminator,
+ ),
+ .read, .write => {},
+ }
+ return;
+ },
+ }) orelse return;
+
+ switch (meta.op) {
+ .read => try self.kittyClipboardRead(
+ &meta,
+ v.payload orelse "",
+ v.terminator,
+ ),
+
+ .write => try self.kittyClipboardWriteBegin(
+ &meta,
+ v.terminator,
+ ),
+
+ .wdata => try self.kittyClipboardWriteData(
+ &meta,
+ v.payload orelse "",
+ v.terminator,
+ ),
+
+ .walias => try self.kittyClipboardWriteAlias(
+ &meta,
+ v.payload orelse "",
+ v.terminator,
+ ),
+ }
+ }
+
+ fn kittyClipboardRead(
+ self: *StreamHandler,
+ meta: *const terminal.kitty.clipboard.Metadata,
+ payload: []const u8,
+ terminator: terminal.osc.Terminator,
+ ) !void {
+ const kitty_clipboard = terminal.kitty.clipboard;
+
+ // Everything about the request, including the request struct
+ // itself, lives in a single arena that crosses to the surface
+ // thread, which owns it from the moment the message is sent.
+ var arena: std.heap.ArenaAllocator = .init(self.alloc);
+ errdefer arena.deinit();
+ const alloc = arena.allocator();
+
+ // The payload is the requested MIME list. A read request with
+ // an undecodable payload is dropped without any response,
+ // matching kitty.
+ const decoded = kitty_clipboard.Payload.init(
+ alloc,
+ payload,
+ ) catch |err| switch (err) {
+ error.OutOfMemory => return error.OutOfMemory,
+ error.Invalid => {
+ arena.deinit();
+ return;
+ },
+ };
+ if (!decoded.isValidUtf8()) {
+ arena.deinit();
+ return;
+ }
+
+ // The targets type ('.') asks for the listing of available
+ // types rather than data. Requested types beyond the cap are
+ // dropped and simply never served, which is how the protocol
+ // reports an unavailable type anyway.
+ var mimes_buf: [kitty_clipboard.max_read_mimes][]const u8 = undefined;
+ var mimes_len: usize = 0;
+ var list = false;
+ var it = decoded.mimeIterator();
+ while (it.next()) |mime| {
+ if (std.mem.eql(u8, mime, kitty_clipboard.targets_mime)) {
+ list = true;
+ continue;
+ }
+ if (mimes_len == mimes_buf.len) continue;
+ mimes_buf[mimes_len] = mime;
+ mimes_len += 1;
+ }
+
+ // Per the spec a password without a name is no password. A
+ // stored session grant for it lets the surface skip its
+ // permission prompt.
+ const pw: []const u8 = if (meta.name.len > 0) meta.pw else "";
+ const granted = self.kittyClipboardReadGranted(pw, mimes_len);
+
+ const req = try alloc.create(apprt.ClipboardRequest.KittyRead);
+ const mimes = try alloc.alloc([:0]const u8, mimes_len);
+ for (mimes_buf[0..mimes_len], mimes) |src, *dst| {
+ dst.* = try alloc.dupeZ(u8, src);
+ }
+ const id = try alloc.dupe(u8, meta.id);
+ const pw_owned = try alloc.dupe(u8, pw);
+ const name_owned = try alloc.dupeZ(u8, meta.name);
+ req.* = .{
+ // The arena must be copied in last so it tracks every
+ // allocation above.
+ .arena = arena,
+ .location = switch (meta.loc) {
+ .primary => .primary,
+ else => .standard,
+ },
+ .mimes = mimes,
+ .list = list,
+ .id = id,
+ .pw = pw_owned,
+ .name = name_owned,
+ .granted = granted,
+ .terminator = terminator,
+ };
+
+ self.surfaceMessageWriter(.{ .kitty_clipboard_read = req });
+ }
+
+ /// Whether a session grant covers a read request, consuming
+ /// one-time grants. A prompt-exempt request never consults the
+ /// grants: consuming a one-time paste password on a listing would
+ /// burn the grant before the follow-up data read.
+ fn kittyClipboardReadGranted(
+ self: *StreamHandler,
+ pw: []const u8,
+ mimes_len: usize,
+ ) bool {
+ if (terminal.kitty.clipboard.readPromptExempt(mimes_len)) return false;
+ return self.kitty_clipboard_grants.use(self.alloc, pw, .read);
+ }
+
+ /// Begin a Kitty clipboard write transaction (type=write).
+ fn kittyClipboardWriteBegin(
+ self: *StreamHandler,
+ meta: *const terminal.kitty.clipboard.Metadata,
+ terminator: terminal.osc.Terminator,
+ ) error{ OutOfMemory, WriteFailed }!void {
+ // A new write silently replaces any in-flight transaction.
+ self.kittyClipboardWriteAbort();
+
+ // A write denied by policy can never succeed, so fail the
+ // transaction up front instead of spooling data we'd only
+ // throw away. Later wdata packets are ignored.
+ if (self.clipboard_write == .deny) {
+ log.info("application attempted to write clipboard, but 'clipboard-write' is set to deny", .{});
+ try self.kittyClipboardWriteStatus(
+ .EPERM,
+ meta.id,
+ terminator,
+ );
+ return;
+ }
+
+ const state = try self.alloc.create(terminal.kitty.clipboard.WriteState);
+ errdefer self.alloc.destroy(state);
+ state.* = try .init(self.alloc, meta, .{
+ .max_size = self.clipboard_write_limit,
+ });
+ self.kitty_clipboard_write = state;
+ }
+
+ /// Accumulate one wdata chunk, or commit the transaction when the
+ /// chunk carries no MIME type.
+ fn kittyClipboardWriteData(
+ self: *StreamHandler,
+ meta: *const terminal.kitty.clipboard.Metadata,
+ payload: []const u8,
+ terminator: terminal.osc.Terminator,
+ ) error{ OutOfMemory, WriteFailed }!void {
+ // Data without a transaction is silently ignored, matching
+ // kitty.
+ const state = self.kitty_clipboard_write orelse return;
+
+ // A wdata packet without a MIME type commits the transaction.
+ if (meta.mime.len == 0) return self.kittyClipboardWriteCommit(
+ state,
+ terminator,
+ );
+
+ state.data(
+ self.alloc,
+ meta,
+ payload,
+ ) catch |err| switch (err) {
+ // Failing to spool matches kitty's EIO for a failed buffer write.
+ error.OutOfMemory => {
+ try self.kittyClipboardWriteFinish(
+ state,
+ .EIO,
+ terminator,
+ );
+ return error.OutOfMemory;
+ },
+
+ // Data over the write limit aborts the transaction and is
+ // reported to the client.
+ error.TooLarge => try self.kittyClipboardWriteFinish(
+ state,
+ .EFBIG,
+ terminator,
+ ),
+
+ // An invalid base64 payload stream aborts the transaction.
+ error.Invalid => try self.kittyClipboardWriteFinish(
+ state,
+ .EINVAL,
+ terminator,
+ ),
+ };
+ }
+
+ /// Register aliases from a walias packet.
+ fn kittyClipboardWriteAlias(
+ self: *StreamHandler,
+ meta: *const terminal.kitty.clipboard.Metadata,
+ payload: []const u8,
+ terminator: terminal.osc.Terminator,
+ ) error{ OutOfMemory, WriteFailed }!void {
+ // Aliases without a transaction are silently ignored. Once a
+ // transaction exists, a missing target MIME type is invalid and
+ // aborts the transaction.
+ const state = self.kitty_clipboard_write orelse return;
+ if (meta.mime.len == 0) return self.kittyClipboardWriteFinish(
+ state,
+ .EINVAL,
+ terminator,
+ );
+
+ state.alias(
+ self.alloc,
+ meta,
+ payload,
+ ) catch |err| switch (err) {
+ error.OutOfMemory => {
+ try self.kittyClipboardWriteFinish(
+ state,
+ .EIO,
+ terminator,
+ );
+ return error.OutOfMemory;
+ },
+
+ // An undecodable alias payload aborts the transaction.
+ error.Invalid => try self.kittyClipboardWriteFinish(
+ state,
+ .EINVAL,
+ terminator,
+ ),
+ };
+ }
+
+ /// Commit the transaction: resolve the final contents and forward
+ /// them to the surface thread, which owns policy, any permission
+ /// prompt, the clipboard write itself, and the final reply.
+ fn kittyClipboardWriteCommit(
+ self: *StreamHandler,
+ state: *terminal.kitty.clipboard.WriteState,
+ terminator: terminal.osc.Terminator,
+ ) error{ OutOfMemory, WriteFailed }!void {
+ self.kittyClipboardWriteSend(
+ state,
+ terminator,
+ ) catch |err| switch (err) {
+ error.OutOfMemory => {
+ try self.kittyClipboardWriteFinish(
+ state,
+ .EIO,
+ terminator,
+ );
+ return error.OutOfMemory;
+ },
+
+ // The last MIME type's payload stream was not correctly
+ // padded, which aborts the transaction.
+ error.Invalid => return try self.kittyClipboardWriteFinish(
+ state,
+ .EINVAL,
+ terminator,
+ ),
+ };
+
+ // The transaction is complete; the surface owns the reply.
+ self.kittyClipboardWriteAbort();
+ }
+
+ fn kittyClipboardWriteSend(
+ self: *StreamHandler,
+ state: *terminal.kitty.clipboard.WriteState,
+ terminator: terminal.osc.Terminator,
+ ) error{ OutOfMemory, Invalid }!void {
+ const committed = try state.commit(self.alloc);
+ defer committed.deinit(self.alloc);
+
+ // Per the spec a password without a name is no password. A
+ // stored session grant for it lets the surface skip its
+ // permission prompt.
+ const pw: []const u8 = if (committed.name.len > 0) committed.pw else "";
+ const granted = self.kitty_clipboard_grants.use(self.alloc, pw, .write);
+
+ // Everything about the request, including the request struct
+ // itself, lives in a single arena that crosses to the surface
+ // thread, which owns it from the moment the message is sent.
+ var arena: std.heap.ArenaAllocator = .init(self.alloc);
+ errdefer arena.deinit();
+ const alloc = arena.allocator();
+
+ const req = try alloc.create(apprt.ClipboardRequest.KittyWrite);
+ const contents = try alloc.alloc(
+ apprt.ClipboardContent,
+ committed.contents.len,
+ );
+ for (committed.contents, contents) |src, *dst| dst.* = .{
+ .mime = try alloc.dupeZ(u8, src.mime),
+ .data = try alloc.dupeZ(u8, src.data),
+ };
+ const id = try alloc.dupe(u8, committed.id);
+ const pw_owned = try alloc.dupe(u8, pw);
+ const name_owned = try alloc.dupeZ(u8, committed.name);
+ req.* = .{
+ // The arena must be copied in last so it tracks every
+ // allocation above.
+ .arena = arena,
+ .location = switch (committed.loc) {
+ .primary => .primary,
+ else => .standard,
+ },
+ .contents = contents,
+ .id = id,
+ .pw = pw_owned,
+ .name = name_owned,
+ .granted = granted,
+ .terminator = terminator,
+ };
+
+ self.surfaceMessageWriter(.{ .kitty_clipboard_write = req });
+ }
+
+ /// Answer the write transaction with its final status and drop it.
+ /// The id echoed is the one from the transaction's opening write
+ /// packet, matching kitty.
+ fn kittyClipboardWriteFinish(
+ self: *StreamHandler,
+ state: *const terminal.kitty.clipboard.WriteState,
+ status: terminal.kitty.clipboard.Status,
+ terminator: terminal.osc.Terminator,
+ ) error{ OutOfMemory, WriteFailed }!void {
+ defer self.kittyClipboardWriteAbort();
+ try self.kittyClipboardWriteStatus(status, state.id, terminator);
+ }
+
+ /// Reply to a write transaction with a single status packet.
+ fn kittyClipboardWriteStatus(
+ self: *StreamHandler,
+ status: terminal.kitty.clipboard.Status,
+ id: []const u8,
+ terminator: terminal.osc.Terminator,
+ ) error{ OutOfMemory, WriteFailed }!void {
+ var stream: std.Io.Writer.Allocating = .init(self.alloc);
+ defer stream.deinit();
+ try (terminal.kitty.clipboard.Response{
+ .op = .write,
+ .status = status,
+ .id = id,
+ .terminator = terminator,
+ }).encode(&stream.writer);
+ self.messageWriter(.{ .write_alloc = .{
+ .alloc = self.alloc,
+ .data = try stream.toOwnedSlice(),
+ } });
+ }
+
+ /// Drop any in-flight write transaction without responding.
+ fn kittyClipboardWriteAbort(self: *StreamHandler) void {
+ if (self.kitty_clipboard_write) |state| {
+ state.deinit(self.alloc);
+ self.alloc.destroy(state);
+ self.kitty_clipboard_write = null;
+ }
+ }
+
fn semanticPrompt(
self: *StreamHandler,
cmd: Stream.Action.SemanticPrompt,
@@ -1175,22 +1505,18 @@ pub const StreamHandler = struct {
return;
}
- var host_buffer: [std.Uri.host_name_max]u8 = undefined;
+ var host_buffer: [std.Io.net.HostName.max_len]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) {
+ const host_valid = internal_os.hostname.isLocal(host.bytes) catch |err| switch (err) {
error.PermissionDenied,
error.Unexpected,
=> {
@@ -1199,7 +1525,7 @@ pub const StreamHandler = struct {
},
};
if (!host_valid) {
- log.warn("OSC 7 host ({s}) must be local", .{host});
+ log.warn("OSC 7 host ({s}) must be local", .{host.bytes});
return;
}
@@ -1244,8 +1570,7 @@ pub const StreamHandler = struct {
var fba: std.heap.FixedBufferAllocator = .init(&buffer);
const alloc = fba.allocator();
- var response: std.ArrayListUnmanaged(u8) = .empty;
- const writer = response.writer(alloc);
+ var response: std.Io.Writer.Allocating = .init(alloc);
var it = requests.constIterator(0);
while (it.next()) |req| {
@@ -1392,7 +1717,7 @@ pub const StreamHandler = struct {
switch (self.osc_color_report_format) {
.@"16-bit" => switch (kind) {
- .palette => |i| try writer.print(
+ .palette => |i| try response.writer.print(
"\x1b]4;{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}",
.{
i,
@@ -1401,7 +1726,7 @@ pub const StreamHandler = struct {
@as(u16, color.b) * 257,
},
),
- .dynamic => |dynamic| try writer.print(
+ .dynamic => |dynamic| try response.writer.print(
"\x1b]{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}",
.{
@intFromEnum(dynamic),
@@ -1414,7 +1739,7 @@ pub const StreamHandler = struct {
},
.@"8-bit" => switch (kind) {
- .palette => |i| try writer.print(
+ .palette => |i| try response.writer.print(
"\x1b]4;{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}",
.{
i,
@@ -1423,7 +1748,7 @@ pub const StreamHandler = struct {
@as(u16, color.b),
},
),
- .dynamic => |dynamic| try writer.print(
+ .dynamic => |dynamic| try response.writer.print(
"\x1b]{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}",
.{
@intFromEnum(dynamic),
@@ -1438,15 +1763,15 @@ pub const StreamHandler = struct {
.none => unreachable,
}
- try writer.writeAll(terminator.string());
+ try response.writer.writeAll(terminator.string());
},
}
}
- if (response.items.len > 0) {
+ if (response.writer.end > 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);
+ const msg = try termio.Message.writeReq(self.alloc, response.writer.buffered());
self.messageWriter(msg);
}
}
@@ -1456,17 +1781,9 @@ pub const StreamHandler = struct {
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);
+ self.surfaceMessageWriter(.{
+ .desktop_notification = .init(title, body),
+ });
}
/// Send a report to the pty.
@@ -1576,3 +1893,78 @@ pub const StreamHandler = struct {
self.surfaceMessageWriter(.{ .progress_report = report });
}
};
+
+test "kitty clipboard read: targets-only never consumes a one-time grant" {
+ const testing = std.testing;
+
+ var handler: StreamHandler = undefined;
+ handler.alloc = testing.allocator;
+ handler.kitty_clipboard_grants = .{};
+ defer handler.kitty_clipboard_grants.deinit(testing.allocator);
+ try handler.kitty_clipboard_grants.grant(testing.allocator, "otp", .read, true);
+
+ // A listing request must not burn the one-time paste password...
+ try testing.expect(!handler.kittyClipboardReadGranted("otp", 0));
+ // ...so the follow-up data read is still granted, exactly once.
+ try testing.expect(handler.kittyClipboardReadGranted("otp", 1));
+ try testing.expect(!handler.kittyClipboardReadGranted("otp", 1));
+}
+
+test "kitty clipboard write: oversized text replies EFBIG" {
+ const testing = std.testing;
+
+ var mailbox = try termio.Mailbox.initSPSC(testing.allocator);
+ defer mailbox.deinit(testing.allocator);
+
+ var mutex: std.Io.Mutex = .init;
+ mutex.lockUncancelable(global.io());
+ defer mutex.unlock(global.io());
+
+ var renderer_state: renderer.State = .{
+ .mutex = &mutex,
+ .terminal = undefined,
+ };
+ var handler: StreamHandler = undefined;
+ handler.alloc = testing.allocator;
+ handler.termio_mailbox = &mailbox;
+ handler.renderer_state = &renderer_state;
+ handler.clipboard_write = .allow;
+ handler.clipboard_write_limit = 4;
+ handler.kitty_clipboard_write = null;
+ defer handler.kittyClipboardWriteAbort();
+
+ const begin: terminal.kitty.clipboard.Metadata = .{
+ .op = .write,
+ .id = "macos",
+ };
+ try handler.kittyClipboardWriteBegin(&begin, .st);
+ const state = handler.kitty_clipboard_write.?;
+ try state.data(
+ testing.allocator,
+ &.{ .op = .wdata, .mime = "text/plain" },
+ "SGVsbA==", // "Hell"
+ );
+ try testing.expectError(error.TooLarge, state.data(
+ testing.allocator,
+ &.{ .op = .wdata, .mime = "text/plain" },
+ "bw==", // "o"
+ ));
+ try handler.kittyClipboardWriteFinish(state, .EFBIG, .st);
+ try testing.expect(handler.kitty_clipboard_write == null);
+
+ const response = mailbox.spsc.queue.pop(global.io());
+ try testing.expect(response != null);
+ const msg = response.?;
+ defer msg.deinit();
+ switch (msg) {
+ .write_alloc => |v| try testing.expectEqualStrings(
+ "\x1B]5522;type=write:status=EFBIG:id=macos\x1B\\",
+ v.data,
+ ),
+ else => try testing.expect(false),
+ }
+
+ // Teardown leaves no transaction that could be committed and
+ // forwarded to the macOS clipboard path.
+ try testing.expect(mailbox.spsc.queue.pop(global.io()) == null);
+}