//! Centralized terminal color / style helpers for the TUI. //! //! Today the CLI emits raw ANSI escapes inline (e.g. `\x1b[2m` for dim, //! `\x1b[36m` for tool/cyan). This module collects those into one named //! palette so components don't hand-roll escapes. This is *centralization*, //! not a configurable theming system — the palette is fixed at comptime. //! //! Ergonomic shape (mirrors pi's `fg(name, text)` / `bg(name, text)`): //! - `Theme.fg(name)` / `Theme.bg(name)` return a `Style` value. //! - `Style.open()` / `Style.close()` return the raw escape strings, for //! callers that build lines manually (the common TUI case, where a line is //! assembled into a buffer). //! - `Style.wrap(alloc, text)` returns an allocator-owned //! `open ++ text ++ close` string, for convenience. //! - `Style.write(writer, text)` writes `open ++ text ++ close` into a //! `*std.Io.Writer`, for streaming callers. //! //! All escapes are static strings, so `open()`/`close()` never allocate and //! never fail. Only `wrap` allocates. const std = @import("std"); /// Reset-all sequence. Closing any style emits this. pub const reset = "\x1b[0m"; /// Named styles the P1 components need. Each maps to a fixed ANSI open /// sequence; the close is always `reset`. pub const StyleName = enum { /// Dimmed text — used for thinking blocks and status/retry lines. dim, /// Assistant message body. Plain (no escape); present so call sites can /// be explicit rather than emitting nothing. assistant, /// User-entered text. user, /// Tool invocation prefix / cyan accent. tool, /// Footer / chrome line. footer, /// Neutral tool-body text shown inside tool blocks. tool_text, /// Pending tool placeholder / secondary chrome. tool_pending, /// Success accent for tool status / glyphs. tool_success, /// Error accent for tool status / glyphs. tool_error, /// Compaction-summary accent/header. compaction_header, /// Thinking-block accent/header. thinking_header, /// The virtual cursor: reverse video block. cursor, /// Error / retry text. err, /// Welcome banner accent (session start chrome). welcome, /// Edit-diff `-` line prefix (slightly lighter red than `.err` /// so the surrounding body text remains readable when the /// prefix is the only colored byte on the line). err_diff, /// Edit-diff `+` line prefix (slightly lighter green than /// `.tool_success_bg`; this is a FOREGROUND color used to /// color the leading `+` byte in a diff, not a background). add_diff, /// Thinking block body (dimmed, distinct entry so the taxonomy is honest /// even though it currently resolves to the same dim escape). thinking, /// Compaction-summary chrome. compaction, /// Full-width background for user messages (#343541 dark blue-gray). user_bg, /// Full-width background for a tool call that is still running (#282832). tool_pending_bg, /// Full-width background for a succeeded tool call (#283228 dark green). tool_success_bg, /// Full-width background for a failed tool call (#3c2828 dark red). tool_error_bg, /// Dim foreground used on top of user_bg (slightly brighter than .dim). user_text, /// Dim foreground used on tool headers (cyan toned down). tool_header, }; /// A resolved style: the opening escape and (implicitly) the `reset` close. pub const Style = struct { open_seq: []const u8, /// When true the style is "empty" (no visible escape); `open()` returns /// "" and `close()` returns "" so wrapping plain text is a no-op. is_plain: bool = false, pub fn open(self: Style) []const u8 { return self.open_seq; } pub fn close(self: Style) []const u8 { return if (self.is_plain) "" else reset; } /// Allocate `open ++ text ++ close`. Caller owns the returned slice. pub fn wrap(self: Style, alloc: std.mem.Allocator, text: []const u8) ![]u8 { return std.fmt.allocPrint(alloc, "{s}{s}{s}", .{ self.open(), text, self.close() }); } /// Write `open ++ text ++ close` into `writer`. pub fn write(self: Style, writer: *std.Io.Writer, text: []const u8) !void { try writer.writeAll(self.open()); try writer.writeAll(text); try writer.writeAll(self.close()); } }; /// The (fixed) theme. A value type so the engine can hold one by value. pub const Theme = struct { /// Resolve a foreground style by name. pub fn fg(self: Theme, name: StyleName) Style { _ = self; return styleFor(name); } /// Resolve a background-oriented style by name. For this fixed palette the /// only background-style use is `cursor` (reverse video already covers /// fg+bg); other names fall back to the same escape as `fg`. Kept as a /// distinct entry point so future call sites read clearly and a richer /// palette can diverge later without churn. pub fn bg(self: Theme, name: StyleName) Style { _ = self; return styleFor(name); } }; /// The single shared theme instance. pub const default: Theme = .{}; fn styleFor(name: StyleName) Style { return switch (name) { .dim => .{ .open_seq = "\x1b[2m" }, // Plain assistant text: no escape, no reset. .assistant => .{ .open_seq = "", .is_plain = true }, .user => .{ .open_seq = "\x1b[39m" }, .tool => .{ .open_seq = "\x1b[36m" }, .footer => .{ .open_seq = "\x1b[2m\x1b[38;2;120;130;150m" }, .tool_text => .{ .open_seq = "\x1b[38;2;210;214;220m" }, .tool_pending => .{ .open_seq = "\x1b[38;2;150;160;185m" }, .tool_success => .{ .open_seq = "\x1b[38;2;110;215;140m" }, .tool_error => .{ .open_seq = "\x1b[38;2;255;140;140m" }, .compaction_header => .{ .open_seq = "\x1b[38;2;255;210;120m" }, .thinking_header => .{ .open_seq = "\x1b[38;2;180;150;255m" }, // Reverse video — the virtual cursor block. .cursor => .{ .open_seq = "\x1b[7m" }, .err => .{ .open_seq = "\x1b[31m" }, // Diff -prefix: lighter red so the surrounding body text // stays readable; only the leading byte is colored. .err_diff => .{ .open_seq = "\x1b[38;2;240;100;100m" }, // Diff +prefix: green, slightly muted. .add_diff => .{ .open_seq = "\x1b[38;2;100;200;120m" }, // Welcome banner: cyan accent, matching the tool accent family. .welcome => .{ .open_seq = "\x1b[36m" }, // Thinking body: dimmed, like status chrome. .thinking => .{ .open_seq = "\x1b[2m" }, // Compaction chrome: dimmed. .compaction => .{ .open_seq = "\x1b[2m" }, // Full-width background fills — truecolor 24-bit (RGB), matching pi's // dark theme palette. These are set-background-color only; text color // is layered on top by pairing with a fg style. .user_bg => .{ .open_seq = "\x1b[48;2;52;53;65m" }, // #343541 .tool_pending_bg => .{ .open_seq = "\x1b[48;2;40;40;50m" }, // #282832 .tool_success_bg => .{ .open_seq = "\x1b[48;2;40;50;40m" }, // #283228 .tool_error_bg => .{ .open_seq = "\x1b[48;2;60;40;40m" }, // #3c2828 // Foreground color for text rendered on top of user_bg. .user_text => .{ .open_seq = "\x1b[38;2;212;212;212m" }, // #d4d4d4 // Cyan tool header, slightly muted. .tool_header => .{ .open_seq = "\x1b[38;2;0;215;255m" }, // #00d7ff }; } test "open/close are static and reversible" { const t = default; const dim = t.fg(.dim); try std.testing.expectEqualStrings("\x1b[2m", dim.open()); try std.testing.expectEqualStrings(reset, dim.close()); } test "plain assistant style emits nothing" { const t = default; const a = t.fg(.assistant); try std.testing.expectEqualStrings("", a.open()); try std.testing.expectEqualStrings("", a.close()); } test "wrap allocates open++text++close" { const t = default; const s = try t.fg(.tool).wrap(std.testing.allocator, "read"); defer std.testing.allocator.free(s); try std.testing.expectEqualStrings("\x1b[36mread\x1b[0m", s); } test "write streams open++text++close" { var buf: [64]u8 = undefined; var w = std.Io.Writer.fixed(&buf); try default.fg(.cursor).write(&w, "X"); try std.testing.expectEqualStrings("\x1b[7mX\x1b[0m", w.buffered()); }