//! 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, /// The virtual cursor: reverse video block. cursor, /// Error / retry text. err, /// Welcome banner accent (session start chrome). welcome, /// 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, }; /// 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" }, // Reverse video — the virtual cursor block. .cursor => .{ .open_seq = "\x1b[7m" }, .err => .{ .open_seq = "\x1b[31m" }, // 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" }, }; } 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()); }