From 116c7af24f35cfb05a2d29d876016dc1719a895d Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 04:23:29 -0700 Subject: [PATCH 01/10] Add cmux theme picker helper hooks --- build.zig | 2 + src/cli/list_themes.zig | 475 ++++++++++++++++++++++++++++++++++++++-- src/main_ghostty.zig | 5 +- 3 files changed, 466 insertions(+), 16 deletions(-) diff --git a/build.zig b/build.zig index f9d861b1946..c162d51f524 100644 --- a/build.zig +++ b/build.zig @@ -36,6 +36,7 @@ pub fn build(b: *std.Build) !void { // All our steps which we'll hook up later. The steps are shown // up here just so that they are more self-documenting. const libvt_step = b.step("lib-vt", "Build libghostty-vt"); + const cli_helper_step = b.step("cli-helper", "Build the Ghostty CLI helper"); const run_step = b.step("run", "Run the app"); const run_valgrind_step = b.step( "run-valgrind", @@ -61,6 +62,7 @@ pub fn build(b: *std.Build) !void { // Ghostty executable, the actual runnable Ghostty program. const exe = try buildpkg.GhosttyExe.init(b, &config, &deps); + cli_helper_step.dependOn(&exe.install_step.step); // Ghostty docs const docs = try buildpkg.GhosttyDocs.init(b, &deps); diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 42aff9d566a..7ebd974a0c6 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -1,3 +1,4 @@ +const builtin = @import("builtin"); const std = @import("std"); const args = @import("args.zig"); const Action = @import("ghostty.zig").Action; @@ -9,6 +10,7 @@ const global_state = &@import("../global.zig").state; const vaxis = @import("vaxis"); const zf = @import("zf"); +const objc = if (builtin.target.os.tag.isDarwin()) @import("objc") else struct {}; // When the number of filtered themes is less than or equal to this threshold, // the window position will be reset to 0 to show all results from the top. @@ -17,6 +19,71 @@ const zf = @import("zf"); const SMALL_LIST_THRESHOLD = 10; const ColorScheme = enum { all, dark, light }; +const ThemeTargetMode = enum { both, light, dark }; +const cmux_block_start = "# cmux themes start"; +const cmux_block_end = "# cmux themes end"; + +const CmuxThemePicker = struct { + config_path: []u8, + bundle_id: []u8, + initial_light: ?[]u8, + initial_dark: ?[]u8, + target_mode: ThemeTargetMode, + original_contents: ?[]u8, + + fn load(alloc: std.mem.Allocator) !?CmuxThemePicker { + const config_path = try trimmedEnvValue(alloc, "CMUX_THEME_PICKER_CONFIG"); + if (config_path == null) return null; + errdefer alloc.free(config_path.?); + + const bundle_id = (try trimmedEnvValue(alloc, "CMUX_THEME_PICKER_BUNDLE_ID")) orelse + try alloc.dupe(u8, "com.cmuxterm.app"); + errdefer alloc.free(bundle_id); + + const initial_light = try trimmedEnvValue(alloc, "CMUX_THEME_PICKER_INITIAL_LIGHT"); + errdefer if (initial_light) |value| alloc.free(value); + + const initial_dark = try trimmedEnvValue(alloc, "CMUX_THEME_PICKER_INITIAL_DARK"); + errdefer if (initial_dark) |value| alloc.free(value); + + const target_mode = target: { + const raw = try trimmedEnvValue(alloc, "CMUX_THEME_PICKER_TARGET") orelse break :target .both; + defer alloc.free(raw); + break :target std.meta.stringToEnum(ThemeTargetMode, raw) orelse .both; + }; + + const original_contents = try readOptionalFile(alloc, config_path.?); + errdefer if (original_contents) |value| alloc.free(value); + + return .{ + .config_path = config_path.?, + .bundle_id = bundle_id, + .initial_light = initial_light, + .initial_dark = initial_dark, + .target_mode = target_mode, + .original_contents = original_contents, + }; + } + + fn deinit(self: *CmuxThemePicker, alloc: std.mem.Allocator) void { + alloc.free(self.config_path); + alloc.free(self.bundle_id); + if (self.initial_light) |value| alloc.free(value); + if (self.initial_dark) |value| alloc.free(value); + if (self.original_contents) |value| alloc.free(value); + } + + fn initialTheme(self: *const CmuxThemePicker) ?[]const u8 { + return switch (self.target_mode) { + .both => if (eqlOptionalTheme(self.initial_light, self.initial_dark)) + self.initial_light orelse self.initial_dark + else + self.initial_dark orelse self.initial_light, + .light => self.initial_light orelse self.initial_dark, + .dark => self.initial_dark orelse self.initial_light, + }; + } +}; pub const Options = struct { /// If true, print the full path to the theme. @@ -222,6 +289,198 @@ fn writeAutoThemeFile(alloc: std.mem.Allocator, theme_name: []const u8) !void { try w.interface.flush(); } +fn trimmedEnvValue(alloc: std.mem.Allocator, key: []const u8) !?[]u8 { + const raw = std.process.getEnvVarOwned(alloc, key) catch |err| switch (err) { + error.EnvironmentVariableNotFound => return null, + else => return err, + }; + + const trimmed = std.mem.trim(u8, raw, " \t\r\n"); + if (trimmed.len == 0) { + alloc.free(raw); + return null; + } + if (trimmed.ptr == raw.ptr and trimmed.len == raw.len) { + return raw; + } + + const duped = try alloc.dupe(u8, trimmed); + alloc.free(raw); + return duped; +} + +fn readOptionalFile(alloc: std.mem.Allocator, path: []const u8) !?[]u8 { + const file = std.fs.openFileAbsolute(path, .{}) catch |err| switch (err) { + error.FileNotFound => return null, + else => return err, + }; + defer file.close(); + + return try file.readToEndAlloc(alloc, 1024 * 1024); +} + +fn writeAbsoluteFile(path: []const u8, contents: []const u8) !void { + if (std.fs.path.dirname(path)) |dir| { + try std.fs.makeDirAbsolute(dir); + } + + var file = try std.fs.createFileAbsolute(path, .{ .truncate = true }); + defer file.close(); + try file.writeAll(contents); +} + +fn removeManagedThemeOverride( + alloc: std.mem.Allocator, + contents: []const u8, +) ![]u8 { + const start = std.mem.indexOf(u8, contents, cmux_block_start) orelse + return try alloc.dupe(u8, contents); + const end_marker = std.mem.indexOfPos(u8, contents, start, cmux_block_end) orelse + return try alloc.dupe(u8, contents); + + var remove_start = start; + if (remove_start > 0 and contents[remove_start - 1] == '\n') { + remove_start -= 1; + } + + var remove_end = end_marker + cmux_block_end.len; + if (remove_end < contents.len and contents[remove_end] == '\n') { + remove_end += 1; + } + + var result: std.ArrayList(u8) = .empty; + errdefer result.deinit(alloc); + try result.appendSlice(alloc, contents[0..remove_start]); + try result.appendSlice(alloc, contents[remove_end..]); + return try result.toOwnedSlice(alloc); +} + +fn encodeCmuxThemeValue( + alloc: std.mem.Allocator, + light: ?[]const u8, + dark: ?[]const u8, +) !?[]u8 { + if (light) |light_theme| { + if (dark) |dark_theme| { + return try std.fmt.allocPrint( + alloc, + "light:{s},dark:{s}", + .{ light_theme, dark_theme }, + ); + } + + return try std.fmt.allocPrint( + alloc, + "light:{s}", + .{light_theme}, + ); + } + + if (dark) |dark_theme| { + return try std.fmt.allocPrint( + alloc, + "dark:{s}", + .{dark_theme}, + ); + } + + return null; +} + +fn writeCmuxThemeOverride( + alloc: std.mem.Allocator, + cmux: *const CmuxThemePicker, + raw_theme_value: []const u8, +) !void { + const existing = (try readOptionalFile(alloc, cmux.config_path)) orelse + try alloc.dupe(u8, ""); + defer alloc.free(existing); + + const stripped = try removeManagedThemeOverride(alloc, existing); + defer alloc.free(stripped); + + const trimmed = std.mem.trim(u8, stripped, " \t\r\n"); + const block = try std.fmt.allocPrint( + alloc, + "{s}\ntheme = {s}\n{s}\n", + .{ cmux_block_start, raw_theme_value, cmux_block_end }, + ); + defer alloc.free(block); + + var next_contents: std.ArrayList(u8) = .empty; + defer next_contents.deinit(alloc); + if (trimmed.len > 0) { + try next_contents.appendSlice(alloc, trimmed); + try next_contents.appendSlice(alloc, "\n\n"); + } + try next_contents.appendSlice(alloc, block); + try writeAbsoluteFile(cmux.config_path, next_contents.items); +} + +fn restoreCmuxThemeOverride(cmux: *const CmuxThemePicker) !void { + if (cmux.original_contents) |contents| { + try writeAbsoluteFile(cmux.config_path, contents); + return; + } + + std.fs.deleteFileAbsolute(cmux.config_path) catch |err| switch (err) { + error.FileNotFound => {}, + else => return err, + }; +} + +fn postCmuxReloadNotification( + alloc: std.mem.Allocator, + bundle_id: []const u8, +) !void { + if (!builtin.target.os.tag.isDarwin()) return; + + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + + const NSString = objc.getClass("NSString") orelse return error.ObjCFailed; + const center_class = objc.getClass("NSDistributedNotificationCenter") orelse + return error.ObjCFailed; + const center = center_class.msgSend(objc.Object, objc.sel("defaultCenter"), .{}); + + const name_c = try alloc.dupeZ(u8, "com.cmuxterm.themes.reload-config"); + defer alloc.free(name_c); + const object_c = try alloc.dupeZ(u8, bundle_id); + defer alloc.free(object_c); + + const name = NSString.msgSend( + objc.Object, + objc.sel("stringWithUTF8String:"), + .{name_c.ptr}, + ); + const object = NSString.msgSend( + objc.Object, + objc.sel("stringWithUTF8String:"), + .{object_c.ptr}, + ); + + center.msgSend( + void, + objc.sel("postNotificationName:object:userInfo:deliverImmediately:"), + .{ + name, + object, + @as(?*anyopaque, null), + true, + }, + ); +} + +fn eqlOptionalTheme(lhs: ?[]const u8, rhs: ?[]const u8) bool { + if (lhs) |left| { + if (rhs) |right| { + return std.ascii.eqlIgnoreCase(left, right); + } + return false; + } + return rhs == null; +} + const Event = union(enum) { key_press: vaxis.Key, mouse: vaxis.Mouse, @@ -232,6 +491,10 @@ const Event = union(enum) { const Preview = struct { allocator: std.mem.Allocator, should_quit: bool, + outcome: enum { + cancel, + apply, + }, tty: vaxis.Tty, vx: vaxis.Vaxis, mouse: ?vaxis.Mouse, @@ -249,6 +512,12 @@ const Preview = struct { color_scheme: vaxis.Color.Scheme, text_input: vaxis.widgets.TextInput, theme_filter: ColorScheme, + cmux: ?CmuxThemePicker, + cmux_target_mode: ThemeTargetMode, + cmux_preview_light: ?[]const u8, + cmux_preview_dark: ?[]const u8, + cmux_applied_light: ?[]const u8, + cmux_applied_dark: ?[]const u8, pub fn init( allocator: std.mem.Allocator, @@ -257,10 +526,12 @@ const Preview = struct { buf: []u8, ) !*Preview { const self = try allocator.create(Preview); + const cmux = try CmuxThemePicker.load(allocator); self.* = .{ .allocator = allocator, .should_quit = false, + .outcome = .cancel, .tty = try .init(buf), .vx = try vaxis.init(allocator, .{}), .mouse = null, @@ -273,15 +544,25 @@ const Preview = struct { .color_scheme = .light, .text_input = .init(allocator), .theme_filter = theme_filter, + .cmux = cmux, + .cmux_target_mode = if (cmux) |value| value.target_mode else .both, + .cmux_preview_light = if (cmux) |value| value.initial_light else null, + .cmux_preview_dark = if (cmux) |value| value.initial_dark else null, + .cmux_applied_light = if (cmux) |value| value.initial_light else null, + .cmux_applied_dark = if (cmux) |value| value.initial_dark else null, }; try self.updateFiltered(); + if (self.cmuxInitialTheme()) |theme_name| { + self.selectTheme(theme_name); + } return self; } pub fn deinit(self: *Preview) void { const allocator = self.allocator; + if (self.cmux) |*value| value.deinit(allocator); self.filtered.deinit(allocator); self.text_input.deinit(); self.vx.deinit(allocator, self.tty.writer()); @@ -290,6 +571,11 @@ const Preview = struct { } pub fn run(self: *Preview) !void { + errdefer self.restoreCmuxOriginal() catch {}; + defer if (self.outcome == .cancel) { + self.restoreCmuxOriginal() catch {}; + }; + var loop: vaxis.Loop(Event) = .{ .tty = &self.tty, .vaxis = &self.vx, @@ -300,7 +586,10 @@ const Preview = struct { const writer = self.tty.writer(); try self.vx.enterAltScreen(writer); - try self.vx.setTitle(writer, "👻 Ghostty Theme Preview 👻"); + try self.vx.setTitle( + writer, + if (self.cmux != null) "cmux Theme Preview" else "👻 Ghostty Theme Preview 👻", + ); try self.vx.queryTerminal(writer, 1 * std.time.ns_per_s); try self.vx.setMouseMode(writer, true); if (self.vx.caps.color_scheme_updates) @@ -411,6 +700,70 @@ const Preview = struct { }; } + fn selectTheme(self: *Preview, theme_name: []const u8) void { + for (self.filtered.items, 0..) |index, i| { + if (std.ascii.eqlIgnoreCase(self.themes[index].theme, theme_name)) { + self.current = i; + return; + } + } + } + + fn cmuxInitialTheme(self: *const Preview) ?[]const u8 { + const cmux = self.cmux orelse return null; + return cmux.initialTheme(); + } + + fn applyCmuxSelectionForCurrentTheme(self: *Preview) !void { + const cmux = self.cmux orelse return; + if (self.filtered.items.len == 0) return; + + const theme = self.themes[self.filtered.items[self.current]].theme; + switch (self.cmux_target_mode) { + .both => { + self.cmux_preview_light = theme; + self.cmux_preview_dark = theme; + }, + .light => self.cmux_preview_light = theme, + .dark => self.cmux_preview_dark = theme, + } + + try self.syncCmuxPreview(cmux); + } + + fn restoreCmuxOriginal(self: *Preview) !void { + const cmux = self.cmux orelse return; + self.cmux_preview_light = cmux.initial_light; + self.cmux_preview_dark = cmux.initial_dark; + try self.syncCmuxPreview(cmux); + } + + fn syncCmuxPreview(self: *Preview, cmux: CmuxThemePicker) !void { + if (eqlOptionalTheme(self.cmux_preview_light, self.cmux_applied_light) and + eqlOptionalTheme(self.cmux_preview_dark, self.cmux_applied_dark)) + { + return; + } + + if (eqlOptionalTheme(self.cmux_preview_light, cmux.initial_light) and + eqlOptionalTheme(self.cmux_preview_dark, cmux.initial_dark)) + { + try restoreCmuxThemeOverride(&cmux); + } else { + const raw_theme_value = (try encodeCmuxThemeValue( + self.allocator, + self.cmux_preview_light, + self.cmux_preview_dark, + )) orelse return; + defer self.allocator.free(raw_theme_value); + try writeCmuxThemeOverride(self.allocator, &cmux, raw_theme_value); + } + + try postCmuxReloadNotification(self.allocator, cmux.bundle_id); + self.cmux_applied_light = self.cmux_preview_light; + self.cmux_applied_dark = self.cmux_preview_dark; + } + fn up(self: *Preview, count: usize) void { if (self.filtered.items.len == 0) { self.current = 0; @@ -432,40 +785,71 @@ const Preview = struct { pub fn update(self: *Preview, event: Event, alloc: std.mem.Allocator) !void { switch (event) { .key_press => |key| { - if (key.matches('c', .{ .ctrl = true })) + if (key.matches('c', .{ .ctrl = true })) { + self.outcome = .cancel; self.should_quit = true; + } switch (self.mode) { .normal => { - if (key.matchesAny(&.{ 'q', vaxis.Key.escape }, .{})) + if (key.matchesAny(&.{ 'q', vaxis.Key.escape }, .{})) { + self.outcome = .cancel; self.should_quit = true; + } if (key.matchesAny(&.{ '?', vaxis.Key.f1 }, .{})) self.mode = .help; if (key.matches('h', .{ .ctrl = true })) self.mode = .help; if (key.matches('/', .{})) self.mode = .search; - if (key.matchesAny(&.{ vaxis.Key.enter, vaxis.Key.kp_enter }, .{})) - self.mode = .save; + if (key.matchesAny(&.{ vaxis.Key.enter, vaxis.Key.kp_enter }, .{})) { + if (self.cmux != null) { + self.outcome = .apply; + self.should_quit = true; + } else { + self.mode = .save; + } + } if (key.matchesAny(&.{ 'x', '/' }, .{ .ctrl = true })) { self.text_input.buf.clearRetainingCapacity(); try self.updateFiltered(); + try self.applyCmuxSelectionForCurrentTheme(); } - if (key.matchesAny(&.{ vaxis.Key.home, vaxis.Key.kp_home }, .{})) + if (key.matchesAny(&.{ vaxis.Key.home, vaxis.Key.kp_home }, .{})) { self.current = 0; - if (key.matchesAny(&.{ vaxis.Key.end, vaxis.Key.kp_end }, .{})) + try self.applyCmuxSelectionForCurrentTheme(); + } + if (key.matchesAny(&.{ vaxis.Key.end, vaxis.Key.kp_end }, .{})) { self.current = self.filtered.items.len - 1; - if (key.matchesAny(&.{ 'j', '+', vaxis.Key.down, vaxis.Key.kp_down, vaxis.Key.kp_add }, .{})) + try self.applyCmuxSelectionForCurrentTheme(); + } + if (key.matchesAny(&.{ 'j', '+', vaxis.Key.down, vaxis.Key.kp_down, vaxis.Key.kp_add }, .{})) { self.down(1); - if (key.matchesAny(&.{ vaxis.Key.page_down, vaxis.Key.kp_down }, .{})) + try self.applyCmuxSelectionForCurrentTheme(); + } + if (key.matchesAny(&.{ vaxis.Key.page_down, vaxis.Key.kp_down }, .{})) { self.down(20); - if (key.matchesAny(&.{ 'k', '-', vaxis.Key.up, vaxis.Key.kp_up, vaxis.Key.kp_subtract }, .{})) + try self.applyCmuxSelectionForCurrentTheme(); + } + if (key.matchesAny(&.{ 'k', '-', vaxis.Key.up, vaxis.Key.kp_up, vaxis.Key.kp_subtract }, .{})) { self.up(1); - if (key.matchesAny(&.{ vaxis.Key.page_up, vaxis.Key.kp_page_up }, .{})) + try self.applyCmuxSelectionForCurrentTheme(); + } + if (key.matchesAny(&.{ vaxis.Key.page_up, vaxis.Key.kp_page_up }, .{})) { self.up(20); + try self.applyCmuxSelectionForCurrentTheme(); + } if (key.matchesAny(&.{ 'h', 'x' }, .{})) self.hex = true; if (key.matches('d', .{})) self.hex = false; + if (self.cmux != null and key.matches('t', .{})) { + self.cmux_target_mode = switch (self.cmux_target_mode) { + .both => .light, + .light => .dark, + .dark => .both, + }; + try self.applyCmuxSelectionForCurrentTheme(); + } if (key.matches('c', .{})) try self.vx.copyToSystemClipboard( self.tty.writer(), @@ -485,11 +869,14 @@ const Preview = struct { .light => self.theme_filter = .all, } try self.updateFiltered(); + try self.applyCmuxSelectionForCurrentTheme(); } }, .help => { - if (key.matches('q', .{})) + if (key.matches('q', .{})) { + self.outcome = .cancel; self.should_quit = true; + } if (key.matchesAny(&.{ '?', vaxis.Key.escape, vaxis.Key.f1 }, .{})) self.mode = .normal; if (key.matches('h', .{ .ctrl = true })) @@ -503,14 +890,18 @@ const Preview = struct { if (key.matchesAny(&.{ 'x', '/' }, .{ .ctrl = true })) { self.text_input.clearRetainingCapacity(); try self.updateFiltered(); + try self.applyCmuxSelectionForCurrentTheme(); break :search; } try self.text_input.update(.{ .key_press = key }); try self.updateFiltered(); + try self.applyCmuxSelectionForCurrentTheme(); }, .save => { - if (key.matches('q', .{})) + if (key.matches('q', .{})) { + self.outcome = .cancel; self.should_quit = true; + } if (key.matchesAny(&.{ vaxis.Key.escape, vaxis.Key.enter, vaxis.Key.kp_enter }, .{})) self.mode = .normal; if (key.matches('w', .{})) { @@ -615,15 +1006,18 @@ const Preview = struct { if (self.mode == .normal) { if (mouse.button == .wheel_up) { self.up(1); + try self.applyCmuxSelectionForCurrentTheme(); } if (mouse.button == .wheel_down) { self.down(1); + try self.applyCmuxSelectionForCurrentTheme(); } if (theme_list.hasMouse(mouse)) |_| { if (mouse.button == .left and mouse.type == .release) { const selection = self.window + mouse.row; if (selection < self.filtered.items.len) { self.current = selection; + try self.applyCmuxSelectionForCurrentTheme(); } } highlight = mouse.row; @@ -720,6 +1114,37 @@ const Preview = struct { try self.drawPreview(alloc, win, theme_list.x_off + theme_list.width); + if (self.cmux != null) { + const footer = win.child(.{ + .x_off = 0, + .y_off = win.height - 1, + .width = win.width, + .height = 1, + }); + footer.fill(.{ .style = self.ui_selected() }); + + const text = try std.fmt.allocPrint( + alloc, + " cmux live preview target={s} light={s} dark={s} t cycle target Enter apply q cancel ", + .{ + @tagName(self.cmux_target_mode), + self.cmux_preview_light orelse "inherit", + self.cmux_preview_dark orelse "inherit", + }, + ); + const max_len = @min(text.len, footer.width); + _ = footer.printSegment( + .{ + .text = text[0..max_len], + .style = self.ui_selected(), + }, + .{ + .row_offset = 0, + .col_offset = 0, + }, + ); + } + switch (self.mode) { .normal => { win.hideCursor(); @@ -761,11 +1186,31 @@ const Preview = struct { .{ .keys = "End", .help = "Go to the end of the list." }, .{ .keys = "/", .help = "Start search." }, .{ .keys = "^X, ^/", .help = "Clear search." }, - .{ .keys = "⏎", .help = "Save theme or close search window." }, - .{ .keys = "w", .help = "Write theme to auto config file." }, + .{ + .keys = "⏎", + .help = if (self.cmux != null) + "Apply current preview and close." + else + "Save theme or close search window.", + }, + .{ + .keys = "w", + .help = if (self.cmux != null) + "Unused in cmux mode." + else + "Write theme to auto config file.", + }, + .{ + .keys = "t", + .help = if (self.cmux != null) + "Cycle cmux target (both, light, dark)." + else + "", + }, }; for (key_help, 0..) |help, captured_i| { + if (help.help.len == 0) continue; const i: u16 = @intCast(captured_i); _ = child.printSegment( .{ diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index 531a0646134..b81297fce4d 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -72,7 +72,9 @@ pub fn main() !MainReturn { } if (comptime build_config.app_runtime == .none) { - const stdout = std.io.getStdOut().writer(); + var stdout_buf: [1024]u8 = undefined; + var stdout_writer = std.fs.File.stdout().writer(&stdout_buf); + const stdout = &stdout_writer.interface; try stdout.print("Usage: ghostty + [flags]\n\n", .{}); try stdout.print( \\This is the Ghostty helper CLI that accompanies the graphical Ghostty app. @@ -89,6 +91,7 @@ pub fn main() !MainReturn { , .{}, ); + try stdout.flush(); posix.exit(0); } From efff67770a0158e8ba9dd15695b6c2d298005bca Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 04:37:27 -0700 Subject: [PATCH 02/10] Fix cmux theme picker preview writes --- src/cli/list_themes.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 7ebd974a0c6..abab7a2052a 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -321,7 +321,7 @@ fn readOptionalFile(alloc: std.mem.Allocator, path: []const u8) !?[]u8 { fn writeAbsoluteFile(path: []const u8, contents: []const u8) !void { if (std.fs.path.dirname(path)) |dir| { - try std.fs.makeDirAbsolute(dir); + try std.fs.cwd().makePath(dir); } var file = try std.fs.createFileAbsolute(path, .{ .truncate = true }); From 872e8b9de36365f5caa61a6745585ed678727e5d Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 04:44:09 -0700 Subject: [PATCH 03/10] Improve cmux theme picker footer contrast --- src/cli/list_themes.zig | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index abab7a2052a..51590d0df06 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -986,6 +986,13 @@ const Preview = struct { }; } + pub fn ui_footer(self: *Preview) vaxis.Style { + return .{ + .fg = self.ui_fg(), + .bg = self.ui_hover_bg(), + }; + } + pub fn draw(self: *Preview, alloc: std.mem.Allocator) !void { const win = self.vx.window(); win.clear(); @@ -1121,7 +1128,7 @@ const Preview = struct { .width = win.width, .height = 1, }); - footer.fill(.{ .style = self.ui_selected() }); + footer.fill(.{ .style = self.ui_footer() }); const text = try std.fmt.allocPrint( alloc, @@ -1136,7 +1143,7 @@ const Preview = struct { _ = footer.printSegment( .{ .text = text[0..max_len], - .style = self.ui_selected(), + .style = self.ui_footer(), }, .{ .row_offset = 0, From eb97801517d40da686ea562a3c792f18ad86319e Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 04:51:59 -0700 Subject: [PATCH 04/10] Respect system theme in cmux picker --- src/cli/list_themes.zig | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 51590d0df06..986659c6bdb 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -29,6 +29,7 @@ const CmuxThemePicker = struct { initial_light: ?[]u8, initial_dark: ?[]u8, target_mode: ThemeTargetMode, + ui_color_scheme: vaxis.Color.Scheme, original_contents: ?[]u8, fn load(alloc: std.mem.Allocator) !?CmuxThemePicker { @@ -52,6 +53,12 @@ const CmuxThemePicker = struct { break :target std.meta.stringToEnum(ThemeTargetMode, raw) orelse .both; }; + const ui_color_scheme = color_scheme: { + const raw = try trimmedEnvValue(alloc, "CMUX_THEME_PICKER_COLOR_SCHEME") orelse break :color_scheme .light; + defer alloc.free(raw); + break :color_scheme std.meta.stringToEnum(vaxis.Color.Scheme, raw) orelse .light; + }; + const original_contents = try readOptionalFile(alloc, config_path.?); errdefer if (original_contents) |value| alloc.free(value); @@ -61,6 +68,7 @@ const CmuxThemePicker = struct { .initial_light = initial_light, .initial_dark = initial_dark, .target_mode = target_mode, + .ui_color_scheme = ui_color_scheme, .original_contents = original_contents, }; } @@ -541,7 +549,7 @@ const Preview = struct { .window = 0, .hex = false, .mode = .normal, - .color_scheme = .light, + .color_scheme = if (cmux) |value| value.ui_color_scheme else .light, .text_input = .init(allocator), .theme_filter = theme_filter, .cmux = cmux, From 6f63ee97f88e7d7cab69b41b3de11d88d9b7b63c Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 04:55:56 -0700 Subject: [PATCH 05/10] Skip theme detection in cmux picker --- src/cli/list_themes.zig | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 986659c6bdb..2bed74aa878 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -598,9 +598,11 @@ const Preview = struct { writer, if (self.cmux != null) "cmux Theme Preview" else "👻 Ghostty Theme Preview 👻", ); - try self.vx.queryTerminal(writer, 1 * std.time.ns_per_s); + if (self.cmux == null) { + try self.vx.queryTerminal(writer, 1 * std.time.ns_per_s); + } try self.vx.setMouseMode(writer, true); - if (self.vx.caps.color_scheme_updates) + if (self.cmux == null and self.vx.caps.color_scheme_updates) try self.vx.subscribeToColorSchemeUpdates(writer); while (!self.should_quit) { From 4c3fb24533ddb2f68d7cac2524f04ddcddc5ab16 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 05:12:20 -0700 Subject: [PATCH 06/10] Match Ghostty theme picker startup --- src/cli/list_themes.zig | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 2bed74aa878..7eedda36012 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -561,9 +561,6 @@ const Preview = struct { }; try self.updateFiltered(); - if (self.cmuxInitialTheme()) |theme_name| { - self.selectTheme(theme_name); - } return self; } @@ -710,20 +707,6 @@ const Preview = struct { }; } - fn selectTheme(self: *Preview, theme_name: []const u8) void { - for (self.filtered.items, 0..) |index, i| { - if (std.ascii.eqlIgnoreCase(self.themes[index].theme, theme_name)) { - self.current = i; - return; - } - } - } - - fn cmuxInitialTheme(self: *const Preview) ?[]const u8 { - const cmux = self.cmux orelse return null; - return cmux.initialTheme(); - } - fn applyCmuxSelectionForCurrentTheme(self: *Preview) !void { const cmux = self.cmux orelse return; if (self.filtered.items.len == 0) return; From bc9be90a21997a4e5f06bf15ae2ec0f937c2dc42 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 06:59:47 -0700 Subject: [PATCH 07/10] Harden cmux theme override writes --- src/cli/list_themes.zig | 59 +++++++++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 7eedda36012..63bf6f8f500 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -330,9 +330,25 @@ fn readOptionalFile(alloc: std.mem.Allocator, path: []const u8) !?[]u8 { fn writeAbsoluteFile(path: []const u8, contents: []const u8) !void { if (std.fs.path.dirname(path)) |dir| { try std.fs.cwd().makePath(dir); + var dir_handle = try std.fs.openDirAbsolute(dir, .{}); + defer dir_handle.close(); + + var buf: [1024]u8 = undefined; + var atomic_file = try dir_handle.atomicFile(std.fs.path.basename(path), .{ + .mode = 0o600, + .write_buffer = &buf, + }); + defer atomic_file.deinit(); + + try atomic_file.file_writer.interface.writeAll(contents); + try atomic_file.finish(); + return; } - var file = try std.fs.createFileAbsolute(path, .{ .truncate = true }); + var file = try std.fs.createFileAbsolute(path, .{ + .truncate = true, + .mode = 0o600, + }); defer file.close(); try file.writeAll(contents); } @@ -341,25 +357,34 @@ fn removeManagedThemeOverride( alloc: std.mem.Allocator, contents: []const u8, ) ![]u8 { - const start = std.mem.indexOf(u8, contents, cmux_block_start) orelse - return try alloc.dupe(u8, contents); - const end_marker = std.mem.indexOfPos(u8, contents, start, cmux_block_end) orelse - return try alloc.dupe(u8, contents); - - var remove_start = start; - if (remove_start > 0 and contents[remove_start - 1] == '\n') { - remove_start -= 1; - } + var result: std.ArrayList(u8) = .empty; + errdefer result.deinit(alloc); + + var cursor: usize = 0; + while (true) { + const start = std.mem.indexOfPos(u8, contents, cursor, cmux_block_start) orelse { + try result.appendSlice(alloc, contents[cursor..]); + break; + }; + const end_marker = std.mem.indexOfPos(u8, contents, start, cmux_block_end) orelse { + try result.appendSlice(alloc, contents[cursor..]); + break; + }; + + var remove_start = start; + if (remove_start > cursor and contents[remove_start - 1] == '\n') { + remove_start -= 1; + } - var remove_end = end_marker + cmux_block_end.len; - if (remove_end < contents.len and contents[remove_end] == '\n') { - remove_end += 1; + var remove_end = end_marker + cmux_block_end.len; + if (remove_end < contents.len and contents[remove_end] == '\n') { + remove_end += 1; + } + + try result.appendSlice(alloc, contents[cursor..remove_start]); + cursor = remove_end; } - var result: std.ArrayList(u8) = .empty; - errdefer result.deinit(alloc); - try result.appendSlice(alloc, contents[0..remove_start]); - try result.appendSlice(alloc, contents[remove_end..]); return try result.toOwnedSlice(alloc); } From 41e796064e89eacabdf3a6729475e250a5518e7a Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 30 Mar 2026 17:28:46 -0700 Subject: [PATCH 08/10] Add macos-background-from-layer config flag When enabled, the renderer sets bg_color alpha to 0 so the host app can provide the terminal background via CALayer.backgroundColor. This allows instant coverage during view resizes without alpha double-stacking between the GPU bg pass and the layer background. --- src/config/Config.zig | 8 ++++++++ src/renderer/generic.zig | 25 ++++++++++++++++--------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 29a45786fcb..8c0541b49ff 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1057,6 +1057,14 @@ palette: Palette = .{}, /// doing so. @"background-blur": BackgroundBlur = .false, +/// When true on macOS, the terminal background color is expected to be +/// provided by the host CALayer's backgroundColor rather than the GPU +/// full-screen background pass. The renderer sets bg_color alpha to 0 +/// so that the layer background shows through without alpha double-stacking. +/// This allows embedding apps to provide instant background coverage +/// during view resizes. +@"macos-background-from-layer": bool = false, + /// The opacity level (opposite of transparency) of an unfocused split. /// Unfocused splits by default are slightly faded out to make it easier to see /// which split is focused. To disable this feature, set this value to 1. diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index e0d8a4dd67a..dd66241dbe0 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -575,6 +575,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { colorspace: configpkg.Config.WindowColorspace, blending: configpkg.Config.AlphaBlending, background_blur: configpkg.Config.BackgroundBlur, + macos_background_from_layer: bool, scroll_to_bottom_on_output: bool, pub fn init( @@ -649,6 +650,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .colorspace = config.@"window-colorspace", .blending = config.@"alpha-blending", .background_blur = config.@"background-blur", + .macos_background_from_layer = config.@"macos-background-from-layer", .scroll_to_bottom_on_output = config.@"scroll-to-bottom".output, .arena = arena, }; @@ -735,9 +737,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { options.config.background.r, options.config.background.g, options.config.background.b, - // Note that if we're on macOS with glass effects - // we'll disable background opacity but we handle - // that in updateFrame. + // Glass effects and layer-background mode zero this + // out in updateFrame; use the config value for now. @intFromFloat(@round(options.config.background_opacity * 255.0)), }, .bools = .{ @@ -1415,13 +1416,19 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // If we're on macOS and have glass styles, we remove // the background opacity because the glass effect handles // it. - if (comptime builtin.os.tag == .macos) switch (self.config.background_blur) { - .@"macos-glass-regular", - .@"macos-glass-clear", - => self.uniforms.bg_color[3] = 0, + if (comptime builtin.os.tag == .macos) { + switch (self.config.background_blur) { + .@"macos-glass-regular", + .@"macos-glass-clear", + => self.uniforms.bg_color[3] = 0, - else => {}, - }; + else => {}, + } + // When the host app provides background via CALayer, + // skip the GPU background fill to avoid double-stacking. + if (self.config.macos_background_from_layer) + self.uniforms.bg_color[3] = 0; + } // Prepare our overlay image for upload (or unload). This // has to use our general allocator since it modifies From f9030b5c5232db69ba8625bb53d51ce735b80d51 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 30 Mar 2026 18:02:12 -0700 Subject: [PATCH 09/10] Skip fullscreen bg draw call in layer-background mode In addition to zeroing bg_color alpha (needed for cell compositing), explicitly skip the fullscreen background fill draw step. This avoids relying on alpha=0 as a no-op for the draw pass and makes the intent explicit. --- src/renderer/generic.zig | 53 ++++++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index dd66241dbe0..f8b53e8aa3c 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1413,9 +1413,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { @intFromFloat(@round(self.config.background_opacity * 255.0)), }; - // If we're on macOS and have glass styles, we remove - // the background opacity because the glass effect handles - // it. + // On macOS, glass styles and layer-background mode both + // zero bg_color alpha so that per-cell backgrounds in the + // shaders composite to transparent instead of the terminal + // background (the host layer provides the background). + // The fullscreen background draw call is also skipped for + // layer-background mode (see draw pass below). if (comptime builtin.os.tag == .macos) { switch (self.config.background_blur) { .@"macos-glass-regular", @@ -1424,8 +1427,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { else => {}, } - // When the host app provides background via CALayer, - // skip the GPU background fill to avoid double-stacking. if (self.config.macos_background_from_layer) self.uniforms.bg_color[3] = 0; } @@ -1665,26 +1666,36 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Otherwise, if we don't have a background image, we // draw the background color by itself in its own step. // + // When the host app provides the background via a + // CALayer (macos_background_from_layer), skip the + // fullscreen fill entirely — the layer handles it. + // // NOTE: We don't use the clear_color for this because that // would require us to do color space conversion on the // CPU-side. In the future when we have utilities for // that we should remove this step and use clear_color. - if (self.bg_image) |img| switch (img) { - .ready => |texture| pass.step(.{ - .pipeline = self.shaders.pipelines.bg_image, - .uniforms = frame.uniforms.buffer, - .buffers = &.{frame.bg_image_buffer.buffer}, - .textures = &.{texture}, - .draw = .{ .type = .triangle, .vertex_count = 3 }, - }), - else => {}, - } else { - pass.step(.{ - .pipeline = self.shaders.pipelines.bg_color, - .uniforms = frame.uniforms.buffer, - .buffers = &.{ null, frame.cells_bg.buffer }, - .draw = .{ .type = .triangle, .vertex_count = 3 }, - }); + const skip_bg_fill = if (comptime builtin.os.tag == .macos) + self.config.macos_background_from_layer + else + false; + if (!skip_bg_fill) { + if (self.bg_image) |img| switch (img) { + .ready => |texture| pass.step(.{ + .pipeline = self.shaders.pipelines.bg_image, + .uniforms = frame.uniforms.buffer, + .buffers = &.{frame.bg_image_buffer.buffer}, + .textures = &.{texture}, + .draw = .{ .type = .triangle, .vertex_count = 3 }, + }), + else => {}, + } else { + pass.step(.{ + .pipeline = self.shaders.pipelines.bg_color, + .uniforms = frame.uniforms.buffer, + .buffers = &.{ null, frame.cells_bg.buffer }, + .draw = .{ .type = .triangle, .vertex_count = 3 }, + }); + } } // Then we draw any kitty images that need From c19c82bfd4a43edad3d8fbd9d040a2bee9079f5d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 4 Apr 2026 17:53:03 -0700 Subject: [PATCH 10/10] Seed initial focus state and avoid startup focus-report leak --- include/ghostty.h | 1 + .../Ghostty/Surface View/SurfaceView.swift | 6 ++ src/Surface.zig | 12 ++++ src/apprt/embedded.zig | 13 +++++ src/termio/stream_handler.zig | 57 ++++++++++++++++++- 5 files changed, 86 insertions(+), 3 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 65b1cdc5a45..541aa16a3d4 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -449,6 +449,7 @@ typedef struct { ghostty_platform_u platform; void* userdata; double scale_factor; + bool focused; float font_size; const char* working_directory; const char* command; diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index 47503dc0e80..da9ba9044bb 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -641,6 +641,9 @@ extension Ghostty { /// The configuration for a surface. For any configuration not set, defaults will be chosen from /// libghostty, usually from the Ghostty configuration. struct SurfaceConfiguration { + /// Initial focused state for the created surface. + var focused: Bool = true + /// Explicit font size to use in points var fontSize: Float32? @@ -665,6 +668,7 @@ extension Ghostty { init() {} init(from config: ghostty_surface_config_s) { + self.focused = config.focused self.fontSize = config.font_size if let workingDirectory = config.working_directory { self.workingDirectory = String.init(cString: workingDirectory, encoding: .utf8) @@ -711,6 +715,8 @@ extension Ghostty { #error("unsupported target") #endif + config.focused = focused + // Zero is our default value that means to inherit the font size. config.font_size = fontSize ?? 0 diff --git a/src/Surface.zig b/src/Surface.zig index 50e55e722a0..62b8c5e9dbe 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -491,6 +491,11 @@ pub fn init( // Initialize our renderer with our initialized surface. try Renderer.surfaceInit(rt_surface); + const initial_focused = if (comptime @hasDecl(apprt.runtime.Surface, "initialFocused")) + rt_surface.initialFocused() + else + true; + // Determine our DPI configurations so we can properly configure // font points to pixels and handle other high-DPI scaling factors. const content_scale = try rt_surface.getContentScale(); @@ -572,6 +577,7 @@ pub fn init( app_mailbox, ); errdefer render_thread.deinit(); + render_thread.flags.focused = initial_focused; // Create the IO thread var io_thread = try termio.Thread.init(alloc); @@ -600,6 +606,7 @@ pub fn init( .io_thr = undefined, .size = size, .config = derived_config, + .focused = initial_focused, // Our conditional state is initialized to the app state. This // lets us get the most likely correct color theme and so on. @@ -699,6 +706,11 @@ pub fn init( // so we can just defer this and not the subcomponents. errdefer self.io.deinit(); + // Seed the terminal focus state before the IO thread starts so DECSET + // 1004 observes the actual initial focus without requiring a synthetic + // focus callback. + self.io.terminal.flags.focused = self.focused; + // Report initial cell size on surface creation _ = try rt_app.performAction( .{ .surface = self }, diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 76e65abf206..d519fd1b042 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -416,6 +416,7 @@ pub const Surface = struct { platform: Platform, userdata: ?*anyopaque = null, core_surface: CoreSurface, + focused: bool = true, content_scale: apprt.ContentScale, size: apprt.SurfaceSize, cursor_pos: apprt.CursorPos, @@ -441,6 +442,11 @@ pub const Surface = struct { /// The scale factor of the screen. scale_factor: f64 = 1, + /// Initial focused state for the surface. This seeds the core focus + /// bookkeeping without triggering focus-reporting side effects during + /// surface creation. + focused: bool = true, + /// The font size to inherit. If 0, default font size will be used. font_size: f32 = 0, @@ -486,6 +492,7 @@ pub const Surface = struct { .platform = try .init(opts.platform_tag, opts.platform), .userdata = opts.userdata, .core_surface = undefined, + .focused = opts.focused, .content_scale = .{ .x = @floatCast(opts.scale_factor), .y = @floatCast(opts.scale_factor), @@ -653,6 +660,10 @@ pub const Surface = struct { return self.io_mode; } + pub fn initialFocused(self: *const Surface) bool { + return self.focused; + } + pub fn ioWriteCallback(self: *const Surface) ?IoWriteCallback { return self.io_write_cb; } @@ -932,6 +943,7 @@ pub const Surface = struct { } pub fn focusCallback(self: *Surface, focused: bool) void { + self.focused = focused; self.core_surface.focusCallback(focused) catch |err| { log.err("error in focus callback err={}", .{err}); return; @@ -970,6 +982,7 @@ pub const Surface = struct { }; return .{ + .focused = self.focused, .font_size = font_size, .working_directory = working_directory, .context = context, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 8c1b5b8abd3..b2f46d3b1ae 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -752,9 +752,12 @@ pub const StreamHandler = struct { .size_report = .mode_2048, }), - .focus_event => if (enabled) self.messageWriter(.{ - .focused = self.terminal.flags.focused, - }), + // xterm-compatible focus reporting only emits on subsequent focus + // transitions. Do not synthesize an immediate state report when the + // mode is enabled, otherwise applications that toggle DECSET 1004 + // during startup receive spurious CSI I/O without any real focus + // change having happened. + .focus_event => {}, .mouse_event_x10 => { if (enabled) { @@ -1549,3 +1552,51 @@ pub const StreamHandler = struct { self.surfaceMessageWriter(.{ .progress_report = report }); } }; + +test "enabling focus reporting does not emit an immediate focus sequence" { + const testing = std.testing; + + var term = try terminal.Terminal.init(testing.allocator, .{ + .cols = 10, + .rows = 5, + }); + defer term.deinit(testing.allocator); + + var mailbox = try termio.Mailbox.initSPSC(testing.allocator); + defer mailbox.deinit(testing.allocator); + + var mutex: std.Thread.Mutex = .{}; + var renderer_state: renderer.State = .{ + .mutex = &mutex, + .terminal = &term, + }; + var size: renderer.Size = .{ + .screen = .{ .width = 800, .height = 600 }, + .cell = .{ .width = 8, .height = 16 }, + .padding = .{}, + }; + var handler = StreamHandler{ + .alloc = testing.allocator, + .size = &size, + .terminal = &term, + .termio_mailbox = &mailbox, + .surface_mailbox = undefined, + .renderer_state = &renderer_state, + .renderer_mailbox = undefined, + .renderer_wakeup = undefined, + .default_cursor_style = .block, + .default_cursor_blink = null, + .enquiry_response = "", + .osc_color_report_format = .none, + .clipboard_write = .deny, + }; + defer handler.deinit(); + + mutex.lock(); + defer mutex.unlock(); + try handler.setMode(.focus_event, true); + + try testing.expect(term.modes.get(.focus_event)); + try testing.expect(!handler.termio_messaged); + try testing.expect(mailbox.spsc.queue.pop() == null); +}