From 3e5c4590da8e6303eface76535b3798c182f598a Mon Sep 17 00:00:00 2001 From: LN Liberda Date: Wed, 8 Oct 2025 05:24:30 +0200 Subject: [PATCH 01/52] Add system integration for highway --- src/build/SharedDeps.zig | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index dfa676bba..e530e4885 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -719,15 +719,19 @@ pub fn addSimd( } // Highway - if (b.lazyDependency("highway", .{ - .target = target, - .optimize = optimize, - })) |highway_dep| { - m.linkLibrary(highway_dep.artifact("highway")); - if (static_libs) |v| try v.append( - b.allocator, - highway_dep.artifact("highway").getEmittedBin(), - ); + if (b.systemIntegrationOption("highway", .{})) { + m.linkSystemLibrary("libhwy", dynamic_link_opts); + } else { + if (b.lazyDependency("highway", .{ + .target = target, + .optimize = optimize, + })) |highway_dep| { + m.linkLibrary(highway_dep.artifact("highway")); + if (static_libs) |v| try v.append( + b.allocator, + highway_dep.artifact("highway").getEmittedBin(), + ); + } } // utfcpp - This is used as a dependency on our hand-written C++ code @@ -746,6 +750,7 @@ pub fn addSimd( m.addIncludePath(b.path("src")); { // From hwy/detect_targets.h + const HWY_AVX10_2: c_int = 1 << 3; const HWY_AVX3_SPR: c_int = 1 << 4; const HWY_AVX3_ZEN4: c_int = 1 << 6; const HWY_AVX3_DL: c_int = 1 << 7; @@ -756,7 +761,7 @@ pub fn addSimd( // The performance difference between AVX2 and AVX512 is not // significant for our use case and AVX512 is very rare on consumer // hardware anyways. - const HWY_DISABLED_TARGETS: c_int = HWY_AVX3_SPR | HWY_AVX3_ZEN4 | HWY_AVX3_DL | HWY_AVX3; + const HWY_DISABLED_TARGETS: c_int = HWY_AVX10_2 | HWY_AVX3_SPR | HWY_AVX3_ZEN4 | HWY_AVX3_DL | HWY_AVX3; m.addCSourceFiles(.{ .files = &.{ From 701a2a1e05806094b5752e42caa3032a118fbdba Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 13 Nov 2025 08:53:04 -0600 Subject: [PATCH 02/52] gtk: update nixpkgs and zig-gobject for Gnome 49 --- build.zig.zon | 6 +++--- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flake.lock | 37 +++++++++++++------------------------ flake.nix | 9 ++++----- flatpak/zig-packages.json | 6 +++--- 7 files changed, 30 insertions(+), 42 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index dfccaf61d..92246cdba 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -55,10 +55,10 @@ .lazy = true, }, .gobject = .{ - // https://github.com/jcollie/ghostty-gobject based on zig_gobject + // https://github.com/ghostty-org/zig-gobject based on zig_gobject // Temporary until we generate them at build time automatically. - .url = "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst", - .hash = "gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV", + .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, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index cd2621b2e..0e3b9b97a 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -24,10 +24,10 @@ "url": "https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz", "hash": "sha256-FKLtu1Ccs+UamlPj9eQ12/WXFgS0uDPmPmB26MCpl7U=" }, - "gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV": { + "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-": { "name": "gobject", - "url": "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst", - "hash": "sha256-SXiqGm81aUn6yq1wFXgNTAULdKOHS/Rzkp5OgNkkcXo=" + "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": "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg=" }, "N-V-__8AALiNBAA-_0gprYr92CjrMj1I5bqNu0TSJOnjFNSr": { "name": "gtk4_layer_shell", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index c38504847..73a769ea4 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -123,11 +123,11 @@ in }; } { - name = "gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV"; + name = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-"; path = fetchZigArtifact { name = "gobject"; - url = "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst"; - hash = "sha256-SXiqGm81aUn6yq1wFXgNTAULdKOHS/Rzkp5OgNkkcXo="; + 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 = "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 6bd86a206..189f7f320 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -6,7 +6,6 @@ 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-09-20-20-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 @@ -27,6 +26,7 @@ https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.t 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/b309dfb4e25a38201d7b300b201a698e02283862.tar.gz https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251110-150531-d5f3d53/ghostty-themes.tgz diff --git a/flake.lock b/flake.lock index 90b97ed4a..0150f7b84 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "flake-compat": { "flake": false, "locked": { - "lastModified": 1747046372, - "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", + "lastModified": 1761588595, + "narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=", "owner": "edolstra", "repo": "flake-compat", - "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", + "rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5", "type": "github" }, "original": { @@ -36,30 +36,17 @@ }, "nixpkgs": { "locked": { - "lastModified": 315532800, - "narHash": "sha256-sV6pJNzFkiPc6j9Bi9JuHBnWdVhtKB/mHgVmMPvDFlk=", - "rev": "82c2e0d6dde50b17ae366d2aa36f224dc19af469", + "lastModified": 1763191728, + "narHash": "sha256-gI9PpaoX4/f28HkjcTbFVpFhtOxSDtOEdFaHZrdETe0=", + "rev": "1d4c88323ac36805d09657d13a5273aea1b34f0c", "type": "tarball", - "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre877938.82c2e0d6dde5/nixexprs.tar.xz" + "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre896415.1d4c88323ac3/nixexprs.tar.xz" }, "original": { "type": "tarball", "url": "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz" } }, - "nixpkgs_2": { - "locked": { - "lastModified": 1758360447, - "narHash": "sha256-XDY3A83bclygHDtesRoaRTafUd80Q30D/Daf9KSG6bs=", - "rev": "8eaee110344796db060382e15d3af0a9fc396e0e", - "type": "tarball", - "url": "https://releases.nixos.org/nixos/unstable/nixos-25.11pre864002.8eaee1103447/nixexprs.tar.xz" - }, - "original": { - "type": "tarball", - "url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz" - } - }, "root": { "inputs": { "flake-compat": "flake-compat", @@ -97,11 +84,11 @@ ] }, "locked": { - "lastModified": 1760401936, - "narHash": "sha256-/zj5GYO5PKhBWGzbHbqT+ehY8EghuABdQ2WGfCwZpCQ=", + "lastModified": 1763295135, + "narHash": "sha256-sGv/NHCmEnJivguGwB5w8LRmVqr1P72OjS+NzcJsssE=", "owner": "mitchellh", "repo": "zig-overlay", - "rev": "365085b6652259753b598d43b723858184980bbe", + "rev": "64f8b42cfc615b2cf99144adf2b7728c7847c72a", "type": "github" }, "original": { @@ -112,7 +99,9 @@ }, "zon2nix": { "inputs": { - "nixpkgs": "nixpkgs_2" + "nixpkgs": [ + "nixpkgs" + ] }, "locked": { "lastModified": 1758405547, diff --git a/flake.nix b/flake.nix index 3dcfef185..18ca3ac18 100644 --- a/flake.nix +++ b/flake.nix @@ -6,7 +6,9 @@ # glibc versions used by our dependencies from Nix are compatible with the # system glibc that the user is building for. # - # We are currently on unstable to get Zig 0.15 for our package.nix + # We are currently on nixpkgs-unstable to get Zig 0.15 for our package.nix and + # Gnome 49/Gtk 4.20. + # nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz"; flake-utils.url = "github:numtide/flake-utils"; @@ -28,10 +30,7 @@ zon2nix = { url = "github:jcollie/zon2nix?rev=bf983aa90ff169372b9fa8c02e57ea75e0b42245"; inputs = { - # Don't override nixpkgs until Zig 0.15 is available in the Nix branch - # we are using for "normal" builds. - # - # nixpkgs.follows = "nixpkgs"; + nixpkgs.follows = "nixpkgs"; }; }; }; diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 8ed18e38b..417284788 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -31,9 +31,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst", - "dest": "vendor/p/gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV", - "sha256": "4978aa1a6f356949facaad7015780d4c050b74a3874bf473929e4e80d924717a" + "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", + "dest": "vendor/p/gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", + "sha256": "d9bd4306f0081d8e4b848b6adfeabd2fab49822ee2b679eb4801dcedf5d60c48" }, { "type": "archive", From f1ab3b20ae6a6798d80cda190073d3d9c079507d Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 13 Nov 2025 08:53:36 -0600 Subject: [PATCH 03/52] gtk: support GTK 4.20 media queries in runtime & custom css --- src/apprt/gtk/class/application.zig | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index eac88f9cf..0d66d16ec 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -1580,7 +1580,7 @@ pub const Application = extern struct { .dark; log.debug("style manager changed scheme={}", .{scheme}); - const priv = self.private(); + const priv: *Private = self.private(); const core_app = priv.core_app; core_app.colorSchemeEvent(self.rt(), scheme) catch |err| { log.warn("error updating app color scheme err={}", .{err}); @@ -1593,6 +1593,26 @@ pub const Application = extern struct { ); }; } + + if (gtk_version.atLeast(4, 20, 0)) { + const gtk_scheme: gtk.InterfaceColorScheme = switch (scheme) { + .light => gtk.InterfaceColorScheme.light, + .dark => gtk.InterfaceColorScheme.dark, + }; + var value = gobject.ext.Value.newFrom(gtk_scheme); + gobject.Object.setProperty( + priv.css_provider.as(gobject.Object), + "prefers-color-scheme", + &value, + ); + for (priv.custom_css_providers.items) |css_provider| { + gobject.Object.setProperty( + css_provider.as(gobject.Object), + "prefers-color-scheme", + &value, + ); + } + } } fn handleReloadConfig( From 7ba88a71786392dccb03da8c37937676f46a7cd1 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 14 Oct 2025 09:35:54 -0500 Subject: [PATCH 04/52] synthetic: make bytes generation more flexible --- src/synthetic/Bytes.zig | 92 ++++++++++++++++++++++++++++++------- src/synthetic/Osc.zig | 72 +++++++++++++++++------------ src/synthetic/cli/Ascii.zig | 19 +++++--- 3 files changed, 130 insertions(+), 53 deletions(-) diff --git a/src/synthetic/Bytes.zig b/src/synthetic/Bytes.zig index 40a94e0e3..7d4c34a33 100644 --- a/src/synthetic/Bytes.zig +++ b/src/synthetic/Bytes.zig @@ -1,4 +1,4 @@ -/// Generates bytes. +//! Generates bytes. const Bytes = @This(); const std = @import("std"); @@ -7,9 +7,7 @@ const Generator = @import("Generator.zig"); /// Random number generator. rand: std.Random, -/// The minimum and maximum length of the generated bytes. The maximum -/// length will be capped to the length of the buffer passed in if the -/// buffer length is smaller. +/// The minimum and maximum length of the generated bytes. min_len: usize = 1, max_len: usize = std.math.maxInt(usize), @@ -18,23 +16,79 @@ max_len: usize = std.math.maxInt(usize), /// side effect of the generator, not an intended use case. alphabet: ?[]const u8 = null, -/// Predefined alphabets. -pub const Alphabet = struct { - pub const ascii = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{}|;':\\\",./<>?`~"; -}; +/// Generate an alphabet given a function that returns true/false for a +/// given byte. +pub fn generateAlphabet(comptime func: fn (u8) bool) []const u8 { + @setEvalBranchQuota(3000); + var count = 0; + for (0..256) |c| { + if (func(c)) count += 1; + } + var alphabet: [count]u8 = undefined; + var i = 0; + for (0..256) |c| { + if (func(c)) { + alphabet[i] = c; + i += 1; + } + } + const result = alphabet; + return &result; +} pub fn generator(self: *Bytes) Generator { return .init(self, next); } -pub fn next(self: *Bytes, writer: *std.Io.Writer, max_len: usize) Generator.Error!void { - std.debug.assert(max_len >= 1); - const len = @min( - self.rand.intRangeAtMostBiased(usize, self.min_len, self.max_len), - max_len, - ); +/// Return a copy of the Bytes, but with a new alphabet. +pub fn newAlphabet(self: *const Bytes, new_alphabet: ?[]const u8) Bytes { + return .{ + .rand = self.rand, + .alphabet = new_alphabet, + .min_len = self.min_len, + .max_len = self.max_len, + }; +} + +/// Return a copy of the Bytes, but with a new min_len. The new min +/// len cannot be more than the previous max_len. +pub fn atLeast(self: *const Bytes, new_min_len: usize) Bytes { + return .{ + .rand = self.rand, + .alphabet = self.alphabet, + .min_len = @min(self.max_len, new_min_len), + .max_len = self.max_len, + }; +} + +/// Return a copy of the Bytes, but with a new max_len. The new max_len cannot +/// be more the previous max_len. +pub fn atMost(self: *const Bytes, new_max_len: usize) Bytes { + return .{ + .rand = self.rand, + .alphabet = self.alphabet, + .min_len = @min(self.min_len, @min(self.max_len, new_max_len)), + .max_len = @min(self.max_len, new_max_len), + }; +} + +pub fn next(self: *const Bytes, writer: *std.Io.Writer, max_len: usize) std.Io.Writer.Error!void { + _ = try self.atMost(max_len).write(writer); +} + +pub fn format(self: *const Bytes, writer: *std.Io.Writer) std.Io.Writer.Error!void { + _ = try self.write(writer); +} + +/// Write some random data and return the number of bytes written. +pub fn write(self: *const Bytes, writer: *std.Io.Writer) std.Io.Writer.Error!usize { + std.debug.assert(self.min_len >= 1); + std.debug.assert(self.max_len >= self.min_len); + + const len = self.rand.intRangeAtMostBiased(usize, self.min_len, self.max_len); var buf: [8]u8 = undefined; + var remaining = len; while (remaining > 0) { const data = buf[0..@min(remaining, buf.len)]; @@ -45,6 +99,8 @@ pub fn next(self: *Bytes, writer: *std.Io.Writer, max_len: usize) Generator.Erro try writer.writeAll(data); remaining -= data.len; } + + return len; } test "bytes" { @@ -52,9 +108,11 @@ test "bytes" { var prng = std.Random.DefaultPrng.init(0); var buf: [256]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var v: Bytes = .{ .rand = prng.random() }; - v.min_len = buf.len; - v.max_len = buf.len; + var v: Bytes = .{ + .rand = prng.random(), + .min_len = buf.len, + .max_len = buf.len, + }; const gen = v.generator(); try gen.next(&writer, buf.len); try testing.expectEqual(buf.len, writer.buffered().len); diff --git a/src/synthetic/Osc.zig b/src/synthetic/Osc.zig index 52940fee9..b43079e1a 100644 --- a/src/synthetic/Osc.zig +++ b/src/synthetic/Osc.zig @@ -35,19 +35,26 @@ p_valid: f64 = 1.0, p_valid_kind: std.enums.EnumArray(ValidKind, f64) = .initFill(1.0), p_invalid_kind: std.enums.EnumArray(InvalidKind, f64) = .initFill(1.0), -/// The alphabet for random bytes (omitting 0x1B and 0x07). -const bytes_alphabet: []const u8 = alphabet: { - var alphabet: [256]u8 = undefined; - for (0..alphabet.len) |i| { - if (i == 0x1B or i == 0x07) { - alphabet[i] = @intCast(i + 1); - } else { - alphabet[i] = @intCast(i); - } - } - const result = alphabet; - break :alphabet &result; -}; +fn checkKvAlphabet(c: u8) bool { + return switch (c) { + std.ascii.control_code.esc, std.ascii.control_code.bel, ';', '=' => false, + else => std.ascii.isPrint(c), + }; +} + +/// The alphabet for random bytes in OSC key/value pairs (omitting 0x1B, +/// 0x07, ';', '='). +pub const kv_alphabet = Bytes.generateAlphabet(checkKvAlphabet); + +fn checkOscAlphabet(c: u8) bool { + return switch (c) { + std.ascii.control_code.esc, std.ascii.control_code.bel => false, + else => true, + }; +} + +/// The alphabet for random bytes in OSCs (omitting 0x1B and 0x07). +pub const osc_alphabet = Bytes.generateAlphabet(checkOscAlphabet); pub fn generator(self: *Osc) Generator { return .init(self, next); @@ -99,35 +106,43 @@ fn nextUnwrapped(self: *Osc, writer: *std.Io.Writer, max_len: usize) Generator.E fn nextUnwrappedValidExact(self: *const Osc, writer: *std.Io.Writer, k: ValidKind, max_len: usize) Generator.Error!void { switch (k) { - .change_window_title => { - try writer.writeAll("0;"); // Set window title - var bytes_gen = self.bytes(); - try bytes_gen.next(writer, max_len - 2); + .change_window_title => change_window_title: { + if (max_len < 3) break :change_window_title; + try writer.print("0;{f}", .{self.bytes().atMost(max_len - 3)}); // Set window title }, - .prompt_start => { + .prompt_start => prompt_start: { + if (max_len < 4) break :prompt_start; + var remaining = max_len; + try writer.writeAll("133;A"); // Start prompt + remaining -= 4; // aid - if (self.rand.boolean()) { - var bytes_gen = self.bytes(); - bytes_gen.max_len = 16; + if (self.rand.boolean()) aid: { + if (remaining < 6) break :aid; try writer.writeAll(";aid="); - try bytes_gen.next(writer, max_len); + remaining -= 5; + remaining -= try self.bytes().newAlphabet(kv_alphabet).atMost(@min(16, remaining)).write(writer); } // redraw - if (self.rand.boolean()) { + if (self.rand.boolean()) redraw: { + if (remaining < 9) break :redraw; try writer.writeAll(";redraw="); if (self.rand.boolean()) { try writer.writeAll("1"); } else { try writer.writeAll("0"); } + remaining -= 9; } }, - .prompt_end => try writer.writeAll("133;B"), // End prompt + .prompt_end => prompt_end: { + if (max_len < 4) break :prompt_end; + try writer.writeAll("133;B"); // End prompt + }, } } @@ -139,14 +154,11 @@ fn nextUnwrappedInvalidExact( ) Generator.Error!void { switch (k) { .random => { - var bytes_gen = self.bytes(); - try bytes_gen.next(writer, max_len); + try self.bytes().atMost(max_len).format(writer); }, .good_prefix => { - try writer.writeAll("133;"); - var bytes_gen = self.bytes(); - try bytes_gen.next(writer, max_len - 4); + try writer.print("133;{f}", .{self.bytes().atMost(max_len - 4)}); }, } } @@ -154,7 +166,7 @@ fn nextUnwrappedInvalidExact( fn bytes(self: *const Osc) Bytes { return .{ .rand = self.rand, - .alphabet = bytes_alphabet, + .alphabet = osc_alphabet, }; } diff --git a/src/synthetic/cli/Ascii.zig b/src/synthetic/cli/Ascii.zig index b2d57fa88..22ca1ffb5 100644 --- a/src/synthetic/cli/Ascii.zig +++ b/src/synthetic/cli/Ascii.zig @@ -3,12 +3,21 @@ const Ascii = @This(); const std = @import("std"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; -const synthetic = @import("../main.zig"); +const Bytes = @import("../Bytes.zig"); const log = std.log.scoped(.@"terminal-stream-bench"); pub const Options = struct {}; +fn checkAsciiAlphabet(c: u8) bool { + return switch (c) { + ' ' => false, + else => std.ascii.isPrint(c), + }; +} + +pub const ascii = Bytes.generateAlphabet(checkAsciiAlphabet); + /// Create a new terminal stream handler for the given arguments. pub fn create( alloc: Allocator, @@ -23,12 +32,10 @@ pub fn destroy(self: *Ascii, alloc: Allocator) void { alloc.destroy(self); } -pub fn run(self: *Ascii, writer: *std.Io.Writer, rand: std.Random) !void { - _ = self; - - var gen: synthetic.Bytes = .{ +pub fn run(_: *Ascii, writer: *std.Io.Writer, rand: std.Random) !void { + var gen: Bytes = .{ .rand = rand, - .alphabet = synthetic.Bytes.Alphabet.ascii, + .alphabet = ascii, }; while (true) { From 51bda77e3a8f584862299e21dbfc5ab338ea4236 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 30 Nov 2025 10:10:50 -0500 Subject: [PATCH 05/52] macos: teach agents about `zig build run` --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index a3e752816..dc2b47a70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,4 +30,5 @@ A file for [guiding coding agents](https://agents.md/). - Do not use `xcodebuild` - Use `zig build` to build the macOS app and any shared Zig code +- Use `zig build run` to build and run the macOS app - Run Xcode tests using `zig build test` From 0c9082eb7235fc46e8851a412568bbf2f61ef3fa Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Sun, 26 Oct 2025 17:24:47 +0100 Subject: [PATCH 06/52] macOS: fix theme reloading ### Background After #9344, the Ghostty theme won't change after switching systems', and reverting #9344 will bring back the issue it fixed. The reason these two issues are related is because the scheme change is based on changes of `effectiveAppearance`, which is also affected by setting the window's `appearance` or changing `NSAppearance.currentDrawing()`. ### Changes Instead of observing `effectiveAppearance`, we now explicitly update the color scheme of surfaces, so that we can control when it happens to avoid callback loops and redundant updates. ### Regression Tests - [x] #8282 - [x] Reloading with `window-theme = light` should update Ghostty with the default dark theme with a dark window theme (break before [#83104ff](https://github.com/ghostty-org/ghostty/commit/83104ff27a42fbcd5a7dec7677d9ed4f9b9c59c8)) - [x] `window-theme = light \n macos-titlebar-style = native` should update Ghostty with the default dark theme with a light window theme - [x] Reloading from the default config to `theme=light:3024 Day,dark:3024 Night \n window-theme = light`, should update Ghostty with the theme `3024 Day` with a light window theme (break on [#d39cc6d](https://github.com/ghostty-org/ghostty/commit/d39cc6d478edd6e1f412fa680e5166ea4f24c898)) - [x] Using `theme=light:3024 Day,dark:3024 Night`; Switching the system's appearance should change Ghostty's appearance (break on [#d39cc6d](https://github.com/ghostty-org/ghostty/commit/d39cc6d478edd6e1f412fa680e5166ea4f24c898)) - [x] Reloading from `theme=light:3024 Day,dark:3024 Night` with a light window theme to the default config, should update Ghostty with the default dark theme with a dark window theme - [x] Reloading from the default config to `theme=light:3024 Day,dark:3024 Night \n window-theme=dark`, should update Ghostty with the theme `3024 Night` with a dark window theme - [x] Reloading from `theme=light:3024 Day,dark:3024 Night \n window-theme=dark` to `theme=light:3024 Day,dark:3024 Night` with light system appearance, should update Ghostty from dark to light - [x] Reload with quick terminal open # Conflicts: # macos/Sources/Features/Terminal/BaseTerminalController.swift --- .../QuickTerminalController.swift | 1 + .../Terminal/BaseTerminalController.swift | 34 +++++++++++++++++++ .../Terminal/TerminalController.swift | 12 ++----- .../Window Styles/TerminalWindow.swift | 5 +++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 20 ----------- 5 files changed, 43 insertions(+), 29 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 4c2052f23..201289736 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -566,6 +566,7 @@ class QuickTerminalController: BaseTerminalController { private func syncAppearance() { guard let window else { return } + defer { updateColorSchemeForSurfaceTree() } // Change the collection behavior of the window depending on the configuration. window.collectionBehavior = derivedConfig.quickTerminalSpaceBehavior.collectionBehavior diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 9104e61ff..1c8e258f7 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -72,6 +72,9 @@ class BaseTerminalController: NSWindowController, /// The previous frame information from the window private var savedFrame: SavedFrame? = nil + /// Cache previously applied appearance to avoid unnecessary updates + private var appliedColorScheme: ghostty_color_scheme_e? + /// The configuration derived from the Ghostty config so we don't need to rely on references. private var derivedConfig: DerivedConfig @@ -1163,4 +1166,35 @@ extension BaseTerminalController: NSMenuItemValidation { return true } } + + // MARK: - Surface Color Scheme + + /// Update the surface tree's color scheme only when it actually changes. + /// + /// Calling ``ghostty_surface_set_color_scheme`` triggers + /// ``syncAppearance(_:)`` via notification, + /// so we avoid redundant calls. + func updateColorSchemeForSurfaceTree() { + /// Derive the target scheme from `window-theme` or system appearance. + /// We set the scheme on surfaces so they pick the correct theme + /// and let ``syncAppearance(_:)`` update the window accordingly. + /// + /// Using App's effectiveAppearance here to prevent incorrect updates. + let themeAppearance = NSApplication.shared.effectiveAppearance + let scheme: ghostty_color_scheme_e + if themeAppearance.isDark { + scheme = GHOSTTY_COLOR_SCHEME_DARK + } else { + scheme = GHOSTTY_COLOR_SCHEME_LIGHT + } + guard scheme != appliedColorScheme else { + return + } + for surfaceView in surfaceTree { + if let surface = surfaceView.surface { + ghostty_surface_set_color_scheme(surface, scheme) + } + } + appliedColorScheme = scheme + } } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 93a05b6b9..5cc2c67f1 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -425,15 +425,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr return } - - // This is a surface-level config update. If we have the surface, we - // update our appearance based on it. - guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree.contains(surfaceView) else { return } - - // We can't use surfaceView.derivedConfig because it may not be updated - // yet since it also responds to notifications. - syncAppearance(.init(config)) + /// Surface-level config will be updated in + /// ``Ghostty/Ghostty/SurfaceView/derivedConfig`` then + /// ``TerminalController/focusedSurfaceDidChange(to:)`` } /// Update the accessory view of each tab according to the keyboard diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index a829ec519..2208d99cf 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -419,6 +419,7 @@ class TerminalWindow: NSWindow { // have no effect if the window is not visible. Ultimately, we'll have this called // at some point when a surface becomes focused. guard isVisible else { return } + defer { updateColorSchemeForSurfaceTree() } // Basic properties appearance = surfaceConfig.windowAppearance @@ -481,6 +482,10 @@ class TerminalWindow: NSWindow { return derivedConfig.backgroundColor.withAlphaComponent(alpha) } + func updateColorSchemeForSurfaceTree() { + terminalController?.updateColorSchemeForSurfaceTree() + } + private func setInitialWindowPosition(x: Int16?, y: Int16?) { // If we don't have an X/Y then we try to use the previously saved window pos. guard let x, let y else { diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 03ef293af..e86df4454 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -369,26 +369,6 @@ extension Ghostty { // Setup our tracking area so we get mouse moved events updateTrackingAreas() - // Observe our appearance so we can report the correct value to libghostty. - // This is the best way I know of to get appearance change notifications. - self.appearanceObserver = observe(\.effectiveAppearance, options: [.new, .initial]) { view, change in - guard let appearance = change.newValue else { return } - guard let surface = view.surface else { return } - let scheme: ghostty_color_scheme_e - switch (appearance.name) { - case .aqua, .vibrantLight: - scheme = GHOSTTY_COLOR_SCHEME_LIGHT - - case .darkAqua, .vibrantDark: - scheme = GHOSTTY_COLOR_SCHEME_DARK - - default: - return - } - - ghostty_surface_set_color_scheme(surface, scheme) - } - // The UTTypes that can be dragged onto this view. registerForDraggedTypes(Array(Self.dropTypes)) } From af3a11b54673a0bb8f3c1e6f2d076f69810cbf4d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Dec 2025 11:09:52 -0800 Subject: [PATCH 07/52] terminal/tmux: output has format/comptimeFormat --- src/terminal/tmux/output.zig | 68 ++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/terminal/tmux/output.zig b/src/terminal/tmux/output.zig index dcfa89ac3..cff1a982d 100644 --- a/src/terminal/tmux/output.zig +++ b/src/terminal/tmux/output.zig @@ -36,6 +36,36 @@ pub fn parseFormatStruct( return result; } +pub fn comptimeFormat( + comptime vars: []const Variable, + comptime delimiter: u8, +) []const u8 { + comptime { + @setEvalBranchQuota(50000); + var counter: std.Io.Writer.Discarding = .init(&.{}); + try format(&counter.writer, vars, delimiter); + + var buf: [counter.count]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try format(&writer, vars, delimiter); + const final = buf; + return final[0..writer.end]; + } +} + +/// Format a set of variables into the proper format string for tmux +/// that we can handle with `parseFormatStruct`. +pub fn format( + writer: *std.Io.Writer, + vars: []const Variable, + delimiter: u8, +) std.Io.Writer.Error!void { + for (vars, 0..) |variable, i| { + if (i != 0) try writer.writeByte(delimiter); + try writer.print("#{{{t}}}", .{variable}); + } +} + /// Returns a struct type that contains fields for each of the given /// format variables. This can be used with `parseFormatStruct` to /// parse an output string into a format struct. @@ -203,3 +233,41 @@ test "parseFormatStruct with empty layout field" { try testing.expectEqual(1, result.session_id); try testing.expectEqualStrings("", result.window_layout); } + +fn testFormat( + comptime vars: []const Variable, + comptime delimiter: u8, + comptime expected: []const u8, +) !void { + const comptime_result = comptime comptimeFormat(vars, delimiter); + try testing.expectEqualStrings(expected, comptime_result); + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try format(&writer, vars, delimiter); + try testing.expectEqualStrings(expected, buf[0..writer.end]); +} + +test "format single variable" { + try testFormat(&.{.session_id}, ' ', "#{session_id}"); +} + +test "format multiple variables" { + try testFormat(&.{ .session_id, .window_id, .window_width, .window_height }, ' ', "#{session_id} #{window_id} #{window_width} #{window_height}"); +} + +test "format with comma delimiter" { + try testFormat(&.{ .window_id, .window_layout }, ',', "#{window_id},#{window_layout}"); +} + +test "format with tab delimiter" { + try testFormat(&.{ .window_width, .window_height }, '\t', "#{window_width}\t#{window_height}"); +} + +test "format empty variables" { + try testFormat(&.{}, ' ', ""); +} + +test "format all variables" { + try testFormat(&.{ .session_id, .window_id, .window_width, .window_height, .window_layout }, ' ', "#{session_id} #{window_id} #{window_width} #{window_height} #{window_layout}"); +} From 0d75a787471a2b1a26dc31d05c5f607d7cab1543 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Dec 2025 11:01:04 -0800 Subject: [PATCH 08/52] terminal/tmux: start viewer state machine --- src/terminal/tmux.zig | 1 + src/terminal/tmux/viewer.zig | 235 ++++++++++++++++++++++++++++++++++ src/termio/stream_handler.zig | 6 +- 3 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 src/terminal/tmux/viewer.zig diff --git a/src/terminal/tmux.zig b/src/terminal/tmux.zig index 82ef5036b..c7cda1442 100644 --- a/src/terminal/tmux.zig +++ b/src/terminal/tmux.zig @@ -6,6 +6,7 @@ pub const output = @import("tmux/output.zig"); pub const ControlParser = control.Parser; pub const ControlNotification = control.Notification; pub const Layout = layout.Layout; +pub const Viewer = @import("tmux/viewer.zig").Viewer; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig new file mode 100644 index 000000000..7a84f9243 --- /dev/null +++ b/src/terminal/tmux/viewer.zig @@ -0,0 +1,235 @@ +const std = @import("std"); +const testing = std.testing; +const assert = @import("../../quirks.zig").inlineAssert; +const control = @import("control.zig"); +const output = @import("output.zig"); + +const log = std.log.scoped(.terminal_tmux_viewer); + +// NOTE: There is some fragility here that can possibly break if tmux +// changes their implementation. In particular, the order of notifications +// and assurances about what is sent when are based on reading the tmux +// source code as of Dec, 2025. These aren't documented as fixed. +// +// I've tried not to depend on anything that seems like it'd change +// in the future. For example, it seems reasonable that command output +// always comes before session attachment. But, I am noting this here +// in case something breaks in the future we can consider it. We should +// be able to easily unit test all variations seen in the real world. + +/// A viewer is a tmux control mode client that attempts to create +/// a remote view of a tmux session, including providing the ability to send +/// new input to the session. +/// +/// This is the primary use case for tmux control mode, but technically +/// tmux control mode clients can do anything a normal tmux client can do, +/// so the `control.zig` and other files in this folder are more general +/// purpose. +/// +/// This struct helps move through a state machine of connecting to a tmux +/// session, negotiating capabilities, listing window state, etc. +pub const Viewer = struct { + state: State = .startup_block, + + /// The current session ID we're attached to. The default value + /// is meaningless, because this has to be sent down during + /// the startup process. + session_id: usize = 0, + + pub const Action = union(enum) { + /// Tmux has closed the control mode connection, we should end + /// our viewer session in some way. + exit, + + /// Send a command to tmux, e.g. `list-windows`. The caller + /// should not worry about parsing this or reading what command + /// it is; just send it to tmux as-is. This will include the + /// trailing newline so you can send it directly. + command: []const u8, + }; + + /// Initial state + pub const init: Viewer = .{}; + + /// Send in the next tmux notification we got from the control mode + /// protocol. The return value is any action that needs to be taken + /// in reaction to this notification (could be none). + pub fn next(self: *Viewer, n: control.Notification) ?Action { + return switch (self.state) { + .startup_block => self.nextStartupBlock(n), + .startup_session => self.nextStartupSession(n), + .defunct => defunct: { + log.info("received notification in defunct state, ignoring", .{}); + break :defunct null; + }, + + // Once we're in the main states, there's a bunch of shared + // logic so we centralize it. + .list_windows => self.nextCommand(n), + }; + } + + fn nextStartupBlock(self: *Viewer, n: control.Notification) ?Action { + assert(self.state == .startup_block); + + switch (n) { + // This is only sent by the DCS parser when we first get + // DCS 1000p, it should never reach us here. + .enter => unreachable, + + // I don't think this is technically possible (reading the + // tmux source code), but if we see an exit we can semantically + // handle this without issue. + .exit => { + self.state = .defunct; + return .exit; + }, + + // Any begin and end (even error) is fine! Now we wait for + // session-changed to get the initial session ID. session-changed + // is guaranteed to come after the initial command output + // since if the initial command is `attach` tmux will run that, + // queue the notification, then do notificatins. + .block_end, .block_err => { + self.state = .startup_session; + return null; + }, + + // I don't like catch-all else branches but startup is such + // a special case of looking for very specific things that + // are unlikely to expand. + else => return null, + } + } + + fn nextStartupSession(self: *Viewer, n: control.Notification) ?Action { + assert(self.state == .startup_session); + + switch (n) { + .enter => unreachable, + + .exit => { + self.state = .defunct; + return .exit; + }, + + .session_changed => |info| { + self.session_id = info.id; + self.state = .list_windows; + return .{ .command = std.fmt.comptimePrint( + "list-windows -F '{s}'", + .{comptime Format.list_windows.comptimeFormat()}, + ) }; + }, + + else => return null, + } + } + + fn nextCommand(self: *Viewer, n: control.Notification) ?Action { + assert(self.state != .startup_block); + assert(self.state != .startup_session); + assert(self.state != .defunct); + + switch (n) { + .enter => unreachable, + + .exit => { + self.state = .defunct; + return .exit; + }, + + .block_end, + .block_err, + => |content| switch (self.state) { + .startup_block, .startup_session, .defunct => unreachable, + .list_windows => { + // TODO: parse the content + _ = content; + return null; + }, + }, + + // TODO: Use exhaustive matching here, determine if we need + // to handle the other cases. + else => return null, + } + } +}; + +const State = enum { + /// We start in this state just after receiving the initial + /// DCS 1000p opening sequence. We wait for an initial + /// begin/end block that is guaranteed to be sent by tmux for + /// the initial control mode command. (See tmux server-client.c + /// where control mode starts). + startup_block, + + /// After receiving the initial block, we wait for a session-changed + /// notification to record the initial session ID. + startup_session, + + /// Tmux has closed the control mode connection + defunct, + + /// We're waiting on a list-windows response from tmux. + list_windows, +}; + +/// Format strings used for commands in our viewer. +const Format = struct { + /// The variables included in this format, in order. + vars: []const output.Variable, + + /// The delimiter to use between variables. This must be a character + /// guaranteed to not appear in any of the variable outputs. + delim: u8, + + const list_windows: Format = .{ + .delim = ' ', + .vars = &.{ + .session_id, + .window_id, + .window_width, + .window_height, + .window_layout, + }, + }; + + /// The format string, available at comptime. + pub fn comptimeFormat(comptime self: Format) []const u8 { + return output.comptimeFormat(self.vars, self.delim); + } + + /// The struct that can contain the parsed output. + pub fn Struct(comptime self: Format) type { + return output.FormatStruct(self.vars); + } +}; + +test "immediate exit" { + var viewer: Viewer = .init; + try testing.expectEqual(.exit, viewer.next(.exit).?); + try testing.expect(viewer.next(.exit) == null); +} + +test "initial flow" { + var viewer: Viewer = .init; + + // First we receive the initial block end + try testing.expect(viewer.next(.{ .block_end = "" }) == null); + + // Then we receive session-changed with the initial session + { + const action = viewer.next(.{ .session_changed = .{ + .id = 42, + .name = "main", + } }).?; + try testing.expect(action == .command); + try testing.expect(std.mem.startsWith(u8, action.command, "list-windows")); + try testing.expectEqual(42, viewer.session_id); + } + + try testing.expectEqual(.exit, viewer.next(.exit).?); + try testing.expect(viewer.next(.exit) == null); +} diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 6e125e100..e25d635c9 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -368,7 +368,11 @@ pub const StreamHandler = struct { fn dcsCommand(self: *StreamHandler, cmd: *terminal.dcs.Command) !void { // log.warn("DCS command: {}", .{cmd}); switch (cmd.*) { - .tmux => |tmux| { + .tmux => |tmux| tmux: { + // If tmux control mode is disabled at the build level, + // then this whole block shouldn't be analyzed. + if (comptime !terminal.options.tmux_control_mode) break :tmux; + // TODO: process it log.warn("tmux control mode event unimplemented cmd={}", .{tmux}); }, From 4c3ef8fa13d12d6b5bba8f9f3e78214187ca8e84 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Dec 2025 15:21:26 -0800 Subject: [PATCH 09/52] terminal/tmux: viewer list windows state --- src/terminal/tmux/viewer.zig | 134 ++++++++++++++++++++++++++++------- 1 file changed, 108 insertions(+), 26 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 7a84f9243..60666b2aa 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const Allocator = std.mem.Allocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; const control = @import("control.zig"); @@ -29,12 +30,17 @@ const log = std.log.scoped(.terminal_tmux_viewer); /// This struct helps move through a state machine of connecting to a tmux /// session, negotiating capabilities, listing window state, etc. pub const Viewer = struct { - state: State = .startup_block, + /// Allocator used for all internal state. + alloc: Allocator, - /// The current session ID we're attached to. The default value - /// is meaningless, because this has to be sent down during - /// the startup process. - session_id: usize = 0, + /// Current state of the state machine. + state: State, + + /// The current session ID we're attached to. + session_id: usize, + + /// The windows in the current session. + windows: std.ArrayList(Window), pub const Action = union(enum) { /// Tmux has closed the control mode connection, we should end @@ -48,8 +54,32 @@ pub const Viewer = struct { command: []const u8, }; - /// Initial state - pub const init: Viewer = .{}; + pub const Window = struct { + id: usize, + width: usize, + height: usize, + // TODO: more fields, obviously! + }; + + /// Initialize a new viewer. + /// + /// The given allocator is used for all internal state. You must + /// call deinit when you're done with the viewer to free it. + pub fn init(alloc: Allocator) Viewer { + return .{ + .alloc = alloc, + .state = .startup_block, + // The default value here is meaningless. We don't get started + // until we receive a session-changed notification which will + // set this to a real value. + .session_id = 0, + .windows = .empty, + }; + } + + pub fn deinit(self: *Viewer) void { + self.windows.deinit(self.alloc); + } /// Send in the next tmux notification we got from the control mode /// protocol. The return value is any action that needs to be taken @@ -80,10 +110,7 @@ pub const Viewer = struct { // I don't think this is technically possible (reading the // tmux source code), but if we see an exit we can semantically // handle this without issue. - .exit => { - self.state = .defunct; - return .exit; - }, + .exit => return self.defunct(), // Any begin and end (even error) is fine! Now we wait for // session-changed to get the initial session ID. session-changed @@ -108,10 +135,7 @@ pub const Viewer = struct { switch (n) { .enter => unreachable, - .exit => { - self.state = .defunct; - return .exit; - }, + .exit => return self.defunct(), .session_changed => |info| { self.session_id = info.id; @@ -134,19 +158,17 @@ pub const Viewer = struct { switch (n) { .enter => unreachable, - .exit => { - self.state = .defunct; - return .exit; - }, + .exit => return self.defunct(), - .block_end, + inline .block_end, .block_err, - => |content| switch (self.state) { + => |content, tag| switch (self.state) { .startup_block, .startup_session, .defunct => unreachable, + .list_windows => { - // TODO: parse the content - _ = content; - return null; + // Move to defunct on error blocks. + if (comptime tag == .block_err) return self.defunct(); + return self.receivedListWindows(content) catch self.defunct(); }, }, @@ -155,6 +177,53 @@ pub const Viewer = struct { else => return null, } } + + fn receivedListWindows( + self: *Viewer, + content: []const u8, + ) !Action { + assert(self.state == .list_windows); + + // This stores our new window state from this list-windows output. + var windows: std.ArrayList(Window) = .empty; + errdefer windows.deinit(self.alloc); + + // Parse all our windows + var it = std.mem.splitScalar(u8, content, '\n'); + while (it.next()) |line_raw| { + const line = std.mem.trim(u8, line_raw, " \t\r"); + if (line.len == 0) continue; + const data = output.parseFormatStruct( + Format.list_windows.Struct(), + line, + Format.list_windows.delim, + ) catch |err| { + log.info("failed to parse list-windows line: {s}", .{line}); + return err; + }; + + try windows.append(self.alloc, .{ + .id = data.window_id, + .width = data.window_width, + .height = data.window_height, + }); + } + + // TODO: Diff our prior windows + + // Replace our window list + self.windows.deinit(self.alloc); + self.windows = windows; + + return .exit; + } + + fn defunct(self: *Viewer) Action { + self.state = .defunct; + // In the future we may want to deallocate a bunch of memory + // when we go defunct. + return .exit; + } }; const State = enum { @@ -208,13 +277,15 @@ const Format = struct { }; test "immediate exit" { - var viewer: Viewer = .init; + var viewer = Viewer.init(testing.allocator); + defer viewer.deinit(); try testing.expectEqual(.exit, viewer.next(.exit).?); try testing.expect(viewer.next(.exit) == null); } test "initial flow" { - var viewer: Viewer = .init; + var viewer = Viewer.init(testing.allocator); + defer viewer.deinit(); // First we receive the initial block end try testing.expect(viewer.next(.{ .block_end = "" }) == null); @@ -228,6 +299,17 @@ test "initial flow" { try testing.expect(action == .command); try testing.expect(std.mem.startsWith(u8, action.command, "list-windows")); try testing.expectEqual(42, viewer.session_id); + // log.warn("{s}", .{action.command}); + } + + // Simulate our list-windows command + { + const action = viewer.next(.{ + .block_end = + \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] + , + }).?; + _ = action; } try testing.expectEqual(.exit, viewer.next(.exit).?); From c1d686534efc3db38db6d6dd29be86939f652073 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Dec 2025 13:20:54 -0800 Subject: [PATCH 10/52] terminal/tmux: list windows --- src/terminal/tmux/viewer.zig | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 60666b2aa..7ee97fa8c 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -52,6 +52,13 @@ pub const Viewer = struct { /// it is; just send it to tmux as-is. This will include the /// trailing newline so you can send it directly. command: []const u8, + + /// Windows changed. This may add, remove or change windows. The + /// caller is responsible for diffing the new window list against + /// the prior one. Remember that for a given Viewer, window IDs + /// are guaranteed to be stable. Additionally, tmux (as of Dec 2025) + /// never re-uses window IDs within a server process lifetime. + windows: []const Window, }; pub const Window = struct { @@ -141,7 +148,7 @@ pub const Viewer = struct { self.session_id = info.id; self.state = .list_windows; return .{ .command = std.fmt.comptimePrint( - "list-windows -F '{s}'", + "list-windows -F '{s}'\n", .{comptime Format.list_windows.comptimeFormat()}, ) }; }, @@ -209,13 +216,11 @@ pub const Viewer = struct { }); } - // TODO: Diff our prior windows - // Replace our window list self.windows.deinit(self.alloc); self.windows = windows; - return .exit; + return .{ .windows = self.windows.items }; } fn defunct(self: *Viewer) Action { @@ -309,7 +314,8 @@ test "initial flow" { \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] , }).?; - _ = action; + try testing.expect(action == .windows); + try testing.expectEqual(1, action.windows.len); } try testing.expectEqual(.exit, viewer.next(.exit).?); From 3cbc232e31fd59f63a1eaea9df068c4d8df0153a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Dec 2025 07:15:53 -0800 Subject: [PATCH 11/52] terminal/tmux: return allocated list of actions --- src/terminal/tmux/viewer.zig | 197 +++++++++++++++++++++++++---------- 1 file changed, 142 insertions(+), 55 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 7ee97fa8c..dc3fdbcfa 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -1,5 +1,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; const control = @import("control.zig"); @@ -42,6 +43,10 @@ pub const Viewer = struct { /// The windows in the current session. windows: std.ArrayList(Window), + /// The arena used for the prior action allocated state. This contains + /// the contents for the actions as well as the actions slice itself. + action_arena: ArenaAllocator.State, + pub const Action = union(enum) { /// Tmux has closed the control mode connection, we should end /// our viewer session in some way. @@ -61,6 +66,11 @@ pub const Viewer = struct { windows: []const Window, }; + pub const Input = union(enum) { + /// Data from tmux was received that needs to be processed. + tmux: control.Notification, + }; + pub const Window = struct { id: usize, width: usize, @@ -81,32 +91,49 @@ pub const Viewer = struct { // set this to a real value. .session_id = 0, .windows = .empty, + .action_arena = .{}, }; } pub fn deinit(self: *Viewer) void { self.windows.deinit(self.alloc); + self.action_arena.promote(self.alloc).deinit(); } - /// Send in the next tmux notification we got from the control mode - /// protocol. The return value is any action that needs to be taken - /// in reaction to this notification (could be none). - pub fn next(self: *Viewer, n: control.Notification) ?Action { - return switch (self.state) { - .startup_block => self.nextStartupBlock(n), - .startup_session => self.nextStartupSession(n), - .defunct => defunct: { - log.info("received notification in defunct state, ignoring", .{}); - break :defunct null; - }, - - // Once we're in the main states, there's a bunch of shared - // logic so we centralize it. - .list_windows => self.nextCommand(n), + /// Send in an input event (such as a tmux protocol notification, + /// keyboard input for a pane, etc.) and process it. The returned + /// list is a set of actions to take as a result of the input prior + /// to the next input. This list may be empty. + pub fn next(self: *Viewer, input: Input) Allocator.Error![]const Action { + return switch (input) { + .tmux => try self.nextTmux(input.tmux), }; } - fn nextStartupBlock(self: *Viewer, n: control.Notification) ?Action { + fn nextTmux( + self: *Viewer, + n: control.Notification, + ) Allocator.Error![]const Action { + return switch (self.state) { + .defunct => defunct: { + log.info("received notification in defunct state, ignoring", .{}); + break :defunct &.{}; + }, + + .startup_block => try self.nextStartupBlock(n), + .startup_session => try self.nextStartupSession(n), + .idle => try self.nextIdle(n), + + // Once we're in the main states, there's a bunch of shared + // logic so we centralize it. + .list_windows => try self.nextCommand(n), + }; + } + + fn nextStartupBlock( + self: *Viewer, + n: control.Notification, + ) Allocator.Error![]const Action { assert(self.state == .startup_block); switch (n) { @@ -117,7 +144,7 @@ pub const Viewer = struct { // I don't think this is technically possible (reading the // tmux source code), but if we see an exit we can semantically // handle this without issue. - .exit => return self.defunct(), + .exit => return try self.defunct(), // Any begin and end (even error) is fine! Now we wait for // session-changed to get the initial session ID. session-changed @@ -126,69 +153,88 @@ pub const Viewer = struct { // queue the notification, then do notificatins. .block_end, .block_err => { self.state = .startup_session; - return null; + return &.{}; }, // I don't like catch-all else branches but startup is such // a special case of looking for very specific things that // are unlikely to expand. - else => return null, + else => return &.{}, } } - fn nextStartupSession(self: *Viewer, n: control.Notification) ?Action { + fn nextStartupSession( + self: *Viewer, + n: control.Notification, + ) Allocator.Error![]const Action { assert(self.state == .startup_session); switch (n) { .enter => unreachable, - .exit => return self.defunct(), + .exit => return try self.defunct(), .session_changed => |info| { self.session_id = info.id; self.state = .list_windows; - return .{ .command = std.fmt.comptimePrint( + return try self.singleAction(.{ .command = std.fmt.comptimePrint( "list-windows -F '{s}'\n", .{comptime Format.list_windows.comptimeFormat()}, - ) }; + ) }); }, - else => return null, + else => return &.{}, } } - fn nextCommand(self: *Viewer, n: control.Notification) ?Action { - assert(self.state != .startup_block); - assert(self.state != .startup_session); - assert(self.state != .defunct); + fn nextIdle( + self: *Viewer, + n: control.Notification, + ) Allocator.Error![]const Action { + assert(self.state == .idle); switch (n) { .enter => unreachable, + .exit => return try self.defunct(), + else => return &.{}, + } + } - .exit => return self.defunct(), + fn nextCommand( + self: *Viewer, + n: control.Notification, + ) Allocator.Error![]const Action { + switch (n) { + .enter => unreachable, + + .exit => return try self.defunct(), inline .block_end, .block_err, => |content, tag| switch (self.state) { - .startup_block, .startup_session, .defunct => unreachable, + .startup_block, + .startup_session, + .idle, + .defunct, + => unreachable, .list_windows => { // Move to defunct on error blocks. - if (comptime tag == .block_err) return self.defunct(); - return self.receivedListWindows(content) catch self.defunct(); + if (comptime tag == .block_err) return try self.defunct(); + return self.receivedListWindows(content) catch return try self.defunct(); }, }, // TODO: Use exhaustive matching here, determine if we need // to handle the other cases. - else => return null, + else => return &.{}, } } fn receivedListWindows( self: *Viewer, content: []const u8, - ) !Action { + ) ![]const Action { assert(self.state == .list_windows); // This stores our new window state from this list-windows output. @@ -220,18 +266,46 @@ pub const Viewer = struct { self.windows.deinit(self.alloc); self.windows = windows; - return .{ .windows = self.windows.items }; + // Go into the idle state + self.state = .idle; + + // TODO: Diff with prior window state, dispatch capture-pane + // requests to collect all of the screen contents, other terminal + // state, etc. + + return try self.singleAction(.{ .windows = self.windows.items }); } - fn defunct(self: *Viewer) Action { + /// Helper to return a single action. The input action must not use + /// any allocated memory from `action_arena` since this will reset + /// the arena. + fn singleAction( + self: *Viewer, + action: Action, + ) Allocator.Error![]const Action { + // Make our actual arena + var arena = self.action_arena.promote(self.alloc); + + // Need to be careful to update our internal state after + // doing allocations since the arena takes a copy of the state. + defer self.action_arena = arena.state; + + // Free everything. We could retain some state here if we wanted + // but I don't think its worth it. + _ = arena.reset(.free_all); + + // Make our single action slice. + const alloc = arena.allocator(); + return try alloc.dupe(Action, &.{action}); + } + + fn defunct(self: *Viewer) Allocator.Error![]const Action { self.state = .defunct; - // In the future we may want to deallocate a bunch of memory - // when we go defunct. - return .exit; + return try self.singleAction(.exit); } }; -const State = enum { +const State = union(enum) { /// We start in this state just after receiving the initial /// DCS 1000p opening sequence. We wait for an initial /// begin/end block that is guaranteed to be sent by tmux for @@ -246,8 +320,13 @@ const State = enum { /// Tmux has closed the control mode connection defunct, - /// We're waiting on a list-windows response from tmux. + /// We're waiting on a list-windows response from tmux. This will + /// be used to resynchronize our entire window state. list_windows, + + /// Idle state, we're not actually doing anything right now except + /// waiting for more events from tmux that may change our behavior. + idle, }; /// Format strings used for commands in our viewer. @@ -284,8 +363,11 @@ const Format = struct { test "immediate exit" { var viewer = Viewer.init(testing.allocator); defer viewer.deinit(); - try testing.expectEqual(.exit, viewer.next(.exit).?); - try testing.expect(viewer.next(.exit) == null); + const actions = try viewer.next(.{ .tmux = .exit }); + try testing.expectEqual(1, actions.len); + try testing.expectEqual(.exit, actions[0]); + const actions2 = try viewer.next(.{ .tmux = .exit }); + try testing.expectEqual(0, actions2.len); } test "initial flow" { @@ -293,31 +375,36 @@ test "initial flow" { defer viewer.deinit(); // First we receive the initial block end - try testing.expect(viewer.next(.{ .block_end = "" }) == null); + const actions0 = try viewer.next(.{ .tmux = .{ .block_end = "" } }); + try testing.expectEqual(0, actions0.len); // Then we receive session-changed with the initial session { - const action = viewer.next(.{ .session_changed = .{ + const actions = try viewer.next(.{ .tmux = .{ .session_changed = .{ .id = 42, .name = "main", - } }).?; - try testing.expect(action == .command); - try testing.expect(std.mem.startsWith(u8, action.command, "list-windows")); + } } }); + try testing.expectEqual(1, actions.len); + try testing.expect(actions[0] == .command); + try testing.expect(std.mem.startsWith(u8, actions[0].command, "list-windows")); try testing.expectEqual(42, viewer.session_id); - // log.warn("{s}", .{action.command}); } // Simulate our list-windows command { - const action = viewer.next(.{ + const actions = try viewer.next(.{ .tmux = .{ .block_end = \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] , - }).?; - try testing.expect(action == .windows); - try testing.expectEqual(1, action.windows.len); + } }); + try testing.expectEqual(1, actions.len); + try testing.expect(actions[0] == .windows); + try testing.expectEqual(1, actions[0].windows.len); } - try testing.expectEqual(.exit, viewer.next(.exit).?); - try testing.expect(viewer.next(.exit) == null); + const exit_actions = try viewer.next(.{ .tmux = .exit }); + try testing.expectEqual(1, exit_actions.len); + try testing.expectEqual(.exit, exit_actions[0]); + const final_actions = try viewer.next(.{ .tmux = .exit }); + try testing.expectEqual(0, final_actions.len); } From 52dbca3d26426937be2e13e2f216177d4e8b467b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Dec 2025 14:10:54 -0800 Subject: [PATCH 12/52] termio: hook up tmux viewer --- src/termio/stream_handler.zig | 77 +++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index e25d635c9..be5cb6418 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -70,6 +70,9 @@ pub const StreamHandler = struct { /// such as XTGETTCAP. dcs: terminal.dcs.Handler = .{}, + /// The tmux control mode viewer state. + tmux_viewer: if (tmux_enabled) ?*terminal.tmux.Viewer else void = if (tmux_enabled) null else {}, + /// This is set to true when a message was written to the termio /// mailbox. This can be used by callers to determine if they need /// to wake up the termio thread. @@ -81,9 +84,18 @@ pub const StreamHandler = struct { pub const Stream = terminal.Stream(StreamHandler); + /// True if we have tmux control mode built in. + pub const tmux_enabled = terminal.options.tmux_control_mode; + pub fn deinit(self: *StreamHandler) void { self.apc.deinit(); self.dcs.deinit(); + if (comptime tmux_enabled) tmux: { + const viewer = self.tmux_viewer orelse break :tmux; + viewer.deinit(); + self.alloc.destroy(viewer); + self.tmux_viewer = null; + } } /// This queues a render operation with the renderer thread. The render @@ -371,10 +383,69 @@ pub const StreamHandler = struct { .tmux => |tmux| tmux: { // If tmux control mode is disabled at the build level, // then this whole block shouldn't be analyzed. - if (comptime !terminal.options.tmux_control_mode) break :tmux; + if (comptime !tmux_enabled) break :tmux; + log.info("tmux control mode event cmd={}", .{tmux}); - // TODO: process it - log.warn("tmux control mode event unimplemented cmd={}", .{tmux}); + switch (tmux) { + .enter => { + // Setup our viewer state + assert(self.tmux_viewer == null); + const viewer = try self.alloc.create(terminal.tmux.Viewer); + errdefer self.alloc.destroy(viewer); + viewer.* = .init(self.alloc); + self.tmux_viewer = viewer; + break :tmux; + }, + + .exit => if (self.tmux_viewer) |viewer| { + // Free our viewer state + viewer.deinit(); + self.alloc.destroy(viewer); + self.tmux_viewer = null; + break :tmux; + }, + + else => {}, + } + + assert(tmux != .enter); + assert(tmux != .exit); + + const viewer = self.tmux_viewer orelse { + // This can only really happen if we failed to + // initialize the viewer on enter. + log.info( + "received tmux control mode command without viewer: {}", + .{tmux}, + ); + + break :tmux; + }; + + for (try viewer.next(.{ .tmux = tmux })) |action| { + log.info("tmux viewer action={}", .{action}); + switch (action) { + .exit => { + // We ignore this because we will fully exit when + // our DCS connection ends. We may want to handle + // this in the future to notify our GUI we're + // disconnected though. + }, + + .command => |command| { + assert(command.len > 0); + assert(command[command.len - 1] == '\n'); + self.messageWriter(try termio.Message.writeReq( + self.alloc, + command, + )); + }, + + .windows => { + // TODO + }, + } + } }, .xtgettcap => |*gettcap| { From b26c42f4a64d9fdb686c3678d08b4ce31b3f7fd6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Dec 2025 14:28:00 -0800 Subject: [PATCH 13/52] terminal/tmux: better formatting for notifications and actions --- src/terminal/tmux/control.zig | 26 +++++++++++++++++++++++++- src/terminal/tmux/viewer.zig | 24 ++++++++++++++++++++++++ src/termio/stream_handler.zig | 6 +++--- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/terminal/tmux/control.zig b/src/terminal/tmux/control.zig index 3624173dd..79ed530ec 100644 --- a/src/terminal/tmux/control.zig +++ b/src/terminal/tmux/control.zig @@ -531,7 +531,31 @@ pub const Notification = union(enum) { session_id: usize, name: []const u8, }, -}; + + pub fn format(self: Notification, writer: *std.Io.Writer) !void { + const T = Notification; + const info = @typeInfo(T).@"union"; + + try writer.writeAll(@typeName(T)); + if (info.tag_type) |TagType| { + try writer.writeAll("{ ."); + try writer.writeAll(@tagName(@as(TagType, self))); + try writer.writeAll(" = "); + + inline for (info.fields) |u_field| { + if (self == @field(TagType, u_field.name)) { + const value = @field(self, u_field.name); + switch (u_field.type) { + []const u8 => try writer.print("\"{s}\"", .{std.mem.trim(u8, value, " \t\r\n")}), + else => try writer.print("{any}", .{value}), + } + } + } + + try writer.writeAll(" }"); + } + } + }; test "tmux begin/end empty" { const testing = std.testing; diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index dc3fdbcfa..32da1b4e4 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -64,6 +64,30 @@ pub const Viewer = struct { /// are guaranteed to be stable. Additionally, tmux (as of Dec 2025) /// never re-uses window IDs within a server process lifetime. windows: []const Window, + + pub fn format(self: Action, writer: *std.Io.Writer) !void { + const T = Action; + const info = @typeInfo(T).@"union"; + + try writer.writeAll(@typeName(T)); + if (info.tag_type) |TagType| { + try writer.writeAll("{ ."); + try writer.writeAll(@tagName(@as(TagType, self))); + try writer.writeAll(" = "); + + inline for (info.fields) |u_field| { + if (self == @field(TagType, u_field.name)) { + const value = @field(self, u_field.name); + switch (u_field.type) { + []const u8 => try writer.print("\"{s}\"", .{std.mem.trim(u8, value, " \t\r\n")}), + else => try writer.print("{any}", .{value}), + } + } + } + + try writer.writeAll(" }"); + } + } }; pub const Input = union(enum) { diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index be5cb6418..8218315be 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -384,7 +384,7 @@ pub const StreamHandler = struct { // If tmux control mode is disabled at the build level, // then this whole block shouldn't be analyzed. if (comptime !tmux_enabled) break :tmux; - log.info("tmux control mode event cmd={}", .{tmux}); + log.info("tmux control mode event cmd={f}", .{tmux}); switch (tmux) { .enter => { @@ -415,7 +415,7 @@ pub const StreamHandler = struct { // This can only really happen if we failed to // initialize the viewer on enter. log.info( - "received tmux control mode command without viewer: {}", + "received tmux control mode command without viewer: {f}", .{tmux}, ); @@ -423,7 +423,7 @@ pub const StreamHandler = struct { }; for (try viewer.next(.{ .tmux = tmux })) |action| { - log.info("tmux viewer action={}", .{action}); + log.info("tmux viewer action={f}", .{action}); switch (action) { .exit => { // We ignore this because we will fully exit when From ec5a60a11993467f19ae99d5723304870f060cb8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 8 Dec 2025 07:25:59 -0800 Subject: [PATCH 14/52] terminal/tmux: make sure we always have space for one action --- src/terminal/tmux/viewer.zig | 90 ++++++++++++++++------------------- src/termio/stream_handler.zig | 2 +- 2 files changed, 43 insertions(+), 49 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 32da1b4e4..275f93d5e 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -47,6 +47,11 @@ pub const Viewer = struct { /// the contents for the actions as well as the actions slice itself. action_arena: ArenaAllocator.State, + /// A single action pre-allocated that we use for single-action + /// returns (common). This ensures that we can never get allocation + /// errors on single-action returns, especially those such as `.exit`. + action_single: [1]Action, + pub const Action = union(enum) { /// Tmux has closed the control mode connection, we should end /// our viewer session in some way. @@ -116,6 +121,7 @@ pub const Viewer = struct { .session_id = 0, .windows = .empty, .action_arena = .{}, + .action_single = undefined, }; } @@ -128,36 +134,39 @@ pub const Viewer = struct { /// keyboard input for a pane, etc.) and process it. The returned /// list is a set of actions to take as a result of the input prior /// to the next input. This list may be empty. - pub fn next(self: *Viewer, input: Input) Allocator.Error![]const Action { + pub fn next(self: *Viewer, input: Input) []const Action { + // Developer note: this function must never return an error. If + // an error occurs we must go into a defunct state or some other + // state to gracefully handle it. return switch (input) { - .tmux => try self.nextTmux(input.tmux), + .tmux => self.nextTmux(input.tmux), }; } fn nextTmux( self: *Viewer, n: control.Notification, - ) Allocator.Error![]const Action { + ) []const Action { return switch (self.state) { .defunct => defunct: { log.info("received notification in defunct state, ignoring", .{}); break :defunct &.{}; }, - .startup_block => try self.nextStartupBlock(n), - .startup_session => try self.nextStartupSession(n), - .idle => try self.nextIdle(n), + .startup_block => self.nextStartupBlock(n), + .startup_session => self.nextStartupSession(n), + .idle => self.nextIdle(n), // Once we're in the main states, there's a bunch of shared // logic so we centralize it. - .list_windows => try self.nextCommand(n), + .list_windows => self.nextCommand(n), }; } fn nextStartupBlock( self: *Viewer, n: control.Notification, - ) Allocator.Error![]const Action { + ) []const Action { assert(self.state == .startup_block); switch (n) { @@ -168,7 +177,7 @@ pub const Viewer = struct { // I don't think this is technically possible (reading the // tmux source code), but if we see an exit we can semantically // handle this without issue. - .exit => return try self.defunct(), + .exit => return self.defunct(), // Any begin and end (even error) is fine! Now we wait for // session-changed to get the initial session ID. session-changed @@ -190,18 +199,18 @@ pub const Viewer = struct { fn nextStartupSession( self: *Viewer, n: control.Notification, - ) Allocator.Error![]const Action { + ) []const Action { assert(self.state == .startup_session); switch (n) { .enter => unreachable, - .exit => return try self.defunct(), + .exit => return self.defunct(), .session_changed => |info| { self.session_id = info.id; self.state = .list_windows; - return try self.singleAction(.{ .command = std.fmt.comptimePrint( + return self.singleAction(.{ .command = std.fmt.comptimePrint( "list-windows -F '{s}'\n", .{comptime Format.list_windows.comptimeFormat()}, ) }); @@ -214,12 +223,12 @@ pub const Viewer = struct { fn nextIdle( self: *Viewer, n: control.Notification, - ) Allocator.Error![]const Action { + ) []const Action { assert(self.state == .idle); switch (n) { .enter => unreachable, - .exit => return try self.defunct(), + .exit => return self.defunct(), else => return &.{}, } } @@ -227,11 +236,11 @@ pub const Viewer = struct { fn nextCommand( self: *Viewer, n: control.Notification, - ) Allocator.Error![]const Action { + ) []const Action { switch (n) { .enter => unreachable, - .exit => return try self.defunct(), + .exit => return self.defunct(), inline .block_end, .block_err, @@ -244,8 +253,8 @@ pub const Viewer = struct { .list_windows => { // Move to defunct on error blocks. - if (comptime tag == .block_err) return try self.defunct(); - return self.receivedListWindows(content) catch return try self.defunct(); + if (comptime tag == .block_err) return self.defunct(); + return self.receivedListWindows(content) catch return self.defunct(); }, }, @@ -297,35 +306,20 @@ pub const Viewer = struct { // requests to collect all of the screen contents, other terminal // state, etc. - return try self.singleAction(.{ .windows = self.windows.items }); + return self.singleAction(.{ .windows = self.windows.items }); } - /// Helper to return a single action. The input action must not use - /// any allocated memory from `action_arena` since this will reset - /// the arena. - fn singleAction( - self: *Viewer, - action: Action, - ) Allocator.Error![]const Action { - // Make our actual arena - var arena = self.action_arena.promote(self.alloc); - - // Need to be careful to update our internal state after - // doing allocations since the arena takes a copy of the state. - defer self.action_arena = arena.state; - - // Free everything. We could retain some state here if we wanted - // but I don't think its worth it. - _ = arena.reset(.free_all); - + /// Helper to return a single action. The input action may use the arena + /// for allocated memory; this will not touch the arena. + fn singleAction(self: *Viewer, action: Action) []const Action { // Make our single action slice. - const alloc = arena.allocator(); - return try alloc.dupe(Action, &.{action}); + self.action_single[0] = action; + return &self.action_single; } - fn defunct(self: *Viewer) Allocator.Error![]const Action { + fn defunct(self: *Viewer) []const Action { self.state = .defunct; - return try self.singleAction(.exit); + return self.singleAction(.exit); } }; @@ -387,10 +381,10 @@ const Format = struct { test "immediate exit" { var viewer = Viewer.init(testing.allocator); defer viewer.deinit(); - const actions = try viewer.next(.{ .tmux = .exit }); + const actions = viewer.next(.{ .tmux = .exit }); try testing.expectEqual(1, actions.len); try testing.expectEqual(.exit, actions[0]); - const actions2 = try viewer.next(.{ .tmux = .exit }); + const actions2 = viewer.next(.{ .tmux = .exit }); try testing.expectEqual(0, actions2.len); } @@ -399,12 +393,12 @@ test "initial flow" { defer viewer.deinit(); // First we receive the initial block end - const actions0 = try viewer.next(.{ .tmux = .{ .block_end = "" } }); + const actions0 = viewer.next(.{ .tmux = .{ .block_end = "" } }); try testing.expectEqual(0, actions0.len); // Then we receive session-changed with the initial session { - const actions = try viewer.next(.{ .tmux = .{ .session_changed = .{ + const actions = viewer.next(.{ .tmux = .{ .session_changed = .{ .id = 42, .name = "main", } } }); @@ -416,7 +410,7 @@ test "initial flow" { // Simulate our list-windows command { - const actions = try viewer.next(.{ .tmux = .{ + const actions = viewer.next(.{ .tmux = .{ .block_end = \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] , @@ -426,9 +420,9 @@ test "initial flow" { try testing.expectEqual(1, actions[0].windows.len); } - const exit_actions = try viewer.next(.{ .tmux = .exit }); + const exit_actions = viewer.next(.{ .tmux = .exit }); try testing.expectEqual(1, exit_actions.len); try testing.expectEqual(.exit, exit_actions[0]); - const final_actions = try viewer.next(.{ .tmux = .exit }); + const final_actions = viewer.next(.{ .tmux = .exit }); try testing.expectEqual(0, final_actions.len); } diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 8218315be..ba207ce7b 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -422,7 +422,7 @@ pub const StreamHandler = struct { break :tmux; }; - for (try viewer.next(.{ .tmux = tmux })) |action| { + for (viewer.next(.{ .tmux = tmux })) |action| { log.info("tmux viewer action={f}", .{action}); switch (action) { .exit => { From 86cd4897012758a59d8068b796b449ff7ff37f16 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 8 Dec 2025 07:09:11 -0800 Subject: [PATCH 15/52] terminal/tmux: introduce command queue for viewer --- src/terminal/tmux/viewer.zig | 196 ++++++++++++++++++++++++++-------- src/termio/stream_handler.zig | 3 +- 2 files changed, 156 insertions(+), 43 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 275f93d5e..384ad609b 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -3,6 +3,7 @@ const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; +const CircBuf = @import("../../datastruct/main.zig").CircBuf; const control = @import("control.zig"); const output = @import("output.zig"); @@ -19,6 +20,12 @@ const log = std.log.scoped(.terminal_tmux_viewer); // in case something breaks in the future we can consider it. We should // be able to easily unit test all variations seen in the real world. +/// The initial capacity of the command queue. We dynamically resize +/// as necessary so the initial value isn't that important, but if we +/// want to feel good about it we should make it large enough to support +/// our most realistic use cases without resizing. +const COMMAND_QUEUE_INITIAL = 8; + /// A viewer is a tmux control mode client that attempts to create /// a remote view of a tmux session, including providing the ability to send /// new input to the session. @@ -40,6 +47,11 @@ pub const Viewer = struct { /// The current session ID we're attached to. session_id: usize, + /// The list of commands we've sent that we want to send and wait + /// for a response for. We only send one command at a time just + /// to avoid any possible confusion around ordering. + command_queue: CommandQueue, + /// The windows in the current session. windows: std.ArrayList(Window), @@ -52,6 +64,8 @@ pub const Viewer = struct { /// errors on single-action returns, especially those such as `.exit`. action_single: [1]Action, + pub const CommandQueue = CircBuf(Command, undefined); + pub const Action = union(enum) { /// Tmux has closed the control mode connection, we should end /// our viewer session in some way. @@ -111,7 +125,11 @@ pub const Viewer = struct { /// /// The given allocator is used for all internal state. You must /// call deinit when you're done with the viewer to free it. - pub fn init(alloc: Allocator) Viewer { + pub fn init(alloc: Allocator) Allocator.Error!Viewer { + // Create our initial command queue + var command_queue: CommandQueue = try .init(alloc, COMMAND_QUEUE_INITIAL); + errdefer command_queue.deinit(alloc); + return .{ .alloc = alloc, .state = .startup_block, @@ -119,6 +137,7 @@ pub const Viewer = struct { // until we receive a session-changed notification which will // set this to a real value. .session_id = 0, + .command_queue = command_queue, .windows = .empty, .action_arena = .{}, .action_single = undefined, @@ -127,6 +146,11 @@ pub const Viewer = struct { pub fn deinit(self: *Viewer) void { self.windows.deinit(self.alloc); + { + var it = self.command_queue.iterator(.forward); + while (it.next()) |command| command.deinit(self.alloc); + self.command_queue.deinit(self.alloc); + } self.action_arena.promote(self.alloc).deinit(); } @@ -155,11 +179,7 @@ pub const Viewer = struct { .startup_block => self.nextStartupBlock(n), .startup_session => self.nextStartupSession(n), - .idle => self.nextIdle(n), - - // Once we're in the main states, there's a bunch of shared - // logic so we centralize it. - .list_windows => self.nextCommand(n), + .command_queue => self.nextCommand(n), }; } @@ -209,11 +229,11 @@ pub const Viewer = struct { .session_changed => |info| { self.session_id = info.id; - self.state = .list_windows; - return self.singleAction(.{ .command = std.fmt.comptimePrint( - "list-windows -F '{s}'\n", - .{comptime Format.list_windows.comptimeFormat()}, - ) }); + self.state = .command_queue; + return self.singleAction(self.queueCommand(.list_windows) catch { + log.warn("failed to queue command, becoming defunct", .{}); + return self.defunct(); + }); }, else => return &.{}, @@ -237,39 +257,85 @@ pub const Viewer = struct { self: *Viewer, n: control.Notification, ) []const Action { - switch (n) { - .enter => unreachable, + // We have to be in a command queue, but the command queue MAY + // be empty. If it is empty, then receivedCommandOutput will + // handle it by ignoring any command output. That's okay! + assert(self.state == .command_queue); - .exit => return self.defunct(), + return switch (n) { + .enter => unreachable, + .exit => self.defunct(), inline .block_end, .block_err, - => |content, tag| switch (self.state) { - .startup_block, - .startup_session, - .idle, - .defunct, - => unreachable, - - .list_windows => { - // Move to defunct on error blocks. - if (comptime tag == .block_err) return self.defunct(); - return self.receivedListWindows(content) catch return self.defunct(); - }, + => |content, tag| self.receivedCommandOutput( + content, + tag == .block_err, + ) catch err: { + log.warn("failed to process command output, becoming defunct", .{}); + break :err self.defunct(); }, // TODO: Use exhaustive matching here, determine if we need // to handle the other cases. - else => return &.{}, + else => &.{}, + }; + } + + fn receivedCommandOutput( + self: *Viewer, + content: []const u8, + is_err: bool, + ) ![]const Action { + // If we have no pending commands, this is unexpected. + const command = self.command_queue.first() orelse { + log.info("unexpected block output err={}", .{is_err}); + return &.{}; + }; + self.command_queue.deleteOldest(1); + + // We always free any memory associated with the command + defer command.deinit(self.alloc); + + // We'll use our arena for the return value here so we can + // easily accumulate actions. + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + _ = arena.reset(.free_all); + const arena_alloc = arena.allocator(); + + // Build up our actions to start with the next command if + // we have one. + var actions: std.ArrayList(Action) = .empty; + if (self.command_queue.first()) |next_command| { + try actions.append( + arena_alloc, + .{ .command = next_command.string() }, + ); } + + // Process our command + switch (command.*) { + .user => {}, + .list_windows => try self.receivedListWindows( + arena_alloc, + &actions, + content, + ), + } + + // Our command processing should not change our state + assert(self.state == .command_queue); + + return actions.items; } fn receivedListWindows( self: *Viewer, + arena_alloc: Allocator, + actions: *std.ArrayList(Action), content: []const u8, - ) ![]const Action { - assert(self.state == .list_windows); - + ) !void { // This stores our new window state from this list-windows output. var windows: std.ArrayList(Window) = .empty; errdefer windows.deinit(self.alloc); @@ -299,14 +365,27 @@ pub const Viewer = struct { self.windows.deinit(self.alloc); self.windows = windows; - // Go into the idle state - self.state = .idle; - // TODO: Diff with prior window state, dispatch capture-pane // requests to collect all of the screen contents, other terminal // state, etc. - return self.singleAction(.{ .windows = self.windows.items }); + try actions.append(arena_alloc, .{ .windows = self.windows.items }); + } + + /// This queues the command at the end of the command queue + /// and returns an action representing the next command that + /// should be run (the head). + /// + /// The next command is not removed, because the expectation is + /// that the head of our command list is always sent to tmux. + fn queueCommand(self: *Viewer, command: Command) Allocator.Error!Action { + // Add our command + try self.command_queue.ensureUnusedCapacity(self.alloc, 1); + self.command_queue.appendAssumeCapacity(command); + + // Get our first command to send, guaranteed to exist since we + // just appended one. + return .{ .command = self.command_queue.first().?.string() }; } /// Helper to return a single action. The input action may use the arena @@ -323,7 +402,7 @@ pub const Viewer = struct { } }; -const State = union(enum) { +const State = enum { /// We start in this state just after receiving the initial /// DCS 1000p opening sequence. We wait for an initial /// begin/end block that is guaranteed to be sent by tmux for @@ -338,13 +417,46 @@ const State = union(enum) { /// Tmux has closed the control mode connection defunct, - /// We're waiting on a list-windows response from tmux. This will - /// be used to resynchronize our entire window state. + /// We're sitting on the command queue waiting for command output + /// in the order provided in the `command_queue` field. This field + /// isn't part of the state because it can be queued at any state. + /// + /// Precondition: if self.command_queue.len > 0, then the first + /// command in the queue has already been sent to tmux (via a + /// `command` Action). The next output is assumed to be the result + /// of this command. + /// + /// To satisfy the above, any transitions INTO this state should + /// send a command Action for the first command in the queue. + command_queue, +}; + +const Command = union(enum) { + /// List all windows so we can sync our window state. list_windows, - /// Idle state, we're not actually doing anything right now except - /// waiting for more events from tmux that may change our behavior. - idle, + /// User command. This is a command provided by the user. Since + /// this is user provided, we can't be sure what it is. + user: []const u8, + + pub fn deinit(self: Command, alloc: Allocator) void { + return switch (self) { + .list_windows => {}, + .user => |v| alloc.free(v), + }; + } + + /// Returns the command to execute. The memory of the return + /// value is always safe as long as this command value is alive. + pub fn string(self: Command) []const u8 { + return switch (self) { + .list_windows => std.fmt.comptimePrint( + "list-windows -F '{s}'\n", + .{comptime Format.list_windows.comptimeFormat()}, + ), + .user => |v| v, + }; + } }; /// Format strings used for commands in our viewer. @@ -379,7 +491,7 @@ const Format = struct { }; test "immediate exit" { - var viewer = Viewer.init(testing.allocator); + var viewer = try Viewer.init(testing.allocator); defer viewer.deinit(); const actions = viewer.next(.{ .tmux = .exit }); try testing.expectEqual(1, actions.len); @@ -389,7 +501,7 @@ test "immediate exit" { } test "initial flow" { - var viewer = Viewer.init(testing.allocator); + var viewer = try Viewer.init(testing.allocator); defer viewer.deinit(); // First we receive the initial block end diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index ba207ce7b..eabfd6a4b 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -392,7 +392,8 @@ pub const StreamHandler = struct { assert(self.tmux_viewer == null); const viewer = try self.alloc.create(terminal.tmux.Viewer); errdefer self.alloc.destroy(viewer); - viewer.* = .init(self.alloc); + viewer.* = try .init(self.alloc); + errdefer viewer.deinit(); self.tmux_viewer = viewer; break :tmux; }, From ea09d257a1cd27b66236de474a2e26b05e843631 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 8 Dec 2025 10:45:28 -0800 Subject: [PATCH 16/52] terminal/tmux: initialize panes --- src/terminal/tmux/viewer.zig | 143 ++++++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 3 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 384ad609b..7b0307a8f 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -4,6 +4,8 @@ const ArenaAllocator = std.heap.ArenaAllocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; const CircBuf = @import("../../datastruct/main.zig").CircBuf; +const Terminal = @import("../Terminal.zig"); +const Layout = @import("layout.zig").Layout; const control = @import("control.zig"); const output = @import("output.zig"); @@ -55,6 +57,9 @@ pub const Viewer = struct { /// The windows in the current session. windows: std.ArrayList(Window), + /// The panes in the current session, mapped by pane ID. + panes: PanesMap, + /// The arena used for the prior action allocated state. This contains /// the contents for the actions as well as the actions slice itself. action_arena: ArenaAllocator.State, @@ -65,6 +70,7 @@ pub const Viewer = struct { action_single: [1]Action, pub const CommandQueue = CircBuf(Command, undefined); + pub const PanesMap = std.AutoArrayHashMapUnmanaged(usize, Pane); pub const Action = union(enum) { /// Tmux has closed the control mode connection, we should end @@ -118,7 +124,20 @@ pub const Viewer = struct { id: usize, width: usize, height: usize, - // TODO: more fields, obviously! + layout_arena: ArenaAllocator.State, + layout: Layout, + + pub fn deinit(self: *Window, alloc: Allocator) void { + self.layout_arena.promote(alloc).deinit(); + } + }; + + pub const Pane = struct { + terminal: Terminal, + + pub fn deinit(self: *Pane, alloc: Allocator) void { + self.terminal.deinit(alloc); + } }; /// Initialize a new viewer. @@ -139,18 +158,27 @@ pub const Viewer = struct { .session_id = 0, .command_queue = command_queue, .windows = .empty, + .panes = .empty, .action_arena = .{}, .action_single = undefined, }; } pub fn deinit(self: *Viewer) void { - self.windows.deinit(self.alloc); + { + for (self.windows.items) |*window| window.deinit(self.alloc); + self.windows.deinit(self.alloc); + } { var it = self.command_queue.iterator(.forward); while (it.next()) |command| command.deinit(self.alloc); self.command_queue.deinit(self.alloc); } + { + var it = self.panes.iterator(); + while (it.next()) |kv| kv.value_ptr.deinit(self.alloc); + self.panes.deinit(self.alloc); + } self.action_arena.promote(self.alloc).deinit(); } @@ -354,22 +382,131 @@ pub const Viewer = struct { return err; }; + // Parse the layout + var arena: ArenaAllocator = .init(self.alloc); + errdefer arena.deinit(); + const window_alloc = arena.allocator(); + const layout: Layout = Layout.parseWithChecksum( + window_alloc, + data.window_layout, + ) catch |err| { + log.info( + "failed to parse window layout id={} layout={s}", + .{ data.window_id, data.window_layout }, + ); + return err; + }; + try windows.append(self.alloc, .{ .id = data.window_id, .width = data.window_width, .height = data.window_height, + .layout_arena = arena.state, + .layout = layout, }); } + // Setup our windows action so the caller can process GUI + // window changes. + try actions.append(arena_alloc, .{ .windows = windows.items }); + + // Go through the window layout and setup all our panes. We move + // this into a new panes map so that we can easily prune our old + // list. + var panes: PanesMap = .empty; + errdefer { + var panes_it = panes.iterator(); + while (panes_it.next()) |kv| kv.value_ptr.deinit(self.alloc); + panes.deinit(self.alloc); + } + for (windows.items) |window| try initLayout( + self.alloc, + &self.panes, + &panes, + arena_alloc, + actions, + window.layout, + ); + + // No more errors after this point. We're about to replace all + // our owned state with our temporary state, and our errdefers + // above will double-free if there is an error. + errdefer comptime unreachable; + // Replace our window list + for (self.windows.items) |*window| window.deinit(self.alloc); self.windows.deinit(self.alloc); self.windows = windows; + // Replace our panes + { + var panes_it = self.panes.iterator(); + while (panes_it.next()) |kv| kv.value_ptr.deinit(self.alloc); + self.panes.deinit(self.alloc); + self.panes = panes; + } + // TODO: Diff with prior window state, dispatch capture-pane // requests to collect all of the screen contents, other terminal // state, etc. + } - try actions.append(arena_alloc, .{ .windows = self.windows.items }); + fn initLayout( + gpa_alloc: Allocator, + panes_old: *PanesMap, + panes_new: *PanesMap, + actions_alloc: Allocator, + actions: *std.ArrayList(Action), + layout: Layout, + ) !void { + switch (layout.content) { + // Nested layouts, continue going. + .horizontal, .vertical => |layouts| { + for (layouts) |l| { + try initLayout( + gpa_alloc, + panes_old, + panes_new, + actions_alloc, + actions, + l, + ); + } + }, + + // A leaf! Initialize. + .pane => |id| pane: { + const gop = try panes_new.getOrPut(gpa_alloc, id); + if (gop.found_existing) { + // We already have the pane setup. It should not exist + // in the old map because we remove that when we set + // it up. + assert(!panes_old.contains(id)); + break :pane; + } + errdefer _ = panes_new.swapRemove(gop.key_ptr.*); + + // We don't have it in our new map. If it exists in our old + // map then we copy it over and we're done. + if (panes_old.fetchSwapRemove(id)) |entry| { + gop.value_ptr.* = entry.value; + break :pane; + } + + // TODO: We need to gracefully handle overflow of our + // max cols/width here. In practice we shouldn't hit this + // so we cast but its not safe. + var t: Terminal = try .init(gpa_alloc, .{ + .cols = @intCast(layout.width), + .rows = @intCast(layout.height), + }); + errdefer t.deinit(gpa_alloc); + + gop.value_ptr.* = .{ + .terminal = t, + }; + }, + } } /// This queues the command at the end of the command queue From 766c306e0437a2f301c11cd9d8e7c1dcf969383b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 8 Dec 2025 19:45:46 -0800 Subject: [PATCH 17/52] terminal/tmux: pane history --- src/terminal/tmux/viewer.zig | 79 +++++++++++++++++++++++++++++------- 1 file changed, 64 insertions(+), 15 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 7b0307a8f..1c5007625 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -315,14 +315,27 @@ pub const Viewer = struct { content: []const u8, is_err: bool, ) ![]const Action { - // If we have no pending commands, this is unexpected. - const command = self.command_queue.first() orelse { + // Get the command we're expecting output for. We need to get the + // non-pointer value because we are deleting it from the circular + // buffer immediately. This shallow copy is all we need since + // all the memory in Command is owned by GPA. + const command: Command = if (self.command_queue.first()) |ptr| switch (ptr.*) { + // I truly can't explain this. A simple `ptr.*` copy will cause + // our memory to become undefined when deleteOldest is called + // below. I logged all the pointers and they don't match so I + // don't know how its being set to undefined. But a copy like + // this does work. + inline else => |v, tag| @unionInit( + Command, + @tagName(tag), + v, + ), + } else { + // If we have no pending commands, this is unexpected. log.info("unexpected block output err={}", .{is_err}); return &.{}; }; self.command_queue.deleteOldest(1); - - // We always free any memory associated with the command defer command.deinit(self.alloc); // We'll use our arena for the return value here so we can @@ -336,20 +349,25 @@ pub const Viewer = struct { // we have one. var actions: std.ArrayList(Action) = .empty; if (self.command_queue.first()) |next_command| { + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + try next_command.formatCommand(&builder.writer); try actions.append( arena_alloc, - .{ .command = next_command.string() }, + .{ .command = builder.writer.buffered() }, ); } // Process our command - switch (command.*) { + switch (command) { .user => {}, .list_windows => try self.receivedListWindows( arena_alloc, &actions, content, ), + .pane_history => { + // TODO + }, } // Our command processing should not change our state @@ -515,6 +533,10 @@ pub const Viewer = struct { /// /// The next command is not removed, because the expectation is /// that the head of our command list is always sent to tmux. + /// + /// Note: this modifies the `action_arena` since this will put + /// the command string into the arena. It does not clear the arena + /// so any previously allocated values remain valid. fn queueCommand(self: *Viewer, command: Command) Allocator.Error!Action { // Add our command try self.command_queue.ensureUnusedCapacity(self.alloc, 1); @@ -522,7 +544,13 @@ pub const Viewer = struct { // Get our first command to send, guaranteed to exist since we // just appended one. - return .{ .command = self.command_queue.first().?.string() }; + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + const arena_alloc = arena.allocator(); + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + const next_command = self.command_queue.first().?; + next_command.formatCommand(&builder.writer) catch return error.OutOfMemory; + return .{ .command = builder.writer.buffered() }; } /// Helper to return a single action. The input action may use the arena @@ -572,27 +600,48 @@ const Command = union(enum) { /// List all windows so we can sync our window state. list_windows, + /// Capture history for the given pane ID. + pane_history: usize, + /// User command. This is a command provided by the user. Since /// this is user provided, we can't be sure what it is. user: []const u8, pub fn deinit(self: Command, alloc: Allocator) void { return switch (self) { - .list_windows => {}, + .list_windows, + .pane_history, + => {}, .user => |v| alloc.free(v), }; } - /// Returns the command to execute. The memory of the return - /// value is always safe as long as this command value is alive. - pub fn string(self: Command) []const u8 { - return switch (self) { - .list_windows => std.fmt.comptimePrint( + /// Format the command into the command that should be executed + /// by tmux. Trailing newlines are appended so this can be sent as-is + /// to tmux. + pub fn formatCommand( + self: Command, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + switch (self) { + .list_windows => try writer.writeAll(std.fmt.comptimePrint( "list-windows -F '{s}'\n", .{comptime Format.list_windows.comptimeFormat()}, + )), + + .pane_history => |id| try writer.print( + // -p = output to stdout instead of buffer + // -e = output escape sequences for SGR + // -S - = start at the top of history ("-") + // -E -1 = end at the last line of history (1 before the + // visible area is -1). + // -t %{d} = target a specific pane ID + "capture-pane -p -e -S - -E -1 -t %{d}", + .{id}, ), - .user => |v| v, - }; + + .user => |v| try writer.writeAll(v), + } } }; From f02a2d5eed7cf59f2eed24cd5b16f225129d9d32 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 07:29:59 -0800 Subject: [PATCH 18/52] terminal/tmux: capture pane --- src/terminal/tmux/viewer.zig | 164 +++++++++++++++++++++++------------ 1 file changed, 110 insertions(+), 54 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 1c5007625..82aed6c2a 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -257,11 +257,17 @@ pub const Viewer = struct { .session_changed => |info| { self.session_id = info.id; - self.state = .command_queue; - return self.singleAction(self.queueCommand(.list_windows) catch { + + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + _ = arena.reset(.free_all); + return self.enterCommandQueue( + arena.allocator(), + .list_windows, + ) catch { log.warn("failed to queue command, becoming defunct", .{}); return self.defunct(); - }); + }; }, else => return &.{}, @@ -348,14 +354,6 @@ pub const Viewer = struct { // Build up our actions to start with the next command if // we have one. var actions: std.ArrayList(Action) = .empty; - if (self.command_queue.first()) |next_command| { - var builder: std.Io.Writer.Allocating = .init(arena_alloc); - try next_command.formatCommand(&builder.writer); - try actions.append( - arena_alloc, - .{ .command = builder.writer.buffered() }, - ); - } // Process our command switch (command) { @@ -370,6 +368,18 @@ pub const Viewer = struct { }, } + // After processing commands, we add our next command to + // execute if we have one. We do this last because command + // processing may itself queue more commands. + if (self.command_queue.first()) |next_command| { + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + try next_command.formatCommand(&builder.writer); + try actions.append( + arena_alloc, + .{ .command = builder.writer.buffered() }, + ); + } + // Our command processing should not change our state assert(self.state == .command_queue); @@ -382,6 +392,9 @@ pub const Viewer = struct { actions: *std.ArrayList(Action), content: []const u8, ) !void { + // If there is an error, reset our actions to what it was before. + errdefer actions.shrinkRetainingCapacity(actions.items.len); + // This stores our new window state from this list-windows output. var windows: std.ArrayList(Window) = .empty; errdefer windows.deinit(self.alloc); @@ -433,19 +446,50 @@ pub const Viewer = struct { // list. var panes: PanesMap = .empty; errdefer { + // Clear out all the new panes. var panes_it = panes.iterator(); - while (panes_it.next()) |kv| kv.value_ptr.deinit(self.alloc); + while (panes_it.next()) |kv| { + if (!self.panes.contains(kv.key_ptr.*)) { + kv.value_ptr.deinit(self.alloc); + } + } panes.deinit(self.alloc); } for (windows.items) |window| try initLayout( self.alloc, &self.panes, &panes, - arena_alloc, - actions, window.layout, ); + // Build up the list of removed panes. + var removed: std.ArrayList(usize) = removed: { + var removed: std.ArrayList(usize) = .empty; + errdefer removed.deinit(self.alloc); + var panes_it = self.panes.iterator(); + while (panes_it.next()) |kv| { + if (panes.contains(kv.key_ptr.*)) continue; + try removed.append(self.alloc, kv.key_ptr.*); + } + + break :removed removed; + }; + defer removed.deinit(self.alloc); + + // Get our list of added panes and setup our command queue + // to populate them. + // TODO: errdefer cleanup + { + var panes_it = panes.iterator(); + while (panes_it.next()) |kv| { + const pane_id: usize = kv.key_ptr.*; + if (self.panes.contains(pane_id)) continue; + try self.queueCommands(&.{ + .{ .pane_history = pane_id }, + }); + } + } + // No more errors after this point. We're about to replace all // our owned state with our temporary state, and our errdefers // above will double-free if there is an error. @@ -458,8 +502,15 @@ pub const Viewer = struct { // Replace our panes { - var panes_it = self.panes.iterator(); - while (panes_it.next()) |kv| kv.value_ptr.deinit(self.alloc); + // First remove our old panes + for (removed.items) |id| if (self.panes.fetchSwapRemove( + id, + )) |entry_const| { + var entry = entry_const; + entry.value.deinit(self.alloc); + }; + // We can now deinit self.panes because the existing + // entries are preserved. self.panes.deinit(self.alloc); self.panes = panes; } @@ -471,10 +522,8 @@ pub const Viewer = struct { fn initLayout( gpa_alloc: Allocator, - panes_old: *PanesMap, + panes_old: *const PanesMap, panes_new: *PanesMap, - actions_alloc: Allocator, - actions: *std.ArrayList(Action), layout: Layout, ) !void { switch (layout.content) { @@ -485,8 +534,6 @@ pub const Viewer = struct { gpa_alloc, panes_old, panes_new, - actions_alloc, - actions, l, ); } @@ -495,19 +542,13 @@ pub const Viewer = struct { // A leaf! Initialize. .pane => |id| pane: { const gop = try panes_new.getOrPut(gpa_alloc, id); - if (gop.found_existing) { - // We already have the pane setup. It should not exist - // in the old map because we remove that when we set - // it up. - assert(!panes_old.contains(id)); - break :pane; - } + if (gop.found_existing) break :pane; errdefer _ = panes_new.swapRemove(gop.key_ptr.*); - // We don't have it in our new map. If it exists in our old - // map then we copy it over and we're done. - if (panes_old.fetchSwapRemove(id)) |entry| { - gop.value_ptr.* = entry.value; + // If we already have this pane, it is already initialized + // so just copy it over. + if (panes_old.getEntry(id)) |entry| { + gop.value_ptr.* = entry.value_ptr.*; break :pane; } @@ -527,30 +568,45 @@ pub const Viewer = struct { } } - /// This queues the command at the end of the command queue - /// and returns an action representing the next command that - /// should be run (the head). - /// - /// The next command is not removed, because the expectation is - /// that the head of our command list is always sent to tmux. - /// - /// Note: this modifies the `action_arena` since this will put - /// the command string into the arena. It does not clear the arena - /// so any previously allocated values remain valid. - fn queueCommand(self: *Viewer, command: Command) Allocator.Error!Action { + /// Enters the command queue state from any other state, queueing + /// the command and returning an action to execute the first command. + fn enterCommandQueue( + self: *Viewer, + arena_alloc: Allocator, + command: Command, + ) Allocator.Error![]const Action { + assert(self.state != .command_queue); + + // Build our command string to send for the action. + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + command.formatCommand(&builder.writer) catch return error.OutOfMemory; + const action: Action = .{ .command = builder.writer.buffered() }; + // Add our command try self.command_queue.ensureUnusedCapacity(self.alloc, 1); self.command_queue.appendAssumeCapacity(command); - // Get our first command to send, guaranteed to exist since we - // just appended one. - var arena = self.action_arena.promote(self.alloc); - defer self.action_arena = arena.state; - const arena_alloc = arena.allocator(); - var builder: std.Io.Writer.Allocating = .init(arena_alloc); - const next_command = self.command_queue.first().?; - next_command.formatCommand(&builder.writer) catch return error.OutOfMemory; - return .{ .command = builder.writer.buffered() }; + // Move into the command queue state + self.state = .command_queue; + + return self.singleAction(action); + } + + /// Queue multiple commands to execute. This doesn't add anything + /// to the actions queue or return actions or anything because the + /// command_queue state will automatically send the next command when + /// it receives output. + fn queueCommands( + self: *Viewer, + commands: []const Command, + ) Allocator.Error!void { + try self.command_queue.ensureUnusedCapacity( + self.alloc, + commands.len, + ); + for (commands) |command| { + self.command_queue.appendAssumeCapacity(command); + } } /// Helper to return a single action. The input action may use the arena @@ -636,7 +692,7 @@ const Command = union(enum) { // -E -1 = end at the last line of history (1 before the // visible area is -1). // -t %{d} = target a specific pane ID - "capture-pane -p -e -S - -E -1 -t %{d}", + "capture-pane -p -e -S - -E -1 -t %{d}\n", .{id}, ), @@ -713,7 +769,7 @@ test "initial flow" { \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] , } }); - try testing.expectEqual(1, actions.len); + try testing.expect(actions.len > 0); try testing.expect(actions[0] == .windows); try testing.expectEqual(1, actions[0].windows.len); } From e1e2791fb72d27c0383140dcc2bf2a10021bec45 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 09:48:17 -0800 Subject: [PATCH 19/52] terminal/tmux: pane_history replays it into terminal --- src/terminal/tmux/viewer.zig | 40 ++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 82aed6c2a..e9d318e7f 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -358,14 +358,17 @@ pub const Viewer = struct { // Process our command switch (command) { .user => {}, + .list_windows => try self.receivedListWindows( arena_alloc, &actions, content, ), - .pane_history => { - // TODO - }, + + .pane_history => |id| try self.receivedPaneHistory( + id, + content, + ), } // After processing commands, we add our next command to @@ -514,10 +517,29 @@ pub const Viewer = struct { self.panes.deinit(self.alloc); self.panes = panes; } + } - // TODO: Diff with prior window state, dispatch capture-pane - // requests to collect all of the screen contents, other terminal - // state, etc. + fn receivedPaneHistory( + self: *Viewer, + id: usize, + content: []const u8, + ) !void { + // Get our pane + const entry = self.panes.getEntry(id) orelse { + log.info("received pane history for untracked pane id={}", .{id}); + return; + }; + const pane: *Pane = entry.value_ptr; + + // Get a VT stream from the terminal so we can send data as-is into + // it. This will populate the active area too so it won't be exactly + // correct but we'll get the active contents soon. + var stream = pane.terminal.vtStream(); + defer stream.deinit(); + stream.nextSlice(content) catch |err| { + log.info("failed to process pane history for pane id={}: {}", .{ id, err }); + return err; + }; } fn initLayout( @@ -747,8 +769,10 @@ test "initial flow" { defer viewer.deinit(); // First we receive the initial block end - const actions0 = viewer.next(.{ .tmux = .{ .block_end = "" } }); - try testing.expectEqual(0, actions0.len); + { + const actions = viewer.next(.{ .tmux = .{ .block_end = "" } }); + try testing.expectEqual(0, actions.len); + } // Then we receive session-changed with the initial session { From 41bf54100524858f59a9cc2e63a3e86eafd8fa1b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 10:17:03 -0800 Subject: [PATCH 20/52] terminal/tmux: test helper --- src/terminal/tmux/viewer.zig | 178 +++++++++++++++++++++++++++-------- 1 file changed, 138 insertions(+), 40 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index e9d318e7f..8d3194748 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -754,53 +754,151 @@ const Format = struct { } }; +const TestStep = struct { + input: Viewer.Input, + contains_tags: []const std.meta.Tag(Viewer.Action) = &.{}, + contains_command: []const u8 = "", + check: ?*const fn (viewer: *Viewer, []const Viewer.Action) anyerror!void = null, + check_command: ?*const fn (viewer: *Viewer, []const u8) anyerror!void = null, + + fn run(self: TestStep, viewer: *Viewer) !void { + const actions = viewer.next(self.input); + + // Common mistake, forgetting the newline on a command. + for (actions) |action| { + if (action == .command) { + try testing.expect(std.mem.endsWith(u8, action.command, "\n")); + } + } + + for (self.contains_tags) |tag| { + var found = false; + for (actions) |action| { + if (action == tag) { + found = true; + break; + } + } + try testing.expect(found); + } + + if (self.contains_command.len > 0) { + var found = false; + for (actions) |action| { + if (action == .command and + std.mem.startsWith(u8, action.command, self.contains_command)) + { + found = true; + break; + } + } + try testing.expect(found); + } + + if (self.check) |check_fn| { + try check_fn(viewer, actions); + } + + if (self.check_command) |check_fn| { + var found = false; + for (actions) |action| { + if (action == .command) { + found = true; + try check_fn(viewer, action.command); + } + } + try testing.expect(found); + } + } +}; + +/// A helper to run a series of test steps against a viewer and assert +/// that the expected actions are produced. +/// +/// I'm generally not a fan of these types of abstracted tests because +/// it makes diagnosing failures harder, but being able to construct +/// simulated tmux inputs and verify outputs is going to be extremely +/// important since the tmux control mode protocol is very complex and +/// fragile. +fn testViewer(viewer: *Viewer, steps: []const TestStep) !void { + for (steps, 0..) |step, i| { + step.run(viewer) catch |err| { + log.warn("testViewer step failed i={} step={}", .{ i, step }); + return err; + }; + } +} + test "immediate exit" { var viewer = try Viewer.init(testing.allocator); defer viewer.deinit(); - const actions = viewer.next(.{ .tmux = .exit }); - try testing.expectEqual(1, actions.len); - try testing.expectEqual(.exit, actions[0]); - const actions2 = viewer.next(.{ .tmux = .exit }); - try testing.expectEqual(0, actions2.len); + + try testViewer(&viewer, &.{ + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + .{ + .input = .{ .tmux = .exit }, + .check = (struct { + fn check(_: *Viewer, actions: []const Viewer.Action) anyerror!void { + try testing.expectEqual(0, actions.len); + } + }).check, + }, + }); } test "initial flow" { var viewer = try Viewer.init(testing.allocator); defer viewer.deinit(); - // First we receive the initial block end - { - const actions = viewer.next(.{ .tmux = .{ .block_end = "" } }); - try testing.expectEqual(0, actions.len); - } - - // Then we receive session-changed with the initial session - { - const actions = viewer.next(.{ .tmux = .{ .session_changed = .{ - .id = 42, - .name = "main", - } } }); - try testing.expectEqual(1, actions.len); - try testing.expect(actions[0] == .command); - try testing.expect(std.mem.startsWith(u8, actions[0].command, "list-windows")); - try testing.expectEqual(42, viewer.session_id); - } - - // Simulate our list-windows command - { - const actions = viewer.next(.{ .tmux = .{ - .block_end = - \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] - , - } }); - try testing.expect(actions.len > 0); - try testing.expect(actions[0] == .windows); - try testing.expectEqual(1, actions[0].windows.len); - } - - const exit_actions = viewer.next(.{ .tmux = .exit }); - try testing.expectEqual(1, exit_actions.len); - try testing.expectEqual(.exit, exit_actions[0]); - const final_actions = viewer.next(.{ .tmux = .exit }); - try testing.expectEqual(0, final_actions.len); + try testViewer(&viewer, &.{ + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 42, + .name = "main", + } } }, + .contains_command = "list-windows", + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(42, v.session_id); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] + , + } }, + .contains_tags = &.{ .windows, .command }, + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ + .block_end = + \\Hello, world! + \\ + , + } }, + // Moves on to the next pane + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); } From b7fe9a926da6e479ccd3d06fd13c49f4f68c07a7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 11:19:47 -0800 Subject: [PATCH 21/52] terminal/tmux: capture visible area after history --- src/terminal/tmux/viewer.zig | 66 +++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 8d3194748..28a2aaf1e 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -369,6 +369,11 @@ pub const Viewer = struct { id, content, ), + + .pane_visible => |id| try self.receivedPaneVisible( + id, + content, + ), } // After processing commands, we add our next command to @@ -489,6 +494,7 @@ pub const Viewer = struct { if (self.panes.contains(pane_id)) continue; try self.queueCommands(&.{ .{ .pane_history = pane_id }, + .{ .pane_visible = pane_id }, }); } } @@ -542,6 +548,31 @@ pub const Viewer = struct { }; } + fn receivedPaneVisible( + self: *Viewer, + id: usize, + content: []const u8, + ) !void { + // Get our pane + const entry = self.panes.getEntry(id) orelse { + log.info("received pane visible for untracked pane id={}", .{id}); + return; + }; + const pane: *Pane = entry.value_ptr; + + // Erase the active area and reset the cursor to the top-left + // before writing the visible content. + pane.terminal.eraseDisplay(.complete, false); + pane.terminal.setCursorPos(1, 1); + + var stream = pane.terminal.vtStream(); + defer stream.deinit(); + stream.nextSlice(content) catch |err| { + log.info("failed to process pane visible for pane id={}: {}", .{ id, err }); + return err; + }; + } + fn initLayout( gpa_alloc: Allocator, panes_old: *const PanesMap, @@ -681,6 +712,9 @@ const Command = union(enum) { /// Capture history for the given pane ID. pane_history: usize, + /// Capture visible area for the given pane ID. + pane_visible: usize, + /// User command. This is a command provided by the user. Since /// this is user provided, we can't be sure what it is. user: []const u8, @@ -689,6 +723,7 @@ const Command = union(enum) { return switch (self) { .list_windows, .pane_history, + .pane_visible, => {}, .user => |v| alloc.free(v), }; @@ -718,6 +753,15 @@ const Command = union(enum) { .{id}, ), + .pane_visible => |id| try writer.print( + // -p = output to stdout instead of buffer + // -e = output escape sequences for SGR + // -t %{d} = target a specific pane ID + // (no -S/-E = capture visible area only) + "capture-pane -p -e -t %{d}\n", + .{id}, + ), + .user => |v| try writer.writeAll(v), } } @@ -888,7 +932,27 @@ test "initial flow" { \\ , } }, - // Moves on to the next pane + // Moves on to pane_visible for pane 0 + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_history for pane 1 + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_visible for pane 1 .contains_command = "capture-pane", .check_command = (struct { fn check(_: *Viewer, command: []const u8) anyerror!void { From a3e01581bea9907c3d03d180c1eb57850b9d89c5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 11:29:27 -0800 Subject: [PATCH 22/52] terminal/tmux: history capture clears active area --- src/terminal/tmux/viewer.zig | 44 ++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 28a2aaf1e..aa9c91a03 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -4,6 +4,7 @@ const ArenaAllocator = std.heap.ArenaAllocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; const CircBuf = @import("../../datastruct/main.zig").CircBuf; +const Screen = @import("../Screen.zig"); const Terminal = @import("../Terminal.zig"); const Layout = @import("layout.zig").Layout; const control = @import("control.zig"); @@ -536,16 +537,34 @@ pub const Viewer = struct { return; }; const pane: *Pane = entry.value_ptr; + const t: *Terminal = &pane.terminal; + const screen: *Screen = t.screens.active; // Get a VT stream from the terminal so we can send data as-is into // it. This will populate the active area too so it won't be exactly // correct but we'll get the active contents soon. - var stream = pane.terminal.vtStream(); + var stream = t.vtStream(); defer stream.deinit(); stream.nextSlice(content) catch |err| { log.info("failed to process pane history for pane id={}: {}", .{ id, err }); return err; }; + + // Populate the active area to be empty since this is only history. + // We'll fill it with blanks and move the cursor to the top-left. + t.carriageReturn(); + for (0..t.rows) |_| try t.index(); + t.setCursorPos(1, 1); + + // Our active area should be empty + if (comptime std.debug.runtime_safety) { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + screen.dumpString(&discarding.writer, .{ + .tl = screen.pages.getTopLeft(.active), + .unwrap = false, + }) catch unreachable; + assert(discarding.count == 0); + } } fn receivedPaneVisible( @@ -929,7 +948,6 @@ test "initial flow" { .input = .{ .tmux = .{ .block_end = \\Hello, world! - \\ , } }, // Moves on to pane_visible for pane 0 @@ -939,6 +957,28 @@ test "initial flow" { try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); } }).check, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr; + const screen: *Screen = pane.terminal.screens.active; + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .history = .{} }, + ); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Hello, world!", str); + } + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .active = .{} }, + ); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + } + }).check, }, .{ .input = .{ .tmux = .{ .block_end = "" } }, From 50ac848672d3752e67af125101f9bccd75748f8b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 12:53:18 -0800 Subject: [PATCH 23/52] terminal/tmux: capture both primary/alt screen --- src/terminal/tmux/viewer.zig | 116 ++++++++++++++++++++++++++++------- 1 file changed, 95 insertions(+), 21 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index aa9c91a03..5df5b83bb 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -5,6 +5,7 @@ const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; const CircBuf = @import("../../datastruct/main.zig").CircBuf; const Screen = @import("../Screen.zig"); +const ScreenSet = @import("../ScreenSet.zig"); const Terminal = @import("../Terminal.zig"); const Layout = @import("layout.zig").Layout; const control = @import("control.zig"); @@ -366,13 +367,15 @@ pub const Viewer = struct { content, ), - .pane_history => |id| try self.receivedPaneHistory( - id, + .pane_history => |cap| try self.receivedPaneHistory( + cap.screen_key, + cap.id, content, ), - .pane_visible => |id| try self.receivedPaneVisible( - id, + .pane_visible => |cap| try self.receivedPaneVisible( + cap.screen_key, + cap.id, content, ), } @@ -494,8 +497,10 @@ pub const Viewer = struct { const pane_id: usize = kv.key_ptr.*; if (self.panes.contains(pane_id)) continue; try self.queueCommands(&.{ - .{ .pane_history = pane_id }, - .{ .pane_visible = pane_id }, + .{ .pane_history = .{ .id = pane_id, .screen_key = .primary } }, + .{ .pane_visible = .{ .id = pane_id, .screen_key = .primary } }, + .{ .pane_history = .{ .id = pane_id, .screen_key = .alternate } }, + .{ .pane_visible = .{ .id = pane_id, .screen_key = .alternate } }, }); } } @@ -528,6 +533,7 @@ pub const Viewer = struct { fn receivedPaneHistory( self: *Viewer, + screen_key: ScreenSet.Key, id: usize, content: []const u8, ) !void { @@ -538,6 +544,7 @@ pub const Viewer = struct { }; const pane: *Pane = entry.value_ptr; const t: *Terminal = &pane.terminal; + _ = try t.switchScreen(screen_key); const screen: *Screen = t.screens.active; // Get a VT stream from the terminal so we can send data as-is into @@ -569,6 +576,7 @@ pub const Viewer = struct { fn receivedPaneVisible( self: *Viewer, + screen_key: ScreenSet.Key, id: usize, content: []const u8, ) !void { @@ -578,13 +586,15 @@ pub const Viewer = struct { return; }; const pane: *Pane = entry.value_ptr; + const t: *Terminal = &pane.terminal; + _ = try t.switchScreen(screen_key); // Erase the active area and reset the cursor to the top-left // before writing the visible content. - pane.terminal.eraseDisplay(.complete, false); - pane.terminal.setCursorPos(1, 1); + t.eraseDisplay(.complete, false); + t.setCursorPos(1, 1); - var stream = pane.terminal.vtStream(); + var stream = t.vtStream(); defer stream.deinit(); stream.nextSlice(content) catch |err| { log.info("failed to process pane visible for pane id={}: {}", .{ id, err }); @@ -729,15 +739,20 @@ const Command = union(enum) { list_windows, /// Capture history for the given pane ID. - pane_history: usize, + pane_history: CapturePane, /// Capture visible area for the given pane ID. - pane_visible: usize, + pane_visible: CapturePane, /// User command. This is a command provided by the user. Since /// this is user provided, we can't be sure what it is. user: []const u8, + const CapturePane = struct { + id: usize, + screen_key: ScreenSet.Key, + }; + pub fn deinit(self: Command, alloc: Allocator) void { return switch (self) { .list_windows, @@ -761,24 +776,34 @@ const Command = union(enum) { .{comptime Format.list_windows.comptimeFormat()}, )), - .pane_history => |id| try writer.print( + .pane_history => |cap| try writer.print( // -p = output to stdout instead of buffer // -e = output escape sequences for SGR + // -a = capture alternate screen (only valid for alternate) + // -q = quiet, don't error if alternate screen doesn't exist // -S - = start at the top of history ("-") // -E -1 = end at the last line of history (1 before the // visible area is -1). // -t %{d} = target a specific pane ID - "capture-pane -p -e -S - -E -1 -t %{d}\n", - .{id}, + "capture-pane -p -e -q {s}-S - -E -1 -t %{d}\n", + .{ + if (cap.screen_key == .alternate) "-a " else "", + cap.id, + }, ), - .pane_visible => |id| try writer.print( + .pane_visible => |cap| try writer.print( // -p = output to stdout instead of buffer // -e = output escape sequences for SGR + // -a = capture alternate screen (only valid for alternate) + // -q = quiet, don't error if alternate screen doesn't exist // -t %{d} = target a specific pane ID // (no -S/-E = capture visible area only) - "capture-pane -p -e -t %{d}\n", - .{id}, + "capture-pane -p -e -q {s}-t %{d}\n", + .{ + if (cap.screen_key == .alternate) "-a " else "", + cap.id, + }, ), .user => |v| try writer.writeAll(v), @@ -938,9 +963,11 @@ test "initial flow" { } }, .contains_tags = &.{ .windows, .command }, .contains_command = "capture-pane", + // pane_history for pane 0 (primary) .check_command = (struct { fn check(_: *Viewer, command: []const u8) anyerror!void { try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a")); } }).check, }, @@ -950,11 +977,12 @@ test "initial flow" { \\Hello, world! , } }, - // Moves on to pane_visible for pane 0 + // Moves on to pane_visible for pane 0 (primary) .contains_command = "capture-pane", .check_command = (struct { fn check(_: *Viewer, command: []const u8) anyerror!void { try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a")); } }).check, .check = (struct { @@ -982,21 +1010,67 @@ test "initial flow" { }, .{ .input = .{ .tmux = .{ .block_end = "" } }, - // Moves on to pane_history for pane 1 + // Moves on to pane_history for pane 0 (alternate) .contains_command = "capture-pane", .check_command = (struct { fn check(_: *Viewer, command: []const u8) anyerror!void { - try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a")); } }).check, }, .{ .input = .{ .tmux = .{ .block_end = "" } }, - // Moves on to pane_visible for pane 1 + // Moves on to pane_visible for pane 0 (alternate) + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_history for pane 1 (primary) .contains_command = "capture-pane", .check_command = (struct { fn check(_: *Viewer, command: []const u8) anyerror!void { try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_visible for pane 1 (primary) + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_history for pane 1 (alternate) + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_visible for pane 1 (alternate) + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a")); } }).check, }, From 938e419e042bfd9322b5180e6ac54c122f558a36 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 13:11:58 -0800 Subject: [PATCH 24/52] terminal/tmux: handle output events --- src/terminal/tmux/viewer.zig | 67 ++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 5df5b83bb..f6cf6292b 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -13,6 +13,12 @@ const output = @import("output.zig"); const log = std.log.scoped(.terminal_tmux_viewer); +// TODO: A list of TODOs as I think about them. +// - We need to make startup more robust so session and block can happen +// out of order. +// - We need to ignore `output` for panes that aren't yet initialized +// (until capture-panes are complete). + // NOTE: There is some fragility here that can possibly break if tmux // changes their implementation. In particular, the order of notifications // and assurances about what is sent when are based on reading the tmux @@ -312,6 +318,20 @@ pub const Viewer = struct { break :err self.defunct(); }, + .output => |out| output: { + self.receivedOutput( + out.pane_id, + out.data, + ) catch |err| { + log.warn( + "failed to process output for pane id={}: {}", + .{ out.pane_id, err }, + ); + }; + + break :output &.{}; + }, + // TODO: Use exhaustive matching here, determine if we need // to handle the other cases. else => &.{}, @@ -602,6 +622,26 @@ pub const Viewer = struct { }; } + fn receivedOutput( + self: *Viewer, + id: usize, + data: []const u8, + ) !void { + const entry = self.panes.getEntry(id) orelse { + log.info("received output for untracked pane id={}", .{id}); + return; + }; + const pane: *Pane = entry.value_ptr; + const t: *Terminal = &pane.terminal; + + var stream = t.vtStream(); + defer stream.deinit(); + stream.nextSlice(data) catch |err| { + log.info("failed to process output for pane id={}: {}", .{ id, err }); + return err; + }; + } + fn initLayout( gpa_alloc: Allocator, panes_old: *const PanesMap, @@ -1074,6 +1114,33 @@ test "initial flow" { } }).check, }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + }, + .{ + .input = .{ .tmux = .{ .output = .{ .pane_id = 0, .data = "new output" } } }, + .check = (struct { + fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void { + try testing.expectEqual(0, actions.len); + const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr; + const screen: *Screen = pane.terminal.screens.active; + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .active = .{} }, + ); + defer testing.allocator.free(str); + try testing.expect(std.mem.containsAtLeast(u8, str, 1, "new output")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .output = .{ .pane_id = 999, .data = "ignored" } } }, + .check = (struct { + fn check(_: *Viewer, actions: []const Viewer.Action) anyerror!void { + try testing.expectEqual(0, actions.len); + } + }).check, + }, .{ .input = .{ .tmux = .exit }, .contains_tags = &.{.exit}, From 64ef640127c7a48172a27990f240a8c068b0ea70 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 13:52:53 -0800 Subject: [PATCH 25/52] terminal/tmux: exhaustive switch for command --- src/terminal/tmux/viewer.zig | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index f6cf6292b..9c6fa1b1f 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -18,6 +18,8 @@ const log = std.log.scoped(.terminal_tmux_viewer); // out of order. // - We need to ignore `output` for panes that aren't yet initialized // (until capture-panes are complete). +// - We should note what the active window pane is on the tmux side; +// we can use this at least for initial focus. // NOTE: There is some fragility here that can possibly break if tmux // changes their implementation. In particular, the order of notifications @@ -332,9 +334,30 @@ pub const Viewer = struct { break :output &.{}; }, - // TODO: Use exhaustive matching here, determine if we need - // to handle the other cases. - else => &.{}, + // TODO: There's real logic to do for these. + .session_changed, + .layout_change, + .window_add, + => &.{}, + + // The active pane changed. We don't care about this because + // we handle our own focus. + .window_pane_changed => &.{}, + + // We ignore this one. It means a session was created or + // destroyed. If it was our own session we will get an exit + // notification very soon. If it is another session we don't + // care. + .sessions_changed => &.{}, + + // We don't use window names for anything, currently. + .window_renamed => &.{}, + + // This is for other clients, which we don't do anything about. + // For us, we'll get `exit` or `session_changed`, respectively. + .client_detached, + .client_session_changed, + => &.{}, }; } From 071070faa3a5d9d56e4f802218cd6e8a31075670 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 14:11:25 -0800 Subject: [PATCH 26/52] terminal/tmux: handle session_changed inside command loop --- src/terminal/tmux/viewer.zig | 136 ++++++++++++++++++++++++++++++++++- 1 file changed, 133 insertions(+), 3 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 9c6fa1b1f..3b401f44e 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -315,9 +315,9 @@ pub const Viewer = struct { => |content, tag| self.receivedCommandOutput( content, tag == .block_err, - ) catch err: { + ) catch { log.warn("failed to process command output, becoming defunct", .{}); - break :err self.defunct(); + return self.defunct(); }, .output => |out| output: { @@ -334,8 +334,14 @@ pub const Viewer = struct { break :output &.{}; }, + // Session changed means we switched to a different tmux session. + // We need to reset our state and start fresh with list-windows. + .session_changed => |info| self.sessionChanged(info.id) catch { + log.warn("failed to handle session change, becoming defunct", .{}); + return self.defunct(); + }, + // TODO: There's real logic to do for these. - .session_changed, .layout_change, .window_add, => &.{}, @@ -361,6 +367,47 @@ pub const Viewer = struct { }; } + /// When a session changes, we have to basically reset our whole state. + /// To do this, we emit an empty windows event (so callers can clear all + /// windows), reset ourself, and start all over. + fn sessionChanged( + self: *Viewer, + session_id: usize, + ) (Allocator.Error || std.Io.Writer.Error)![]const Action { + // Build up a new viewer. Its the easiest way to reset ourselves. + var replacement: Viewer = try .init(self.alloc); + errdefer replacement.deinit(); + + // Build actions: empty windows notification + list-windows command + var arena = replacement.action_arena.promote(replacement.alloc); + const arena_alloc = arena.allocator(); + var actions: std.ArrayList(Action) = .empty; + try actions.append(arena_alloc, .{ .windows = &.{} }); + + // Setup our command queue + try actions.appendSlice( + arena_alloc, + try replacement.enterCommandQueue( + arena_alloc, + .list_windows, + ), + ); + + // Save arena state back before swap + replacement.action_arena = arena.state; + + // Swap our self, no more error handling after this. + errdefer comptime unreachable; + self.deinit(); + self.* = replacement; + + // Set our session ID and jump directly to the list + self.session_id = session_id; + + assert(self.state == .command_queue); + return actions.items; + } + fn receivedCommandOutput( self: *Viewer, content: []const u8, @@ -1000,6 +1047,89 @@ test "immediate exit" { }); } +test "session changed resets state" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "first", + } } }, + .contains_command = "list-windows", + }, + // Receive window layout with two panes (same format as "initial flow" test) + .{ + .input = .{ .tmux = .{ + .block_end = + \\$1 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(1, v.session_id); + try testing.expectEqual(1, v.windows.items.len); + try testing.expectEqual(2, v.panes.count()); + } + }).check, + }, + // Now session changes - should reset everything + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 2, + .name = "second", + } } }, + .contains_tags = &.{ .windows, .command }, + .contains_command = "list-windows", + .check = (struct { + fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void { + // Session ID should be updated + try testing.expectEqual(2, v.session_id); + // Windows should be cleared (empty windows action sent) + var found_empty_windows = false; + for (actions) |action| { + if (action == .windows and action.windows.len == 0) { + found_empty_windows = true; + } + } + try testing.expect(found_empty_windows); + // Old windows should be cleared + try testing.expectEqual(0, v.windows.items.len); + // Old panes should be cleared + try testing.expectEqual(0, v.panes.count()); + } + }).check, + }, + // Receive new window layout for new session (same layout, different session/window) + // Uses same pane IDs 0,1 - they should be re-created since old panes were cleared + .{ + .input = .{ .tmux = .{ + .block_end = + \\$2 @1 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(2, v.session_id); + try testing.expectEqual(1, v.windows.items.len); + try testing.expectEqual(1, v.windows.items[0].id); + // Panes 0 and 1 should be created (fresh, since old ones were cleared) + try testing.expectEqual(2, v.panes.count()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} + test "initial flow" { var viewer = try Viewer.init(testing.allocator); defer viewer.deinit(); From 5df95ba210b40ef55a61d0401816b8d1c3099bd6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 00:07:05 +0000 Subject: [PATCH 27/52] build(deps): bump peter-evans/create-pull-request from 7.0.11 to 8.0.0 Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 7.0.11 to 8.0.0. - [Release notes](https://github.com/peter-evans/create-pull-request/releases) - [Commits](https://github.com/peter-evans/create-pull-request/compare/22a9089034f40e5a961c8808d113e2c98fb63676...98357b18bf14b5342f975ff684046ec3b2a07725) --- updated-dependencies: - dependency-name: peter-evans/create-pull-request dependency-version: 8.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/update-colorschemes.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index ca65c2a21..bceb8aef1 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -62,7 +62,7 @@ jobs: run: nix build .#ghostty - name: Create pull request - uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 with: title: Update iTerm2 colorschemes base: main From 1a2b3c165ac049ded7c893f23ea5ee1205bd35d2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 15:31:44 -0800 Subject: [PATCH 28/52] terminal/tmux: layoutChanged handling --- src/terminal/tmux/viewer.zig | 436 ++++++++++++++++++++++++++++------- 1 file changed, 356 insertions(+), 80 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 3b401f44e..b8579d1d5 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -341,10 +341,20 @@ pub const Viewer = struct { return self.defunct(); }, + // Layout changed of a single window. + .layout_change => |info| self.layoutChanged( + info.window_id, + info.layout, + ) catch { + // Note: in the future, we can probably handle a failure + // here with a fallback to remove this one window, list + // windows again, and try again. + log.warn("failed to handle layout change, becoming defunct", .{}); + return self.defunct(); + }, + // TODO: There's real logic to do for these. - .layout_change, - .window_add, - => &.{}, + .window_add => &.{}, // The active pane changed. We don't care about this because // we handle our own focus. @@ -367,6 +377,164 @@ pub const Viewer = struct { }; } + /// When the layout changes for a single window, a pane may be added + /// or removed that we've never seen, in addition to the layout itself + /// physically changing. + /// + /// To handle this, its similar to list-windows except we expect the + /// window to already exist. We update the layout, do the initLayout + /// call for any diffs, setup commands to capture any new panes, + /// prune any removed panes. + fn layoutChanged( + self: *Viewer, + window_id: usize, + layout_str: []const u8, + ) ![]const Action { + // Find the window this layout change is for. + const window: *Window = window: for (self.windows.items) |*w| { + if (w.id == window_id) break :window w; + } else { + log.info("layout change for unknown window id={}", .{window_id}); + return &.{}; + }; + + // Clear our prior window arena and setup our layout + window.layout = layout: { + var arena = window.layout_arena.promote(self.alloc); + defer window.layout_arena = arena.state; + _ = arena.reset(.retain_capacity); + break :layout Layout.parseWithChecksum( + arena.allocator(), + layout_str, + ) catch |err| { + log.info( + "failed to parse window layout id={} layout={s}", + .{ window_id, layout_str }, + ); + return err; + }; + }; + + // If our command queue started out empty and becomes non-empty, + // then we need to send down the command. + const command_queue_empty = self.command_queue.empty(); + + // Reset our arena so we can build up actions. + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + _ = arena.reset(.free_all); + const arena_alloc = arena.allocator(); + + // Our initial action is to definitely let the caller know that + // some windows changed. + var actions: std.ArrayList(Action) = .empty; + try actions.append(arena_alloc, .{ .windows = self.windows.items }); + + // Sync up our panes + try self.syncLayouts(self.windows.items); + + // If our command queue was empty and now its not we need to add + // a command to the output. + assert(self.state == .command_queue); + if (command_queue_empty and !self.command_queue.empty()) { + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + const command = self.command_queue.first().?; + command.formatCommand(&builder.writer) catch return error.OutOfMemory; + const action: Action = .{ .command = builder.writer.buffered() }; + try actions.append(arena_alloc, action); + } + + return actions.items; + } + + fn syncLayouts( + self: *Viewer, + windows: []const Window, + ) !void { + // Go through the window layout and setup all our panes. We move + // this into a new panes map so that we can easily prune our old + // list. + var panes: PanesMap = .empty; + errdefer { + // Clear out all the new panes. + var panes_it = panes.iterator(); + while (panes_it.next()) |kv| { + if (!self.panes.contains(kv.key_ptr.*)) { + kv.value_ptr.deinit(self.alloc); + } + } + panes.deinit(self.alloc); + } + for (windows) |window| try initLayout( + self.alloc, + &self.panes, + &panes, + window.layout, + ); + + // Build up the list of removed panes. + var removed: std.ArrayList(usize) = removed: { + var removed: std.ArrayList(usize) = .empty; + errdefer removed.deinit(self.alloc); + var panes_it = self.panes.iterator(); + while (panes_it.next()) |kv| { + if (panes.contains(kv.key_ptr.*)) continue; + try removed.append(self.alloc, kv.key_ptr.*); + } + + break :removed removed; + }; + defer removed.deinit(self.alloc); + + // Ensure we can add the windows + try self.windows.ensureTotalCapacity(self.alloc, windows.len); + + // Get our list of added panes and setup our command queue + // to populate them. + // TODO: errdefer cleanup + { + var panes_it = panes.iterator(); + while (panes_it.next()) |kv| { + const pane_id: usize = kv.key_ptr.*; + if (self.panes.contains(pane_id)) continue; + try self.queueCommands(&.{ + .{ .pane_history = .{ .id = pane_id, .screen_key = .primary } }, + .{ .pane_visible = .{ .id = pane_id, .screen_key = .primary } }, + .{ .pane_history = .{ .id = pane_id, .screen_key = .alternate } }, + .{ .pane_visible = .{ .id = pane_id, .screen_key = .alternate } }, + }); + } + } + + // No more errors after this point. We're about to replace all + // our owned state with our temporary state, and our errdefers + // above will double-free if there is an error. + errdefer comptime unreachable; + + // Replace our window list if it changed. We assume it didn't + // change if our pointer is pointing to the same data. + if (windows.ptr != self.windows.items.ptr) { + for (self.windows.items) |*window| window.deinit(self.alloc); + self.windows.clearRetainingCapacity(); + self.windows.appendSliceAssumeCapacity(windows); + } + + // Replace our panes + { + // First remove our old panes + for (removed.items) |id| if (self.panes.fetchSwapRemove( + id, + )) |entry_const| { + var entry = entry_const; + entry.value.deinit(self.alloc); + }; + // We can now deinit self.panes because the existing + // entries are preserved. + self.panes.deinit(self.alloc); + self.panes = panes; + } + } + /// When a session changes, we have to basically reset our whole state. /// To do this, we emit an empty windows event (so callers can clear all /// windows), reset ourself, and start all over. @@ -499,7 +667,7 @@ pub const Viewer = struct { // This stores our new window state from this list-windows output. var windows: std.ArrayList(Window) = .empty; - errdefer windows.deinit(self.alloc); + defer windows.deinit(self.alloc); // Parse all our windows var it = std.mem.splitScalar(u8, content, '\n'); @@ -543,82 +711,8 @@ pub const Viewer = struct { // window changes. try actions.append(arena_alloc, .{ .windows = windows.items }); - // Go through the window layout and setup all our panes. We move - // this into a new panes map so that we can easily prune our old - // list. - var panes: PanesMap = .empty; - errdefer { - // Clear out all the new panes. - var panes_it = panes.iterator(); - while (panes_it.next()) |kv| { - if (!self.panes.contains(kv.key_ptr.*)) { - kv.value_ptr.deinit(self.alloc); - } - } - panes.deinit(self.alloc); - } - for (windows.items) |window| try initLayout( - self.alloc, - &self.panes, - &panes, - window.layout, - ); - - // Build up the list of removed panes. - var removed: std.ArrayList(usize) = removed: { - var removed: std.ArrayList(usize) = .empty; - errdefer removed.deinit(self.alloc); - var panes_it = self.panes.iterator(); - while (panes_it.next()) |kv| { - if (panes.contains(kv.key_ptr.*)) continue; - try removed.append(self.alloc, kv.key_ptr.*); - } - - break :removed removed; - }; - defer removed.deinit(self.alloc); - - // Get our list of added panes and setup our command queue - // to populate them. - // TODO: errdefer cleanup - { - var panes_it = panes.iterator(); - while (panes_it.next()) |kv| { - const pane_id: usize = kv.key_ptr.*; - if (self.panes.contains(pane_id)) continue; - try self.queueCommands(&.{ - .{ .pane_history = .{ .id = pane_id, .screen_key = .primary } }, - .{ .pane_visible = .{ .id = pane_id, .screen_key = .primary } }, - .{ .pane_history = .{ .id = pane_id, .screen_key = .alternate } }, - .{ .pane_visible = .{ .id = pane_id, .screen_key = .alternate } }, - }); - } - } - - // No more errors after this point. We're about to replace all - // our owned state with our temporary state, and our errdefers - // above will double-free if there is an error. - errdefer comptime unreachable; - - // Replace our window list - for (self.windows.items) |*window| window.deinit(self.alloc); - self.windows.deinit(self.alloc); - self.windows = windows; - - // Replace our panes - { - // First remove our old panes - for (removed.items) |id| if (self.panes.fetchSwapRemove( - id, - )) |entry_const| { - var entry = entry_const; - entry.value.deinit(self.alloc); - }; - // We can now deinit self.panes because the existing - // entries are preserved. - self.panes.deinit(self.alloc); - self.panes = panes; - } + // Sync up our layouts. This will populate unknown panes, prune, etc. + try self.syncLayouts(windows.items); } fn receivedPaneHistory( @@ -1300,3 +1394,185 @@ test "initial flow" { }, }); } + +test "layout change" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(1, v.windows.items.len); + try testing.expectEqual(1, v.panes.count()); + try testing.expect(v.panes.contains(0)); + } + }).check, + }, + // Complete all capture-pane commands for pane 0 (primary and alternate) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // Now send a layout_change that splits into two panes + .{ + .input = .{ .tmux = .{ .layout_change = .{ + .window_id = 0, + .layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .visible_layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .raw_flags = "*", + } } }, + .contains_tags = &.{.windows}, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + // Should still have 1 window + try testing.expectEqual(1, v.windows.items.len); + // Should now have 2 panes (0 and 2) + try testing.expectEqual(2, v.panes.count()); + try testing.expect(v.panes.contains(0)); + try testing.expect(v.panes.contains(2)); + // Commands should be queued for the new pane + try testing.expectEqual(4, v.command_queue.len()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} + +test "layout_change does not return command when queue not empty" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expect(!v.command_queue.empty()); + } + }).check, + }, + // Do NOT complete capture-pane commands - queue still has commands. + // Send a layout_change that splits into two panes. + // This should NOT return a command action since queue was not empty. + .{ + .input = .{ .tmux = .{ .layout_change = .{ + .window_id = 0, + .layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .visible_layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .raw_flags = "*", + } } }, + .contains_tags = &.{.windows}, + .check = (struct { + fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void { + try testing.expectEqual(2, v.panes.count()); + // Should not contain a command action + for (actions) |action| { + try testing.expect(action != .command); + } + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} + +test "layout_change returns command when queue was empty" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + }, + // Complete all capture-pane commands for pane 0 + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // Queue should now be empty + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expect(v.command_queue.empty()); + } + }).check, + }, + // Now send a layout_change that splits into two panes. + // This should return a command action since we're queuing commands + // for the new pane and the queue was empty. + .{ + .input = .{ .tmux = .{ .layout_change = .{ + .window_id = 0, + .layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .visible_layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .raw_flags = "*", + } } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(2, v.panes.count()); + try testing.expect(!v.command_queue.empty()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} From 582ea5d84bab67a56f061dce22b46e56f223d1e8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 17:15:23 -0800 Subject: [PATCH 29/52] terminal/tmux: window add --- src/terminal/tmux/viewer.zig | 148 ++++++++++++++++++++++++++++++++++- 1 file changed, 146 insertions(+), 2 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index b8579d1d5..c3860c6e4 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -353,8 +353,11 @@ pub const Viewer = struct { return self.defunct(); }, - // TODO: There's real logic to do for these. - .window_add => &.{}, + // A window was added to this session. + .window_add => |info| self.windowAdd(info.id) catch { + log.warn("failed to handle window add, becoming defunct", .{}); + return self.defunct(); + }, // The active pane changed. We don't care about this because // we handle our own focus. @@ -447,6 +450,40 @@ pub const Viewer = struct { return actions.items; } + /// When a window is added to the session, we need to refresh our window + /// list to get the new window's information. + fn windowAdd(self: *Viewer, window_id: usize) ![]const Action { + _ = window_id; // We refresh all windows via list-windows + + // If our command queue started out empty and becomes non-empty, + // then we need to send down the command. + const command_queue_empty = self.command_queue.empty(); + + // Queue list-windows to get the updated window list + try self.queueCommands(&.{.list_windows}); + + // If our command queue was empty and now it's not, we need to add + // a command to the output. + assert(self.state == .command_queue); + if (command_queue_empty) { + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + _ = arena.reset(.free_all); + const arena_alloc = arena.allocator(); + + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + const command = self.command_queue.first().?; + command.formatCommand(&builder.writer) catch return error.OutOfMemory; + const action: Action = .{ .command = builder.writer.buffered() }; + + var actions: std.ArrayList(Action) = .empty; + try actions.append(arena_alloc, action); + return actions.items; + } + + return &.{}; + } + fn syncLayouts( self: *Viewer, windows: []const Window, @@ -1576,3 +1613,110 @@ test "layout_change returns command when queue was empty" { }, }); } + +test "window_add queues list_windows when queue empty" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + }, + // Complete all capture-pane commands for pane 0 + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // Queue should now be empty + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expect(v.command_queue.empty()); + } + }).check, + }, + // Now send window_add - should trigger list-windows command + .{ + .input = .{ .tmux = .{ .window_add = .{ .id = 1 } } }, + .contains_command = "list-windows", + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + // Command queue should have list_windows + try testing.expect(!v.command_queue.empty()); + try testing.expectEqual(1, v.command_queue.len()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} + +test "window_add queues list_windows when queue not empty" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + // Queue should have capture-pane commands + try testing.expect(!v.command_queue.empty()); + } + }).check, + }, + // Do NOT complete capture-pane commands - queue still has commands. + // Send window_add - should queue list-windows but NOT return command action + .{ + .input = .{ .tmux = .{ .window_add = .{ .id = 1 } } }, + .check = (struct { + fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void { + // Should not contain a command action since queue was not empty + for (actions) |action| { + try testing.expect(action != .command); + } + // But list_windows should be in the queue + try testing.expect(!v.command_queue.empty()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} From 4c30c5aa765c1c76c5c4c1b1285b66af61f1840a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 20:19:20 -0800 Subject: [PATCH 30/52] terminal/tmux: cleanup command queue logic --- src/terminal/tmux/viewer.zig | 220 +++++++++++++++++------------------ 1 file changed, 109 insertions(+), 111 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index c3860c6e4..306bcd69d 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -306,43 +306,73 @@ pub const Viewer = struct { // handle it by ignoring any command output. That's okay! assert(self.state == .command_queue); - return switch (n) { + // Clear our prior arena so it is ready to be used for any + // actions immediately. + { + var arena = self.action_arena.promote(self.alloc); + _ = arena.reset(.free_all); + self.action_arena = arena.state; + } + + // Setup our empty actions list that commands can populate. + var actions: std.ArrayList(Action) = .empty; + + // Track whether the in-flight command slot is available. Starts true + // if queue is empty (no command in flight). Set to true when a command + // completes (block_end/block_err) or the queue is reset (session_changed). + var command_consumed = self.command_queue.empty(); + + switch (n) { .enter => unreachable, - .exit => self.defunct(), + .exit => return self.defunct(), inline .block_end, .block_err, - => |content, tag| self.receivedCommandOutput( - content, - tag == .block_err, - ) catch { - log.warn("failed to process command output, becoming defunct", .{}); - return self.defunct(); - }, - - .output => |out| output: { - self.receivedOutput( - out.pane_id, - out.data, - ) catch |err| { - log.warn( - "failed to process output for pane id={}: {}", - .{ out.pane_id, err }, - ); + => |content, tag| { + self.receivedCommandOutput( + &actions, + content, + tag == .block_err, + ) catch { + log.warn("failed to process command output, becoming defunct", .{}); + return self.defunct(); }; - break :output &.{}; + // Command is consumed since a block end/err is the output + // from a command. + command_consumed = true; + }, + + .output => |out| self.receivedOutput( + out.pane_id, + out.data, + ) catch |err| { + log.warn( + "failed to process output for pane id={}: {}", + .{ out.pane_id, err }, + ); }, // Session changed means we switched to a different tmux session. // We need to reset our state and start fresh with list-windows. - .session_changed => |info| self.sessionChanged(info.id) catch { - log.warn("failed to handle session change, becoming defunct", .{}); - return self.defunct(); + // This completely replaces the viewer, so treat it like a fresh start. + .session_changed => |info| { + self.sessionChanged( + &actions, + info.id, + ) catch { + log.warn("failed to handle session change, becoming defunct", .{}); + return self.defunct(); + }; + + // Command is consumed because sessionChanged resets + // our entire viewer. + command_consumed = true; }, // Layout changed of a single window. .layout_change => |info| self.layoutChanged( + &actions, info.window_id, info.layout, ) catch { @@ -361,23 +391,53 @@ pub const Viewer = struct { // The active pane changed. We don't care about this because // we handle our own focus. - .window_pane_changed => &.{}, + .window_pane_changed => {}, // We ignore this one. It means a session was created or // destroyed. If it was our own session we will get an exit // notification very soon. If it is another session we don't // care. - .sessions_changed => &.{}, + .sessions_changed => {}, // We don't use window names for anything, currently. - .window_renamed => &.{}, + .window_renamed => {}, // This is for other clients, which we don't do anything about. // For us, we'll get `exit` or `session_changed`, respectively. .client_detached, .client_session_changed, - => &.{}, - }; + => {}, + } + + // After processing commands, we add our next command to + // execute if we have one. We do this last because command + // processing may itself queue more commands. We only emit a + // command if a prior command was consumed (or never existed). + if (self.state == .command_queue and command_consumed) { + if (self.command_queue.first()) |next_command| { + // We should not have any commands, because our nextCommand + // always queues them. + if (comptime std.debug.runtime_safety) { + for (actions.items) |action| { + if (action == .command) assert(false); + } + } + + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + const arena_alloc = arena.allocator(); + + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + next_command.formatCommand(&builder.writer) catch + return self.defunct(); + actions.append( + arena_alloc, + .{ .command = builder.writer.buffered() }, + ) catch return self.defunct(); + } + } + + return actions.items; } /// When the layout changes for a single window, a pane may be added @@ -390,15 +450,16 @@ pub const Viewer = struct { /// prune any removed panes. fn layoutChanged( self: *Viewer, + actions: *std.ArrayList(Action), window_id: usize, layout_str: []const u8, - ) ![]const Action { + ) !void { // Find the window this layout change is for. const window: *Window = window: for (self.windows.items) |*w| { if (w.id == window_id) break :window w; } else { log.info("layout change for unknown window id={}", .{window_id}); - return &.{}; + return; }; // Clear our prior window arena and setup our layout @@ -418,70 +479,29 @@ pub const Viewer = struct { }; }; - // If our command queue started out empty and becomes non-empty, - // then we need to send down the command. - const command_queue_empty = self.command_queue.empty(); - // Reset our arena so we can build up actions. var arena = self.action_arena.promote(self.alloc); defer self.action_arena = arena.state; - _ = arena.reset(.free_all); const arena_alloc = arena.allocator(); // Our initial action is to definitely let the caller know that // some windows changed. - var actions: std.ArrayList(Action) = .empty; try actions.append(arena_alloc, .{ .windows = self.windows.items }); // Sync up our panes try self.syncLayouts(self.windows.items); - - // If our command queue was empty and now its not we need to add - // a command to the output. - assert(self.state == .command_queue); - if (command_queue_empty and !self.command_queue.empty()) { - var builder: std.Io.Writer.Allocating = .init(arena_alloc); - const command = self.command_queue.first().?; - command.formatCommand(&builder.writer) catch return error.OutOfMemory; - const action: Action = .{ .command = builder.writer.buffered() }; - try actions.append(arena_alloc, action); - } - - return actions.items; } /// When a window is added to the session, we need to refresh our window /// list to get the new window's information. - fn windowAdd(self: *Viewer, window_id: usize) ![]const Action { + fn windowAdd( + self: *Viewer, + window_id: usize, + ) !void { _ = window_id; // We refresh all windows via list-windows - // If our command queue started out empty and becomes non-empty, - // then we need to send down the command. - const command_queue_empty = self.command_queue.empty(); - // Queue list-windows to get the updated window list try self.queueCommands(&.{.list_windows}); - - // If our command queue was empty and now it's not, we need to add - // a command to the output. - assert(self.state == .command_queue); - if (command_queue_empty) { - var arena = self.action_arena.promote(self.alloc); - defer self.action_arena = arena.state; - _ = arena.reset(.free_all); - const arena_alloc = arena.allocator(); - - var builder: std.Io.Writer.Allocating = .init(arena_alloc); - const command = self.command_queue.first().?; - command.formatCommand(&builder.writer) catch return error.OutOfMemory; - const action: Action = .{ .command = builder.writer.buffered() }; - - var actions: std.ArrayList(Action) = .empty; - try actions.append(arena_alloc, action); - return actions.items; - } - - return &.{}; } fn syncLayouts( @@ -577,26 +597,26 @@ pub const Viewer = struct { /// windows), reset ourself, and start all over. fn sessionChanged( self: *Viewer, + actions: *std.ArrayList(Action), session_id: usize, - ) (Allocator.Error || std.Io.Writer.Error)![]const Action { + ) (Allocator.Error || std.Io.Writer.Error)!void { // Build up a new viewer. Its the easiest way to reset ourselves. var replacement: Viewer = try .init(self.alloc); errdefer replacement.deinit(); + // Our actions must start out empty so we don't mix arenas + assert(actions.items.len == 0); + errdefer actions.* = .empty; + // Build actions: empty windows notification + list-windows command var arena = replacement.action_arena.promote(replacement.alloc); const arena_alloc = arena.allocator(); - var actions: std.ArrayList(Action) = .empty; try actions.append(arena_alloc, .{ .windows = &.{} }); - // Setup our command queue - try actions.appendSlice( - arena_alloc, - try replacement.enterCommandQueue( - arena_alloc, - .list_windows, - ), - ); + // Setup our command queue and put ourselves in the command queue + // state. + try replacement.queueCommands(&.{.list_windows}); + replacement.state = .command_queue; // Save arena state back before swap replacement.action_arena = arena.state; @@ -610,14 +630,14 @@ pub const Viewer = struct { self.session_id = session_id; assert(self.state == .command_queue); - return actions.items; } fn receivedCommandOutput( self: *Viewer, + actions: *std.ArrayList(Action), content: []const u8, is_err: bool, - ) ![]const Action { + ) !void { // Get the command we're expecting output for. We need to get the // non-pointer value because we are deleting it from the circular // buffer immediately. This shallow copy is all we need since @@ -636,7 +656,7 @@ pub const Viewer = struct { } else { // If we have no pending commands, this is unexpected. log.info("unexpected block output err={}", .{is_err}); - return &.{}; + return; }; self.command_queue.deleteOldest(1); defer command.deinit(self.alloc); @@ -645,20 +665,15 @@ pub const Viewer = struct { // easily accumulate actions. var arena = self.action_arena.promote(self.alloc); defer self.action_arena = arena.state; - _ = arena.reset(.free_all); const arena_alloc = arena.allocator(); - // Build up our actions to start with the next command if - // we have one. - var actions: std.ArrayList(Action) = .empty; - // Process our command switch (command) { .user => {}, .list_windows => try self.receivedListWindows( arena_alloc, - &actions, + actions, content, ), @@ -674,23 +689,6 @@ pub const Viewer = struct { content, ), } - - // After processing commands, we add our next command to - // execute if we have one. We do this last because command - // processing may itself queue more commands. - if (self.command_queue.first()) |next_command| { - var builder: std.Io.Writer.Allocating = .init(arena_alloc); - try next_command.formatCommand(&builder.writer); - try actions.append( - arena_alloc, - .{ .command = builder.writer.buffered() }, - ); - } - - // Our command processing should not change our state - assert(self.state == .command_queue); - - return actions.items; } fn receivedListWindows( From bf46c4ebe74d0e668762e84e690e86ca1389e486 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 20:49:03 -0800 Subject: [PATCH 31/52] terminal/tmux: many more output formats --- src/terminal/tmux/output.zig | 318 ++++++++++++++++++++++++++++++++++- 1 file changed, 312 insertions(+), 6 deletions(-) diff --git a/src/terminal/tmux/output.zig b/src/terminal/tmux/output.zig index cff1a982d..02dca23e6 100644 --- a/src/terminal/tmux/output.zig +++ b/src/terminal/tmux/output.zig @@ -95,16 +95,107 @@ pub fn FormatStruct(comptime vars: []const Variable) type { /// a subset of them here that are relevant to the use case of implementing /// control mode for terminal emulators. pub const Variable = enum { + /// 1 if pane is in alternate screen. + alternate_on, + /// Saved cursor X in alternate screen. + alternate_saved_x, + /// Saved cursor Y in alternate screen. + alternate_saved_y, + /// 1 if bracketed paste mode is enabled. + bracketed_paste, + /// 1 if the cursor is blinking. + cursor_blinking, + /// Cursor colour in pane. Possible formats: + /// - Named colors: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, + /// `cyan`, `white`, `default`, `terminal`, or bright variants. + /// - 256 colors: `colour` where N is 0-255 (e.g., `colour100`). + /// - RGB hex: `#RRGGBB` (e.g., `#ff0000`). + /// - Empty string if unset. + cursor_colour, + /// Pane cursor flag. + cursor_flag, + /// Cursor shape in pane. Possible values: `block`, `underline`, `bar`, + /// or `default`. + cursor_shape, + /// Cursor X position in pane. + cursor_x, + /// Cursor Y position in pane. + cursor_y, + /// 1 if focus reporting is enabled. + focus_flag, + /// Pane insert flag. + insert_flag, + /// Pane keypad cursor flag. + keypad_cursor_flag, + /// Pane keypad flag. + keypad_flag, + /// Pane mouse all flag. + mouse_all_flag, + /// Pane mouse any flag. + mouse_any_flag, + /// Pane mouse button flag. + mouse_button_flag, + /// Pane mouse SGR flag. + mouse_sgr_flag, + /// Pane mouse standard flag. + mouse_standard_flag, + /// Pane mouse UTF-8 flag. + mouse_utf8_flag, + /// Pane origin flag. + origin_flag, + /// Unique pane ID prefixed with `%` (e.g., `%0`, `%42`). + pane_id, + /// Pane tab positions as a comma-separated list of 0-indexed column + /// numbers (e.g., `8,16,24,32`). Empty string if no tabs are set. + pane_tabs, + /// Bottom of scroll region in pane. + scroll_region_lower, + /// Top of scroll region in pane. + scroll_region_upper, + /// Unique session ID prefixed with `$` (e.g., `$0`, `$42`). session_id, + /// Unique window ID prefixed with `@` (e.g., `@0`, `@42`). window_id, + /// Width of window. window_width, + /// Height of window. window_height, + /// Window layout description, ignoring zoomed window panes. Format is + /// `,` where checksum is a 4-digit hex CRC16 and layout + /// encodes pane dimensions as `WxH,X,Y[,ID]` with `{...}` for horizontal + /// splits and `[...]` for vertical splits. window_layout, + /// Pane wrap flag. + wrap_flag, /// Parse the given string value into the appropriate resulting /// type for this variable. pub fn parse(comptime self: Variable, value: []const u8) !Type(self) { return switch (self) { + .alternate_on, + .bracketed_paste, + .cursor_blinking, + .cursor_flag, + .focus_flag, + .insert_flag, + .keypad_cursor_flag, + .keypad_flag, + .mouse_all_flag, + .mouse_any_flag, + .mouse_button_flag, + .mouse_sgr_flag, + .mouse_standard_flag, + .mouse_utf8_flag, + .origin_flag, + .wrap_flag, + => std.mem.eql(u8, value, "1"), + .alternate_saved_x, + .alternate_saved_y, + .cursor_x, + .cursor_y, + .scroll_region_lower, + .scroll_region_upper, + => try std.fmt.parseInt(usize, value, 10), .session_id => if (value.len >= 2 and value[0] == '$') try std.fmt.parseInt(usize, value[1..], 10) else @@ -113,24 +204,105 @@ pub const Variable = enum { try std.fmt.parseInt(usize, value[1..], 10) else return error.FormatError, + .pane_id => if (value.len >= 2 and value[0] == '%') + try std.fmt.parseInt(usize, value[1..], 10) + else + return error.FormatError, .window_width => try std.fmt.parseInt(usize, value, 10), .window_height => try std.fmt.parseInt(usize, value, 10), - .window_layout => value, + .cursor_colour, + .cursor_shape, + .pane_tabs, + .window_layout, + => value, }; } /// The type of the parsed value for this variable type. pub fn Type(comptime self: Variable) type { return switch (self) { - .session_id => usize, - .window_id => usize, - .window_width => usize, - .window_height => usize, - .window_layout => []const u8, + .alternate_on, + .bracketed_paste, + .cursor_blinking, + .cursor_flag, + .focus_flag, + .insert_flag, + .keypad_cursor_flag, + .keypad_flag, + .mouse_all_flag, + .mouse_any_flag, + .mouse_button_flag, + .mouse_sgr_flag, + .mouse_standard_flag, + .mouse_utf8_flag, + .origin_flag, + .wrap_flag, + => bool, + .alternate_saved_x, + .alternate_saved_y, + .cursor_x, + .cursor_y, + .scroll_region_lower, + .scroll_region_upper, + .session_id, + .window_id, + .pane_id, + .window_width, + .window_height, + => usize, + .cursor_colour, + .cursor_shape, + .pane_tabs, + .window_layout, + => []const u8, }; } }; +test "parse alternate_on" { + try testing.expectEqual(true, try Variable.parse(.alternate_on, "1")); + try testing.expectEqual(false, try Variable.parse(.alternate_on, "0")); + try testing.expectEqual(false, try Variable.parse(.alternate_on, "")); + try testing.expectEqual(false, try Variable.parse(.alternate_on, "true")); + try testing.expectEqual(false, try Variable.parse(.alternate_on, "yes")); +} + +test "parse alternate_saved_x" { + try testing.expectEqual(0, try Variable.parse(.alternate_saved_x, "0")); + try testing.expectEqual(42, try Variable.parse(.alternate_saved_x, "42")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.alternate_saved_x, "abc")); +} + +test "parse alternate_saved_y" { + try testing.expectEqual(0, try Variable.parse(.alternate_saved_y, "0")); + try testing.expectEqual(42, try Variable.parse(.alternate_saved_y, "42")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.alternate_saved_y, "abc")); +} + +test "parse cursor_x" { + try testing.expectEqual(0, try Variable.parse(.cursor_x, "0")); + try testing.expectEqual(79, try Variable.parse(.cursor_x, "79")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.cursor_x, "abc")); +} + +test "parse cursor_y" { + try testing.expectEqual(0, try Variable.parse(.cursor_y, "0")); + try testing.expectEqual(23, try Variable.parse(.cursor_y, "23")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.cursor_y, "abc")); +} + +test "parse scroll_region_upper" { + try testing.expectEqual(0, try Variable.parse(.scroll_region_upper, "0")); + try testing.expectEqual(5, try Variable.parse(.scroll_region_upper, "5")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.scroll_region_upper, "abc")); +} + +test "parse scroll_region_lower" { + try testing.expectEqual(0, try Variable.parse(.scroll_region_lower, "0")); + try testing.expectEqual(23, try Variable.parse(.scroll_region_lower, "23")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.scroll_region_lower, "abc")); +} + test "parse session id" { try testing.expectEqual(42, try Variable.parse(.session_id, "$42")); try testing.expectEqual(0, try Variable.parse(.session_id, "$0")); @@ -176,6 +348,140 @@ test "parse window layout" { try testing.expectEqualStrings("a]b,c{d}e(f)", try Variable.parse(.window_layout, "a]b,c{d}e(f)")); } +test "parse cursor_flag" { + try testing.expectEqual(true, try Variable.parse(.cursor_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.cursor_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.cursor_flag, "")); + try testing.expectEqual(false, try Variable.parse(.cursor_flag, "true")); +} + +test "parse insert_flag" { + try testing.expectEqual(true, try Variable.parse(.insert_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.insert_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.insert_flag, "")); + try testing.expectEqual(false, try Variable.parse(.insert_flag, "true")); +} + +test "parse keypad_cursor_flag" { + try testing.expectEqual(true, try Variable.parse(.keypad_cursor_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.keypad_cursor_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.keypad_cursor_flag, "")); + try testing.expectEqual(false, try Variable.parse(.keypad_cursor_flag, "true")); +} + +test "parse keypad_flag" { + try testing.expectEqual(true, try Variable.parse(.keypad_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.keypad_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.keypad_flag, "")); + try testing.expectEqual(false, try Variable.parse(.keypad_flag, "true")); +} + +test "parse mouse_any_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_any_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_any_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_any_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_any_flag, "true")); +} + +test "parse mouse_button_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_button_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_button_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_button_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_button_flag, "true")); +} + +test "parse mouse_sgr_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_sgr_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_sgr_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_sgr_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_sgr_flag, "true")); +} + +test "parse mouse_standard_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_standard_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_standard_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_standard_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_standard_flag, "true")); +} + +test "parse mouse_utf8_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_utf8_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_utf8_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_utf8_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_utf8_flag, "true")); +} + +test "parse wrap_flag" { + try testing.expectEqual(true, try Variable.parse(.wrap_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.wrap_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.wrap_flag, "")); + try testing.expectEqual(false, try Variable.parse(.wrap_flag, "true")); +} + +test "parse bracketed_paste" { + try testing.expectEqual(true, try Variable.parse(.bracketed_paste, "1")); + try testing.expectEqual(false, try Variable.parse(.bracketed_paste, "0")); + try testing.expectEqual(false, try Variable.parse(.bracketed_paste, "")); + try testing.expectEqual(false, try Variable.parse(.bracketed_paste, "true")); +} + +test "parse cursor_blinking" { + try testing.expectEqual(true, try Variable.parse(.cursor_blinking, "1")); + try testing.expectEqual(false, try Variable.parse(.cursor_blinking, "0")); + try testing.expectEqual(false, try Variable.parse(.cursor_blinking, "")); + try testing.expectEqual(false, try Variable.parse(.cursor_blinking, "true")); +} + +test "parse focus_flag" { + try testing.expectEqual(true, try Variable.parse(.focus_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.focus_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.focus_flag, "")); + try testing.expectEqual(false, try Variable.parse(.focus_flag, "true")); +} + +test "parse mouse_all_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_all_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_all_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_all_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_all_flag, "true")); +} + +test "parse origin_flag" { + try testing.expectEqual(true, try Variable.parse(.origin_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.origin_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.origin_flag, "")); + try testing.expectEqual(false, try Variable.parse(.origin_flag, "true")); +} + +test "parse pane_id" { + try testing.expectEqual(42, try Variable.parse(.pane_id, "%42")); + try testing.expectEqual(0, try Variable.parse(.pane_id, "%0")); + try testing.expectError(error.FormatError, Variable.parse(.pane_id, "0")); + try testing.expectError(error.FormatError, Variable.parse(.pane_id, "@0")); + try testing.expectError(error.FormatError, Variable.parse(.pane_id, "%")); + try testing.expectError(error.FormatError, Variable.parse(.pane_id, "")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.pane_id, "%abc")); +} + +test "parse cursor_colour" { + try testing.expectEqualStrings("red", try Variable.parse(.cursor_colour, "red")); + try testing.expectEqualStrings("#ff0000", try Variable.parse(.cursor_colour, "#ff0000")); + try testing.expectEqualStrings("", try Variable.parse(.cursor_colour, "")); +} + +test "parse cursor_shape" { + try testing.expectEqualStrings("block", try Variable.parse(.cursor_shape, "block")); + try testing.expectEqualStrings("underline", try Variable.parse(.cursor_shape, "underline")); + try testing.expectEqualStrings("bar", try Variable.parse(.cursor_shape, "bar")); + try testing.expectEqualStrings("", try Variable.parse(.cursor_shape, "")); +} + +test "parse pane_tabs" { + try testing.expectEqualStrings("0,8,16,24", try Variable.parse(.pane_tabs, "0,8,16,24")); + try testing.expectEqualStrings("", try Variable.parse(.pane_tabs, "")); + try testing.expectEqualStrings("0", try Variable.parse(.pane_tabs, "0")); +} + test "parseFormatStruct single field" { const T = FormatStruct(&.{.session_id}); const result = try parseFormatStruct(T, "$42", ' '); From 58000f5821040060fe8c07c97073bff80886ebd1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 09:28:52 -0800 Subject: [PATCH 32/52] terminal/tmux: build up pane states --- src/terminal/tmux/viewer.zig | 372 ++++++++++++++++++++++++++++++++++- 1 file changed, 370 insertions(+), 2 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 306bcd69d..5384e293f 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -3,7 +3,9 @@ const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; +const size = @import("../size.zig"); const CircBuf = @import("../../datastruct/main.zig").CircBuf; +const CursorStyle = @import("../cursor.zig").Style; const Screen = @import("../Screen.zig"); const ScreenSet = @import("../ScreenSet.zig"); const Terminal = @import("../Terminal.zig"); @@ -551,9 +553,11 @@ pub const Viewer = struct { // TODO: errdefer cleanup { var panes_it = panes.iterator(); + var added: bool = false; while (panes_it.next()) |kv| { const pane_id: usize = kv.key_ptr.*; if (self.panes.contains(pane_id)) continue; + added = true; try self.queueCommands(&.{ .{ .pane_history = .{ .id = pane_id, .screen_key = .primary } }, .{ .pane_visible = .{ .id = pane_id, .screen_key = .primary } }, @@ -561,6 +565,10 @@ pub const Viewer = struct { .{ .pane_visible = .{ .id = pane_id, .screen_key = .alternate } }, }); } + + // If we added any panes, then we also want to resync the pane + // state (terminal modes and cursor positions and so on). + if (added) try self.queueCommands(&.{.pane_state}); } // No more errors after this point. We're about to replace all @@ -671,6 +679,8 @@ pub const Viewer = struct { switch (command) { .user => {}, + .pane_state => try self.receivedPaneState(content), + .list_windows => try self.receivedListWindows( arena_alloc, actions, @@ -750,6 +760,137 @@ pub const Viewer = struct { try self.syncLayouts(windows.items); } + fn receivedPaneState( + self: *Viewer, + content: []const u8, + ) !void { + var it = std.mem.splitScalar(u8, content, '\n'); + while (it.next()) |line_raw| { + const line = std.mem.trim(u8, line_raw, " \t\r"); + if (line.len == 0) continue; + + const data = output.parseFormatStruct( + Format.list_panes.Struct(), + line, + Format.list_panes.delim, + ) catch |err| { + log.info("failed to parse list-panes line: {s}", .{line}); + return err; + }; + + // Get the pane for this ID + const entry = self.panes.getEntry(data.pane_id) orelse { + log.info("received pane state for untracked pane id={}", .{data.pane_id}); + continue; + }; + const pane: *Pane = entry.value_ptr; + const t: *Terminal = &pane.terminal; + + // Determine which screen to use based on alternate_on + const screen_key: ScreenSet.Key = if (data.alternate_on) .alternate else .primary; + + // Set cursor position on the appropriate screen (tmux uses 0-based) + if (t.screens.get(screen_key)) |screen| { + cursor: { + const cursor_x = std.math.cast( + size.CellCountInt, + data.cursor_x, + ) orelse break :cursor; + const cursor_y = std.math.cast( + size.CellCountInt, + data.cursor_y, + ) orelse break :cursor; + if (cursor_x >= screen.pages.cols or + cursor_y >= screen.pages.rows) break :cursor; + screen.cursorAbsolute(cursor_x, cursor_y); + } + + // Set cursor shape on this screen + if (data.cursor_shape.len > 0) { + if (std.mem.eql(u8, data.cursor_shape, "block")) { + screen.cursor.cursor_style = .block; + } else if (std.mem.eql(u8, data.cursor_shape, "underline")) { + screen.cursor.cursor_style = .underline; + } else if (std.mem.eql(u8, data.cursor_shape, "bar")) { + screen.cursor.cursor_style = .bar; + } + } + // "default" or unknown: leave as-is + } + + // Set alternate screen saved cursor position + if (t.screens.get(.alternate)) |alt_screen| cursor: { + const alt_x = std.math.cast( + size.CellCountInt, + data.alternate_saved_x, + ) orelse break :cursor; + const alt_y = std.math.cast( + size.CellCountInt, + data.alternate_saved_y, + ) orelse break :cursor; + + // If our coordinates are outside our screen we ignore it. + // tmux actually sends MAX_INT for when there isn't a set + // cursor position, so this isn't theoretical. + if (alt_x >= alt_screen.pages.cols or + alt_y >= alt_screen.pages.rows) break :cursor; + + alt_screen.cursorAbsolute(alt_x, alt_y); + } + + // Set cursor visibility + t.modes.set(.cursor_visible, data.cursor_flag); + + // Set cursor blinking + t.modes.set(.cursor_blinking, data.cursor_blinking); + + // Terminal modes + t.modes.set(.insert, data.insert_flag); + t.modes.set(.wraparound, data.wrap_flag); + t.modes.set(.keypad_keys, data.keypad_flag); + t.modes.set(.cursor_keys, data.keypad_cursor_flag); + t.modes.set(.origin, data.origin_flag); + + // Mouse modes + t.modes.set(.mouse_event_any, data.mouse_all_flag); + t.modes.set(.mouse_event_button, data.mouse_any_flag); + t.modes.set(.mouse_event_normal, data.mouse_button_flag); + t.modes.set(.mouse_event_x10, data.mouse_standard_flag); + t.modes.set(.mouse_format_utf8, data.mouse_utf8_flag); + t.modes.set(.mouse_format_sgr, data.mouse_sgr_flag); + + // Focus and bracketed paste + t.modes.set(.focus_event, data.focus_flag); + t.modes.set(.bracketed_paste, data.bracketed_paste); + + // Scroll region (tmux uses 0-based values) + scroll: { + const scroll_top = std.math.cast( + size.CellCountInt, + data.scroll_region_upper, + ) orelse break :scroll; + const scroll_bottom = std.math.cast( + size.CellCountInt, + data.scroll_region_lower, + ) orelse break :scroll; + t.scrolling_region.top = scroll_top; + t.scrolling_region.bottom = scroll_bottom; + } + + // Tab stops - parse comma-separated list and set + t.tabstops.reset(0); // Clear all tabstops first + if (data.pane_tabs.len > 0) { + var tabs_it = std.mem.splitScalar(u8, data.pane_tabs, ','); + while (tabs_it.next()) |tab_str| { + const col = std.fmt.parseInt(usize, tab_str, 10) catch continue; + const col_cell = std.math.cast(size.CellCountInt, col) orelse continue; + if (col_cell >= t.cols) continue; + t.tabstops.set(col_cell); + } + } + } + } + fn receivedPaneHistory( self: *Viewer, screen_key: ScreenSet.Key, @@ -983,6 +1124,10 @@ const Command = union(enum) { /// Capture visible area for the given pane ID. pane_visible: CapturePane, + /// Capture the pane terminal state as best we can. The pane ID(s) + /// are part of the output so we can map it back to our panes. + pane_state, + /// User command. This is a command provided by the user. Since /// this is user provided, we can't be sure what it is. user: []const u8, @@ -997,6 +1142,7 @@ const Command = union(enum) { .list_windows, .pane_history, .pane_visible, + .pane_state, => {}, .user => |v| alloc.free(v), }; @@ -1045,6 +1191,11 @@ const Command = union(enum) { }, ), + .pane_state => try writer.writeAll(std.fmt.comptimePrint( + "list-panes -F '{s}'\n", + .{comptime Format.list_panes.comptimeFormat()}, + )), + .user => |v| try writer.writeAll(v), } } @@ -1059,6 +1210,45 @@ const Format = struct { /// guaranteed to not appear in any of the variable outputs. delim: u8, + const list_panes: Format = .{ + .delim = ';', + .vars = &.{ + .pane_id, + // Cursor position & appearance + .cursor_x, + .cursor_y, + .cursor_flag, + .cursor_shape, + .cursor_colour, + .cursor_blinking, + // Alternate screen + .alternate_on, + .alternate_saved_x, + .alternate_saved_y, + // Terminal modes + .insert_flag, + .wrap_flag, + .keypad_flag, + .keypad_cursor_flag, + .origin_flag, + // Mouse modes + .mouse_all_flag, + .mouse_any_flag, + .mouse_button_flag, + .mouse_standard_flag, + .mouse_utf8_flag, + .mouse_sgr_flag, + // Focus & special features + .focus_flag, + .bracketed_paste, + // Scroll region + .scroll_region_upper, + .scroll_region_lower, + // Tab stops + .pane_tabs, + }, + }; + const list_windows: Format = .{ .delim = ' ', .vars = &.{ @@ -1461,6 +1651,8 @@ test "layout change" { }).check, }, // Complete all capture-pane commands for pane 0 (primary and alternate) + // plus pane_state + .{ .input = .{ .tmux = .{ .block_end = "" } } }, .{ .input = .{ .tmux = .{ .block_end = "" } } }, .{ .input = .{ .tmux = .{ .block_end = "" } } }, .{ .input = .{ .tmux = .{ .block_end = "" } } }, @@ -1482,8 +1674,8 @@ test "layout change" { try testing.expectEqual(2, v.panes.count()); try testing.expect(v.panes.contains(0)); try testing.expect(v.panes.contains(2)); - // Commands should be queued for the new pane - try testing.expectEqual(4, v.command_queue.len()); + // Commands should be queued for the new pane (4 capture-pane + 1 pane_state) + try testing.expectEqual(5, v.command_queue.len()); } }).check, }, @@ -1718,3 +1910,179 @@ test "window_add queues list_windows when queue not empty" { }, }); } + +test "two pane flow with pane state" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial block_end from attach + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // Session changed notification + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 0, + .name = "0", + } } }, + .contains_command = "list-windows", + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(0, v.session_id); + } + }).check, + }, + // list-windows output with 2 panes in a vertical split + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 165 79 ca97,165x79,0,0[165x40,0,0,0,165x38,0,41,4] + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(1, v.windows.items.len); + const window = v.windows.items[0]; + try testing.expectEqual(0, window.id); + try testing.expectEqual(165, window.width); + try testing.expectEqual(79, window.height); + try testing.expectEqual(2, v.panes.count()); + try testing.expect(v.panes.contains(0)); + try testing.expect(v.panes.contains(4)); + } + }).check, + }, + // capture-pane pane 0 primary history + .{ + .input = .{ .tmux = .{ + .block_end = + \\prompt % + \\prompt % + , + } }, + }, + // capture-pane pane 0 primary visible + .{ + .input = .{ .tmux = .{ + .block_end = + \\prompt % + , + } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr; + const screen: *Screen = pane.terminal.screens.active; + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .history = .{} }, + ); + defer testing.allocator.free(str); + // History has 2 lines with "prompt %" (padded to screen width) + try testing.expect(std.mem.containsAtLeast(u8, str, 2, "prompt %")); + } + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .active = .{} }, + ); + defer testing.allocator.free(str); + try testing.expectEqualStrings("prompt %", str); + } + } + }).check, + }, + // capture-pane pane 0 alternate history (empty) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // capture-pane pane 0 alternate visible (empty) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // capture-pane pane 4 primary history + .{ + .input = .{ .tmux = .{ + .block_end = + \\prompt % + , + } }, + }, + // capture-pane pane 4 primary visible + .{ + .input = .{ .tmux = .{ + .block_end = + \\prompt % + , + } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + const pane: *Viewer.Pane = v.panes.getEntry(4).?.value_ptr; + const screen: *Screen = pane.terminal.screens.active; + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .history = .{} }, + ); + defer testing.allocator.free(str); + try testing.expectEqualStrings("prompt %", str); + } + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .active = .{} }, + ); + defer testing.allocator.free(str); + // Active screen starts with "prompt %" at beginning + try testing.expect(std.mem.startsWith(u8, str, "prompt %")); + } + } + }).check, + }, + // capture-pane pane 4 alternate history (empty) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // capture-pane pane 4 alternate visible (empty) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // list-panes output with terminal state + .{ + .input = .{ .tmux = .{ + .block_end = + \\%0;42;0;1;;;;0;4294967295;4294967295;0;1;0;0;0;0;0;0;0;0;0;;;0;39;8,16,24,32,40,48,56,64,72,80,88,96,104,112,120,128,136,144,152,160 + \\%4;10;5;1;;;;0;4294967295;4294967295;0;1;0;0;0;0;0;0;0;0;0;;;0;37;8,16,24,32,40,48,56,64,72,80,88,96,104,112,120,128,136,144,152,160 + , + } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + // Pane 0: cursor at (42, 0), cursor visible, wraparound on + { + const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr; + const t: *Terminal = &pane.terminal; + const screen: *Screen = t.screens.get(.primary).?; + try testing.expectEqual(42, screen.cursor.x); + try testing.expectEqual(0, screen.cursor.y); + try testing.expect(t.modes.get(.cursor_visible)); + try testing.expect(t.modes.get(.wraparound)); + try testing.expect(!t.modes.get(.insert)); + try testing.expect(!t.modes.get(.origin)); + try testing.expect(!t.modes.get(.keypad_keys)); + try testing.expect(!t.modes.get(.cursor_keys)); + } + // Pane 4: cursor at (10, 5), cursor visible, wraparound on + { + const pane: *Viewer.Pane = v.panes.getEntry(4).?.value_ptr; + const t: *Terminal = &pane.terminal; + const screen: *Screen = t.screens.get(.primary).?; + try testing.expectEqual(10, screen.cursor.x); + try testing.expectEqual(5, screen.cursor.y); + try testing.expect(t.modes.get(.cursor_visible)); + try testing.expect(t.modes.get(.wraparound)); + try testing.expect(!t.modes.get(.insert)); + try testing.expect(!t.modes.get(.origin)); + try testing.expect(!t.modes.get(.keypad_keys)); + try testing.expect(!t.modes.get(.cursor_keys)); + } + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} From 29bb18d8cd20ea092d4023a89563bfc9f0f90fbd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 10:33:56 -0800 Subject: [PATCH 33/52] terminal/tmux: grab tmux version on startup --- src/terminal/tmux/output.zig | 11 ++++ src/terminal/tmux/viewer.zig | 120 ++++++++++++++++++++++++++++++++--- 2 files changed, 121 insertions(+), 10 deletions(-) diff --git a/src/terminal/tmux/output.zig b/src/terminal/tmux/output.zig index 02dca23e6..6b8073e44 100644 --- a/src/terminal/tmux/output.zig +++ b/src/terminal/tmux/output.zig @@ -154,6 +154,8 @@ pub const Variable = enum { scroll_region_upper, /// Unique session ID prefixed with `$` (e.g., `$0`, `$42`). session_id, + /// Server version (e.g., `3.5a`). + version, /// Unique window ID prefixed with `@` (e.g., `@0`, `@42`). window_id, /// Width of window. @@ -213,6 +215,7 @@ pub const Variable = enum { .cursor_colour, .cursor_shape, .pane_tabs, + .version, .window_layout, => value, }; @@ -253,6 +256,7 @@ pub const Variable = enum { .cursor_colour, .cursor_shape, .pane_tabs, + .version, .window_layout, => []const u8, }; @@ -482,6 +486,13 @@ test "parse pane_tabs" { try testing.expectEqualStrings("0", try Variable.parse(.pane_tabs, "0")); } +test "parse version" { + try testing.expectEqualStrings("3.5a", try Variable.parse(.version, "3.5a")); + try testing.expectEqualStrings("3.5", try Variable.parse(.version, "3.5")); + try testing.expectEqualStrings("next-3.5", try Variable.parse(.version, "next-3.5")); + try testing.expectEqualStrings("", try Variable.parse(.version, "")); +} + test "parseFormatStruct single field" { const T = FormatStruct(&.{.session_id}); const result = try parseFormatStruct(T, "$42", ' '); diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 5384e293f..002f85c5f 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -61,6 +61,11 @@ pub const Viewer = struct { /// The current session ID we're attached to. session_id: usize, + /// The tmux server version string (e.g., "3.5a"). We capture this + /// on startup because it will allow us to change behavior between + /// versions as necessary. + tmux_version: []const u8, + /// The list of commands we've sent that we want to send and wait /// for a response for. We only send one command at a time just /// to avoid any possible confusion around ordering. @@ -168,6 +173,7 @@ pub const Viewer = struct { // until we receive a session-changed notification which will // set this to a real value. .session_id = 0, + .tmux_version = "", .command_queue = command_queue, .windows = .empty, .panes = .empty, @@ -191,6 +197,9 @@ pub const Viewer = struct { while (it.next()) |kv| kv.value_ptr.deinit(self.alloc); self.panes.deinit(self.alloc); } + if (self.tmux_version.len > 0) { + self.alloc.free(self.tmux_version); + } self.action_arena.promote(self.alloc).deinit(); } @@ -273,9 +282,10 @@ pub const Viewer = struct { var arena = self.action_arena.promote(self.alloc); defer self.action_arena = arena.state; _ = arena.reset(.free_all); + return self.enterCommandQueue( arena.allocator(), - .list_windows, + &.{ .tmux_version, .list_windows }, ) catch { log.warn("failed to queue command, becoming defunct", .{}); return self.defunct(); @@ -626,6 +636,9 @@ pub const Viewer = struct { try replacement.queueCommands(&.{.list_windows}); replacement.state = .command_queue; + // Transfer preserved version to replacement + replacement.tmux_version = try replacement.alloc.dupe(u8, self.tmux_version); + // Save arena state back before swap replacement.action_arena = arena.state; @@ -698,9 +711,33 @@ pub const Viewer = struct { cap.id, content, ), + + .tmux_version => try self.receivedTmuxVersion(content), } } + fn receivedTmuxVersion( + self: *Viewer, + content: []const u8, + ) !void { + const line = std.mem.trim(u8, content, " \t\r\n"); + if (line.len == 0) return; + + const data = output.parseFormatStruct( + Format.tmux_version.Struct(), + line, + Format.tmux_version.delim, + ) catch |err| { + log.info("failed to parse tmux version: {s}", .{line}); + return err; + }; + + if (self.tmux_version.len > 0) { + self.alloc.free(self.tmux_version); + } + self.tmux_version = try self.alloc.dupe(u8, data.version); + } + fn receivedListWindows( self: *Viewer, arena_alloc: Allocator, @@ -1031,22 +1068,23 @@ pub const Viewer = struct { } /// Enters the command queue state from any other state, queueing - /// the command and returning an action to execute the first command. + /// the commands and returning an action to execute the first command. fn enterCommandQueue( self: *Viewer, arena_alloc: Allocator, - command: Command, + commands: []const Command, ) Allocator.Error![]const Action { assert(self.state != .command_queue); + assert(commands.len > 0); // Build our command string to send for the action. var builder: std.Io.Writer.Allocating = .init(arena_alloc); - command.formatCommand(&builder.writer) catch return error.OutOfMemory; + commands[0].formatCommand(&builder.writer) catch return error.OutOfMemory; const action: Action = .{ .command = builder.writer.buffered() }; - // Add our command - try self.command_queue.ensureUnusedCapacity(self.alloc, 1); - self.command_queue.appendAssumeCapacity(command); + // Add our commands + try self.command_queue.ensureUnusedCapacity(self.alloc, commands.len); + for (commands) |cmd| self.command_queue.appendAssumeCapacity(cmd); // Move into the command queue state self.state = .command_queue; @@ -1128,6 +1166,9 @@ const Command = union(enum) { /// are part of the output so we can map it back to our panes. pane_state, + /// Get the tmux server version. + tmux_version, + /// User command. This is a command provided by the user. Since /// this is user provided, we can't be sure what it is. user: []const u8, @@ -1143,6 +1184,7 @@ const Command = union(enum) { .pane_history, .pane_visible, .pane_state, + .tmux_version, => {}, .user => |v| alloc.free(v), }; @@ -1196,6 +1238,11 @@ const Command = union(enum) { .{comptime Format.list_panes.comptimeFormat()}, )), + .tmux_version => try writer.writeAll(std.fmt.comptimePrint( + "display-message -p '{s}'\n", + .{comptime Format.tmux_version.comptimeFormat()}, + )), + .user => |v| try writer.writeAll(v), } } @@ -1260,6 +1307,11 @@ const Format = struct { }, }; + const tmux_version: Format = .{ + .delim = ' ', + .vars = &.{.version}, + }; + /// The format string, available at comptime. pub fn comptimeFormat(comptime self: Format) []const u8 { return output.comptimeFormat(self.vars, self.delim); @@ -1378,6 +1430,11 @@ test "session changed resets state" { .id = 1, .name = "first", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive window layout with two panes (same format as "initial flow" test) @@ -1393,10 +1450,11 @@ test "session changed resets state" { try testing.expectEqual(1, v.session_id); try testing.expectEqual(1, v.windows.items.len); try testing.expectEqual(2, v.panes.count()); + try testing.expectEqualStrings("3.5a", v.tmux_version); } }).check, }, - // Now session changes - should reset everything + // Now session changes - should reset everything but keep version .{ .input = .{ .tmux = .{ .session_changed = .{ .id = 2, @@ -1420,6 +1478,8 @@ test "session changed resets state" { try testing.expectEqual(0, v.windows.items.len); // Old panes should be cleared try testing.expectEqual(0, v.panes.count()); + // Version should still be preserved + try testing.expectEqualStrings("3.5a", v.tmux_version); } }).check, }, @@ -1460,13 +1520,23 @@ test "initial flow" { .id = 42, .name = "main", } } }, - .contains_command = "list-windows", + .contains_command = "display-message", .check = (struct { fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { try testing.expectEqual(42, v.session_id); } }).check, }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, + .contains_command = "list-windows", + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqualStrings("3.5a", v.tmux_version); + } + }).check, + }, .{ .input = .{ .tmux = .{ .block_end = @@ -1632,6 +1702,11 @@ test "layout change" { .id = 1, .name = "test", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive initial window layout with one pane @@ -1698,6 +1773,11 @@ test "layout_change does not return command when queue not empty" { .id = 1, .name = "test", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive initial window layout with one pane @@ -1754,6 +1834,11 @@ test "layout_change returns command when queue was empty" { .id = 1, .name = "test", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive initial window layout with one pane @@ -1816,6 +1901,11 @@ test "window_add queues list_windows when queue empty" { .id = 1, .name = "test", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive initial window layout with one pane @@ -1872,6 +1962,11 @@ test "window_add queues list_windows when queue not empty" { .id = 1, .name = "test", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive initial window layout with one pane @@ -1924,13 +2019,18 @@ test "two pane flow with pane state" { .id = 0, .name = "0", } } }, - .contains_command = "list-windows", + .contains_command = "display-message", .check = (struct { fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { try testing.expectEqual(0, v.session_id); } }).check, }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, + .contains_command = "list-windows", + }, // list-windows output with 2 panes in a vertical split .{ .input = .{ .tmux = .{ From b3e7c922630398457a301c3fcd2921bdc282e24b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 10:34:35 -0800 Subject: [PATCH 34/52] fmt --- src/terminal/tmux/control.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminal/tmux/control.zig b/src/terminal/tmux/control.zig index 79ed530ec..dbc64b340 100644 --- a/src/terminal/tmux/control.zig +++ b/src/terminal/tmux/control.zig @@ -555,7 +555,7 @@ pub const Notification = union(enum) { try writer.writeAll(" }"); } } - }; +}; test "tmux begin/end empty" { const testing = std.testing; From 37f467c023e901c9125e940fdc379d1bf36c1d06 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 10:37:48 -0800 Subject: [PATCH 35/52] terminal/tmux: docs --- src/terminal/tmux/viewer.zig | 104 +++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 002f85c5f..0fcaaf207 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -51,6 +51,110 @@ const COMMAND_QUEUE_INITIAL = 8; /// /// This struct helps move through a state machine of connecting to a tmux /// session, negotiating capabilities, listing window state, etc. +/// +/// ## Viewer Lifecycle +/// +/// The viewer progresses through several states from initial connection +/// to steady-state operation. Here is the full flow: +/// +/// ``` +/// β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +/// β”‚ TMUX CONTROL MODE START β”‚ +/// β”‚ (DCS 1000p received by host) β”‚ +/// β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +/// β”‚ +/// β–Ό +/// β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +/// β”‚ startup_block β”‚ +/// β”‚ β”‚ +/// β”‚ Wait for initial %begin/%end block from β”‚ +/// β”‚ tmux. This is the response to the initial β”‚ +/// β”‚ command (e.g., "attach -t 0"). β”‚ +/// β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +/// β”‚ %end / %error +/// β–Ό +/// β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +/// β”‚ startup_session β”‚ +/// β”‚ β”‚ +/// β”‚ Wait for %session-changed notification β”‚ +/// β”‚ to get the initial session ID. β”‚ +/// β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +/// β”‚ %session-changed +/// β–Ό +/// β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +/// β”‚ command_queue β”‚ +/// β”‚ β”‚ +/// β”‚ Main operating state. Process commands β”‚ +/// β”‚ sequentially and handle notifications. β”‚ +/// β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +/// β”‚ +/// β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +/// β”‚ β”‚ β”‚ +/// β–Ό β–Ό β–Ό +/// β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +/// β”‚ tmux_version β”‚ β”‚ list_windows β”‚ β”‚ %output / %layout- β”‚ +/// β”‚ β”‚ β”‚ β”‚ β”‚ change / etc. β”‚ +/// β”‚ Query tmux version for β”‚ β”‚ Get all windows in the β”‚ β”‚ β”‚ +/// β”‚ compatibility checks. β”‚ β”‚ current session. β”‚ β”‚ Handle live updates β”‚ +/// β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ from tmux server. β”‚ +/// β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +/// β–Ό +/// β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +/// β”‚ syncLayouts β”‚ +/// β”‚ β”‚ +/// β”‚ For each window, parse layout and sync β”‚ +/// β”‚ panes. New panes trigger capture commands. β”‚ +/// β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +/// β”‚ +/// β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +/// β”‚ For each new pane: β”‚ +/// β–Ό β–Ό +/// β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +/// β”‚ pane_history β”‚ β”‚ pane_visible β”‚ +/// β”‚ (primary screen) β”‚ β”‚ (primary screen) β”‚ +/// β”‚ β”‚ β”‚ β”‚ +/// β”‚ Capture scrollback β”‚ β”‚ Capture visible area β”‚ +/// β”‚ history into terminal. β”‚ β”‚ into terminal. β”‚ +/// β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +/// β”‚ β”‚ +/// β–Ό β–Ό +/// β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +/// β”‚ pane_history β”‚ β”‚ pane_visible β”‚ +/// β”‚ (alternate screen) β”‚ β”‚ (alternate screen) β”‚ +/// β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +/// β”‚ β”‚ +/// β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +/// β–Ό +/// β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +/// β”‚ pane_state β”‚ +/// β”‚ β”‚ +/// β”‚ Query cursor position, cursor style, β”‚ +/// β”‚ and alternate screen mode for all panes. β”‚ +/// β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +/// β”‚ +/// β–Ό +/// β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +/// β”‚ READY FOR OPERATION β”‚ +/// β”‚ β”‚ +/// β”‚ Panes are populated with content. The β”‚ +/// β”‚ viewer handles %output for live updates, β”‚ +/// β”‚ %layout-change for pane changes, and β”‚ +/// β”‚ %session-changed for session switches. β”‚ +/// β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +/// ``` +/// +/// ## Error Handling +/// +/// At any point, if an unrecoverable error occurs or tmux sends `%exit`, +/// the viewer transitions to the `defunct` state and emits an `.exit` action. +/// +/// ## Session Changes +/// +/// When `%session-changed` is received during `command_queue` state, the +/// viewer resets itself completely: clears all windows/panes, emits an +/// empty windows action, and restarts the `list_windows` flow for the new +/// session. +/// pub const Viewer = struct { /// Allocator used for all internal state. alloc: Allocator, From 05c704b2471ca43e8c3fa3616121824f5c37c65b Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Mon, 8 Dec 2025 10:50:46 -0600 Subject: [PATCH 36/52] build: skip git version detection when used as dependency Detect if ghostty is being built as a dependency by comparing the build root with ghostty's source directory. When used as a dependency, skip git detection entirely and use the version from build.zig.zon. This fixes build failures when downstream projects have git tags that don't match ghostty's version format. Previously, ghostty would read the downstream project's git tags and panic at Config.zig:246 with "tagged releases must be in vX.Y.Z format matching build.zig". --- build.zig | 6 ++++++ src/build/Config.zig | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/build.zig b/build.zig index 5fd611b6c..472c3957a 100644 --- a/build.zig +++ b/build.zig @@ -2,6 +2,7 @@ const std = @import("std"); const assert = std.debug.assert; const builtin = @import("builtin"); const buildpkg = @import("src/build/main.zig"); + const appVersion = @import("build.zig.zon").version; const minimumZigVersion = @import("build.zig.zon").minimum_zig_version; @@ -317,3 +318,8 @@ 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 e88213d71..981cd7de5 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -218,6 +218,22 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config { try std.SemanticVersion.parse(v) 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, + }; + } + // If no explicit version is given, we try to detect it from git. const vsn = GitVersion.detect(b) catch |err| switch (err) { // If Git isn't available we just make an unknown dev version. From 7642b8bec4294ecdaf9184fd69ed761a7e2aa422 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 13:13:35 -0800 Subject: [PATCH 37/52] build: highway system integration should default to false --- src/build/SharedDeps.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index e530e4885..5e2cd40b9 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -719,7 +719,7 @@ pub fn addSimd( } // Highway - if (b.systemIntegrationOption("highway", .{})) { + if (b.systemIntegrationOption("highway", .{ .default = false })) { m.linkSystemLibrary("libhwy", dynamic_link_opts); } else { if (b.lazyDependency("highway", .{ From 93d77ae43672dd1c8017a63cf93516b9157054fe Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sun, 16 Nov 2025 02:24:10 -0800 Subject: [PATCH 38/52] Always use overlay scroller, flash when mouse moved --- macos/Sources/Ghostty/SurfaceScrollView.swift | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift index 4e81eda14..157136136 100644 --- a/macos/Sources/Ghostty/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -34,10 +34,15 @@ class SurfaceScrollView: NSView { scrollView.hasHorizontalScroller = false scrollView.autohidesScrollers = false scrollView.usesPredominantAxisScrolling = true + // Always use the overlay style. See mouseMoved for how we make + // it usable without a scroll wheel or gestures. + scrollView.scrollerStyle = .overlay // hide default background to show blur effect properly scrollView.drawsBackground = false - // don't let the content view clip it's subviews, to enable the + // don't let the content view clip its subviews, to enable the // surface to draw the background behind non-overlay scrollers + // (we currently only use overlay scrollers, but might as well + // configure the views correctly in case we change our mind) scrollView.contentView.clipsToBounds = false // The document view is what the scrollview is actually going @@ -107,7 +112,10 @@ class SurfaceScrollView: NSView { observers.append(NotificationCenter.default.addObserver( forName: NSScroller.preferredScrollerStyleDidChangeNotification, object: nil, - queue: .main + // Since this observer is used to immediately override the event + // that produced the notification, we let it run synchronously on + // the posting thread. + queue: nil ) { [weak self] _ in self?.handleScrollerStyleChange() }) @@ -176,10 +184,10 @@ class SurfaceScrollView: NSView { private func synchronizeAppearance() { let scrollbarConfig = surfaceView.derivedConfig.scrollbar scrollView.hasVerticalScroller = scrollbarConfig != .never - scrollView.verticalScroller?.controlSize = .small let hasLightBackground = OSColor(surfaceView.derivedConfig.backgroundColor).isLightColor // Make sure the scroller’s appearance matches the surface's background color. scrollView.appearance = NSAppearance(named: hasLightBackground ? .aqua : .darkAqua) + updateTrackingAreas() } /// Positions the surface view to fill the currently visible rectangle. @@ -240,6 +248,7 @@ class SurfaceScrollView: NSView { /// Handles scrollbar style changes private func handleScrollerStyleChange() { + scrollView.scrollerStyle = .overlay synchronizeCoreSurface() } @@ -350,4 +359,32 @@ class SurfaceScrollView: NSView { } return contentHeight } + + // MARK: Mouse events + + override func mouseMoved(with: NSEvent) { + // When the OS preferred style is .legacy, the user should be able to + // click and drag the scroller without using scroll wheels or gestures, + // so we flash it when the mouse is moved over the scrollbar area. + guard NSScroller.preferredScrollerStyle == .legacy else { return } + scrollView.flashScrollers() + } + + override func updateTrackingAreas() { + // To update our tracking area we just recreate it all. + trackingAreas.forEach { removeTrackingArea($0) } + + super.updateTrackingAreas() + + // Our tracking area is the scroller frame + guard let scroller = scrollView.verticalScroller else { return } + addTrackingArea(NSTrackingArea( + rect: convert(scroller.bounds, from: scroller), + options: [ + .mouseMoved, + .activeInKeyWindow, + ], + owner: self, + userInfo: nil)) + } } From c0951ce6d8887ea81e29b3485dc828d3f9191601 Mon Sep 17 00:00:00 2001 From: Denys Zhak Date: Sat, 6 Dec 2025 20:44:30 +0000 Subject: [PATCH 39/52] macOS: fix tab context menu opens on macOS 26 with titlebar tabs --- .../TitlebarTabsTahoeTerminalWindow.swift | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index 7ce138c2a..802e98dc1 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -8,6 +8,9 @@ import SwiftUI class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSToolbarDelegate { /// The view model for SwiftUI views private var viewModel = ViewModel() + + /// Tb bar view for event routing + private weak var tabBarView: NSView? /// Titlebar tabs can't support the update accessory because of the way we layout /// the native tabs back into the menu bar. @@ -67,6 +70,30 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool viewModel.isMainWindow = false } + + override func sendEvent(_ event: NSEvent) { + guard let tabBarView, viewModel.hasTabBar else { + super.sendEvent(event) + return + } + + let isRightClick = + event.type == .rightMouseDown || + (event.type == .otherMouseDown && event.buttonNumber == 2) + + guard isRightClick else { + super.sendEvent(event) + return + } + let locationInTabBar = tabBarView.convert(event.locationInWindow, from: nil) + + if tabBarView.bounds.contains(locationInTabBar) { + tabBarView.rightMouseDown(with: event) + } else { + super.sendEvent(event) + } + } + // This is called by macOS for native tabbing in order to add the tab bar. We hook into // this, detect the tab bar being added, and override its behavior. override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) { @@ -148,6 +175,8 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool let tabBar = findTabBar() else { return } + self.tabBarView = tabBar + // View model updates must happen on their own ticks. DispatchQueue.main.async { [weak self] in self?.viewModel.hasTabBar = true @@ -206,6 +235,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // Remove the observer so we can call setup again. self.tabBarObserver = nil + self.tabBarView = nil // Wait a tick to let the new tab bars appear and then set them up. DispatchQueue.main.async { @@ -223,6 +253,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // Clear our observations self.tabBarObserver = nil + self.tabBarView = nil } // MARK: NSToolbarDelegate From 969bcbe8e308a72aa96a5a8d47c53ffc6708bcb7 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Sun, 7 Dec 2025 09:02:03 +0100 Subject: [PATCH 40/52] Update macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift --- .../Window Styles/TitlebarTabsTahoeTerminalWindow.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index 802e98dc1..a58b8ba91 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -9,7 +9,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool /// The view model for SwiftUI views private var viewModel = ViewModel() - /// Tb bar view for event routing + /// Tab bar view for event routing private weak var tabBarView: NSView? /// Titlebar tabs can't support the update accessory because of the way we layout From 76c2de6088581c7d634679b67bec8d9f1b90576c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:09:26 -0800 Subject: [PATCH 41/52] macos: remove the tabBarView variable we can search it --- .../TitlebarTabsTahoeTerminalWindow.swift | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index a58b8ba91..5d910d2e0 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -8,9 +8,6 @@ import SwiftUI class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSToolbarDelegate { /// The view model for SwiftUI views private var viewModel = ViewModel() - - /// Tab bar view for event routing - private weak var tabBarView: NSView? /// Titlebar tabs can't support the update accessory because of the way we layout /// the native tabs back into the menu bar. @@ -71,27 +68,35 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool viewModel.isMainWindow = false } + /// On our Tahoe titlebar tabs, we need to fix up right click events because they don't work + /// naturally due to whatever mess we made. override func sendEvent(_ event: NSEvent) { - guard let tabBarView, viewModel.hasTabBar else { + guard viewModel.hasTabBar else { super.sendEvent(event) return } let isRightClick = event.type == .rightMouseDown || - (event.type == .otherMouseDown && event.buttonNumber == 2) - + (event.type == .otherMouseDown && event.buttonNumber == 2) || + (event.type == .leftMouseDown && event.modifierFlags.contains(.control)) guard isRightClick else { super.sendEvent(event) return } - let locationInTabBar = tabBarView.convert(event.locationInWindow, from: nil) - - if tabBarView.bounds.contains(locationInTabBar) { - tabBarView.rightMouseDown(with: event) - } else { + + guard let tabBarView = findTabBar() else { super.sendEvent(event) + return } + + let locationInTabBar = tabBarView.convert(event.locationInWindow, from: nil) + guard tabBarView.bounds.contains(locationInTabBar) else { + super.sendEvent(event) + return + } + + tabBarView.rightMouseDown(with: event) } // This is called by macOS for native tabbing in order to add the tab bar. We hook into @@ -175,8 +180,6 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool let tabBar = findTabBar() else { return } - self.tabBarView = tabBar - // View model updates must happen on their own ticks. DispatchQueue.main.async { [weak self] in self?.viewModel.hasTabBar = true @@ -235,7 +238,6 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // Remove the observer so we can call setup again. self.tabBarObserver = nil - self.tabBarView = nil // Wait a tick to let the new tab bars appear and then set them up. DispatchQueue.main.async { @@ -253,7 +255,6 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // Clear our observations self.tabBarObserver = nil - self.tabBarView = nil } // MARK: NSToolbarDelegate From 625d7274bf0bcebf17b5cd4ffa853165269489a6 Mon Sep 17 00:00:00 2001 From: George Papadakis Date: Mon, 1 Dec 2025 20:15:53 +0200 Subject: [PATCH 42/52] Add close tabs on the right action --- include/ghostty.h | 1 + .../Terminal/TerminalController.swift | 95 ++++++++++++++++++- .../Window Styles/TerminalWindow.swift | 60 ++++++++++++ macos/Sources/Ghostty/Ghostty.App.swift | 7 ++ macos/Sources/Ghostty/Package.swift | 3 + pkg/apple-sdk/build.zig | 27 ++++++ src/Surface.zig | 1 + src/apprt/action.zig | 2 + src/apprt/gtk/class/tab.zig | 1 + src/apprt/gtk/ui/1.5/window.blp | 30 ++++++ src/input/Binding.zig | 6 +- src/input/command.zig | 5 + 12 files changed, 231 insertions(+), 7 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 6cafe8773..cb8646560 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -714,6 +714,7 @@ typedef struct { typedef enum { GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS, GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER, + GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT, } ghostty_action_close_tab_mode_e; // apprt.surface.Message.ChildExited diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 5cc2c67f1..1083fb405 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -104,6 +104,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr selector: #selector(onCloseOtherTabs), name: .ghosttyCloseOtherTabs, object: nil) + center.addObserver( + self, + selector: #selector(onCloseTabsOnTheRight), + name: .ghosttyCloseTabsOnTheRight, + object: nil) center.addObserver( self, selector: #selector(onResetWindowSize), @@ -627,6 +632,48 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } + private func closeTabsOnTheRightImmediately() { + guard let window = window else { return } + guard let tabGroup = window.tabGroup else { return } + guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return } + + let tabsToClose = tabGroup.windows.enumerated().filter { $0.offset > currentIndex } + guard !tabsToClose.isEmpty else { return } + + if let undoManager { + undoManager.beginUndoGrouping() + } + defer { + undoManager?.endUndoGrouping() + } + + for (_, candidate) in tabsToClose { + if let controller = candidate.windowController as? TerminalController { + controller.closeTabImmediately(registerRedo: false) + } + } + + if let undoManager { + undoManager.setActionName("Close Tabs on the Right") + + undoManager.registerUndo( + withTarget: self, + expiresAfter: undoExpiration + ) { target in + DispatchQueue.main.async { + target.window?.makeKeyAndOrderFront(nil) + } + + undoManager.registerUndo( + withTarget: target, + expiresAfter: target.undoExpiration + ) { target in + target.closeTabsOnTheRightImmediately() + } + } + } + } + /// Closes the current window (including any other tabs) immediately and without /// confirmation. This will setup proper undo state so the action can be undone. private func closeWindowImmediately() { @@ -1078,24 +1125,24 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // If we only have one window then we have no other tabs to close guard tabGroup.windows.count > 1 else { return } - + // Check if we have to confirm close. guard tabGroup.windows.contains(where: { window in // Ignore ourself if window == self.window { return false } - + // Ignore non-terminals guard let controller = window.windowController as? TerminalController else { return false } - + // Check if any surfaces require confirmation return controller.surfaceTree.contains(where: { $0.needsConfirmQuit }) }) else { self.closeOtherTabsImmediately() return } - + confirmClose( messageText: "Close Other Tabs?", informativeText: "At least one other tab still has a running process. If you close the tab the process will be killed." @@ -1104,6 +1151,35 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } + @IBAction func closeTabsOnTheRight(_ sender: Any?) { + guard let window = window else { return } + guard let tabGroup = window.tabGroup else { return } + guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return } + + let tabsToClose = tabGroup.windows.enumerated().filter { $0.offset > currentIndex } + guard !tabsToClose.isEmpty else { return } + + let needsConfirm = tabsToClose.contains { (_, candidate) in + guard let controller = candidate.windowController as? TerminalController else { + return false + } + + return controller.surfaceTree.contains(where: { $0.needsConfirmQuit }) + } + + if !needsConfirm { + self.closeTabsOnTheRightImmediately() + return + } + + confirmClose( + messageText: "Close Tabs on the Right?", + informativeText: "At least one tab to the right still has a running process. If you close the tab the process will be killed." + ) { + self.closeTabsOnTheRightImmediately() + } + } + @IBAction func returnToDefaultSize(_ sender: Any?) { guard let window, let defaultSize else { return } defaultSize.apply(to: window) @@ -1305,6 +1381,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr closeOtherTabs(self) } + @objc private func onCloseTabsOnTheRight(notification: SwiftUI.Notification) { + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard surfaceTree.contains(target) else { return } + closeTabsOnTheRight(self) + } + @objc private func onCloseWindow(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } guard surfaceTree.contains(target) else { return } @@ -1367,6 +1449,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr extension TerminalController { override func validateMenuItem(_ item: NSMenuItem) -> Bool { switch item.action { + case #selector(closeTabsOnTheRight): + guard let window, let tabGroup = window.tabGroup else { return false } + guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return false } + return tabGroup.windows.enumerated().contains { $0.offset > currentIndex } + case #selector(returnToDefaultSize): guard let window else { return false } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 2208d99cf..cbbbf99f7 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -26,6 +26,8 @@ class TerminalWindow: NSWindow { /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig = .init() + + private var tabMenuObserver: NSObjectProtocol? = nil /// Whether this window supports the update accessory. If this is false, then views within this /// window should determine how to show update notifications. @@ -53,6 +55,15 @@ class TerminalWindow: NSWindow { override func awakeFromNib() { // Notify that this terminal window has loaded NotificationCenter.default.post(name: Self.terminalDidAwake, object: self) + + tabMenuObserver = NotificationCenter.default.addObserver( + forName: Notification.Name(rawValue: "NSMenuWillOpenNotification"), + object: nil, + queue: .main + ) { [weak self] note in + guard let self, let menu = note.object as? NSMenu else { return } + self.configureTabContextMenuIfNeeded(menu) + } // This is required so that window restoration properly creates our tabs // again. I'm not sure why this is required. If you don't do this, then @@ -202,6 +213,8 @@ class TerminalWindow: NSWindow { /// added. static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") + private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem") + func findTitlebarView() -> NSView? { // Find our tab bar. If it doesn't exist we don't do anything. // @@ -277,6 +290,47 @@ class TerminalWindow: NSWindow { } } + private func configureTabContextMenuIfNeeded(_ menu: NSMenu) { + guard isTabContextMenu(menu) else { return } + if let existing = menu.items.first(where: { $0.identifier == Self.closeTabsOnRightMenuItemIdentifier }) { + menu.removeItem(existing) + } + guard let terminalController else { return } + + let title = NSLocalizedString("Close Tabs on the Right", comment: "Tab context menu option") + let item = NSMenuItem(title: title, action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") + item.identifier = Self.closeTabsOnRightMenuItemIdentifier + item.target = terminalController + item.isEnabled = true + + let closeOtherIndex = menu.items.firstIndex(where: { menuItem in + guard let action = menuItem.action else { return false } + let name = NSStringFromSelector(action).lowercased() + return name.contains("close") && name.contains("other") && name.contains("tab") + }) + + let closeThisIndex = menu.items.firstIndex(where: { menuItem in + guard let action = menuItem.action else { return false } + let name = NSStringFromSelector(action).lowercased() + return name.contains("close") && name.contains("tab") + }) + + if let idx = closeOtherIndex { + menu.insertItem(item, at: idx + 1) + } else if let idx = closeThisIndex { + menu.insertItem(item, at: idx + 1) + } else { + menu.addItem(item) + } + } + + private func isTabContextMenu(_ menu: NSMenu) -> Bool { + guard NSApp.keyWindow === self else { return false } + let selectorNames = menu.items.compactMap { $0.action }.map { NSStringFromSelector($0).lowercased() } + return selectorNames.contains { $0.contains("close") && $0.contains("tab") } + } + + // MARK: Tab Key Equivalents var keyEquivalent: String? = nil { @@ -517,6 +571,12 @@ class TerminalWindow: NSWindow { standardWindowButton(.miniaturizeButton)?.isHidden = true standardWindowButton(.zoomButton)?.isHidden = true } + + deinit { + if let observer = tabMenuObserver { + NotificationCenter.default.removeObserver(observer) + } + } // MARK: Config diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 39ebbb51f..f6452e54e 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -861,6 +861,13 @@ extension Ghostty { ) return + case GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT: + NotificationCenter.default.post( + name: .ghosttyCloseTabsOnTheRight, + object: surfaceView + ) + return + default: assertionFailure() } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 7ee815caa..4b3eb60aa 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -380,6 +380,9 @@ extension Notification.Name { /// Close other tabs static let ghosttyCloseOtherTabs = Notification.Name("com.mitchellh.ghostty.closeOtherTabs") + /// Close tabs to the right of the focused tab + static let ghosttyCloseTabsOnTheRight = Notification.Name("com.mitchellh.ghostty.closeTabsOnTheRight") + /// Close window static let ghosttyCloseWindow = Notification.Name("com.mitchellh.ghostty.closeWindow") diff --git a/pkg/apple-sdk/build.zig b/pkg/apple-sdk/build.zig index c573c3910..32cb726fd 100644 --- a/pkg/apple-sdk/build.zig +++ b/pkg/apple-sdk/build.zig @@ -30,6 +30,7 @@ pub fn addPaths( framework: []const u8, system_include: []const u8, library: []const u8, + cxx_include: []const u8, }) = .{}; }; @@ -82,11 +83,36 @@ pub fn addPaths( }); }; + const cxx_include_path = cxx: { + const preferred = try std.fs.path.join(b.allocator, &.{ + libc.sys_include_dir.?, + "c++", + "v1", + }); + if (std.fs.accessAbsolute(preferred, .{})) |_| { + break :cxx preferred; + } else |_| {} + + const sdk_root = std.fs.path.dirname(libc.sys_include_dir.?).?; + const fallback = try std.fs.path.join(b.allocator, &.{ + sdk_root, + "include", + "c++", + "v1", + }); + if (std.fs.accessAbsolute(fallback, .{})) |_| { + break :cxx fallback; + } else |_| {} + + break :cxx preferred; + }; + gop.value_ptr.* = .{ .libc = path, .framework = framework_path, .system_include = libc.sys_include_dir.?, .library = library_path, + .cxx_include = cxx_include_path, }; } @@ -107,5 +133,6 @@ pub fn addPaths( // https://github.com/ziglang/zig/issues/24024 step.root_module.addSystemFrameworkPath(.{ .cwd_relative = value.framework }); step.root_module.addSystemIncludePath(.{ .cwd_relative = value.system_include }); + step.root_module.addSystemIncludePath(.{ .cwd_relative = value.cxx_include }); step.root_module.addLibraryPath(.{ .cwd_relative = value.library }); } diff --git a/src/Surface.zig b/src/Surface.zig index 653178bdc..9e7ad0b97 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5299,6 +5299,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool switch (v) { .this => .this, .other => .other, + .right => .right, }, ), diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 00bf8685a..365f525f8 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -767,6 +767,8 @@ pub const CloseTabMode = enum(c_int) { this, /// Close all other tabs. other, + /// Close all tabs to the right of the current tab. + right, }; pub const CommandFinished = struct { diff --git a/src/apprt/gtk/class/tab.zig b/src/apprt/gtk/class/tab.zig index c8b5607a6..fb3b8b0ef 100644 --- a/src/apprt/gtk/class/tab.zig +++ b/src/apprt/gtk/class/tab.zig @@ -347,6 +347,7 @@ pub const Tab = extern struct { switch (mode) { .this => tab_view.closePage(page), .other => tab_view.closeOtherPages(page), + .right => tab_view.closePagesAfter(page), } } diff --git a/src/apprt/gtk/ui/1.5/window.blp b/src/apprt/gtk/ui/1.5/window.blp index 8c0a7bedb..de06b04da 100644 --- a/src/apprt/gtk/ui/1.5/window.blp +++ b/src/apprt/gtk/ui/1.5/window.blp @@ -162,6 +162,7 @@ template $GhosttyWindow: Adw.ApplicationWindow { page-attached => $page_attached(); page-detached => $page_detached(); create-window => $tab_create_window(); + menu-model: tab_context_menu; shortcuts: none; } } @@ -192,6 +193,35 @@ menu split_menu { } } +menu tab_context_menu { + section { + item { + label: _("New Tab"); + action: "win.new-tab"; + } + } + + section { + item { + label: _("Close Tab"); + action: "tab.close"; + target: "this"; + } + + item { + label: _("Close Other Tabs"); + action: "tab.close"; + target: "other"; + } + + item { + label: _("Close Tabs on the Right"); + action: "tab.close"; + target: "right"; + } + } +} + menu main_menu { section { item { diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 1e7db3592..66fe03651 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -600,9 +600,8 @@ pub const Action = union(enum) { /// of the `confirm-close-surface` configuration setting. close_surface, - /// Close the current tab and all splits therein _or_ close all tabs and - /// splits thein of tabs _other_ than the current tab, depending on the - /// mode. + /// Close the current tab and all splits therein, close all other tabs, or + /// close every tab to the right of the current one depending on the mode. /// /// If the mode is not specified, defaults to closing the current tab. /// @@ -1005,6 +1004,7 @@ pub const Action = union(enum) { pub const CloseTabMode = enum { this, other, + right, pub const default: CloseTabMode = .this; }; diff --git a/src/input/command.zig b/src/input/command.zig index 72fb7f4ee..6baeca23b 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -538,6 +538,11 @@ fn actionCommands(action: Action.Key) []const Command { .title = "Close Other Tabs", .description = "Close all tabs in this window except the current one.", }, + .{ + .action = .{ .close_tab = .right }, + .title = "Close Tabs on the Right", + .description = "Close every tab to the right of the current one.", + }, }, .close_window => comptime &.{.{ From cca10f3ca8b701c9c34bbcd1fc918e0de3e004e9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:17:25 -0800 Subject: [PATCH 43/52] Revert GTK UI changes, apple-sdk build stuff --- pkg/apple-sdk/build.zig | 27 --------------------------- src/apprt/gtk/ui/1.5/window.blp | 30 ------------------------------ src/input/command.zig | 2 +- 3 files changed, 1 insertion(+), 58 deletions(-) diff --git a/pkg/apple-sdk/build.zig b/pkg/apple-sdk/build.zig index 32cb726fd..c573c3910 100644 --- a/pkg/apple-sdk/build.zig +++ b/pkg/apple-sdk/build.zig @@ -30,7 +30,6 @@ pub fn addPaths( framework: []const u8, system_include: []const u8, library: []const u8, - cxx_include: []const u8, }) = .{}; }; @@ -83,36 +82,11 @@ pub fn addPaths( }); }; - const cxx_include_path = cxx: { - const preferred = try std.fs.path.join(b.allocator, &.{ - libc.sys_include_dir.?, - "c++", - "v1", - }); - if (std.fs.accessAbsolute(preferred, .{})) |_| { - break :cxx preferred; - } else |_| {} - - const sdk_root = std.fs.path.dirname(libc.sys_include_dir.?).?; - const fallback = try std.fs.path.join(b.allocator, &.{ - sdk_root, - "include", - "c++", - "v1", - }); - if (std.fs.accessAbsolute(fallback, .{})) |_| { - break :cxx fallback; - } else |_| {} - - break :cxx preferred; - }; - gop.value_ptr.* = .{ .libc = path, .framework = framework_path, .system_include = libc.sys_include_dir.?, .library = library_path, - .cxx_include = cxx_include_path, }; } @@ -133,6 +107,5 @@ pub fn addPaths( // https://github.com/ziglang/zig/issues/24024 step.root_module.addSystemFrameworkPath(.{ .cwd_relative = value.framework }); step.root_module.addSystemIncludePath(.{ .cwd_relative = value.system_include }); - step.root_module.addSystemIncludePath(.{ .cwd_relative = value.cxx_include }); step.root_module.addLibraryPath(.{ .cwd_relative = value.library }); } diff --git a/src/apprt/gtk/ui/1.5/window.blp b/src/apprt/gtk/ui/1.5/window.blp index de06b04da..8c0a7bedb 100644 --- a/src/apprt/gtk/ui/1.5/window.blp +++ b/src/apprt/gtk/ui/1.5/window.blp @@ -162,7 +162,6 @@ template $GhosttyWindow: Adw.ApplicationWindow { page-attached => $page_attached(); page-detached => $page_detached(); create-window => $tab_create_window(); - menu-model: tab_context_menu; shortcuts: none; } } @@ -193,35 +192,6 @@ menu split_menu { } } -menu tab_context_menu { - section { - item { - label: _("New Tab"); - action: "win.new-tab"; - } - } - - section { - item { - label: _("Close Tab"); - action: "tab.close"; - target: "this"; - } - - item { - label: _("Close Other Tabs"); - action: "tab.close"; - target: "other"; - } - - item { - label: _("Close Tabs on the Right"); - action: "tab.close"; - target: "right"; - } - } -} - menu main_menu { section { item { diff --git a/src/input/command.zig b/src/input/command.zig index 6baeca23b..4cbe9ffc4 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -540,7 +540,7 @@ fn actionCommands(action: Action.Key) []const Command { }, .{ .action = .{ .close_tab = .right }, - .title = "Close Tabs on the Right", + .title = "Close Tabs to the Right", .description = "Close every tab to the right of the current one.", }, }, From 4424451c59eb16189054b1787b247e762fe74c4d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:24:54 -0800 Subject: [PATCH 44/52] macos: remove to "close to the right" --- macos/Sources/Features/Terminal/TerminalController.swift | 6 ++---- .../Features/Terminal/Window Styles/TerminalWindow.swift | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 1083fb405..a275c3f39 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -640,9 +640,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr let tabsToClose = tabGroup.windows.enumerated().filter { $0.offset > currentIndex } guard !tabsToClose.isEmpty else { return } - if let undoManager { - undoManager.beginUndoGrouping() - } + undoManager?.beginUndoGrouping() defer { undoManager?.endUndoGrouping() } @@ -654,7 +652,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } if let undoManager { - undoManager.setActionName("Close Tabs on the Right") + undoManager.setActionName("Close Tabs to the Right") undoManager.registerUndo( withTarget: self, diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index cbbbf99f7..1f9f10502 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -297,7 +297,7 @@ class TerminalWindow: NSWindow { } guard let terminalController else { return } - let title = NSLocalizedString("Close Tabs on the Right", comment: "Tab context menu option") + let title = NSLocalizedString("Close Tabs to the Right", comment: "Tab context menu option") let item = NSMenuItem(title: title, action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") item.identifier = Self.closeTabsOnRightMenuItemIdentifier item.target = terminalController From f612e4632cc84ebad71c266c704f0d5bcfc1f829 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:43:38 -0800 Subject: [PATCH 45/52] macos: clean up some style on tab bar context menu configuring --- .../Window Styles/TerminalWindow.swift | 57 ++++++++++--------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 1f9f10502..0ae4c3b02 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -56,12 +56,14 @@ class TerminalWindow: NSWindow { // Notify that this terminal window has loaded NotificationCenter.default.post(name: Self.terminalDidAwake, object: self) + // This is fragile, but there doesn't seem to be an official API for customizing + // native tab bar menus. tabMenuObserver = NotificationCenter.default.addObserver( forName: Notification.Name(rawValue: "NSMenuWillOpenNotification"), object: nil, queue: .main - ) { [weak self] note in - guard let self, let menu = note.object as? NSMenu else { return } + ) { [weak self] n in + guard let self, let menu = n.object as? NSMenu else { return } self.configureTabContextMenuIfNeeded(menu) } @@ -292,32 +294,26 @@ class TerminalWindow: NSWindow { private func configureTabContextMenuIfNeeded(_ menu: NSMenu) { guard isTabContextMenu(menu) else { return } - if let existing = menu.items.first(where: { $0.identifier == Self.closeTabsOnRightMenuItemIdentifier }) { + + let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") + item.identifier = Self.closeTabsOnRightMenuItemIdentifier + item.target = nil + item.isEnabled = true + + // Remove any previously configured items, because the menu is + // cached across different tab targets. + if let existing = menu.items.first(where: { $0.identifier == item.identifier }) { menu.removeItem(existing) } - guard let terminalController else { return } - let title = NSLocalizedString("Close Tabs to the Right", comment: "Tab context menu option") - let item = NSMenuItem(title: title, action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") - item.identifier = Self.closeTabsOnRightMenuItemIdentifier - item.target = terminalController - item.isEnabled = true - - let closeOtherIndex = menu.items.firstIndex(where: { menuItem in - guard let action = menuItem.action else { return false } - let name = NSStringFromSelector(action).lowercased() - return name.contains("close") && name.contains("other") && name.contains("tab") - }) - - let closeThisIndex = menu.items.firstIndex(where: { menuItem in - guard let action = menuItem.action else { return false } - let name = NSStringFromSelector(action).lowercased() - return name.contains("close") && name.contains("tab") - }) - - if let idx = closeOtherIndex { + // Insert it wherever we can + if let idx = menu.items.firstIndex(where: { + $0.action == NSSelectorFromString("performCloseOtherTabs:") + }) { menu.insertItem(item, at: idx + 1) - } else if let idx = closeThisIndex { + } else if let idx = menu.items.firstIndex(where: { + $0.action == NSSelectorFromString("performClose:") + }) { menu.insertItem(item, at: idx + 1) } else { menu.addItem(item) @@ -326,8 +322,17 @@ class TerminalWindow: NSWindow { private func isTabContextMenu(_ menu: NSMenu) -> Bool { guard NSApp.keyWindow === self else { return false } - let selectorNames = menu.items.compactMap { $0.action }.map { NSStringFromSelector($0).lowercased() } - return selectorNames.contains { $0.contains("close") && $0.contains("tab") } + + // These are the target selectors, at least for macOS 26. + let tabContextSelectors: Set = [ + "performClose:", + "performCloseOtherTabs:", + "moveTabToNewWindow:", + "toggleTabOverview:" + ] + + let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) }) + return !selectorNames.isDisjoint(with: tabContextSelectors) } From dc641c7861c44b8ecdfb8a3747d99c8bc5360e41 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:47:15 -0800 Subject: [PATCH 46/52] macos: change to NSMenu extension --- .../Window Styles/TerminalWindow.swift | 19 ++---------- .../Helpers/Extensions/NSMenu+Extension.swift | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+), 17 deletions(-) create mode 100644 macos/Sources/Helpers/Extensions/NSMenu+Extension.swift diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 0ae4c3b02..997996e3b 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -299,23 +299,8 @@ class TerminalWindow: NSWindow { item.identifier = Self.closeTabsOnRightMenuItemIdentifier item.target = nil item.isEnabled = true - - // Remove any previously configured items, because the menu is - // cached across different tab targets. - if let existing = menu.items.first(where: { $0.identifier == item.identifier }) { - menu.removeItem(existing) - } - - // Insert it wherever we can - if let idx = menu.items.firstIndex(where: { - $0.action == NSSelectorFromString("performCloseOtherTabs:") - }) { - menu.insertItem(item, at: idx + 1) - } else if let idx = menu.items.firstIndex(where: { - $0.action == NSSelectorFromString("performClose:") - }) { - menu.insertItem(item, at: idx + 1) - } else { + if !menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) && + !menu.insertItem(item, after: NSSelectorFromString("performClose:")) { menu.addItem(item) } } diff --git a/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift b/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift new file mode 100644 index 000000000..7ddfa419f --- /dev/null +++ b/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift @@ -0,0 +1,29 @@ +import AppKit + +extension NSMenu { + /// Inserts a menu item after an existing item with the specified action selector. + /// + /// If an item with the same identifier already exists, it is removed first to avoid duplicates. + /// This is useful when menus are cached and reused across different targets. + /// + /// - Parameters: + /// - item: The menu item to insert. + /// - action: The action selector to search for. The new item will be inserted after the first + /// item with this action. + /// - Returns: `true` if the item was inserted after the specified action, `false` if the action + /// was not found and the item was not inserted. + @discardableResult + func insertItem(_ item: NSMenuItem, after action: Selector) -> Bool { + if let identifier = item.identifier, + let existing = items.first(where: { $0.identifier == identifier }) { + removeItem(existing) + } + + guard let idx = items.firstIndex(where: { $0.action == action }) else { + return false + } + + insertItem(item, at: idx + 1) + return true + } +} From 1387dbefad18809a95a5eaacb7a3f223891d0e9c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:50:26 -0800 Subject: [PATCH 47/52] macos: target should be the correct target --- .../Terminal/Window Styles/TerminalWindow.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 997996e3b..b8c9d4c7d 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -294,11 +294,19 @@ class TerminalWindow: NSWindow { private func configureTabContextMenuIfNeeded(_ menu: NSMenu) { guard isTabContextMenu(menu) else { return } + + // Get the target from an existing menu item. The native tab context menu items + // target the specific window/controller that was right-clicked, not the focused one. + // We need to use that same target so validation and action use the correct tab. + let targetController = menu.items + .first { $0.action == NSSelectorFromString("performClose:") } + .flatMap { $0.target as? NSWindow } + .flatMap { $0.windowController as? TerminalController } let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") item.identifier = Self.closeTabsOnRightMenuItemIdentifier - item.target = nil - item.isEnabled = true + item.target = targetController + if !menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) && !menu.insertItem(item, after: NSSelectorFromString("performClose:")) { menu.addItem(item) From eb75d48e6b59f32cbad65ee7233586aa84940541 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:56:07 -0800 Subject: [PATCH 48/52] macos: add xmark to other tab close items --- .../Terminal/Window Styles/TerminalWindow.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index b8c9d4c7d..77ee98cb4 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -303,14 +303,23 @@ class TerminalWindow: NSWindow { .flatMap { $0.target as? NSWindow } .flatMap { $0.windowController as? TerminalController } + // Close tabs to the right let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") item.identifier = Self.closeTabsOnRightMenuItemIdentifier item.target = targetController - + item.setImageIfDesired(systemSymbolName: "xmark") if !menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) && !menu.insertItem(item, after: NSSelectorFromString("performClose:")) { menu.addItem(item) } + + // Other close items should have the xmark to match Safari on macOS 26 + for menuItem in menu.items { + if menuItem.action == NSSelectorFromString("performClose:") || + menuItem.action == NSSelectorFromString("performCloseOtherTabs:") { + menuItem.setImageIfDesired(systemSymbolName: "xmark") + } + } } private func isTabContextMenu(_ menu: NSMenu) -> Bool { From 3352d5f0810200e74b1bd537f6c70a3a3018e957 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:57:36 -0800 Subject: [PATCH 49/52] Fix up close right description --- 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 4cbe9ffc4..b3f9e86b6 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -541,7 +541,7 @@ fn actionCommands(action: Action.Key) []const Command { .{ .action = .{ .close_tab = .right }, .title = "Close Tabs to the Right", - .description = "Close every tab to the right of the current one.", + .description = "Close all tabs to the right of the current one.", }, }, From 4a6d551941c5c8000e0f0921dbc5af37ee119da3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 21:20:38 -0800 Subject: [PATCH 50/52] macos: don't put NSMenu extension in iOS build --- macos/Ghostty.xcodeproj/project.pbxproj | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index ca420afaa..b70eb131b 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -156,6 +156,7 @@ "Helpers/Extensions/NSAppearance+Extension.swift", "Helpers/Extensions/NSApplication+Extension.swift", "Helpers/Extensions/NSImage+Extension.swift", + "Helpers/Extensions/NSMenu+Extension.swift", "Helpers/Extensions/NSMenuItem+Extension.swift", "Helpers/Extensions/NSPasteboard+Extension.swift", "Helpers/Extensions/NSScreen+Extension.swift", @@ -876,7 +877,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = GK79KXBF4F; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -915,7 +916,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = GK79KXBF4F; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -954,7 +955,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = GK79KXBF4F; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; From 669733d59775f013066e573aa7c88da3c4bc2f34 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 21:21:03 -0800 Subject: [PATCH 51/52] macos: remove iOS signing (dev team) --- macos/Ghostty.xcodeproj/project.pbxproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index b70eb131b..31e812f0c 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -877,7 +877,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = GK79KXBF4F; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -916,7 +916,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = GK79KXBF4F; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -955,7 +955,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = GK79KXBF4F; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; From f96aca7a3f96e057e72e8745446f4d1dbd5820e3 Mon Sep 17 00:00:00 2001 From: "Felipe M.B." Date: Thu, 11 Dec 2025 04:10:03 -0300 Subject: [PATCH 52/52] Fix typo in po/README_CONTRIB Change translable to translatable. --- po/README_CONTRIBUTORS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/po/README_CONTRIBUTORS.md b/po/README_CONTRIBUTORS.md index 2c405acf3..e232c0620 100644 --- a/po/README_CONTRIBUTORS.md +++ b/po/README_CONTRIBUTORS.md @@ -9,7 +9,7 @@ for any localization that they may add. ## GTK -In the GTK app runtime, translable strings are mainly sourced from Blueprint +In the GTK app runtime, translatable strings are mainly sourced from Blueprint files (located under `src/apprt/gtk/ui`). Blueprints have a native syntax for translatable strings, which look like this: