From 6366ce9a220e6a445d0632c3b75e9b336b3d424e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 22 Mar 2026 20:26:46 -0700 Subject: [PATCH] terminal: add set_window_title effect to stream handler Previously the window_title action was silently ignored in the readonly stream handler. Add a set_window_title callback to the Effects struct so callers can be notified when a window title is set via OSC 2. Follows the same pattern as bell and write_pty where the callback is optional and defaults to null in readonly mode. --- src/terminal/stream_terminal.zig | 80 +++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index a4961f27b..bec775fb2 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -40,12 +40,16 @@ pub const Handler = struct { /// during the lifetime of the call. write_pty: ?*const fn (*Handler, [:0]const u8) void, + /// Called when the window title is set via OSC 2. + set_window_title: ?*const fn (*Handler, []const u8) void, + /// No effects means that the stream effectively becomes readonly /// that only affects pure terminal state and ignores all side /// effects beyond that. pub const readonly: Effects = .{ .bell = null, .write_pty = null, + .set_window_title = null, }; }; @@ -190,6 +194,7 @@ pub const Handler = struct { // Effect-based handlers .bell => self.bell(), + .window_title => self.setWindowTitle(value.title), .request_mode => self.requestMode(value.mode), .request_mode_unknown => self.requestModeUnknown(value.mode, value.ansi), @@ -214,7 +219,6 @@ pub const Handler = struct { .device_attributes, .device_status, .kitty_keyboard_query, - .window_title, .report_pwd, .show_desktop_notification, .progress_report, @@ -235,6 +239,11 @@ pub const Handler = struct { func(self, data); } + inline fn setWindowTitle(self: *Handler, title: []const u8) void { + const func = self.effects.set_window_title orelse return; + func(self, title); + } + fn requestMode(self: *Handler, mode: modes.Mode) void { const report = self.terminal.modes.getReport(.fromMode(mode)); self.sendModeReport(report); @@ -1163,3 +1172,72 @@ test "stream: CSI W with intermediate but no params" { s.nextSlice("\x1b[?W"); } + +test "window_title effect is called" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var last_title: ?[]const u8 = null; + fn setWindowTitle(handler: *Handler, title: []const u8) void { + _ = handler; + if (last_title) |old| testing.allocator.free(old); + last_title = testing.allocator.dupe(u8, title) catch null; + } + }; + S.last_title = null; + defer if (S.last_title) |t2| testing.allocator.free(t2); + + var handler: Handler = .init(&t); + handler.effects.set_window_title = &S.setWindowTitle; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // Set window title via OSC 2 + s.nextSlice("\x1b]2;Hello World\x1b\\"); + try testing.expectEqualStrings("Hello World", S.last_title.?); +} + +test "window_title effect not called without callback" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Should not crash when no callback is set + s.nextSlice("\x1b]2;Hello World\x1b\\"); + + // Terminal should still be functional + s.nextSlice("Test"); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Test", str); +} + +test "window_title effect with empty title" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var last_title: ?[]const u8 = null; + fn setWindowTitle(handler: *Handler, title: []const u8) void { + _ = handler; + if (last_title) |old| testing.allocator.free(old); + last_title = testing.allocator.dupe(u8, title) catch null; + } + }; + S.last_title = null; + defer if (S.last_title) |t2| testing.allocator.free(t2); + + var handler: Handler = .init(&t); + handler.effects.set_window_title = &S.setWindowTitle; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // Set empty window title + s.nextSlice("\x1b]2;\x1b\\"); + try testing.expectEqualStrings("", S.last_title.?); +}