From 172f15da3b904633f798d99cb6e43acd78cbdc79 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Jul 2026 10:11:07 -0700 Subject: [PATCH] terminal: expose compression through libghostty-vt Scrollback compression scheduling was only available to Zig callers that used Terminal directly, leaving C embedders unable to drive the same idle compression policy. Define ABI-aware mode and result enums on Terminal and export activity and compression operations through the C API. Keep scheduling caller-owned, validate C inputs, and document the incremental contract with a complete example. Report unsupported reclamation consistently for full passes so callers can disable compression on targets that cannot retain decommitted mappings. --- example/c-vt-compression/README.md | 17 ++++ example/c-vt-compression/build.zig | 32 ++++++ example/c-vt-compression/build.zig.zon | 14 +++ example/c-vt-compression/src/main.c | 72 +++++++++++++ include/ghostty/vt.h | 6 ++ include/ghostty/vt/terminal.h | 96 +++++++++++++++++ src/lib_vt.zig | 2 + src/terminal/PageList.zig | 17 ++-- src/terminal/Terminal.zig | 31 +++++- src/terminal/c/main.zig | 2 + src/terminal/c/terminal.zig | 136 +++++++++++++++++++++++++ 11 files changed, 415 insertions(+), 10 deletions(-) create mode 100644 example/c-vt-compression/README.md create mode 100644 example/c-vt-compression/build.zig create mode 100644 example/c-vt-compression/build.zig.zon create mode 100644 example/c-vt-compression/src/main.c diff --git a/example/c-vt-compression/README.md b/example/c-vt-compression/README.md new file mode 100644 index 000000000..a269faf2e --- /dev/null +++ b/example/c-vt-compression/README.md @@ -0,0 +1,17 @@ +# Example: Scrollback Compression in C + +This example shows how a libghostty-vt embedding application can track +compression-relevant terminal activity and perform incremental scrollback +compression after its own idle delay. + +libghostty-vt does not create a timer or background thread. The embedding +application remains responsible for scheduling compression and serializing it +with other access to the terminal. + +## Usage + +Run the example: + +```shell-session +zig build run +``` diff --git a/example/c-vt-compression/build.zig b/example/c-vt-compression/build.zig new file mode 100644 index 000000000..ab918ffc8 --- /dev/null +++ b/example/c-vt-compression/build.zig @@ -0,0 +1,32 @@ +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 exe_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + }); + exe_mod.addCSourceFiles(.{ + .root = b.path("src"), + .files = &.{"main.c"}, + }); + + if (b.lazyDependency("ghostty", .{})) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + const exe = b.addExecutable(.{ + .name = "c_vt_compression", + .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); +} diff --git a/example/c-vt-compression/build.zig.zon b/example/c-vt-compression/build.zig.zon new file mode 100644 index 000000000..d102fd67b --- /dev/null +++ b/example/c-vt-compression/build.zig.zon @@ -0,0 +1,14 @@ +.{ + .name = .c_vt_compression, + .version = "0.0.0", + .fingerprint = 0x3d527aa68a4cf8d, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + .ghostty = .{ .path = "../../" }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-compression/src/main.c b/example/c-vt-compression/src/main.c new file mode 100644 index 000000000..f3269b410 --- /dev/null +++ b/example/c-vt-compression/src/main.c @@ -0,0 +1,72 @@ +#include +#include +#include +#include +#include + +//! [compression-idle-step] +// Perform one step after the application's idle timer fires. Returning true +// asks the application to schedule another step while the terminal is idle. +static bool compression_idle_step(GhosttyTerminal terminal) { + GhosttyTerminalCompressionResult compression_result; + GhosttyResult result = ghostty_terminal_compress( + terminal, + GHOSTTY_TERMINAL_COMPRESSION_MODE_INCREMENTAL, + &compression_result); + assert(result == GHOSTTY_SUCCESS); + + switch (compression_result) { + case GHOSTTY_TERMINAL_COMPRESSION_RESULT_PENDING: + return true; + case GHOSTTY_TERMINAL_COMPRESSION_RESULT_COMPLETE: + case GHOSTTY_TERMINAL_COMPRESSION_RESULT_UNSUPPORTED: + return false; + default: + assert(false); + return false; + } +} +//! [compression-idle-step] + +int main(void) { + GhosttyTerminal terminal; + GhosttyTerminalOptions opts = { + .cols = 80, + .rows = 24, + .max_scrollback = 10 * 1024 * 1024, + }; + GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts); + assert(result == GHOSTTY_SUCCESS); + + //! [compression-activity] + uint64_t compression_activity; + result = ghostty_terminal_compression_activity( + terminal, + &compression_activity); + assert(result == GHOSTTY_SUCCESS); + + // Terminal mutations may change the token. When it changes, restart the + // application's idle timer rather than compressing on the output path. + const char *line = "repeated and compressible terminal history\r\n"; + for (size_t i = 0; i < 4000; i++) { + ghostty_terminal_vt_write( + terminal, + (const uint8_t *)line, + strlen(line)); + } + + uint64_t new_activity; + result = ghostty_terminal_compression_activity(terminal, &new_activity); + assert(result == GHOSTTY_SUCCESS); + if (new_activity != compression_activity) { + compression_activity = new_activity; + // Restart the application's compression idle timer here. + } + //! [compression-activity] + + // Simulate the idle timer and its short pending-work continuations. + while (compression_idle_step(terminal)) {} + + ghostty_terminal_free(terminal); + return 0; +} diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index b566c730a..5606f1690 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -56,6 +56,7 @@ * - @ref c-vt-formatter/src/main.c - Terminal formatter example * - @ref c-vt-grid-traverse/src/main.c - Grid traversal example using grid refs * - @ref c-vt-grid-ref-tracked/src/main.c - Tracked grid ref example + * - @ref c-vt-compression/src/main.c - Idle scrollback compression example * */ @@ -105,6 +106,11 @@ * detect when it loses its value, and move it to a new point. */ +/** @example c-vt-compression/src/main.c + * This example demonstrates how to schedule incremental scrollback compression + * after compression-relevant terminal activity becomes idle. + */ + /** @example c-vt-selection-gesture/src/main.c * This example demonstrates how to use synthetic selection gesture events to * derive drag and deep-press selection snapshots. diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index b03653129..b67e4c6de 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -40,6 +40,17 @@ extern "C" { * @snippet c-vt-stream/src/main.c vt-stream-init * @snippet c-vt-stream/src/main.c vt-stream-write * + * ## Scrollback Compression + * + * Scrollback compression is caller-driven. The terminal exposes an opaque + * activity token so an embedding application can restart an idle timer only + * when compression-relevant state changes. Once idle, call incremental + * compression until it no longer reports pending work. libghostty-vt does not + * create a timer or background thread. + * + * @snippet c-vt-compression/src/main.c compression-activity + * @snippet c-vt-compression/src/main.c compression-idle-step + * * ## Effects * * By default, the terminal sequence processing with ghostty_terminal_vt_write() @@ -177,6 +188,37 @@ typedef struct { // future options. } GhosttyTerminalOptions; +/** + * Amount of compression work to perform before returning. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Perform one bounded compression step suitable for idle scheduling. */ + GHOSTTY_TERMINAL_COMPRESSION_MODE_INCREMENTAL = 0, + + /** Synchronously inspect every currently eligible page. */ + GHOSTTY_TERMINAL_COMPRESSION_MODE_FULL = 1, + GHOSTTY_TERMINAL_COMPRESSION_MODE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalCompressionMode; + +/** + * Scheduling result from terminal compression. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Retained-mapping reclamation is unavailable on this target. */ + GHOSTTY_TERMINAL_COMPRESSION_RESULT_UNSUPPORTED = 0, + + /** More incremental compression work remains. */ + GHOSTTY_TERMINAL_COMPRESSION_RESULT_PENDING = 1, + + /** The pass has no continuation to schedule. */ + GHOSTTY_TERMINAL_COMPRESSION_RESULT_COMPLETE = 2, + GHOSTTY_TERMINAL_COMPRESSION_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalCompressionResult; + /** * Scroll viewport behavior tag. * @@ -1158,6 +1200,60 @@ GHOSTTY_API void ghostty_terminal_vt_write(GhosttyTerminal terminal, GHOSTTY_API void ghostty_terminal_scroll_viewport(GhosttyTerminal terminal, GhosttyTerminalScrollViewport behavior); +/** + * Return the current compression activity token. + * + * The token is opaque and only equality comparisons are meaningful. An + * embedding application should cache it and restart its compression idle + * delay whenever the value changes. The value may wrap and changes in either + * direction have the same meaning. + * + * This function only observes terminal state. It does not perform or schedule + * compression. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param[out] out_activity Receives the current activity token + * @return GHOSTTY_SUCCESS on success, or GHOSTTY_INVALID_VALUE if an argument + * is NULL + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_compression_activity( + GhosttyTerminal terminal, + uint64_t* out_activity); + +/** + * Compress eligible terminal scrollback. + * + * Incremental mode performs bounded work suitable for an idle callback. A + * pending result means the application should invoke another step while the + * terminal remains idle. A complete result means no continuation is needed + * until ghostty_terminal_compression_activity() changes. Full mode performs + * one synchronous scan and can stall on large scrollback buffers. + * + * Compression is opportunistic. Complete means the pass has finished, not + * that every page was compressed: pages may be unprofitable or encounter an + * allocation or reclamation failure. Compression changes only the terminal's + * storage representation and never its logical contents or scrollback limit. + * Accessing compressed history restores it transparently. + * + * This function is not thread-safe with other operations on the same + * terminal. The caller must serialize it with writes, rendering, searches, + * and other terminal access. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param mode The amount of compression work to perform + * @param[out] out_result Receives the compression scheduling result + * @return GHOSTTY_SUCCESS on success, or GHOSTTY_INVALID_VALUE if an argument + * or mode is invalid + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_compress( + GhosttyTerminal terminal, + GhosttyTerminalCompressionMode mode, + GhosttyTerminalCompressionResult* out_result); + /** * Get the current value of a terminal mode. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index ab13254df..e01cdbb89 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -261,6 +261,8 @@ comptime { @export(&c.terminal_set, .{ .name = "ghostty_terminal_set" }); @export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" }); @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" }); @export(&c.terminal_mode_get, .{ .name = "ghostty_terminal_mode_get" }); @export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" }); @export(&c.terminal_get, .{ .name = "ghostty_terminal_get" }); diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index c160f490e..167654d61 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -4005,9 +4005,9 @@ const compressPage_tw = tripwire.module( /// state. /// /// PageList tracks mutations which require a later incremental pass in its -/// compression state. Full compression always returns `complete`, indicating -/// that it has no continuation to schedule rather than that every page was -/// compressed. +/// compression state. On supported targets, full compression returns +/// `complete`, indicating that it has no continuation to schedule rather than +/// that every page was compressed. pub fn compress( self: *PageList, mode: enum { incremental, drain, full }, @@ -4020,6 +4020,13 @@ pub fn compress( .unsupported => break .unsupported, }, .full => full: { + // Match incremental mode's unsupported result. Full compression + // has no useful work to perform without strict reclamation. + if (!terminal_mem.canReclaim(.strict)) { + self.page_compression.reset(); + break :full .unsupported; + } + self.compressFull(); // Full compression has no continuation. Discard any partial @@ -4107,10 +4114,6 @@ fn compressIncremental(self: *PageList) IncrementalCompressionResult { /// Compress every fully historical resident page which is currently cold. fn compressFull(self: *PageList) void { - // Avoid the codec, scratch allocation, and exact encoded allocation on a - // target where strict retained-mapping reclamation cannot succeed. - if (!terminal_mem.canReclaim(.strict)) return; - var it: CompressionIterator = .init(self); while (it.next()) |node| { diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index dfe2a1536..b9363684d 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2314,6 +2314,25 @@ pub fn compressionActivity(self: *const Terminal) u64 { return @as(u64, state.activity_serial); } +/// The amount of compression work performed by `compress` before returning. +/// +/// The declaration order is part of the libghostty-vt C ABI. Removed values +/// must leave a `null` hole so later values retain their integer values. +pub const CompressionMode = lib.Enum(lib.target, &.{ + "incremental", + "full", +}); + +/// The scheduling result of a `compress` call. +/// +/// The declaration order is part of the libghostty-vt C ABI. Removed values +/// must leave a `null` hole so later values retain their integer values. +pub const CompressionResult = lib.Enum(lib.target, &.{ + "unsupported", + "pending", + "complete", +}); + /// Compress cold memory to save resident memory space. /// /// Full compression does a full pass compressing everything it can before @@ -2330,13 +2349,19 @@ pub fn compressionActivity(self: *const Terminal) u64 { /// experience, for example during idle times. pub fn compress( self: *Terminal, - mode: enum { incremental, full }, -) PageList.IncrementalCompressionResult { + mode: CompressionMode, +) CompressionResult { const pages = &self.screens.get(.primary).?.pages; - return switch (mode) { + const result = switch (mode) { .incremental => pages.compress(.incremental), .full => pages.compress(.full), }; + + return switch (result) { + .unsupported => .unsupported, + .pending => .pending, + .complete => .complete, + }; } /// To be called before shifting a row (as in insertLines and deleteLines) diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index ae91565e0..37dc57684 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -183,6 +183,8 @@ pub const terminal_resize = terminal.resize; pub const terminal_set = terminal.set; pub const terminal_vt_write = terminal.vt_write; pub const terminal_scroll_viewport = terminal.scroll_viewport; +pub const terminal_compression_activity = terminal.compression_activity; +pub const terminal_compress = terminal.compress; pub const terminal_mode_get = terminal.mode_get; pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index a2a75147f..439b2e001 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -223,6 +223,12 @@ const Effects = struct { /// C: GhosttyTerminal pub const Terminal = ?*TerminalWrapper; +/// C: GhosttyTerminalCompressionMode +pub const CompressionMode = ZigTerminal.CompressionMode; + +/// C: GhosttyTerminalCompressionResult +pub const CompressionResult = ZigTerminal.CompressionResult; + pub fn zigTerminal(terminal_: Terminal) ?*ZigTerminal { return (terminal_ orelse return null).terminal; } @@ -315,6 +321,30 @@ pub fn vt_write( wrapper.stream.nextSlice(ptr[0..len]); } +pub fn compression_activity( + terminal_: Terminal, + out_activity_: ?*u64, +) callconv(lib.calling_conv) Result { + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + const out_activity = out_activity_ orelse return .invalid_value; + out_activity.* = t.compressionActivity(); + return .success; +} + +pub fn compress( + terminal_: Terminal, + mode_: c_int, + out_result_: ?*CompressionResult, +) callconv(lib.calling_conv) Result { + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + const out_result = out_result_ orelse return .invalid_value; + const mode = std.meta.intToEnum(CompressionMode, mode_) catch + return .invalid_value; + + out_result.* = t.compress(mode); + return .success; +} + /// C: GhosttyTerminalOption pub const Option = enum(c_int) { userdata = 0, @@ -1099,6 +1129,112 @@ test "scroll_viewport null" { scroll_viewport(null, .{ .tag = .row, .value = .{ .row = 1 } }); } +test "compression invalid arguments" { + var activity: u64 = undefined; + var compression_result: CompressionResult = undefined; + + try testing.expectEqual( + Result.invalid_value, + compression_activity(null, &activity), + ); + try testing.expectEqual( + Result.invalid_value, + compress(null, @intFromEnum(CompressionMode.incremental), &compression_result), + ); + + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000_000, + }, + )); + defer free(t); + + try testing.expectEqual( + Result.invalid_value, + compression_activity(t, null), + ); + try testing.expectEqual( + Result.invalid_value, + compress(t, @intFromEnum(CompressionMode.incremental), null), + ); + try testing.expectEqual( + Result.invalid_value, + compress(t, -1, &compression_result), + ); +} + +test "compression activity and incremental scheduling" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000_000, + }, + )); + defer free(t); + + var initial_activity: u64 = undefined; + try testing.expectEqual( + Result.success, + compression_activity(t, &initial_activity), + ); + + const line = "repeated and compressible terminal history\r\n"; + const repeat = 4_000; + const input = try testing.allocator.alloc(u8, line.len * repeat); + defer testing.allocator.free(input); + for (0..repeat) |i| + @memcpy(input[i * line.len ..][0..line.len], line); + vt_write(t, input.ptr, input.len); + + var activity: u64 = undefined; + try testing.expectEqual( + Result.success, + compression_activity(t, &activity), + ); + try testing.expect(activity != initial_activity); + + var compression_result: CompressionResult = undefined; + for (0..1_000) |_| { + try testing.expectEqual( + Result.success, + compress( + t, + @intFromEnum(CompressionMode.incremental), + &compression_result, + ), + ); + + switch (compression_result) { + .pending => continue, + .complete => break, + .unsupported => unreachable, + } + } else return error.TestUnexpectedResult; + + // Compression changes storage representation, not the activity token. + var final_activity: u64 = undefined; + try testing.expectEqual( + Result.success, + compression_activity(t, &final_activity), + ); + try testing.expectEqual(activity, final_activity); + + try testing.expectEqual( + Result.success, + compress(t, @intFromEnum(CompressionMode.full), &compression_result), + ); + try testing.expectEqual(CompressionResult.complete, compression_result); +} + test "reset" { var t: Terminal = null; try testing.expectEqual(Result.success, new(