terminal: add ReadonlyStream that updates terminal state (#9346)

This adds a new stream handler implementation that updates terminal
state in reaction to VT sequences, but doesn't perform any of the
actions that would require responses (e.g. queries).

This is exposed in two ways: first, as a standalone `ReadonlyStream` and
`ReadonlyHandler` type that contains all the implementation. Second, as
a convenience func on `Terminal` as `vtStream` and `vtHandler` which
return their respective types preconfigured to update the calling
terminal state.

This dramatically simplifies libghostty-vt usage from Zig (and will
eventually be exposed to C, too) since a Terminal on its own is ready to
go as a full VT parser and state machine without needing to build any
custom types!

There's a second big bonus here which is that our `stream_readonly.zig`
tests are true end-to-end tests for raw bytes to terminal state. This
will let us test a wider variety of situations more broadly. To start,
there are only a handful of tests implemented here.

**AI disclosure:** Amp wrote basically this whole thing, but I reviewed
it. https://ampcode.com/threads/T-3490efd2-1137-4112-96f6-4bf8a0141ff5
pull/9350/head
Mitchell Hashimoto 2025-10-25 14:52:33 -07:00 committed by GitHub
parent 186b91ef84
commit 580262c96f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 714 additions and 9 deletions

View File

@ -94,7 +94,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
dir: [c-vt, c-vt-key-encode, c-vt-paste, zig-vt] dir: [c-vt, c-vt-key-encode, c-vt-paste, zig-vt, zig-vt-stream]
name: Example ${{ matrix.dir }} name: Example ${{ matrix.dir }}
runs-on: namespace-profile-ghostty-sm runs-on: namespace-profile-ghostty-sm
needs: test needs: test

View File

@ -0,0 +1,33 @@
# Example: `vtStream` API for Parsing Terminal Streams
This example demonstrates how to use the `vtStream` API to parse and process
VT sequences. The `vtStream` API is ideal for read-only terminal applications
that need to parse terminal output without responding to queries, such as:
- Replay tooling
- CI log viewers
- PaaS builder output
- etc.
The stream processes VT escape sequences and updates terminal state, while
ignoring sequences that require responses (like device status queries).
Requires the Zig version stated in the `build.zig.zon` file.
## Usage
Run the program:
```shell-session
zig build run
```
The example will process various VT sequences including:
- Plain text output
- ANSI color codes
- Cursor positioning
- Line clearing
- Multiple line handling
And display the final terminal state after processing all sequences.

View File

@ -0,0 +1,39 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const run_step = b.step("run", "Run the app");
const test_step = b.step("test", "Run unit tests");
const exe_mod = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
if (b.lazyDependency("ghostty", .{})) |dep| {
exe_mod.addImport(
"ghostty-vt",
dep.module("ghostty-vt"),
);
}
const exe = b.addExecutable(.{
.name = "zig_vt_stream",
.root_module = exe_mod,
});
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
if (b.args) |args| run_cmd.addArgs(args);
run_step.dependOn(&run_cmd.step);
const exe_unit_tests = b.addTest(.{
.root_module = exe_mod,
});
const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests);
test_step.dependOn(&run_exe_unit_tests.step);
}

View File

@ -0,0 +1,14 @@
.{
.name = .zig_vt_stream,
.version = "0.0.0",
.fingerprint = 0x34c1f71303690b3f,
.minimum_zig_version = "0.15.1",
.dependencies = .{
.ghostty = .{ .path = "../../" },
},
.paths = .{
"build.zig",
"build.zig.zon",
"src",
},
}

View File

