From 918840cf1d1d617d1c8bb63f738a56ca7c6f165d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 21 Mar 2026 11:14:58 -0700 Subject: [PATCH] vt: persist VT stream state across vt_write calls Previously, every call to vt_write created a fresh ReadonlyStream with new Parser and UTF8Decoder state. This meant escape sequences split across write boundaries (e.g. ESC in one write, [27m in the next) would lose parser state, causing the second write to start in ground state and print the CSI parameters as literal text. The C API now stores a persistent ReadonlyStream in the TerminalWrapper struct, which is created when the Terminal is initialized. The vt_write function feeds bytes through this stored stream, allowing it to maintain parser state across calls. This change ensures that escape sequences split across write boundaries are correctly parsed and rendered. --- src/terminal/Terminal.zig | 5 ++ src/terminal/c/formatter.zig | 2 +- src/terminal/c/key_encode.zig | 3 +- src/terminal/c/mouse_encode.zig | 3 +- src/terminal/c/render.zig | 3 +- src/terminal/c/terminal.zig | 87 ++++++++++++++++++++++++--------- 6 files changed, 77 insertions(+), 26 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 636a8f2ee..a0ebe8a07 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -239,6 +239,11 @@ pub fn deinit(self: *Terminal, alloc: Allocator) void { /// terminal state. The streams will only process read-only data that /// modifies terminal state. Sequences that query or otherwise require /// output will be ignored. +/// +/// Important: this creates a new stream each time with fresh parser state. +/// If you need to persist parser state across multiple writes (e.g. +/// for handling escape sequences split across write boundaries), you +/// must store and reuse the returned stream. pub fn vtStream(self: *Terminal) ReadonlyStream { return .initAlloc(self.gpa(), self.vtHandler()); } diff --git a/src/terminal/c/formatter.zig b/src/terminal/c/formatter.zig index 511d371f8..a768287ad 100644 --- a/src/terminal/c/formatter.zig +++ b/src/terminal/c/formatter.zig @@ -124,7 +124,7 @@ fn terminal_new_( InvalidValue, OutOfMemory, }!*FormatterWrapper { - const t = terminal_ orelse return error.InvalidValue; + const t: *ZigTerminal = (terminal_ orelse return error.InvalidValue).terminal; const alloc = lib_alloc.default(alloc_); const ptr = alloc.create(FormatterWrapper) catch diff --git a/src/terminal/c/key_encode.zig b/src/terminal/c/key_encode.zig index 58405876f..2cdb765af 100644 --- a/src/terminal/c/key_encode.zig +++ b/src/terminal/c/key_encode.zig @@ -9,6 +9,7 @@ const OptionAsAlt = @import("../../input/config.zig").OptionAsAlt; const Result = @import("result.zig").Result; const KeyEvent = @import("key_event.zig").Event; const Terminal = @import("terminal.zig").Terminal; +const ZigTerminal = @import("../Terminal.zig"); const log = std.log.scoped(.key_encode); @@ -121,7 +122,7 @@ pub fn setopt_from_terminal( terminal_: Terminal, ) callconv(.c) void { const wrapper = encoder_ orelse return; - const t = terminal_ orelse return; + const t: *ZigTerminal = (terminal_ orelse return).terminal; wrapper.opts = .fromTerminal(t); } diff --git a/src/terminal/c/mouse_encode.zig b/src/terminal/c/mouse_encode.zig index 963e296bd..224b64837 100644 --- a/src/terminal/c/mouse_encode.zig +++ b/src/terminal/c/mouse_encode.zig @@ -11,6 +11,7 @@ const mouse_event = @import("mouse_event.zig"); const Result = @import("result.zig").Result; const Event = mouse_event.Event; const Terminal = @import("terminal.zig").Terminal; +const ZigTerminal = @import("../Terminal.zig"); const log = std.log.scoped(.mouse_encode); @@ -188,7 +189,7 @@ pub fn setopt_from_terminal( terminal_: Terminal, ) callconv(.c) void { const wrapper = encoder_ orelse return; - const t = terminal_ orelse return; + const t: *ZigTerminal = (terminal_ orelse return).terminal; wrapper.opts.event = t.flags.mouse_event; wrapper.opts.format = t.flags.mouse_format; wrapper.last_cell = null; diff --git a/src/terminal/c/render.zig b/src/terminal/c/render.zig index b7f2fca0a..0daf6abbe 100644 --- a/src/terminal/c/render.zig +++ b/src/terminal/c/render.zig @@ -10,6 +10,7 @@ const page = @import("../page.zig"); const size = @import("../size.zig"); const Style = @import("../style.zig").Style; const terminal_c = @import("terminal.zig"); +const ZigTerminal = @import("../Terminal.zig"); const renderpkg = @import("../render.zig"); const Result = @import("result.zig").Result; const row = @import("row.zig"); @@ -166,7 +167,7 @@ pub fn update( terminal_: terminal_c.Terminal, ) callconv(.c) Result { const state = state_ orelse return .invalid_value; - const t = terminal_ orelse return .invalid_value; + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; state.state.update(state.alloc, t) catch return .out_of_memory; return .success; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index ad560dc1a..2a2fe0ae6 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -3,6 +3,7 @@ const testing = std.testing; const lib_alloc = @import("../../lib/allocator.zig"); const CAllocator = lib_alloc.Allocator; const ZigTerminal = @import("../Terminal.zig"); +const ReadonlyStream = @import("../stream_readonly.zig").Stream; const ScreenSet = @import("../ScreenSet.zig"); const PageList = @import("../PageList.zig"); const kitty = @import("../kitty/key.zig"); @@ -17,8 +18,16 @@ const Result = @import("result.zig").Result; const log = std.log.scoped(.terminal_c); +/// Wrapper around ZigTerminal that tracks additional state for C API usage, +/// such as the persistent VT stream needed to handle escape sequences split +/// across multiple vt_write calls. +const TerminalWrapper = struct { + terminal: *ZigTerminal, + stream: ReadonlyStream, +}; + /// C: GhosttyTerminal -pub const Terminal = ?*ZigTerminal; +pub const Terminal = ?*TerminalWrapper; /// C: GhosttyTerminalOptions pub const Options = extern struct { @@ -51,21 +60,28 @@ pub fn new( fn new_( alloc_: ?*const CAllocator, opts: Options, -) NewError!*ZigTerminal { +) NewError!*TerminalWrapper { if (opts.cols == 0 or opts.rows == 0) return error.InvalidValue; const alloc = lib_alloc.default(alloc_); - const ptr = alloc.create(ZigTerminal) catch + const t = alloc.create(ZigTerminal) catch return error.OutOfMemory; - errdefer alloc.destroy(ptr); + errdefer alloc.destroy(t); - ptr.* = try .init(alloc, .{ + t.* = try .init(alloc, .{ .cols = opts.cols, .rows = opts.rows, .max_scrollback = opts.max_scrollback, }); - return ptr; + const wrapper = alloc.create(TerminalWrapper) catch + return error.OutOfMemory; + wrapper.* = .{ + .terminal = t, + .stream = t.vtStream(), + }; + + return wrapper; } pub fn vt_write( @@ -73,9 +89,8 @@ pub fn vt_write( ptr: [*]const u8, len: usize, ) callconv(.c) void { - const t = terminal_ orelse return; - var stream = t.vtStream(); - stream.nextSlice(ptr[0..len]); + const wrapper = terminal_ orelse return; + wrapper.stream.nextSlice(ptr[0..len]); } /// C: GhosttyTerminalScrollViewport @@ -85,7 +100,7 @@ pub fn scroll_viewport( terminal_: Terminal, behavior: ScrollViewport, ) callconv(.c) void { - const t = terminal_ orelse return; + const t: *ZigTerminal = (terminal_ orelse return).terminal; t.scrollViewport(switch (behavior.tag) { .top => .top, .bottom => .bottom, @@ -98,14 +113,14 @@ pub fn resize( cols: size.CellCountInt, rows: size.CellCountInt, ) callconv(.c) Result { - const t = terminal_ orelse return .invalid_value; + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; if (cols == 0 or rows == 0) return .invalid_value; t.resize(t.gpa(), cols, rows) catch return .out_of_memory; return .success; } pub fn reset(terminal_: Terminal) callconv(.c) void { - const t = terminal_ orelse return; + const t: *ZigTerminal = (terminal_ orelse return).terminal; t.fullReset(); } @@ -114,7 +129,7 @@ pub fn mode_get( tag: modes.ModeTag.Backing, out_value: *bool, ) callconv(.c) Result { - const t = terminal_ orelse return .invalid_value; + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; const mode_tag: modes.ModeTag = @bitCast(tag); const mode = modes.modeFromInt(mode_tag.value, mode_tag.ansi) orelse return .invalid_value; out_value.* = t.modes.get(mode); @@ -126,7 +141,7 @@ pub fn mode_set( tag: modes.ModeTag.Backing, value: bool, ) callconv(.c) Result { - const t = terminal_ orelse return .invalid_value; + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; const mode_tag: modes.ModeTag = @bitCast(tag); const mode = modes.modeFromInt(mode_tag.value, mode_tag.ansi) orelse return .invalid_value; t.modes.set(mode, value); @@ -193,7 +208,7 @@ fn getTyped( comptime data: TerminalData, out: *data.OutType(), ) Result { - const t = terminal_ orelse return .invalid_value; + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; switch (data) { .invalid => return .invalid_value, .cols => out.* = t.cols, @@ -216,7 +231,7 @@ pub fn grid_ref( pt: point.Point.C, out_ref: ?*grid_ref_c.CGridRef, ) callconv(.c) Result { - const t = terminal_ orelse return .invalid_value; + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; const zig_pt: point.Point = switch (pt.tag) { .active => .{ .active = pt.value.active }, .viewport => .{ .viewport = pt.value.viewport }, @@ -230,11 +245,14 @@ pub fn grid_ref( } pub fn free(terminal_: Terminal) callconv(.c) void { - const t = terminal_ orelse return; + const wrapper = terminal_ orelse return; + const t = wrapper.terminal; + wrapper.stream.deinit(); const alloc = t.gpa(); t.deinit(alloc); alloc.destroy(t); + alloc.destroy(wrapper); } test "new/free" { @@ -296,7 +314,7 @@ test "scroll_viewport" { )); defer free(t); - const zt = t.?; + const zt = t.?.terminal; // Write "hello" on the first line vt_write(t, "hello", 5); @@ -355,7 +373,7 @@ test "reset" { vt_write(t, "Hello", 5); reset(t); - const str = try t.?.plainString(testing.allocator); + const str = try t.?.terminal.plainString(testing.allocator); defer testing.allocator.free(str); try testing.expectEqualStrings("", str); } @@ -378,8 +396,8 @@ test "resize" { defer free(t); try testing.expectEqual(Result.success, resize(t, 40, 12)); - try testing.expectEqual(40, t.?.cols); - try testing.expectEqual(12, t.?.rows); + try testing.expectEqual(40, t.?.terminal.cols); + try testing.expectEqual(12, t.?.terminal.rows); } test "resize null" { @@ -499,11 +517,36 @@ test "vt_write" { vt_write(t, "Hello", 5); - const str = try t.?.plainString(testing.allocator); + const str = try t.?.terminal.plainString(testing.allocator); defer testing.allocator.free(str); try testing.expectEqualStrings("Hello", str); } +test "vt_write split escape sequence" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + // Write "Hello" in bold by splitting the CSI bold sequence across two writes. + // ESC [ 1 m = bold on, ESC [ 0 m = reset + // Split ESC from the rest of the CSI sequence. + vt_write(t, "Hello \x1b", 7); + vt_write(t, "[1mBold\x1b[0m", 10); + + const str = try t.?.terminal.plainString(testing.allocator); + defer testing.allocator.free(str); + // If the escape sequence leaked, we'd see "[1mBold" as literal text. + try testing.expectEqualStrings("Hello Bold", str); +} + test "get cols and rows" { var t: Terminal = null; try testing.expectEqual(Result.success, new(