add kitty cli graphics infrastructure

This commit is contained in:
Emil Lerch 2026-06-25 15:58:51 -07:00
parent 889cfc215a
commit 6d47b46a5a
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 277 additions and 0 deletions

View file

@ -43,6 +43,8 @@
const std = @import("std");
const zfin = @import("../root.zig");
const validator = @import("../comptime_validator.zig");
const chart = @import("../charts/chart.zig");
const term_query = @import("../term_query.zig");
// Group taxonomy
@ -206,6 +208,9 @@ pub const Globals = struct {
watchlist_path: ?[]const u8 = null,
/// Cache-freshness policy from `--refresh-data=<value>`.
refresh_policy: RefreshPolicy = .auto,
/// Chart graphics mode from `--chart` (auto / braille / WxH). Default
/// is auto: kitty graphics when the terminal supports it, else braille.
chart_config: chart.ChartConfig = .{},
};
// RunCtx
@ -250,6 +255,10 @@ pub const RunCtx = struct {
now_s: i64,
color: bool,
out: *std.Io.Writer,
/// Detected inline-graphics capability (kitty) + cell size, captured
/// once at invocation entry. Commands consult this together with
/// `globals.chart_config` to choose kitty-graphics vs braille output.
graphics_caps: term_query.Caps = .{},
/// Resolve the portfolio pattern(s) (from `-p`/`--portfolio` or
/// the default `portfolio*.srf` pattern) through cwd -> ZFIN_HOME.

View file

@ -3,6 +3,8 @@ const zfin = @import("root.zig");
const tui = @import("tui.zig");
const cli = @import("commands/common.zig");
const cmd_framework = @import("commands/framework.zig");
const chart = @import("charts/chart.zig");
const term_query = @import("term_query.zig");
/// Comptime registry of CLI commands. Field name is the user-facing
/// subcommand name; value is the imported module struct. Order
@ -159,6 +161,8 @@ const Globals = struct {
/// Default: `.auto` (TTL-respecting). Other values: `.force`
/// (re-fetch regardless of TTL) and `.never` (offline mode).
refresh_policy: cmd_framework.RefreshPolicy = .auto,
/// Chart graphics mode from `--chart` (auto / braille / WxH).
chart_config: chart.ChartConfig = .{},
/// Index into args of the first post-global token (the subcommand).
cursor: usize,
};
@ -168,6 +172,8 @@ const GlobalParseError = error{
UnknownGlobalFlag,
/// `--refresh-data=<value>` got something other than auto/force/never.
InvalidRefreshDataValue,
/// `--chart=<value>` got something other than auto/braille/WxH.
InvalidChartValue,
/// Multiple `.srf` files appeared as a single -p argument, almost
/// certainly because the shell expanded an unquoted glob. We
/// surface this as a dedicated error so the user gets a friendly
@ -284,6 +290,12 @@ fn parseGlobals(allocator: std.mem.Allocator, args: []const []const u8) GlobalPa
// space). Surface the shape mismatch explicitly.
return error.MissingValue;
}
if (std.mem.eql(u8, a, "--chart")) {
if (i + 1 >= args.len) return error.MissingValue;
g.chart_config = chart.ChartConfig.parse(args[i + 1]) orelse return error.InvalidChartValue;
i += 2;
continue;
}
// Help flags are subcommand-like tokens, stop scanning.
if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) break;
@ -355,6 +367,7 @@ fn runCli(init: std.process.Init) !u8 {
cli.stderrPrint(io, "\nRun 'zfin help' for usage.\n");
},
error.InvalidRefreshDataValue => cli.stderrPrint(io, "Error: --refresh-data=<value> requires one of: auto, force, never.\n"),
error.InvalidChartValue => cli.stderrPrint(io, "Error: --chart requires one of: auto, braille, or WxH (e.g. 1920x1080).\n"),
error.UnquotedGlobLikely => {
cli.stderrPrint(io,
\\Error: -p was given a single value followed by additional .srf files.
@ -499,11 +512,13 @@ fn runCli(init: std.process.Init) !u8 {
.portfolio_patterns = globals.portfolio_patterns,
.watchlist_path = globals.watchlist_path,
.refresh_policy = globals.refresh_policy,
.chart_config = globals.chart_config,
},
.today = today,
.now_s = now_s,
.color = color,
.out = out,
.graphics_caps = term_query.detect(io, init.environ_map),
};
const dispatched_args = if (comptime Module.meta.uppercase_first_arg)
try cmd_framework.normalizeFirstArg(allocator, cmd_args)

158
src/term_graphics.zig Normal file
View file

@ -0,0 +1,158 @@
//! Kitty graphics protocol emission for the plain CLI commands.
//!
//! The TUI renders bitmap charts through vaxis, which speaks the kitty
//! graphics protocol for us. The non-interactive CLI (`zfin history`,
//! `quote`, `projections`) has no vaxis loop, so it emits the protocol
//! directly: this module turns a raw RGB buffer into the APC escape
//! sequence that a kitty-capable terminal renders inline at the cursor.
//!
//! It also centralizes the inline-chart sizing: the on-screen size is
//! expressed in terminal *columns* (the headline projections/quote
//! charts are wider than the history timeline), and the pixel render
//! size is derived from the terminal's cell pixel dimensions.
const std = @import("std");
/// Default cell pixel size assumed when the terminal hasn't reported one.
/// Kitty scales the transmitted image to the requested `c`=cols,`r`=rows
/// footprint, so an imperfect assumption only affects render crispness,
/// not on-screen layout.
pub const default_cell_w: u32 = 8;
pub const default_cell_h: u32 = 16;
/// Inline-chart target widths in terminal columns. The headline charts
/// (projections, quote) are wider than the history timeline; tuned to
/// roughly match-and-exceed the braille chart's on-screen footprint.
pub const history_cols: u16 = 80;
pub const projection_cols: u16 = 120;
pub const quote_cols: u16 = 120;
/// Maximum base64 payload bytes per APC escape, per the kitty protocol.
const chunk_max: usize = 4096;
pub const PixelDims = struct { width: u32, height: u32 };
/// Pixel dimensions for a chart occupying `cols` x `rows` cells.
pub fn pixelDims(cols: u16, rows: u16, cell_w: u32, cell_h: u32) PixelDims {
return .{ .width = @as(u32, cols) * cell_w, .height = @as(u32, rows) * cell_h };
}
/// Rows for a chart `cols` wide that preserve a ~3:1 width:height pixel
/// aspect (matching the braille chart's proportions), given the cell
/// pixel size. Always at least 1.
pub fn rowsForWidth(cols: u16, cell_w: u32, cell_h: u32) u16 {
const width_px = @as(u32, cols) * cell_w;
const height_px = width_px / 3;
const rows = (height_px + cell_h - 1) / cell_h; // round up
return @intCast(@max(rows, @as(u32, 1)));
}
/// Emit `rgb` (length must be `width_px * height_px * 3`) as a kitty
/// graphics image displayed at the cursor, occupying `cols` x `rows`
/// terminal cells. The payload is base64-encoded and split across as
/// many APC escapes as needed (<= 4096 base64 bytes each). `q=2`
/// suppresses the terminal's OK/error replies since the CLI isn't
/// reading them back.
///
/// The cursor is left where the terminal put it (kitty does not move it
/// past the image); callers should print `rows` newlines afterward to
/// advance below the chart.
pub fn emitKittyRGB(
writer: *std.Io.Writer,
alloc: std.mem.Allocator,
rgb: []const u8,
width_px: u32,
height_px: u32,
cols: u16,
rows: u16,
) !void {
const Encoder = std.base64.standard.Encoder;
const b64 = try alloc.alloc(u8, Encoder.calcSize(rgb.len));
defer alloc.free(b64);
_ = Encoder.encode(b64, rgb);
var off: usize = 0;
var first = true;
while (true) {
const end = @min(off + chunk_max, b64.len);
const more = end < b64.len;
try writer.writeAll("\x1b_G");
if (first) {
// First escape carries the image metadata + control keys.
try writer.print(
"a=T,q=2,f=24,s={d},v={d},c={d},r={d},m={d}",
.{ width_px, height_px, cols, rows, @intFromBool(more) },
);
first = false;
} else {
try writer.print("m={d}", .{@intFromBool(more)});
}
try writer.writeAll(";");
try writer.writeAll(b64[off..end]);
try writer.writeAll("\x1b\\");
off = end;
if (!more) break;
}
}
// Tests
const testing = std.testing;
test "pixelDims multiplies cells by cell size" {
const d = pixelDims(80, 14, 8, 16);
try testing.expectEqual(@as(u32, 640), d.width);
try testing.expectEqual(@as(u32, 224), d.height);
}
test "rowsForWidth keeps a ~3:1 pixel aspect" {
// 8x16 cells: rows ~= cols/6, rounded up.
try testing.expectEqual(@as(u16, 14), rowsForWidth(80, 8, 16)); // 640/3/16 = 13.3 -> 14
try testing.expectEqual(@as(u16, 20), rowsForWidth(120, 8, 16)); // 960/3/16 = 20
// Never zero, even for a tiny width.
try testing.expect(rowsForWidth(1, 8, 16) >= 1);
}
test "emitKittyRGB: single chunk carries control keys + decodable payload" {
const alloc = testing.allocator;
var aw: std.Io.Writer.Allocating = .init(alloc);
defer aw.deinit();
const rgb = [_]u8{ 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33 }; // 2x1 RGB
try emitKittyRGB(&aw.writer, alloc, &rgb, 2, 1, 4, 2);
const out = aw.written();
try testing.expect(std.mem.startsWith(u8, out, "\x1b_G"));
try testing.expect(std.mem.endsWith(u8, out, "\x1b\\"));
try testing.expect(std.mem.indexOf(u8, out, "a=T,q=2,f=24,s=2,v=1,c=4,r=2,m=0") != null);
// Exactly one APC escape (one terminator).
try testing.expectEqual(@as(usize, 1), std.mem.count(u8, out, "\x1b\\"));
// Payload (between ';' and the ST) round-trips back to the input.
const semi = std.mem.indexOfScalar(u8, out, ';').?;
const payload = out[semi + 1 .. out.len - 2];
var decoded: [6]u8 = undefined;
try std.base64.standard.Decoder.decode(&decoded, payload);
try testing.expectEqualSlices(u8, &rgb, &decoded);
}
test "emitKittyRGB: large payload is chunked with m=1 then a final m=0" {
const alloc = testing.allocator;
var aw: std.Io.Writer.Allocating = .init(alloc);
defer aw.deinit();
// 64x64 RGB = 12288 bytes -> base64 16384 bytes -> exactly 4 chunks.
const rgb = try alloc.alloc(u8, 64 * 64 * 3);
defer alloc.free(rgb);
@memset(rgb, 0x7F);
try emitKittyRGB(&aw.writer, alloc, rgb, 64, 64, 10, 5);
const out = aw.written();
try testing.expectEqual(@as(usize, 4), std.mem.count(u8, out, "\x1b_G"));
// First escape: control keys + m=1 (more chunks follow).
try testing.expect(std.mem.indexOf(u8, out, "a=T,q=2,f=24,s=64,v=64,c=10,r=5,m=1") != null);
// A continuation escape with no control keys, and the final m=0.
try testing.expect(std.mem.indexOf(u8, out, "\x1b_Gm=1;") != null);
try testing.expect(std.mem.indexOf(u8, out, "\x1b_Gm=0;") != null);
}

95
src/term_query.zig Normal file
View file

@ -0,0 +1,95 @@
//! Terminal capability probing for inline kitty graphics in the plain
//! CLI commands (the TUI gets this from vaxis).
//!
//! v1 uses environment heuristics plus a TTY check; it does not actively
//! query the terminal. The cell pixel size defaults to a common 8x16 -
//! kitty scales the transmitted image to the requested cell footprint,
//! so the default only affects render crispness, not layout. (An active
//! `CSI 16 t` cell-size query is a sensible follow-up.)
const std = @import("std");
const term_graphics = @import("term_graphics.zig");
pub const Caps = struct {
/// The terminal appears to speak the kitty graphics protocol.
kitty: bool = false,
/// Cell pixel size used to size inline charts.
cell_w: u32 = term_graphics.default_cell_w,
cell_h: u32 = term_graphics.default_cell_h,
};
/// Detect inline-graphics capability. Graphics escapes are only ever
/// emitted to a real terminal, never into a pipe or file, so a non-TTY
/// stdout always yields `.kitty = false` (callers fall back to braille).
pub fn detect(io: std.Io, environ_map: *const std.process.Environ.Map) Caps {
const is_tty = std.Io.File.stdout().isTty(io) catch false;
if (!is_tty) return .{};
return .{ .kitty = kittyFromEnv(environ_map) };
}
/// Environment heuristic for kitty-graphics support. Recognizes kitty,
/// Ghostty, and WezTerm - the common terminals implementing the
/// protocol. (iTerm2 uses a different image protocol and is not matched.)
pub fn kittyFromEnv(env: *const std.process.Environ.Map) bool {
if (env.get("TERM")) |t| {
if (std.mem.indexOf(u8, t, "kitty") != null) return true;
}
if (env.get("KITTY_WINDOW_ID") != null) return true;
if (env.get("GHOSTTY_RESOURCES_DIR") != null) return true;
if (env.get("GHOSTTY_BIN_DIR") != null) return true;
if (env.get("WEZTERM_EXECUTABLE") != null) return true;
if (env.get("WEZTERM_PANE") != null) return true;
if (env.get("TERM_PROGRAM")) |p| {
if (std.mem.eql(u8, p, "ghostty")) return true;
if (std.mem.eql(u8, p, "WezTerm")) return true;
}
return false;
}
// Tests
const testing = std.testing;
test "kittyFromEnv: kitty TERM" {
var env = std.process.Environ.Map.init(testing.allocator);
defer env.deinit();
try env.put("TERM", "xterm-kitty");
try testing.expect(kittyFromEnv(&env));
}
test "kittyFromEnv: KITTY_WINDOW_ID, ghostty, wezterm" {
{
var env = std.process.Environ.Map.init(testing.allocator);
defer env.deinit();
try env.put("TERM", "xterm-256color");
try env.put("KITTY_WINDOW_ID", "1");
try testing.expect(kittyFromEnv(&env));
}
{
var env = std.process.Environ.Map.init(testing.allocator);
defer env.deinit();
try env.put("TERM_PROGRAM", "ghostty");
try testing.expect(kittyFromEnv(&env));
}
{
var env = std.process.Environ.Map.init(testing.allocator);
defer env.deinit();
try env.put("WEZTERM_PANE", "0");
try testing.expect(kittyFromEnv(&env));
}
}
test "kittyFromEnv: plain xterm / Apple Terminal is not kitty" {
var env = std.process.Environ.Map.init(testing.allocator);
defer env.deinit();
try env.put("TERM", "xterm-256color");
try env.put("TERM_PROGRAM", "Apple_Terminal");
try testing.expect(!kittyFromEnv(&env));
}
test "Caps defaults to the assumed cell size, no kitty" {
const c: Caps = .{};
try testing.expect(!c.kitty);
try testing.expectEqual(term_graphics.default_cell_w, c.cell_w);
try testing.expectEqual(term_graphics.default_cell_h, c.cell_h);
}