libghostty-vt: add C API for snapshotting functions

Expose terminal snapshot through the libghostty-vt C API and add
a new C example that runs in CI to verify this stuff works!

## Example

```c
size_t continuation_limit = 1024;
assert(ghostty_terminal_set(
    terminal,
    GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES,
    &continuation_limit) == GHOSTTY_SUCCESS);

uint8_t *bytes = NULL;
size_t len = 0;
assert(ghostty_snapshot_encode_alloc(
    terminal, NULL, &bytes, &len) == GHOSTTY_SUCCESS);

GhosttySnapshotDecoder decoder = NULL;
assert(ghostty_snapshot_decoder_new_buf(
    NULL, &decoder, bytes, len) == GHOSTTY_SUCCESS);

GhosttyTerminal restored = NULL;
assert(ghostty_snapshot_decoder_decode(
    decoder, &restored) == GHOSTTY_SUCCESS);

ghostty_snapshot_decoder_free(decoder);
ghostty_free(NULL, bytes, len);
```

Streaming decode:

```c
GhosttyReader reader = {
    .read = read_snapshot,
    .userdata = source,
};
GhosttySnapshotDecoder decoder = NULL;
assert(ghostty_snapshot_decoder_new(
    NULL, &decoder, reader) == GHOSTTY_SUCCESS);

GhosttyTerminal terminal = NULL;
assert(ghostty_snapshot_decoder_ready(
    decoder, &terminal) == GHOSTTY_SUCCESS);

GhosttyResult result;
while ((result = ghostty_snapshot_decoder_next(decoder)) ==
       GHOSTTY_SUCCESS) {
  size_t rows = 0;
  assert(ghostty_snapshot_decoder_get(
      decoder,
      GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_ROWS,
      &rows) == GHOSTTY_SUCCESS);
  render(terminal);
}
assert(result == GHOSTTY_NO_VALUE);
```
This commit is contained in:
Mitchell Hashimoto
2026-08-03 12:38:08 -07:00
parent 7d748097a0
commit d7bb4b8639
19 changed files with 3652 additions and 62 deletions

View File

@@ -0,0 +1,15 @@
# Example: Terminal Snapshots in C
This example creates a terminal with continuation tracking, encodes its full
state, and restores the snapshot using both the one-shot and incremental C
decoder APIs. The incremental path uses a synchronous `GhosttyReader` callback
and reports each restored history page. The standalone project links the static
libghostty-vt artifact so it can run consistently on every supported host.
## Usage
Run the example:
```shell-session
zig build run
```

View File

