From 6c8c07981d7b4d7c6509323ffb1fe19f45e8af1c Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Sun, 26 Jul 2026 13:56:14 -0500 Subject: [PATCH] terminal: add visibility reports Applications cannot infer whether an unfocused terminal remains visible, so focus reports are insufficient for avoiding expensive rendering while a view is hidden. Implement private mode 2033 and the visibility query/report sequences. Track conservative per-surface visibility, report every effective change while enabled, and always answer explicit queries and mode enables. Keep view visibility across terminal resets because it is owned by the host, not terminal state. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019fa965-aa5f-7099-85b4-a9679d2c8bd3 --- include/ghostty/vt/modes.h | 1 + src/Surface.zig | 22 ++++++++++ src/terminal/Terminal.zig | 11 ++++- src/terminal/device_status.zig | 34 ++++++++++++++++ src/terminal/modes.zig | 1 + src/terminal/stream_terminal.zig | 69 ++++++++++++++++++++++++++++++++ src/termio/Termio.zig | 24 +++++++++++ src/termio/Thread.zig | 5 +++ src/termio/message.zig | 9 +++++ src/termio/stream_handler.zig | 12 ++++++ 10 files changed, 187 insertions(+), 1 deletion(-) diff --git a/include/ghostty/vt/modes.h b/include/ghostty/vt/modes.h index 8e1fd9117..fbef4e52b 100644 --- a/include/ghostty/vt/modes.h +++ b/include/ghostty/vt/modes.h @@ -92,6 +92,7 @@ extern "C" { #define GHOSTTY_MODE_SYNC_OUTPUT (ghostty_mode_new(2026, false)) /**< Synchronized output */ #define GHOSTTY_MODE_GRAPHEME_CLUSTER (ghostty_mode_new(2027, false)) /**< Grapheme cluster mode */ #define GHOSTTY_MODE_COLOR_SCHEME_REPORT (ghostty_mode_new(2031, false)) /**< Report color scheme */ +#define GHOSTTY_MODE_VISIBILITY_REPORT (ghostty_mode_new(2033, false)) /**< Report terminal visibility */ #define GHOSTTY_MODE_IN_BAND_RESIZE (ghostty_mode_new(2048, false)) /**< In-band size reports */ /** @} */ diff --git a/src/Surface.zig b/src/Surface.zig index 32e91bfa7..f494f6c82 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -154,6 +154,10 @@ child_exited: bool = false, /// to let us know. focused: bool = true, +/// Whether this surface may be visible. Unknown visibility is considered +/// visible so reporting remains conservative. +visible: bool = true, + /// Used to determine whether to continuously scroll. selection_scroll_active: bool = false, @@ -3310,9 +3314,27 @@ pub fn occlusionCallback(self: *Surface, visible: bool) !void { crash.sentry.thread_state = self.crashThreadState(); defer crash.sentry.thread_state = null; + // Avoid duplicate renderer and visibility reports. + if (self.visible == visible) return; + self.visible = visible; + + // Update the terminal state for synchronous queries, then notify the IO + // thread so it can emit a mode 2033 report when enabled. + self.renderer_state.mutex.lockUncancelable(global.io()); + self.io.terminal.flags.visible = visible; + const report_visibility = self.io.terminal.modes.get(.report_visibility); + self.renderer_state.mutex.unlock(global.io()); + if (report_visibility) { + self.queueIo(.{ .visibility_report = .{ + .visible = visible, + .force = false, + } }, .unlocked); + } + _ = self.renderer_thread.mailbox.push(global.io(), .{ .visible = visible, }, .{ .forever = {} }); + try self.queueRender(); } diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index cfaf86cea..d47fd474f 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -117,6 +117,10 @@ flags: packed struct { /// True if the window is focused. focused: bool = true, + /// True if the terminal view may be visible. Unknown visibility is + /// represented as visible so callers behave conservatively. + visible: bool = true, + /// True if the terminal is in a password entry mode. This is set /// to true based on termios state. This is set /// to true based on termios state. @@ -4645,8 +4649,13 @@ pub fn fullReset(self: *Terminal) void { self.screens.active.reset(); // Rest our basic state + const visible = self.flags.visible; self.modes.reset(); - self.flags = .{}; + self.flags = .{ + // Visibility belongs to the view rather than terminal state, so a + // terminal reset must not make a hidden view potentially visible. + .visible = visible, + }; self.tabstops.reset(TABSTOP_INTERVAL); self.previous_char = null; self.pwd.clearRetainingCapacity(); diff --git a/src/terminal/device_status.zig b/src/terminal/device_status.zig index f61ee4a39..9d158f398 100644 --- a/src/terminal/device_status.zig +++ b/src/terminal/device_status.zig @@ -7,6 +7,13 @@ pub const ColorScheme = lib.Enum(lib.target, &.{ "dark", }); +/// The visibility state reported in response to a CSI ? 998 n query or when +/// DEC mode 2033 is enabled. +pub const Visibility = lib.Enum(lib.target, &.{ + "potentially_visible", + "not_visible", +}); + /// Maximum number of bytes that `encodeColorSchemeReport` will write. pub const max_color_scheme_report_encode_size = max: { var result: usize = 0; @@ -33,6 +40,21 @@ pub fn encodeColorSchemeReport( }); } +/// Maximum number of bytes that `encodeVisibilityReport` will write. +pub const max_visibility_report_encode_size = "\x1B[?999;2n".len; + +/// Encode a visibility report response for CSI ? 998 n queries and DEC mode +/// 2033 notifications. +pub fn encodeVisibilityReport( + writer: *std.Io.Writer, + visibility: Visibility, +) std.Io.Writer.Error!void { + try writer.writeAll(switch (visibility) { + .potentially_visible => "\x1B[?999;1n", + .not_visible => "\x1B[?999;2n", + }); +} + /// An enum(u16) of the available device status requests. pub const Request = dsr_enum: { var names: [entries.len][]const u8 = undefined; @@ -91,6 +113,7 @@ const entries: []const Entry = &.{ .{ .name = "operating_status", .value = 5 }, .{ .name = "cursor_position", .value = 6 }, .{ .name = "color_scheme", .value = 996, .question = true }, + .{ .name = "visibility", .value = 998, .question = true }, }; test "encode color scheme report dark" { @@ -108,3 +131,14 @@ test "encode color scheme report light" { try encodeColorSchemeReport(&writer, .light); try std.testing.expectEqualStrings("\x1B[?997;2n", writer.buffered()); } + +test "encode visibility report" { + var buf: [max_visibility_report_encode_size]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encodeVisibilityReport(&writer, .potentially_visible); + try std.testing.expectEqualStrings("\x1B[?999;1n", writer.buffered()); + + writer = .fixed(&buf); + try encodeVisibilityReport(&writer, .not_visible); + try std.testing.expectEqualStrings("\x1B[?999;2n", writer.buffered()); +} diff --git a/src/terminal/modes.zig b/src/terminal/modes.zig index da80e4d6b..7403f3ffa 100644 --- a/src/terminal/modes.zig +++ b/src/terminal/modes.zig @@ -282,6 +282,7 @@ const entries: []const ModeEntry = &.{ .{ .name = "synchronized_output", .value = 2026 }, .{ .name = "grapheme_cluster", .value = 2027 }, .{ .name = "report_color_scheme", .value = 2031 }, + .{ .name = "report_visibility", .value = 2033 }, .{ .name = "in_band_size_reports", .value = 2048 }, }; diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 89f48b417..7586711a4 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -507,9 +507,24 @@ pub const Handler = struct { buf[writer.end] = 0; self.writePty(buf[0..writer.end :0]); }, + + .visibility => self.sendVisibilityReport(), } } + fn sendVisibilityReport(self: *Handler) void { + const write_pty = self.effects.write_pty orelse return; + + var buf: [device_status.max_visibility_report_encode_size + 1]u8 = undefined; + var writer: std.Io.Writer = .fixed(buf[0..device_status.max_visibility_report_encode_size]); + device_status.encodeVisibilityReport( + &writer, + if (self.terminal.flags.visible) .potentially_visible else .not_visible, + ) catch return; + buf[writer.end] = 0; + write_pty(self, buf[0..writer.end :0]); + } + fn reportEnquiry(self: *Handler) void { const func = self.effects.enquiry orelse return; const response = func(self); @@ -704,6 +719,8 @@ pub const Handler = struct { .focus_event, => {}, + .report_visibility => if (enabled) self.sendVisibilityReport(), + .mouse_event_x10 => { if (enabled) { self.terminal.flags.mouse_event = .x10; @@ -2939,6 +2956,57 @@ test "device status: color scheme without callback" { try testing.expect(S.written == null); } +test "visibility reports" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var written: ?[]const u8 = null; + var count: usize = 0; + + fn writePty(_: *Handler, data: [:0]const u8) void { + if (written) |old| testing.allocator.free(old); + written = testing.allocator.dupe(u8, data) catch @panic("OOM"); + count += 1; + } + }; + S.written = null; + S.count = 0; + defer if (S.written) |old| testing.allocator.free(old); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // Mode 2033 is supported and initially disabled. + s.nextSlice("\x1B[?2033$p"); + try testing.expectEqualStrings("\x1B[?2033;2$y", S.written.?); + + // A one-shot query reports the current state without enabling the mode. + s.nextSlice("\x1B[?998n"); + try testing.expectEqualStrings("\x1B[?999;1n", S.written.?); + try testing.expect(!t.modes.get(.report_visibility)); + + // Enabling always sends an immediate report, even when already enabled. + t.flags.visible = false; + s.nextSlice("\x1B[?2033h"); + try testing.expectEqualStrings("\x1B[?999;2n", S.written.?); + const count = S.count; + s.nextSlice("\x1B[?2033h"); + try testing.expectEqual(count + 1, S.count); + + // Disabling sends no report. + s.nextSlice("\x1B[?2033l"); + try testing.expectEqual(count + 1, S.count); + + // A terminal reset preserves the view's externally owned visibility. + s.nextSlice("\x1Bc"); + s.nextSlice("\x1B[?998n"); + try testing.expectEqualStrings("\x1B[?999;2n", S.written.?); +} + test "device status: readonly ignores all" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); @@ -2950,6 +3018,7 @@ test "device status: readonly ignores all" { s.nextSlice("\x1B[5n"); s.nextSlice("\x1B[6n"); s.nextSlice("\x1B[?996n"); + s.nextSlice("\x1B[?998n"); // Terminal should still be functional s.nextSlice("Test"); diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 883d8d5de..e3b564bc9 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -723,6 +723,30 @@ pub fn colorSchemeReportLocked(self: *Termio, td: *ThreadData, force: bool) !voi try self.queueWrite(td, writer.buffered(), false); } +/// Sends a visibility report to the pty. Unforced reports are only sent while +/// DEC mode 2033 is enabled. +pub fn visibilityReport( + self: *Termio, + td: *ThreadData, + visible: bool, + force: bool, +) !void { + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); + + if (!force and !self.renderer_state.terminal.modes.get(.report_visibility)) { + return; + } + + var buf: [terminalpkg.device_status.max_visibility_report_encode_size]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try terminalpkg.device_status.encodeVisibilityReport( + &writer, + if (visible) .potentially_visible else .not_visible, + ); + try self.queueWrite(td, writer.buffered(), false); +} + /// ThreadData is the data created and stored in the termio thread /// when the thread is started and destroyed when the thread is /// stopped. diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 4f5982208..088096780 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -313,6 +313,11 @@ fn drainMailbox( log.debug("mailbox message={s}", .{@tagName(message)}); switch (message) { .color_scheme_report => |v| try io.colorSchemeReport(data, v.force), + .visibility_report => |v| try io.visibilityReport( + data, + v.visible, + v.force, + ), .crash => @panic("crash request, crashing intentionally"), .change_config => |config| { defer config.alloc.destroy(config.ptr); diff --git a/src/termio/message.zig b/src/termio/message.zig index 4ee7f245e..5830a1db5 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -22,6 +22,15 @@ pub const Message = union(enum) { force: bool, }, + /// Request a visibility report is sent to the pty. + visibility_report: struct { + /// The visibility state to report. + visible: bool, + + /// Send the report even when mode 2033 is disabled. + force: bool, + }, + /// Purposely crash the renderer. This is used for testing and debugging. /// See the "crash" binding action. crash: void, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index b9b18f63a..ddc5eb82b 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -704,6 +704,13 @@ pub const StreamHandler = struct { .size_report = .mode_2048, }), + .report_visibility => if (enabled) self.messageWriter(.{ + .visibility_report = .{ + .visible = self.terminal.flags.visible, + .force = true, + }, + }), + .focus_event => if (enabled) self.messageWriter(.{ .focused = self.terminal.flags.focused, }), @@ -820,6 +827,11 @@ pub const StreamHandler = struct { }, .color_scheme => self.messageWriter(.{ .color_scheme_report = .{ .force = true } }), + + .visibility => self.messageWriter(.{ .visibility_report = .{ + .visible = self.terminal.flags.visible, + .force = true, + } }), } }