summaryrefslogtreecommitdiff
path: root/src/tui_theme.zig
blob: 9e7e56585b9314401bb627ea8032ca320e931d6c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
//! 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,
    /// 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" },
        // 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" },
        // 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());
}