summaryrefslogtreecommitdiff
path: root/src/tui_theme.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-08 10:41:30 -0600
committert <t@tjp.lol>2026-06-08 12:25:55 -0600
commite5ed00c52bb10aec811734c5568c881c41e58474 (patch)
treef51342f759efac79ec175dcc3ede7ebebc0bc470 /src/tui_theme.zig
parenta5881243f9bb4642f90fa94179f7a5bff23e4657 (diff)
Replace print CLIRenderer with differential TUI engine
Implement TUI Phase 1: a raw-mode terminal, differential render engine, pinned input box + footer, and component model wired into the libpanto event stream. Adds foundation modules (theme, key, component, input, terminal), the render engine, components, and the app loop; removes the old print CLIRenderer and driveTurn REPL from main.zig. Also removes scratch status reports (tui-p1/, progress.md) for the now completed work.
Diffstat (limited to 'src/tui_theme.zig')
-rw-r--r--src/tui_theme.zig136
1 files changed, 136 insertions, 0 deletions
diff --git a/src/tui_theme.zig b/src/tui_theme.zig
new file mode 100644
index 0000000..1acbca0
--- /dev/null
+++ b/src/tui_theme.zig
@@ -0,0 +1,136 @@
+//! 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,
+};
+
+/// 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" },
+ };
+}
+
+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());
+}