diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 147410678..31cd5c4f1 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -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 diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 5b78b8cbe..66da515a8 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -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" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index d6f9e6656..3615984cc 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -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; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 92336ef53..37cd1406b 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -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( diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 1d0341ed7..729b6fc04 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -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);