libghostty: introduce optional "effects" to handle queries and side effects for terminals (#11787)

Renames `ReadonlyStream` to `TerminalStream` and introduces an
effects-based callback system so that the stream handler can optionally
respond to queries and side effects (bell, title changes, device
attributes, device status, size reports, XTVERSION, ENQ, DECRQM, kitty
keyboard queries).

The default behavior is still read-only, callers have to opt-in to
setting callbacks to function pointers.

This doesn't handle every possible side effect yet, e.g. this doesn't
include clipboards, pwd reporting, and others. But this covers the
important ones.

This PR is Zig only, the C version of this will come later.
This commit is contained in:
Mitchell Hashimoto
2026-03-23 15:30:18 -07:00
committed by GitHub
11 changed files with 1371 additions and 56 deletions

View File

@@ -58,10 +58,9 @@ pub const SizeReportStyle = terminal.SizeReportStyle;
pub const StringMap = terminal.StringMap;
pub const Style = terminal.Style;
pub const Terminal = terminal.Terminal;
pub const TerminalStream = terminal.TerminalStream;
pub const Stream = terminal.Stream;
pub const StreamAction = terminal.StreamAction;
pub const ReadonlyStream = terminal.ReadonlyStream;
pub const ReadonlyHandler = terminal.ReadonlyHandler;
pub const Cursor = Screen.Cursor;
pub const CursorStyle = Screen.CursorStyle;
pub const CursorStyleReq = terminal.CursorStyle;

View File

@@ -24,8 +24,7 @@ const sgr = @import("sgr.zig");
const Tabstops = @import("Tabstops.zig");
const color = @import("color.zig");
const mouse = @import("mouse.zig");
const ReadonlyHandler = @import("stream_readonly.zig").Handler;
const ReadonlyStream = @import("stream_readonly.zig").Stream;
const Stream = @import("stream_terminal.zig").Stream;
const size = @import("size.zig");
const pagepkg = @import("page.zig");
@@ -68,6 +67,9 @@ scrolling_region: ScrollingRegion,
/// The last reported pwd, if any.
pwd: std.ArrayList(u8),
/// The title of the terminal as set by escape sequences (e.g. OSC 0/2).
title: std.ArrayList(u8),
/// The color state for this terminal.
colors: Colors,
@@ -220,6 +222,7 @@ pub fn init(
.right = cols - 1,
},
.pwd = .empty,
.title = .empty,
.colors = opts.colors,
.modes = .{
.values = opts.default_modes,
@@ -232,24 +235,30 @@ pub fn deinit(self: *Terminal, alloc: Allocator) void {
self.tabstops.deinit(alloc);
self.screens.deinit(alloc);
self.pwd.deinit(alloc);
self.title.deinit(alloc);
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.
/// modifies terminal state.
///
/// Sequences that query or otherwise require output will be ignored.
/// If you want to handle side effects, use `vtHandler` and set the
/// effects field yourself, then initialize a stream.
///
/// This must be deinitialized by the caller.
///
/// 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 {
pub fn vtStream(self: *Terminal) Stream {
return .initAlloc(self.gpa(), self.vtHandler());
}
/// This is the handler-side only for vtStream.
pub fn vtHandler(self: *Terminal) ReadonlyHandler {
pub fn vtHandler(self: *Terminal) Stream.Handler {
return .init(self);
}
@@ -2877,6 +2886,19 @@ pub fn getPwd(self: *const Terminal) ?[]const u8 {
return self.pwd.items;
}
/// Set the title for the terminal, as set by escape sequences (e.g. OSC 0/2).
pub fn setTitle(self: *Terminal, t: []const u8) !void {
self.title.clearRetainingCapacity();
try self.title.appendSlice(self.gpa(), t);
}
/// Returns the title for the terminal, if any. The memory is owned by the
/// Terminal and is not copied. It is safe until a reset or setTitle.
pub fn getTitle(self: *const Terminal) ?[]const u8 {
if (self.title.items.len == 0) return null;
return self.title.items;
}
/// Switch to the given screen type (alternate or primary).
///
/// This does NOT handle behaviors such as clearing the screen,
@@ -3086,6 +3108,7 @@ pub fn fullReset(self: *Terminal) void {
self.tabstops.reset(TABSTOP_INTERVAL);
self.previous_char = null;
self.pwd.clearRetainingCapacity();
self.title.clearRetainingCapacity();
self.status_display = .main;
self.scrolling_region = .{
.top = 0,

View File

@@ -52,16 +52,6 @@ pub const RenditionAspect = enum(u16) {
_,
};
/// The device attribute request type (ESC [ c).
pub const DeviceAttributeReq = lib.Enum(
lib_target,
&.{
"primary", // Blank
"secondary", // >
"tertiary", // =
},
);
/// Possible cursor styles (ESC [ q)
pub const CursorStyle = lib.Enum(
lib_target,

View File

@@ -3,7 +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 Stream = @import("../stream_terminal.zig").Stream;
const ScreenSet = @import("../ScreenSet.zig");
const PageList = @import("../PageList.zig");
const kitty = @import("../kitty/key.zig");
@@ -23,7 +23,7 @@ const log = std.log.scoped(.terminal_c);
/// across multiple vt_write calls.
const TerminalWrapper = struct {
terminal: *ZigTerminal,
stream: ReadonlyStream,
stream: Stream,
};
/// C: GhosttyTerminal
@@ -68,17 +68,24 @@ fn new_(
return error.OutOfMemory;
errdefer alloc.destroy(t);
const wrapper = alloc.create(TerminalWrapper) catch
return error.OutOfMemory;
errdefer alloc.destroy(wrapper);
// Setup our terminal
t.* = try .init(alloc, .{
.cols = opts.cols,
.rows = opts.rows,
.max_scrollback = opts.max_scrollback,
});
errdefer t.deinit(alloc);
// Setup our stream
const handler: Stream.Handler = t.vtHandler();
const wrapper = alloc.create(TerminalWrapper) catch
return error.OutOfMemory;
wrapper.* = .{
.terminal = t,
.stream = t.vtStream(),
.stream = .initAlloc(alloc, handler),
};
return wrapper;

View File

@@ -0,0 +1,229 @@
const std = @import("std");
const testing = std.testing;
const build_options = @import("terminal_options");
const lib = @import("../lib/main.zig");
const lib_target: lib.Target = if (build_options.c_abi) .c else .zig;
/// The device attribute request type (CSI c).
pub const Req = lib.Enum(lib_target, &.{
"primary", // Blank
"secondary", // >
"tertiary", // =
});
/// Response data for all device attribute queries.
pub const Attributes = struct {
/// Reply to CSI c (DA1).
primary: Primary = .{},
/// Reply to CSI > c (DA2).
secondary: Secondary = .{},
/// Reply to CSI = c (DA3).
tertiary: Tertiary = .{},
/// Encode the response for the given request type into the writer.
pub fn encode(
self: Attributes,
req: Req,
writer: *std.Io.Writer,
) std.Io.Writer.Error!void {
switch (req) {
.primary => try self.primary.encode(writer),
.secondary => try self.secondary.encode(writer),
.tertiary => try self.tertiary.encode(writer),
}
}
};
/// Primary device attributes (DA1).
///
/// Response format: CSI ? Pp ; Ps... c
/// where Pp is the conformance level and Ps are feature flags.
pub const Primary = struct {
/// Conformance level sent as the first parameter.
conformance_level: ConformanceLevel = .vt220,
/// Optional feature attributes.
features: []const Feature = &.{.ansi_color},
/// DA1 feature attribute codes.
pub const Feature = enum(u16) {
columns_132 = 1,
printer = 2,
regis = 3,
sixel = 4,
selective_erase = 6,
user_defined_keys = 8,
national_replacement = 9,
technical_characters = 15,
locator = 16,
terminal_state = 17,
windowing = 18,
horizontal_scrolling = 21,
ansi_color = 22,
rectangular_editing = 28,
ansi_text_locator = 29,
clipboard = 52,
_,
};
/// Encode the primary DA response into the writer.
pub fn encode(self: Primary, writer: *std.Io.Writer) std.Io.Writer.Error!void {
try writer.print("\x1b[?{}", .{@intFromEnum(self.conformance_level)});
for (self.features) |feature| try writer.print(";{}", .{@intFromEnum(feature)});
try writer.writeAll("c");
}
};
/// Secondary device attributes (DA2).
///
/// Response format: CSI > Pp ; Pv ; Pc c
pub const Secondary = struct {
/// Terminal type identifier (Pp parameter from secondary DA response).
device_type: DeviceType = .vt220,
/// Firmware/patch version number.
firmware_version: u16 = 0,
/// ROM cartridge registration number. Always 0 for emulators.
rom_cartridge: u16 = 0,
/// Encode the secondary DA response into the writer.
pub fn encode(self: Secondary, writer: *std.Io.Writer) std.Io.Writer.Error!void {
try writer.print("\x1b[>{};{};{}c", .{
@intFromEnum(self.device_type),
self.firmware_version,
self.rom_cartridge,
});
}
};
/// Tertiary device attributes (DA3).
///
/// Response format: DCS ! | D...D ST
/// where D...D is the unit ID as hex digits (DECRPTUI).
pub const Tertiary = struct {
/// Unit ID (DECRPTUI). Encoded as 8 uppercase hex digits.
/// Meaningless for emulators nowadays. The actual DEC manuals
/// appear to split this into two 16-bit fields but since there
/// is no practical usage I know if I'm simplifying this.
unit_id: u32 = 0,
/// Encode the tertiary DA response into the writer.
pub fn encode(
self: Tertiary,
writer: *std.Io.Writer,
) std.Io.Writer.Error!void {
try writer.print(
"\x1bP!|{X:0>8}\x1b\\",
.{self.unit_id},
);
}
};
/// Conformance level reported as the first parameter (Pp) in the
/// primary device attributes (DA1) response.
pub const ConformanceLevel = enum(u16) {
// VT100-series have per-model values.
vt100 = 1,
vt132 = 4,
vt102 = 6,
vt131 = 7,
vt125 = 12,
// VT200+ use 60 + decTerminalID/100.
/// Level 2 conformance (VT200 series, e.g. VT220, VT240).
level_2 = 62,
/// Level 3 conformance (VT300 series, e.g. VT320, VT340).
level_3 = 63,
/// Level 4 conformance (VT400 series, e.g. VT420).
level_4 = 64,
/// Level 5 conformance (VT500 series, e.g. VT510, VT520, VT525).
level_5 = 65,
_,
pub const vt101 = ConformanceLevel.vt100;
pub const vt220 = ConformanceLevel.level_2;
pub const vt240 = ConformanceLevel.level_2;
pub const vt320 = ConformanceLevel.level_3;
pub const vt340 = ConformanceLevel.level_3;
pub const vt420 = ConformanceLevel.level_4;
pub const vt510 = ConformanceLevel.level_5;
pub const vt520 = ConformanceLevel.level_5;
pub const vt525 = ConformanceLevel.level_5;
};
/// Terminal type identifier reported as the Pp parameter in the
/// secondary device attributes (DA2) response. Values correspond
/// to the decTerminalID resource in xterm.
pub const DeviceType = enum(u16) {
vt100 = 0,
vt220 = 1,
vt240 = 2,
vt330 = 18,
vt340 = 19,
vt320 = 24,
vt382 = 32,
vt420 = 41,
vt510 = 61,
vt520 = 64,
vt525 = 65,
_,
};
test "primary default" {
var buf: [64]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try (Primary{}).encode(&writer);
try testing.expectEqualStrings("\x1b[?62;22c", writer.buffered());
}
test "primary with clipboard" {
var buf: [64]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try (Primary{ .features = &.{ .ansi_color, .clipboard } }).encode(&writer);
try testing.expectEqualStrings("\x1b[?62;22;52c", writer.buffered());
}
test "primary with multiple features" {
var buf: [64]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try (Primary{
.conformance_level = .vt420,
.features = &.{ .columns_132, .selective_erase, .ansi_color },
}).encode(&writer);
try testing.expectEqualStrings("\x1b[?64;1;6;22c", writer.buffered());
}
test "primary no features" {
var buf: [64]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try (Primary{
.conformance_level = .vt100,
.features = &.{},
}).encode(&writer);
try testing.expectEqualStrings("\x1b[?1c", writer.buffered());
}
test "secondary default" {
var buf: [64]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try (Secondary{}).encode(&writer);
try testing.expectEqualStrings("\x1b[>1;0;0c", writer.buffered());
}
test "tertiary default" {
var buf: [64]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try (Tertiary{}).encode(&writer);
try testing.expectEqualStrings("\x1bP!|00000000\x1b\\", writer.buffered());
}
test "tertiary custom unit id" {
var buf: [64]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try (Tertiary{ .unit_id = 0xAABBCCDD }).encode(&writer);
try testing.expectEqualStrings("\x1bP!|AABBCCDD\x1b\\", writer.buffered());
}

View File

@@ -1,5 +1,11 @@
const std = @import("std");
/// The color scheme reported in response to a CSI ? 996 n query.
pub const ColorScheme = enum {
light,
dark,
};
/// An enum(u16) of the available device status requests.
pub const Request = dsr_enum: {
const EnumField = std.builtin.Type.EnumField;

View File

@@ -3,13 +3,14 @@ const stream = @import("stream.zig");
const ansi = @import("ansi.zig");
const csi = @import("csi.zig");
const render = @import("render.zig");
const stream_readonly = @import("stream_readonly.zig");
const stream_terminal = @import("stream_terminal.zig");
const style = @import("style.zig");
pub const apc = @import("apc.zig");
pub const dcs = @import("dcs.zig");
pub const osc = @import("osc.zig");
pub const point = @import("point.zig");
pub const color = @import("color.zig");
pub const device_attributes = @import("device_attributes.zig");
pub const device_status = @import("device_status.zig");
pub const focus = @import("focus.zig");
pub const formatter = @import("formatter.zig");
@@ -42,8 +43,6 @@ pub const PageList = @import("PageList.zig");
pub const Parser = @import("Parser.zig");
pub const Pin = PageList.Pin;
pub const Point = point.Point;
pub const ReadonlyHandler = stream_readonly.Handler;
pub const ReadonlyStream = stream_readonly.Stream;
pub const RenderState = render.RenderState;
pub const Screen = @import("Screen.zig");
pub const ScreenSet = @import("ScreenSet.zig");
@@ -53,12 +52,13 @@ pub const SizeReportStyle = csi.SizeReportStyle;
pub const StringMap = @import("StringMap.zig");
pub const Style = style.Style;
pub const Terminal = @import("Terminal.zig");
pub const TerminalStream = stream_terminal.Stream;
pub const Stream = stream.Stream;
pub const StreamAction = stream.Action;
pub const Cursor = Screen.Cursor;
pub const CursorStyle = Screen.CursorStyle;
pub const CursorStyleReq = ansi.CursorStyle;
pub const DeviceAttributeReq = ansi.DeviceAttributeReq;
pub const DeviceAttributeReq = device_attributes.Req;
pub const Mode = modes.Mode;
pub const ModePacked = modes.ModePacked;
pub const ModifyKeyFormat = ansi.ModifyKeyFormat;

View File

@@ -9,6 +9,7 @@ const lib = @import("../lib/main.zig");
const Parser = @import("Parser.zig");
const ansi = @import("ansi.zig");
const charsets = @import("charsets.zig");
const device_attributes = @import("device_attributes.zig");
const device_status = @import("device_status.zig");
const csi = @import("csi.zig");
const kitty = @import("kitty.zig");
@@ -97,7 +98,7 @@ pub const Action = union(Key) {
title_push: u16,
title_pop: u16,
xtversion,
device_attributes: ansi.DeviceAttributeReq,
device_attributes: device_attributes.Req,
device_status: DeviceStatus,
kitty_keyboard_query,
kitty_keyboard_push: KittyKeyboardFlags,
@@ -419,11 +420,12 @@ pub const Action = union(Key) {
/// e.g. you don't need to pay a conditional branching cost on every single
/// action because the Zig compiler codegens separate code paths for every
/// single action at comptime.
pub fn Stream(comptime Handler: type) type {
pub fn Stream(comptime H: type) type {
return struct {
const Self = @This();
pub const Action = streampkg.Action;
pub const Handler = H;
const T = switch (@typeInfo(Handler)) {
.pointer => |p| p.child,
@@ -1281,7 +1283,7 @@ pub fn Stream(comptime Handler: type) type {
// c - Device Attributes (DA1)
'c' => {
const req: ?ansi.DeviceAttributeReq = switch (input.intermediates.len) {
const req: ?device_attributes.Req = switch (input.intermediates.len) {
0 => .primary,
1 => switch (input.intermediates[0]) {
'>' => .secondary,

View File

@@ -999,6 +999,12 @@ pub const StreamHandler = struct {
return;
}
// Set the title on the terminal state. We ignore any errors since
// we can continue to operate just fine without it.
self.terminal.setTitle(title) catch |err| {
log.warn("error setting title in terminal state: {}", .{err});
};
@memcpy(buf[0..title.len], title);
buf[title.len] = 0;

View File

@@ -2,7 +2,7 @@ const std = @import("std");
const ghostty_vt = @import("ghostty-vt");
const mem = @import("mem.zig");
const Terminal = ghostty_vt.Terminal;
const ReadonlyStream = ghostty_vt.ReadonlyStream;
const TerminalStream = ghostty_vt.TerminalStream;
/// Use a single global allocator for simplicity and to avoid heap
/// allocation overhead in the fuzzer. The allocator is backed by a fixed
@@ -33,7 +33,7 @@ pub export fn zig_fuzz_test(
}) catch return;
defer t.deinit(alloc);
var stream: ReadonlyStream = t.vtStream();
var stream: TerminalStream = t.vtStream();
defer stream.deinit();
// Use the first byte to decide between the scalar and slice paths