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
......@@ -702,6 +702,16 @@ pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Termin
702702 return null;
703703 };
704704
705+ // The formatter has no title extra and never emits OSC 0/1/2, so the title
706+ // has to be replayed separately or an attaching client shows whatever its
707+ // terminal defaults to, usually the client process name. OSC 2 does not
708+ // move the cursor, so this is safe to append after the content.
709+ if (term.getTitle()) |title| {
710+ builder.writer.print("\x1b]2;{s}\x07", .{title}) catch |err| {
711+ std.log.warn("failed to format title err={s}", .{@errorName(err)});
712+ };
713+ }
714+
705715 const output = builder.writer.buffered();
706716 if (output.len == 0) return null;
707717
......@@ -1191,6 +1201,49 @@ test "serializeTerminalState excludes synchronized output replay" {
11911201 try testing.expect(std.mem.indexOf(u8, output, "\x1b[?2026h") == null);
11921202 }
11931203
1204+test "serializeTerminalState replays the title" {
1205+ const alloc = testing.allocator;
1206+ const io = testing.io;
1207+
1208+ var term = try ghostty_vt.Terminal.init(io, alloc, .{
1209+ .cols = 80,
1210+ .rows = 24,
1211+ });
1212+ defer term.deinit(alloc);
1213+
1214+ var stream = term.vtStream();
1215+ defer stream.deinit();
1216+
1217+ stream.nextSlice("\x1b]2;my title\x07");
1218+ stream.nextSlice("hello");
1219+
1220+ const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
1221+ defer alloc.free(output);
1222+
1223+ try testing.expect(std.mem.indexOf(u8, output, "\x1b]2;my title\x07") != null);
1224+}
1225+
1226+test "serializeTerminalState omits the title when none is set" {
1227+ const alloc = testing.allocator;
1228+ const io = testing.io;
1229+
1230+ var term = try ghostty_vt.Terminal.init(io, alloc, .{
1231+ .cols = 80,
1232+ .rows = 24,
1233+ });
1234+ defer term.deinit(alloc);
1235+
1236+ var stream = term.vtStream();
1237+ defer stream.deinit();
1238+
1239+ stream.nextSlice("hello");
1240+
1241+ const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
1242+ defer alloc.free(output);
1243+
1244+ try testing.expect(std.mem.indexOf(u8, output, "\x1b]2;") == null);
1245+}
1246+
11941247 fn testCreateTerminal(alloc: std.mem.Allocator, io: std.Io, cols: u16, rows: u16, vt_data: []const u8) !ghostty_vt.Terminal {
11951248 var term = try ghostty_vt.Terminal.init(io, alloc, .{
11961249 .cols = cols,