@ -0,0 +1,40 @@
const std = @import("std");
const ghostty_vt = @import("ghostty-vt");
pub fn main() !void {
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer _ = gpa.deinit();
const alloc = gpa.allocator();
var t: ghostty_vt.Terminal = try .init(alloc, .{ .cols = 80, .rows = 24 });
defer t.deinit(alloc);
// Create a read-only VT stream for parsing terminal sequences
var stream = t.vtStream();
defer stream.deinit();
// Basic text with newline
try stream.nextSlice("Hello, World!\r\n");
// ANSI color codes: ESC[1;32m = bold green, ESC[0m = reset
try stream.nextSlice("\x1b[1;32mGreen Text\x1b[0m\r\n");
// Cursor positioning: ESC[1;1H = move to row 1, column 1
try stream.nextSlice("\x1b[1;1HTop-left corner\r\n");
// Cursor movement: ESC[5B = move down 5 lines
try stream.nextSlice("\x1b[5B");
try stream.nextSlice("Moved down!\r\n");
// Erase line: ESC[2K = clear entire line
try stream.nextSlice("\x1b[2K");
try stream.nextSlice("New content\r\n");
// Multiple lines
try stream.nextSlice("Line A\r\nLine B\r\nLine C\r\n");
// Get the final terminal state as a plain string
const str = try t.plainString(alloc);
defer alloc.free(str);
std.debug.print("{s}\n", .{str});
}

View File

