From fe52eb9494f1a4a631b66953046e07a1d29bb32b Mon Sep 17 00:00:00 2001 From: greathongtu Date: Sat, 27 Sep 2025 15:16:27 +0800 Subject: [PATCH 01/93] Adding keybind 'w' to the +list-themes TUI that would write out a file that contained themes --- src/cli/list_themes.zig | 43 +++++++++++++++++++++++++++++++++++++- src/config/config-template | 7 +++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index cc6cfaf3e..e54bbf307 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -192,6 +192,28 @@ pub fn run(gpa_alloc: std.mem.Allocator) !u8 { return 0; } +fn resolveAutoThemePath(alloc: std.mem.Allocator) ![]u8 { + const main_cfg_path = try Config.preferredDefaultFilePath(alloc); + defer alloc.free(main_cfg_path); + + const base_dir = std.fs.path.dirname(main_cfg_path) orelse return error.BadPathName; + return try std.fs.path.join(alloc, &.{ base_dir, "auto", "theme.ghostty" }); +} + +fn writeAutoThemeFile(alloc: std.mem.Allocator, theme_name: []const u8) !void { + const auto_path = try resolveAutoThemePath(alloc); + defer alloc.free(auto_path); + + if (std.fs.path.dirname(auto_path)) |dir| { + try std.fs.cwd().makePath(dir); + } + + var f = try std.fs.createFileAbsolute(auto_path, .{ .truncate = true }); + defer f.close(); + + try f.writer().print("theme = {s}\n", .{theme_name}); +} + const Event = union(enum) { key_press: vaxis.Key, mouse: vaxis.Mouse, @@ -483,6 +505,9 @@ const Preview = struct { self.should_quit = true; if (key.matchesAny(&.{ vaxis.Key.escape, vaxis.Key.enter, vaxis.Key.kp_enter }, .{})) self.mode = .normal; + if (key.matches('w', .{})) { + self.saveSelectedTheme(); + } }, } }, @@ -694,7 +719,7 @@ const Preview = struct { .help => { win.hideCursor(); const width = 60; - const height = 20; + const height = 22; const child = win.child( .{ .x_off = win.width / 2 -| width / 2, @@ -729,6 +754,7 @@ const Preview = struct { .{ .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." }, }; for (key_help, 0..) |help, captured_i| { @@ -805,6 +831,9 @@ const Preview = struct { try std.fmt.allocPrint(alloc, "theme = {s}", .{theme.theme}), "", "Save the configuration file and then reload it to apply the new theme.", + "", + "Or press 'w' to write an auto theme file.", + "", "For more details on configuration and themes, visit the Ghostty documentation:", "", "https://ghostty.org/docs/config/reference", @@ -1653,6 +1682,18 @@ const Preview = struct { }); } } + + fn saveSelectedTheme(self: *Preview) void { + if (self.filtered.items.len == 0) + return; + + const idx = self.filtered.items[self.current]; + const theme = self.themes[idx]; + + writeAutoThemeFile(self.allocator, theme.theme) catch { + return; + }; + } }; fn color(config: Config, palette: usize) vaxis.Color { diff --git a/src/config/config-template b/src/config/config-template index 63309137a..d71c36a9e 100644 --- a/src/config/config-template +++ b/src/config/config-template @@ -24,6 +24,13 @@ # reloaded while running; some only apply to new windows and others may require # a full restart to take effect. +# Auto theme include +# ================== +# This include makes it easy to pick a theme via `ghostty +list-themes`: +# press Enter on a theme, then 'w' to write the auto theme file. +# This path is relative to this config file. +config-file = ?auto/theme.ghostty + # Config syntax crash course # ========================== # # The config file consists of simple key-value pairs, From 906dac3145e063d4b5a5f6dd10db1d55027ced79 Mon Sep 17 00:00:00 2001 From: greathongtu Date: Sat, 4 Oct 2025 21:17:04 +0800 Subject: [PATCH 02/93] io as interface --- src/cli/list_themes.zig | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index e54bbf307..9b11947df 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -211,7 +211,10 @@ fn writeAutoThemeFile(alloc: std.mem.Allocator, theme_name: []const u8) !void { var f = try std.fs.createFileAbsolute(auto_path, .{ .truncate = true }); defer f.close(); - try f.writer().print("theme = {s}\n", .{theme_name}); + var buf: [128]u8 = undefined; + var w = f.writer(&buf); + try w.interface.print("theme = {s}\n", .{theme_name}); + try w.interface.flush(); } const Event = union(enum) { From 9339ccf769b122d1f171462f73d423de1b05df09 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sun, 26 Oct 2025 16:09:14 -0700 Subject: [PATCH 03/93] Decouple balanced top and left window paddings --- src/renderer/size.zig | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/renderer/size.zig b/src/renderer/size.zig index b26c1581e..d8b529c26 100644 --- a/src/renderer/size.zig +++ b/src/renderer/size.zig @@ -44,6 +44,15 @@ pub const Size = struct { self.grid(), self.cell, ); + + // The top/bottom padding is interesting. Subjectively, lots of padding + // at the top looks bad. So instead of always being equal (like left/right), + // we force the top padding to be at most equal to the maximum left padding, + // which is the balanced explicit horizontal padding plus half a cell width. + const max_padding_left = (explicit.left + explicit.right + self.cell.width) / 2; + const vshift = self.padding.top -| max_padding_left; + self.padding.top -= vshift; + self.padding.bottom += vshift; } }; @@ -258,16 +267,12 @@ pub const Padding = struct { const space_right = @as(f32, @floatFromInt(screen.width)) - grid_width; const space_bot = @as(f32, @floatFromInt(screen.height)) - grid_height; - // The left/right padding is just an equal split. + // The padding is split equally along both axes. const padding_right = @floor(space_right / 2); const padding_left = padding_right; - // The top/bottom padding is interesting. Subjectively, lots of padding - // at the top looks bad. So instead of always being equal (like left/right), - // we force the top padding to be at most equal to the left, and the bottom - // padding is the difference thereafter. - const padding_top = @min(padding_left, @floor(space_bot / 2)); - const padding_bot = space_bot - padding_top; + const padding_bot = @floor(space_bot / 2); + const padding_top = padding_bot; const zero = @as(f32, 0); return .{ From 10fcd9111cdeb7a8fc4f7caa3fed0b01e8cb4a9a Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 21 Aug 2025 17:38:46 -0500 Subject: [PATCH 04/93] nix: make 'nix flake check' happy --- flake.nix | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/flake.nix b/flake.nix index 3dcfef185..e744c1a09 100644 --- a/flake.nix +++ b/flake.nix @@ -48,7 +48,7 @@ system: let pkgs = nixpkgs.legacyPackages.${system}; in { - devShell.${system} = pkgs.callPackage ./nix/devShell.nix { + devShells.${system}.default = pkgs.callPackage ./nix/devShell.nix { zig = zig.packages.${system}."0.15.2"; wraptest = pkgs.callPackage ./nix/pkgs/wraptest.nix {}; zon2nix = zon2nix; @@ -96,6 +96,9 @@ in { type = "app"; program = "${program}"; + meta = { + description = "start a vm from ${toString module}"; + }; } ); in { @@ -121,11 +124,6 @@ ghostty = self.packages.${prev.stdenv.hostPlatform.system}.ghostty-debug; }; }; - create-vm = import ./nix/vm/create.nix; - create-cinnamon-vm = import ./nix/vm/create-cinnamon.nix; - create-gnome-vm = import ./nix/vm/create-gnome.nix; - create-plasma6-vm = import ./nix/vm/create-plasma6.nix; - create-xfce-vm = import ./nix/vm/create-xfce.nix; }; nixConfig = { From ca8313570c4180885a5ab55cdd04bc238292d083 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 21 Aug 2025 17:39:02 -0500 Subject: [PATCH 05/93] nix: add vm-based integration tests --- .gitignore | 1 + flake.lock | 22 ++++++ flake.nix | 12 +++ nix/tests.nix | 167 ++++++++++++++++++++++++++++++++++++++++ nix/vm/common-gnome.nix | 13 ++++ nix/vm/common.nix | 7 +- 6 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 nix/tests.nix diff --git a/.gitignore b/.gitignore index e451b171a..e521f8851 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ zig-cache/ .zig-cache/ zig-out/ /result* +/.nixos-test-history example/*.wasm test/ghostty test/cases/**/*.actual.png diff --git a/flake.lock b/flake.lock index 90b97ed4a..ece49febb 100644 --- a/flake.lock +++ b/flake.lock @@ -34,6 +34,27 @@ "type": "github" } }, + "home-manager": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1755776884, + "narHash": "sha256-CPM7zm6csUx7vSfKvzMDIjepEJv1u/usmaT7zydzbuI=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "4fb695d10890e9fc6a19deadf85ff79ffb78da86", + "type": "github" + }, + "original": { + "owner": "nix-community", + "ref": "release-25.05", + "repo": "home-manager", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 315532800, @@ -64,6 +85,7 @@ "inputs": { "flake-compat": "flake-compat", "flake-utils": "flake-utils", + "home-manager": "home-manager", "nixpkgs": "nixpkgs", "zig": "zig", "zon2nix": "zon2nix" diff --git a/flake.nix b/flake.nix index e744c1a09..aac42fbc0 100644 --- a/flake.nix +++ b/flake.nix @@ -34,6 +34,13 @@ # nixpkgs.follows = "nixpkgs"; }; }; + + home-manager = { + url = "github:nix-community/home-manager?ref=release-25.05"; + inputs = { + nixpkgs.follows = "nixpkgs"; + }; + }; }; outputs = { @@ -41,6 +48,7 @@ nixpkgs, zig, zon2nix, + home-manager, ... }: builtins.foldl' nixpkgs.lib.recursiveUpdate {} ( @@ -80,6 +88,10 @@ formatter.${system} = pkgs.alejandra; + checks.${system} = import ./nix/tests.nix { + inherit home-manager nixpkgs self system; + }; + apps.${system} = let runVM = ( module: let diff --git a/nix/tests.nix b/nix/tests.nix new file mode 100644 index 000000000..51fafad3e --- /dev/null +++ b/nix/tests.nix @@ -0,0 +1,167 @@ +{ + self, + system, + nixpkgs, + home-manager, + ... +}: let + nixos-version = nixpkgs.lib.trivial.release; + + pkgs = import nixpkgs { + inherit system; + overlays = [ + self.overlays.debug + ]; + }; + + pink_value = "#FF0087"; + + color_test = '' + import tempfile + import subprocess + + def check_for_pink(final=False) -> bool: + with tempfile.NamedTemporaryFile() as tmpin: + machine.send_monitor_command("screendump {}".format(tmpin.name)) + + cmd = 'convert {} -define histogram:unique-colors=true -format "%c" histogram:info:'.format( + tmpin.name + ) + ret = subprocess.run(cmd, shell=True, capture_output=True) + if ret.returncode != 0: + raise Exception( + "image analysis failed with exit code {}".format(ret.returncode) + ) + + text = ret.stdout.decode("utf-8") + return "${pink_value}" in text + ''; + + mkTestGnome = { + name, + settings, + testScript, + ocr ? false, + }: + pkgs.testers.runNixOSTest { + name = name; + + enableOCR = ocr; + + extraBaseModules = { + imports = [ + home-manager.nixosModules.home-manager + ]; + }; + + nodes = { + machine = { + config, + pkgs, + ... + }: { + imports = [ + ./vm/wayland-gnome.nix + settings + ]; + + virtualisation.vmVariant = { + virtualisation.host.pkgs = pkgs; + }; + + users.groups.ghostty = { + gid = 1000; + }; + + users.users.ghostty = { + uid = 1000; + }; + + home-manager = { + users = { + ghostty = { + home = { + username = config.users.users.ghostty.name; + homeDirectory = config.users.users.ghostty.home; + stateVersion = nixos-version; + }; + }; + }; + }; + + system.stateVersion = nixos-version; + }; + }; + + testScript = testScript; + }; +in { + basic-version-check = pkgs.testers.runNixOSTest { + name = "basic-version-check"; + nodes = { + machine = {pkgs, ...}: { + users.groups.ghostty = {}; + users.users.ghostty = { + isNormalUser = true; + group = "ghostty"; + packages = [ + pkgs.ghostty + ]; + }; + }; + }; + testScript = {...}: '' + machine.succeed("su - ghostty -c 'ghostty +version'") + ''; + }; + + basic-window-check-gnome = mkTestGnome { + name = "basic-window-check-gnome"; + settings = { + home-manager.users.ghostty = { + xdg.configFile = { + "ghostty/config".text = '' + background = ${pink_value} + ''; + }; + }; + }; + ocr = true; + testScript = {nodes, ...}: let + user = nodes.machine.users.users.ghostty; + bus_path = "/run/user/${toString user.uid}/bus"; + bus = "DBUS_SESSION_BUS_ADDRESS=unix:path=${bus_path}"; + gdbus = "${bus} gdbus"; + ghostty = "${bus} ghostty"; + su = command: "su - ${user.name} -c '${command}'"; + gseval = "call --session -d org.gnome.Shell -o /org/gnome/Shell -m org.gnome.Shell.Eval"; + wm_class = su "${gdbus} ${gseval} global.display.focus_window.wm_class"; + in '' + ${color_test} + + with subtest("wait for x"): + start_all() + machine.wait_for_x() + + machine.wait_for_file("${bus_path}") + + with subtest("Ensuring no pink is present without the terminal."): + assert ( + check_for_pink() == False + ), "Pink was present on the screen before we even launched a terminal!" + + machine.systemctl("enable app-com.mitchellh.ghostty-debug.service", user="${user.name}") + machine.succeed("${su "${ghostty} +new-window"}") + machine.wait_until_succeeds("${wm_class} | grep -q 'com.mitchellh.ghostty-debug'") + + machine.sleep(2) + + with subtest("Have the terminal display a color."): + assert( + check_for_pink() == True + ), "Pink was not found on the screen!" + + machine.systemctl("stop app-com.mitchellh.ghostty-debug.service", user="${user.name}") + ''; + }; +} diff --git a/nix/vm/common-gnome.nix b/nix/vm/common-gnome.nix index 0c2bef150..ab4aab9e9 100644 --- a/nix/vm/common-gnome.nix +++ b/nix/vm/common-gnome.nix @@ -22,6 +22,19 @@ }; }; + systemd.user.services = { + "org.gnome.Shell@wayland" = { + serviceConfig = { + ExecStart = [ + # Clear the list before overriding it. + "" + # Eval API is now internal so Shell needs to run in unsafe mode. + "${pkgs.gnome-shell}/bin/gnome-shell --unsafe-mode" + ]; + }; + }; + }; + environment.systemPackages = [ pkgs.gnomeExtensions.no-overview ]; diff --git a/nix/vm/common.nix b/nix/vm/common.nix index eefd7c1c0..63b7570b8 100644 --- a/nix/vm/common.nix +++ b/nix/vm/common.nix @@ -35,12 +35,6 @@ initialPassword = "ghostty"; }; - environment.etc = { - "xdg/autostart/com.mitchellh.ghostty.desktop" = { - source = "${pkgs.ghostty}/share/applications/com.mitchellh.ghostty.desktop"; - }; - }; - environment.systemPackages = [ pkgs.kitty pkgs.fish @@ -61,6 +55,7 @@ services.displayManager = { autoLogin = { + enable = true; user = "ghostty"; }; }; From f9d6a6d56fa8a3bf71d857ba1bcd0939453c648e Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 22 Aug 2025 17:02:00 -0500 Subject: [PATCH 06/93] nix vm tests: update contributors documentation --- CONTRIBUTING.md | 263 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de7df4b71..34e6b273b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -142,3 +142,266 @@ pull request will be accepted with a high degree of certainty. > **Pull requests are NOT a place to discuss feature design.** Please do > not open a WIP pull request to discuss a feature. Instead, use a discussion > and link to your branch. + +# Developer Guide + +> [!NOTE] +> +> **The remainder of this file is dedicated to developers actively +> working on Ghostty.** If you're a user reporting an issue, you can +> ignore the rest of this document. + +## Including and Updating Translations + +See the [Contributor's Guide](po/README_CONTRIBUTORS.md) for more details. + +## Checking for Memory Leaks + +While Zig does an amazing job of finding and preventing memory leaks, +Ghostty uses many third-party libraries that are written in C. Improper usage +of those libraries or bugs in those libraries can cause memory leaks that +Zig cannot detect by itself. + +### On Linux + +On Linux the recommended tool to check for memory leaks is Valgrind. The +recommended way to run Valgrind is via `zig build`: + +```sh +zig build run-valgrind +``` + +This builds a Ghostty executable with Valgrind support and runs Valgrind +with the proper flags to ensure we're suppressing known false positives. + +You can combine the same build args with `run-valgrind` that you can with +`run`, such as specifying additional configurations after a trailing `--`. + +## Input Stack Testing + +The input stack is the part of the codebase that starts with a +key event and ends with text encoding being sent to the pty (it +does not include _rendering_ the text, which is part of the +font or rendering stack). + +If you modify any part of the input stack, you must manually verify +all the following input cases work properly. We unfortunately do +not automate this in any way, but if we can do that one day that'd +save a LOT of grief and time. + +Note: this list may not be exhaustive, I'm still working on it. + +### Linux IME + +IME (Input Method Editors) are a common source of bugs in the input stack, +especially on Linux since there are multiple different IME systems +interacting with different windowing systems and application frameworks +all written by different organizations. + +The following matrix should be tested to ensure that all IME input works +properly: + +1. Wayland, X11 +2. ibus, fcitx, none +3. Dead key input (e.g. Spanish), CJK (e.g. Japanese), Emoji, Unicode Hex +4. ibus versions: 1.5.29, 1.5.30, 1.5.31 (each exhibit slightly different behaviors) + +> [!NOTE] +> +> This is a **work in progress**. I'm still working on this list and it +> is not complete. As I find more test cases, I will add them here. + +#### Dead Key Input + +Set your keyboard layout to "Spanish" (or another layout that uses dead keys). + +1. Launch Ghostty +2. Press `'` +3. Press `a` +4. Verify that `á` is displayed + +Note that the dead key may or may not show a preedit state visually. +For ibus and fcitx it does but for the "none" case it does not. Importantly, +the text should be correct when it is sent to the pty. + +We should also test canceling dead key input: + +1. Launch Ghostty +2. Press `'` +3. Press escape +4. Press `a` +5. Verify that `a` is displayed (no diacritic) + +#### CJK Input + +Configure fcitx or ibus with a keyboard layout like Japanese or Mozc. The +exact layout doesn't matter. + +1. Launch Ghostty +2. Press `Ctrl+Shift` to switch to "Hiragana" +3. On a US physical layout, type: `konn`, you should see `こん` in preedit. +4. Press `Enter` +5. Verify that `こん` is displayed in the terminal. + +We should also test switching input methods while preedit is active, which +should commit the text: + +1. Launch Ghostty +2. Press `Ctrl+Shift` to switch to "Hiragana" +3. On a US physical layout, type: `konn`, you should see `こん` in preedit. +4. Press `Ctrl+Shift` to switch to another layout (any) +5. Verify that `こん` is displayed in the terminal as committed text. + +## Nix Virtual Machines + +Several Nix virtual machine definitions are provided by the project for testing +and developing Ghostty against multiple different Linux desktop environments. + +Running these requires a working Nix installation, either Nix on your +favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Further +requirements for macOS are detailed below. + +VMs should only be run on your local desktop and then powered off when not in +use, which will discard any changes to the VM. + +The VM definitions provide minimal software "out of the box" but additional +software can be installed by using standard Nix mechanisms like `nix run nixpkgs#`. + +### Linux + +1. Check out the Ghostty source and change to the directory. +2. Run `nix run .#`. `` can be any of the VMs defined in the + `nix/vm` directory (without the `.nix` suffix) excluding any file prefixed + with `common` or `create`. +3. The VM will build and then launch. Depending on the speed of your system, this + can take a while, but eventually you should get a new VM window. +4. The Ghostty source directory should be mounted to `/tmp/shared` in the VM. Depending + on what UID and GID of the user that you launched the VM as, `/tmp/shared` _may_ be + writable by the VM user, so be careful! + +### macOS + +1. To run the VMs on macOS you will need to enable the Linux builder in your `nix-darwin` + config. This _should_ be as simple as adding `nix.linux-builder.enable=true` to your + configuration and then rebuilding. See [this](https://nixcademy.com/posts/macos-linux-builder/) + blog post for more information about the Linux builder and how to tune the performance. +2. Once the Linux builder has been enabled, you should be able to follow the Linux instructions + above to launch a VM. + +### Custom VMs + +To easily create a custom VM without modifying the Ghostty source, create a new +directory, then create a file called `flake.nix` with the following text in the +new directory. + +``` +{ + inputs = { + nixpkgs.url = "nixpkgs/nixpkgs-unstable"; + ghostty.url = "github:ghostty-org/ghostty"; + }; + outputs = { + nixpkgs, + ghostty, + ... + }: { + nixosConfigurations.custom-vm = ghostty.create-gnome-vm { + nixpkgs = nixpkgs; + system = "x86_64-linux"; + overlay = ghostty.overlays.releasefast; + # module = ./configuration.nix # also works + module = {pkgs, ...}: { + environment.systemPackages = [ + pkgs.btop + ]; + }; + }; + }; +} +``` + +The custom VM can then be run with a command like this: + +``` +nix run .#nixosConfigurations.custom-vm.config.system.build.vm +``` + +A file named `ghostty.qcow2` will be created that is used to persist any changes +made in the VM. To "reset" the VM to default delete the file and it will be +recreated the next time you run the VM. + +### Contributing new VM definitions + +#### VM Acceptance Criteria + +We welcome the contribution of new VM definitions, as long as they meet the following criteria: + +1. They should be different enough from existing VM definitions that they represent a distinct + user (and developer) experience. +2. There's a significant Ghostty user population that uses a similar environment. +3. The VMs can be built using only packages from the current stable NixOS release. + +#### VM Definition Criteria + +1. VMs should be as minimal as possible so that they build and launch quickly. + Additional software can be added at runtime with a command like `nix run nixpkgs#`. +2. VMs should not expose any services to the network, or run any remote access + software like SSH daemons, VNC or RDP. +3. VMs should auto-login using the "ghostty" user. + +## Nix VM Integration Tests + +Several Nix VM tests are provided by the project for testing Ghostty in a "live" +environment rather than just unit tests. + +Running these requires a working Nix installation, either Nix on your +favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Further +requirements for macOS are detailed below. + +### Linux + +1. Check out the Ghostty source and change to the directory. +2. Run `nix run .#check...driver`. `` should be + `x86_64-linux` or `aarch64-linux` (even on macOS, this launches a Linux + VM, not a macOS one). `` should be one of the tests defined in + `nix/tests.nix`. The test will build and then launch. Depending on the speed + of your system, this can take a while. Eventually though the test should + complete. Hopefully successfully, but if not error messages should be printed + out that can be used to diagnose the issue. +3. To run _all_ of the tests, run `nix flake check`. + +### macOS + +1. To run the VMs on macOS you will need to enable the Linux builder in your `nix-darwin` + config. This _should_ be as simple as adding `nix.linux-builder.enable=true` to your + configuration and then rebuilding. See [this](https://nixcademy.com/posts/macos-linux-builder/) + blog post for more information about the Linux builder and how to tune the performance. +2. Once the Linux builder has been enabled, you should be able to follow the Linux instructions + above to launch a test. + +### Interactively Running Test VMs + +To run a test interactively, run `nix run +.#check...driverInteractive`. This will load a Python console +that can be used to manage the test VMs. In this console run `start_all()` to +start the VM(s). The VMs should boot up and a window should appear showing the +VM's console. + +For more information about the Nix test console, see [the NixOS manual](https://nixos.org/manual/nixos/stable/index.html#sec-call-nixos-test-outside-nixos) + +### SSH Access to Test VMs + +Some test VMs are configured to allow outside SSH access for debugging. To +access the VM, use a command like the following: + +``` +ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=none -p 2222 root@192.168.122.1 +ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=none -p 2222 ghostty@192.168.122.1 +``` + +The SSH options are important because the SSH host keys will be regenerated +every time the test is started. Without them, your personal SSH known hosts file +will become difficult to manage. The port that is needed to access the VM may +change depending on the test. + +None of the users in the VM have passwords so do not expose these VMs to the Internet. From c77bbe6d7ec5736b4defb183c9daab18cd5f400e Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 22 Aug 2025 17:02:54 -0500 Subject: [PATCH 07/93] nix vms: make base vm more suitable for tests --- nix/vm/common.nix | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/nix/vm/common.nix b/nix/vm/common.nix index 63b7570b8..b2fec28e8 100644 --- a/nix/vm/common.nix +++ b/nix/vm/common.nix @@ -4,9 +4,6 @@ documentation.nixos.enable = false; - networking.hostName = "ghostty"; - networking.domain = "mitchellh.com"; - virtualisation.vmVariant = { virtualisation.memorySize = 2048; }; @@ -28,11 +25,11 @@ users.groups.ghostty = {}; users.users.ghostty = { + isNormalUser = true; description = "Ghostty"; group = "ghostty"; extraGroups = ["wheel"]; - isNormalUser = true; - initialPassword = "ghostty"; + hashedPassword = ""; }; environment.systemPackages = [ From debec946daf90174801d9cedbb52c084efb095b5 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 22 Aug 2025 17:04:38 -0500 Subject: [PATCH 08/93] nix vm tests: refactor to make gnome vm node builder reusable --- nix/tests.nix | 94 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 63 insertions(+), 31 deletions(-) diff --git a/nix/tests.nix b/nix/tests.nix index 51fafad3e..33902d4d0 100644 --- a/nix/tests.nix +++ b/nix/tests.nix @@ -37,6 +37,65 @@ return "${pink_value}" in text ''; + mkNodeGnome = { + config, + pkgs, + settings, + sshPort ? null, + ... + }: { + imports = [ + ./vm/wayland-gnome.nix + settings + ]; + + virtualisation = { + forwardPorts = pkgs.lib.optionals (sshPort != null) [ + { + from = "host"; + host.port = sshPort; + guest.port = 22; + } + ]; + + vmVariant = { + virtualisation.host.pkgs = pkgs; + }; + }; + + services.openssh = { + enable = true; + settings = { + PermitRootLogin = "yes"; + PermitEmptyPasswords = "yes"; + }; + }; + + security.pam.services.sshd.allowNullPassword = true; + + users.groups.ghostty = { + gid = 1000; + }; + + users.users.ghostty = { + uid = 1000; + }; + + home-manager = { + users = { + ghostty = { + home = { + username = config.users.users.ghostty.name; + homeDirectory = config.users.users.ghostty.home; + stateVersion = nixos-version; + }; + }; + }; + }; + + system.stateVersion = nixos-version; + }; + mkTestGnome = { name, settings, @@ -59,38 +118,11 @@ config, pkgs, ... - }: { - imports = [ - ./vm/wayland-gnome.nix - settings - ]; - - virtualisation.vmVariant = { - virtualisation.host.pkgs = pkgs; + }: + mkNodeGnome { + inherit config pkgs settings; + sshPort = 2222; }; - - users.groups.ghostty = { - gid = 1000; - }; - - users.users.ghostty = { - uid = 1000; - }; - - home-manager = { - users = { - ghostty = { - home = { - username = config.users.users.ghostty.name; - homeDirectory = config.users.users.ghostty.home; - stateVersion = nixos-version; - }; - }; - }; - }; - - system.stateVersion = nixos-version; - }; }; testScript = testScript; From f26a6b949c58f0f7e3587a8f17997e868719abd9 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 22 Aug 2025 17:05:18 -0500 Subject: [PATCH 09/93] nix vm tests: sync ghostty user with other tests --- nix/tests.nix | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nix/tests.nix b/nix/tests.nix index 33902d4d0..1ef420cf3 100644 --- a/nix/tests.nix +++ b/nix/tests.nix @@ -136,6 +136,8 @@ in { users.users.ghostty = { isNormalUser = true; group = "ghostty"; + extraGroups = ["wheel"]; + hashedPassword = ""; packages = [ pkgs.ghostty ]; From 516c416fa4d9c31a82dd1a149f91524f64f2392c Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 22 Aug 2025 17:55:19 -0500 Subject: [PATCH 10/93] nix vm tests: fix ssh command --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34e6b273b..6d8976b21 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -395,8 +395,8 @@ Some test VMs are configured to allow outside SSH access for debugging. To access the VM, use a command like the following: ``` -ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=none -p 2222 root@192.168.122.1 -ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=none -p 2222 ghostty@192.168.122.1 +ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -p 2222 root@192.168.122.1 +ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -p 2222 ghostty@192.168.122.1 ``` The SSH options are important because the SSH host keys will be regenerated From 8386159764fb398c8a60aa71367edb11b62db5c8 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 22 Aug 2025 17:55:54 -0500 Subject: [PATCH 11/93] nix vm tests: add test for ssh-terminfo shell integration feature --- nix/tests.nix | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/nix/tests.nix b/nix/tests.nix index 1ef420cf3..a9970e80c 100644 --- a/nix/tests.nix +++ b/nix/tests.nix @@ -89,6 +89,13 @@ homeDirectory = config.users.users.ghostty.home; stateVersion = nixos-version; }; + programs.ssh = { + enable = true; + extraOptionOverrides = { + StrictHostKeyChecking = "accept-new"; + UserKnownHostsFile = "/dev/null"; + }; + }; }; }; }; @@ -198,4 +205,79 @@ in { machine.systemctl("stop app-com.mitchellh.ghostty-debug.service", user="${user.name}") ''; }; + + ssh-integration-test = pkgs.testers.runNixOSTest { + name = "ssh-integration-test"; + extraBaseModules = { + imports = [ + home-manager.nixosModules.home-manager + ]; + }; + nodes = { + server = {...}: { + users.groups.ghostty = {}; + users.users.ghostty = { + isNormalUser = true; + group = "ghostty"; + extraGroups = ["wheel"]; + hashedPassword = ""; + packages = []; + }; + services.openssh = { + enable = true; + settings = { + PermitRootLogin = "yes"; + PermitEmptyPasswords = "yes"; + }; + }; + security.pam.services.sshd.allowNullPassword = true; + }; + client = { + config, + pkgs, + ... + }: + mkNodeGnome { + inherit config pkgs; + settings = { + home-manager.users.ghostty = { + xdg.configFile = { + "ghostty/config".text = let + in '' + shell-integration-features = ssh-terminfo + ''; + }; + }; + }; + sshPort = 2222; + }; + }; + testScript = {nodes, ...}: let + user = nodes.client.users.users.ghostty; + bus_path = "/run/user/${toString user.uid}/bus"; + bus = "DBUS_SESSION_BUS_ADDRESS=unix:path=${bus_path}"; + gdbus = "${bus} gdbus"; + ghostty = "${bus} ghostty"; + su = command: "su - ${user.name} -c '${command}'"; + gseval = "call --session -d org.gnome.Shell -o /org/gnome/Shell -m org.gnome.Shell.Eval"; + wm_class = su "${gdbus} ${gseval} global.display.focus_window.wm_class"; + in '' + with subtest("Start server and wait for ssh to be ready."): + server.start() + server.wait_for_open_port(22) + + with subtest("Start client and wait for ghostty window."): + client.start() + client.wait_for_x() + client.wait_for_file("${bus_path}") + client.systemctl("enable app-com.mitchellh.ghostty-debug.service", user="${user.name}") + client.succeed("${su "${ghostty} +new-window"}") + client.wait_until_succeeds("${wm_class} | grep -q 'com.mitchellh.ghostty-debug'") + + with subtest("SSH from client to server and verify that the Ghostty terminfo is copied.") + client.sleep(2) + client.send_chars("ssh ghostty@server\n") + server.wait_for_file("${user.home}/.terminfo/x/xterm-ghostty", timeout=30) + ''; + }; } From 10bac6a5dd94f072bcb4d95cd956d0516b50ff7b Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 10 Dec 2025 22:26:40 -0600 Subject: [PATCH 12/93] benchmark: use newer bytes api to generate ascii --- src/synthetic/cli/Ascii.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/synthetic/cli/Ascii.zig b/src/synthetic/cli/Ascii.zig index 22ca1ffb5..d416189ce 100644 --- a/src/synthetic/cli/Ascii.zig +++ b/src/synthetic/cli/Ascii.zig @@ -36,10 +36,12 @@ pub fn run(_: *Ascii, writer: *std.Io.Writer, rand: std.Random) !void { var gen: Bytes = .{ .rand = rand, .alphabet = ascii, + .min_len = 1024, + .max_len = 1024, }; while (true) { - gen.next(writer, 1024) catch |err| { + _ = gen.write(writer) catch |err| { const Error = error{ WriteFailed, BrokenPipe } || @TypeOf(err); switch (@as(Error, err)) { error.BrokenPipe => return, // stdout closed From cfdcd50e184240e48fe6b6d9e0bd6ed0afb3ae46 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 10 Dec 2025 22:30:19 -0600 Subject: [PATCH 13/93] benchmark: generate more types of OSC sequences --- src/os/string_encoding.zig | 13 ++++ src/synthetic/Osc.zig | 123 +++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/src/os/string_encoding.zig b/src/os/string_encoding.zig index 162023ad2..042001ea7 100644 --- a/src/os/string_encoding.zig +++ b/src/os/string_encoding.zig @@ -265,3 +265,16 @@ test "percent 7" { @memcpy(&src, s); try std.testing.expectError(error.DecodeError, urlPercentDecode(&src)); } + +/// Is the given character valid in URI percent encoding? +fn isValidChar(c: u8) bool { + return switch (c) { + ' ', ';', '=' => false, + else => return std.ascii.isPrint(c), + }; +} + +/// Write data to the writer after URI percent encoding. +pub fn urlPercentEncode(writer: *std.Io.Writer, data: []const u8) std.Io.Writer.Error!void { + try std.Uri.Component.percentEncode(writer, data, isValidChar); +} diff --git a/src/synthetic/Osc.zig b/src/synthetic/Osc.zig index b43079e1a..00de43f7f 100644 --- a/src/synthetic/Osc.zig +++ b/src/synthetic/Osc.zig @@ -5,12 +5,23 @@ const std = @import("std"); const assert = std.debug.assert; const Generator = @import("Generator.zig"); const Bytes = @import("Bytes.zig"); +const urlPercentEncode = @import("../os/string_encoding.zig").urlPercentEncode; /// Valid OSC request kinds that can be generated. pub const ValidKind = enum { change_window_title, prompt_start, prompt_end, + end_of_input, + end_of_command, + rxvt_notify, + mouse_shape, + clipboard_operation, + report_pwd, + hyperlink_start, + hyperlink_end, + conemu_progress, + iterm2_notification, }; /// Invalid OSC request kinds that can be generated. @@ -55,6 +66,9 @@ fn checkOscAlphabet(c: u8) bool { /// The alphabet for random bytes in OSCs (omitting 0x1B and 0x07). pub const osc_alphabet = Bytes.generateAlphabet(checkOscAlphabet); +pub const ascii_alphabet = Bytes.generateAlphabet(std.ascii.isPrint); +pub const alphabetic_alphabet = Bytes.generateAlphabet(std.ascii.isAlphabetic); +pub const alphanumeric_alphabet = Bytes.generateAlphabet(std.ascii.isAlphanumeric); pub fn generator(self: *Osc) Generator { return .init(self, next); @@ -143,6 +157,115 @@ fn nextUnwrappedValidExact(self: *const Osc, writer: *std.Io.Writer, k: ValidKin if (max_len < 4) break :prompt_end; try writer.writeAll("133;B"); // End prompt }, + + .end_of_input => end_of_input: { + if (max_len < 5) break :end_of_input; + var remaining = max_len; + try writer.writeAll("133;C"); // End prompt + remaining -= 5; + if (self.rand.boolean()) cmdline: { + const prefix = ";cmdline_url="; + if (remaining < prefix.len + 1) break :cmdline; + try writer.writeAll(prefix); + remaining -= prefix.len; + var buf: [128]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try self.bytes().newAlphabet(ascii_alphabet).atMost(@min(remaining, buf.len)).format(&w); + try urlPercentEncode(writer, w.buffered()); + remaining -= w.buffered().len; + } + }, + + .end_of_command => end_of_command: { + if (max_len < 4) break :end_of_command; + try writer.writeAll("133;D"); // End prompt + if (self.rand.boolean()) exit_code: { + if (max_len < 7) break :exit_code; + try writer.print(";{d}", .{self.rand.int(u8)}); + } + }, + + .mouse_shape => mouse_shape: { + if (max_len < 4) break :mouse_shape; + try writer.print("22;{f}", .{self.bytes().newAlphabet(alphabetic_alphabet).atMost(@min(32, max_len - 3))}); // Start prompt + }, + + .rxvt_notify => rxvt_notify: { + const prefix = "777;notify;"; + if (max_len < prefix.len) break :rxvt_notify; + var remaining = max_len; + try writer.writeAll(prefix); + remaining -= prefix.len; + remaining -= try self.bytes().newAlphabet(kv_alphabet).atMost(@min(remaining - 2, 32)).write(writer); + try writer.writeByte(';'); + remaining -= 1; + remaining -= try self.bytes().newAlphabet(osc_alphabet).atMost(remaining).write(writer); + }, + + .clipboard_operation => { + try writer.writeAll("52;"); + var remaining = max_len - 3; + if (self.rand.boolean()) { + remaining -= try self.bytes().newAlphabet(alphabetic_alphabet).atMost(1).write(writer); + } + try writer.writeByte(';'); + remaining -= 1; + if (self.rand.boolean()) { + remaining -= try self.bytes().newAlphabet(osc_alphabet).atMost(remaining).write(writer); + } + }, + + .report_pwd => report_pwd: { + const prefix = "7;file://localhost"; + if (max_len < prefix.len) break :report_pwd; + var remaining = max_len; + try writer.writeAll(prefix); + remaining -= prefix.len; + for (0..self.rand.intRangeAtMost(usize, 2, 5)) |_| { + try writer.writeByte('/'); + remaining -= 1; + remaining -= try self.bytes().newAlphabet(alphanumeric_alphabet).atMost(@min(16, remaining)).write(writer); + } + }, + + .hyperlink_start => { + try writer.writeAll("8;"); + if (self.rand.boolean()) { + try writer.print("id={f}", .{self.bytes().newAlphabet(alphanumeric_alphabet).atMost(16)}); + } + try writer.writeAll(";https://localhost"); + for (0..self.rand.intRangeAtMost(usize, 2, 5)) |_| { + try writer.print("/{f}", .{self.bytes().newAlphabet(alphanumeric_alphabet).atMost(16)}); + } + }, + + .hyperlink_end => hyperlink_end: { + if (max_len < 3) break :hyperlink_end; + try writer.writeAll("8;;"); + }, + + .conemu_progress => { + try writer.writeAll("9;"); + switch (self.rand.intRangeAtMost(u3, 0, 4)) { + 0, 3 => |c| { + try writer.print(";{d}", .{c}); + }, + 1, 2, 4 => |c| { + if (self.rand.boolean()) { + try writer.print(";{d}", .{c}); + } else { + try writer.print(";{d};{d}", .{ c, self.rand.intRangeAtMost(u8, 0, 100) }); + } + }, + else => unreachable, + } + }, + + .iterm2_notification => iterm2_notification: { + if (max_len < 3) break :iterm2_notification; + // add a prefix to ensure that this is not interpreted as a ConEmu OSC + try writer.print("9;_{f}", .{self.bytes().newAlphabet(ascii_alphabet).atMost(max_len - 3)}); + }, } } From 01a75ceec4e7619345cb5f1031b98626bbe85f3d Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 10 Dec 2025 22:31:27 -0600 Subject: [PATCH 14/93] benchmark: add option to microbenchmark OSC parser --- src/benchmark/OscParser.zig | 118 ++++++++++++++++++++++++++++++++++++ src/benchmark/cli.zig | 2 + src/synthetic/cli/Osc.zig | 26 +++++++- 3 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 src/benchmark/OscParser.zig diff --git a/src/benchmark/OscParser.zig b/src/benchmark/OscParser.zig new file mode 100644 index 000000000..6243aba7d --- /dev/null +++ b/src/benchmark/OscParser.zig @@ -0,0 +1,118 @@ +//! This benchmark tests the throughput of the OSC parser. +const OscParser = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const Parser = @import("../terminal/osc.zig").Parser; +const log = std.log.scoped(.@"osc-parser-bench"); + +opts: Options, + +/// The file, opened in the setup function. +data_f: ?std.fs.File = null, + +parser: Parser, + +pub const Options = struct { + /// The data to read as a filepath. If this is "-" then + /// we will read stdin. If this is unset, then we will + /// do nothing (benchmark is a noop). It'd be more unixy to + /// use stdin by default but I find that a hanging CLI command + /// with no interaction is a bit annoying. + data: ?[]const u8 = null, +}; + +/// Create a new terminal stream handler for the given arguments. +pub fn create( + alloc: Allocator, + opts: Options, +) !*OscParser { + const ptr = try alloc.create(OscParser); + errdefer alloc.destroy(ptr); + ptr.* = .{ + .opts = opts, + .data_f = null, + .parser = .init(alloc), + }; + return ptr; +} + +pub fn destroy(self: *OscParser, alloc: Allocator) void { + self.parser.deinit(); + alloc.destroy(self); +} + +pub fn benchmark(self: *OscParser) Benchmark { + return .init(self, .{ + .stepFn = step, + .setupFn = setup, + .teardownFn = teardown, + }); +} + +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *OscParser = @ptrCast(@alignCast(ptr)); + + // Open our data file to prepare for reading. We can do more + // validation here eventually. + assert(self.data_f == null); + self.data_f = options.dataFile(self.opts.data) catch |err| { + log.warn("error opening data file err={}", .{err}); + return error.BenchmarkFailed; + }; + self.parser.reset(); +} + +fn teardown(ptr: *anyopaque) void { + const self: *OscParser = @ptrCast(@alignCast(ptr)); + if (self.data_f) |f| { + f.close(); + self.data_f = null; + } +} + +fn step(ptr: *anyopaque) Benchmark.Error!void { + const self: *OscParser = @ptrCast(@alignCast(ptr)); + + const f = self.data_f orelse return; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + var r = f.reader(&read_buf); + + var osc_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + while (true) { + r.interface.fill(@bitSizeOf(usize) / 8) catch |err| switch (err) { + error.EndOfStream => return, + error.ReadFailed => return error.BenchmarkFailed, + }; + const len = r.interface.takeInt(usize, .little) catch |err| switch (err) { + error.EndOfStream => return, + error.ReadFailed => return error.BenchmarkFailed, + }; + + if (len > osc_buf.len) return error.BenchmarkFailed; + + r.interface.readSliceAll(osc_buf[0..len]) catch |err| switch (err) { + error.EndOfStream => return, + error.ReadFailed => return error.BenchmarkFailed, + }; + + for (osc_buf[0..len]) |c| self.parser.next(c); + _ = self.parser.end(std.ascii.control_code.bel); + self.parser.reset(); + } +} + +test OscParser { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *OscParser = try .create(alloc, .{}); + defer impl.destroy(alloc); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} diff --git a/src/benchmark/cli.zig b/src/benchmark/cli.zig index 816ecd3f6..13f070774 100644 --- a/src/benchmark/cli.zig +++ b/src/benchmark/cli.zig @@ -12,6 +12,7 @@ pub const Action = enum { @"terminal-parser", @"terminal-stream", @"is-symbol", + @"osc-parser", /// Returns the struct associated with the action. The struct /// should have a few decls: @@ -29,6 +30,7 @@ pub const Action = enum { .@"grapheme-break" => @import("GraphemeBreak.zig"), .@"terminal-parser" => @import("TerminalParser.zig"), .@"is-symbol" => @import("IsSymbol.zig"), + .@"osc-parser" => @import("OscParser.zig"), }; } }; diff --git a/src/synthetic/cli/Osc.zig b/src/synthetic/cli/Osc.zig index 8250b81de..686563fc3 100644 --- a/src/synthetic/cli/Osc.zig +++ b/src/synthetic/cli/Osc.zig @@ -10,6 +10,14 @@ const log = std.log.scoped(.@"terminal-stream-bench"); pub const Options = struct { /// Probability of generating a valid value. @"p-valid": f64 = 0.5, + + style: enum { + /// Write all OSC data, including ESC ] and ST for end-to-end tests + streaming, + /// Only write data, prefixed with a length, used for testing just the + /// OSC parser. + parser, + } = .streaming, }; opts: Options, @@ -40,9 +48,21 @@ pub fn run(self: *Osc, writer: *std.Io.Writer, rand: std.Random) !void { var fixed: std.Io.Writer = .fixed(&buf); try gen.next(&fixed, buf.len); const data = fixed.buffered(); - writer.writeAll(data) catch |err| switch (err) { - error.WriteFailed => return, - }; + switch (self.opts.style) { + .streaming => { + writer.writeAll(data) catch |err| switch (err) { + error.WriteFailed => return, + }; + }, + .parser => { + writer.writeInt(usize, data.len - 3, .little) catch |err| switch (err) { + error.WriteFailed => return, + }; + writer.writeAll(data[2 .. data.len - 1]) catch |err| switch (err) { + error.WriteFailed => return, + }; + }, + } } } From 65539d0d54faef71d49afc23a7b6fd0a875d2bcb Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 12 Dec 2025 18:21:17 +0800 Subject: [PATCH 15/93] CONTRIBUTING: limit AI assistance to code only I think at this point all moderators and helpers can agree with me in that LLM-generated responses are a blight upon this Earth. Also probably worth putting in a clause against AI-generated assets (cf. the Commit Goods situation) --- CONTRIBUTING.md | 39 ++++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4285f42f..a5f9213c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,8 +23,38 @@ it, please check out our ["Developing Ghostty"](HACKING.md) document as well. If you are using any kind of AI assistance while contributing to Ghostty, **this must be disclosed in the pull request**, along with the extent to which AI assistance was used (e.g. docs only vs. code generation). -As a small exception, trivial tab-completion doesn't need to be disclosed, -so long as it is limited to single keywords or short phrases. + +**We currently restrict AI assistance to code changes only.** +No AI-generated media, e.g. artwork, icons, videos and other assets is +allowed, as it goes against the methodology and ethos behind Ghostty. +While AI-assisted code can help with productive prototyping, creative +inspiration and even automated bugfinding, we have currently found zero +benefit to AI-generated assets. Instead, we are far more interested and +invested in funding professional work done by human designers and artists. +If you intend to submit AI-generated assets to Ghostty, sorry, +**we are not interested**. + +Likewise, all community interactions, including all comments on issues and +discussions and all PR titles and descriptions **must be composed by a human**. +Community moderators and Ghostty maintainers reserve the right to mark +AI-generated responses as spam or disruptive content, and ban users who have +been repeatedly caught relying entirely on LLMs during interactions. + +> [!NOTE] +> If your English isn't the best and you are currently relying on an LLM to +> translate your responses, don't fret — usually we maintainers will be able +> to understand your messages well enough. We'd like to encourage real humans +> to interact with each other more, and the positive impact of genuine, +> responsive yet imperfect human interaction more than makes up for any +> language barrier. +> +> Please write your responses yourself, to the best of your ability. +> We greatly appreciate it. Thank you. ❤️ + +Minor exceptions to this policy include trivial AI-generated tab completion +functionality, as it usually does not impact the quality of the code and +do not need to be disclosed, and commit titles and messages, which are often +generated by AI coding agents. The submitter must have also tested the pull request on all impacted platforms, and it's **highly discouraged** to code for an unfamiliar platform @@ -32,11 +62,6 @@ with AI assistance alone: if you only have a macOS machine, do **not** ask AI to write the equivalent GTK code, and vice versa — someone else with more expertise will eventually get to it and do it for you. -Even though using AI to generate responses on a PR is allowed when properly -disclosed, **we do not encourage you to do so**. Often, the positive impact -of genuine, responsive human interaction more than makes up for any language -barrier. ❤️ - An example disclosure: > This PR was written primarily by Claude Code. From 5e049e1b3af15db4878104b87eb7646caa1fd356 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 12 Dec 2025 18:46:05 +0800 Subject: [PATCH 16/93] CONTRIBUTING: AI-assisted != AI-generated --- CONTRIBUTING.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a5f9213c0..75aa42676 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,14 +17,22 @@ it, please check out our ["Developing Ghostty"](HACKING.md) document as well. > [!IMPORTANT] > -> If you are using **any kind of AI assistance** to contribute to Ghostty, -> it must be disclosed in the pull request. +> The Ghostty project allows AI-**assisted** _code contributions_, which +> must be properly disclosed in the pull request. If you are using any kind of AI assistance while contributing to Ghostty, **this must be disclosed in the pull request**, along with the extent to which AI assistance was used (e.g. docs only vs. code generation). -**We currently restrict AI assistance to code changes only.** +**Note that AI _assistance_ does not equal AI _generation_**. We require +a significant amount of human accountability, involvement and interaction +even within AI-assisted contributions. Contributors are required to be able +to understand the AI-assisted output, and be able to reason with it and +answer critical questions about it. Should a PR see no visible human +accountability and involvement, or it is so broken that it requires significant +rework to be acceptable, **we reserve the right to close it without hesitation**. + +**In addition, we currently restrict AI assistance to code changes only.** No AI-generated media, e.g. artwork, icons, videos and other assets is allowed, as it goes against the methodology and ethos behind Ghostty. While AI-assisted code can help with productive prototyping, creative @@ -32,7 +40,7 @@ inspiration and even automated bugfinding, we have currently found zero benefit to AI-generated assets. Instead, we are far more interested and invested in funding professional work done by human designers and artists. If you intend to submit AI-generated assets to Ghostty, sorry, -**we are not interested**. +we are not interested. Likewise, all community interactions, including all comments on issues and discussions and all PR titles and descriptions **must be composed by a human**. @@ -85,13 +93,6 @@ work than any human. That isn't the world we live in today, and in most cases it's generating slop. I say this despite being a fan of and using them successfully myself (with heavy supervision)! -When using AI assistance, we expect a fairly high level of accountability -and responsibility from contributors, and expect them to understand the code -that is produced and be able to answer critical questions about it. It -isn't a maintainers job to review a PR so broken that it requires -significant rework to be acceptable, and we **reserve the right to close -these PRs without hesitation**. - Please be respectful to maintainers and disclose AI assistance. ## Quick Guide From 8a1bb215c13e27f16e46d74bf59a48fc730d9b1b Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 12 Dec 2025 18:54:22 +0800 Subject: [PATCH 17/93] CONTRIBUTING: further clarifications --- CONTRIBUTING.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75aa42676..d5fb606b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,10 +27,10 @@ which AI assistance was used (e.g. docs only vs. code generation). **Note that AI _assistance_ does not equal AI _generation_**. We require a significant amount of human accountability, involvement and interaction even within AI-assisted contributions. Contributors are required to be able -to understand the AI-assisted output, and be able to reason with it and -answer critical questions about it. Should a PR see no visible human -accountability and involvement, or it is so broken that it requires significant -rework to be acceptable, **we reserve the right to close it without hesitation**. +to understand the AI-assisted output, reason with it and answer critical +questions about it. Should a PR see no visible human accountability and +involvement, or it is so broken that it requires significant rework to be +acceptable, **we reserve the right to close it without hesitation**. **In addition, we currently restrict AI assistance to code changes only.** No AI-generated media, e.g. artwork, icons, videos and other assets is @@ -57,6 +57,9 @@ been repeatedly caught relying entirely on LLMs during interactions. > language barrier. > > Please write your responses yourself, to the best of your ability. +> If you do feel the need to polish your sentences, however, please use +> dedicated translation software rather than an LLM. +> > We greatly appreciate it. Thank you. ❤️ Minor exceptions to this policy include trivial AI-generated tab completion From 315c8852a8e4746dd352486486abf8ab982ad87d Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 12 Dec 2025 18:58:52 +0800 Subject: [PATCH 18/93] CONTRIBUTING: reorganize paragraphs --- CONTRIBUTING.md | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d5fb606b4..8b8c4d7f2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,13 +24,20 @@ If you are using any kind of AI assistance while contributing to Ghostty, **this must be disclosed in the pull request**, along with the extent to which AI assistance was used (e.g. docs only vs. code generation). -**Note that AI _assistance_ does not equal AI _generation_**. We require -a significant amount of human accountability, involvement and interaction -even within AI-assisted contributions. Contributors are required to be able -to understand the AI-assisted output, reason with it and answer critical -questions about it. Should a PR see no visible human accountability and -involvement, or it is so broken that it requires significant rework to be -acceptable, **we reserve the right to close it without hesitation**. +The submitter must have also tested the pull request on all impacted +platforms, and it's **highly discouraged** to code for an unfamiliar platform +with AI assistance alone: if you only have a macOS machine, do **not** ask AI +to write the equivalent GTK code, and vice versa — someone else with more +expertise will eventually get to it and do it for you. + +> [!WARNING] +> **Note that AI _assistance_ does not equal AI _generation_**. We require +> a significant amount of human accountability, involvement and interaction +> even within AI-assisted contributions. Contributors are required to be able +> to understand the AI-assisted output, reason with it and answer critical +> questions about it. Should a PR see no visible human accountability and +> involvement, or it is so broken that it requires significant rework to be +> acceptable, **we reserve the right to close it without hesitation**. **In addition, we currently restrict AI assistance to code changes only.** No AI-generated media, e.g. artwork, icons, videos and other assets is @@ -67,12 +74,6 @@ functionality, as it usually does not impact the quality of the code and do not need to be disclosed, and commit titles and messages, which are often generated by AI coding agents. -The submitter must have also tested the pull request on all impacted -platforms, and it's **highly discouraged** to code for an unfamiliar platform -with AI assistance alone: if you only have a macOS machine, do **not** ask AI -to write the equivalent GTK code, and vice versa — someone else with more -expertise will eventually get to it and do it for you. - An example disclosure: > This PR was written primarily by Claude Code. From 04fecd7c07fccad423ab1c33324a1997e142b6e2 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Thu, 11 Dec 2025 21:02:42 -0500 Subject: [PATCH 19/93] os/shell: introduce ShellCommandBuilder This builder is an efficient way to construct space-separated shell command strings. We use it in setupBash to avoid using an intermediate array of arguments to construct our bash command line. --- src/os/shell.zig | 77 ++++++++++++++++++++++++++++++++ src/termio/shell_integration.zig | 20 ++++----- 2 files changed, 87 insertions(+), 10 deletions(-) diff --git a/src/os/shell.zig b/src/os/shell.zig index 9fce3e385..fe8f1b2fd 100644 --- a/src/os/shell.zig +++ b/src/os/shell.zig @@ -1,7 +1,84 @@ const std = @import("std"); const testing = std.testing; +const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; +/// Builder for constructing space-separated shell command strings. +/// Uses a caller-provided allocator (typically with stackFallback). +pub const ShellCommandBuilder = struct { + buffer: std.Io.Writer.Allocating, + + pub fn init(allocator: Allocator) ShellCommandBuilder { + return .{ .buffer = .init(allocator) }; + } + + pub fn deinit(self: *ShellCommandBuilder) void { + self.buffer.deinit(); + } + + /// Append an argument to the command with automatic space separation. + pub fn appendArg(self: *ShellCommandBuilder, arg: []const u8) (Allocator.Error || Writer.Error)!void { + if (arg.len == 0) return; + if (self.buffer.written().len > 0) { + try self.buffer.writer.writeByte(' '); + } + try self.buffer.writer.writeAll(arg); + } + + /// Get the final null-terminated command string, transferring ownership to caller. + /// Calling deinit() after this is safe but unnecessary. + pub fn toOwnedSlice(self: *ShellCommandBuilder) Allocator.Error![:0]const u8 { + return try self.buffer.toOwnedSliceSentinel(0); + } +}; + +test ShellCommandBuilder { + // Empty command + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try testing.expectEqualStrings("", cmd.buffer.written()); + } + + // Single arg + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try cmd.appendArg("bash"); + try testing.expectEqualStrings("bash", cmd.buffer.written()); + } + + // Multiple args + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try cmd.appendArg("bash"); + try cmd.appendArg("--posix"); + try cmd.appendArg("-l"); + try testing.expectEqualStrings("bash --posix -l", cmd.buffer.written()); + } + + // Empty arg + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try cmd.appendArg("bash"); + try cmd.appendArg(""); + try testing.expectEqualStrings("bash", cmd.buffer.written()); + } + + // toOwnedSlice + { + var cmd = ShellCommandBuilder.init(testing.allocator); + try cmd.appendArg("bash"); + try cmd.appendArg("--posix"); + const result = try cmd.toOwnedSlice(); + defer testing.allocator.free(result); + try testing.expectEqualStrings("bash --posix", result); + try testing.expectEqual(@as(u8, 0), result[result.len]); + } +} + /// Writer that escapes characters that shells treat specially to reduce the /// risk of injection attacks or other such weirdness. Specifically excludes /// linefeeds so that they can be used to delineate lists of file paths. diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index a79e38639..128b345ea 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -259,8 +259,9 @@ fn setupBash( resource_dir: []const u8, env: *EnvMap, ) !?config.Command { - var args: std.ArrayList([:0]const u8) = try .initCapacity(alloc, 2); - defer args.deinit(alloc); + var stack_fallback = std.heap.stackFallback(4096, alloc); + var cmd = internal_os.shell.ShellCommandBuilder.init(stack_fallback.get()); + defer cmd.deinit(); // Iterator that yields each argument in the original command line. // This will allocate once proportionate to the command line length. @@ -269,9 +270,9 @@ fn setupBash( // Start accumulating arguments with the executable and initial flags. if (iter.next()) |exe| { - try args.append(alloc, try alloc.dupeZ(u8, exe)); + try cmd.appendArg(exe); } else return null; - try args.append(alloc, "--posix"); + try cmd.appendArg("--posix"); // Stores the list of intercepted command line flags that will be passed // to our shell integration script: --norc --noprofile @@ -304,17 +305,17 @@ fn setupBash( if (std.mem.indexOfScalar(u8, arg, 'c') != null) { return null; } - try args.append(alloc, try alloc.dupeZ(u8, arg)); + try cmd.appendArg(arg); } else if (std.mem.eql(u8, arg, "-") or std.mem.eql(u8, arg, "--")) { // All remaining arguments should be passed directly to the shell // command. We shouldn't perform any further option processing. - try args.append(alloc, try alloc.dupeZ(u8, arg)); + try cmd.appendArg(arg); while (iter.next()) |remaining_arg| { - try args.append(alloc, try alloc.dupeZ(u8, remaining_arg)); + try cmd.appendArg(remaining_arg); } break; } else { - try args.append(alloc, try alloc.dupeZ(u8, arg)); + try cmd.appendArg(arg); } } try env.put("GHOSTTY_BASH_INJECT", buf[0..inject.end]); @@ -352,8 +353,7 @@ fn setupBash( ); try env.put("ENV", integ_dir); - // Join the accumulated arguments to form the final command string. - return .{ .shell = try std.mem.joinZ(alloc, " ", args.items) }; + return .{ .shell = try cmd.toOwnedSlice() }; } test "bash" { From 12bb2f3f4775fe1f203e7e0ec4c93ebc7c51062f Mon Sep 17 00:00:00 2001 From: Matthew Hrehirchuk Date: Fri, 10 Oct 2025 12:30:55 -0600 Subject: [PATCH 20/93] feat: add readonly surface mode --- include/ghostty.h | 1 + src/Surface.zig | 31 ++++++++++++++++++++++++++++- src/apprt/action.zig | 6 ++++++ src/apprt/gtk/class/application.zig | 4 ++++ src/input/Binding.zig | 11 ++++++++++ src/input/command.zig | 6 ++++++ 6 files changed, 58 insertions(+), 1 deletion(-) diff --git a/include/ghostty.h b/include/ghostty.h index 702a88ecc..cd716e38f 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -797,6 +797,7 @@ typedef enum { GHOSTTY_ACTION_RESIZE_SPLIT, GHOSTTY_ACTION_EQUALIZE_SPLITS, GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM, + GHOSTTY_ACTION_TOGGLE_READONLY, GHOSTTY_ACTION_PRESENT_TERMINAL, GHOSTTY_ACTION_SIZE_LIMIT, GHOSTTY_ACTION_RESET_WINDOW_SIZE, diff --git a/src/Surface.zig b/src/Surface.zig index 8cd8d253b..951ef14ef 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -145,6 +145,12 @@ focused: bool = true, /// Used to determine whether to continuously scroll. selection_scroll_active: bool = false, +/// True if the surface is in read-only mode. When read-only, no input +/// is sent to the PTY but terminal-level operations like selections, +/// scrolling, and copy/paste keybinds still work. Warn before quit is +/// always enabled in this state. +readonly: bool = false, + /// Used to send notifications that long running commands have finished. /// Requires that shell integration be active. Should represent a nanosecond /// precision timestamp. It does not necessarily need to correspond to the @@ -871,6 +877,9 @@ pub fn deactivateInspector(self: *Surface) void { /// True if the surface requires confirmation to quit. This should be called /// by apprt to determine if the surface should confirm before quitting. pub fn needsConfirmQuit(self: *Surface) bool { + // If the surface is in read-only mode, always require confirmation + if (self.readonly) return true; + // If the child has exited, then our process is certainly not alive. // We check this first to avoid the locking overhead below. if (self.child_exited) return false; @@ -2559,6 +2568,12 @@ pub fn keyCallback( if (insp_ev) |*ev| ev else null, )) |v| return v; + // If the surface is in read-only mode, we consume the key event here + // without sending it to the PTY. + if (self.readonly) { + return .consumed; + } + // If we allow KAM and KAM is enabled then we do nothing. if (self.config.vt_kam_allowed) { self.renderer_state.mutex.lock(); @@ -3267,7 +3282,9 @@ pub fn scrollCallback( // we convert to cursor keys. This only happens if we're: // (1) alt screen (2) no explicit mouse reporting and (3) alt // scroll mode enabled. - if (self.io.terminal.screens.active_key == .alternate and + // Additionally, we don't send cursor keys if the surface is in read-only mode. + if (!self.readonly and + self.io.terminal.screens.active_key == .alternate and self.io.terminal.flags.mouse_event == .none and self.io.terminal.modes.get(.mouse_alternate_scroll)) { @@ -3393,6 +3410,9 @@ fn mouseReport( assert(self.config.mouse_reporting); assert(self.io.terminal.flags.mouse_event != .none); + // If the surface is in read-only mode, do not send mouse reports to the PTY + if (self.readonly) return; + // Depending on the event, we may do nothing at all. switch (self.io.terminal.flags.mouse_event) { .none => unreachable, // checked by assert above @@ -5383,6 +5403,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .toggle_readonly => { + self.readonly = !self.readonly; + return try self.rt_app.performAction( + .{ .surface = self }, + .toggle_readonly, + {}, + ); + }, + .reset_window_size => return try self.rt_app.performAction( .{ .surface = self }, .reset_window_size, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 94965d38c..83e2f5011 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -139,6 +139,11 @@ pub const Action = union(Key) { /// to take up the entire window. toggle_split_zoom, + /// Toggle whether the surface is in read-only mode. When read-only, + /// no input is sent to the PTY but terminal-level operations like + /// selections, scrolling, and copy/paste keybinds still work. + toggle_readonly, + /// Present the target terminal whether its a tab, split, or window. present_terminal, @@ -335,6 +340,7 @@ pub const Action = union(Key) { resize_split, equalize_splits, toggle_split_zoom, + toggle_readonly, present_terminal, size_limit, reset_window_size, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 47c2972ac..bbf408e02 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -724,6 +724,10 @@ pub const Application = extern struct { .toggle_window_decorations => return Action.toggleWindowDecorations(target), .toggle_command_palette => return Action.toggleCommandPalette(target), .toggle_split_zoom => return Action.toggleSplitZoom(target), + .toggle_readonly => { + // The readonly state is managed in Surface.zig. + return true; + }, .show_on_screen_keyboard => return Action.showOnScreenKeyboard(target), .command_finished => return Action.commandFinished(target, value), diff --git a/src/input/Binding.zig b/src/input/Binding.zig index e1c636ab7..d368c48b2 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -552,6 +552,16 @@ pub const Action = union(enum) { /// reflect this by displaying an icon indicating the zoomed state. toggle_split_zoom, + /// Toggle read-only mode for the current surface. + /// + /// When a surface is in read-only mode: + /// - No input is sent to the PTY (mouse events, key encoding) + /// - Input can still be used at the terminal level to make selections, + /// copy/paste (keybinds), scroll, etc. + /// - Warn before quit is always enabled in this state even if an active + /// process is not running + toggle_readonly, + /// Resize the current split in the specified direction and amount in /// pixels. The two arguments should be joined with a comma (`,`), /// like in `resize_split:up,10`. @@ -1241,6 +1251,7 @@ pub const Action = union(enum) { .new_split, .goto_split, .toggle_split_zoom, + .toggle_readonly, .resize_split, .equalize_splits, .inspector, diff --git a/src/input/command.zig b/src/input/command.zig index 639fc6e39..ce218718f 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -485,6 +485,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Toggle the zoom state of the current split.", }}, + .toggle_readonly => comptime &.{.{ + .action = .toggle_readonly, + .title = "Toggle Read-Only Mode", + .description = "Toggle read-only mode for the current surface.", + }}, + .equalize_splits => comptime &.{.{ .action = .equalize_splits, .title = "Equalize Splits", From 547bcd261dcbd25bfab99d3fb00c2f93af994605 Mon Sep 17 00:00:00 2001 From: Matthew Hrehirchuk Date: Tue, 21 Oct 2025 09:57:14 -0600 Subject: [PATCH 21/93] fix: removed apprt action for toggle_readonly --- include/ghostty.h | 1 - src/Surface.zig | 6 +----- src/apprt/action.zig | 6 ------ src/apprt/gtk/class/application.zig | 4 ---- 4 files changed, 1 insertion(+), 16 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index cd716e38f..702a88ecc 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -797,7 +797,6 @@ typedef enum { GHOSTTY_ACTION_RESIZE_SPLIT, GHOSTTY_ACTION_EQUALIZE_SPLITS, GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM, - GHOSTTY_ACTION_TOGGLE_READONLY, GHOSTTY_ACTION_PRESENT_TERMINAL, GHOSTTY_ACTION_SIZE_LIMIT, GHOSTTY_ACTION_RESET_WINDOW_SIZE, diff --git a/src/Surface.zig b/src/Surface.zig index 951ef14ef..7bfdad665 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5405,11 +5405,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .toggle_readonly => { self.readonly = !self.readonly; - return try self.rt_app.performAction( - .{ .surface = self }, - .toggle_readonly, - {}, - ); + return true; }, .reset_window_size => return try self.rt_app.performAction( diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 83e2f5011..94965d38c 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -139,11 +139,6 @@ pub const Action = union(Key) { /// to take up the entire window. toggle_split_zoom, - /// Toggle whether the surface is in read-only mode. When read-only, - /// no input is sent to the PTY but terminal-level operations like - /// selections, scrolling, and copy/paste keybinds still work. - toggle_readonly, - /// Present the target terminal whether its a tab, split, or window. present_terminal, @@ -340,7 +335,6 @@ pub const Action = union(Key) { resize_split, equalize_splits, toggle_split_zoom, - toggle_readonly, present_terminal, size_limit, reset_window_size, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index bbf408e02..47c2972ac 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -724,10 +724,6 @@ pub const Application = extern struct { .toggle_window_decorations => return Action.toggleWindowDecorations(target), .toggle_command_palette => return Action.toggleCommandPalette(target), .toggle_split_zoom => return Action.toggleSplitZoom(target), - .toggle_readonly => { - // The readonly state is managed in Surface.zig. - return true; - }, .show_on_screen_keyboard => return Action.showOnScreenKeyboard(target), .command_finished => return Action.commandFinished(target, value), From b58ac983cfeecded082c7f51fe9149062952907e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 07:29:42 -0800 Subject: [PATCH 22/93] docs changes --- src/Surface.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Surface.zig b/src/Surface.zig index e4ba605f6..1ea7a7201 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -147,7 +147,7 @@ selection_scroll_active: bool = false, /// True if the surface is in read-only mode. When read-only, no input /// is sent to the PTY but terminal-level operations like selections, -/// scrolling, and copy/paste keybinds still work. Warn before quit is +/// (native) scrolling, and copy keybinds still work. Warn before quit is /// always enabled in this state. readonly: bool = false, From 2d9c83dbb7ee50471f8326f3687651d2a944c350 Mon Sep 17 00:00:00 2001 From: Michael Bommarito Date: Fri, 12 Dec 2025 13:36:36 -0500 Subject: [PATCH 23/93] fix: bash shell integration use-after-free bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ShellCommandBuilder uses a stackFallback allocator, which means toOwnedSlice() may return memory allocated on the stack. When setupBash() returns, this stack memory becomes invalid, causing a use-after-free. This manifested as garbage data in the shell command string, often appearing as errors like "/bin/sh: 1: ically: not found" (where "ically" was part of nearby memory, likely from the comment "automatically"). The fix copies the command string to the arena allocator before returning, ensuring the memory remains valid for the lifetime of the command. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/termio/shell_integration.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 128b345ea..71492230e 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -353,7 +353,11 @@ fn setupBash( ); try env.put("ENV", integ_dir); - return .{ .shell = try cmd.toOwnedSlice() }; + // Get the command string from the builder, then copy it to the arena + // allocator. The stackFallback allocator's memory becomes invalid after + // this function returns, so we must copy to the arena. + const cmd_str = try cmd.toOwnedSlice(); + return .{ .shell = try alloc.dupeZ(u8, cmd_str) }; } test "bash" { From 29fdb541d56f980afa53b6ea3ef7c8985317e1de Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 12:00:20 -0800 Subject: [PATCH 24/93] make all IO message queueing go through queueIo so we can intercept --- src/Surface.zig | 85 ++++++++++++++++++++++++------------------- src/termio/Termio.zig | 5 ++- 2 files changed, 51 insertions(+), 39 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 1ea7a7201..819972509 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -818,6 +818,15 @@ inline fn surfaceMailbox(self: *Surface) Mailbox { }; } +/// Queue a message for the IO thread. +fn queueIo( + self: *Surface, + msg: termio.Message, + mutex: termio.Termio.MutexState, +) void { + self.io.queueMessage(msg, mutex); +} + /// Forces the surface to render. This is useful for when the surface /// is in the middle of animation (such as a resize, etc.) or when /// the render timer is managed manually by the apprt. @@ -849,7 +858,7 @@ pub fn activateInspector(self: *Surface) !void { // Notify our components we have an inspector active _ = self.renderer_thread.mailbox.push(.{ .inspector = true }, .{ .forever = {} }); - self.io.queueMessage(.{ .inspector = true }, .unlocked); + self.queueIo(.{ .inspector = true }, .unlocked); } /// Deactivate the inspector and stop collecting any information. @@ -866,7 +875,7 @@ pub fn deactivateInspector(self: *Surface) void { // Notify our components we have deactivated inspector _ = self.renderer_thread.mailbox.push(.{ .inspector = false }, .{ .forever = {} }); - self.io.queueMessage(.{ .inspector = false }, .unlocked); + self.queueIo(.{ .inspector = false }, .unlocked); // Deinit the inspector insp.deinit(); @@ -938,7 +947,7 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { // We always use an allocating message because we don't know // the length of the title and this isn't a performance critical // path. - self.io.queueMessage(.{ + self.queueIo(.{ .write_alloc = .{ .alloc = self.alloc, .data = data, @@ -1130,7 +1139,7 @@ fn selectionScrollTick(self: *Surface) !void { // If our screen changed while this is happening, we stop our // selection scroll. if (self.mouse.left_click_screen != t.screens.active_key) { - self.io.queueMessage( + self.queueIo( .{ .selection_scroll = false }, .locked, ); @@ -1362,7 +1371,7 @@ fn reportColorScheme(self: *Surface, force: bool) void { .dark => "\x1B[?997;1n", }; - self.io.queueMessage(.{ .write_stable = output }, .unlocked); + self.queueIo(.{ .write_stable = output }, .unlocked); } fn searchCallback(event: terminal.search.Thread.Event, ud: ?*anyopaque) void { @@ -1735,7 +1744,7 @@ pub fn updateConfig( errdefer termio_config_ptr.deinit(); _ = self.renderer_thread.mailbox.push(renderer_message, .{ .forever = {} }); - self.io.queueMessage(.{ + self.queueIo(.{ .change_config = .{ .alloc = self.alloc, .ptr = termio_config_ptr, @@ -2301,7 +2310,7 @@ fn setCellSize(self: *Surface, size: rendererpkg.CellSize) !void { self.balancePaddingIfNeeded(); // Notify the terminal - self.io.queueMessage(.{ .resize = self.size }, .unlocked); + self.queueIo(.{ .resize = self.size }, .unlocked); // Update our terminal default size if necessary. self.recomputeInitialSize() catch |err| { @@ -2404,7 +2413,7 @@ fn resize(self: *Surface, size: rendererpkg.ScreenSize) !void { } // Mail the IO thread - self.io.queueMessage(.{ .resize = self.size }, .unlocked); + self.queueIo(.{ .resize = self.size }, .unlocked); } /// Recalculate the balanced padding if needed. @@ -2686,7 +2695,7 @@ pub fn keyCallback( } errdefer write_req.deinit(); - self.io.queueMessage(switch (write_req) { + self.queueIo(switch (write_req) { .small => |v| .{ .write_small = v }, .stable => |v| .{ .write_stable = v }, .alloc => |v| .{ .write_alloc = v }, @@ -2915,7 +2924,7 @@ fn endKeySequence( if (self.keyboard.queued.items.len > 0) { switch (action) { .flush => for (self.keyboard.queued.items) |write_req| { - self.io.queueMessage(switch (write_req) { + self.queueIo(switch (write_req) { .small => |v| .{ .write_small = v }, .stable => |v| .{ .write_stable = v }, .alloc => |v| .{ .write_alloc = v }, @@ -3141,7 +3150,7 @@ pub fn focusCallback(self: *Surface, focused: bool) !void { self.renderer_state.mutex.lock(); self.io.terminal.flags.focused = focused; self.renderer_state.mutex.unlock(); - self.io.queueMessage(.{ .focused = focused }, .unlocked); + self.queueIo(.{ .focused = focused }, .unlocked); } } @@ -3307,7 +3316,7 @@ pub fn scrollCallback( }; }; for (0..y.magnitude()) |_| { - self.io.queueMessage(.{ .write_stable = seq }, .locked); + self.queueIo(.{ .write_stable = seq }, .locked); } } @@ -3532,7 +3541,7 @@ fn mouseReport( data[5] = 32 + @as(u8, @intCast(viewport_point.y)) + 1; // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = 6, } }, .locked); @@ -3555,7 +3564,7 @@ fn mouseReport( i += try std.unicode.utf8Encode(@intCast(32 + viewport_point.y + 1), data[i..]); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(i), } }, .locked); @@ -3576,7 +3585,7 @@ fn mouseReport( }); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(resp.len), } }, .locked); @@ -3593,7 +3602,7 @@ fn mouseReport( }); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(resp.len), } }, .locked); @@ -3622,7 +3631,7 @@ fn mouseReport( }); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(resp.len), } }, .locked); @@ -3774,7 +3783,7 @@ pub fn mouseButtonCallback( // Stop selection scrolling when releasing the left mouse button // but only when selection scrolling is active. if (self.selection_scroll_active) { - self.io.queueMessage( + self.queueIo( .{ .selection_scroll = false }, .unlocked, ); @@ -4131,7 +4140,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { break :arrow if (t.modes.get(.cursor_keys)) "\x1bOB" else "\x1b[B"; }; for (0..@abs(path.y)) |_| { - self.io.queueMessage(.{ .write_stable = arrow }, .locked); + self.queueIo(.{ .write_stable = arrow }, .locked); } } if (path.x != 0) { @@ -4141,7 +4150,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { break :arrow if (t.modes.get(.cursor_keys)) "\x1bOC" else "\x1b[C"; }; for (0..@abs(path.x)) |_| { - self.io.queueMessage(.{ .write_stable = arrow }, .locked); + self.queueIo(.{ .write_stable = arrow }, .locked); } } } @@ -4414,7 +4423,7 @@ pub fn cursorPosCallback( // Stop selection scrolling when inside the viewport within a 1px buffer // for fullscreen windows, but only when selection scrolling is active. if (pos.y >= 1 and self.selection_scroll_active) { - self.io.queueMessage( + self.queueIo( .{ .selection_scroll = false }, .locked, ); @@ -4514,7 +4523,7 @@ pub fn cursorPosCallback( if ((pos.y <= 1 or pos.y > max_y - 1) and !self.selection_scroll_active) { - self.io.queueMessage( + self.queueIo( .{ .selection_scroll = true }, .locked, ); @@ -4890,7 +4899,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .esc => try std.fmt.bufPrint(&buf, "\x1b{s}", .{data}), else => unreachable, }; - self.io.queueMessage(try termio.Message.writeReq( + self.queueIo(try termio.Message.writeReq( self.alloc, full_data, ), .unlocked); @@ -4917,7 +4926,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ); return true; }; - self.io.queueMessage(try termio.Message.writeReq( + self.queueIo(try termio.Message.writeReq( self.alloc, text, ), .unlocked); @@ -4950,9 +4959,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }; if (normal) { - self.io.queueMessage(.{ .write_stable = ck.normal }, .unlocked); + self.queueIo(.{ .write_stable = ck.normal }, .unlocked); } else { - self.io.queueMessage(.{ .write_stable = ck.application }, .unlocked); + self.queueIo(.{ .write_stable = ck.application }, .unlocked); } }, @@ -5225,19 +5234,19 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool if (self.io.terminal.screens.active_key == .alternate) return false; } - self.io.queueMessage(.{ + self.queueIo(.{ .clear_screen = .{ .history = true }, }, .unlocked); }, .scroll_to_top => { - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .top = {} }, }, .unlocked); }, .scroll_to_bottom => { - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .bottom = {} }, }, .unlocked); }, @@ -5267,14 +5276,14 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .scroll_page_up => { const rows: isize = @intCast(self.size.grid().rows); - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = -1 * rows }, }, .unlocked); }, .scroll_page_down => { const rows: isize = @intCast(self.size.grid().rows); - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = rows }, }, .unlocked); }, @@ -5282,19 +5291,19 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .scroll_page_fractional => |fraction| { const rows: f32 = @floatFromInt(self.size.grid().rows); const delta: isize = @intFromFloat(@trunc(fraction * rows)); - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = delta }, }, .unlocked); }, .scroll_page_lines => |lines| { - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = lines }, }, .unlocked); }, .jump_to_prompt => |delta| { - self.io.queueMessage(.{ + self.queueIo(.{ .jump_to_prompt = @intCast(delta), }, .unlocked); }, @@ -5514,7 +5523,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }; }, - .io => self.io.queueMessage(.{ .crash = {} }, .unlocked), + .io => self.queueIo(.{ .crash = {} }, .unlocked), }, .adjust_selection => |direction| { @@ -5712,7 +5721,7 @@ fn writeScreenFile( }, .url = path, }), - .paste => self.io.queueMessage(try termio.Message.writeReq( + .paste => self.queueIo(try termio.Message.writeReq( self.alloc, path, ), .unlocked), @@ -5852,7 +5861,7 @@ fn completeClipboardPaste( }; for (vecs) |vec| if (vec.len > 0) { - self.io.queueMessage(try termio.Message.writeReq( + self.queueIo(try termio.Message.writeReq( self.alloc, vec, ), .unlocked); @@ -5898,7 +5907,7 @@ fn completeClipboardReadOSC52( const encoded = enc.encode(buf[prefix.len..], data); assert(encoded.len == size); - self.io.queueMessage(try termio.Message.writeReq( + self.queueIo(try termio.Message.writeReq( self.alloc, buf, ), .unlocked); diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 53df00433..7263418a7 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -22,6 +22,9 @@ const configpkg = @import("../config.zig"); const log = std.log.scoped(.io_exec); +/// Mutex state argument for queueMessage. +pub const MutexState = enum { locked, unlocked }; + /// Allocator alloc: Allocator, @@ -380,7 +383,7 @@ pub fn threadExit(self: *Termio, data: *ThreadData) void { pub fn queueMessage( self: *Termio, msg: termio.Message, - mutex: enum { locked, unlocked }, + mutex: MutexState, ) void { self.mailbox.send(msg, switch (mutex) { .locked => self.renderer_state.mutex, From 6dd9a74e6e2318ca313638e96c8d3cd4df41bfd6 Mon Sep 17 00:00:00 2001 From: Michael Hazan Date: Fri, 12 Dec 2025 22:56:06 +0200 Subject: [PATCH 25/93] fix(docs): `window-decoration` is now `none` instead of `false` --- src/config/Config.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 20256e951..1deb3e532 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1329,7 +1329,7 @@ maximize: bool = false, /// new windows, not just the first one. /// /// On macOS, this setting does not work if window-decoration is set to -/// "false", because native fullscreen on macOS requires window decorations +/// "none", because native fullscreen on macOS requires window decorations /// to be set. fullscreen: bool = false, @@ -2825,7 +2825,7 @@ keybind: Keybinds = .{}, /// also known as the traffic lights, that allow you to close, miniaturize, and /// zoom the window. /// -/// This setting has no effect when `window-decoration = false` or +/// This setting has no effect when `window-decoration = none` or /// `macos-titlebar-style = hidden`, as the window buttons are always hidden in /// these modes. /// @@ -2866,7 +2866,7 @@ keybind: Keybinds = .{}, /// macOS 14 does not have this issue and any other macOS version has not /// been tested. /// -/// The "hidden" style hides the titlebar. Unlike `window-decoration = false`, +/// The "hidden" style hides the titlebar. Unlike `window-decoration = none`, /// however, it does not remove the frame from the window or cause it to have /// squared corners. Changing to or from this option at run-time may affect /// existing windows in buggy ways. @@ -3205,7 +3205,7 @@ else /// manager's simple titlebar. The behavior of this option will vary with your /// window manager. /// -/// This option does nothing when `window-decoration` is false or when running +/// This option does nothing when `window-decoration` is none or when running /// under macOS. @"gtk-titlebar": bool = true, From 0bf3642939122bcc0beea45929f4d5d4ea14335a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 13:07:43 -0800 Subject: [PATCH 26/93] core: manage read-only through queueIo --- src/Surface.zig | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 819972509..19dc086dd 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -819,11 +819,26 @@ inline fn surfaceMailbox(self: *Surface) Mailbox { } /// Queue a message for the IO thread. +/// +/// We centralize all our logic into this spot so we can intercept +/// messages for example in readonly mode. fn queueIo( self: *Surface, msg: termio.Message, mutex: termio.Termio.MutexState, ) void { + // In readonly mode, we don't allow any writes through to the pty. + if (self.readonly) { + switch (msg) { + .write_small, + .write_stable, + .write_alloc, + => return, + + else => {}, + } + } + self.io.queueMessage(msg, mutex); } @@ -3291,9 +3306,7 @@ pub fn scrollCallback( // we convert to cursor keys. This only happens if we're: // (1) alt screen (2) no explicit mouse reporting and (3) alt // scroll mode enabled. - // Additionally, we don't send cursor keys if the surface is in read-only mode. - if (!self.readonly and - self.io.terminal.screens.active_key == .alternate and + if (self.io.terminal.screens.active_key == .alternate and self.io.terminal.flags.mouse_event == .none and self.io.terminal.modes.get(.mouse_alternate_scroll)) { @@ -3402,10 +3415,9 @@ pub fn contentScaleCallback(self: *Surface, content_scale: apprt.ContentScale) ! const MouseReportAction = enum { press, release, motion }; /// Returns true if mouse reporting is enabled both in the config and -/// the terminal state, and the surface is not in read-only mode. +/// the terminal state. fn isMouseReporting(self: *const Surface) bool { - return !self.readonly and - self.config.mouse_reporting and + return self.config.mouse_reporting and self.io.terminal.flags.mouse_event != .none; } @@ -3420,9 +3432,6 @@ fn mouseReport( assert(self.config.mouse_reporting); assert(self.io.terminal.flags.mouse_event != .none); - // Callers must verify the surface is not in read-only mode - assert(!self.readonly); - // Depending on the event, we may do nothing at all. switch (self.io.terminal.flags.mouse_event) { .none => unreachable, // checked by assert above From dc7bc3014e1ea4033f07af372e86f34d400182bc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 13:13:53 -0800 Subject: [PATCH 27/93] add apprt action to notify apprt of surface readonly state --- include/ghostty.h | 8 ++++++++ src/Surface.zig | 5 +++++ src/apprt/action.zig | 9 +++++++++ src/apprt/gtk/class/application.zig | 1 + 4 files changed, 23 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 702a88ecc..a75fdc245 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -573,6 +573,12 @@ typedef enum { GHOSTTY_QUIT_TIMER_STOP, } ghostty_action_quit_timer_e; +// apprt.action.Readonly +typedef enum { + GHOSTTY_READONLY_OFF, + GHOSTTY_READONLY_ON, +} ghostty_action_readonly_e; + // apprt.action.DesktopNotification.C typedef struct { const char* title; @@ -837,6 +843,7 @@ typedef enum { GHOSTTY_ACTION_END_SEARCH, GHOSTTY_ACTION_SEARCH_TOTAL, GHOSTTY_ACTION_SEARCH_SELECTED, + GHOSTTY_ACTION_READONLY, } ghostty_action_tag_e; typedef union { @@ -874,6 +881,7 @@ typedef union { ghostty_action_start_search_s start_search; ghostty_action_search_total_s search_total; ghostty_action_search_selected_s search_selected; + ghostty_action_readonly_e readonly; } ghostty_action_u; typedef struct { diff --git a/src/Surface.zig b/src/Surface.zig index 19dc086dd..45b629865 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5424,6 +5424,11 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .toggle_readonly => { self.readonly = !self.readonly; + _ = try self.rt_app.performAction( + .{ .surface = self }, + .readonly, + if (self.readonly) .on else .off, + ); return true; }, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 94965d38c..608081a46 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -314,6 +314,9 @@ pub const Action = union(Key) { /// The currently selected search match index (1-based). search_selected: SearchSelected, + /// The readonly state of the surface has changed. + readonly: Readonly, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -375,6 +378,7 @@ pub const Action = union(Key) { end_search, search_total, search_selected, + readonly, }; /// Sync with: ghostty_action_u @@ -532,6 +536,11 @@ pub const QuitTimer = enum(c_int) { stop, }; +pub const Readonly = enum(c_int) { + off, + on, +}; + pub const MouseVisibility = enum(c_int) { visible, hidden, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 47c2972ac..efca498b4 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -746,6 +746,7 @@ pub const Application = extern struct { .check_for_updates, .undo, .redo, + .readonly, => { log.warn("unimplemented action={}", .{action}); return false; From ec2638b3c6e3ceb870e459380fa0f91a46a392a2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 13:41:32 -0800 Subject: [PATCH 28/93] macos: readonly badge --- macos/Sources/Ghostty/Ghostty.App.swift | 28 +++++++++++ macos/Sources/Ghostty/Package.swift | 4 ++ macos/Sources/Ghostty/SurfaceView.swift | 47 +++++++++++++++++++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 13 +++++ macos/Sources/Ghostty/SurfaceView_UIKit.swift | 3 ++ 5 files changed, 95 insertions(+) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index aff3edbc7..4788a4376 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -588,6 +588,9 @@ extension Ghostty { case GHOSTTY_ACTION_RING_BELL: ringBell(app, target: target) + case GHOSTTY_ACTION_READONLY: + setReadonly(app, target: target, v: action.action.readonly) + case GHOSTTY_ACTION_CHECK_FOR_UPDATES: checkForUpdates(app) @@ -1010,6 +1013,31 @@ extension Ghostty { } } + private static func setReadonly( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_readonly_e) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("set readonly does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + NotificationCenter.default.post( + name: .ghosttyDidChangeReadonly, + object: surfaceView, + userInfo: [ + SwiftUI.Notification.Name.ReadonlyKey: v == GHOSTTY_READONLY_ON, + ] + ) + + default: + assertionFailure() + } + } + private static func moveTab( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 4b3eb60aa..258857e8e 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -391,6 +391,10 @@ extension Notification.Name { /// Ring the bell static let ghosttyBellDidRing = Notification.Name("com.mitchellh.ghostty.ghosttyBellDidRing") + + /// Readonly mode changed + static let ghosttyDidChangeReadonly = Notification.Name("com.mitchellh.ghostty.didChangeReadonly") + static let ReadonlyKey = ghosttyDidChangeReadonly.rawValue + ".readonly" static let ghosttyCommandPaletteDidToggle = Notification.Name("com.mitchellh.ghostty.commandPaletteDidToggle") /// Toggle maximize of current window diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index ba678db59..c027162ab 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -104,6 +104,11 @@ extension Ghostty { } .ghosttySurfaceView(surfaceView) + // Readonly indicator badge + if surfaceView.readonly { + ReadonlyBadge() + } + // Progress report if let progressReport = surfaceView.progressReport, progressReport.state != .remove { VStack(spacing: 0) { @@ -757,6 +762,48 @@ extension Ghostty { } } + // MARK: Readonly Badge + + /// A badge overlay that indicates a surface is in readonly mode. + /// Positioned in the top-right corner and styled to be noticeable but unobtrusive. + struct ReadonlyBadge: View { + private let badgeColor = Color(hue: 0.08, saturation: 0.5, brightness: 0.8) + + var body: some View { + VStack { + HStack { + Spacer() + + HStack(spacing: 5) { + Image(systemName: "eye.fill") + .font(.system(size: 12)) + Text("Read-only") + .font(.system(size: 12, weight: .medium)) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(badgeBackground) + .foregroundStyle(badgeColor) + } + .padding(8) + + Spacer() + } + .allowsHitTesting(false) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Read-only terminal") + } + + private var badgeBackground: some View { + RoundedRectangle(cornerRadius: 6) + .fill(.regularMaterial) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color.orange.opacity(0.6), lineWidth: 1.5) + ) + } + } + #if canImport(AppKit) /// When changing the split state, or going full screen (native or non), the terminal view /// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 130df6f44..d8670e644 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -123,6 +123,9 @@ extension Ghostty { /// True when the bell is active. This is set inactive on focus or event. @Published private(set) var bell: Bool = false + /// True when the surface is in readonly mode. + @Published private(set) var readonly: Bool = false + // An initial size to request for a window. This will only affect // then the view is moved to a new window. var initialSize: NSSize? = nil @@ -333,6 +336,11 @@ extension Ghostty { selector: #selector(ghosttyBellDidRing(_:)), name: .ghosttyBellDidRing, object: self) + center.addObserver( + self, + selector: #selector(ghosttyDidChangeReadonly(_:)), + name: .ghosttyDidChangeReadonly, + object: self) center.addObserver( self, selector: #selector(windowDidChangeScreen), @@ -703,6 +711,11 @@ extension Ghostty { bell = true } + @objc private func ghosttyDidChangeReadonly(_ notification: SwiftUI.Notification) { + guard let value = notification.userInfo?[SwiftUI.Notification.Name.ReadonlyKey] as? Bool else { return } + readonly = value + } + @objc private func windowDidChangeScreen(notification: SwiftUI.Notification) { guard let window = self.window else { return } guard let object = notification.object as? NSWindow, window == object else { return } diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index 09c41c0b5..568a93314 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -43,6 +43,9 @@ extension Ghostty { // The current search state. When non-nil, the search overlay should be shown. @Published var searchState: SearchState? = nil + + /// True when the surface is in readonly mode. + @Published private(set) var readonly: Bool = false // Returns sizing information for the surface. This is the raw C // structure because I'm lazy. From ceb1b5e587c7a769f33ca8e0d208ce3067cb2947 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 13:50:20 -0800 Subject: [PATCH 29/93] macos: add a read-only menu item in View --- macos/Sources/App/macOS/AppDelegate.swift | 1 + macos/Sources/App/macOS/MainMenu.xib | 7 +++++++ macos/Sources/Ghostty/SurfaceView_AppKit.swift | 12 ++++++++++++ 3 files changed, 20 insertions(+) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 8baee3d89..e10547bbc 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -69,6 +69,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuResetFontSize: NSMenuItem? @IBOutlet private var menuChangeTitle: NSMenuItem? @IBOutlet private var menuChangeTabTitle: NSMenuItem? + @IBOutlet private var menuReadonly: NSMenuItem? @IBOutlet private var menuQuickTerminal: NSMenuItem? @IBOutlet private var menuTerminalInspector: NSMenuItem? @IBOutlet private var menuCommandPalette: NSMenuItem? diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index d009b9c62..a321061dd 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -47,6 +47,7 @@ + @@ -328,6 +329,12 @@ + + + + + + diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index d8670e644..853a6d51c 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1512,6 +1512,14 @@ extension Ghostty { } } + @IBAction func toggleReadonly(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "toggle_readonly" + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + @IBAction func splitRight(_ sender: Any) { guard let surface = self.surface else { return } ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_RIGHT) @@ -1988,6 +1996,10 @@ extension Ghostty.SurfaceView: NSMenuItemValidation { case #selector(findHide): return searchState != nil + case #selector(toggleReadonly): + item.state = readonly ? .on : .off + return true + default: return true } From 173d8efd90536afc53316cbc00f3628dae3fd3df Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 13:55:02 -0800 Subject: [PATCH 30/93] macos: add to context menu --- macos/Sources/App/macOS/AppDelegate.swift | 1 + macos/Sources/Ghostty/SurfaceView_AppKit.swift | 3 +++ 2 files changed, 4 insertions(+) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index e10547bbc..043d85e1e 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -545,6 +545,7 @@ class AppDelegate: NSObject, self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal") self.menuChangeTabTitle?.setImageIfDesired(systemSymbolName: "pencil.line") self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope") + self.menuReadonly?.setImageIfDesired(systemSymbolName: "eye.fill") self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward") self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye") self.menuZoomSplit?.setImageIfDesired(systemSymbolName: "arrow.up.left.and.arrow.down.right") diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 853a6d51c..d26545ebc 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1429,6 +1429,9 @@ extension Ghostty { item.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise") item = menu.addItem(withTitle: "Toggle Terminal Inspector", action: #selector(toggleTerminalInspector(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "scope") + item = menu.addItem(withTitle: "Terminal Read-only", action: #selector(toggleReadonly(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "eye.fill") + item.state = readonly ? .on : .off menu.addItem(.separator()) item = menu.addItem(withTitle: "Change Tab Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "pencil.line") From 22b8809858088d8760c5601e4ec1658d6be964d4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 14:01:35 -0800 Subject: [PATCH 31/93] macos: add a popover to the readonly badge with info --- macos/Sources/Ghostty/SurfaceView.swift | 54 ++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index c027162ab..3bdcaafe6 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -106,7 +106,9 @@ extension Ghostty { // Readonly indicator badge if surfaceView.readonly { - ReadonlyBadge() + ReadonlyBadge { + surfaceView.toggleReadonly(nil) + } } // Progress report @@ -767,6 +769,10 @@ extension Ghostty { /// A badge overlay that indicates a surface is in readonly mode. /// Positioned in the top-right corner and styled to be noticeable but unobtrusive. struct ReadonlyBadge: View { + let onDisable: () -> Void + + @State private var showingPopover = false + private let badgeColor = Color(hue: 0.08, saturation: 0.5, brightness: 0.8) var body: some View { @@ -784,12 +790,18 @@ extension Ghostty { .padding(.vertical, 4) .background(badgeBackground) .foregroundStyle(badgeColor) + .onTapGesture { + showingPopover = true + } + .backport.pointerStyle(.link) + .popover(isPresented: $showingPopover, arrowEdge: .bottom) { + ReadonlyPopoverView(onDisable: onDisable, isPresented: $showingPopover) + } } .padding(8) Spacer() } - .allowsHitTesting(false) .accessibilityElement(children: .ignore) .accessibilityLabel("Read-only terminal") } @@ -803,6 +815,44 @@ extension Ghostty { ) } } + + struct ReadonlyPopoverView: View { + let onDisable: () -> Void + @Binding var isPresented: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "eye.fill") + .foregroundColor(.orange) + .font(.system(size: 13)) + Text("Read-Only Mode") + .font(.system(size: 13, weight: .semibold)) + } + + Text("This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Spacer() + + Button("Disable") { + onDisable() + isPresented = false + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + .padding(16) + .frame(width: 280) + } + } #if canImport(AppKit) /// When changing the split state, or going full screen (native or non), the terminal view From ddaf307cf7a6304b4376fb98e94e614369c46f1d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 14:05:46 -0800 Subject: [PATCH 32/93] macos: more strict detection for tab context menu We were accidentally modifying the "View" menu. --- .../Features/Terminal/Window Styles/TerminalWindow.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index d04d7001c..160473328 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -708,8 +708,8 @@ extension TerminalWindow { private func isTabContextMenu(_ menu: NSMenu) -> Bool { guard NSApp.keyWindow === self else { return false } - // These are the target selectors, at least for macOS 26. - let tabContextSelectors: Set = [ + // These selectors must all exist for it to be a tab context menu. + let requiredSelectors: Set = [ "performClose:", "performCloseOtherTabs:", "moveTabToNewWindow:", @@ -717,7 +717,7 @@ extension TerminalWindow { ] let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) }) - return !selectorNames.isDisjoint(with: tabContextSelectors) + return requiredSelectors.isSubset(of: selectorNames) } private func appendTabModifierSection(to menu: NSMenu, target: TerminalController?) { From 43b4ed5bc0c20d6a39d20260a924c308f065d43e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 14:12:02 -0800 Subject: [PATCH 33/93] macos: only show readonly badge on AppKit --- macos/Sources/Ghostty/SurfaceView.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 3bdcaafe6..eaf935df9 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -103,14 +103,7 @@ extension Ghostty { } } .ghosttySurfaceView(surfaceView) - - // Readonly indicator badge - if surfaceView.readonly { - ReadonlyBadge { - surfaceView.toggleReadonly(nil) - } - } - + .allowsHitTesting(false) // Progress report if let progressReport = surfaceView.progressReport, progressReport.state != .remove { VStack(spacing: 0) { @@ -123,6 +116,13 @@ extension Ghostty { } #if canImport(AppKit) + // Readonly indicator badge + if surfaceView.readonly { + ReadonlyBadge { + surfaceView.toggleReadonly(nil) + } + } + // If we are in the middle of a key sequence, then we show a visual element. We only // support this on macOS currently although in theory we can support mobile with keyboards! if !surfaceView.keySequence.isEmpty { From 19e0864688e0ce53d030d7d66eb474e9ccba816e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 14:14:14 -0800 Subject: [PATCH 34/93] macos: unintended change --- macos/Sources/Ghostty/SurfaceView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index eaf935df9..82232dd89 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -103,7 +103,7 @@ extension Ghostty { } } .ghosttySurfaceView(surfaceView) - .allowsHitTesting(false) + // Progress report if let progressReport = surfaceView.progressReport, progressReport.state != .remove { VStack(spacing: 0) { From 182cb35bae0f7dc45cb6f98374a6babf42e73401 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 14:15:43 -0800 Subject: [PATCH 35/93] core: remove readonly check --- src/Surface.zig | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 45b629865..a3b306fef 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2592,12 +2592,6 @@ pub fn keyCallback( if (insp_ev) |*ev| ev else null, )) |v| return v; - // If the surface is in read-only mode, we consume the key event here - // without sending it to the PTY. - if (self.readonly) { - return .consumed; - } - // If we allow KAM and KAM is enabled then we do nothing. if (self.config.vt_kam_allowed) { self.renderer_state.mutex.lock(); From 4a04efaff1e1ece5a78131c09221ae3700392e06 Mon Sep 17 00:00:00 2001 From: definfo Date: Sat, 13 Dec 2025 16:55:41 +0800 Subject: [PATCH 36/93] fix: explicitly allow preservation for TERMINFO in shell-integration Due to security issues, `sudo` implementations may not preserve environment variables unless appended with `--preserve-env=list`. Signed-off-by: definfo --- src/shell-integration/bash/ghostty.bash | 2 +- src/shell-integration/elvish/lib/ghostty-integration.elv | 2 +- .../fish/vendor_conf.d/ghostty-shell-integration.fish | 2 +- src/shell-integration/zsh/ghostty-integration | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index e910a9885..799d0cff6 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -103,7 +103,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then builtin command sudo "$@"; else - builtin command sudo TERMINFO="$TERMINFO" "$@"; + builtin command sudo --preserve-env=TERMINFO "$@"; fi } fi diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 33473c8b0..e4b449ae5 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -97,7 +97,7 @@ if (not (has-value $arg =)) { break } } - if (not $sudoedit) { set args = [ TERMINFO=$E:TERMINFO $@args ] } + if (not $sudoedit) { set args = [ --preserve-env=TERMINFO $@args ] } (external sudo) $@args } diff --git a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish index 47af9be98..580e27f45 100644 --- a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +++ b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish @@ -90,7 +90,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" if test "$sudo_has_sudoedit_flags" = "yes" command sudo $argv else - command sudo TERMINFO="$TERMINFO" $argv + command sudo --preserve-env=TERMINFO $argv end end end diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 7ff43efd9..c87630c92 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -255,7 +255,7 @@ _ghostty_deferred_init() { if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then builtin command sudo "$@"; else - builtin command sudo TERMINFO="$TERMINFO" "$@"; + builtin command sudo --preserve-env=TERMINFO "$@"; fi } fi From 91b4a218cad2fd0c9c8fa448593e1ebb199d5013 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Sun, 30 Nov 2025 20:45:18 +0100 Subject: [PATCH 37/93] macOS: change `window` to `new-window` for `macos-dock-drop-behavior` Matches current option references and Swift implementation --- src/config/Config.zig | 44 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 1deb3e532..1e3a79862 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -86,6 +86,10 @@ pub const compatibility = std.StaticStringMap( // Ghostty 1.2 removed the "desktop" option and renamed it to "detect". // The semantics also changed slightly but this is the correct mapping. .{ "gtk-single-instance", compatGtkSingleInstance }, + + // Ghostty 1.3 rename the "window" option to "new-window". + // See: https://github.com/ghostty-org/ghostty/pull/9764 + .{ "macos-dock-drop-behavior", compatMacOSDockDropBehavior }, }); /// The font families to use. @@ -2911,7 +2915,7 @@ keybind: Keybinds = .{}, /// /// * `new-tab` - Create a new tab in the current window, or open /// a new window if none exist. -/// * `window` - Create a new window unconditionally. +/// * `new-window` - Create a new window unconditionally. /// /// The default value is `new-tab`. /// @@ -4445,6 +4449,23 @@ fn compatBoldIsBright( return true; } +fn compatMacOSDockDropBehavior( + self: *Config, + alloc: Allocator, + key: []const u8, + value: ?[]const u8, +) bool { + _ = alloc; + assert(std.mem.eql(u8, key, "macos-dock-drop-behavior")); + + if (std.mem.eql(u8, value orelse "", "window")) { + self.@"macos-dock-drop-behavior" = .@"new-window"; + return true; + } + + return false; +} + /// Add a diagnostic message to the config with the given string. /// This is always added with a location of "none". pub fn addDiagnosticFmt( @@ -7875,7 +7896,7 @@ pub const WindowNewTabPosition = enum { /// See macos-dock-drop-behavior pub const MacOSDockDropBehavior = enum { @"new-tab", - window, + @"new-window", }; /// See window-show-tab-bar @@ -9491,3 +9512,22 @@ test "compatibility: removed bold-is-bright" { ); } } + +test "compatibility: window new-window" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--macos-dock-drop-behavior=window", + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + try testing.expectEqual( + MacOSDockDropBehavior.@"new-window", + cfg.@"macos-dock-drop-behavior", + ); + } +} From c5d6b951e99dcff378c9b49f9f5fb56ab2874ec5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Dec 2025 07:06:06 -0800 Subject: [PATCH 38/93] input: shift+backspace in Kitty with only disambiguate should do CSIu Fixes #9868 (shift+backspace part only) --- src/input/key_encode.zig | 45 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/src/input/key_encode.zig b/src/input/key_encode.zig index b63de6f6d..736df58a0 100644 --- a/src/input/key_encode.zig +++ b/src/input/key_encode.zig @@ -178,7 +178,7 @@ fn kitty( // Quote ("report all" mode): // Note that all keys are reported as escape codes, including Enter, // Tab, Backspace etc. - if (effective_mods.empty()) { + if (binding_mods.empty()) { switch (event.key) { .enter => return try writer.writeByte('\r'), .tab => return try writer.writeByte('\t'), @@ -1311,7 +1311,48 @@ test "kitty: enter, backspace, tab" { try testing.expectEqualStrings("\x1b[9;1:3u", writer.buffered()); } } -// + +test "kitty: shift+backspace emits CSI u" { + // Backspace with shift modifier should emit CSI u sequence, not raw 0x7F. + // This is important for programs that want to distinguish shift+backspace. + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .backspace, + .mods = .{ .shift = true }, + .utf8 = "", + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\x1b[127;2u", writer.buffered()); +} + +test "kitty: shift+enter emits CSI u" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .enter, + .mods = .{ .shift = true }, + .utf8 = "", + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\x1b[13;2u", writer.buffered()); +} + +test "kitty: shift+tab emits CSI u" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .tab, + .mods = .{ .shift = true }, + .utf8 = "", + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\x1b[9;2u", writer.buffered()); +} + test "kitty: enter with all flags" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); From 1c1ef99fb1d7cdab5af3d058fc2ff51867eab26a Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Tue, 21 Oct 2025 22:13:42 +0200 Subject: [PATCH 39/93] Window switching initial --- include/ghostty.h | 6 +++++ src/Surface.zig | 11 +++++++++ src/apprt/action.zig | 11 +++++++++ src/apprt/gtk/class/application.zig | 36 +++++++++++++++++++++++++++++ src/input/Binding.zig | 10 ++++++++ src/input/command.zig | 14 +++++++++++ 6 files changed, 88 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index a75fdc245..82ac392e2 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -512,6 +512,12 @@ typedef enum { GHOSTTY_GOTO_SPLIT_RIGHT, } ghostty_action_goto_split_e; +// apprt.action.GotoWindow +typedef enum { + GHOSTTY_GOTO_WINDOW_PREVIOUS, + GHOSTTY_GOTO_WINDOW_NEXT, +} ghostty_action_goto_window_e; + // apprt.action.ResizeSplit.Direction typedef enum { GHOSTTY_RESIZE_SPLIT_UP, diff --git a/src/Surface.zig b/src/Surface.zig index a3b306fef..69a390c2d 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5390,6 +5390,17 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, ), + .goto_window => |direction| return try self.rt_app.performAction( + .{ .surface = self }, + .goto_window, + switch (direction) { + inline else => |tag| @field( + apprt.action.GotoWindow, + @tagName(tag), + ), + }, + ), + .resize_split => |value| return try self.rt_app.performAction( .{ .surface = self }, .resize_split, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 608081a46..4bb590eee 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -129,6 +129,9 @@ pub const Action = union(Key) { /// Jump to a specific split. goto_split: GotoSplit, + /// Jump to next/previous window. + goto_window: GotoWindow, + /// Resize the split in the given direction. resize_split: ResizeSplit, @@ -335,6 +338,7 @@ pub const Action = union(Key) { move_tab, goto_tab, goto_split, + goto_window, resize_split, equalize_splits, toggle_split_zoom, @@ -474,6 +478,13 @@ pub const GotoSplit = enum(c_int) { right, }; +// This is made extern (c_int) to make interop easier with our embedded +// runtime. The small size cost doesn't make a difference in our union. +pub const GotoWindow = enum(c_int) { + previous, + next, +}; + /// The amount to resize the split by and the direction to resize it in. pub const ResizeSplit = extern struct { amount: u16, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index efca498b4..e53201c96 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -659,6 +659,8 @@ pub const Application = extern struct { .goto_split => return Action.gotoSplit(target, value), + .goto_window => return Action.gotoWindow(value), + .goto_tab => return Action.gotoTab(target, value), .initial_size => return Action.initialSize(target, value), @@ -2014,6 +2016,40 @@ const Action = struct { } } + pub fn gotoWindow( + direction: apprt.action.GotoWindow, + ) bool { + const glist = gtk.Window.listToplevels(); + defer glist.free(); + + const node = glist.findCustom(null, findActiveWindow); + + // Check based on direction if we are at beginning or end of window list to loop around + // else just go to next/previous window + switch(direction) { + .next => { + const next_node = node.f_next orelse glist; + + const window: *gtk.Window = @ptrCast(@alignCast(next_node.f_data orelse return false)); + gtk.Window.present(window); + return true; + }, + .previous => { + const prev_node = node.f_prev orelse last: { + var current = glist; + while (current.f_next) |next| { + current = next; + } + break :last current; + }; + const window: *gtk.Window = @ptrCast(@alignCast(prev_node.f_data orelse return false)); + gtk.Window.present(window); + return true; + }, + } + return false; + } + pub fn initialSize( target: apprt.Target, value: apprt.action.InitialSize, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index d368c48b2..0a927b85f 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -545,6 +545,10 @@ pub const Action = union(enum) { /// (`previous` and `next`). goto_split: SplitFocusDirection, + + /// Focus on either the previous window or the next one ('previous', 'next') + goto_window: WindowDirection, + /// Zoom in or out of the current split. /// /// When a split is zoomed into, it will take up the entire space in @@ -931,6 +935,11 @@ pub const Action = union(enum) { right, }; + pub const WindowDirection = enum { + previous, + next, + }; + pub const SplitResizeParameter = struct { SplitResizeDirection, u16, @@ -1250,6 +1259,7 @@ pub const Action = union(enum) { .toggle_tab_overview, .new_split, .goto_split, + .goto_window, .toggle_split_zoom, .toggle_readonly, .resize_split, diff --git a/src/input/command.zig b/src/input/command.zig index ce218718f..037b5317c 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -479,6 +479,20 @@ fn actionCommands(action: Action.Key) []const Command { }, }, + .goto_window => comptime &.{ + .{ + .action = .{ .goto_window = .previous }, + .title = "Focus Window: Previous", + .description = "Focus the previous window, if any.", + }, + .{ + .action = .{ .goto_window = .previous }, + .title = "Focus Window: Next", + .description = "Focus the next window, if any.", + }, + }, + + .toggle_split_zoom => comptime &.{.{ .action = .toggle_split_zoom, .title = "Toggle Split Zoom", From 3000136e6113c1c2f4b47f604803aaa6f76ca1a6 Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Thu, 23 Oct 2025 22:30:27 +0200 Subject: [PATCH 40/93] Changed switching previous/next to have no duplication --- src/apprt/gtk/class/application.zig | 65 +++++++++++++++-------------- 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index e53201c96..ed2044c4e 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -2016,37 +2016,40 @@ const Action = struct { } } - pub fn gotoWindow( - direction: apprt.action.GotoWindow, - ) bool { - const glist = gtk.Window.listToplevels(); - defer glist.free(); - - const node = glist.findCustom(null, findActiveWindow); - - // Check based on direction if we are at beginning or end of window list to loop around - // else just go to next/previous window - switch(direction) { - .next => { - const next_node = node.f_next orelse glist; - - const window: *gtk.Window = @ptrCast(@alignCast(next_node.f_data orelse return false)); - gtk.Window.present(window); - return true; - }, - .previous => { - const prev_node = node.f_prev orelse last: { - var current = glist; - while (current.f_next) |next| { - current = next; - } - break :last current; - }; - const window: *gtk.Window = @ptrCast(@alignCast(prev_node.f_data orelse return false)); - gtk.Window.present(window); - return true; - }, - } + pub fn gotoWindow( + direction: apprt.action.GotoWindow, + ) bool { + const glist = gtk.Window.listToplevels(); + defer glist.free(); + + const node = glist.findCustom(null, findActiveWindow); + + const target_node = switch (direction) { + .next => node.f_next orelse glist, + .previous => node.f_prev orelse last: { + var current = glist; + while (current.f_next) |next| { + current = next; + } + break :last current; + }, + }; + const gtk_window: *gtk.Window = @ptrCast(@alignCast(target_node.f_data orelse return false)); + gtk.Window.present(gtk_window); + + const ghostty_window: *Window = @ptrCast(@alignCast(gtk_window)); + var value = std.mem.zeroes(gobject.Value); + defer value.unset(); + _ = value.init(gobject.ext.typeFor(?*Surface)); + ghostty_window.as(gobject.Object).getProperty("active-surface", &value); + + const surface: ?*Surface = @ptrCast(@alignCast(value.getObject())); + if (surface) |s| { + s.grabFocus(); + return true; + } + + log.warn("window has no active surface, cannot grab focus", .{}); return false; } From 55ae4430b9fbf0c4556c07eb0f39649bbcc658ab Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Thu, 23 Oct 2025 23:33:27 +0200 Subject: [PATCH 41/93] Formatting --- src/apprt/action.zig | 2 +- src/apprt/gtk/class/application.zig | 68 ++++++++++++++--------------- src/input/Binding.zig | 3 +- src/input/command.zig | 1 - 4 files changed, 36 insertions(+), 38 deletions(-) diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 4bb590eee..af1c22552 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -129,7 +129,7 @@ pub const Action = union(Key) { /// Jump to a specific split. goto_split: GotoSplit, - /// Jump to next/previous window. + /// Jump to next/previous window. goto_window: GotoWindow, /// Resize the split in the given direction. diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index ed2044c4e..331fff4e9 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -2016,40 +2016,40 @@ const Action = struct { } } - pub fn gotoWindow( - direction: apprt.action.GotoWindow, - ) bool { - const glist = gtk.Window.listToplevels(); - defer glist.free(); - - const node = glist.findCustom(null, findActiveWindow); - - const target_node = switch (direction) { - .next => node.f_next orelse glist, - .previous => node.f_prev orelse last: { - var current = glist; - while (current.f_next) |next| { - current = next; - } - break :last current; - }, - }; - const gtk_window: *gtk.Window = @ptrCast(@alignCast(target_node.f_data orelse return false)); - gtk.Window.present(gtk_window); - - const ghostty_window: *Window = @ptrCast(@alignCast(gtk_window)); - var value = std.mem.zeroes(gobject.Value); - defer value.unset(); - _ = value.init(gobject.ext.typeFor(?*Surface)); - ghostty_window.as(gobject.Object).getProperty("active-surface", &value); - - const surface: ?*Surface = @ptrCast(@alignCast(value.getObject())); - if (surface) |s| { - s.grabFocus(); - return true; - } - - log.warn("window has no active surface, cannot grab focus", .{}); + pub fn gotoWindow( + direction: apprt.action.GotoWindow, + ) bool { + const glist = gtk.Window.listToplevels(); + defer glist.free(); + + const node = glist.findCustom(null, findActiveWindow); + + const target_node = switch (direction) { + .next => node.f_next orelse glist, + .previous => node.f_prev orelse last: { + var current = glist; + while (current.f_next) |next| { + current = next; + } + break :last current; + }, + }; + const gtk_window: *gtk.Window = @ptrCast(@alignCast(target_node.f_data orelse return false)); + gtk.Window.present(gtk_window); + + const ghostty_window: *Window = @ptrCast(@alignCast(gtk_window)); + var value = std.mem.zeroes(gobject.Value); + defer value.unset(); + _ = value.init(gobject.ext.typeFor(?*Surface)); + ghostty_window.as(gobject.Object).getProperty("active-surface", &value); + + const surface: ?*Surface = @ptrCast(@alignCast(value.getObject())); + if (surface) |s| { + s.grabFocus(); + return true; + } + + log.warn("window has no active surface, cannot grab focus", .{}); return false; } diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 0a927b85f..a3284c718 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -545,7 +545,6 @@ pub const Action = union(enum) { /// (`previous` and `next`). goto_split: SplitFocusDirection, - /// Focus on either the previous window or the next one ('previous', 'next') goto_window: WindowDirection, @@ -936,7 +935,7 @@ pub const Action = union(enum) { }; pub const WindowDirection = enum { - previous, + previous, next, }; diff --git a/src/input/command.zig b/src/input/command.zig index 037b5317c..deb6e8412 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -492,7 +492,6 @@ fn actionCommands(action: Action.Key) []const Command { }, }, - .toggle_split_zoom => comptime &.{.{ .action = .toggle_split_zoom, .title = "Toggle Split Zoom", From afbcfa9e3d4771cf3127cf4ce9ec7b19cec957da Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Fri, 24 Oct 2025 09:45:37 +0200 Subject: [PATCH 42/93] Added GOTO_WINDOW to actions --- src/input/Binding.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index a3284c718..31672bc1a 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -546,7 +546,7 @@ pub const Action = union(enum) { goto_split: SplitFocusDirection, /// Focus on either the previous window or the next one ('previous', 'next') - goto_window: WindowDirection, + goto_window: GotoWindow, /// Zoom in or out of the current split. /// @@ -934,7 +934,7 @@ pub const Action = union(enum) { right, }; - pub const WindowDirection = enum { + pub const GotoWindow = enum { previous, next, }; From 4f02e6c0965567ec8820732c8541fdfe1137ca59 Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Fri, 24 Oct 2025 14:10:20 +0200 Subject: [PATCH 43/93] Wrong action typo fix --- src/input/command.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/input/command.zig b/src/input/command.zig index deb6e8412..a377effa2 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -486,7 +486,7 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Focus the previous window, if any.", }, .{ - .action = .{ .goto_window = .previous }, + .action = .{ .goto_window = .next }, .title = "Focus Window: Next", .description = "Focus the next window, if any.", }, From b344c978d01651ac4efabb3ca185024843bc7150 Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Fri, 24 Oct 2025 15:26:17 +0200 Subject: [PATCH 44/93] Added GOTO_WINDOW to actions enum --- include/ghostty.h | 1 + 1 file changed, 1 insertion(+) diff --git a/include/ghostty.h b/include/ghostty.h index 82ac392e2..514e52c77 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -806,6 +806,7 @@ typedef enum { GHOSTTY_ACTION_MOVE_TAB, GHOSTTY_ACTION_GOTO_TAB, GHOSTTY_ACTION_GOTO_SPLIT, + GHOSTTY_ACTION_GOTO_WINDOW, GHOSTTY_ACTION_RESIZE_SPLIT, GHOSTTY_ACTION_EQUALIZE_SPLITS, GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM, From 6b8a7e1dd14bcd24b70cfced6976aed64afae194 Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Fri, 24 Oct 2025 15:32:43 +0200 Subject: [PATCH 45/93] Replaced direction switch, direclty handling next and previous now --- src/Surface.zig | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 69a390c2d..4ff25992a 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5394,10 +5394,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .{ .surface = self }, .goto_window, switch (direction) { - inline else => |tag| @field( - apprt.action.GotoWindow, - @tagName(tag), - ), + .next => apprt.action.GotoWindow.next, + .previous => apprt.action.GotoWindow.previous, }, ), From 6230d134e18942cef23ad4821036b70b3c7d0bc3 Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Fri, 24 Oct 2025 19:22:44 +0200 Subject: [PATCH 46/93] Type-safe rework --- src/apprt/gtk/class/application.zig | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 331fff4e9..0769a26df 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -2034,18 +2034,18 @@ const Action = struct { break :last current; }, }; - const gtk_window: *gtk.Window = @ptrCast(@alignCast(target_node.f_data orelse return false)); + const data = target_node.f_data orelse return false; + const gtk_window: *gtk.Window = @ptrCast(@alignCast(data)); gtk.Window.present(gtk_window); - const ghostty_window: *Window = @ptrCast(@alignCast(gtk_window)); - var value = std.mem.zeroes(gobject.Value); - defer value.unset(); - _ = value.init(gobject.ext.typeFor(?*Surface)); - ghostty_window.as(gobject.Object).getProperty("active-surface", &value); + const ghostty_window = gobject.ext.cast(Window, gtk_window) orelse return false; + + var surface: ?*gobject.Object = null; + ghostty_window.as(gobject.Object).get("active-surface", &surface, @as(?*anyopaque, null)); - const surface: ?*Surface = @ptrCast(@alignCast(value.getObject())); if (surface) |s| { - s.grabFocus(); + const surface_obj = gobject.ext.cast(Surface, s) orelse return false; + surface_obj.grabFocus(); return true; } From 7e0dc09873095546362b6c951da0420b4da3f6dc Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Fri, 24 Oct 2025 20:04:50 +0200 Subject: [PATCH 47/93] Just using decl literals --- src/Surface.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 4ff25992a..19c2662c1 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5394,8 +5394,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .{ .surface = self }, .goto_window, switch (direction) { - .next => apprt.action.GotoWindow.next, - .previous => apprt.action.GotoWindow.previous, + .previous => .previous, + .next => .next, }, ), From bb246b2e0c9dc3139c68684f37c0caf826c6b3e6 Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Tue, 28 Oct 2025 19:44:43 +0100 Subject: [PATCH 48/93] Added null handling for findCustom --- src/apprt/gtk/class/application.zig | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 0769a26df..5b264fcce 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -2022,18 +2022,19 @@ const Action = struct { const glist = gtk.Window.listToplevels(); defer glist.free(); - const node = glist.findCustom(null, findActiveWindow); + const node = @as(?*glib.List, glist.findCustom(null, findActiveWindow)); - const target_node = switch (direction) { - .next => node.f_next orelse glist, - .previous => node.f_prev orelse last: { + const target_node = if (node) |n| switch (direction) { + .next => n.f_next orelse glist, + .previous => n.f_prev orelse last: { var current = glist; while (current.f_next) |next| { current = next; } break :last current; }, - }; + } else glist; + const data = target_node.f_data orelse return false; const gtk_window: *gtk.Window = @ptrCast(@alignCast(data)); gtk.Window.present(gtk_window); From 4c2fb7ae0ebbbe28fd21f2d5fc4ee96bea168af7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Dec 2025 13:51:16 -0800 Subject: [PATCH 49/93] Update mirror for direct deps --- build.zig.zon | 12 ++-- build.zig.zon.bak | 124 ++++++++++++++++++++++++++++++++++++++ build.zig.zon.json | 12 ++-- build.zig.zon.nix | 12 ++-- build.zig.zon.txt | 12 ++-- flatpak/zig-packages.json | 12 ++-- 6 files changed, 154 insertions(+), 30 deletions(-) create mode 100644 build.zig.zon.bak diff --git a/build.zig.zon b/build.zig.zon index 191ae7fa9..79c8c69c3 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -15,13 +15,13 @@ }, .vaxis = .{ // rockorager/libvaxis - .url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", + .url = "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", .hash = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS", .lazy = true, }, .z2d = .{ // vancluever/z2d - .url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", + .url = "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz", .hash = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T", .lazy = true, }, @@ -39,7 +39,7 @@ }, .uucode = .{ // jacobsandlund/uucode - .url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + .url = "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", .hash = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", }, .zig_wayland = .{ @@ -50,14 +50,14 @@ }, .zf = .{ // natecraddock/zf - .url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", + .url = "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", .hash = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh", .lazy = true, }, .gobject = .{ // https://github.com/ghostty-org/zig-gobject based on zig_gobject // Temporary until we generate them at build time automatically. - .url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", + .url = "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst", .hash = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", .lazy = true, }, @@ -116,7 +116,7 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + .url = "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz", .hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", .lazy = true, }, diff --git a/build.zig.zon.bak b/build.zig.zon.bak new file mode 100644 index 000000000..191ae7fa9 --- /dev/null +++ b/build.zig.zon.bak @@ -0,0 +1,124 @@ +.{ + .name = .ghostty, + .version = "1.3.0-dev", + .paths = .{""}, + .fingerprint = 0x64407a2a0b4147e5, + .minimum_zig_version = "0.15.2", + .dependencies = .{ + // Zig libs + + .libxev = .{ + // mitchellh/libxev + .url = "https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz", + .hash = "libxev-0.0.0-86vtc4IcEwCqEYxEYoN_3KXmc6A9VLcm22aVImfvecYs", + .lazy = true, + }, + .vaxis = .{ + // rockorager/libvaxis + .url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", + .hash = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS", + .lazy = true, + }, + .z2d = .{ + // vancluever/z2d + .url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", + .hash = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T", + .lazy = true, + }, + .zig_objc = .{ + // mitchellh/zig-objc + .url = "https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz", + .hash = "zig_objc-0.0.0-Ir_Sp5gTAQCvxxR7oVIrPXxXwsfKgVP7_wqoOQrZjFeK", + .lazy = true, + }, + .zig_js = .{ + // mitchellh/zig-js + .url = "https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz", + .hash = "zig_js-0.0.0-rjCAV-6GAADxFug7rDmPH-uM_XcnJ5NmuAMJCAscMjhi", + .lazy = true, + }, + .uucode = .{ + // jacobsandlund/uucode + .url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + .hash = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", + }, + .zig_wayland = .{ + // codeberg ifreund/zig-wayland + .url = "https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz", + .hash = "wayland-0.5.0-dev-lQa1khrMAQDJDwYFKpdH3HizherB7sHo5dKMECfvxQHe", + .lazy = true, + }, + .zf = .{ + // natecraddock/zf + .url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", + .hash = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh", + .lazy = true, + }, + .gobject = .{ + // https://github.com/ghostty-org/zig-gobject based on zig_gobject + // Temporary until we generate them at build time automatically. + .url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", + .hash = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", + .lazy = true, + }, + + // C libs + .cimgui = .{ .path = "./pkg/cimgui", .lazy = true }, + .fontconfig = .{ .path = "./pkg/fontconfig", .lazy = true }, + .freetype = .{ .path = "./pkg/freetype", .lazy = true }, + .gtk4_layer_shell = .{ .path = "./pkg/gtk4-layer-shell", .lazy = true }, + .harfbuzz = .{ .path = "./pkg/harfbuzz", .lazy = true }, + .highway = .{ .path = "./pkg/highway", .lazy = true }, + .libintl = .{ .path = "./pkg/libintl", .lazy = true }, + .libpng = .{ .path = "./pkg/libpng", .lazy = true }, + .macos = .{ .path = "./pkg/macos", .lazy = true }, + .oniguruma = .{ .path = "./pkg/oniguruma", .lazy = true }, + .opengl = .{ .path = "./pkg/opengl", .lazy = true }, + .sentry = .{ .path = "./pkg/sentry", .lazy = true }, + .simdutf = .{ .path = "./pkg/simdutf", .lazy = true }, + .utfcpp = .{ .path = "./pkg/utfcpp", .lazy = true }, + .wuffs = .{ .path = "./pkg/wuffs", .lazy = true }, + .zlib = .{ .path = "./pkg/zlib", .lazy = true }, + + // Shader translation + .glslang = .{ .path = "./pkg/glslang", .lazy = true }, + .spirv_cross = .{ .path = "./pkg/spirv-cross", .lazy = true }, + + // Wayland + .wayland = .{ + .url = "https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz", + .hash = "N-V-__8AAKrHGAAs2shYq8UkE6bGcR1QJtLTyOE_lcosMn6t", + .lazy = true, + }, + .wayland_protocols = .{ + .url = "https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz", + .hash = "N-V-__8AAKw-DAAaV8bOAAGqA0-oD7o-HNIlPFYKRXSPT03S", + .lazy = true, + }, + .plasma_wayland_protocols = .{ + .url = "https://deps.files.ghostty.org/plasma_wayland_protocols-12207e0851c12acdeee0991e893e0132fc87bb763969a585dc16ecca33e88334c566.tar.gz", + .hash = "N-V-__8AAKYZBAB-CFHBKs3u4JkeiT4BMvyHu3Y5aaWF3Bbs", + .lazy = true, + }, + + // Fonts + .jetbrains_mono = .{ + .url = "https://deps.files.ghostty.org/JetBrainsMono-2.304.tar.gz", + .hash = "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x", + .lazy = true, + }, + .nerd_fonts_symbols_only = .{ + .url = "https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz", + .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO26s", + .lazy = true, + }, + + // Other + .apple_sdk = .{ .path = "./pkg/apple-sdk" }, + .iterm2_themes = .{ + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + .hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", + .lazy = true, + }, + }, +} diff --git a/build.zig.zon.json b/build.zig.zon.json index e4171834d..cd807e67a 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -26,7 +26,7 @@ }, "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-": { "name": "gobject", - "url": "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", + "url": "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst", "hash": "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg=" }, "N-V-__8AALiNBAA-_0gprYr92CjrMj1I5bqNu0TSJOnjFNSr": { @@ -51,7 +51,7 @@ }, "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + "url": "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz", "hash": "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { @@ -116,12 +116,12 @@ }, "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E": { "name": "uucode", - "url": "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + "url": "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", "hash": "sha256-SzpYGhgG4B6Luf8eT35sKLobCxjmwEuo1Twk0jeu9g4=" }, "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS": { "name": "vaxis", - "url": "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", + "url": "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", "hash": "sha256-LnIzK8icW1Qexua9SHaeHz+3V8QAbz0a+UC1T5sIjvY=" }, "N-V-__8AAKrHGAAs2shYq8UkE6bGcR1QJtLTyOE_lcosMn6t": { @@ -141,12 +141,12 @@ }, "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T": { "name": "z2d", - "url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", + "url": "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz", "hash": "sha256-+QqCRoXwrFA1/l+oWvYVyAVebGQitAFQNhi9U3EVrxA=" }, "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh": { "name": "zf", - "url": "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", + "url": "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", "hash": "sha256-OwFdkorwTp4mJyvBXrTbtNmp1GnrbSkKDdrmc7d8RWg=" }, "zig_js-0.0.0-rjCAV-6GAADxFug7rDmPH-uM_XcnJ5NmuAMJCAscMjhi": { diff --git a/build.zig.zon.nix b/build.zig.zon.nix index c0f923145..e95b26960 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -126,7 +126,7 @@ in name = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-"; path = fetchZigArtifact { name = "gobject"; - url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst"; + url = "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst"; hash = "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg="; }; } @@ -166,7 +166,7 @@ in name = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz"; + url = "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz"; hash = "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk="; }; } @@ -270,7 +270,7 @@ in name = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E"; path = fetchZigArtifact { name = "uucode"; - url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz"; + url = "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz"; hash = "sha256-SzpYGhgG4B6Luf8eT35sKLobCxjmwEuo1Twk0jeu9g4="; }; } @@ -278,7 +278,7 @@ in name = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS"; path = fetchZigArtifact { name = "vaxis"; - url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz"; + url = "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz"; hash = "sha256-LnIzK8icW1Qexua9SHaeHz+3V8QAbz0a+UC1T5sIjvY="; }; } @@ -310,7 +310,7 @@ in name = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T"; path = fetchZigArtifact { name = "z2d"; - url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz"; + url = "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz"; hash = "sha256-+QqCRoXwrFA1/l+oWvYVyAVebGQitAFQNhi9U3EVrxA="; }; } @@ -318,7 +318,7 @@ in name = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh"; path = fetchZigArtifact { name = "zf"; - url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz"; + url = "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz"; hash = "sha256-OwFdkorwTp4mJyvBXrTbtNmp1GnrbSkKDdrmc7d8RWg="; }; } diff --git a/build.zig.zon.txt b/build.zig.zon.txt index ceeb3aa3d..33a90a906 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -6,10 +6,12 @@ https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz https://deps.files.ghostty.org/gettext-0.24.tar.gz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz +https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz +https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz https://deps.files.ghostty.org/libpng-1220aa013f0c83da3fb64ea6d327f9173fa008d10e28bc9349eac3463457723b1c66.tar.gz https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz https://deps.files.ghostty.org/libxml2-2.11.5.tar.gz @@ -19,17 +21,15 @@ https://deps.files.ghostty.org/plasma_wayland_protocols-12207e0851c12acdeee0991e https://deps.files.ghostty.org/sentry-1220446be831adcca918167647c06c7b825849fa3fba5f22da394667974537a9c77e.tar.gz https://deps.files.ghostty.org/spirv_cross-1220fb3b5586e8be67bc3feb34cbe749cf42a60d628d2953632c2f8141302748c8da.tar.gz https://deps.files.ghostty.org/utfcpp-1220d4d18426ca72fc2b7e56ce47273149815501d0d2395c2a98c726b31ba931e641.tar.gz +https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz +https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz +https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz +https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz -https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz -https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz -https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz -https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz -https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz -https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index a6d431c8e..ddb6075b7 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -31,7 +31,7 @@ }, { "type": "archive", - "url": "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", + "url": "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst", "dest": "vendor/p/gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", "sha256": "d9bd4306f0081d8e4b848b6adfeabd2fab49822ee2b679eb4801dcedf5d60c48" }, @@ -61,7 +61,7 @@ }, { "type": "archive", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + "url": "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz", "dest": "vendor/p/N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", "sha256": "e5078f050952b33f56dabe334d2dd00fe70301ec8e6e1479d44d80909dd92149" }, @@ -139,13 +139,13 @@ }, { "type": "archive", - "url": "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + "url": "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", "dest": "vendor/p/uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", "sha256": "4b3a581a1806e01e8bb9ff1e4f7e6c28ba1b0b18e6c04ba8d53c24d237aef60e" }, { "type": "archive", - "url": "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", + "url": "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", "dest": "vendor/p/vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS", "sha256": "2e72332bc89c5b541ec6e6bd48769e1f3fb757c4006f3d1af940b54f9b088ef6" }, @@ -169,13 +169,13 @@ }, { "type": "archive", - "url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", + "url": "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz", "dest": "vendor/p/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T", "sha256": "f90a824685f0ac5035fe5fa85af615c8055e6c6422b401503618bd537115af10" }, { "type": "archive", - "url": "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", + "url": "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", "dest": "vendor/p/zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh", "sha256": "3b015d928af04e9e26272bc15eb4dbb4d9a9d469eb6d290a0ddae673b77c4568" }, From dfb94cd55d404f81d34708130cf423dae6cc37b5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Dec 2025 14:17:55 -0800 Subject: [PATCH 50/93] apprt/gtk: clean up gotoWindow --- src/apprt/gtk/class/application.zig | 83 +++++++++++++++++++---------- src/apprt/gtk/class/window.zig | 2 +- 2 files changed, 55 insertions(+), 30 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 5b264fcce..d404304d0 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -2016,44 +2016,69 @@ const Action = struct { } } - pub fn gotoWindow( - direction: apprt.action.GotoWindow, - ) bool { + pub fn gotoWindow(direction: apprt.action.GotoWindow) bool { const glist = gtk.Window.listToplevels(); defer glist.free(); - const node = @as(?*glib.List, glist.findCustom(null, findActiveWindow)); + // The window we're starting from is typically our active window. + const starting: *glib.List = @as(?*glib.List, glist.findCustom( + null, + findActiveWindow, + )) orelse glist; - const target_node = if (node) |n| switch (direction) { - .next => n.f_next orelse glist, - .previous => n.f_prev orelse last: { - var current = glist; - while (current.f_next) |next| { - current = next; - } - break :last current; - }, - } else glist; - - const data = target_node.f_data orelse return false; - const gtk_window: *gtk.Window = @ptrCast(@alignCast(data)); - gtk.Window.present(gtk_window); - - const ghostty_window = gobject.ext.cast(Window, gtk_window) orelse return false; - - var surface: ?*gobject.Object = null; - ghostty_window.as(gobject.Object).get("active-surface", &surface, @as(?*anyopaque, null)); - - if (surface) |s| { - const surface_obj = gobject.ext.cast(Surface, s) orelse return false; - surface_obj.grabFocus(); - return true; + // Go forward or backwards in the list until we find a valid + // window that is visible. + var current_: ?*glib.List = starting; + while (current_) |node| : (current_ = switch (direction) { + .next => node.f_next, + .previous => node.f_prev, + }) { + const data = node.f_data orelse continue; + const gtk_window: *gtk.Window = @ptrCast(@alignCast(data)); + if (gotoWindowMaybe(gtk_window)) return true; + } + + // If we reached here, we didn't find a valid window to focus. + // Wrap around. + current_ = switch (direction) { + .next => glist, + .previous => last: { + var end: *glib.List = glist; + while (end.f_next) |next| end = next; + break :last end; + }, + }; + while (current_) |node| : (current_ = switch (direction) { + .next => node.f_next, + .previous => node.f_prev, + }) { + if (current_ == starting) break; + const data = node.f_data orelse continue; + const gtk_window: *gtk.Window = @ptrCast(@alignCast(data)); + if (gotoWindowMaybe(gtk_window)) return true; } - log.warn("window has no active surface, cannot grab focus", .{}); return false; } + fn gotoWindowMaybe(gtk_window: *gtk.Window) bool { + // If it is already active skip it. + if (gtk_window.isActive() != 0) return false; + // If it is hidden, skip it. + if (gtk_window.as(gtk.Widget).isVisible() == 0) return false; + // If it isn't a Ghostty window, skip it. + const window = gobject.ext.cast( + Window, + gtk_window, + ) orelse return false; + + // Focus our active surface + const surface = window.getActiveSurface() orelse return false; + gtk.Window.present(gtk_window); + surface.grabFocus(); + return true; + } + pub fn initialSize( target: apprt.Target, value: apprt.action.InitialSize, diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index c691b84a6..77fd2eea5 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -793,7 +793,7 @@ pub const Window = extern struct { /// Get the currently active surface. See the "active-surface" property. /// This does not ref the value. - fn getActiveSurface(self: *Self) ?*Surface { + pub fn getActiveSurface(self: *Self) ?*Surface { const tab = self.getSelectedTab() orelse return null; return tab.getActiveSurface(); } From 1a117c46e03f863a383dc9833f9950cf95bf59e4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Dec 2025 14:29:09 -0800 Subject: [PATCH 51/93] macos: fix missing goto_window union entry --- include/ghostty.h | 1 + 1 file changed, 1 insertion(+) diff --git a/include/ghostty.h b/include/ghostty.h index 514e52c77..b0395b89e 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -859,6 +859,7 @@ typedef union { ghostty_action_move_tab_s move_tab; ghostty_action_goto_tab_e goto_tab; ghostty_action_goto_split_e goto_split; + ghostty_action_goto_window_e goto_window; ghostty_action_resize_split_s resize_split; ghostty_action_size_limit_s size_limit; ghostty_action_initial_size_s initial_size; From 05ee9ae733f216408045d1f0d1a806412508be81 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Dec 2025 14:33:15 -0800 Subject: [PATCH 52/93] macos: implement goto_window:next/previousu --- macos/Sources/Ghostty/Ghostty.App.swift | 61 +++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 4788a4376..2cd0a362a 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -501,6 +501,9 @@ extension Ghostty { case GHOSTTY_ACTION_GOTO_SPLIT: return gotoSplit(app, target: target, direction: action.action.goto_split) + case GHOSTTY_ACTION_GOTO_WINDOW: + return gotoWindow(app, target: target, direction: action.action.goto_window) + case GHOSTTY_ACTION_RESIZE_SPLIT: resizeSplit(app, target: target, resize: action.action.resize_split) @@ -1149,6 +1152,64 @@ extension Ghostty { } } + private static func gotoWindow( + _ app: ghostty_app_t, + target: ghostty_target_s, + direction: ghostty_action_goto_window_e + ) -> Bool { + // Collect candidate windows: visible terminal windows that are either + // standalone or the currently selected tab in their tab group. This + // treats each native tab group as a single "window" for navigation + // purposes, since goto_tab handles per-tab navigation. + let candidates: [NSWindow] = NSApplication.shared.windows.filter { window in + guard window.windowController is BaseTerminalController else { return false } + guard window.isVisible, !window.isMiniaturized else { return false } + // For native tabs, only include the selected tab in each group + if let group = window.tabGroup, group.selectedWindow !== window { + return false + } + return true + } + + // Need at least two windows to navigate between + guard candidates.count > 1 else { return false } + + // Find starting index from the current key/main window + let startIndex = candidates.firstIndex(where: { $0.isKeyWindow }) + ?? candidates.firstIndex(where: { $0.isMainWindow }) + ?? 0 + + let step: Int + switch direction { + case GHOSTTY_GOTO_WINDOW_NEXT: + step = 1 + case GHOSTTY_GOTO_WINDOW_PREVIOUS: + step = -1 + default: + return false + } + + // Iterate with wrap-around until we find a valid window or return to start + let count = candidates.count + var index = (startIndex + step + count) % count + + while index != startIndex { + let candidate = candidates[index] + if candidate.isVisible, !candidate.isMiniaturized { + candidate.makeKeyAndOrderFront(nil) + // Also focus the terminal surface within the window + if let controller = candidate.windowController as? BaseTerminalController, + let surface = controller.focusedSurface { + Ghostty.moveFocus(to: surface) + } + return true + } + index = (index + step + count) % count + } + + return false + } + private static func resizeSplit( _ app: ghostty_app_t, target: ghostty_target_s, From 3d5d170f8b81be29316395507cc977d44ec6851c Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 14 Dec 2025 00:15:58 +0000 Subject: [PATCH 53/93] deps: Update iTerm2 color schemes --- build.zig.zon | 2 +- build.zig.zon.json | 2 +- build.zig.zon.nix | 2 +- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 79c8c69c3..271428778 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -116,7 +116,7 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", .hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", .lazy = true, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index cd807e67a..c9a64ca5f 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -51,7 +51,7 @@ }, "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-": { "name": "iterm2_themes", - "url": "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz", + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", "hash": "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { diff --git a/build.zig.zon.nix b/build.zig.zon.nix index e95b26960..43a8efe46 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -166,7 +166,7 @@ in name = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz"; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz"; hash = "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk="; }; } diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 33a90a906..24a2978d6 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -11,7 +11,6 @@ https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz -https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz https://deps.files.ghostty.org/libpng-1220aa013f0c83da3fb64ea6d327f9173fa008d10e28bc9349eac3463457723b1c66.tar.gz https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz https://deps.files.ghostty.org/libxml2-2.11.5.tar.gz @@ -33,3 +32,4 @@ https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz +https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index ddb6075b7..21f79ec04 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -61,7 +61,7 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz", + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", "dest": "vendor/p/N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", "sha256": "e5078f050952b33f56dabe334d2dd00fe70301ec8e6e1479d44d80909dd92149" }, From 786dc9343876a28bd76f9979c59f574202b6be81 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 14 Dec 2025 16:24:50 -0500 Subject: [PATCH 54/93] macos: populate the sparkle:channel element This makes the update channel name available alongside the version, data, etc., which we can use in our update view (on the Released line). --- dist/macos/update_appcast_tag.py | 2 ++ dist/macos/update_appcast_tip.py | 2 ++ .../Sources/Features/Update/UpdatePopoverView.swift | 12 ++++++++++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/dist/macos/update_appcast_tag.py b/dist/macos/update_appcast_tag.py index 2cb20dd5d..8c2ee8314 100644 --- a/dist/macos/update_appcast_tag.py +++ b/dist/macos/update_appcast_tag.py @@ -77,6 +77,8 @@ elem = ET.SubElement(item, "title") elem.text = f"Build {build}" elem = ET.SubElement(item, "pubDate") elem.text = now.strftime(pubdate_format) +elem = ET.SubElement(item, "sparkle:channel") +elem.text = "stable" elem = ET.SubElement(item, "sparkle:version") elem.text = build elem = ET.SubElement(item, "sparkle:shortVersionString") diff --git a/dist/macos/update_appcast_tip.py b/dist/macos/update_appcast_tip.py index ff1fb4be5..1876f0a17 100644 --- a/dist/macos/update_appcast_tip.py +++ b/dist/macos/update_appcast_tip.py @@ -75,6 +75,8 @@ elem = ET.SubElement(item, "title") elem.text = f"Build {build}" elem = ET.SubElement(item, "pubDate") elem.text = now.strftime(pubdate_format) +elem = ET.SubElement(item, "sparkle:channel") +elem.text = "tip" elem = ET.SubElement(item, "sparkle:version") elem.text = build elem = ET.SubElement(item, "sparkle:shortVersionString") diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index 87d76f801..2c56e5f4e 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -125,7 +125,15 @@ fileprivate struct UpdateAvailableView: View { let dismiss: DismissAction private let labelWidth: CGFloat = 60 - + + private func releaseDateString(date: Date, channel: String?) -> String { + let dateString = date.formatted(date: .abbreviated, time: .omitted) + if let channel, !channel.isEmpty { + return "\(dateString) (\(channel))" + } + return dateString + } + var body: some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 12) { @@ -157,7 +165,7 @@ fileprivate struct UpdateAvailableView: View { Text("Released:") .foregroundColor(.secondary) .frame(width: labelWidth, alignment: .trailing) - Text(date.formatted(date: .abbreviated, time: .omitted)) + Text(releaseDateString(date: date, channel: update.appcastItem.channel)) } .font(.system(size: 11)) } From 1fdc0c0b9f84f95abda54cffc8af1780fa6928ca Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 14 Dec 2025 13:58:02 -0800 Subject: [PATCH 55/93] terminal: CSI S compatiblity improvements Fixes #9905 This fixes a major compatibility issues with the CSI S sequence: When our top margin is at the top (row 0) without left/right margins, we should be creating scrollback. Previously, we were only deleting. --- src/terminal/Terminal.zig | 195 ++++++++++++++++++++++++++++--- src/terminal/stream_readonly.zig | 2 +- src/termio/stream_handler.zig | 2 +- 3 files changed, 182 insertions(+), 17 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 6c9db6a8d..3d00abf74 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1219,7 +1219,7 @@ pub fn index(self: *Terminal) !void { // this check. !self.screens.active.blankCell().isZero()) { - self.scrollUp(1); + try self.scrollUp(1); return; } @@ -1398,7 +1398,7 @@ pub fn scrollDown(self: *Terminal, count: usize) void { /// The new lines are created according to the current SGR state. /// /// Does not change the (absolute) cursor position. -pub fn scrollUp(self: *Terminal, count: usize) void { +pub fn scrollUp(self: *Terminal, count: usize) !void { // Preserve our x/y to restore. const old_x = self.screens.active.cursor.x; const old_y = self.screens.active.cursor.y; @@ -1408,6 +1408,32 @@ pub fn scrollUp(self: *Terminal, count: usize) void { self.screens.active.cursor.pending_wrap = old_wrap; } + // If our scroll region is at the top and we have no left/right + // margins then we move the scrolled out text into the scrollback. + if (self.scrolling_region.top == 0 and + self.scrolling_region.left == 0 and + self.scrolling_region.right == self.cols - 1) + { + // Scrolling dirties the images because it updates their placements pins. + if (comptime build_options.kitty_graphics) { + self.screens.active.kitty_images.dirty = true; + } + + // Clamp count to the scroll region height. + const region_height = self.scrolling_region.bottom + 1; + const adjusted_count = @min(count, region_height); + + // TODO: Create an optimized version that can scroll N times + // This isn't critical because in most cases, scrollUp is used + // with count=1, but it's still a big optimization opportunity. + + // Move our cursor to the bottom of the scroll region so we can + // use the cursorScrollAbove function to create scrollback + self.screens.active.cursorAbsolute(0, self.scrolling_region.bottom); + for (0..adjusted_count) |_| try self.screens.active.cursorScrollAbove(); + return; + } + // Move to the top of the scroll region self.screens.active.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top); self.deleteLines(count); @@ -5635,14 +5661,16 @@ test "Terminal: scrollUp simple" { t.setCursorPos(2, 2); const cursor = t.screens.active.cursor; - t.clearDirty(); - t.scrollUp(1); + const viewport_before = t.screens.active.pages.getTopLeft(.viewport); + try t.scrollUp(1); try testing.expectEqual(cursor.x, t.screens.active.cursor.x); try testing.expectEqual(cursor.y, t.screens.active.cursor.y); - try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); - try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); - try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + // Viewport should have moved. Our entire page should've scrolled! + // The viewport moving will cause our render state to make the full + // frame as dirty. + const viewport_after = t.screens.active.pages.getTopLeft(.viewport); + try testing.expect(!viewport_before.eql(viewport_after)); { const str = try t.plainString(testing.allocator); @@ -5666,7 +5694,7 @@ test "Terminal: scrollUp moves hyperlink" { try t.linefeed(); try t.printString("GHI"); t.setCursorPos(2, 2); - t.scrollUp(1); + try t.scrollUp(1); { const str = try t.plainString(testing.allocator); @@ -5717,7 +5745,7 @@ test "Terminal: scrollUp clears hyperlink" { try t.linefeed(); try t.printString("GHI"); t.setCursorPos(2, 2); - t.scrollUp(1); + try t.scrollUp(1); { const str = try t.plainString(testing.allocator); @@ -5755,7 +5783,7 @@ test "Terminal: scrollUp top/bottom scroll region" { t.setCursorPos(1, 1); t.clearDirty(); - t.scrollUp(1); + try t.scrollUp(1); // This is dirty because the cursor moves from this row try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); @@ -5787,7 +5815,7 @@ test "Terminal: scrollUp left/right scroll region" { const cursor = t.screens.active.cursor; t.clearDirty(); - t.scrollUp(1); + try t.scrollUp(1); try testing.expectEqual(cursor.x, t.screens.active.cursor.x); try testing.expectEqual(cursor.y, t.screens.active.cursor.y); @@ -5819,7 +5847,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { t.scrolling_region.left = 1; t.scrolling_region.right = 3; t.setCursorPos(2, 2); - t.scrollUp(1); + try t.scrollUp(1); { const str = try t.plainString(testing.allocator); @@ -5919,7 +5947,7 @@ test "Terminal: scrollUp preserves pending wrap" { try t.print('B'); t.setCursorPos(3, 5); try t.print('C'); - t.scrollUp(1); + try t.scrollUp(1); try t.print('X'); { @@ -5940,7 +5968,7 @@ test "Terminal: scrollUp full top/bottom region" { t.setTopAndBottomMargin(2, 5); t.clearDirty(); - t.scrollUp(4); + try t.scrollUp(4); // This is dirty because the cursor moves from this row try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); @@ -5966,7 +5994,7 @@ test "Terminal: scrollUp full top/bottomleft/right scroll region" { t.setLeftAndRightMargin(2, 4); t.clearDirty(); - t.scrollUp(4); + try t.scrollUp(4); // This is dirty because the cursor moves from this row try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); @@ -5982,6 +6010,143 @@ test "Terminal: scrollUp full top/bottomleft/right scroll region" { } } +test "Terminal: scrollUp creates scrollback in primary screen" { + // When in primary screen with full-width scroll region at top, + // scrollUp (CSI S) should push lines into scrollback like xterm. + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 10 }); + defer t.deinit(alloc); + + // Fill the screen with content + try t.printString("AAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("BBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DDDDD"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("EEEEE"); + + t.clearDirty(); + + // Scroll up by 1, which should push "AAAAA" into scrollback + try t.scrollUp(1); + + // The cursor row (new empty row) should be dirty + try testing.expect(t.screens.active.cursor.page_row.dirty); + + // The active screen should now show BBBBB through EEEEE plus one blank line + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("BBBBB\nCCCCC\nDDDDD\nEEEEE", str); + } + + // Now scroll to the top to see scrollback - AAAAA should be there + t.screens.active.scroll(.{ .top = {} }); + { + const str = try t.plainString(alloc); + defer alloc.free(str); + // Should see AAAAA in scrollback + try testing.expectEqualStrings("AAAAA\nBBBBB\nCCCCC\nDDDDD\nEEEEE", str); + } +} + +test "Terminal: scrollUp with max_scrollback zero" { + // When max_scrollback is 0, scrollUp should still work but not retain history + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 }); + defer t.deinit(alloc); + + try t.printString("AAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("BBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCC"); + + try t.scrollUp(1); + + // Active screen should show scrolled content + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("BBBBB\nCCCCC", str); + } + + // Scroll to top - should be same as active since no scrollback + t.screens.active.scroll(.{ .top = {} }); + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("BBBBB\nCCCCC", str); + } +} + +test "Terminal: scrollUp with max_scrollback zero and top margin" { + // When max_scrollback is 0 and top margin is set, should use deleteLines path + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 }); + defer t.deinit(alloc); + + try t.printString("AAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("BBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DDDDD"); + + // Set top margin (not at row 0) + t.setTopAndBottomMargin(2, 5); + + try t.scrollUp(1); + + { + const str = try t.plainString(alloc); + defer alloc.free(str); + // First row preserved, rest scrolled + try testing.expectEqualStrings("AAAAA\nCCCCC\nDDDDD", str); + } +} + +test "Terminal: scrollUp with max_scrollback zero and left/right margin" { + // When max_scrollback is 0 with left/right margins, uses deleteLines path + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 10, .max_scrollback = 0 }); + defer t.deinit(alloc); + + try t.printString("AAAAABBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCCDDDDD"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("EEEEEFFFFF"); + + // Set left/right margins (columns 2-6, 1-indexed = indices 1-5) + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(2, 6); + + try t.scrollUp(1); + + { + const str = try t.plainString(alloc); + defer alloc.free(str); + // cols 1-5 scroll, col 0 and cols 6+ preserved + try testing.expectEqualStrings("ACCCCDBBBB\nCEEEEFDDDD\nE FFFF", str); + } +} + test "Terminal: scrollDown simple" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 3b088e2b7..c33dba1bb 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -100,7 +100,7 @@ pub const Handler = struct { .insert_lines => self.terminal.insertLines(value), .insert_blanks => self.terminal.insertBlanks(value), .delete_lines => self.terminal.deleteLines(value), - .scroll_up => self.terminal.scrollUp(value), + .scroll_up => try self.terminal.scrollUp(value), .scroll_down => self.terminal.scrollDown(value), .horizontal_tab => try self.horizontalTab(value), .horizontal_tab_back => try self.horizontalTabBack(value), diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index eabfd6a4b..182770339 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -246,7 +246,7 @@ pub const StreamHandler = struct { .insert_lines => self.terminal.insertLines(value), .insert_blanks => self.terminal.insertBlanks(value), .delete_lines => self.terminal.deleteLines(value), - .scroll_up => self.terminal.scrollUp(value), + .scroll_up => try self.terminal.scrollUp(value), .scroll_down => self.terminal.scrollDown(value), .tab_clear_current => self.terminal.tabClear(.current), .tab_clear_all => self.terminal.tabClear(.all), From bbda6c35e3c3acd5f87a26a0ba3e7c5f5efeb74f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 00:05:17 +0000 Subject: [PATCH 56/93] build(deps): bump actions/download-artifact from 6.0.0 to 7.0.0 Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 6.0.0 to 7.0.0. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/018cc2cf5baa6db3ef3c5f8a56943fffe632ef53...37930b1c2abaa49bbe596cd826c3c89aef350131) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release-tag.yml | 10 +++++----- .github/workflows/snap.yml | 2 +- .github/workflows/test.yml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index c8c0fbf66..a25b8659d 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -286,7 +286,7 @@ jobs: curl -sL https://sentry.io/get-cli/ | bash - name: Download macOS Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: macos @@ -309,7 +309,7 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Download macOS Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: macos @@ -357,17 +357,17 @@ jobs: GHOSTTY_VERSION: ${{ needs.setup.outputs.version }} steps: - name: Download macOS Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: macos - name: Download Sparkle Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: sparkle - name: Download Source Tarball Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: source-tarball diff --git a/.github/workflows/snap.yml b/.github/workflows/snap.yml index 641bbcca6..6fc7e0fb4 100644 --- a/.github/workflows/snap.yml +++ b/.github/workflows/snap.yml @@ -26,7 +26,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Download Source Tarball Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: run-id: ${{ inputs.source-run-id }} artifact-ids: ${{ inputs.source-artifact-id }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 18af9d909..d959fd6b4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1075,7 +1075,7 @@ jobs: uses: namespacelabs/nscloud-setup-buildx-action@91c2e6537780e3b092cb8476406be99a8f91bd5e # v0.0.20 - name: Download Source Tarball Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: source-tarball From 7e5683ebfd780808347d0e3c76ae95dfe0375983 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 00:05:24 +0000 Subject: [PATCH 57/93] build(deps): bump actions/upload-artifact from 5.0.0 to 6.0.0 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5.0.0 to 6.0.0. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/330a01c490aca151604b8cf639adc76d48f6c5d4...b7c566a772e6b6bfb58ed0dc250532a479d7789f) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release-tag.yml | 6 +++--- .github/workflows/test.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index c8c0fbf66..5adb90ea0 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -113,7 +113,7 @@ jobs: nix develop -c minisign -S -m "ghostty-source.tar.gz" -s minisign.key < minisign.password - name: Upload artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: source-tarball path: |- @@ -269,7 +269,7 @@ jobs: zip -9 -r --symlinks ../../../ghostty-macos-universal-dsym.zip Ghostty.app.dSYM/ - name: Upload artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: macos path: |- @@ -340,7 +340,7 @@ jobs: mv appcast_new.xml appcast.xml - name: Upload artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: sparkle path: |- diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 18af9d909..9720eb345 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -397,7 +397,7 @@ jobs: - name: Upload artifact id: upload-artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: source-tarball path: |- From b0c053cfb7a69e81da2f0eb9e411db31bd11c1b0 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 14 Dec 2025 19:21:45 -0500 Subject: [PATCH 58/93] zsh: document unsupported system-level ZDOTDIR We rely on temporarily setting ZDOTDIR to our `zsh` resource directory to implement automatic shell integration. Setting ZDOTDIR in a system file like /etc/zshenv overrides our ZDOTDIR value, preventing our shell integration from being loaded. The only way to prevent /etc/zshenv from being run is via the --no-rcs flag. (The --no-globalrcs only applies to system-level files _after_ /etc/zshenv is loaded.) Unfortunately, there doesn't appear to be a way to run a "bootstrap" script (to reimplement the Zsh startup sequence manually, similar to how our bash integration works) and then enter an interactive shell session. https://zsh.sourceforge.io/Doc/Release/Files.html Given all of the above, document this as an unsupported configuration for automatic shell integration and point affected users at our manual shell integration option. --- src/shell-integration/README.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 3f8543c68..9c422ef26 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -78,10 +78,16 @@ on the Fish startup process, see the ### Zsh -For `zsh`, Ghostty sets `ZDOTDIR` so that it loads our configuration -from the `zsh` directory. The existing `ZDOTDIR` is retained so that -after loading the Ghostty shell integration the normal Zsh loading -sequence occurs. +Automatic [Zsh](https://www.zsh.org/) integration works by temporarily setting +`ZDOTDIR` to our `zsh` directory. An existing `ZDOTDIR` environment variable +value will be retained and restored after our shell integration scripts are +run. + +However, if `ZDOTDIR` is set in a system-wide file like `/etc/zshenv`, it will +override Ghostty's `ZDOTDIR` value, preventing the shell integration from being +loaded. In this case, the shell integration needs to be loaded manually. + +To load the Zsh shell integration manually: ```zsh if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then From a0a915a06f42b8add7ee1773666d81b877cd9989 Mon Sep 17 00:00:00 2001 From: kadekillary Date: Mon, 15 Dec 2025 06:31:54 -0600 Subject: [PATCH 59/93] refactor(build): simplify dependency detection logic - Removes unnecessary marker constant from build.zig that existed solely to signal build root status - Uses filesystem check (@src().file access) instead of compile-time declaration lookup to detect when ghostty is a dependency - Same behavior with less indirection: file resolves from build root only when ghostty is the main project --- build.zig | 5 ----- src/build/Config.zig | 22 ++++++++-------------- 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/build.zig b/build.zig index 472c3957a..fa68b91b4 100644 --- a/build.zig +++ b/build.zig @@ -318,8 +318,3 @@ pub fn build(b: *std.Build) !void { try translations_step.addError("cannot update translations when i18n is disabled", .{}); } } - -/// Marker used by Config.zig to detect if ghostty is the build root. -/// This avoids running logic such as Git tag checking when Ghostty -/// is used as a dependency. -pub const _ghostty_build_root = true; diff --git a/src/build/Config.zig b/src/build/Config.zig index 981cd7de5..3a8a4e0c7 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -219,20 +219,14 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config { else version: { const app_version = try std.SemanticVersion.parse(appVersion); - // Detect if ghostty is being built as a dependency by checking if the - // build root has our marker. When used as a dependency, we skip git - // detection entirely to avoid reading the downstream project's git state. - const is_dependency = !@hasDecl( - @import("root"), - "_ghostty_build_root", - ); - if (is_dependency) { - break :version .{ - .major = app_version.major, - .minor = app_version.minor, - .patch = app_version.patch, - }; - } + // Is ghostty a dependency? If so, skip git detection. + // @src().file won't resolve from b.build_root unless ghostty + // is the project being built. + b.build_root.handle.access(@src().file, .{}) catch break :version .{ + .major = app_version.major, + .minor = app_version.minor, + .patch = app_version.patch, + }; // If no explicit version is given, we try to detect it from git. const vsn = GitVersion.detect(b) catch |err| switch (err) { From b15f16995c4b198fa5ff4d9627ea8af57a8a2d69 Mon Sep 17 00:00:00 2001 From: James Baumgarten Date: Fri, 25 Jul 2025 22:47:12 -0600 Subject: [PATCH 60/93] Fix i3 window border disappearing after fullscreen toggle When toggling a Ghostty window between fullscreen and windowed mode in the i3 window manager, window borders would disappear and not return. Root cause was that syncAppearance() was updating X11 properties on every call during window transitions, even when values hadn't changed. These redundant property updates interfered with i3's border management. The fix adds caching to syncBlur() and syncDecorations() to only update X11 properties when values actually change, eliminating unnecessary property changes during fullscreen transitions. --- src/apprt/gtk/winproto/x11.zig | 51 ++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig index 9dc273563..c73d4d482 100644 --- a/src/apprt/gtk/winproto/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -173,6 +173,10 @@ pub const Window = struct { blur_region: Region = .{}, + // Cache last applied values to avoid redundant X11 property updates + last_applied_blur_region: ?Region = null, + last_applied_decoration_hints: ?MotifWMHints = null, + pub fn init( alloc: Allocator, app: *App, @@ -255,19 +259,34 @@ pub const Window = struct { const gtk_widget = self.apprt_window.as(gtk.Widget); const config = if (self.apprt_window.getConfig()) |v| v.get() else return; + // When blur is disabled, remove the property if it was previously set + const blur = config.@"background-blur"; + if (!blur.enabled()) { + if (self.last_applied_blur_region != null) { + try self.deleteProperty(self.app.atoms.kde_blur); + self.last_applied_blur_region = null; + } + return; + } + // Transform surface coordinates to device coordinates. const scale = gtk_widget.getScaleFactor(); self.blur_region.width = gtk_widget.getWidth() * scale; self.blur_region.height = gtk_widget.getHeight() * scale; - const blur = config.@"background-blur"; log.debug("set blur={}, window xid={}, region={}", .{ blur, self.x11_surface.getXid(), self.blur_region, }); - if (blur.enabled()) { + // Only update X11 properties when the blur region actually changes + const region_changed = if (self.last_applied_blur_region) |last| + !std.meta.eql(self.blur_region, last) + else + true; + + if (region_changed) { try self.changeProperty( Region, self.app.atoms.kde_blur, @@ -276,8 +295,7 @@ pub const Window = struct { .{ .mode = .replace }, &self.blur_region, ); - } else { - try self.deleteProperty(self.app.atoms.kde_blur); + self.last_applied_blur_region = self.blur_region; } } @@ -307,14 +325,23 @@ pub const Window = struct { .auto, .client, .none => false, }; - try self.changeProperty( - MotifWMHints, - self.app.atoms.motif_wm_hints, - self.app.atoms.motif_wm_hints, - ._32, - .{ .mode = .replace }, - &hints, - ); + // Only update decoration hints when they actually change + const hints_changed = if (self.last_applied_decoration_hints) |last| + !std.meta.eql(hints, last) + else + true; + + if (hints_changed) { + try self.changeProperty( + MotifWMHints, + self.app.atoms.motif_wm_hints, + self.app.atoms.motif_wm_hints, + ._32, + .{ .mode = .replace }, + &hints, + ); + self.last_applied_decoration_hints = hints; + } } pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { From 47462ccc954e191506efac1f77389166ba1dcee3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Dec 2025 09:38:36 -0800 Subject: [PATCH 62/93] clean up some blurring code --- src/apprt/gtk/winproto/x11.zig | 63 ++++++++++++++++------------------ 1 file changed, 30 insertions(+), 33 deletions(-) diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig index c73d4d482..1e73c6139 100644 --- a/src/apprt/gtk/winproto/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -173,7 +173,9 @@ pub const Window = struct { blur_region: Region = .{}, - // Cache last applied values to avoid redundant X11 property updates + // Cache last applied values to avoid redundant X11 property updates. + // Redundant property updates seem to cause some visual glitches + // with some window managers: https://github.com/ghostty-org/ghostty/pull/8075 last_applied_blur_region: ?Region = null, last_applied_decoration_hints: ?MotifWMHints = null, @@ -266,6 +268,7 @@ pub const Window = struct { try self.deleteProperty(self.app.atoms.kde_blur); self.last_applied_blur_region = null; } + return; } @@ -274,29 +277,26 @@ pub const Window = struct { self.blur_region.width = gtk_widget.getWidth() * scale; self.blur_region.height = gtk_widget.getHeight() * scale; + // Only update X11 properties when the blur region actually changes + if (self.last_applied_blur_region) |last| { + if (std.meta.eql(self.blur_region, last)) return; + } + log.debug("set blur={}, window xid={}, region={}", .{ blur, self.x11_surface.getXid(), self.blur_region, }); - // Only update X11 properties when the blur region actually changes - const region_changed = if (self.last_applied_blur_region) |last| - !std.meta.eql(self.blur_region, last) - else - true; - - if (region_changed) { - try self.changeProperty( - Region, - self.app.atoms.kde_blur, - c.XA_CARDINAL, - ._32, - .{ .mode = .replace }, - &self.blur_region, - ); - self.last_applied_blur_region = self.blur_region; - } + try self.changeProperty( + Region, + self.app.atoms.kde_blur, + c.XA_CARDINAL, + ._32, + .{ .mode = .replace }, + &self.blur_region, + ); + self.last_applied_blur_region = self.blur_region; } fn syncDecorations(self: *Window) !void { @@ -326,22 +326,19 @@ pub const Window = struct { }; // Only update decoration hints when they actually change - const hints_changed = if (self.last_applied_decoration_hints) |last| - !std.meta.eql(hints, last) - else - true; - - if (hints_changed) { - try self.changeProperty( - MotifWMHints, - self.app.atoms.motif_wm_hints, - self.app.atoms.motif_wm_hints, - ._32, - .{ .mode = .replace }, - &hints, - ); - self.last_applied_decoration_hints = hints; + if (self.last_applied_decoration_hints) |last| { + if (std.meta.eql(hints, last)) return; } + + try self.changeProperty( + MotifWMHints, + self.app.atoms.motif_wm_hints, + self.app.atoms.motif_wm_hints, + ._32, + .{ .mode = .replace }, + &hints, + ); + self.last_applied_decoration_hints = hints; } pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { From 07578d5e3f12e4fe20c899b1472a21bc768671dc Mon Sep 17 00:00:00 2001 From: Uzair Aftab Date: Mon, 15 Dec 2025 18:59:34 +0100 Subject: [PATCH 63/93] nix: replace deprecated system with stdenv.hostPlatform.system --- nix/devShell.nix | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nix/devShell.nix b/nix/devShell.nix index 4aaf4ef5c..d37107133 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -70,7 +70,6 @@ wayland-scanner, wayland-protocols, zon2nix, - system, pkgs, # needed by GTK for loading SVG icons while running from within the # developer shell @@ -100,7 +99,7 @@ in scdoc zig zip - zon2nix.packages.${system}.zon2nix + zon2nix.packages.${stdenv.hostPlatform.system}.zon2nix # For web and wasm stuff nodejs From a02364cbefe0cb718679ec49543c979aa1a134cc Mon Sep 17 00:00:00 2001 From: Justy Null Date: Fri, 19 Sep 2025 22:23:32 -0700 Subject: [PATCH 64/93] feat: add liquid glass background effect support --- .../Window Styles/TerminalWindow.swift | 61 +++++++++++++++++++ macos/Sources/Ghostty/Ghostty.Config.swift | 13 +++- src/config/Config.zig | 25 ++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 160473328..69b4b3f1a 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -44,6 +44,9 @@ class TerminalWindow: NSWindow { true } + /// Glass effect view for liquid glass background when transparency is enabled + private var glassEffectView: NSView? + /// Gets the terminal controller from the window controller. var terminalController: TerminalController? { windowController as? TerminalController @@ -476,6 +479,11 @@ class TerminalWindow: NSWindow { // Terminal.app more easily. backgroundColor = .white.withAlphaComponent(0.001) + // Add liquid glass behind terminal content + if #available(macOS 26.0, *), derivedConfig.backgroundGlassStyle != "off" { + setupGlassLayer() + } + if let appDelegate = NSApp.delegate as? AppDelegate { ghostty_set_window_background_blur( appDelegate.ghostty.app, @@ -484,6 +492,11 @@ class TerminalWindow: NSWindow { } else { isOpaque = true + // Remove liquid glass when not transparent + if #available(macOS 26.0, *) { + removeGlassLayer() + } + let backgroundColor = preferredBackgroundColor ?? NSColor(surfaceConfig.backgroundColor) self.backgroundColor = backgroundColor.withAlphaComponent(1) } @@ -562,6 +575,51 @@ class TerminalWindow: NSWindow { } } + // MARK: Glass + + @available(macOS 26.0, *) + private func setupGlassLayer() { + guard let contentView = contentView else { return } + + // Remove existing glass effect view + glassEffectView?.removeFromSuperview() + + // Get the window content view (parent of the NSHostingView) + guard let windowContentView = contentView.superview else { return } + + // Create NSGlassEffectView for native glass effect + let effectView = NSGlassEffectView() + + // Map Ghostty config to NSGlassEffectView style + let glassStyle = derivedConfig.backgroundGlassStyle + switch glassStyle { + case "regular": + effectView.style = NSGlassEffectView.Style.regular + case "clear": + effectView.style = NSGlassEffectView.Style.clear + default: + // Should not reach here since we check for "off" before calling setupGlassLayer() + return + } + + effectView.cornerRadius = 18 + effectView.tintColor = preferredBackgroundColor + + effectView.frame = windowContentView.bounds + effectView.autoresizingMask = [.width, .height] + + // Position BELOW the terminal content to act as background + windowContentView.addSubview(effectView, positioned: .below, relativeTo: contentView) + glassEffectView = effectView + } + + @available(macOS 26.0, *) + private func removeGlassLayer() { + glassEffectView?.removeFromSuperview() + glassEffectView = nil + } + +>>>>>>> Conflict 4 of 4 ends // MARK: Config struct DerivedConfig { @@ -569,12 +627,14 @@ class TerminalWindow: NSWindow { let backgroundColor: NSColor let backgroundOpacity: Double let macosWindowButtons: Ghostty.MacOSWindowButtons + let backgroundGlassStyle: String init() { self.title = nil self.backgroundColor = NSColor.windowBackgroundColor self.backgroundOpacity = 1 self.macosWindowButtons = .visible + self.backgroundGlassStyle = "off" } init(_ config: Ghostty.Config) { @@ -582,6 +642,7 @@ class TerminalWindow: NSWindow { self.backgroundColor = NSColor(config.backgroundColor) self.backgroundOpacity = config.backgroundOpacity self.macosWindowButtons = config.macosWindowButtons + self.backgroundGlassStyle = config.backgroundGlassStyle } } } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 2df0a8656..20629c58f 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -261,6 +261,17 @@ extension Ghostty { return MacOSWindowButtons(rawValue: str) ?? defaultValue } + var backgroundGlassStyle: String { + let defaultValue = "off" + guard let config = self.config else { return defaultValue } + var v: UnsafePointer? = nil + let key = "background-glass-style" + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard let ptr = v else { return defaultValue } + return String(cString: ptr) + } + + var macosTitlebarStyle: String { let defaultValue = "transparent" guard let config = self.config else { return defaultValue } @@ -635,7 +646,7 @@ extension Ghostty.Config { static let title = BellFeatures(rawValue: 1 << 3) static let border = BellFeatures(rawValue: 1 << 4) } - + enum MacDockDropBehavior: String { case new_tab = "new-tab" case new_window = "new-window" diff --git a/src/config/Config.zig b/src/config/Config.zig index 1deb3e532..b542bcb1d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -950,6 +950,24 @@ palette: Palette = .{}, /// doing so. @"background-blur": BackgroundBlur = .false, +/// The style of the glass effect when `background-opacity` is less than 1 +/// and the terminal is using a modern glass effect (macOS 26.0+ only). +/// +/// Valid values are: +/// +/// * `off` - No glass effect +/// * `regular` - Standard glass effect with some opacity +/// * `clear` - Highly transparent glass effect +/// +/// This setting only takes effect on macOS 26.0+ when transparency is enabled +/// (`background-opacity` < 1). On older macOS versions or when transparency +/// is disabled, this setting has no effect. +/// +/// The default value is `off`. +/// +/// Available since: 1.2.2 +@"background-glass-style": BackgroundGlassStyle = .off, + /// 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. @@ -8383,6 +8401,13 @@ pub const BackgroundBlur = union(enum) { } }; +/// See background-glass-style +pub const BackgroundGlassStyle = enum { + off, + regular, + clear, +}; + /// See window-decoration pub const WindowDecoration = enum(c_int) { auto, From d40af61960b41652d11e45429cb41b42f40b50a9 Mon Sep 17 00:00:00 2001 From: Justy Null Date: Sat, 20 Sep 2025 11:44:04 -0700 Subject: [PATCH 65/93] refactor: migrate background glass effect to new macos-background-style config --- .../Window Styles/TerminalWindow.swift | 38 ++++++++----- macos/Sources/Ghostty/Ghostty.Config.swift | 10 ++-- macos/Sources/Ghostty/Package.swift | 17 ++++-- src/config/Config.zig | 54 ++++++++++--------- 4 files changed, 70 insertions(+), 49 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 69b4b3f1a..51f4d5b1c 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -480,11 +480,9 @@ class TerminalWindow: NSWindow { backgroundColor = .white.withAlphaComponent(0.001) // Add liquid glass behind terminal content - if #available(macOS 26.0, *), derivedConfig.backgroundGlassStyle != "off" { + if #available(macOS 26.0, *), derivedConfig.macosBackgroundStyle != .blur { setupGlassLayer() - } - - if let appDelegate = NSApp.delegate as? AppDelegate { + } else if let appDelegate = NSApp.delegate as? AppDelegate { ghostty_set_window_background_blur( appDelegate.ghostty.app, Unmanaged.passUnretained(self).toOpaque()) @@ -576,7 +574,7 @@ class TerminalWindow: NSWindow { } // MARK: Glass - + @available(macOS 26.0, *) private func setupGlassLayer() { guard let contentView = contentView else { return } @@ -591,18 +589,18 @@ class TerminalWindow: NSWindow { let effectView = NSGlassEffectView() // Map Ghostty config to NSGlassEffectView style - let glassStyle = derivedConfig.backgroundGlassStyle - switch glassStyle { - case "regular": + let backgroundStyle = derivedConfig.macosBackgroundStyle + switch backgroundStyle { + case .regularGlass: effectView.style = NSGlassEffectView.Style.regular - case "clear": + case .clearGlass: effectView.style = NSGlassEffectView.Style.clear default: - // Should not reach here since we check for "off" before calling setupGlassLayer() + // Should not reach here since we check for "default" before calling setupGlassLayer() return } - effectView.cornerRadius = 18 + effectView.cornerRadius = derivedConfig.windowCornerRadius effectView.tintColor = preferredBackgroundColor effectView.frame = windowContentView.bounds @@ -627,14 +625,16 @@ class TerminalWindow: NSWindow { let backgroundColor: NSColor let backgroundOpacity: Double let macosWindowButtons: Ghostty.MacOSWindowButtons - let backgroundGlassStyle: String + let macosBackgroundStyle: Ghostty.MacBackgroundStyle + let windowCornerRadius: CGFloat init() { self.title = nil self.backgroundColor = NSColor.windowBackgroundColor self.backgroundOpacity = 1 self.macosWindowButtons = .visible - self.backgroundGlassStyle = "off" + self.macosBackgroundStyle = .blur + self.windowCornerRadius = 16 } init(_ config: Ghostty.Config) { @@ -642,7 +642,17 @@ class TerminalWindow: NSWindow { self.backgroundColor = NSColor(config.backgroundColor) self.backgroundOpacity = config.backgroundOpacity self.macosWindowButtons = config.macosWindowButtons - self.backgroundGlassStyle = config.backgroundGlassStyle + self.macosBackgroundStyle = config.macosBackgroundStyle + + // Set corner radius based on macos-titlebar-style + // Native, transparent, and hidden styles use 16pt radius + // Tabs style uses 20pt radius + switch config.macosTitlebarStyle { + case "tabs": + self.windowCornerRadius = 20 + default: + self.windowCornerRadius = 16 + } } } } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 20629c58f..1488b0790 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -261,17 +261,17 @@ extension Ghostty { return MacOSWindowButtons(rawValue: str) ?? defaultValue } - var backgroundGlassStyle: String { - let defaultValue = "off" + var macosBackgroundStyle: MacBackgroundStyle { + let defaultValue = MacBackgroundStyle.blur guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil - let key = "background-glass-style" + let key = "macos-background-style" guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } guard let ptr = v else { return defaultValue } - return String(cString: ptr) + let str = String(cString: ptr) + return MacBackgroundStyle(rawValue: str) ?? defaultValue } - var macosTitlebarStyle: String { let defaultValue = "transparent" guard let config = self.config else { return defaultValue } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 258857e8e..e769b814e 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -56,7 +56,7 @@ extension Ghostty { case app case zig_run } - + /// Returns the mechanism that launched the app. This is based on an env var so /// its up to the env var being set in the correct circumstance. static var launchSource: LaunchSource { @@ -65,7 +65,7 @@ extension Ghostty { // source. If its unset we assume we're in a CLI environment. return .cli } - + // If the env var is set but its unknown then we default back to the app. return LaunchSource(rawValue: envValue) ?? .app } @@ -76,17 +76,17 @@ extension Ghostty { extension Ghostty { class AllocatedString { private let cString: ghostty_string_s - + init(_ c: ghostty_string_s) { self.cString = c } - + var string: String { guard let ptr = cString.ptr else { return "" } let data = Data(bytes: ptr, count: Int(cString.len)) return String(data: data, encoding: .utf8) ?? "" } - + deinit { ghostty_string_free(cString) } @@ -352,6 +352,13 @@ extension Ghostty { case hidden } + /// Enum for the macos-background-style config option + enum MacBackgroundStyle: String { + case blur + case regularGlass = "regular-glass" + case clearGlass = "clear-glass" + } + /// Enum for auto-update-channel config option enum AutoUpdateChannel: String { case tip diff --git a/src/config/Config.zig b/src/config/Config.zig index b542bcb1d..287efa89d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -950,24 +950,6 @@ palette: Palette = .{}, /// doing so. @"background-blur": BackgroundBlur = .false, -/// The style of the glass effect when `background-opacity` is less than 1 -/// and the terminal is using a modern glass effect (macOS 26.0+ only). -/// -/// Valid values are: -/// -/// * `off` - No glass effect -/// * `regular` - Standard glass effect with some opacity -/// * `clear` - Highly transparent glass effect -/// -/// This setting only takes effect on macOS 26.0+ when transparency is enabled -/// (`background-opacity` < 1). On older macOS versions or when transparency -/// is disabled, this setting has no effect. -/// -/// The default value is `off`. -/// -/// Available since: 1.2.2 -@"background-glass-style": BackgroundGlassStyle = .off, - /// 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. @@ -3124,6 +3106,28 @@ keybind: Keybinds = .{}, /// Available since: 1.2.0 @"macos-shortcuts": MacShortcuts = .ask, +/// The background style for macOS windows when `background-opacity` is less than 1. +/// This controls the visual effect applied behind the terminal background. +/// +/// Valid values are: +/// +/// * `blur` - Uses the standard background behavior. The `background-blur` +/// configuration will control whether blur is applied (available on all macOS versions) +/// * `regular-glass` - Standard glass effect with some opacity (macOS 26.0+ only) +/// * `clear-glass` - Highly transparent glass effect (macOS 26.0+ only) +/// +/// The `blur` option does not force any blur effect - it simply respects the +/// `background-blur` configuration. The glass options override `background-blur` +/// and apply their own visual effects. +/// +/// On macOS versions prior to 26.0, only `blur` has an effect. The glass +/// options will fall back to `blur` behavior on older versions. +/// +/// The default value is `blur`. +/// +/// Available since: 1.2.2 +@"macos-background-style": MacBackgroundStyle = .blur, + /// Put every surface (tab, split, window) into a dedicated Linux cgroup. /// /// This makes it so that resource management can be done on a per-surface @@ -7708,6 +7712,13 @@ pub const MacShortcuts = enum { ask, }; +/// See macos-background-style +pub const MacBackgroundStyle = enum { + blur, + @"regular-glass", + @"clear-glass", +}; + /// See gtk-single-instance pub const GtkSingleInstance = enum { false, @@ -8401,13 +8412,6 @@ pub const BackgroundBlur = union(enum) { } }; -/// See background-glass-style -pub const BackgroundGlassStyle = enum { - off, - regular, - clear, -}; - /// See window-decoration pub const WindowDecoration = enum(c_int) { auto, From 45aceace726656e49e145c2f0fa504eb97e80e2e Mon Sep 17 00:00:00 2001 From: Justy Null Date: Sat, 20 Sep 2025 16:05:05 -0700 Subject: [PATCH 66/93] fix: disable renderer background when macOS effects are enabled --- .../Window Styles/TerminalWindow.swift | 4 ++-- macos/Sources/Ghostty/Ghostty.Config.swift | 2 +- macos/Sources/Ghostty/Package.swift | 2 +- src/config/Config.zig | 15 +++---------- src/renderer/generic.zig | 22 +++++++++++++++++-- 5 files changed, 27 insertions(+), 18 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 51f4d5b1c..07deb6ded 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -480,7 +480,7 @@ class TerminalWindow: NSWindow { backgroundColor = .white.withAlphaComponent(0.001) // Add liquid glass behind terminal content - if #available(macOS 26.0, *), derivedConfig.macosBackgroundStyle != .blur { + if #available(macOS 26.0, *), derivedConfig.macosBackgroundStyle != .defaultStyle { setupGlassLayer() } else if let appDelegate = NSApp.delegate as? AppDelegate { ghostty_set_window_background_blur( @@ -633,7 +633,7 @@ class TerminalWindow: NSWindow { self.backgroundColor = NSColor.windowBackgroundColor self.backgroundOpacity = 1 self.macosWindowButtons = .visible - self.macosBackgroundStyle = .blur + self.macosBackgroundStyle = .defaultStyle self.windowCornerRadius = 16 } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 1488b0790..4a80f2af8 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -262,7 +262,7 @@ extension Ghostty { } var macosBackgroundStyle: MacBackgroundStyle { - let defaultValue = MacBackgroundStyle.blur + let defaultValue = MacBackgroundStyle.defaultStyle guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-background-style" diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index e769b814e..4279cc012 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -354,7 +354,7 @@ extension Ghostty { /// Enum for the macos-background-style config option enum MacBackgroundStyle: String { - case blur + case defaultStyle = "default" case regularGlass = "regular-glass" case clearGlass = "clear-glass" } diff --git a/src/config/Config.zig b/src/config/Config.zig index 287efa89d..09731b13d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -3111,22 +3111,13 @@ keybind: Keybinds = .{}, /// /// Valid values are: /// -/// * `blur` - Uses the standard background behavior. The `background-blur` +/// * `default` - Uses the standard background behavior. The `background-blur` /// configuration will control whether blur is applied (available on all macOS versions) /// * `regular-glass` - Standard glass effect with some opacity (macOS 26.0+ only) /// * `clear-glass` - Highly transparent glass effect (macOS 26.0+ only) /// -/// The `blur` option does not force any blur effect - it simply respects the -/// `background-blur` configuration. The glass options override `background-blur` -/// and apply their own visual effects. -/// -/// On macOS versions prior to 26.0, only `blur` has an effect. The glass -/// options will fall back to `blur` behavior on older versions. -/// -/// The default value is `blur`. -/// /// Available since: 1.2.2 -@"macos-background-style": MacBackgroundStyle = .blur, +@"macos-background-style": MacBackgroundStyle = .default, /// Put every surface (tab, split, window) into a dedicated Linux cgroup. /// @@ -7714,7 +7705,7 @@ pub const MacShortcuts = enum { /// See macos-background-style pub const MacBackgroundStyle = enum { - blur, + default, @"regular-glass", @"clear-glass", }; diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 8c55da602..013761f1e 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -561,6 +561,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { vsync: bool, colorspace: configpkg.Config.WindowColorspace, blending: configpkg.Config.AlphaBlending, + macos_background_style: configpkg.Config.MacBackgroundStyle, pub fn init( alloc_gpa: Allocator, @@ -633,6 +634,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .vsync = config.@"window-vsync", .colorspace = config.@"window-colorspace", .blending = config.@"alpha-blending", + .macos_background_style = config.@"macos-background-style", .arena = arena, }; } @@ -644,6 +646,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } }; + /// Determines if the terminal background should be disabled based on platform and config. + /// On macOS, when background effects are enabled (background style != default), the effect + /// layer handles the background rendering instead of the terminal renderer. + fn shouldDisableBackground(config: DerivedConfig) bool { + return switch (builtin.os.tag) { + .macos => config.macos_background_style != .default, + else => false, + }; + } + pub fn init(alloc: Allocator, options: renderer.Options) !Self { // Initialize our graphics API wrapper, this will prepare the // surface provided by the apprt and set up any API-specific @@ -716,7 +728,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type { options.config.background.r, options.config.background.g, options.config.background.b, - @intFromFloat(@round(options.config.background_opacity * 255.0)), + if (shouldDisableBackground(options.config)) + 0 + else + @intFromFloat(@round(options.config.background_opacity * 255.0)), }, .bools = .{ .cursor_wide = false, @@ -1293,7 +1308,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.terminal_state.colors.background.r, self.terminal_state.colors.background.g, self.terminal_state.colors.background.b, - @intFromFloat(@round(self.config.background_opacity * 255.0)), + if (shouldDisableBackground(self.config)) + 0 + else + @intFromFloat(@round(self.config.background_opacity * 255.0)), }; } } From d5c378cd6bb8541b7e6d914e1fc7900a257171f9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 12 Oct 2025 13:18:15 -0700 Subject: [PATCH 67/93] minor style tweaks --- .../Terminal/Window Styles/TerminalWindow.swift | 5 +++-- src/config/Config.zig | 13 ++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 07deb6ded..444ce28bd 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -573,6 +573,7 @@ class TerminalWindow: NSWindow { } } +#if compiler(>=6.2) // MARK: Glass @available(macOS 26.0, *) @@ -616,8 +617,8 @@ class TerminalWindow: NSWindow { glassEffectView?.removeFromSuperview() glassEffectView = nil } - ->>>>>>> Conflict 4 of 4 ends +#endif // compiler(>=6.2) + // MARK: Config struct DerivedConfig { diff --git a/src/config/Config.zig b/src/config/Config.zig index 09731b13d..0e7b51c4f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -3106,17 +3106,20 @@ keybind: Keybinds = .{}, /// Available since: 1.2.0 @"macos-shortcuts": MacShortcuts = .ask, -/// The background style for macOS windows when `background-opacity` is less than 1. -/// This controls the visual effect applied behind the terminal background. +/// The background style for macOS windows when `background-opacity` is less +/// than 1. This controls the visual effect applied behind the terminal +/// background. /// /// Valid values are: /// /// * `default` - Uses the standard background behavior. The `background-blur` -/// configuration will control whether blur is applied (available on all macOS versions) -/// * `regular-glass` - Standard glass effect with some opacity (macOS 26.0+ only) +/// configuration will control whether blur is applied (available on +/// all macOS versions) +/// * `regular-glass` - Standard glass effect with some opacity (macOS +/// 26.0+ only) /// * `clear-glass` - Highly transparent glass effect (macOS 26.0+ only) /// -/// Available since: 1.2.2 +/// Available since: 1.3.0 @"macos-background-style": MacBackgroundStyle = .default, /// Put every surface (tab, split, window) into a dedicated Linux cgroup. From 42493de0989d1a2c27f5c27deb471c6e08d66ad6 Mon Sep 17 00:00:00 2001 From: Justy Null Date: Fri, 17 Oct 2025 18:39:11 -0700 Subject: [PATCH 68/93] fix: make titlebar transparent when using glass background style --- .../Terminal/Window Styles/TerminalWindow.swift | 3 +++ .../TransparentTitlebarTerminalWindow.swift | 12 +++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 444ce28bd..6105cac53 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -627,6 +627,7 @@ class TerminalWindow: NSWindow { let backgroundOpacity: Double let macosWindowButtons: Ghostty.MacOSWindowButtons let macosBackgroundStyle: Ghostty.MacBackgroundStyle + let macosTitlebarStyle: String let windowCornerRadius: CGFloat init() { @@ -635,6 +636,7 @@ class TerminalWindow: NSWindow { self.backgroundOpacity = 1 self.macosWindowButtons = .visible self.macosBackgroundStyle = .defaultStyle + self.macosTitlebarStyle = "transparent" self.windowCornerRadius = 16 } @@ -644,6 +646,7 @@ class TerminalWindow: NSWindow { self.backgroundOpacity = config.backgroundOpacity self.macosWindowButtons = config.macosWindowButtons self.macosBackgroundStyle = config.macosBackgroundStyle + self.macosTitlebarStyle = config.macosTitlebarStyle // Set corner radius based on macos-titlebar-style // Native, transparent, and hidden styles use 16pt radius diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index 08d56c83d..eea1956fc 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -88,7 +88,17 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // color of the titlebar in native fullscreen view. if let titlebarView = titlebarContainer?.firstDescendant(withClassName: "NSTitlebarView") { titlebarView.wantsLayer = true - titlebarView.layer?.backgroundColor = preferredBackgroundColor?.cgColor + + // For glass background styles, use a transparent titlebar to let the glass effect show through + // Only apply this for transparent and tabs titlebar styles + let isGlassStyle = derivedConfig.macosBackgroundStyle == .regularGlass || + derivedConfig.macosBackgroundStyle == .clearGlass + let isTransparentTitlebar = derivedConfig.macosTitlebarStyle == "transparent" || + derivedConfig.macosTitlebarStyle == "tabs" + + titlebarView.layer?.backgroundColor = (isGlassStyle && isTransparentTitlebar) + ? NSColor.clear.cgColor + : preferredBackgroundColor?.cgColor } // In all cases, we have to hide the background view since this has multiple subviews From bb2307116662bdf778906f48260524482fba2312 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Dec 2025 10:29:21 -0800 Subject: [PATCH 69/93] config: change macos-background-style to be enums on background-blur --- macos/Sources/Ghostty/Ghostty.Config.swift | 52 +++++++++++++++++++-- src/config/Config.zig | 54 ++++++++++++++++++---- src/config/c_get.zig | 18 ++++++-- src/renderer/generic.zig | 5 +- 4 files changed, 109 insertions(+), 20 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 4a80f2af8..5a622d19c 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -413,12 +413,12 @@ extension Ghostty { return v; } - var backgroundBlurRadius: Int { - guard let config = self.config else { return 1 } - var v: Int = 0 + var backgroundBlur: BackgroundBlur { + guard let config = self.config else { return .disabled } + var v: Int16 = 0 let key = "background-blur" _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) - return v; + return BackgroundBlur(fromCValue: v) } var unfocusedSplitOpacity: Double { @@ -637,6 +637,50 @@ extension Ghostty.Config { case download } + /// Background blur configuration that maps from the C API values. + /// Positive values represent blur radius, special negative values + /// represent macOS-specific glass effects. + enum BackgroundBlur: Equatable { + case disabled + case radius(Int) + case macosGlassRegular + case macosGlassClear + + init(fromCValue value: Int16) { + switch value { + case 0: + self = .disabled + case -1: + self = .macosGlassRegular + case -2: + self = .macosGlassClear + default: + self = .radius(Int(value)) + } + } + + var isEnabled: Bool { + switch self { + case .disabled: + return false + default: + return true + } + } + + /// Returns the blur radius if applicable, nil for glass effects. + var radius: Int? { + switch self { + case .disabled: + return nil + case .radius(let r): + return r + case .macosGlassRegular, .macosGlassClear: + return nil + } + } + } + struct BellFeatures: OptionSet { let rawValue: CUnsignedInt diff --git a/src/config/Config.zig b/src/config/Config.zig index 0e7b51c4f..4a3810901 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -8338,6 +8338,8 @@ pub const AutoUpdate = enum { pub const BackgroundBlur = union(enum) { false, true, + @"macos-glass-regular", + @"macos-glass-clear", radius: u8, pub fn parseCLI(self: *BackgroundBlur, input: ?[]const u8) !void { @@ -8347,14 +8349,35 @@ pub const BackgroundBlur = union(enum) { return; }; - self.* = if (cli.args.parseBool(input_)) |b| - if (b) .true else .false - else |_| - .{ .radius = std.fmt.parseInt( - u8, - input_, - 0, - ) catch return error.InvalidValue }; + // Try to parse normal bools + if (cli.args.parseBool(input_)) |b| { + self.* = if (b) .true else .false; + return; + } else |_| {} + + // Try to parse enums + if (std.meta.stringToEnum( + std.meta.Tag(BackgroundBlur), + input_, + )) |v| switch (v) { + inline else => |tag| tag: { + // We can only parse void types + const info = std.meta.fieldInfo(BackgroundBlur, tag); + if (info.type != void) break :tag; + self.* = @unionInit( + BackgroundBlur, + @tagName(tag), + {}, + ); + return; + }, + }; + + self.* = .{ .radius = std.fmt.parseInt( + u8, + input_, + 0, + ) catch return error.InvalidValue }; } pub fn enabled(self: BackgroundBlur) bool { @@ -8365,11 +8388,16 @@ pub const BackgroundBlur = union(enum) { }; } - pub fn cval(self: BackgroundBlur) u8 { + pub fn cval(self: BackgroundBlur) i16 { return switch (self) { .false => 0, .true => 20, .radius => |v| v, + // I hate sentinel values like this but this is only for + // our macOS application currently. We can switch to a proper + // tagged union if we ever need to. + .@"macos-glass-regular" => -1, + .@"macos-glass-clear" => -2, }; } @@ -8381,6 +8409,8 @@ pub const BackgroundBlur = union(enum) { .false => try formatter.formatEntry(bool, false), .true => try formatter.formatEntry(bool, true), .radius => |v| try formatter.formatEntry(u8, v), + .@"macos-glass-regular" => try formatter.formatEntry([]const u8, "macos-glass-regular"), + .@"macos-glass-clear" => try formatter.formatEntry([]const u8, "macos-glass-clear"), } } @@ -8400,6 +8430,12 @@ pub const BackgroundBlur = union(enum) { try v.parseCLI("42"); try testing.expectEqual(42, v.radius); + try v.parseCLI("macos-glass-regular"); + try testing.expectEqual(.@"macos-glass-regular", v); + + try v.parseCLI("macos-glass-clear"); + try testing.expectEqual(.@"macos-glass-clear", v); + try testing.expectError(error.InvalidValue, v.parseCLI("")); try testing.expectError(error.InvalidValue, v.parseCLI("aaaa")); try testing.expectError(error.InvalidValue, v.parseCLI("420")); diff --git a/src/config/c_get.zig b/src/config/c_get.zig index f235f596a..0f8f897a2 100644 --- a/src/config/c_get.zig +++ b/src/config/c_get.zig @@ -193,20 +193,32 @@ test "c_get: background-blur" { { c.@"background-blur" = .false; - var cval: u8 = undefined; + var cval: i16 = undefined; try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); try testing.expectEqual(0, cval); } { c.@"background-blur" = .true; - var cval: u8 = undefined; + var cval: i16 = undefined; try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); try testing.expectEqual(20, cval); } { c.@"background-blur" = .{ .radius = 42 }; - var cval: u8 = undefined; + var cval: i16 = undefined; try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); try testing.expectEqual(42, cval); } + { + c.@"background-blur" = .@"macos-glass-regular"; + var cval: i16 = undefined; + try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); + try testing.expectEqual(-1, cval); + } + { + c.@"background-blur" = .@"macos-glass-clear"; + var cval: i16 = undefined; + try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); + try testing.expectEqual(-2, cval); + } } diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 013761f1e..e3db3cd93 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -728,10 +728,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { options.config.background.r, options.config.background.g, options.config.background.b, - if (shouldDisableBackground(options.config)) - 0 - else - @intFromFloat(@round(options.config.background_opacity * 255.0)), + @intFromFloat(@round(options.config.background_opacity * 255.0)), }, .bools = .{ .cursor_wide = false, From a6ddf03a2ee5aec49bb4a4488e3061d8bd737839 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Dec 2025 10:48:20 -0800 Subject: [PATCH 70/93] remove the macos-background-style config --- .../Window Styles/TerminalWindow.swift | 17 +++++----- .../TransparentTitlebarTerminalWindow.swift | 3 +- macos/Sources/Ghostty/Ghostty.Config.swift | 21 ++++++------ macos/Sources/Ghostty/Package.swift | 7 ---- src/config/Config.zig | 29 ++++------------ src/renderer/generic.zig | 33 ++++++++++--------- 6 files changed, 42 insertions(+), 68 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 6105cac53..7066f7bd6 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -480,7 +480,7 @@ class TerminalWindow: NSWindow { backgroundColor = .white.withAlphaComponent(0.001) // Add liquid glass behind terminal content - if #available(macOS 26.0, *), derivedConfig.macosBackgroundStyle != .defaultStyle { + if #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle { setupGlassLayer() } else if let appDelegate = NSApp.delegate as? AppDelegate { ghostty_set_window_background_blur( @@ -590,14 +590,13 @@ class TerminalWindow: NSWindow { let effectView = NSGlassEffectView() // Map Ghostty config to NSGlassEffectView style - let backgroundStyle = derivedConfig.macosBackgroundStyle - switch backgroundStyle { - case .regularGlass: + switch derivedConfig.backgroundBlur { + case .macosGlassRegular: effectView.style = NSGlassEffectView.Style.regular - case .clearGlass: + case .macosGlassClear: effectView.style = NSGlassEffectView.Style.clear default: - // Should not reach here since we check for "default" before calling setupGlassLayer() + // Should not reach here since we check for glass style before calling setupGlassLayer() return } @@ -623,10 +622,10 @@ class TerminalWindow: NSWindow { struct DerivedConfig { let title: String? + let backgroundBlur: Ghostty.Config.BackgroundBlur let backgroundColor: NSColor let backgroundOpacity: Double let macosWindowButtons: Ghostty.MacOSWindowButtons - let macosBackgroundStyle: Ghostty.MacBackgroundStyle let macosTitlebarStyle: String let windowCornerRadius: CGFloat @@ -635,7 +634,7 @@ class TerminalWindow: NSWindow { self.backgroundColor = NSColor.windowBackgroundColor self.backgroundOpacity = 1 self.macosWindowButtons = .visible - self.macosBackgroundStyle = .defaultStyle + self.backgroundBlur = .disabled self.macosTitlebarStyle = "transparent" self.windowCornerRadius = 16 } @@ -645,7 +644,7 @@ class TerminalWindow: NSWindow { self.backgroundColor = NSColor(config.backgroundColor) self.backgroundOpacity = config.backgroundOpacity self.macosWindowButtons = config.macosWindowButtons - self.macosBackgroundStyle = config.macosBackgroundStyle + self.backgroundBlur = config.backgroundBlur self.macosTitlebarStyle = config.macosTitlebarStyle // Set corner radius based on macos-titlebar-style diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index eea1956fc..57b889b82 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -91,8 +91,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // For glass background styles, use a transparent titlebar to let the glass effect show through // Only apply this for transparent and tabs titlebar styles - let isGlassStyle = derivedConfig.macosBackgroundStyle == .regularGlass || - derivedConfig.macosBackgroundStyle == .clearGlass + let isGlassStyle = derivedConfig.backgroundBlur.isGlassStyle let isTransparentTitlebar = derivedConfig.macosTitlebarStyle == "transparent" || derivedConfig.macosTitlebarStyle == "tabs" diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 5a622d19c..47826a104 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -261,17 +261,6 @@ extension Ghostty { return MacOSWindowButtons(rawValue: str) ?? defaultValue } - var macosBackgroundStyle: MacBackgroundStyle { - let defaultValue = MacBackgroundStyle.defaultStyle - guard let config = self.config else { return defaultValue } - var v: UnsafePointer? = nil - let key = "macos-background-style" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } - guard let ptr = v else { return defaultValue } - let str = String(cString: ptr) - return MacBackgroundStyle(rawValue: str) ?? defaultValue - } - var macosTitlebarStyle: String { let defaultValue = "transparent" guard let config = self.config else { return defaultValue } @@ -668,6 +657,16 @@ extension Ghostty.Config { } } + /// Returns true if this is a macOS glass style (regular or clear). + var isGlassStyle: Bool { + switch self { + case .macosGlassRegular, .macosGlassClear: + return true + default: + return false + } + } + /// Returns the blur radius if applicable, nil for glass effects. var radius: Int? { switch self { diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 4279cc012..b834ea31f 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -352,13 +352,6 @@ extension Ghostty { case hidden } - /// Enum for the macos-background-style config option - enum MacBackgroundStyle: String { - case defaultStyle = "default" - case regularGlass = "regular-glass" - case clearGlass = "clear-glass" - } - /// Enum for auto-update-channel config option enum AutoUpdateChannel: String { case tip diff --git a/src/config/Config.zig b/src/config/Config.zig index 4a3810901..18224a3cd 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -927,6 +927,12 @@ palette: Palette = .{}, /// reasonable for a good looking blur. Higher blur intensities may /// cause strange rendering and performance issues. /// +/// On macOS 26.0 and later, there are additional special values that +/// can be set to use the native macOS glass effects: +/// +/// * `macos-glass-regular` - Standard glass effect with some opacity +/// * `macos-glass-clear` - Highly transparent glass effect +/// /// Supported on macOS and on some Linux desktop environments, including: /// /// * KDE Plasma (Wayland and X11) @@ -3106,22 +3112,6 @@ keybind: Keybinds = .{}, /// Available since: 1.2.0 @"macos-shortcuts": MacShortcuts = .ask, -/// The background style for macOS windows when `background-opacity` is less -/// than 1. This controls the visual effect applied behind the terminal -/// background. -/// -/// Valid values are: -/// -/// * `default` - Uses the standard background behavior. The `background-blur` -/// configuration will control whether blur is applied (available on -/// all macOS versions) -/// * `regular-glass` - Standard glass effect with some opacity (macOS -/// 26.0+ only) -/// * `clear-glass` - Highly transparent glass effect (macOS 26.0+ only) -/// -/// Available since: 1.3.0 -@"macos-background-style": MacBackgroundStyle = .default, - /// Put every surface (tab, split, window) into a dedicated Linux cgroup. /// /// This makes it so that resource management can be done on a per-surface @@ -7706,13 +7696,6 @@ pub const MacShortcuts = enum { ask, }; -/// See macos-background-style -pub const MacBackgroundStyle = enum { - default, - @"regular-glass", - @"clear-glass", -}; - /// See gtk-single-instance pub const GtkSingleInstance = enum { false, diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index e3db3cd93..39eec7b43 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -561,7 +561,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { vsync: bool, colorspace: configpkg.Config.WindowColorspace, blending: configpkg.Config.AlphaBlending, - macos_background_style: configpkg.Config.MacBackgroundStyle, + background_blur: configpkg.Config.BackgroundBlur, pub fn init( alloc_gpa: Allocator, @@ -634,7 +634,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .vsync = config.@"window-vsync", .colorspace = config.@"window-colorspace", .blending = config.@"alpha-blending", - .macos_background_style = config.@"macos-background-style", + .background_blur = config.@"background-blur", .arena = arena, }; } @@ -646,16 +646,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } }; - /// Determines if the terminal background should be disabled based on platform and config. - /// On macOS, when background effects are enabled (background style != default), the effect - /// layer handles the background rendering instead of the terminal renderer. - fn shouldDisableBackground(config: DerivedConfig) bool { - return switch (builtin.os.tag) { - .macos => config.macos_background_style != .default, - else => false, - }; - } - pub fn init(alloc: Allocator, options: renderer.Options) !Self { // Initialize our graphics API wrapper, this will prepare the // surface provided by the apprt and set up any API-specific @@ -728,6 +718,9 @@ 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. @intFromFloat(@round(options.config.background_opacity * 255.0)), }, .bools = .{ @@ -1305,10 +1298,18 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.terminal_state.colors.background.r, self.terminal_state.colors.background.g, self.terminal_state.colors.background.b, - if (shouldDisableBackground(self.config)) - 0 - else - @intFromFloat(@round(self.config.background_opacity * 255.0)), + @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. + if (comptime builtin.os.tag == .macos) switch (self.config.background_blur) { + .@"macos-glass-regular", + .@"macos-glass-clear", + => self.uniforms.bg_color[3] = 0, + + else => {}, }; } } From 8482e0777db9f675641c5567d562f5f4d43b5fc4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Dec 2025 10:58:35 -0800 Subject: [PATCH 71/93] macos: remove glass view on syncAppearance with blur --- .../Window Styles/TerminalWindow.swift | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 7066f7bd6..0c0ac0646 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -483,6 +483,11 @@ class TerminalWindow: NSWindow { if #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle { setupGlassLayer() } else if let appDelegate = NSApp.delegate as? AppDelegate { + // If we had a prior glass layer we should remove it + if #available(macOS 26.0, *) { + removeGlassLayer() + } + ghostty_set_window_background_blur( appDelegate.ghostty.app, Unmanaged.passUnretained(self).toOpaque()) @@ -578,12 +583,11 @@ class TerminalWindow: NSWindow { @available(macOS 26.0, *) private func setupGlassLayer() { - guard let contentView = contentView else { return } - // Remove existing glass effect view - glassEffectView?.removeFromSuperview() - + removeGlassLayer() + // Get the window content view (parent of the NSHostingView) + guard let contentView else { return } guard let windowContentView = contentView.superview else { return } // Create NSGlassEffectView for native glass effect @@ -596,13 +600,13 @@ class TerminalWindow: NSWindow { case .macosGlassClear: effectView.style = NSGlassEffectView.Style.clear default: - // Should not reach here since we check for glass style before calling setupGlassLayer() - return + // Should not reach here since we check for glass style before calling + // setupGlassLayer() + assertionFailure() } effectView.cornerRadius = derivedConfig.windowCornerRadius effectView.tintColor = preferredBackgroundColor - effectView.frame = windowContentView.bounds effectView.autoresizingMask = [.width, .height] From 4e10f27be4fbd1d0c8c2dc84dd9bc3deab339e0c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Dec 2025 11:00:53 -0800 Subject: [PATCH 72/93] config: macos blur settings enable blur on non-Mac --- src/config/Config.zig | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index 18224a3cd..409e35516 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -933,6 +933,9 @@ palette: Palette = .{}, /// * `macos-glass-regular` - Standard glass effect with some opacity /// * `macos-glass-clear` - Highly transparent glass effect /// +/// If the macOS values are set, then this implies `background-blur = true` +/// on non-macOS platforms. +/// /// Supported on macOS and on some Linux desktop environments, including: /// /// * KDE Plasma (Wayland and X11) @@ -8368,6 +8371,11 @@ pub const BackgroundBlur = union(enum) { .false => false, .true => true, .radius => |v| v > 0, + + // We treat these as true because they both imply some blur! + // This has the effect of making the standard blur happen on + // Linux. + .@"macos-glass-regular", .@"macos-glass-clear" => true, }; } From f8c03bb6f6ff7cf71c7e04077059173859496cd2 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 21 Sep 2025 00:21:14 -0500 Subject: [PATCH 73/93] logging: document GHOSTTY_LOG and make it more flexible --- HACKING.md | 30 ++++++++++++++++++ src/Surface.zig | 3 ++ src/apprt/gtk/class/application.zig | 6 +++- src/build/GhosttyXcodebuild.zig | 2 +- src/build/mdgen/ghostty_1_footer.md | 13 ++++++++ src/build/mdgen/ghostty_5_header.md | 39 +++++++++++++++++++---- src/cli/args.zig | 2 +- src/global.zig | 23 ++++++-------- src/main_ghostty.zig | 49 ++++++++++++++++------------- 9 files changed, 124 insertions(+), 43 deletions(-) diff --git a/HACKING.md b/HACKING.md index 0a4bbef20..bde50ec99 100644 --- a/HACKING.md +++ b/HACKING.md @@ -93,6 +93,36 @@ produced. > may ask you to fix it and close the issue. It isn't a maintainers job to > review a PR so broken that it requires significant rework to be acceptable. +## Logging + +Ghostty can write logs to a number of destinations. On all platforms, logging to +`stderr` is available. Depending on the platform and how Ghostty was launched, +logs sent to `stderr` may be stored by the system and made available for later +retrieval. + +On Linux if Ghostty is launched by the default `systemd` user service, you can use +`journald` to see Ghostty's logs: `journalctl --user --unit app-com.mitchellh.ghostty.service`. + +On macOS logging to the macOS unified log is available and enabled by default. +Use the system `log` CLI to view Ghostty's logs: `sudo log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"'`. + +Ghostty's logging can be configured in two ways. The first is by what +optimization level Ghostty is compiled with. If Ghostty is compiled with `Debug` +optimizations debug logs will be output to `stderr`. If Ghostty is compiled with +any other optimization the debug logs will not be output to `stderr`. + +Ghostty also checks the `GHOSTTY_LOG` environment variable. It can be used +to control which destinations receive logs. Ghostty currently defines two +destinations: + +- `stderr` - logging to `stderr`. +- `macos` - logging to macOS's unified log (has no effect on non-macOS platforms). + +Combine values with a comma to enable multiple destinations. Prefix a +destination with `no-` to disable it. Enabling and disabling destinations +can be done at the same time. Setting `GHOSTTY_LOG` to `true` will enable all +destinations. Setting `GHOSTTY_LOG` to `false` will disable all destinations. + ## Linting ### Prettier diff --git a/src/Surface.zig b/src/Surface.zig index 19c2662c1..96aaf84d8 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -607,6 +607,9 @@ pub fn init( }; errdefer env.deinit(); + // don't leak GHOSTTY_LOG to any subprocesses + env.remove("GHOSTTY_LOG"); + // Initialize our IO backend var io_exec = try termio.Exec.init(alloc, .{ .command = command, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index d404304d0..c951cc6ac 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -8,6 +8,8 @@ const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); +const build_config = @import("../../../build_config.zig"); +const state = &@import("../../../global.zig").state; const i18n = @import("../../../os/main.zig").i18n; const apprt = @import("../../../apprt.zig"); const cgroup = @import("../cgroup.zig"); @@ -2677,7 +2679,9 @@ fn setGtkEnv(config: *const CoreConfig) error{NoSpaceLeft}!void { /// disable it. @"vulkan-disable": bool = false, } = .{ - .opengl = config.@"gtk-opengl-debug", + // `gtk-opengl-debug` dumps logs directly to stderr so both must be true + // to enable OpenGL debugging. + .opengl = state.logging.stderr and config.@"gtk-opengl-debug", }; var gdk_disable: struct { diff --git a/src/build/GhosttyXcodebuild.zig b/src/build/GhosttyXcodebuild.zig index 27691d744..5ca4c5e9a 100644 --- a/src/build/GhosttyXcodebuild.zig +++ b/src/build/GhosttyXcodebuild.zig @@ -151,7 +151,7 @@ pub fn init( // This overrides our default behavior and forces logs to show // up on stderr (in addition to the centralized macOS log). - open.setEnvironmentVariable("GHOSTTY_LOG", "1"); + open.setEnvironmentVariable("GHOSTTY_LOG", "stderr,macos"); // Configure how we're launching open.setEnvironmentVariable("GHOSTTY_MAC_LAUNCH_SOURCE", "zig_run"); diff --git a/src/build/mdgen/ghostty_1_footer.md b/src/build/mdgen/ghostty_1_footer.md index 88aa16273..a63a85fd4 100644 --- a/src/build/mdgen/ghostty_1_footer.md +++ b/src/build/mdgen/ghostty_1_footer.md @@ -37,6 +37,19 @@ precedence over the XDG environment locations. : **WINDOWS ONLY:** alternate location to search for configuration files. +**GHOSTTY_LOG** + +: The `GHOSTTY_LOG` environment variable can be used to control which +destinations receive logs. Ghostty currently defines two destinations: + +: - `stderr` - logging to `stderr`. +: - `macos` - logging to macOS's unified log (has no effect on non-macOS platforms). + +: Combine values with a comma to enable multiple destinations. Prefix a +destination with `no-` to disable it. Enabling and disabling destinations +can be done at the same time. Setting `GHOSTTY_LOG` to `true` will enable all +destinations. Setting `GHOSTTY_LOG` to `false` will disable all destinations. + # BUGS See GitHub issues: diff --git a/src/build/mdgen/ghostty_5_header.md b/src/build/mdgen/ghostty_5_header.md index b9d4cb751..2b12f546a 100644 --- a/src/build/mdgen/ghostty_5_header.md +++ b/src/build/mdgen/ghostty_5_header.md @@ -73,16 +73,12 @@ the public config files of many Ghostty users for examples and inspiration. ## Configuration Errors If your configuration file has any errors, Ghostty does its best to ignore -them and move on. Configuration errors currently show up in the log. The log -is written directly to stderr, so it is up to you to figure out how to access -that for your system (for now). On macOS, you can also use the system `log` CLI -utility with `log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"'`. +them and move on. Configuration errors will be logged. ## Debugging Configuration You can verify that configuration is being properly loaded by looking at the -debug output of Ghostty. Documentation for how to view the debug output is in -the "building Ghostty" section at the end of the README. +debug output of Ghostty. In the debug output, you should see in the first 20 lines or so messages about loading (or not loading) a configuration file, as well as any errors it may have @@ -93,3 +89,34 @@ will fall back to default values for erroneous keys. You can also view the full configuration Ghostty is loading using `ghostty +show-config` from the command-line. Use the `--help` flag to additional options for that command. + +## Logging + +Ghostty can write logs to a number of destinations. On all platforms, logging to +`stderr` is available. Depending on the platform and how Ghostty was launched, +logs sent to `stderr` may be stored by the system and made available for later +retrieval. + +On Linux if Ghostty is launched by the default `systemd` user service, you can use +`journald` to see Ghostty's logs: `journalctl --user --unit app-com.mitchellh.ghostty.service`. + +On macOS logging to the macOS unified log is available and enabled by default. +--Use the system `log` CLI to view Ghostty's logs: `sudo log stream level debug +--predicate 'subsystem=="com.mitchellh.ghostty"'`. + +Ghostty's logging can be configured in two ways. The first is by what +optimization level Ghostty is compiled with. If Ghostty is compiled with `Debug` +optimizations debug logs will be output to `stderr`. If Ghostty is compiled with +any other optimization the debug logs will not be output to `stderr`. + +Ghostty also checks the `GHOSTTY_LOG` environment variable. It can be used +to control which destinations receive logs. Ghostty currently defines two +destinations: + +- `stderr` - logging to `stderr`. +- `macos` - logging to macOS's unified log (has no effect on non-macOS platforms). + +Combine values with a comma to enable multiple destinations. Prefix a +destination with `no-` to disable it. Enabling and disabling destinations +can be done at the same time. Setting `GHOSTTY_LOG` to `true` will enable all +destinations. Setting `GHOSTTY_LOG` to `false` will disable all destinations. diff --git a/src/cli/args.zig b/src/cli/args.zig index 43a15ca06..bd5060d69 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -604,7 +604,7 @@ pub fn parseAutoStruct( return result; } -fn parsePackedStruct(comptime T: type, v: []const u8) !T { +pub fn parsePackedStruct(comptime T: type, v: []const u8) !T { const info = @typeInfo(T).@"struct"; comptime assert(info.layout == .@"packed"); diff --git a/src/global.zig b/src/global.zig index 8034fabe0..29eaf5f36 100644 --- a/src/global.zig +++ b/src/global.zig @@ -39,9 +39,13 @@ pub const GlobalState = struct { resources_dir: internal_os.ResourcesDir, /// Where logging should go - pub const Logging = union(enum) { - disabled: void, - stderr: void, + pub const Logging = packed struct { + /// Whether to log to stderr. For lib mode we always disable stderr + /// logging by default. Otherwise it's enabled by default. + stderr: bool = build_config.app_runtime != .none, + /// Whether to log to macOS's unified logging. Enabled by default + /// on macOS. + macos: bool = builtin.os.tag.isDarwin(), }; /// Initialize the global state. @@ -61,7 +65,7 @@ pub const GlobalState = struct { .gpa = null, .alloc = undefined, .action = null, - .logging = .{ .stderr = {} }, + .logging = .{}, .rlimits = .{}, .resources_dir = .{}, }; @@ -100,12 +104,7 @@ pub const GlobalState = struct { // If we have an action executing, we disable logging by default // since we write to stderr we don't want logs messing up our // output. - if (self.action != null) self.logging = .{ .disabled = {} }; - - // For lib mode we always disable stderr logging by default. - if (comptime build_config.app_runtime == .none) { - self.logging = .{ .disabled = {} }; - } + if (self.action != null) self.logging.stderr = false; // I don't love the env var name but I don't have it in my heart // to parse CLI args 3 times (once for actions, once for config, @@ -114,9 +113,7 @@ pub const GlobalState = struct { // easy to set. if ((try internal_os.getenv(self.alloc, "GHOSTTY_LOG"))) |v| { defer v.deinit(self.alloc); - if (v.value.len > 0) { - self.logging = .{ .stderr = {} }; - } + self.logging = cli.args.parsePackedStruct(Logging, v.value) catch .{}; } // Setup our signal handlers before logging diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index 261e0ad7d..72d602989 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -118,19 +118,17 @@ fn logFn( comptime format: []const u8, args: anytype, ) void { - // Stuff we can do before the lock - const level_txt = comptime level.asText(); - const prefix = if (scope == .default) ": " else "(" ++ @tagName(scope) ++ "): "; - - // Lock so we are thread-safe - std.debug.lockStdErr(); - defer std.debug.unlockStdErr(); - // On Mac, we use unified logging. To view this: // // sudo log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"' // - if (builtin.target.os.tag.isDarwin()) { + // macOS logging is thread safe so no need for locks/mutexes + macos: { + if (comptime !builtin.target.os.tag.isDarwin()) break :macos; + if (!state.logging.macos) break :macos; + + const prefix = if (scope == .default) "" else @tagName(scope) ++ ": "; + // Convert our levels to Mac levels const mac_level: macos.os.LogType = switch (level) { .debug => .debug, @@ -143,26 +141,35 @@ fn logFn( // but we shouldn't be logging too much. const logger = macos.os.Log.create(build_config.bundle_id, @tagName(scope)); defer logger.release(); - logger.log(std.heap.c_allocator, mac_level, format, args); + logger.log(std.heap.c_allocator, mac_level, prefix ++ format, args); } - switch (state.logging) { - .disabled => {}, + stderr: { + // don't log debug messages to stderr unless we are a debug build + if (comptime builtin.mode != .Debug and level == .debug) break :stderr; - .stderr => { - // Always try default to send to stderr - var buffer: [1024]u8 = undefined; - var stderr = std.fs.File.stderr().writer(&buffer); - const writer = &stderr.interface; - nosuspend writer.print(level_txt ++ prefix ++ format ++ "\n", args) catch return; - // TODO: Do we want to use flushless stderr in the future? - writer.flush() catch {}; - }, + // skip if we are not logging to stderr + if (!state.logging.stderr) break :stderr; + + // Lock so we are thread-safe + var buf: [64]u8 = undefined; + const stderr = std.debug.lockStderrWriter(&buf); + defer std.debug.unlockStderrWriter(); + + const level_txt = comptime level.asText(); + const prefix = if (scope == .default) ": " else "(" ++ @tagName(scope) ++ "): "; + nosuspend stderr.print(level_txt ++ prefix ++ format ++ "\n", args) catch break :stderr; + nosuspend stderr.flush() catch break :stderr; } } pub const std_options: std.Options = .{ // Our log level is always at least info in every build mode. + // + // Note, we don't lower this to debug even with conditional logging + // via GHOSTTY_LOG because our debug logs are very expensive to + // calculate and we want to make sure they're optimized out in + // builds. .log_level = switch (builtin.mode) { .Debug => .debug, else => .info, From 78e539d68453fcedb29b31c7a296a9a816c7858e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Dec 2025 12:28:40 -0800 Subject: [PATCH 74/93] Revert "macos: populate the sparkle:channel element" --- dist/macos/update_appcast_tag.py | 2 -- dist/macos/update_appcast_tip.py | 2 -- .../Sources/Features/Update/UpdatePopoverView.swift | 12 ++---------- 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/dist/macos/update_appcast_tag.py b/dist/macos/update_appcast_tag.py index 8c2ee8314..2cb20dd5d 100644 --- a/dist/macos/update_appcast_tag.py +++ b/dist/macos/update_appcast_tag.py @@ -77,8 +77,6 @@ elem = ET.SubElement(item, "title") elem.text = f"Build {build}" elem = ET.SubElement(item, "pubDate") elem.text = now.strftime(pubdate_format) -elem = ET.SubElement(item, "sparkle:channel") -elem.text = "stable" elem = ET.SubElement(item, "sparkle:version") elem.text = build elem = ET.SubElement(item, "sparkle:shortVersionString") diff --git a/dist/macos/update_appcast_tip.py b/dist/macos/update_appcast_tip.py index 1876f0a17..ff1fb4be5 100644 --- a/dist/macos/update_appcast_tip.py +++ b/dist/macos/update_appcast_tip.py @@ -75,8 +75,6 @@ elem = ET.SubElement(item, "title") elem.text = f"Build {build}" elem = ET.SubElement(item, "pubDate") elem.text = now.strftime(pubdate_format) -elem = ET.SubElement(item, "sparkle:channel") -elem.text = "tip" elem = ET.SubElement(item, "sparkle:version") elem.text = build elem = ET.SubElement(item, "sparkle:shortVersionString") diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index 2c56e5f4e..87d76f801 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -125,15 +125,7 @@ fileprivate struct UpdateAvailableView: View { let dismiss: DismissAction private let labelWidth: CGFloat = 60 - - private func releaseDateString(date: Date, channel: String?) -> String { - let dateString = date.formatted(date: .abbreviated, time: .omitted) - if let channel, !channel.isEmpty { - return "\(dateString) (\(channel))" - } - return dateString - } - + var body: some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 12) { @@ -165,7 +157,7 @@ fileprivate struct UpdateAvailableView: View { Text("Released:") .foregroundColor(.secondary) .frame(width: labelWidth, alignment: .trailing) - Text(releaseDateString(date: date, channel: update.appcastItem.channel)) + Text(date.formatted(date: .abbreviated, time: .omitted)) } .font(.system(size: 11)) } From 32395fd83837913a2d4a43998bfd76da744ec887 Mon Sep 17 00:00:00 2001 From: Elad Kaplan Date: Tue, 16 Dec 2025 10:09:07 +0200 Subject: [PATCH 75/93] Fix cmd-click opening of relative/local paths --- src/Surface.zig | 24 +++++++++++++++++++++++- src/config/url.zig | 20 ++++++++++++++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 19c2662c1..6c00af575 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2034,6 +2034,23 @@ pub fn pwd( return try alloc.dupe(u8, terminal_pwd); } +/// Resolves a relative file path to an absolute path using the terminal's pwd. +fn resolvePathForOpening( + self: *Surface, + path: []const u8, +) Allocator.Error!?[]const u8 { + if (!std.fs.path.isAbsolute(path)) { + const terminal_pwd = self.io.terminal.getPwd() orelse { + return null; + }; + + const resolved = try std.fs.path.resolve(self.alloc, &.{ terminal_pwd, path }); + return resolved; + } + + return null; +} + /// Returns the x/y coordinate of where the IME (Input Method Editor) /// keyboard should be rendered. pub fn imePoint(self: *const Surface) apprt.IMEPos { @@ -4262,7 +4279,12 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { .trim = false, }); defer self.alloc.free(str); - try self.openUrl(.{ .kind = .unknown, .url = str }); + + const resolved_path = try self.resolvePathForOpening(str); + defer if (resolved_path) |p| self.alloc.free(p); + + const url_to_open = resolved_path orelse str; + try self.openUrl(.{ .kind = .unknown, .url = url_to_open }); }, ._open_osc8 => { diff --git a/src/config/url.zig b/src/config/url.zig index da3928aff..1901cb6f0 100644 --- a/src/config/url.zig +++ b/src/config/url.zig @@ -26,7 +26,7 @@ pub const regex = "(?:" ++ url_schemes ++ \\)(?: ++ ipv6_url_pattern ++ - \\|[\w\-.~:/?#@!$&*+,;=%]+(?:[\(\[]\w*[\)\]])?)+(? Date: Tue, 16 Dec 2025 10:17:54 +0200 Subject: [PATCH 76/93] Add a description to the test section comment --- src/config/url.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/url.zig b/src/config/url.zig index 1901cb6f0..fdbc964d7 100644 --- a/src/config/url.zig +++ b/src/config/url.zig @@ -253,6 +253,7 @@ test "url regex" { .input = "IPv6 in markdown [link](http://[2001:db8::1]/docs)", .expect = "http://[2001:db8::1]/docs", }, + // File paths with spaces .{ .input = "./spaces-end. ", .expect = "./spaces-end. ", From c4cd2ca81d93c4af8d75cd930a6e8691ee36018c Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 16 Dec 2025 08:24:18 -0500 Subject: [PATCH 77/93] zsh: removed unused self_dir variable This came from the original Kitty script on which ours is based, but we don't use it. --- src/shell-integration/zsh/ghostty-integration | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index c87630c92..febf3e59c 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -93,9 +93,6 @@ _entrypoint() { _ghostty_deferred_init() { builtin emulate -L zsh -o no_warn_create_global -o no_aliases - # The directory where ghostty-integration is located: /../shell-integration/zsh. - builtin local self_dir="${functions_source[_ghostty_deferred_init]:A:h}" - # Enable semantic markup with OSC 133. _ghostty_precmd() { builtin local -i cmd_status=$? From 3f504f33e540b35f772505f6fd5f1a8702ac0c5b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 16 Dec 2025 06:47:28 -0800 Subject: [PATCH 78/93] ci: color scheme GHA uploads to mirror This changes our GHA that updates our color schemes to also upload it to our dependency mirror at the same time. This prevents issues where the upstream disappears, which we've had many times. --- .github/workflows/update-colorschemes.yml | 25 +++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index dc3ebb2b6..4ca4d2901 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -37,16 +37,33 @@ jobs: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - name: Run zig fetch - id: zig_fetch + - name: Download colorschemes + id: download env: GH_TOKEN: ${{ github.token }} run: | # Get the latest release from iTerm2-Color-Schemes RELEASE_INFO=$(gh api repos/mbadolato/iTerm2-Color-Schemes/releases/latest) TAG_NAME=$(echo "$RELEASE_INFO" | jq -r '.tag_name') - nix develop -c zig fetch --save="iterm2_themes" "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/${TAG_NAME}/ghostty-themes.tgz" + FILENAME="ghostty-themes-${TAG_NAME}.tgz" + mkdir -p upload + curl -L -o "upload/${FILENAME}" "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/${TAG_NAME}/ghostty-themes.tgz" echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT + echo "filename=$FILENAME" >> $GITHUB_OUTPUT + + - name: Upload to R2 + uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4 + with: + r2-account-id: ${{ secrets.CF_R2_DEPS_ACCOUNT_ID }} + r2-access-key-id: ${{ secrets.CF_R2_DEPS_AWS_KEY }} + r2-secret-access-key: ${{ secrets.CF_R2_DEPS_SECRET_KEY }} + r2-bucket: ghostty-deps + source-dir: upload + destination-dir: ./ + + - name: Run zig fetch + run: | + nix develop -c zig fetch --save="iterm2_themes" "https://deps.files.ghostty.org/${{ steps.download.outputs.filename }}" - name: Update zig cache hash run: | @@ -75,5 +92,5 @@ jobs: build.zig.zon.json flatpak/zig-packages.json body: | - Upstream release: https://github.com/mbadolato/iTerm2-Color-Schemes/releases/tag/${{ steps.zig_fetch.outputs.tag_name }} + Upstream release: https://github.com/mbadolato/iTerm2-Color-Schemes/releases/tag/${{ steps.download.outputs.tag_name }} labels: dependencies From 1a8eb52e998921aaf3d7f7233fe3d9996fad67e8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 16 Dec 2025 06:52:28 -0800 Subject: [PATCH 79/93] ci: disable many macOS builds we don't use This disables a bunch of configurations that we don't need to actually test for. The main one we want to keep building is Freetype because we sometimes use this to compare behaviors, but Coretext is the default. This is one of the primary drivers of CI CPU time. --- .github/workflows/test.yml | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 854458c09..3a5bf58df 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,7 @@ jobs: - build-linux-libghostty - build-nix - build-macos - - build-macos-matrix + - build-macos-freetype - build-snap - build-windows - test @@ -464,7 +464,7 @@ jobs: cd macos xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" - build-macos-matrix: + build-macos-freetype: runs-on: namespace-profile-ghostty-macos-tahoe needs: test steps: @@ -493,18 +493,10 @@ jobs: - name: Test All run: | nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=freetype - nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext - nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_freetype - nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_harfbuzz - nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_noshape - name: Build All run: | nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=freetype - nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext - nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext_freetype - nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext_harfbuzz - nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext_noshape build-windows: runs-on: windows-2022 From ef0fec473ae2478c03f3eedd8c6310457809964f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 16 Dec 2025 06:59:49 -0800 Subject: [PATCH 80/93] ci: move flatpak out to a triggered build similar to snap --- .github/workflows/flatpak.yml | 50 +++++++++++++++++++++++++++++++++++ .github/workflows/test.yml | 46 +++++++++++++------------------- 2 files changed, 69 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/flatpak.yml diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml new file mode 100644 index 000000000..7c4256e0e --- /dev/null +++ b/.github/workflows/flatpak.yml @@ -0,0 +1,50 @@ +on: + workflow_dispatch: + inputs: + source-run-id: + description: run id of the workflow that generated the artifact + required: true + type: string + source-artifact-id: + description: source tarball built during build-dist + required: true + type: string + +name: Flatpak + +jobs: + build: + if: github.repository == 'ghostty-org/ghostty' + name: "Flatpak" + container: + image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-47 + options: --privileged + strategy: + fail-fast: false + matrix: + variant: + - arch: x86_64 + runner: namespace-profile-ghostty-md + - arch: aarch64 + runner: namespace-profile-ghostty-md-arm64 + runs-on: ${{ matrix.variant.runner }} + steps: + - name: Download Source Tarball Artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + run-id: ${{ inputs.source-run-id }} + artifact-ids: ${{ inputs.source-artifact-id }} + github-token: ${{ github.token }} + + - name: Extract tarball + run: | + mkdir dist + tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz + + - uses: flatpak/flatpak-github-actions/flatpak-builder@92ae9851ad316786193b1fd3f40c4b51eb5cb101 # v6.6 + with: + bundle: com.mitchellh.ghostty + manifest-path: dist/flatpak/com.mitchellh.ghostty.yml + cache-key: flatpak-builder-${{ github.sha }} + arch: ${{ matrix.variant.arch }} + verbose: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3a5bf58df..30f34120a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,7 +44,7 @@ jobs: - test-debian-13 - valgrind - zig-fmt - - flatpak + steps: - id: status name: Determine status @@ -421,6 +421,24 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + trigger-flatpak: + if: github.event_name != 'pull_request' + runs-on: namespace-profile-ghostty-xsm + needs: [build-dist, build-flatpak] + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Trigger Flatpak workflow + run: | + gh workflow run \ + flatpak.yml \ + --ref ${{ github.ref_name || 'main' }} \ + --field source-run-id=${{ github.run_id }} \ + --field source-artifact-id=${{ needs.build-dist.outputs.artifact-id }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + build-macos: runs-on: namespace-profile-ghostty-macos-tahoe needs: test @@ -1084,32 +1102,6 @@ jobs: build-args: | DISTRO_VERSION=13 - flatpak: - if: github.repository == 'ghostty-org/ghostty' - name: "Flatpak" - container: - image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-47 - options: --privileged - strategy: - fail-fast: false - matrix: - variant: - - arch: x86_64 - runner: namespace-profile-ghostty-md - - arch: aarch64 - runner: namespace-profile-ghostty-md-arm64 - runs-on: ${{ matrix.variant.runner }} - needs: test - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: flatpak/flatpak-github-actions/flatpak-builder@92ae9851ad316786193b1fd3f40c4b51eb5cb101 # v6.6 - with: - bundle: com.mitchellh.ghostty - manifest-path: flatpak/com.mitchellh.ghostty.yml - cache-key: flatpak-builder-${{ github.sha }} - arch: ${{ matrix.variant.arch }} - verbose: true - valgrind: if: github.repository == 'ghostty-org/ghostty' runs-on: namespace-profile-ghostty-lg From d680404fae55b0dc99f1f66c7dbaaf97677fc002 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Fri, 14 Nov 2025 14:33:06 +0100 Subject: [PATCH 81/93] macOS: save&restore quick terminal state --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + macos/Sources/App/macOS/AppDelegate.swift | 63 +++++++++++++- .../QuickTerminalController.swift | 83 +++++++++++++------ .../QuickTerminalRestorableState.swift | 26 ++++++ .../QuickTerminalScreenStateCache.swift | 11 ++- .../Terminal/TerminalRestorable.swift | 75 +++++++++++------ 6 files changed, 200 insertions(+), 59 deletions(-) create mode 100644 macos/Sources/Features/QuickTerminal/QuickTerminalRestorableState.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index eb5d706c3..562166c87 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -95,6 +95,7 @@ Features/QuickTerminal/QuickTerminal.xib, Features/QuickTerminal/QuickTerminalController.swift, Features/QuickTerminal/QuickTerminalPosition.swift, + Features/QuickTerminal/QuickTerminalRestorableState.swift, Features/QuickTerminal/QuickTerminalScreen.swift, Features/QuickTerminal/QuickTerminalScreenStateCache.swift, Features/QuickTerminal/QuickTerminalSize.swift, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 043d85e1e..1697f7438 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -99,11 +99,35 @@ class AppDelegate: NSObject, /// The global undo manager for app-level state such as window restoration. lazy var undoManager = ExpiringUndoManager() + /// The current state of the quick terminal. + private var quickTerminalControllerState: QuickTerminalState = .uninitialized + /// Our quick terminal. This starts out uninitialized and only initializes if used. - private(set) lazy var quickController = QuickTerminalController( - ghostty, - position: derivedConfig.quickTerminalPosition - ) + var quickController: QuickTerminalController { + switch quickTerminalControllerState { + case .initialized(let controller): + return controller + + case .pendingRestore(let state): + let controller = QuickTerminalController( + ghostty, + position: derivedConfig.quickTerminalPosition, + baseConfig: state.baseConfig, + restorationState: state + ) + quickTerminalControllerState = .initialized(controller) + return controller + + case .uninitialized: + let controller = QuickTerminalController( + ghostty, + position: derivedConfig.quickTerminalPosition, + restorationState: nil + ) + quickTerminalControllerState = .initialized(controller) + return controller + } + } /// Manages updates let updateController = UpdateController() @@ -996,10 +1020,31 @@ class AppDelegate: NSObject, func application(_ app: NSApplication, willEncodeRestorableState coder: NSCoder) { Self.logger.debug("application will save window state") + + guard ghostty.config.windowSaveState != "never" else { return } + + // Encode our quick terminal state if we have it. + switch quickTerminalControllerState { + case .initialized(let controller) where controller.restorable: + let data = QuickTerminalRestorableState(from: controller) + data.encode(with: coder) + + case .pendingRestore(let state): + state.encode(with: coder) + + default: + break + } } func application(_ app: NSApplication, didDecodeRestorableState coder: NSCoder) { Self.logger.debug("application will restore window state") + + // Decode our quick terminal state. + if ghostty.config.windowSaveState != "never", + let state = QuickTerminalRestorableState(coder: coder) { + quickTerminalControllerState = .pendingRestore(state) + } } //MARK: - UNUserNotificationCenterDelegate @@ -1273,6 +1318,16 @@ extension AppDelegate: NSMenuItemValidation { } } +/// Represents the state of the quick terminal controller. +private enum QuickTerminalState { + /// Controller has not been initialized and has no pending restoration state. + case uninitialized + /// Restoration state is pending; controller will use this when first accessed. + case pendingRestore(QuickTerminalRestorableState) + /// Controller has been initialized. + case initialized(QuickTerminalController) +} + @globalActor fileprivate actor AppIconActor: GlobalActor { static let shared = AppIconActor() diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 201289736..4377b6510 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -22,7 +22,7 @@ class QuickTerminalController: BaseTerminalController { private var previousActiveSpace: CGSSpace? = nil /// Cache for per-screen window state. - private let screenStateCache = QuickTerminalScreenStateCache() + let screenStateCache: QuickTerminalScreenStateCache /// Non-nil if we have hidden dock state. private var hiddenDock: HiddenDock? = nil @@ -32,15 +32,27 @@ class QuickTerminalController: BaseTerminalController { /// Tracks if we're currently handling a manual resize to prevent recursion private var isHandlingResize: Bool = false - + + /// This is set to false by init if the window managed by this controller should not be restorable. + /// For example, terminals executing custom scripts are not restorable. + let restorable: Bool + private var restorationState: QuickTerminalRestorableState? + init(_ ghostty: Ghostty.App, position: QuickTerminalPosition = .top, baseConfig base: Ghostty.SurfaceConfiguration? = nil, - surfaceTree tree: SplitTree? = nil + restorationState: QuickTerminalRestorableState? = nil, ) { self.position = position self.derivedConfig = DerivedConfig(ghostty.config) - + // The window we manage is not restorable if we've specified a command + // to execute. We do this because the restored window is meaningless at the + // time of writing this: it'd just restore to a shell in the same directory + // as the script. We may want to revisit this behavior when we have scrollback + // restoration. + restorable = (base?.command ?? "") == "" + self.restorationState = restorationState + self.screenStateCache = QuickTerminalScreenStateCache(stateByDisplay: restorationState?.screenStateEntries ?? [:]) // Important detail here: we initialize with an empty surface tree so // that we don't start a terminal process. This gets started when the // first terminal is shown in `animateIn`. @@ -104,8 +116,8 @@ class QuickTerminalController: BaseTerminalController { // window close so we can animate out. window.delegate = self - // The quick window is not restorable (yet!). "Yet" because in theory we can - // make this restorable, but it isn't currently implemented. + // The quick window is restored by `screenStateCache`. + // We disable this for better control window.isRestorable = false // Setup our configured appearance that we support. @@ -342,16 +354,33 @@ class QuickTerminalController: BaseTerminalController { // animate out. if surfaceTree.isEmpty, let ghostty_app = ghostty.app { - var config = Ghostty.SurfaceConfiguration() - config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1" - - let view = Ghostty.SurfaceView(ghostty_app, baseConfig: config) - surfaceTree = SplitTree(view: view) - focusedSurface = view + if let tree = restorationState?.surfaceTree, !tree.isEmpty { + surfaceTree = tree + let view = tree.first(where: { $0.id.uuidString == restorationState?.focusedSurface }) ?? tree.first! + focusedSurface = view + // Add a short delay to check if the correct surface is focused. + // Each SurfaceWrapper defaults its FocusedValue to itself; without this delay, + // the tree often focuses the first surface instead of the intended one. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + if !view.focused { + self.focusedSurface = view + self.makeWindowKey(window) + } + } + } else { + var config = Ghostty.SurfaceConfiguration() + config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1" + + let view = Ghostty.SurfaceView(ghostty_app, baseConfig: config) + surfaceTree = SplitTree(view: view) + focusedSurface = view + } } // Animate the window in animateWindowIn(window: window, from: position) + // Clear the restoration state after first use + restorationState = nil } func animateOut() { @@ -370,6 +399,22 @@ class QuickTerminalController: BaseTerminalController { animateWindowOut(window: window, to: position) } + func saveScreenState(exitFullscreen: Bool) { + // If we are in fullscreen, then we exit fullscreen. We do this immediately so + // we have th correct window.frame for the save state below. + if exitFullscreen, let fullscreenStyle, fullscreenStyle.isFullscreen { + fullscreenStyle.exit() + } + guard let window else { return } + // Save the current window frame before animating out. This preserves + // the user's preferred window size and position for when the quick + // terminal is reactivated with a new surface. Without this, SwiftUI + // would reset the window to its minimum content size. + if window.frame.width > 0 && window.frame.height > 0, let screen = window.screen { + screenStateCache.save(frame: window.frame, for: screen) + } + } + private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) { guard let screen = derivedConfig.quickTerminalScreen.screen else { return } @@ -494,19 +539,7 @@ class QuickTerminalController: BaseTerminalController { } private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) { - // If we are in fullscreen, then we exit fullscreen. We do this immediately so - // we have th correct window.frame for the save state below. - if let fullscreenStyle, fullscreenStyle.isFullscreen { - fullscreenStyle.exit() - } - - // Save the current window frame before animating out. This preserves - // the user's preferred window size and position for when the quick - // terminal is reactivated with a new surface. Without this, SwiftUI - // would reset the window to its minimum content size. - if window.frame.width > 0 && window.frame.height > 0, let screen = window.screen { - screenStateCache.save(frame: window.frame, for: screen) - } + saveScreenState(exitFullscreen: true) // If we hid the dock then we unhide it. hiddenDock = nil diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalRestorableState.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalRestorableState.swift new file mode 100644 index 000000000..1fd8642d8 --- /dev/null +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalRestorableState.swift @@ -0,0 +1,26 @@ +import Cocoa + +struct QuickTerminalRestorableState: TerminalRestorable { + static var version: Int { 1 } + + let focusedSurface: String? + let surfaceTree: SplitTree + let screenStateEntries: QuickTerminalScreenStateCache.Entries + + init(from controller: QuickTerminalController) { + controller.saveScreenState(exitFullscreen: true) + self.focusedSurface = controller.focusedSurface?.id.uuidString + self.surfaceTree = controller.surfaceTree + self.screenStateEntries = controller.screenStateCache.stateByDisplay + } + + init(copy other: QuickTerminalRestorableState) { + self = other + } + + var baseConfig: Ghostty.SurfaceConfiguration? { + var config = Ghostty.SurfaceConfiguration() + config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1" + return config + } +} diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift index 7dc53816c..a1c17abb9 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift @@ -7,6 +7,8 @@ import Cocoa /// to restore to its previous size and position when reopened. It uses stable display UUIDs /// to survive NSScreen garbage collection and automatically prunes stale entries. class QuickTerminalScreenStateCache { + typealias Entries = [UUID: DisplayEntry] + /// The maximum number of saved screen states we retain. This is to avoid some kind of /// pathological memory growth in case we get our screen state serializing wrong. I don't /// know anyone with more than 10 screens, so let's just arbitrarily go with that. @@ -16,9 +18,10 @@ class QuickTerminalScreenStateCache { private static let screenStaleTTL: TimeInterval = 14 * 24 * 60 * 60 /// Keyed by display UUID to survive NSScreen garbage collection. - private var stateByDisplay: [UUID: DisplayEntry] = [:] - - init() { + private(set) var stateByDisplay: Entries = [:] + + init(stateByDisplay: Entries = [:]) { + self.stateByDisplay = stateByDisplay NotificationCenter.default.addObserver( self, selector: #selector(onScreensChanged(_:)), @@ -96,7 +99,7 @@ class QuickTerminalScreenStateCache { } } - private struct DisplayEntry { + struct DisplayEntry: Codable { var frame: NSRect var screenSize: CGSize var scale: CGFloat diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 425f7ffb1..fd0f4eab5 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -1,10 +1,47 @@ import Cocoa +protocol TerminalRestorable: Codable { + static var selfKey: String { get } + static var versionKey: String { get } + static var version: Int { get } + init(copy other: Self) + + /// Returns a base configuration to use when restoring terminal surfaces. + /// Override this to provide custom environment variables or other configuration. + var baseConfig: Ghostty.SurfaceConfiguration? { get } +} + +extension TerminalRestorable { + static var selfKey: String { "state" } + static var versionKey: String { "version" } + + /// Default implementation returns nil (no custom base config). + var baseConfig: Ghostty.SurfaceConfiguration? { nil } + + init?(coder aDecoder: NSCoder) { + // If the version doesn't match then we can't decode. In the future we can perform + // version upgrading or something but for now we only have one version so we + // don't bother. + guard aDecoder.decodeInteger(forKey: Self.versionKey) == Self.version else { + return nil + } + + guard let v = aDecoder.decodeObject(of: CodableBridge.self, forKey: Self.selfKey) else { + return nil + } + + self.init(copy: v.value) + } + + func encode(with coder: NSCoder) { + coder.encode(Self.version, forKey: Self.versionKey) + coder.encode(CodableBridge(self), forKey: Self.selfKey) + } +} + /// The state stored for terminal window restoration. -class TerminalRestorableState: Codable { - static let selfKey = "state" - static let versionKey = "version" - static let version: Int = 7 +class TerminalRestorableState: TerminalRestorable { + class var version: Int { 7 } let focusedSurface: String? let surfaceTree: SplitTree @@ -20,28 +57,12 @@ class TerminalRestorableState: Codable { self.titleOverride = controller.titleOverride } - init?(coder aDecoder: NSCoder) { - // If the version doesn't match then we can't decode. In the future we can perform - // version upgrading or something but for now we only have one version so we - // don't bother. - guard aDecoder.decodeInteger(forKey: Self.versionKey) == Self.version else { - return nil - } - - guard let v = aDecoder.decodeObject(of: CodableBridge.self, forKey: Self.selfKey) else { - return nil - } - - self.surfaceTree = v.value.surfaceTree - self.focusedSurface = v.value.focusedSurface - self.effectiveFullscreenMode = v.value.effectiveFullscreenMode - self.tabColor = v.value.tabColor - self.titleOverride = v.value.titleOverride - } - - func encode(with coder: NSCoder) { - coder.encode(Self.version, forKey: Self.versionKey) - coder.encode(CodableBridge(self), forKey: Self.selfKey) + required init(copy other: TerminalRestorableState) { + self.surfaceTree = other.surfaceTree + self.focusedSurface = other.focusedSurface + self.effectiveFullscreenMode = other.effectiveFullscreenMode + self.tabColor = other.tabColor + self.titleOverride = other.titleOverride } } @@ -170,3 +191,5 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { } } } + + From f7d0d72f19cb8c6d6bd510a2f77e18e82d560794 Mon Sep 17 00:00:00 2001 From: greathongtu Date: Wed, 17 Dec 2025 00:31:39 +0800 Subject: [PATCH 82/93] remove auto theme include in config-template --- src/cli/list_themes.zig | 12 ++++++++---- src/config/config-template | 7 ------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 716d662b6..42aff9d56 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -2,6 +2,7 @@ const std = @import("std"); const args = @import("args.zig"); const Action = @import("ghostty.zig").Action; const Config = @import("../config/Config.zig"); +const configpkg = @import("../config.zig"); const themepkg = @import("../config/theme.zig"); const tui = @import("tui.zig"); const global_state = &@import("../global.zig").state; @@ -197,7 +198,7 @@ pub fn run(gpa_alloc: std.mem.Allocator) !u8 { } fn resolveAutoThemePath(alloc: std.mem.Allocator) ![]u8 { - const main_cfg_path = try Config.preferredDefaultFilePath(alloc); + const main_cfg_path = try configpkg.preferredDefaultFilePath(alloc); defer alloc.free(main_cfg_path); const base_dir = std.fs.path.dirname(main_cfg_path) orelse return error.BadPathName; @@ -815,8 +816,8 @@ const Preview = struct { .save => { const theme = self.themes[self.filtered.items[self.current]]; - const width = 90; - const height = 12; + const width = 92; + const height = 17; const child = win.child( .{ .x_off = win.width / 2 -| width / 2, @@ -839,7 +840,10 @@ const Preview = struct { "", "Save the configuration file and then reload it to apply the new theme.", "", - "Or press 'w' to write an auto theme file.", + "Or press 'w' to write an auto theme file to your system's preferred default config path.", + "Then add the following line to your Ghostty configuration and reload:", + "", + "config-file = ?auto/theme.ghostty", "", "For more details on configuration and themes, visit the Ghostty documentation:", "", diff --git a/src/config/config-template b/src/config/config-template index d71c36a9e..63309137a 100644 --- a/src/config/config-template +++ b/src/config/config-template @@ -24,13 +24,6 @@ # reloaded while running; some only apply to new windows and others may require # a full restart to take effect. -# Auto theme include -# ================== -# This include makes it easy to pick a theme via `ghostty +list-themes`: -# press Enter on a theme, then 'w' to write the auto theme file. -# This path is relative to this config file. -config-file = ?auto/theme.ghostty - # Config syntax crash course # ========================== # # The config file consists of simple key-value pairs, From d364e421a84de0af7002a237e9930f91237f2af1 Mon Sep 17 00:00:00 2001 From: lorenries Date: Wed, 8 Oct 2025 12:04:42 -0400 Subject: [PATCH 83/93] introduce split-preserve-zoom config to maintain zoomed splits during navigation --- .../Terminal/BaseTerminalController.swift | 12 ++++++-- macos/Sources/Ghostty/Ghostty.Config.swift | 14 +++++++++ src/apprt/gtk/class/split_tree.zig | 29 +++++++++++++++++++ src/config/Config.zig | 12 ++++++++ src/config/c_get.zig | 16 ++++++++++ 5 files changed, 81 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 6336f0f55..98f1bcbf8 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -621,9 +621,14 @@ class BaseTerminalController: NSWindowController, return } - // Remove the zoomed state for this surface tree. if surfaceTree.zoomed != nil { - surfaceTree = .init(root: surfaceTree.root, zoomed: nil) + if derivedConfig.splitPreserveZoom.contains(.navigation) { + surfaceTree = SplitTree( + root: surfaceTree.root, + zoomed: surfaceTree.root?.node(view: nextSurface)) + } else { + surfaceTree = SplitTree(root: surfaceTree.root, zoomed: nil) + } } // Move focus to the next surface @@ -1188,17 +1193,20 @@ class BaseTerminalController: NSWindowController, let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon let windowStepResize: Bool let focusFollowsMouse: Bool + let splitPreserveZoom: Ghostty.Config.SplitPreserveZoom init() { self.macosTitlebarProxyIcon = .visible self.windowStepResize = false self.focusFollowsMouse = false + self.splitPreserveZoom = .init() } init(_ config: Ghostty.Config) { self.macosTitlebarProxyIcon = config.macosTitlebarProxyIcon self.windowStepResize = config.windowStepResize self.focusFollowsMouse = config.focusFollowsMouse + self.splitPreserveZoom = config.splitPreserveZoom } } } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 47826a104..7ea545f7a 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -124,6 +124,14 @@ extension Ghostty { return .init(rawValue: v) } + var splitPreserveZoom: SplitPreserveZoom { + guard let config = self.config else { return .init() } + var v: CUnsignedInt = 0 + let key = "split-preserve-zoom" + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .init() } + return .init(rawValue: v) + } + var initialWindow: Bool { guard let config = self.config else { return true } var v = true; @@ -690,6 +698,12 @@ extension Ghostty.Config { static let border = BellFeatures(rawValue: 1 << 4) } + struct SplitPreserveZoom: OptionSet { + let rawValue: CUnsignedInt + + static let navigation = SplitPreserveZoom(rawValue: 1 << 0) + } + enum MacDockDropBehavior: String { case new_tab = "new-tab" case new_window = "new-window" diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 48656c951..46b3268d9 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -340,6 +340,35 @@ pub const SplitTree = extern struct { const surface = tree.nodes[target.idx()].leaf; surface.grabFocus(); + // We also need to setup our last_focused to this because if we + // trigger a tree change like below, the grab focus above never + // actually triggers in time to set this and this ensures we + // grab focus to the right thing. + const old_last_focused = self.private().last_focused.get(); + defer if (old_last_focused) |v| v.unref(); // unref strong ref from get + self.private().last_focused.set(surface); + errdefer self.private().last_focused.set(old_last_focused); + + if (tree.zoomed != null) { + const app = Application.default(); + const config_obj = app.getConfig(); + defer config_obj.unref(); + const config = config_obj.get(); + + if (!config.@"split-preserve-zoom".navigation) { + tree.zoomed = null; + } else { + tree.zoom(target); + } + + // When the zoom state changes our tree state changes and + // we need to send the proper notifications to trigger + // relayout. + const object = self.as(gobject.Object); + object.notifyByPspec(properties.tree.impl.param_spec); + object.notifyByPspec(properties.@"is-zoomed".impl.param_spec); + } + return true; } diff --git a/src/config/Config.zig b/src/config/Config.zig index 409e35516..7ced916fe 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -985,6 +985,14 @@ palette: Palette = .{}, /// Available since: 1.1.0 @"split-divider-color": ?Color = null, +/// Control when Ghostty preserves the zoomed state of a split. This is a packed +/// struct so more options can be added in the future. The `navigation` option +/// keeps the current split zoomed when split navigation (`goto_split`) changes +/// the focused split. +/// +/// Example: `split-preserve-zoom = navigation` +@"split-preserve-zoom": SplitPreserveZoom = .{}, + /// The foreground and background color for search matches. This only applies /// to non-focused search matches, also known as candidate matches. /// @@ -7423,6 +7431,10 @@ pub const ShellIntegrationFeatures = packed struct { path: bool = true, }; +pub const SplitPreserveZoom = packed struct { + navigation: bool = false, +}; + pub const RepeatableCommand = struct { value: std.ArrayListUnmanaged(inputpkg.Command) = .empty, diff --git a/src/config/c_get.zig b/src/config/c_get.zig index 0f8f897a2..dcfdc6716 100644 --- a/src/config/c_get.zig +++ b/src/config/c_get.zig @@ -222,3 +222,19 @@ test "c_get: background-blur" { try testing.expectEqual(-2, cval); } } + +test "c_get: split-preserve-zoom" { + const testing = std.testing; + const alloc = testing.allocator; + + var c = try Config.default(alloc); + defer c.deinit(); + + var bits: c_uint = undefined; + try testing.expect(get(&c, .@"split-preserve-zoom", @ptrCast(&bits))); + try testing.expectEqual(@as(c_uint, 0), bits); + + c.@"split-preserve-zoom".navigation = true; + try testing.expect(get(&c, .@"split-preserve-zoom", @ptrCast(&bits))); + try testing.expectEqual(@as(c_uint, 1), bits); +} From 4883fd938e90c4f88cc66cb0b24da8eb2d9304fa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 16 Dec 2025 11:27:51 -0800 Subject: [PATCH 84/93] config: better docs for split-preserve-zoom --- src/config/Config.zig | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 7ced916fe..9b4c1ed94 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -985,12 +985,20 @@ palette: Palette = .{}, /// Available since: 1.1.0 @"split-divider-color": ?Color = null, -/// Control when Ghostty preserves the zoomed state of a split. This is a packed -/// struct so more options can be added in the future. The `navigation` option -/// keeps the current split zoomed when split navigation (`goto_split`) changes -/// the focused split. +/// Control when Ghostty preserves a zoomed split. Under normal circumstances, +/// any operation that changes focus or layout of the split tree in a window +/// will unzoom any zoomed split. This configuration allows you to control +/// this behavior. +/// +/// This can be set to `navigation` to preserve the zoomed split state +/// when navigating to another split (e.g. via `goto_split`). This will +/// change the zoomed split to the newly focused split instead of unzooming. +/// +/// Any options can also be prefixed with `no-` to disable that option. /// /// Example: `split-preserve-zoom = navigation` +/// +/// Available since: 1.3.0 @"split-preserve-zoom": SplitPreserveZoom = .{}, /// The foreground and background color for search matches. This only applies From 4c6d3f8ed2d53f5881ca525750650066c55c5c9f Mon Sep 17 00:00:00 2001 From: himura467 Date: Fri, 10 Oct 2025 11:01:41 +0900 Subject: [PATCH 85/93] macos: add `toggle_background_opacity` keybind action --- include/ghostty.h | 1 + .../QuickTerminalController.swift | 10 +++++++- .../Terminal/BaseTerminalController.swift | 19 +++++++++++++++ .../Terminal/TerminalController.swift | 8 +++++++ .../Window Styles/TerminalWindow.swift | 3 +++ macos/Sources/Ghostty/Ghostty.App.swift | 24 +++++++++++++++++++ src/Surface.zig | 6 +++++ src/apprt/action.zig | 4 ++++ src/input/Binding.zig | 11 +++++++++ src/input/command.zig | 6 +++++ 10 files changed, 91 insertions(+), 1 deletion(-) diff --git a/include/ghostty.h b/include/ghostty.h index b0395b89e..47db34e71 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -803,6 +803,7 @@ typedef enum { GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL, GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE, GHOSTTY_ACTION_TOGGLE_VISIBILITY, + GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY, GHOSTTY_ACTION_MOVE_TAB, GHOSTTY_ACTION_GOTO_TAB, GHOSTTY_ACTION_GOTO_SPLIT, diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 4377b6510..d2db44d2d 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -313,6 +313,13 @@ class QuickTerminalController: BaseTerminalController { animateOut() } + override func toggleBackgroundOpacity() { + super.toggleBackgroundOpacity() + + // Sync the window appearance with the new opacity state + syncAppearance() + } + // MARK: Methods func toggle() { @@ -608,7 +615,8 @@ class QuickTerminalController: BaseTerminalController { guard window.isVisible else { return } // If we have window transparency then set it transparent. Otherwise set it opaque. - if (self.derivedConfig.backgroundOpacity < 1) { + // Also check if the user has overridden transparency to be fully opaque. + if !isBackgroundOpaque && self.derivedConfig.backgroundOpacity < 1 { window.isOpaque = false // This is weird, but we don't use ".clear" because this creates a look that diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 98f1bcbf8..892bef555 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -78,6 +78,9 @@ class BaseTerminalController: NSWindowController, /// The configuration derived from the Ghostty config so we don't need to rely on references. private var derivedConfig: DerivedConfig + /// Track whether background is forced opaque (true) or using config transparency (false) + var isBackgroundOpaque: Bool = false + /// The cancellables related to our focused surface. private var focusedSurfaceCancellables: Set = [] @@ -812,6 +815,22 @@ class BaseTerminalController: NSWindowController, } } + // MARK: Background Opacity + + /// Toggle the background opacity between transparent and opaque states. + /// If the configured background-opacity is already opaque (>= 1), this resets + /// the override flag to false so that future config changes take effect. + /// Subclasses should override this to sync their appearance after toggling. + func toggleBackgroundOpacity() { + // If config is already opaque, just ensure override is disabled + if ghostty.config.backgroundOpacity >= 1 { + isBackgroundOpaque = false + } else { + // Otherwise toggle between transparent and opaque + isBackgroundOpaque.toggle() + } + } + // MARK: Fullscreen /// Toggle fullscreen for the given mode. diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index a980723ba..29b856cdb 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -176,6 +176,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr syncAppearance(focusedSurface.derivedConfig) } + override func toggleBackgroundOpacity() { + super.toggleBackgroundOpacity() + + // Sync the window appearance with the new opacity state + guard let focusedSurface else { return } + syncAppearance(focusedSurface.derivedConfig) + } + // MARK: Terminal Creation /// Returns all the available terminal controllers present in the app currently. diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 0c0ac0646..730cdea65 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -469,7 +469,10 @@ class TerminalWindow: NSWindow { // Window transparency only takes effect if our window is not native fullscreen. // In native fullscreen we disable transparency/opacity because the background // becomes gray and widgets show through. + // Also check if the user has overridden transparency to be fully opaque. + let forceOpaque = terminalController?.isBackgroundOpaque ?? false if !styleMask.contains(.fullScreen) && + !forceOpaque && surfaceConfig.backgroundOpacity < 1 { isOpaque = false diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 2cd0a362a..4e9d039d4 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -573,6 +573,9 @@ extension Ghostty { case GHOSTTY_ACTION_TOGGLE_VISIBILITY: toggleVisibility(app, target: target) + case GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY: + toggleBackgroundOpacity(app, target: target) + case GHOSTTY_ACTION_KEY_SEQUENCE: keySequence(app, target: target, v: action.action.key_sequence) @@ -1375,6 +1378,27 @@ extension Ghostty { } } + private static func toggleBackgroundOpacity( + _ app: ghostty_app_t, + target: ghostty_target_s + ) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("toggle background opacity does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface, + let surfaceView = self.surfaceView(from: surface), + let controller = surfaceView.window?.windowController as? BaseTerminalController else { return } + + controller.toggleBackgroundOpacity() + + default: + assertionFailure() + } + } + private static func toggleSecureInput( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/src/Surface.zig b/src/Surface.zig index 4786e0b86..d84e786f3 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5518,6 +5518,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .toggle_background_opacity => return try self.rt_app.performAction( + .{ .surface = self }, + .toggle_background_opacity, + {}, + ), + .show_on_screen_keyboard => return try self.rt_app.performAction( .{ .surface = self }, .show_on_screen_keyboard, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index af1c22552..7b9e9d222 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -115,6 +115,9 @@ pub const Action = union(Key) { /// Toggle the visibility of all Ghostty terminal windows. toggle_visibility, + /// Toggle the window background opacity. This currently only works on macOS. + toggle_background_opacity, + /// Moves a tab by a relative offset. /// /// Adjusts the tab position based on `offset` (e.g., -1 for left, +1 @@ -335,6 +338,7 @@ pub const Action = union(Key) { toggle_quick_terminal, toggle_command_palette, toggle_visibility, + toggle_background_opacity, move_tab, goto_tab, goto_split, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 31672bc1a..9f3ad8a2a 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -755,6 +755,16 @@ pub const Action = union(enum) { /// Only implemented on macOS. toggle_visibility, + /// Toggle the window background opacity between transparent and opaque. + /// + /// This does nothing when `background-opacity` is set to 1 or above. + /// + /// When `background-opacity` is less than 1, this action will either make + /// the window transparent or not depending on its current transparency state. + /// + /// Only implemented on macOS. + toggle_background_opacity, + /// Check for updates. /// /// Only implemented on macOS. @@ -1240,6 +1250,7 @@ pub const Action = union(enum) { .toggle_secure_input, .toggle_mouse_reporting, .toggle_command_palette, + .toggle_background_opacity, .show_on_screen_keyboard, .reset_window_size, .crash, diff --git a/src/input/command.zig b/src/input/command.zig index a377effa2..d5daafd7d 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -618,6 +618,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Toggle whether mouse events are reported to terminal applications.", }}, + .toggle_background_opacity => comptime &.{.{ + .action = .toggle_background_opacity, + .title = "Toggle Background Opacity", + .description = "Toggle the window background between transparent and opaque.", + }}, + .check_for_updates => comptime &.{.{ .action = .check_for_updates, .title = "Check for Updates", From ded3dd4cbcf84e0156c3cdd4eb43ae0a2d6c2e89 Mon Sep 17 00:00:00 2001 From: himura467 Date: Fri, 10 Oct 2025 12:56:20 +0900 Subject: [PATCH 86/93] refactor(macos): do nothing if `background-opacity >= 1` --- .../Terminal/BaseTerminalController.swift | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 892bef555..f8e0cc8e9 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -818,17 +818,14 @@ class BaseTerminalController: NSWindowController, // MARK: Background Opacity /// Toggle the background opacity between transparent and opaque states. - /// If the configured background-opacity is already opaque (>= 1), this resets - /// the override flag to false so that future config changes take effect. - /// Subclasses should override this to sync their appearance after toggling. + /// Do nothing if the configured background-opacity is >= 1 (already opaque). + /// Subclasses should override this to add platform-specific checks and sync appearance. func toggleBackgroundOpacity() { - // If config is already opaque, just ensure override is disabled - if ghostty.config.backgroundOpacity >= 1 { - isBackgroundOpaque = false - } else { - // Otherwise toggle between transparent and opaque - isBackgroundOpaque.toggle() - } + // Do nothing if config is already fully opaque + guard ghostty.config.backgroundOpacity < 1 else { return } + + // Toggle between transparent and opaque + isBackgroundOpaque.toggle() } // MARK: Fullscreen From 8d49c698e47519a889269cbdcdef33f705135767 Mon Sep 17 00:00:00 2001 From: himura467 Date: Fri, 10 Oct 2025 12:56:52 +0900 Subject: [PATCH 87/93] refactor(macos): do nothing if in fullscreen --- macos/Sources/Features/Terminal/TerminalController.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 29b856cdb..cc5b48700 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -177,6 +177,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } override func toggleBackgroundOpacity() { + // Do nothing if in fullscreen (transparency doesn't apply in fullscreen) + guard let window = self.window, !window.styleMask.contains(.fullScreen) else { return } + super.toggleBackgroundOpacity() // Sync the window appearance with the new opacity state From ba2cbef1f1d3effcfbc672dbea52ac9b0b01bdcf Mon Sep 17 00:00:00 2001 From: himura467 Date: Fri, 10 Oct 2025 14:56:15 +0900 Subject: [PATCH 88/93] apprt/gtk: list `toggle_background_opacity` as unimplemented --- src/apprt/gtk/class/application.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index c951cc6ac..be0f3f2c8 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -741,6 +741,7 @@ pub const Application = extern struct { .close_all_windows, .float_window, .toggle_visibility, + .toggle_background_opacity, .cell_size, .key_sequence, .render_inspector, From f9a1f526c897a3f8c94c697f3624de0e1c250fcd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 16 Dec 2025 11:38:25 -0800 Subject: [PATCH 89/93] update some copy for the background opacity toggle --- src/apprt/action.zig | 4 +++- src/input/command.zig | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 7b9e9d222..8e0a9d018 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -115,7 +115,9 @@ pub const Action = union(Key) { /// Toggle the visibility of all Ghostty terminal windows. toggle_visibility, - /// Toggle the window background opacity. This currently only works on macOS. + /// Toggle the window background opacity. This only has an effect + /// if the window started as transparent (non-opaque), and toggles + /// it between fully opaque and the configured background opacity. toggle_background_opacity, /// Moves a tab by a relative offset. diff --git a/src/input/command.zig b/src/input/command.zig index d5daafd7d..6ac4312a9 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -621,7 +621,7 @@ fn actionCommands(action: Action.Key) []const Command { .toggle_background_opacity => comptime &.{.{ .action = .toggle_background_opacity, .title = "Toggle Background Opacity", - .description = "Toggle the window background between transparent and opaque.", + .description = "Toggle the background opacity of a window that started transparent.", }}, .check_for_updates => comptime &.{.{ From 95f4093e96f98dc963575a043ece13778c339cd1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 16 Dec 2025 12:59:51 -0800 Subject: [PATCH 90/93] macos: make syncAppearance a virtual method on BaseTerminalController --- .../QuickTerminalController.swift | 9 +----- .../Terminal/BaseTerminalController.swift | 21 +++++++++++++- .../Terminal/TerminalController.swift | 29 +++++-------------- .../Window Styles/TerminalWindow.swift | 1 + 4 files changed, 29 insertions(+), 31 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index d2db44d2d..8a642034f 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -313,13 +313,6 @@ class QuickTerminalController: BaseTerminalController { animateOut() } - override func toggleBackgroundOpacity() { - super.toggleBackgroundOpacity() - - // Sync the window appearance with the new opacity state - syncAppearance() - } - // MARK: Methods func toggle() { @@ -603,7 +596,7 @@ class QuickTerminalController: BaseTerminalController { }) } - private func syncAppearance() { + override func syncAppearance() { guard let window else { return } defer { updateColorSchemeForSurfaceTree() } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index f8e0cc8e9..5f067c128 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -815,7 +815,7 @@ class BaseTerminalController: NSWindowController, } } - // MARK: Background Opacity + // MARK: Appearance /// Toggle the background opacity between transparent and opaque states. /// Do nothing if the configured background-opacity is >= 1 (already opaque). @@ -823,9 +823,25 @@ class BaseTerminalController: NSWindowController, func toggleBackgroundOpacity() { // Do nothing if config is already fully opaque guard ghostty.config.backgroundOpacity < 1 else { return } + + // Do nothing if in fullscreen (transparency doesn't apply in fullscreen) + guard let window, !window.styleMask.contains(.fullScreen) else { return } // Toggle between transparent and opaque isBackgroundOpaque.toggle() + + // Update our appearance + syncAppearance() + } + + /// Override this to resync any appearance related properties. This will be called automatically + /// when certain window properties change that affect appearance. The list below should be updated + /// as we add new things: + /// + /// - ``toggleBackgroundOpacity`` + func syncAppearance() { + // Purposely a no-op. This lets subclasses override this and we can call + // it virtually from here. } // MARK: Fullscreen @@ -888,6 +904,9 @@ class BaseTerminalController: NSWindowController, } else { updateOverlayIsVisible = defaultUpdateOverlayVisibility() } + + // Always resync our appearance + syncAppearance() } // MARK: Clipboard Confirmation diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index cc5b48700..8a0c5f46d 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -165,28 +165,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } - - override func fullscreenDidChange() { - super.fullscreenDidChange() - - // When our fullscreen state changes, we resync our appearance because some - // properties change when fullscreen or not. - guard let focusedSurface else { return } - - syncAppearance(focusedSurface.derivedConfig) - } - - override func toggleBackgroundOpacity() { - // Do nothing if in fullscreen (transparency doesn't apply in fullscreen) - guard let window = self.window, !window.styleMask.contains(.fullScreen) else { return } - - super.toggleBackgroundOpacity() - - // Sync the window appearance with the new opacity state - guard let focusedSurface else { return } - syncAppearance(focusedSurface.derivedConfig) - } - // MARK: Terminal Creation /// Returns all the available terminal controllers present in the app currently. @@ -500,6 +478,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr tabWindowsHash = v self.relabelTabs() } + + override func syncAppearance() { + // When our focus changes, we update our window appearance based on the + // currently focused surface. + guard let focusedSurface else { return } + syncAppearance(focusedSurface.derivedConfig) + } private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { // Let our window handle its own appearance diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 730cdea65..4196df97f 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -469,6 +469,7 @@ class TerminalWindow: NSWindow { // Window transparency only takes effect if our window is not native fullscreen. // In native fullscreen we disable transparency/opacity because the background // becomes gray and widgets show through. + // // Also check if the user has overridden transparency to be fully opaque. let forceOpaque = terminalController?.isBackgroundOpaque ?? false if !styleMask.contains(.fullScreen) && From ccc2d32aa55021d967162fa3545017e6eb398b10 Mon Sep 17 00:00:00 2001 From: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com> Date: Wed, 17 Dec 2025 05:16:27 +0800 Subject: [PATCH 91/93] Fix macOS log command for Ghostty Corrected the command for viewing Ghostty logs on macOS. --- src/build/mdgen/ghostty_5_header.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/build/mdgen/ghostty_5_header.md b/src/build/mdgen/ghostty_5_header.md index 2b12f546a..ce3196eb6 100644 --- a/src/build/mdgen/ghostty_5_header.md +++ b/src/build/mdgen/ghostty_5_header.md @@ -101,7 +101,7 @@ On Linux if Ghostty is launched by the default `systemd` user service, you can u `journald` to see Ghostty's logs: `journalctl --user --unit app-com.mitchellh.ghostty.service`. On macOS logging to the macOS unified log is available and enabled by default. ---Use the system `log` CLI to view Ghostty's logs: `sudo log stream level debug +--Use the system `log` CLI to view Ghostty's logs: `sudo log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"'`. Ghostty's logging can be configured in two ways. The first is by what From a25a5360f3f8788a16ec7a5185fa8548a9f8a34e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 16 Dec 2025 13:11:26 -0800 Subject: [PATCH 92/93] ai: add /review-branch command This is a subcommand I've been using for some time. It takes an optional issue/PR number as context and produces a prompt to review a branch for how well it addresses the issue along with any isolated issues it spots. Example: https://ampcode.com/threads/T-019b2877-475f-758d-ae88-93c722561576 --- .agents/commands/review-branch | 75 ++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100755 .agents/commands/review-branch diff --git a/.agents/commands/review-branch b/.agents/commands/review-branch new file mode 100755 index 000000000..edd8bcbd8 --- /dev/null +++ b/.agents/commands/review-branch @@ -0,0 +1,75 @@ +#!/usr/bin/env nu + +# A command to review the changes made in the current Git branch. +# +# IMPORTANT: This command is prompted to NOT write any code and to ONLY +# produce a review summary. You should still be vigilant when running this +# but that is the expected behavior. +# +# The optional `` parameter can be an issue number, PR number, +# or a full GitHub URL to provide additional context. +def main [ + issue?: any, # Optional GitHub issue/PR number or URL for context +] { + let issueContext = if $issue != null { + let data = gh issue view $issue --json author,title,number,body,comments | from json + let comments = if ($data.comments? != null) { + $data.comments | each { |comment| + let author = if ($comment.author?.login? != null) { $comment.author.login } else { "unknown" } + $" +### Comment by ($author) +($comment.body) +" | str trim + } | str join "\n\n" + } else { + "" + } + + $" +## Source Issue: ($data.title) \(#($data.number)\) + +### Description +($data.body) + +### Comments +($comments) +" + } else { + "" + } + + $" +# Branch Review + +Inspect the changes made in this Git branch. Identify any possible issues +and suggest improvements. Do not write code. Explain the problems clearly +and propose a brief plan for addressing them. +($issueContext) +## Your Tasks + +You are an experienced software developer with expertise in code review. + +Review the change history between the current branch and its +base branch. Analyze all relevant code for possible issues, including but +not limited to: + +- Code quality and readability +- Code style that matches or mimics the rest of the codebase +- Potential bugs or logical errors +- Edge cases that may not be handled +- Performance considerations +- Security vulnerabilities +- Backwards compatibility \(if applicable\) +- Test coverage and effectiveness + +For test coverage, consider if the changes are in an area of the codebase +that is testable. If so, check if there are appropriate tests added or +modified. Consider if the code itself should be modified to be more +testable. + +Think deeply about the implications of the changes here and proposed. +Consult the oracle if you have access to it. + +**ONLY CREATE A SUMMARY. DO NOT WRITE ANY CODE.** +" | str trim +} From f37acdf6a0935d30ea0dbb7ea3a26d5cc55bba4f Mon Sep 17 00:00:00 2001 From: Dominique Martinet Date: Fri, 14 Nov 2025 13:22:36 +0900 Subject: [PATCH 93/93] gtk/opengl: print an error when OpenGL version is too old #1123 added a warning when the OpenGL version is too old, but it is never used because GTK enforces the version set with gl_area.setRequiredVersion() before prepareContext() is called: we end up with a generic "failed to make GL context" error: warning(gtk_ghostty_surface): failed to make GL context current: Unable to create a GL context warning(gtk_ghostty_surface): this error is almost always due to a library, driver, or GTK issue warning(gtk_ghostty_surface): this is a common cause of this issue: https://ghostty.org/docs/help/gtk-opengl-context This patch removes the requirement at the GTK level and lets the ghostty renderer check, now failing as follow: info(opengl): loaded OpenGL 4.2 error(opengl): OpenGL version is too old. Ghostty requires OpenGL 4.3 warning(gtk_ghostty_surface): failed to initialize surface err=error.OpenGLOutdated warning(gtk_ghostty_surface): surface failed to initialize err=error.SurfaceError (Note that this does not render a ghostty window, unlike the previous error which rendered the "Unable to acquire an OpenGL context for rendering." view, so while the error itself is easier to understand it might be harder to view) --- src/apprt/gtk/class/surface.zig | 8 +------- src/renderer/OpenGL.zig | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 548ae1a6a..93d1beeb2 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -1658,13 +1658,7 @@ pub const Surface = extern struct { }; priv.drop_target.setGtypes(&drop_target_types, drop_target_types.len); - // Initialize our GLArea. We only set the values we can't set - // in our blueprint file. - const gl_area = priv.gl_area; - gl_area.setRequiredVersion( - renderer.OpenGL.MIN_VERSION_MAJOR, - renderer.OpenGL.MIN_VERSION_MINOR, - ); + // Setup properties we can't set from our Blueprint file. self.as(gtk.Widget).setCursorFromName("text"); // Initialize our config diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index da577f957..4b01da0c5 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -137,15 +137,7 @@ fn prepareContext(getProcAddress: anytype) !void { errdefer gl.glad.unload(); log.info("loaded OpenGL {}.{}", .{ major, minor }); - // Enable debug output for the context. - try gl.enable(gl.c.GL_DEBUG_OUTPUT); - - // Register our debug message callback with the OpenGL context. - gl.glad.context.DebugMessageCallback.?(glDebugMessageCallback, null); - - // Enable SRGB framebuffer for linear blending support. - try gl.enable(gl.c.GL_FRAMEBUFFER_SRGB); - + // Need to check version before trying to enable it if (major < MIN_VERSION_MAJOR or (major == MIN_VERSION_MAJOR and minor < MIN_VERSION_MINOR)) { @@ -155,6 +147,15 @@ fn prepareContext(getProcAddress: anytype) !void { ); return error.OpenGLOutdated; } + + // Enable debug output for the context. + try gl.enable(gl.c.GL_DEBUG_OUTPUT); + + // Register our debug message callback with the OpenGL context. + gl.glad.context.DebugMessageCallback.?(glDebugMessageCallback, null); + + // Enable SRGB framebuffer for linear blending support. + try gl.enable(gl.c.GL_FRAMEBUFFER_SRGB); } /// This is called early right after surface creation.