@@ -0,0 +1,33 @@
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"},
});
exe_mod.addCMacro("GHOSTTY_STATIC", "");
if (b.lazyDependency("ghostty", .{})) |dep| {
exe_mod.linkLibrary(dep.artifact("ghostty-vt-static"));
}
const exe = b.addExecutable(.{
.name = "c_vt_snapshot",
.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_snapshot,
.version = "0.0.0",
.fingerprint = 0xff13ccc637fd5383,
.minimum_zig_version = "0.15.1",
.dependencies = .{
.ghostty = .{ .path = "../../" },
},
.paths = .{
"build.zig",
"build.zig.zon",
"src",
},
}

View File

@@ -0,0 +1,162 @@
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <ghostty/vt.h>
//! [snapshot-buffer-reader]
typedef struct {
const uint8_t *data;
size_t len;
size_t offset;
} BufferReader;
// GhosttyReader callbacks are synchronous. A successful zero-byte read is
// permanent EOF; returning false would report an I/O error.
static bool buffer_read(void *userdata,
uint8_t *buffer,
size_t capacity,
size_t *out_read) {
BufferReader *reader = userdata;
size_t remaining = reader->len - reader->offset;
size_t count = remaining < capacity ? remaining : capacity;
// Deliberately return short reads to demonstrate that the decoder retries.
if (count > 64) count = 64;
memcpy(buffer, reader->data + reader->offset, count);
reader->offset += count;
*out_read = count;
return true;
}
//! [snapshot-buffer-reader]
int main(void) {
GhosttyResult result;
//! [snapshot-encode]
GhosttyTerminal source = NULL;
// A wide, shallow screen fills backing pages quickly enough to leave older
// PAGE records after READY for the incremental decoder to demonstrate.
result = ghostty_terminal_new(NULL, &source, 215, 2);
assert(result == GHOSTTY_SUCCESS);
// Snapshot encoding requires continuation tracking to be enabled before
// feeding input. The limit bounds unfinished VT sequence retention.
const size_t continuation_limit = 1024;
result = ghostty_terminal_set(
source,
GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES,
&continuation_limit);
assert(result == GHOSTTY_SUCCESS);
// Keep enough scrollback to demonstrate incremental history restoration.
result = ghostty_terminal_set(
source, GHOSTTY_TERMINAL_OPT_SCROLLBACK_MAX_BYTES, NULL);
assert(result == GHOSTTY_SUCCESS);
const char *line = "snapshot history line\r\n";
for (size_t i = 0; i < 1000; i++) {
ghostty_terminal_vt_write(
source, (const uint8_t *)line, strlen(line));
}
// Leave an SGR sequence unfinished so its continuation is snapshotted too.
const char *unfinished = "\x1b[31";
ghostty_terminal_vt_write(
source, (const uint8_t *)unfinished, strlen(unfinished));
uint8_t *snapshot = NULL;
size_t snapshot_len = 0;
result = ghostty_snapshot_encode_alloc(
source, NULL, &snapshot, &snapshot_len);
assert(result == GHOSTTY_SUCCESS);
printf("encoded %zu snapshot bytes\n", snapshot_len);
//! [snapshot-encode]
//! [snapshot-decode]
GhosttySnapshotDecoder full_decoder = NULL;
result = ghostty_snapshot_decoder_new_buf(
NULL, &full_decoder, snapshot, snapshot_len);
assert(result == GHOSTTY_SUCCESS);
GhosttyTerminal full_terminal = NULL;
result = ghostty_snapshot_decoder_decode(full_decoder, &full_terminal);
assert(result == GHOSTTY_SUCCESS);
ghostty_snapshot_decoder_free(full_decoder);
ghostty_terminal_free(full_terminal);
//! [snapshot-decode]
//! [snapshot-incremental]
BufferReader reader_state = {
.data = snapshot,
.len = snapshot_len,
.offset = 0,
};
GhosttyReader reader = {
.read = buffer_read,
.userdata = &reader_state,
};
GhosttySnapshotDecoder incremental_decoder = NULL;
result = ghostty_snapshot_decoder_new(
NULL, &incremental_decoder, reader);
assert(result == GHOSTTY_SUCCESS);
// READY authenticates and returns a renderable terminal before old history.
GhosttyTerminal incremental_terminal = NULL;
result = ghostty_snapshot_decoder_ready(
incremental_decoder, &incremental_terminal);
assert(result == GHOSTTY_SUCCESS);
uint64_t history_rows = 0;
result = ghostty_snapshot_decoder_get(
incremental_decoder,
GHOSTTY_SNAPSHOT_DECODER_DATA_HISTORY_ROWS_PRIMARY,
&history_rows);
assert(result == GHOSTTY_SUCCESS);
printf("snapshot advertises %llu primary history rows\n",
(unsigned long long)history_rows);
size_t page_count = 0;
while ((result = ghostty_snapshot_decoder_next(incremental_decoder)) ==
GHOSTTY_SUCCESS) {
GhosttyTerminalScreen screen;
size_t rows = 0;
uint32_t remaining = 0;
const GhosttySnapshotDecoderData keys[] = {
GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_SCREEN,
GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_ROWS,
GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_REMAINING,
};
void *values[] = {&screen, &rows, &remaining};
size_t written = 0;
result = ghostty_snapshot_decoder_get_multi(
incremental_decoder,
sizeof(keys) / sizeof(keys[0]),
keys,
values,
&written);
assert(result == GHOSTTY_SUCCESS);
assert(written == sizeof(keys) / sizeof(keys[0]));
printf("restored %zu rows to screen %d (%u pages remain)\n",
rows, (int)screen, remaining);
page_count++;
}
// NO_VALUE means FINISH authenticated successfully and is idempotent.
assert(result == GHOSTTY_NO_VALUE);
assert(page_count > 0);
assert(ghostty_snapshot_decoder_next(incremental_decoder) ==
GHOSTTY_NO_VALUE);
ghostty_snapshot_decoder_free(incremental_decoder);
ghostty_terminal_free(incremental_terminal);
//! [snapshot-incremental]
ghostty_free(NULL, snapshot, snapshot_len);
ghostty_terminal_free(source);
return 0;
}