Commit 0843272

Chance Zibolski  ·  2026-08-01 10:04:35 -0400 EDT
parent 8a6d038
fix: replay the window title on attach (#224)

The formatter has no title extra and never emits OSC 0/1/2, so a client that
attaches to an existing session shows whatever its terminal falls back to,
usually the name of the client process, even though the title is tracked in
the terminal state.

Programs that set a title only emit it on their own schedule, so a shell
reprints it at the next prompt but a long running program does not reprint it
at all, leaving the wrong title for as long as it runs.
1 files changed,  +53, -0
+53, -0
 1@@ -702,6 +702,16 @@ pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Termin
 2         return null;
 3     };
 4 
 5+    // The formatter has no title extra and never emits OSC 0/1/2, so the title
 6+    // has to be replayed separately or an attaching client shows whatever its
 7+    // terminal defaults to, usually the client process name. OSC 2 does not
 8+    // move the cursor, so this is safe to append after the content.
 9+    if (term.getTitle()) |title| {
10+        builder.writer.print("\x1b]2;{s}\x07", .{title}) catch |err| {
11+            std.log.warn("failed to format title err={s}", .{@errorName(err)});
12+        };
13+    }
14+
15     const output = builder.writer.buffered();
16     if (output.len == 0) return null;
17 
18@@ -1191,6 +1201,49 @@ test "serializeTerminalState excludes synchronized output replay" {
19     try testing.expect(std.mem.indexOf(u8, output, "\x1b[?2026h") == null);
20 }
21 
22+test "serializeTerminalState replays the title" {
23+    const alloc = testing.allocator;
24+    const io = testing.io;
25+
26+    var term = try ghostty_vt.Terminal.init(io, alloc, .{
27+        .cols = 80,
28+        .rows = 24,
29+    });
30+    defer term.deinit(alloc);
31+
32+    var stream = term.vtStream();
33+    defer stream.deinit();
34+
35+    stream.nextSlice("\x1b]2;my title\x07");
36+    stream.nextSlice("hello");
37+
38+    const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
39+    defer alloc.free(output);
40+
41+    try testing.expect(std.mem.indexOf(u8, output, "\x1b]2;my title\x07") != null);
42+}
43+
44+test "serializeTerminalState omits the title when none is set" {
45+    const alloc = testing.allocator;
46+    const io = testing.io;
47+
48+    var term = try ghostty_vt.Terminal.init(io, alloc, .{
49+        .cols = 80,
50+        .rows = 24,
51+    });
52+    defer term.deinit(alloc);
53+
54+    var stream = term.vtStream();
55+    defer stream.deinit();
56+
57+    stream.nextSlice("hello");
58+
59+    const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
60+    defer alloc.free(output);
61+
62+    try testing.expect(std.mem.indexOf(u8, output, "\x1b]2;") == null);
63+}
64+
65 fn testCreateTerminal(alloc: std.mem.Allocator, io: std.Io, cols: u16, rows: u16, vt_data: []const u8) !ghostty_vt.Terminal {
66     var term = try ghostty_vt.Terminal.init(io, alloc, .{
67         .cols = cols,