@ -22,6 +22,8 @@ const sgr = @import("sgr.zig");
const Tabstops = @import("Tabstops.zig"); const Tabstops = @import("Tabstops.zig");
const color = @import("color.zig"); const color = @import("color.zig");
const mouse_shape_pkg = @import("mouse_shape.zig"); const mouse_shape_pkg = @import("mouse_shape.zig");
const ReadonlyHandler = @import("stream_readonly.zig").Handler;
const ReadonlyStream = @import("stream_readonly.zig").Stream;
const size = @import("size.zig"); const size = @import("size.zig");
const pagepkg = @import("page.zig"); const pagepkg = @import("page.zig");
@ -239,6 +241,19 @@ pub fn deinit(self: *Terminal, alloc: Allocator) void {
self.* = undefined; self.* = undefined;
} }
/// Return a terminal.Stream that can process VT streams and update this
/// terminal state. The streams will only process read-only data that
/// modifies terminal state. Sequences that query or otherwise require
/// output will be ignored.
pub fn vtStream(self: *Terminal) ReadonlyStream {
return .initAlloc(self.gpa(), self.vtHandler());
}
/// This is the handler-side only for vtStream.
pub fn vtHandler(self: *Terminal) ReadonlyHandler {
return .init(self);
}
/// The general allocator we should use for this terminal. /// The general allocator we should use for this terminal.
fn gpa(self: *Terminal) Allocator { fn gpa(self: *Terminal) Allocator {
return self.screen.alloc; return self.screen.alloc;

View File

@ -6,6 +6,7 @@ const ansi = @import("ansi.zig");
const csi = @import("csi.zig"); const csi = @import("csi.zig");
const hyperlink = @import("hyperlink.zig"); const hyperlink = @import("hyperlink.zig");
const sgr = @import("sgr.zig"); const sgr = @import("sgr.zig");
const stream_readonly = @import("stream_readonly.zig");
const style = @import("style.zig"); const style = @import("style.zig");
pub const apc = @import("apc.zig"); pub const apc = @import("apc.zig");
pub const dcs = @import("dcs.zig"); pub const dcs = @import("dcs.zig");
@ -36,6 +37,8 @@ pub const PageList = @import("PageList.zig");
pub const Parser = @import("Parser.zig"); pub const Parser = @import("Parser.zig");
pub const Pin = PageList.Pin; pub const Pin = PageList.Pin;
pub const Point = point.Point; pub const Point = point.Point;
pub const ReadonlyHandler = stream_readonly.Handler;
pub const ReadonlyStream = stream_readonly.Stream;
pub const Screen = @import("Screen.zig"); pub const Screen = @import("Screen.zig");
pub const ScreenType = Terminal.ScreenType; pub const ScreenType = Terminal.ScreenType;
pub const Scrollbar = PageList.Scrollbar; pub const Scrollbar = PageList.Scrollbar;

View File

@ -3,6 +3,7 @@ const std = @import("std");
const build_options = @import("terminal_options"); const build_options = @import("terminal_options");
const assert = std.debug.assert; const assert = std.debug.assert;
const testing = std.testing; const testing = std.testing;
const Allocator = std.mem.Allocator;
const simd = @import("../simd/main.zig"); const simd = @import("../simd/main.zig");
const lib = @import("../lib/main.zig"); const lib = @import("../lib/main.zig");
const Parser = @import("Parser.zig"); const Parser = @import("Parser.zig");
@ -29,6 +30,8 @@ const debug = false;
const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; const lib_target: lib.Target = if (build_options.c_abi) .c else .zig;
/// The possible actions that can be emitted by the Stream
/// function for handling.
pub const Action = union(Key) { pub const Action = union(Key) {
print: Print, print: Print,
print_repeat: usize, print_repeat: usize,
@ -456,6 +459,8 @@ pub const Action = union(Key) {
/// about in its pursuit of implementing a terminal emulator or other /// about in its pursuit of implementing a terminal emulator or other
/// functionality. /// functionality.
/// ///
/// The Handler type must also have a `deinit` function.
///
/// The "comptime" key is on purpose (vs. a standard Zig tagged union) /// The "comptime" key is on purpose (vs. a standard Zig tagged union)
/// because it allows the compiler to optimize away unimplemented actions. /// because it allows the compiler to optimize away unimplemented actions.
/// e.g. you don't need to pay a conditional branching cost on every single /// e.g. you don't need to pay a conditional branching cost on every single
@ -476,6 +481,19 @@ pub fn Stream(comptime Handler: type) type {
parser: Parser, parser: Parser,
utf8decoder: UTF8Decoder, utf8decoder: UTF8Decoder,
/// Initialize an allocation-free stream. This will preallocate various
/// sizes as necessary and anything over that will be dropped. If you
/// want to support more dynamic behavior use initAlloc instead.
///
/// As a concrete example of something that requires heap allocation,
/// consider OSC 52 (clipboard operations) which can be arbitrarily
/// large.
///
/// If you want to limit allocation size, use an allocator with
/// a size limit with initAlloc.
///
/// This takes ownership of the handler and will call deinit
/// when the stream is deinitialized.
pub fn init(h: Handler) Self { pub fn init(h: Handler) Self {
return .{ return .{
.handler = h, .handler = h,
@ -484,8 +502,16 @@ pub fn Stream(comptime Handler: type) type {
}; };
} }
/// Initialize the stream that supports heap allocation as necessary.
pub fn initAlloc(alloc: Allocator, h: Handler) Self {
var self: Self = .init(h);
self.parser.osc_parser.alloc = alloc;
return self;
}
pub fn deinit(self: *Self) void { pub fn deinit(self: *Self) void {
self.parser.deinit(); self.parser.deinit();
self.handler.deinit();
} }
/// Process a string of characters. /// Process a string of characters.

View File

@ -0,0 +1,542 @@
const std = @import("std");
const testing = std.testing;
const stream = @import("stream.zig");
const Action = stream.Action;
const CursorStyle = @import("Screen.zig").CursorStyle;
const Mode = @import("modes.zig").Mode;
const Terminal = @import("Terminal.zig");
/// This is a Stream implementation that processes actions against
/// a Terminal and updates the Terminal state. It is called "readonly" because
/// it only processes actions that modify terminal state, while ignoring
/// any actions that require a response (like queries).
///
/// If you're implementing a terminal emulator that only needs to render
/// output and doesn't need to respond (since it maybe isn't running the
/// actual program), this is the stream type to use. For example, this is
/// ideal for replay tooling, CI logs, PaaS builder output, etc.
pub const Stream = stream.Stream(Handler);
/// See Stream, which is just the stream wrapper around this.
///
/// This isn't attached directly to Terminal because there is additional
/// state and options we plan to add in the future, such as APC/DCS which
/// don't make sense to me to add to the Terminal directly. Instead, you
/// can call `vtHandler` on Terminal to initialize this handler.
pub const Handler = struct {
/// The terminal state to modify.
terminal: *Terminal,
pub fn init(terminal: *Terminal) Handler {
return .{
.terminal = terminal,
};
}
pub fn deinit(self: *Handler) void {
// Currently does nothing but may in the future so callers should
// call this.
_ = self;
}
pub fn vt(
self: *Handler,
comptime action: Action.Tag,
value: Action.Value(action),
) !void {
switch (action) {
.print => try self.terminal.print(value.cp),
.print_repeat => try self.terminal.printRepeat(value),
.backspace => self.terminal.backspace(),
.carriage_return => self.terminal.carriageReturn(),
.linefeed => try self.terminal.linefeed(),
.index => try self.terminal.index(),
.next_line => {
try self.terminal.index();
self.terminal.carriageReturn();
},
.reverse_index => self.terminal.reverseIndex(),
.cursor_up => self.terminal.cursorUp(value.value),
.cursor_down => self.terminal.cursorDown(value.value),
.cursor_left => self.terminal.cursorLeft(value.value),
.cursor_right => self.terminal.cursorRight(value.value),
.cursor_pos => self.terminal.setCursorPos(value.row, value.col),
.cursor_col => self.terminal.setCursorPos(self.terminal.screen.cursor.y + 1, value.value),
.cursor_row => self.terminal.setCursorPos(value.value, self.terminal.screen.cursor.x + 1),
.cursor_col_relative => self.terminal.setCursorPos(
self.terminal.screen.cursor.y + 1,
self.terminal.screen.cursor.x + 1 +| value.value,
),
.cursor_row_relative => self.terminal.setCursorPos(
self.terminal.screen.cursor.y + 1 +| value.value,
self.terminal.screen.cursor.x + 1,
),
.cursor_style => {
const blink = switch (value) {
.default, .steady_block, .steady_bar, .steady_underline => false,
.blinking_block, .blinking_bar, .blinking_underline => true,
};
const style: CursorStyle = switch (value) {
.default, .blinking_block, .steady_block => .block,
.blinking_bar, .steady_bar => .bar,
.blinking_underline, .steady_underline => .underline,
};
self.terminal.modes.set(.cursor_blinking, blink);
self.terminal.screen.cursor.cursor_style = style;
},
.erase_display_below => self.terminal.eraseDisplay(.below, value),
.erase_display_above => self.terminal.eraseDisplay(.above, value),
.erase_display_complete => self.terminal.eraseDisplay(.complete, value),
.erase_display_scrollback => self.terminal.eraseDisplay(.scrollback, value),
.erase_display_scroll_complete => self.terminal.eraseDisplay(.scroll_complete, value),
.erase_line_right => self.terminal.eraseLine(.right, value),
.erase_line_left => self.terminal.eraseLine(.left, value),
.erase_line_complete => self.terminal.eraseLine(.complete, value),
.erase_line_right_unless_pending_wrap => self.terminal.eraseLine(.right_unless_pending_wrap, value),
.delete_chars => self.terminal.deleteChars(value),
.erase_chars => self.terminal.eraseChars(value),
.insert_lines => self.terminal.insertLines(value),
.insert_blanks => self.terminal.insertBlanks(value),
.delete_lines => self.terminal.deleteLines(value),
.scroll_up => self.terminal.scrollUp(value),
.scroll_down => self.terminal.scrollDown(value),
.horizontal_tab => try self.horizontalTab(value),
.horizontal_tab_back => try self.horizontalTabBack(value),
.tab_clear_current => self.terminal.tabClear(.current),
.tab_clear_all => self.terminal.tabClear(.all),
.tab_set => self.terminal.tabSet(),
.tab_reset => self.terminal.tabReset(),
.set_mode => try self.setMode(value.mode, true),
.reset_mode => try self.setMode(value.mode, false),
.save_mode => self.terminal.modes.save(value.mode),
.restore_mode => {
const v = self.terminal.modes.restore(value.mode);
try self.setMode(value.mode, v);
},
.top_and_bottom_margin => self.terminal.setTopAndBottomMargin(value.top_left, value.bottom_right),
.left_and_right_margin => self.terminal.setLeftAndRightMargin(value.top_left, value.bottom_right),
.left_and_right_margin_ambiguous => {
if (self.terminal.modes.get(.enable_left_and_right_margin)) {
self.terminal.setLeftAndRightMargin(0, 0);
} else {
self.terminal.saveCursor();
}
},
.save_cursor => self.terminal.saveCursor(),
.restore_cursor => try self.terminal.restoreCursor(),
.invoke_charset => self.terminal.invokeCharset(value.bank, value.charset, value.locking),
.configure_charset => self.terminal.configureCharset(value.slot, value.charset),
.set_attribute => switch (value) {
.unknown => {},
else => self.terminal.setAttribute(value) catch {},
},
.protected_mode_off => self.terminal.setProtectedMode(.off),
.protected_mode_iso => self.terminal.setProtectedMode(.iso),
.protected_mode_dec => self.terminal.setProtectedMode(.dec),
.mouse_shift_capture => self.terminal.flags.mouse_shift_capture = if (value) .true else .false,
.kitty_keyboard_push => self.terminal.screen.kitty_keyboard.push(value.flags),
.kitty_keyboard_pop => self.terminal.screen.kitty_keyboard.pop(@intCast(value)),
.kitty_keyboard_set => self.terminal.screen.kitty_keyboard.set(.set, value.flags),
.kitty_keyboard_set_or => self.terminal.screen.kitty_keyboard.set(.@"or", value.flags),
.kitty_keyboard_set_not => self.terminal.screen.kitty_keyboard.set(.not, value.flags),
.modify_key_format => {
self.terminal.flags.modify_other_keys_2 = false;
switch (value) {
.other_keys_numeric => self.terminal.flags.modify_other_keys_2 = true,
else => {},
}
},
.active_status_display => self.terminal.status_display = value,
.decaln => try self.terminal.decaln(),
.full_reset => self.terminal.fullReset(),
.start_hyperlink => try self.terminal.screen.startHyperlink(value.uri, value.id),
.end_hyperlink => self.terminal.screen.endHyperlink(),
.prompt_start => {
self.terminal.screen.cursor.page_row.semantic_prompt = .prompt;
self.terminal.flags.shell_redraws_prompt = value.redraw;
},
.prompt_continuation => self.terminal.screen.cursor.page_row.semantic_prompt = .prompt_continuation,
.prompt_end => self.terminal.markSemanticPrompt(.input),
.end_of_input => self.terminal.markSemanticPrompt(.command),
.end_of_command => self.terminal.screen.cursor.page_row.semantic_prompt = .input,
.mouse_shape => self.terminal.mouse_shape = value,
// No supported DCS commands have any terminal-modifying effects,
// but they may in the future. For now we just ignore it.
.dcs_hook,
.dcs_put,
.dcs_unhook,
=> {},
// APC can modify terminal state (Kitty graphics) but we don't
// currently support it in the readonly stream.
.apc_start,
.apc_end,
.apc_put,
=> {},
// Have no terminal-modifying effect
.bell,
.enquiry,
.request_mode,
.request_mode_unknown,
.size_report,
.xtversion,
.device_attributes,
.device_status,
.kitty_keyboard_query,
.kitty_color_report,
.color_operation,
.window_title,
.report_pwd,
.show_desktop_notification,
.progress_report,
.clipboard_contents,
.title_push,
.title_pop,
=> {},
}
}
inline fn horizontalTab(self: *Handler, count: u16) !void {
for (0..count) |_| {
const x = self.terminal.screen.cursor.x;
try self.terminal.horizontalTab();
if (x == self.terminal.screen.cursor.x) break;
}
}
inline fn horizontalTabBack(self: *Handler, count: u16) !void {
for (0..count) |_| {
const x = self.terminal.screen.cursor.x;
try self.terminal.horizontalTabBack();
if (x == self.terminal.screen.cursor.x) break;
}
}
fn setMode(self: *Handler, mode: Mode, enabled: bool) !void {
// Set the mode on the terminal
self.terminal.modes.set(mode, enabled);
// Some modes require additional processing
switch (mode) {
.autorepeat,
.reverse_colors,
=> {},
.origin => self.terminal.setCursorPos(1, 1),
.enable_left_and_right_margin => if (!enabled) {
self.terminal.scrolling_region.left = 0;
self.terminal.scrolling_region.right = self.terminal.cols - 1;
},
.alt_screen_legacy => self.terminal.switchScreenMode(.@"47", enabled),
.alt_screen => self.terminal.switchScreenMode(.@"1047", enabled),
.alt_screen_save_cursor_clear_enter => self.terminal.switchScreenMode(.@"1049", enabled),
.save_cursor => if (enabled) {
self.terminal.saveCursor();
} else {
try self.terminal.restoreCursor();
},
.enable_mode_3 => {},
.@"132_column" => try self.terminal.deccolm(
self.terminal.screen.alloc,
if (enabled) .@"132_cols" else .@"80_cols",
),
.synchronized_output,
.linefeed,
.in_band_size_reports,
.focus_event,
=> {},
.mouse_event_x10 => {
if (enabled) {
self.terminal.flags.mouse_event = .x10;
} else {
self.terminal.flags.mouse_event = .none;
}
},
.mouse_event_normal => {
if (enabled) {
self.terminal.flags.mouse_event = .normal;
} else {
self.terminal.flags.mouse_event = .none;
}
},
.mouse_event_button => {
if (enabled) {
self.terminal.flags.mouse_event = .button;
} else {
self.terminal.flags.mouse_event = .none;
}
},
.mouse_event_any => {
if (enabled) {
self.terminal.flags.mouse_event = .any;
} else {
self.terminal.flags.mouse_event = .none;
}
},
.mouse_format_utf8 => self.terminal.flags.mouse_format = if (enabled) .utf8 else .x10,
.mouse_format_sgr => self.terminal.flags.mouse_format = if (enabled) .sgr else .x10,
.mouse_format_urxvt => self.terminal.flags.mouse_format = if (enabled) .urxvt else .x10,
.mouse_format_sgr_pixels => self.terminal.flags.mouse_format = if (enabled) .sgr_pixels else .x10,
else => {},
}
}
};
test "basic print" {
var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
try s.nextSlice("Hello");
try testing.expectEqual(@as(usize, 5), t.screen.cursor.x);
try testing.expectEqual(@as(usize, 0), t.screen.cursor.y);
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("Hello", str);
}
test "cursor movement" {
var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Move cursor using escape sequences
try s.nextSlice("Hello\x1B[1;1H");
try testing.expectEqual(@as(usize, 0), t.screen.cursor.x);
try testing.expectEqual(@as(usize, 0), t.screen.cursor.y);
// Move to position 2,3
try s.nextSlice("\x1B[2;3H");
try testing.expectEqual(@as(usize, 2), t.screen.cursor.x);
try testing.expectEqual(@as(usize, 1), t.screen.cursor.y);
}
test "erase operations" {
var t: Terminal = try .init(testing.allocator, .{ .cols = 20, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Print some text
try s.nextSlice("Hello World");
try testing.expectEqual(@as(usize, 11), t.screen.cursor.x);
try testing.expectEqual(@as(usize, 0), t.screen.cursor.y);
// Move cursor to position 1,6 and erase from cursor to end of line
try s.nextSlice("\x1B[1;6H");
try s.nextSlice("\x1B[K");
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("Hello", str);
}
test "tabs" {
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
try s.nextSlice("A\tB");
try testing.expectEqual(@as(usize, 9), t.screen.cursor.x);
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("A B", str);
}
test "modes" {
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();
// Test wraparound mode
try testing.expect(t.modes.get(.wraparound));
try s.nextSlice("\x1B[?7l"); // Disable wraparound
try testing.expect(!t.modes.get(.wraparound));
try s.nextSlice("\x1B[?7h"); // Enable wraparound
try testing.expect(t.modes.get(.wraparound));
}
test "scrolling regions" {
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();
// Set scrolling region from line 5 to 20
try s.nextSlice("\x1B[5;20r");
try testing.expectEqual(@as(usize, 4), t.scrolling_region.top);
try testing.expectEqual(@as(usize, 19), t.scrolling_region.bottom);
try testing.expectEqual(@as(usize, 0), t.scrolling_region.left);
try testing.expectEqual(@as(usize, 79), t.scrolling_region.right);
}
test "charsets" {
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();
// Configure G0 as DEC special graphics
try s.nextSlice("\x1B(0");
try s.nextSlice("`"); // Should print diamond character
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("", str);
}
test "alt screen" {
var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 5 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Write to primary screen
try s.nextSlice("Primary");
try testing.expectEqual(Terminal.ScreenType.primary, t.active_screen);
// Switch to alt screen
try s.nextSlice("\x1B[?1049h");
try testing.expectEqual(Terminal.ScreenType.alternate, t.active_screen);
// Write to alt screen
try s.nextSlice("Alt");
// Switch back to primary
try s.nextSlice("\x1B[?1049l");
try testing.expectEqual(Terminal.ScreenType.primary, t.active_screen);
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("Primary", str);
}
test "cursor save and restore" {
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();
// Move cursor to 10,15
try s.nextSlice("\x1B[10;15H");
try testing.expectEqual(@as(usize, 14), t.screen.cursor.x);
try testing.expectEqual(@as(usize, 9), t.screen.cursor.y);
// Save cursor
try s.nextSlice("\x1B7");
// Move cursor elsewhere
try s.nextSlice("\x1B[1;1H");
try testing.expectEqual(@as(usize, 0), t.screen.cursor.x);
try testing.expectEqual(@as(usize, 0), t.screen.cursor.y);
// Restore cursor
try s.nextSlice("\x1B8");
try testing.expectEqual(@as(usize, 14), t.screen.cursor.x);
try testing.expectEqual(@as(usize, 9), t.screen.cursor.y);
}
test "attributes" {
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();
// Set bold and write text
try s.nextSlice("\x1B[1mBold\x1B[0m");
// Verify we can write attributes - just check the string was written
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("Bold", str);
}
test "DECALN screen alignment" {
var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 3 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Run DECALN
try s.nextSlice("\x1B#8");
// Verify entire screen is filled with 'E'
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("EEEEEEEEEE\nEEEEEEEEEE\nEEEEEEEEEE", str);
// Cursor should be at 1,1
try testing.expectEqual(@as(usize, 0), t.screen.cursor.x);
try testing.expectEqual(@as(usize, 0), t.screen.cursor.y);
}
test "full reset" {
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();
// Make some changes
try s.nextSlice("Hello");
try s.nextSlice("\x1B[10;20H");
try s.nextSlice("\x1B[5;20r"); // Set scroll region
try s.nextSlice("\x1B[?7l"); // Disable wraparound
// Full reset
try s.nextSlice("\x1Bc");
// Verify reset state
try testing.expectEqual(@as(usize, 0), t.screen.cursor.x);
try testing.expectEqual(@as(usize, 0), t.screen.cursor.y);
try testing.expectEqual(@as(usize, 0), t.scrolling_region.top);
try testing.expectEqual(@as(usize, 23), t.scrolling_region.bottom);
try testing.expect(t.modes.get(.wraparound));
}
test "ignores query actions" {
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();
// These should be ignored without error
try s.nextSlice("\x1B[c"); // Device attributes
try s.nextSlice("\x1B[5n"); // Device status report
try s.nextSlice("\x1B[6n"); // Cursor position report
// Terminal should still be functional
try s.nextSlice("Test");
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("Test", str);
}

View File

@ -313,13 +313,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void {
.size = opts.size, .size = opts.size,
.backend = backend, .backend = backend,
.mailbox = opts.mailbox, .mailbox = opts.mailbox,
.terminal_stream = stream: { .terminal_stream = .initAlloc(alloc, handler),
var s: terminalpkg.Stream(StreamHandler) = .init(handler);
// Populate the OSC parser allocator (optional) because
// we want to support large OSC payloads such as OSC 52.
s.parser.osc_parser.alloc = alloc;
break :stream s;
},
.thread_enter_state = thread_enter_state, .thread_enter_state = thread_enter_state,
}; };
} }
@ -331,7 +325,6 @@ pub fn deinit(self: *Termio) void {
self.mailbox.deinit(self.alloc); self.mailbox.deinit(self.alloc);
// Clear any StreamHandler state // Clear any StreamHandler state
self.terminal_stream.handler.deinit();
self.terminal_stream.deinit(); self.terminal_stream.deinit();
// Clear any initial state if we have it // Clear any initial state if we have it