diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 45dad39..ee5aaaa 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -155,6 +155,13 @@ jobs:
timeStyle: 'short',
timeZone: 'UTC',
}).format(new Date(timestamp))} UTC`;
+ const formatDate = (timestamp) =>
+ new Intl.DateTimeFormat('en-GB', {
+ dateStyle: 'medium',
+ timeZone: 'UTC',
+ }).format(new Date(timestamp));
+ const buildDate = formatDate(run.created_at);
+ const buildDateMarkup = `${buildDate}`;
const commitShortSha = commitSha.slice(0, 7);
const artifacts = [
{
@@ -175,7 +182,7 @@ jobs:
const body = [
marker,
- '## 📦 PR builds',
+ `## 📦 PR builds · ${buildDateMarkup}`,
'',
'| Platform | Download |',
'| --- | --- |',
diff --git a/.github/workflows/comment-artifacts.yml b/.github/workflows/comment-artifacts.yml
index d0c87f0..80fd2c9 100644
--- a/.github/workflows/comment-artifacts.yml
+++ b/.github/workflows/comment-artifacts.yml
@@ -33,6 +33,13 @@ jobs:
timeStyle: 'short',
timeZone: 'UTC',
}).format(new Date(timestamp))} UTC`;
+ const formatDate = (timestamp) =>
+ new Intl.DateTimeFormat('en-GB', {
+ dateStyle: 'medium',
+ timeZone: 'UTC',
+ }).format(new Date(timestamp));
+ const buildDate = formatDate(run.created_at);
+ const buildDateMarkup = `${buildDate}`;
const artifacts = await github.paginate(
github.rest.actions.listWorkflowRunArtifacts,
{ owner, repo, run_id: run.id, per_page: 100 },
@@ -53,7 +60,7 @@ jobs:
const body = [
marker,
- '## 📦 PR builds',
+ `## 📦 PR builds · ${buildDateMarkup}`,
'',
'| Platform | Download |',
'| --- | --- |',
diff --git a/bench.sh b/bench.sh
index a49ffcc..e5aab5f 100755
--- a/bench.sh
+++ b/bench.sh
@@ -11,6 +11,8 @@ fi
RATE="${3:-100}"
SYNC_UPDATES="${4:-4}"
+CHUNK_BYTES="${5:-128}"
+WRITE_DELAY_MS="${6:-0}"
run_blocking_child() {
local result_path="$2"
@@ -153,7 +155,7 @@ if [[ "$MODE" == "nvim-restart" ]]; then
exec "$SCRIPT_DIR/launch.sh" --app-arg="--startup-command" --app-arg=":restart" --app-arg="--startup-command-delay-frames" --app-arg="$DELAY_FRAMES" --app-arg="--snapshot-dump" --app-arg="$OUT_PATH"
fi
-python3 - "$MODE" "$COUNT" "$RATE" "$SYNC_UPDATES" <<'PY'
+python3 - "$MODE" "$COUNT" "$RATE" "$SYNC_UPDATES" "$CHUNK_BYTES" "$WRITE_DELAY_MS" <<'PY'
import os
import re
import select
@@ -166,6 +168,10 @@ import tty
mode = sys.argv[1]
count = int(sys.argv[2])
+rate_arg = int(sys.argv[3]) if len(sys.argv) > 3 else 100
+sync_updates_arg = int(sys.argv[4]) if len(sys.argv) > 4 else 4
+chunk_bytes = max(1, int(sys.argv[5])) if len(sys.argv) > 5 else 128
+write_delay_ms = max(0.0, float(sys.argv[6])) if len(sys.argv) > 6 else 0.0
cols, rows = shutil.get_terminal_size((80, 24))
rows = max(4, rows)
cols = max(20, cols)
@@ -187,7 +193,7 @@ def cleanup(*_):
global running
running = False
try:
- sys.stdout.write(RESET + SHOW + ALT_OFF)
+ sys.stdout.write(RESET + SHOW + SYNC_OFF + ALT_OFF)
sys.stdout.flush()
except Exception:
pass
@@ -612,6 +618,87 @@ def run_sync_output(batches, rate_hz=100, updates_per_batch=4):
f"mode: CSI ?2026h/l updates_per_batch={updates_per_batch}\n"
)
+def chunked_frame_text(frame, row):
+ red = (frame * 17 + row * 11) % 200 + 30
+ green = (frame * 7 + row * 19) % 200 + 30
+ blue = (frame * 23 + row * 5) % 200 + 30
+ label = f" frame={frame:05d} row={row:03d} "
+ body = (label + (" <>[]{}()##==++--" * 32))[:cols].ljust(cols)
+ return (
+ f"{CSI}{row + 1};1H{CSI}2K"
+ f"{CSI}48;2;{red};{green};{blue}m{CSI}38;2;240;240;240m"
+ f"{body}{RESET}"
+ )
+
+def run_chunked(frames, rate_hz=100, updates_per_frame=4, write_chunk_bytes=128, synchronized=True, write_delay_ms=0.0):
+ """Split alternate-screen redraws into small PTY writes."""
+ interval = 1.0 / rate_hz
+ updates_per_frame = max(1, updates_per_frame)
+ write_chunk_bytes = max(1, write_chunk_bytes)
+ write_delay_s = write_delay_ms / 1000.0
+
+ sys.stdout.write(HIDE + ALT_ON + CLEAR + HOME)
+ sys.stdout.flush()
+ frame_times = []
+ written = 0
+ done = 0
+ start = time.perf_counter()
+
+ for frame in range(frames):
+ if not running:
+ break
+ batch_start = time.perf_counter()
+ if synchronized:
+ sys.stdout.write(SYNC_ON)
+ sys.stdout.flush()
+ for update in range(updates_per_frame):
+ logical_frame = frame * updates_per_frame + update
+ for row in range(rows - 1):
+ data = chunked_frame_text(logical_frame, row).encode()
+ for offset in range(0, len(data), write_chunk_bytes):
+ chunk = data[offset:offset + write_chunk_bytes]
+ os.write(sys.stdout.fileno(), chunk)
+ written += len(chunk)
+ if write_delay_s > 0:
+ time.sleep(write_delay_s)
+ status = f"{CSI}{rows};1H{CSI}0mchunked bench frame={logical_frame:05d} size={cols}x{rows}"
+ status_bytes = status.encode()
+ for offset in range(0, len(status_bytes), write_chunk_bytes):
+ chunk = status_bytes[offset:offset + write_chunk_bytes]
+ os.write(sys.stdout.fileno(), chunk)
+ written += len(chunk)
+ if update + 1 < updates_per_frame:
+ time.sleep(interval / updates_per_frame)
+ if synchronized:
+ sys.stdout.write(SYNC_OFF)
+ sys.stdout.flush()
+
+ frame_times.append(time.perf_counter() - batch_start)
+ done += 1
+ sleep_for = interval - frame_times[-1]
+ if sleep_for > 0.0001:
+ time.sleep(sleep_for)
+
+ elapsed = time.perf_counter() - start
+ cleanup()
+ if frame_times:
+ avg_ms = sum(frame_times) / len(frame_times) * 1000
+ min_ms = min(frame_times) * 1000
+ max_ms = max(frame_times) * 1000
+ p99_ms = sorted(frame_times)[int(len(frame_times) * 0.99)] * 1000
+ else:
+ avg_ms = min_ms = max_ms = p99_ms = 0.0
+
+ sys.stdout.write(stat_line("chunked", elapsed, "frames", max(1, done)))
+ sys.stdout.write(
+ f"bytes: {written} ({written / max(elapsed, 1e-9):.0f}/s)\n"
+ f"frame duration (ms): avg={avg_ms:.2f} min={min_ms:.2f} "
+ f"max={max_ms:.2f} p99={p99_ms:.2f}\n"
+ f"mode: {'CSI ?2026h/l' if synchronized else 'raw'} "
+ f"updates_per_frame={updates_per_frame} write_chunk_bytes={write_chunk_bytes} "
+ f"write_delay_ms={write_delay_ms:.1f}\n"
+ )
+
def run_keypress(presses, rate_hz=100):
"""
Simulate rapid j/k scrolling in nvim:
@@ -695,16 +782,16 @@ if mode == "scroll":
elif mode == "repaint":
run_repaint(count)
elif mode == "keypress":
- rate = int(sys.argv[3]) if len(sys.argv) > 3 else 100
- run_keypress(count, rate_hz=rate)
+ run_keypress(count, rate_hz=rate_arg)
elif mode == "split-scroll":
- rate = int(sys.argv[3]) if len(sys.argv) > 3 else 100
- run_split_scroll(count, rate_hz=rate)
+ run_split_scroll(count, rate_hz=rate_arg)
elif mode == "sync-output":
- rate = int(sys.argv[3]) if len(sys.argv) > 3 else 100
- updates = int(sys.argv[4]) if len(sys.argv) > 4 else 4
- run_sync_output(count, rate_hz=rate, updates_per_batch=updates)
+ run_sync_output(count, rate_hz=rate_arg, updates_per_batch=sync_updates_arg)
+elif mode == "chunked":
+ run_chunked(count, rate_hz=rate_arg, updates_per_frame=sync_updates_arg, write_chunk_bytes=chunk_bytes, write_delay_ms=write_delay_ms)
+elif mode == "chunked-raw":
+ run_chunked(count, rate_hz=rate_arg, updates_per_frame=sync_updates_arg, write_chunk_bytes=chunk_bytes, synchronized=False, write_delay_ms=write_delay_ms)
else:
- sys.stderr.write("usage: ./bench.sh [scroll|repaint|keypress|split-scroll|sync-output|bypass-blocking|nvim-restart|minimize-restore] ...\n")
+ sys.stderr.write("usage: ./bench.sh [scroll|repaint|keypress|split-scroll|sync-output|chunked|chunked-raw|bypass-blocking|nvim-restart|minimize-restore] ...\n")
sys.exit(2)
PY
diff --git a/src/app.zig b/src/app.zig
index 8345aca..8ae0e58 100644
--- a/src/app.zig
+++ b/src/app.zig
@@ -1876,6 +1876,7 @@ pub const App = struct {
if (self.mux) |*mux| {
var panes = mux.paneIterator();
while (panes.next()) |pane| {
+ pane.applyTerminalTheme(runtime, &self.config.terminal_theme);
pane.refreshTitle(runtime, self.config.windowTitle(), self.config.shellForDomain(if (pane.domain_name.len > 0) pane.domain_name else null) catch self.config.shellOrDefault());
_ = pane.refreshCwd();
}
diff --git a/src/app/lua_callbacks.zig b/src/app/lua_callbacks.zig
index 4c06923..2ce5f45 100644
--- a/src/app/lua_callbacks.zig
+++ b/src/app/lua_callbacks.zig
@@ -393,6 +393,12 @@ pub fn luaRefreshLiveConfigCallback(app_ptr: *anyopaque) void {
std.log.info("config: command_timing={}", .{app.config.command_timing});
cmd_ipc.syncCommandTimingEnv(app);
app.pending_renderer_refresh = app.config.backend == .sokol or app.config.backend == .webgpu;
+ if (app.ghostty) |*runtime| {
+ if (app.mux) |*mux| {
+ var panes = mux.paneIterator();
+ while (panes.next()) |pane| pane.applyTerminalTheme(runtime, &app.config.terminal_theme);
+ }
+ }
mux_ops.invalidateAllPanes(app);
app.requestLayoutResize(true);
}
diff --git a/src/app/terminal_callbacks.zig b/src/app/terminal_callbacks.zig
index 3f54a65..325f1dc 100644
--- a/src/app/terminal_callbacks.zig
+++ b/src/app/terminal_callbacks.zig
@@ -63,8 +63,19 @@ fn sizeCallback(_: ?*anyopaque, userdata: ?*anyopaque, out: ?*ghostty.SizeReport
return true;
}
-fn colorSchemeCallback(_: ?*anyopaque, _: ?*anyopaque, _: ?*ghostty.ColorScheme) callconv(.c) bool {
- return false;
+fn colorSchemeCallback(_: ?*anyopaque, userdata: ?*anyopaque, out: ?*ghostty.ColorScheme) callconv(.c) bool {
+ const out_ptr = out orelse return false;
+ const pane = paneFromUserdata(userdata) orelse return false;
+ const app: *App = @ptrCast(@alignCast(pane.host_context orelse return false));
+ out_ptr.* = colorSchemeForBackground(app.config.terminal_theme.background);
+ return true;
+}
+
+fn colorSchemeForBackground(background: ghostty.ColorRgb) ghostty.ColorScheme {
+ const brightness: u32 = @as(u32, background.r) * 299 +
+ @as(u32, background.g) * 587 +
+ @as(u32, background.b) * 114;
+ return if (brightness > 128_000) .light else .dark;
}
fn deviceAttributesCallback(_: ?*anyopaque, _: ?*anyopaque, out: ?*ghostty.DeviceAttributes) callconv(.c) bool {
@@ -86,3 +97,14 @@ fn titleChangedCallback(_: ?*anyopaque, userdata: ?*anyopaque) callconv(.c) void
pane.title_dirty = true;
}
}
+
+test "color scheme follows configured terminal background" {
+ try std.testing.expectEqual(
+ ghostty.ColorScheme.dark,
+ colorSchemeForBackground(.{ .r = 25, .g = 26, .b = 28 }),
+ );
+ try std.testing.expectEqual(
+ ghostty.ColorScheme.light,
+ colorSchemeForBackground(.{ .r = 240, .g = 240, .b = 240 }),
+ );
+}
diff --git a/src/bench/renderer_bench.zig b/src/bench/renderer_bench.zig
index deaf5ae..e739045 100644
--- a/src/bench/renderer_bench.zig
+++ b/src/bench/renderer_bench.zig
@@ -14,6 +14,7 @@ const default_iterations: usize = 10;
const Scenario = enum {
repaint,
+ chunked,
scroll,
styled,
replay,
@@ -457,6 +458,32 @@ fn buildCorpus(allocator: std.mem.Allocator, options: Options) ![]u8 {
try appendFormat(&corpus, allocator, "\x1b[0mrepaint benchmark frame={d} size={d}x{d}", .{ frame, options.cols, options.rows });
}
},
+ .chunked => {
+ // Model OpenCode/Copilot-style alternate-screen updates: each frame
+ // repaints rows independently inside DEC synchronized output, with
+ // truecolor backgrounds and small erase/write sequences. The bench
+ // feeds this corpus in chunks so render-state snapshots can land
+ // between writes.
+ try corpus.appendSlice(allocator, "\x1b[?1049h\x1b[2J\x1b[H");
+ var frame: usize = 0;
+ while (frame < options.frames) : (frame += 1) {
+ try corpus.appendSlice(allocator, "\x1b[?2026h");
+ var row: usize = 0;
+ while (row < options.rows) : (row += 1) {
+ const red: u8 = @intCast((frame * 17 + row * 11) % 200 + 30);
+ const green: u8 = @intCast((frame * 7 + row * 19) % 200 + 30);
+ const blue: u8 = @intCast((frame * 23 + row * 5) % 200 + 30);
+ try appendFormat(&corpus, allocator, "\x1b[{d};1H\x1b[2K\x1b[48;2;{d};{d};{d}m\x1b[38;2;240;240;240m", .{
+ row + 1, red, green, blue,
+ });
+ var label_buffer: [128]u8 = undefined;
+ const label = try std.fmt.bufPrint(&label_buffer, " frame={d} row={d} ", .{ frame, row });
+ try corpus.appendSlice(allocator, label);
+ if (label.len < options.cols) try appendRepeated(&corpus, allocator, " ", options.cols - label.len);
+ }
+ try corpus.appendSlice(allocator, "\x1b[0m\x1b[?2026l");
+ }
+ },
.scroll => {
const lines = try std.math.mul(usize, options.frames, options.rows);
var line: usize = 0;
@@ -528,12 +555,15 @@ fn pipelineSample(harness: *Harness, corpus: []const u8) !struct { total: i128,
parse_ns += io.nanoTimestamp() - start;
start = io.nanoTimestamp();
+ harness.runtime.clearRenderStateDirty(session.render_state);
try harness.runtime.updateRenderState(session.render_state, session.terminal);
render_state_ns += io.nanoTimestamp() - start;
harness.captureState(&session);
start = io.nanoTimestamp();
- const force_full = offset == 0;
+ // Match cached-pane policy: full dirty means screen-wide replacement;
+ // ordinary chunked output uses retained partial rows.
+ const force_full = offset == 0 or harness.dirty_level == .full;
harness.queue(&session, force_full);
if (force_full) harness.clearDirtyRows(&session);
render_ns += io.nanoTimestamp() - start;
@@ -654,6 +684,7 @@ fn printJsonStats(writer: anytype, name: []const u8, samples: []i128) !void {
fn parseScenario(value: []const u8) !Scenario {
if (std.mem.eql(u8, value, "repaint")) return .repaint;
+ if (std.mem.eql(u8, value, "chunked")) return .chunked;
if (std.mem.eql(u8, value, "scroll")) return .scroll;
if (std.mem.eql(u8, value, "styled")) return .styled;
if (std.mem.eql(u8, value, "replay")) return .replay;
diff --git a/src/lua_bridge.zig b/src/lua_bridge.zig
index fada6cd..88f4743 100644
--- a/src/lua_bridge.zig
+++ b/src/lua_bridge.zig
@@ -3644,12 +3644,14 @@ fn l_set_config(state: *State) callconv(.c) c_int {
if (std.mem.eql(u8, key, "theme") and value_type == .table) {
const theme_idx = absoluteIndex(api, state, -1);
applyThemeTable(ctx.cfg, api, state, theme_idx) catch |err| std.log.err("config theme field failed: {s}", .{@errorName(err)});
+ refresh_live_config = true;
continue;
}
if (std.mem.eql(u8, key, "terminal_theme") and value_type == .table) {
const theme_idx = absoluteIndex(api, state, -1);
applyThemeTable(ctx.cfg, api, state, theme_idx) catch |err| std.log.err("config terminal_theme field failed: {s}", .{@errorName(err)});
+ refresh_live_config = true;
continue;
}
diff --git a/src/pane.zig b/src/pane.zig
index b378714..3ede7eb 100644
--- a/src/pane.zig
+++ b/src/pane.zig
@@ -268,6 +268,17 @@ pub const Pane = struct {
return .{ .allocator = allocator };
}
+ pub fn applyTerminalTheme(self: *Pane, runtime: *GhosttyRuntime, theme: *const Config.TerminalTheme) void {
+ runtime.setTerminalDefaultColors(
+ self.terminal,
+ theme.foreground,
+ theme.background,
+ theme.cursor,
+ &theme.palette,
+ );
+ self.render_dirty = .full;
+ }
+
pub fn deinit(self: *Pane, runtime: *GhosttyRuntime) void {
self.boot_output.deinit(self.allocator);
self.osc1337_buf.deinit(self.allocator);
@@ -300,6 +311,14 @@ pub const Pane = struct {
const terminal = try runtime.createTerminal(cfg.cols, cfg.rows, cfg.scrollback);
errdefer runtime.freeTerminal(terminal);
+ runtime.setTerminalDefaultColors(
+ terminal,
+ cfg.terminal_theme.foreground,
+ cfg.terminal_theme.background,
+ cfg.terminal_theme.cursor,
+ &cfg.terminal_theme.palette,
+ );
+
runtime.setTerminalUserdata(terminal, self);
runtime.setKittyImageStorageLimit(terminal, 64 * 1024 * 1024);
diff --git a/src/render/sokol_runtime.zig b/src/render/sokol_runtime.zig
index 35429fd..227d202 100644
--- a/src/render/sokol_runtime.zig
+++ b/src/render/sokol_runtime.zig
@@ -424,6 +424,9 @@ const PaneCacheEntry = struct {
/// and ghostty does not mark the old cursor row as dirty (content unchanged).
/// Initialised to maxInt(usize) so it matches no row on the first frame.
prev_cursor_row: usize = std.math.maxInt(usize),
+ /// True when the previous cached render consumed PTY output. The first
+ /// quiet frame after a burst gets one full refresh before cache reuse.
+ pty_burst_active: bool = false,
last_cursor_row: usize = std.math.maxInt(usize),
last_cursor_col: usize = std.math.maxInt(usize),
last_cursor_visible: bool = false,
@@ -462,6 +465,7 @@ fn getOrCreatePaneCacheEntry(pane: *const Pane, w: u32, h: u32) ?*PaneCacheEntry
@memset(&entry.row_map_keys, ROW_MAP_EMPTY);
@memset(&entry.row_map_vals, 0);
entry.prev_cursor_row = std.math.maxInt(usize);
+ entry.pty_burst_active = false;
entry.last_cursor_row = std.math.maxInt(usize);
entry.last_cursor_col = std.math.maxInt(usize);
entry.last_cursor_visible = false;
@@ -498,12 +502,24 @@ fn getOrCreatePaneCacheEntry(pane: *const Pane, w: u32, h: u32) ?*PaneCacheEntry
.last_rows = 0,
.validity = .invalid,
.last_atlas_reset_epoch = 0,
+ .pty_burst_active = false,
};
new_entry.cache.clear();
g_pane_caches[free_slot] = new_entry;
return &g_pane_caches[free_slot].?;
}
+fn paneCacheReadyForPresent(pane: *const Pane, w: u32, h: u32) bool {
+ for (&g_pane_caches) |*slot| {
+ const entry = if (slot.*) |*entry| entry else continue;
+ if (entry.pane != pane) continue;
+ return !entry.cache.needsResize(w, h) and
+ entry.validity != .invalid and
+ !entry.needs_clear;
+ }
+ return false;
+}
+
/// Release the cache entry for a pane that has been destroyed.
fn releasePaneCache(pane: *const Pane) void {
for (&g_pane_caches) |*slot| {
@@ -3029,6 +3045,7 @@ fn invalidateAllPaneCaches() void {
@memset(&entry.row_map_keys, ROW_MAP_EMPTY);
@memset(&entry.row_map_vals, 0);
entry.prev_cursor_row = std.math.maxInt(usize);
+ entry.pty_burst_active = false;
}
}
}
@@ -3050,6 +3067,7 @@ pub fn invalidatePaneCacheForPane(pane: *const Pane) void {
@memset(&entry.row_map_keys, ROW_MAP_EMPTY);
@memset(&entry.row_map_vals, 0);
entry.prev_cursor_row = std.math.maxInt(usize);
+ entry.pty_burst_active = false;
return;
}
}
@@ -3229,11 +3247,24 @@ fn frameCb(user_data: ?*anyopaque) callconv(.c) void {
const single_visible_pane = if (leaves.len == 0) app.activePane() else null;
const auto_disable_multi_pane_cache = leaves.len > MAX_CACHED_VISIBLE_PANES;
const use_direct_multi_pane = (app.config.renderer_disable_multi_pane_cache or auto_disable_multi_pane_cache) and leaves.len > 1 and !sync_cache_supported;
+ const sync_cache_unready = if (!visible_sync_output or use_safe_render or use_direct_multi_pane or use_direct_render)
+ false
+ else if (leaves.len > 0)
+ for (leaves) |leaf| {
+ if (leaf.pane.synchronized_output_active and
+ !paneCacheReadyForPresent(leaf.pane, leaf.bounds.width, leaf.bounds.height)) break true;
+ } else false
+ else if (single_visible_pane) |pane|
+ pane.synchronized_output_active and
+ !paneCacheReadyForPresent(pane, @intFromFloat(width), @intFromFloat(height))
+ else
+ false;
// Direct paths have no retained terminal surface. Keep the last
- // presented frame until synchronized output ends instead of clearing
- // the swapchain and drawing newer terminal state.
+ // presented frame until synchronized output ends instead of clearing the
+ // swapchain and drawing newer terminal state. Cached paths must do the same
+ // when a screen transition invalidated their retained surface mid-batch.
if (visible_sync_output and
- (use_safe_render or use_direct_multi_pane or use_direct_render) and
+ (use_safe_render or use_direct_multi_pane or use_direct_render or sync_cache_unready) and
g_renderer_ready)
{
c.sapp_skip_present();
@@ -3384,7 +3415,7 @@ fn frameCb(user_data: ?*anyopaque) callconv(.c) void {
if (grid_changed) {
cache_entry.stable_after_resize = false;
}
- const settled_clean = dirty_level == .false_value and selection_redraw_range == null and !pty_active and !atlas_stale and !cache_entry.needs_clear and !geometry_stale and !size_mismatch and !grid_changed and !cursor_state_changed;
+ const settled_clean = dirty_level == .false_value and selection_redraw_range == null and !pty_active and !cache_entry.pty_burst_active and !atlas_stale and !cache_entry.needs_clear and !geometry_stale and !size_mismatch and !grid_changed and !cursor_state_changed;
if (dirty_level == .false_value and cache_entry.validity == .valid and settled_clean and cache_entry.stable_after_resize) {
if (cfg.debug_terminal_trace and focused) {
std.log.info("terminal-trace cache pane={x} mode=cached_clean dirty={s} cursor_changed={} cursor_visible={} cursor_blinking={} cursor_blink_visible={} cursor_style={s} cursor_row={d} cursor_col={d}", .{
@@ -3407,7 +3438,13 @@ fn frameCb(user_data: ?*anyopaque) callconv(.c) void {
return .cached_clean;
}
const unsettled = size_mismatch or grid_changed or !cache_entry.stable_after_resize;
- const force_full = dirty_level == .full or atlas_stale or cache_entry.needs_clear or geometry_stale or unsettled or background_changed;
+ // PTY output may contain only part of a TUI repaint when the
+ // read budget expires. Do not mix those rows with an older
+ // retained surface: larger windows make that mismatch both
+ // more likely and visible for longer. The quiet frame after
+ // the burst remains a final full reconciliation.
+ const pty_burst_refresh = pty_active or cache_entry.pty_burst_active;
+ const force_full = dirty_level == .full or pty_burst_refresh or atlas_stale or cache_entry.needs_clear or geometry_stale or unsettled or background_changed;
if (cfg.debug_terminal_trace and focused) {
std.log.info("terminal-trace cache pane={x} mode=cached_dirty dirty={s} force_full={} cursor_changed={} cursor_visible={} cursor_blinking={} cursor_blink_visible={} cursor_style={s} cursor_row={d} cursor_col={d} pty_active={} size_mismatch={} grid_changed={}", .{
@intFromPtr(pane),
@@ -3559,10 +3596,11 @@ fn frameCb(user_data: ?*anyopaque) callconv(.c) void {
// scroll frames redraw the dirty rows without forcing a CLEAR.
pane.pty_wrote_this_frame = false; // consumed by renderer
cache_entry.needs_clear = false;
- const now_stable = !pty_active and dirty_level == .false_value and !atlas_stale and !geometry_stale and !size_mismatch and !grid_changed;
+ const now_stable = !atlas_stale and !geometry_stale and !size_mismatch and !grid_changed;
cache_entry.stable_after_resize = now_stable;
cache_entry.validity = if (cache_entry.stable_after_resize) .valid else .priming;
- if (cache_entry.force_full_frames > 0 and !pty_active) cache_entry.force_full_frames -= 1;
+ if (cache_entry.force_full_frames > 0) cache_entry.force_full_frames -= 1;
+ cache_entry.pty_burst_active = pty_active;
cache_entry.last_bg_color = bg_color;
cache_entry.has_bg_color = true;
cache_entry.prev_cursor_row = cursor_row;
@@ -5018,6 +5056,7 @@ fn handleMouseButton(app: *App, event: c.sapp_event, action: ghostty.MouseAction
entry.force_full_frames = 3;
entry.stable_after_resize = false;
entry.needs_clear = true;
+ entry.pty_burst_active = false;
@memset(&entry.row_map_keys, ROW_MAP_EMPTY);
@memset(&entry.row_map_vals, 0);
}
diff --git a/src/term/ghostty.zig b/src/term/ghostty.zig
index 8cc2090..19464b4 100644
--- a/src/term/ghostty.zig
+++ b/src/term/ghostty.zig
@@ -915,6 +915,35 @@ pub const Runtime = struct {
return handle;
}
+ /// Set the embedder's terminal color defaults. Ghostty uses these values
+ /// both for cells that use the default SGR colors and for OSC 10/11/12
+ /// queries from programs running in the terminal.
+ pub fn setTerminalDefaultColors(
+ self: *Runtime,
+ handle: ?*anyopaque,
+ foreground: ColorRgb,
+ background: ColorRgb,
+ cursor: ?ColorRgb,
+ palette: *const [256]ColorRgb,
+ ) void {
+ const terminal = handle orelse return;
+
+ var foreground_value = foreground;
+ _ = self.terminal_set(terminal, @intFromEnum(TerminalOpt.color_foreground), &foreground_value);
+
+ var background_value = background;
+ _ = self.terminal_set(terminal, @intFromEnum(TerminalOpt.color_background), &background_value);
+
+ if (cursor) |cursor_value| {
+ var cursor_color = cursor_value;
+ _ = self.terminal_set(terminal, @intFromEnum(TerminalOpt.color_cursor), &cursor_color);
+ } else {
+ _ = self.terminal_set(terminal, @intFromEnum(TerminalOpt.color_cursor), null);
+ }
+
+ _ = self.terminal_set(terminal, @intFromEnum(TerminalOpt.color_palette), palette);
+ }
+
pub fn freeTerminal(self: *Runtime, handle: ?*anyopaque) void {
if (handle) |terminal| self.terminal_free(terminal);
}
@@ -1738,3 +1767,26 @@ test "synchronized output mode round trips through Ghostty" {
try std.testing.expect(runtime.setTerminalMode(terminal, .synchronized_output, false));
try std.testing.expect(!runtime.terminalMode(terminal, .synchronized_output));
}
+
+test "terminal default colors are exposed through render state" {
+ var runtime = try Runtime.init(std.testing.allocator, null);
+ defer runtime.deinit();
+
+ const terminal = try runtime.createTerminal(80, 24, 0);
+ defer runtime.freeTerminal(terminal);
+
+ var palette = [_]ColorRgb{.{ .r = 0, .g = 0, .b = 0 }} ** 256;
+ palette[0] = .{ .r = 1, .g = 2, .b = 3 };
+ const foreground = ColorRgb{ .r = 220, .g = 221, .b = 222 };
+ const background = ColorRgb{ .r = 25, .g = 26, .b = 28 };
+ runtime.setTerminalDefaultColors(terminal, foreground, background, null, &palette);
+
+ const render_state = try runtime.createRenderState();
+ defer runtime.freeRenderState(render_state);
+ try runtime.updateRenderState(render_state, terminal);
+
+ const colors = runtime.renderStateColors(render_state).?;
+ try std.testing.expectEqual(foreground, colors.foreground);
+ try std.testing.expectEqual(background, colors.background);
+ try std.testing.expectEqual(palette[0], colors.palette[0]);
+}