Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<relative-time datetime="${run.created_at}">${buildDate}</relative-time>`;
const commitShortSha = commitSha.slice(0, 7);
const artifacts = [
{
Expand All @@ -175,7 +182,7 @@ jobs:

const body = [
marker,
'## 📦 PR builds',
`## 📦 PR builds · ${buildDateMarkup}`,
'',
'| Platform | Download |',
'| --- | --- |',
Expand Down
9 changes: 8 additions & 1 deletion .github/workflows/comment-artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<relative-time datetime="${run.created_at}">${buildDate}</relative-time>`;
const artifacts = await github.paginate(
github.rest.actions.listWorkflowRunArtifacts,
{ owner, repo, run_id: run.id, per_page: 100 },
Expand All @@ -53,7 +60,7 @@ jobs:

const body = [
marker,
'## 📦 PR builds',
`## 📦 PR builds · ${buildDateMarkup}`,
'',
'| Platform | Download |',
'| --- | --- |',
Expand Down
107 changes: 97 additions & 10 deletions bench.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions src/app.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
6 changes: 6 additions & 0 deletions src/app/lua_callbacks.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
26 changes: 24 additions & 2 deletions src/app/terminal_callbacks.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 }),
);
}
33 changes: 32 additions & 1 deletion src/bench/renderer_bench.zig
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const default_iterations: usize = 10;

const Scenario = enum {
repaint,
chunked,
scroll,
styled,
replay,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/lua_bridge.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
19 changes: 19 additions & 0 deletions src/pane.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading