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.
This commit is contained in:
Mitchell Hashimoto
2026-07-09 10:11:07 -07:00
parent 6d5dda40db
commit 172f15da3b
11 changed files with 415 additions and 10 deletions

View File

@@ -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
```

View File

@@ -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);
}

View File

@@ -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",
},
}

View File

@@ -0,0 +1,72 @@
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <string.h>
#include <ghostty/vt.h>
//! [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;
}

View File

@@ -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.

View File

@@ -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.
*

View File

@@ -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" });

View File

@@ -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| {

View File

@@ -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)

View File

@@ -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;

View File

@@ -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(