libghostty: functions to detect and write until stream ground state

This adds new functions to both C and Zig to write VT data until the
VT parser reaches a "ground" state. The ground state is when the
parser/stream is stateless: between all partial UTF-8, OSC, CSI, etc.

This lets embedders safely interleave custom VT sequences from multiple
sources. A practical example is a standard terminal reading from a pty
that is then doing custom APC or something mid-stream for their emulator
client.
This commit is contained in:
Mitchell Hashimoto
2026-08-12 06:24:34 -07:00
parent 396166ecbe
commit a69a591af1
5 changed files with 480 additions and 21 deletions

View File

@@ -54,17 +54,17 @@ extern "C" {
*
* ## Effects
*
* By default, the terminal sequence processing with ghostty_terminal_vt_write()
* only process sequences that directly affect terminal state and
* By default, terminal sequence processing with the VT write functions only
* processes sequences that directly affect terminal state and
* ignores sequences that have side effect behavior or require responses.
* These sequences include things like bell characters, title changes, device
* attributes queries, and more. To handle these sequences, the embedder
* must configure "effects."
*
* Effects are callbacks that the terminal invokes in response to VT
* sequences processed during ghostty_terminal_vt_write(). They let the
* embedding application react to terminal-initiated events such as bell
* characters, title changes, device status report responses, and more.
* sequences processed during VT writes. They let the embedding application
* react to terminal-initiated events such as bell characters, title changes,
* device status report responses, and more.
*
* Each effect is registered with ghostty_terminal_set() using the
* corresponding `GhosttyTerminalOption` identifier. A `NULL` value
@@ -75,9 +75,10 @@ extern "C" {
* back to their own application state without global variables.
* You cannot specify different userdata for different callbacks.
*
* All callbacks are invoked synchronously during
* ghostty_terminal_vt_write(). Callbacks **must not** call
* ghostty_terminal_vt_write() on the same terminal (no reentrancy).
* All callbacks are invoked synchronously during VT writes. Callbacks
* **must not** call ghostty_terminal_vt_write() or
* ghostty_terminal_vt_write_until_ground() on the same terminal
* (no reentrancy).
* And callbacks must be very careful to not block for too long or perform
* expensive operations, since they are blocking further IO processing.
*
@@ -1138,8 +1139,8 @@ typedef enum GHOSTTY_ENUM_TYPED {
*
* Continuation bytes reconstruct an escape sequence or UTF-8 codepoint
* which was unfinished at the end of the most recent
* ghostty_terminal_vt_write() call. They are used automatically by terminal
* snapshots and may also be exported directly with the continuation APIs.
* VT write call. They are used automatically by terminal snapshots and may
* also be exported directly with the continuation APIs.
*
* Tracking is disabled by default. A nonzero value enables tracking and
* sets its byte limit. Passing NULL or a pointer to zero disables tracking.
@@ -1337,9 +1338,9 @@ typedef enum GHOSTTY_ENUM_TYPED {
/**
* The terminal title as set by escape sequences (e.g. OSC 0/2).
*
* Returns a borrowed string. The pointer is valid until the next call
* to ghostty_terminal_vt_write() or ghostty_terminal_reset(). An empty
* string (len=0) is returned when no title has been set.
* Returns a borrowed string. The pointer is valid until the next mutating
* terminal call. An empty string (len=0) is returned when no title has been
* set.
*
* Output type: GhosttyString *
*/
@@ -1349,9 +1350,9 @@ typedef enum GHOSTTY_ENUM_TYPED {
* The terminal's current working directory as set by escape sequences
* (e.g. OSC 7).
*
* Returns a borrowed string. The pointer is valid until the next call
* to ghostty_terminal_vt_write() or ghostty_terminal_reset(). An empty
* string (len=0) is returned when no pwd has been set.
* Returns a borrowed string. The pointer is valid until the next mutating
* terminal call. An empty string (len=0) is returned when no pwd has been
* set.
*
* Output type: GhosttyString *
*/
@@ -1597,6 +1598,21 @@ typedef enum GHOSTTY_ENUM_TYPED {
* Input/output type: GhosttyTerminalModeConfig *
*/
GHOSTTY_TERMINAL_DATA_MODE = 37,
/**
* Whether VT processing is at ground.
*
* Ground is when the stream isn't in the middle of any type of sequence:
* UTF-8, ESC, CSI, OSC, etc. It is the stateless point of the stream.
*
* This is useful to know because it is a point at which you can
* safely insert out-of-band VT sequences. For example, while reading
* from a pty if you want to make your own changes, you can wait until
* the pty input reaches ground, then write yours.
*
* Output type: bool *
*/
GHOSTTY_TERMINAL_DATA_VT_GROUND = 38,
GHOSTTY_TERMINAL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyTerminalData;
@@ -1682,9 +1698,10 @@ GHOSTTY_API GhosttyResult ghostty_terminal_resize(GhosttyTerminal terminal,
* The behavior of a NULL value is specific to each option and is
* documented by the corresponding GhosttyTerminalOption value.
*
* Callbacks are invoked synchronously during ghostty_terminal_vt_write().
* Callbacks must not call ghostty_terminal_vt_write() on the same
* terminal (no reentrancy).
* Callbacks are invoked synchronously during VT writes. Callbacks must not
* call ghostty_terminal_vt_write() or
* ghostty_terminal_vt_write_until_ground() on the same terminal
* (no reentrancy).
*
* @param terminal The terminal handle (may be NULL, in which case this is a no-op)
* @param option The option to set
@@ -1722,6 +1739,38 @@ GHOSTTY_API void ghostty_terminal_vt_write(GhosttyTerminal terminal,
const uint8_t* data,
size_t len);
/**
* Write VT-encoded data, but only the shortest prefix needed to reach ground.
*
* Ground is when the stream isn't in the middle of any type of sequence:
* UTF-8, ESC, CSI, OSC, etc. It is the stateless point of the stream.
*
* This is useful to know because it is a point at which you can
* safely insert out-of-band VT sequences. For example, while reading
* from a pty if you want to make your own changes, you can wait until
* the pty input reaches ground, then write yours.
*
* If the stream is already at ground then this consumes nothing and returns
* GHOSTTY_SUCCESS. On success, out_consumed is the number of bytes consumed
* before reaching ground, including the byte that reaches it.
* GHOSTTY_NO_VALUE means the full slice was consumed without reaching ground.
*
* @param terminal The terminal handle (must not be NULL)
* @param data Pointer to the data to write, or NULL when len is zero
* @param len Length of the data in bytes
* @param[out] out_consumed Number of bytes consumed (must not be NULL)
* @return GHOSTTY_SUCCESS if ground was reached, GHOSTTY_NO_VALUE if all input
* was consumed without reaching ground, or GHOSTTY_INVALID_VALUE if
* an argument is invalid
*
* @ingroup terminal
*/
GHOSTTY_API GhosttyResult ghostty_terminal_vt_write_until_ground(
GhosttyTerminal terminal,
const uint8_t* data,
size_t len,
size_t* out_consumed);
/**
* Write the terminal's replay-safe VT continuation to a callback writer.
*
@@ -1735,8 +1784,8 @@ GHOSTTY_API void ghostty_terminal_vt_write(GhosttyTerminal terminal,
* GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to a nonzero value before the
* input that produced the continuation was written.
*
* The caller must serialize this operation with ghostty_terminal_vt_write()
* and all other access to the same terminal.
* The caller must serialize this operation with both VT write functions and
* all other access to the same terminal.
*
* @param terminal Terminal to read from (must not be NULL)
* @param writer Destination writer whose write callback must not be NULL

View File

@@ -294,6 +294,7 @@ comptime {
@export(&c.terminal_resize, .{ .name = "ghostty_terminal_resize" });
@export(&c.terminal_set, .{ .name = "ghostty_terminal_set" });
@export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" });
@export(&c.terminal_vt_write_until_ground, .{ .name = "ghostty_terminal_vt_write_until_ground" });
@export(&c.terminal_scroll_viewport, .{ .name = "ghostty_terminal_scroll_viewport" });
@export(&c.terminal_compression_activity, .{ .name = "ghostty_terminal_compression_activity" });
@export(&c.terminal_compress, .{ .name = "ghostty_terminal_compress" });

View File

@@ -184,6 +184,7 @@ pub const terminal_reset = terminal.reset;
pub const terminal_resize = terminal.resize;
pub const terminal_set = terminal.set;
pub const terminal_vt_write = terminal.vt_write;
pub const terminal_vt_write_until_ground = terminal.vt_write_until_ground;
pub const terminal_scroll_viewport = terminal.scroll_viewport;
pub const terminal_compression_activity = terminal.compression_activity;
pub const terminal_compress = terminal.compress;

View File

@@ -697,6 +697,32 @@ pub fn vt_write(
wrapper.stream.nextSlice(ptr[0..len]);
}
pub fn vt_write_until_ground(
terminal_: Terminal,
ptr_: ?[*]const u8,
len: usize,
out_consumed_: ?*usize,
) callconv(lib.calling_conv) Result {
const out_consumed = out_consumed_ orelse return .invalid_value;
out_consumed.* = 0;
const wrapper = terminal_ orelse return .invalid_value;
const input: []const u8 = if (ptr_) |ptr|
ptr[0..len]
else if (len == 0)
""
else
return .invalid_value;
if (wrapper.stream.nextSliceUntilGround(input)) |consumed| {
out_consumed.* = consumed;
return .success;
}
out_consumed.* = len;
return .no_value;
}
pub const ContinuationWriteError = error{
InvalidValue,
WriteFailed,
@@ -1328,6 +1354,7 @@ pub const TerminalData = enum(c_int) {
scrollback_max_lines = 35,
continuation_max_bytes = 36,
mode = 37,
vt_ground = 38,
/// Output type expected for querying the data of the given kind.
pub fn OutType(comptime self: TerminalData) type {
@@ -1339,6 +1366,7 @@ pub const TerminalData = enum(c_int) {
.mouse_tracking,
.viewport_active,
.vt_processing_error,
.vt_ground,
=> bool,
.active_screen => TerminalScreen,
.kitty_keyboard_flags => u8,
@@ -1489,6 +1517,7 @@ fn getTyped(
),
.viewport_active => out.* = t.screens.active.pages.viewport == .active,
.vt_processing_error => out.* = wrapper.stream.handler.semantic_failure,
.vt_ground => out.* = wrapper.stream.ground(),
.scrollback_max_bytes => {
const max = t.screens.get(.primary).?.pages.limits.bytes.explicit;
if (max == std.math.maxInt(usize)) return .no_value;
@@ -2498,6 +2527,189 @@ test "vt_write" {
try testing.expectEqualStrings("Hello", str);
}
test "vt_write_until_ground result contract" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
// Input remains untouched when the stream is already at ground.
var consumed: usize = 99;
const untouched = "unprocessed";
try testing.expectEqual(
Result.success,
vt_write_until_ground(t, untouched, untouched.len, &consumed),
);
try testing.expectEqual(@as(usize, 0), consumed);
// Complete a split CSI and stop before inspecting the printable suffix.
vt_write(t, "\x1b[31", 4);
const input = "mABC\x1b[";
try testing.expectEqual(
Result.success,
vt_write_until_ground(t, input, input.len, &consumed),
);
try testing.expectEqual(@as(usize, 1), consumed);
try testing.expect(t.?.stream.ground());
var str = try t.?.terminal.plainString(testing.allocator);
try testing.expectEqualStrings("", str);
testing.allocator.free(str);
// The untouched suffix can be processed after work at the boundary.
vt_write(t, input.ptr + consumed, input.len - consumed);
str = try t.?.terminal.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("ABC", str);
try testing.expect(!t.?.stream.ground());
// Exhausting input while unfinished is distinct from reaching ground on
// the final byte, even though both consume the entire input.
try testing.expectEqual(
Result.no_value,
vt_write_until_ground(t, "123", 3, &consumed),
);
try testing.expectEqual(@as(usize, 3), consumed);
try testing.expect(!t.?.stream.ground());
try testing.expectEqual(
Result.success,
vt_write_until_ground(t, "m", 1, &consumed),
);
try testing.expectEqual(@as(usize, 1), consumed);
try testing.expect(t.?.stream.ground());
}
test "vt_write_until_ground handles UTF-8 and abort boundaries" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
var ground: bool = false;
try testing.expectEqual(
Result.success,
get(t, .vt_ground, @ptrCast(&ground)),
);
try testing.expect(ground);
vt_write(t, &.{0xF0}, 1);
try testing.expectEqual(
Result.success,
get(t, .vt_ground, @ptrCast(&ground)),
);
try testing.expect(!ground);
const utf8_suffix = [_]u8{ 0x9F, 0x98, 0x84 };
var consumed: usize = 99;
try testing.expectEqual(
Result.success,
vt_write_until_ground(t, &utf8_suffix, utf8_suffix.len, &consumed),
);
try testing.expectEqual(utf8_suffix.len, consumed);
try testing.expect(t.?.stream.ground());
// A malformed continuation resets the decoder and reaches ground after
// processing the retry byte.
vt_write(t, &.{ 0xE0, 0xA0 }, 2);
try testing.expectEqual(
Result.success,
vt_write_until_ground(t, "A", 1, &consumed),
);
try testing.expectEqual(@as(usize, 1), consumed);
try testing.expect(t.?.stream.ground());
vt_write(t, "\x1b[123", 5);
try testing.expectEqual(
Result.success,
vt_write_until_ground(t, &.{0x18}, 1, &consumed),
);
try testing.expectEqual(@as(usize, 1), consumed);
try testing.expect(t.?.stream.ground());
}
test "vt_write_until_ground invokes effects only for consumed input" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var bell_count: usize = 0;
fn bell(_: Terminal, _: ?*anyopaque) callconv(lib.calling_conv) void {
bell_count += 1;
}
};
S.bell_count = 0;
try testing.expectEqual(Result.success, set(t, .bell, @ptrCast(&S.bell)));
vt_write(t, "\x1b[31", 4);
const input = "m\x07";
var consumed: usize = 99;
try testing.expectEqual(
Result.success,
vt_write_until_ground(t, input, input.len, &consumed),
);
try testing.expectEqual(@as(usize, 1), consumed);
try testing.expectEqual(@as(usize, 0), S.bell_count);
vt_write(t, input.ptr + consumed, input.len - consumed);
try testing.expectEqual(@as(usize, 1), S.bell_count);
}
test "vt_write_until_ground validates arguments" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
var consumed: usize = 99;
try testing.expectEqual(
Result.invalid_value,
vt_write_until_ground(null, "x", 1, &consumed),
);
try testing.expectEqual(@as(usize, 0), consumed);
consumed = 99;
try testing.expectEqual(
Result.invalid_value,
vt_write_until_ground(t, null, 1, &consumed),
);
try testing.expectEqual(@as(usize, 0), consumed);
try testing.expectEqual(
Result.invalid_value,
vt_write_until_ground(t, "", 0, null),
);
// NULL represents a valid empty slice. While unfinished it consumes zero
// bytes and reports that no ground boundary was found.
vt_write(t, "\x1b[", 2);
consumed = 99;
try testing.expectEqual(
Result.no_value,
vt_write_until_ground(t, null, 0, &consumed),
);
try testing.expectEqual(@as(usize, 0), consumed);
}
test "vt_write split escape sequence" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(

View File

@@ -595,6 +595,56 @@ pub fn Stream(comptime H: type) type {
if (self.continuation != null) self.trackContinuation(input);
}
/// Process a string of characters, but only the shortest prefix
/// needed to reach the ground state.
///
/// The ground state is when the stream isn't in the middle of any
/// type of sequence: UTF-8, ESC, CSI, OSC, etc. It is the stateless
/// point of the stream.
///
/// If the stream is already at ground then this consumes nothing
/// and returns zero. A non-null return is the number of bytes consumed
/// before reaching ground, including the byte that reaches it. A null
/// return means the full slice was consumed without reaching ground.
///
/// This is anywhere from 1% to 5% slower than nextSlice, depending
/// on the types of inputs provided (e.g. OSC vs APC, whether it
/// ever reaches ground, etc.).
pub inline fn nextSliceUntilGround(
self: *Self,
input: []const u8,
) ?usize {
const consumed = self.nextSliceUntilGroundUntracked(input);
const consumed_len = consumed orelse input.len;
if (self.continuation != null and consumed_len > 0) {
self.trackContinuation(input[0..consumed_len]);
}
return consumed;
}
inline fn nextSliceUntilGroundUntracked(
self: *Self,
input: []const u8,
) ?usize {
if (self.ground()) return 0;
// Process UTF-8 if we're within that state.
var offset: usize = 0;
while (self.utf8decoder.state != 0) {
if (offset >= input.len) return null;
self.nextUtf8(input[offset]);
offset += 1;
}
// Process non-UTF-8
if (self.parser.state != .ground) {
offset += self.consumeUntilGround(input[offset..]);
}
return if (self.ground()) offset else null;
}
inline fn nextSliceUntracked(self: *Self, input: []const u8) void {
// Disable SIMD optimizations if build requests it or if our
// manual debug mode is on.
@@ -4161,6 +4211,152 @@ const ContinuationNullHandler = struct {
) void {}
};
test "stream: nextSliceUntilGround stops at the earliest boundary" {
const S = Stream(ContinuationTestHandler);
var stream: S = .init(.{ .handler = .{} });
defer stream.deinit();
stream.nextSlice("\x1b[31");
try testing.expect(!stream.ground());
stream.handler.committed = 0;
const input = "mABC\x1b[";
const consumed = stream.nextSliceUntilGround(input).?;
try testing.expectEqual(@as(usize, 1), consumed);
try testing.expect(stream.ground());
try testing.expectEqual(@as(usize, 1), stream.handler.committed);
// The suffix was not inspected by the handler and can be processed after
// the caller performs work at the boundary.
stream.nextSlice(input[consumed..]);
try testing.expect(!stream.ground());
try testing.expectEqual(Parser.State.csi_entry, stream.parser.state);
try testing.expectEqual(@as(usize, 4), stream.handler.committed);
}
test "stream: nextSliceUntilGround consumes all input without a boundary" {
const S = Stream(ContinuationNullHandler);
var stream: S = .init(.{ .handler = .{} });
defer stream.deinit();
try testing.expectEqual(
@as(?usize, 0),
stream.nextSliceUntilGround("unprocessed"),
);
try testing.expect(stream.ground());
stream.nextSlice("\x1b[");
try testing.expectEqual(
@as(?usize, null),
stream.nextSliceUntilGround("123"),
);
try testing.expect(!stream.ground());
// A boundary on the final byte is distinguishable from exhausting the
// input while still pending.
try testing.expectEqual(
@as(?usize, 1),
stream.nextSliceUntilGround("m"),
);
try testing.expect(stream.ground());
}
test "stream: nextSliceUntilGround handles UTF-8 boundaries" {
const S = Stream(ContinuationTestHandler);
// A completed codepoint is committed synchronously, and the printable
// suffix remains untouched.
var valid: S = .init(.{ .handler = .{} });
defer valid.deinit();
valid.nextSlice(&.{0xF0});
const valid_input = [_]u8{ 0x9F, 0x98, 0x84, 'X' };
try testing.expectEqual(
@as(?usize, 3),
valid.nextSliceUntilGround(&valid_input),
);
try testing.expect(valid.ground());
try testing.expectEqual(@as(usize, 1), valid.handler.committed);
// A malformed continuation emits the replacement codepoint and retries
// the same byte. Ground is observed after that complete byte operation.
var malformed: S = .init(.{ .handler = .{} });
defer malformed.deinit();
malformed.nextSlice(&.{ 0xE0, 0xA0 });
try testing.expectEqual(
@as(?usize, 1),
malformed.nextSliceUntilGround("A!"),
);
try testing.expect(malformed.ground());
try testing.expectEqual(@as(usize, 2), malformed.handler.committed);
// If the retried byte is ESC, the stream has begun VT state at the end of
// that byte and must continue to the following ground boundary.
var retry_escape: S = .init(.{ .handler = .{} });
defer retry_escape.deinit();
retry_escape.nextSlice(&.{ 0xE0, 0xA0 });
try testing.expectEqual(
@as(?usize, 3),
retry_escape.nextSliceUntilGround("\x1b[mX"),
);
try testing.expect(retry_escape.ground());
}
test "stream: nextSliceUntilGround handles aborts and bulk strings" {
const S = Stream(ContinuationTestHandler);
var aborted: S = .init(.{ .handler = .{} });
defer aborted.deinit();
aborted.nextSlice("\x1b[123");
try testing.expectEqual(
@as(?usize, 1),
aborted.nextSliceUntilGround(&.{ 0x18, 'X' }),
);
try testing.expect(aborted.ground());
var apc: S = .init(.{ .handler = .{} });
defer apc.deinit();
apc.nextSlice("\x1b_Gseed");
var input: [131]u8 = undefined;
@memset(input[0..128], 'a');
input[128..130].* = "\x1b\\".*;
input[130] = 'X';
try testing.expectEqual(
@as(?usize, 130),
apc.nextSliceUntilGround(&input),
);
try testing.expect(apc.ground());
}
test "stream: nextSliceUntilGround tracks only the consumed prefix" {
const S = Stream(ContinuationNullHandler);
var stream: S = .init(.{
.allocator = testing.allocator,
.handler = .{},
.continuation_max_bytes = 64,
});
defer stream.deinit();
stream.nextSlice("\x1b[31");
try testing.expectEqual(
@as(?usize, 1),
stream.nextSliceUntilGround("mX\x1b["),
);
var buf: [64]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try stream.writeContinuation(&writer);
try testing.expectEqual(@as(usize, 0), writer.buffered().len);
stream.nextSlice("\x1b[");
try testing.expectEqual(
@as(?usize, null),
stream.nextSliceUntilGround("123"),
);
var pending_writer: std.Io.Writer = .fixed(&buf);
try stream.writeContinuation(&pending_writer);
try testing.expectEqualStrings("\x1b[123", pending_writer.buffered());
}
test "stream: continuation lifecycle" {
const S = Stream(ContinuationTestHandler);