summaryrefslogtreecommitdiff
path: root/src/main.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/main.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/main.zig')
-rw-r--r--src/main.zig280
1 files changed, 111 insertions, 169 deletions
diff --git a/src/main.zig b/src/main.zig
index 2a1de27..f1527d1 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -15,6 +15,17 @@ const system_prompt = @import("system_prompt.zig");
const command = @import("command.zig");
const command_compaction = @import("compaction.zig");
+// TUI foundation layer (Phase 1, sub-phase 1). Not yet wired into the REPL;
+// referenced here so `zig build test` type-checks and exercises them.
+const tui_terminal = @import("tui_terminal.zig");
+const tui_key = @import("tui_key.zig");
+const tui_input = @import("tui_input.zig");
+const tui_theme = @import("tui_theme.zig");
+const tui_component = @import("tui_component.zig");
+const tui_engine = @import("tui_engine.zig");
+const tui_components = @import("tui_components.zig");
+const tui_app = @import("tui_app.zig");
+
// Shorthand alias for the Lua C API. The bridge module owns the actual
// `@cImport`; we re-use it here so the smoke check uses identical types.
const lua = lua_bridge.c;
@@ -43,111 +54,14 @@ test {
_ = system_prompt;
_ = command;
_ = command_compaction;
-}
-
-const ContentBlockType = panto.ContentBlockType;
-const MessageRole = panto.MessageRole;
-const Event = panto.Event;
-
-/// Display state for the pull-stream CLI renderer. Prints streaming deltas
-/// to stdout; thinking blocks are dimmed with ANSI escape codes, text blocks
-/// render plain. Persistence is owned by the agent; the renderer is
-/// display-only.
-const CLIRenderer = struct {
- stdout: *std.Io.Writer,
- file: *std.Io.File.Writer,
- allocator: std.mem.Allocator,
-
- /// Per-turn reset hook. Currently a no-op (no per-turn render state),
- /// retained as the REPL's turn-boundary signal.
- pub fn beginTurn(self: *CLIRenderer) void {
- _ = self;
- }
-
- pub fn deinit(self: *CLIRenderer) void {
- _ = self;
- }
-
- /// Render one pull `Event`. Mirrors the prior push receiver's output
- /// exactly: dim `[thinking]`, a `tool:` prefix opened at block start
- /// with the name appended at block_complete, a trailing newline at each
- /// `message_complete`, and a dim retry status line.
- fn render(self: *CLIRenderer, ev: Event) !void {
- switch (ev) {
- .message_start => {},
- .block_start => |b| {
- switch (b.block_type) {
- .Thinking => try self.stdout.writeAll("\x1b[2m[thinking] "),
- // Tool name is not known reliably at start time (OpenAI
- // may stream id/name across fragments). Open with a bare
- // prefix; the name lands at block_complete from the
- // assembled ContentBlock.
- .ToolUse => try self.stdout.writeAll("\n\x1b[36mtool: \x1b[0m"),
- else => {},
- }
- try self.file.flush();
- },
- // The print-based CLI defers tool-name rendering to
- // block_complete to avoid cursor gymnastics (we can't go back and
- // edit the prefix), so tool_details is a no-op here.
- .tool_details => {},
- .content_delta => |d| {
- try self.stdout.writeAll(d.delta);
- try self.file.flush();
- },
- .block_complete => |b| {
- switch (b.block) {
- .Thinking => try self.stdout.writeAll("\x1b[0m\n"),
- // Append the tool name now that we know it for certain.
- .ToolUse => |tu| try self.stdout.print("\x1b[36m : ({s})\x1b[0m", .{tu.name}),
- else => {},
- }
- try self.file.flush();
- },
- .message_complete => {
- try self.stdout.writeAll("\n");
- try self.file.flush();
- },
- // Surface provider retry scheduling as a dim status line so the
- // user can see the agent is waiting on the provider, not hung.
- .provider_retry => |info| {
- if (info.compaction) {
- self.stdout.print(
- "\x1b[2mcontext overflow: compacting and retrying\x1b[0m\n",
- .{},
- ) catch {};
- } else {
- const secs = @as(f64, @floatFromInt(info.delay_ms)) / 1000.0;
- self.stdout.print(
- "\x1b[2mprovider unavailable ({s}): retrying in {d:.1}s (attempt {d}/{d})\x1b[0m\n",
- .{ @errorName(info.err), secs, info.attempt + 1, info.max_attempts },
- ) catch {};
- }
- self.file.flush() catch {};
- },
- .tool_dispatch_start, .tool_dispatch_complete, .turn_complete => {},
- }
- }
-
- /// Reset any in-progress display state after a failed turn. Errors here
- /// must be swallowed — we're already in the error path.
- fn renderError(self: *CLIRenderer, err: anyerror) void {
- _ = &err;
- // If we were rendering a Thinking block, clear the dim style so
- // subsequent output (including the "[error: ...]" line) is readable.
- self.stdout.writeAll("\x1b[0m") catch {};
- self.file.flush() catch {};
- }
-};
-
-/// Drive a whole turn: open the pull `Stream` and render every event until
-/// it terminates (`turn_complete` → `next()` returns null). A failure
-/// surfaces as the error from `next()`; the caller renders it. The stream is
-/// always `deinit`ed (persisting the turn tail) on every exit path.
-fn driveTurn(agent: *panto.Agent, message: panto.UserMessage, renderer: *CLIRenderer) !void {
- var stream = try agent.run(message);
- defer stream.deinit();
- while (try stream.next()) |ev| try renderer.render(ev);
+ _ = tui_terminal;
+ _ = tui_key;
+ _ = tui_input;
+ _ = tui_theme;
+ _ = tui_component;
+ _ = tui_engine;
+ _ = tui_components;
+ _ = tui_app;
}
/// Spin up a Lua interpreter, run a no-op, tear it down. Catches
@@ -261,9 +175,9 @@ pub fn main(init: std.process.Init) !void {
var stdout_file = std.Io.File.stdout().writer(io, &stdout_buffer);
const stdout = &stdout_file.interface;
- var stdin_buffer: [4096]u8 = undefined;
- var stdin_file = std.Io.File.stdin().reader(io, &stdin_buffer);
- const stdin = &stdin_file.interface;
+ // The TUI reads raw bytes from the tty fd directly (raw mode); we only
+ // need the stdin file handle, not a buffered reader.
+ const stdin_handle = std.Io.File.stdin().handle;
// Resolve where this project's sessions live.
var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
@@ -480,23 +394,6 @@ pub fn main(init: std.process.Init) !void {
// config the agent re-reads each turn (visible before the first turn).
active_config.compaction.compaction_prompt = compaction_prompt;
- const banner_base: []const u8 = switch (provider_config) {
- inline else => |c| c.base_url,
- };
- try stdout.print(
- "panto — {s}: {s} @ {s}\n",
- .{ banner_provider_initial, banner_model_initial, banner_base },
- );
- try stdout.print("> ", .{});
- try stdout_file.flush();
-
- var cli_renderer = CLIRenderer{
- .stdout = stdout,
- .file = &stdout_file,
- .allocator = alloc,
- };
- defer cli_renderer.deinit();
-
// Build the slash-command registry and register builtins, then append
// any commands declared by Lua extensions (harvested at load time).
var cmd_registry = command.Registry.init(alloc);
@@ -521,10 +418,18 @@ pub fn main(init: std.process.Init) !void {
};
}
+ // Command output is captured into an in-memory buffer (the command
+ // Context's `stdout`) and flushed into the TUI transcript after each
+ // dispatch — TUI-safe: handlers never write raw bytes mid-frame. This is
+ // the documented minimal choice for slash-command output under the TUI; a
+ // richer routing (per-command transcript components) can refine it later.
+ var cmd_capture = std.Io.Writer.Allocating.init(alloc);
+ defer cmd_capture.deinit();
+
var cmd_ctx: command.Context = .{
.allocator = alloc,
.agent = agent,
- .stdout = stdout,
+ .stdout = &cmd_capture.writer,
.stdout_file = &stdout_file,
.compaction_prompt = compaction_prompt,
.provider_name = banner_provider_initial,
@@ -532,54 +437,91 @@ pub fn main(init: std.process.Init) !void {
.lua_rt = rt,
};
- while (true) {
- const maybe_line = stdin.takeDelimiter('\n') catch |err| {
- std.debug.print("read error: {}\n", .{err});
- return;
- };
- const line = maybe_line orelse {
- try stdout.writeAll("\n");
- try stdout_file.flush();
- break;
- };
+ // -- TUI bring-up ------------------------------------------------------
+ //
+ // Full replacement of the print REPL (version control is the safety net).
+ // The terminal enters raw mode + bracketed paste; the engine drives a
+ // differential render of the transcript + pinned input box + footer; the
+ // app loop pumps libpanto's pull Stream into component state. The terminal
+ // is restored on every exit path (defer) and on crash (signal handlers
+ // installed by `enableRawMode` + the panic-restore hook).
+ var term = tui_terminal.Terminal.init(stdin_handle, init.environ_map.*) catch |err| switch (err) {
+ tui_terminal.Error.NotATty => {
+ std.debug.print(
+ "error: panto's TUI requires an interactive terminal (stdin/stdout must be a tty).\n",
+ .{},
+ );
+ std.process.exit(1);
+ },
+ else => return err,
+ };
+ try term.enableRawMode();
+ defer term.deinit();
- if (line.len == 0) {
- try stdout.writeAll("\n> ");
- try stdout_file.flush();
- continue;
- }
+ const size = term.refreshSize();
+ var tui_writer_buf: [16 * 1024]u8 = undefined;
+ var tui_file = std.Io.File.stdout().writer(io, &tui_writer_buf);
+ var engine = tui_engine.Engine.init(
+ alloc,
+ &tui_file.interface,
+ size.cols,
+ size.rows,
+ term.caps.synchronized_output,
+ );
+ defer engine.deinit();
- // Slash commands are handled by the CLI, not sent to the model.
- // Any line starting with `/` is a command; an unrecognized one
- // prints an error rather than reaching the model.
- if (std.mem.startsWith(u8, line, "/")) {
- cmd_registry.dispatch(line, &cmd_ctx) catch |err| switch (err) {
- command.Error.CommandNotFound => {
- try stdout.print("\n[unknown command: {s}]\n> ", .{line});
- try stdout_file.flush();
- },
- else => {
- try stdout.print("\n[command error: {s}]\n> ", .{@errorName(err)});
- try stdout_file.flush();
- },
- };
- continue;
+ var input_box = tui_components.InputBox.init(alloc);
+ defer input_box.deinit();
+ var footer = tui_components.Footer.init(alloc);
+ defer footer.deinit();
+
+ var io_clock = tui_app.IoClock.init(io);
+ var app = tui_app.App.init(
+ alloc,
+ &engine,
+ io_clock.clock(),
+ &input_box,
+ &footer,
+ );
+ defer app.deinit();
+
+ const Flusher = struct {
+ fn flush(ctx: *anyopaque) void {
+ const fw: *std.Io.File.Writer = @ptrCast(@alignCast(ctx));
+ fw.interface.flush() catch {};
}
+ };
+ app.setFlusher(&tui_file, Flusher.flush);
- // Drive the turn. `run` submits + persists the user prompt and the
- // agent owns the conversation, persisting everything it generates
- // (assistant/tool-result messages, and the compaction window on
- // auto-compaction) through its session store — the CLI no longer
- // touches persistence.
- cli_renderer.beginTurn();
- driveTurn(agent, .{ .text = line }, &cli_renderer) catch |err| {
- cli_renderer.renderError(err);
- try stdout.print("\n[error: {s}]\n", .{@errorName(err)});
- };
+ const model_label = try std.fmt.allocPrint(
+ alloc,
+ "{s}:{s}",
+ .{ banner_provider_initial, banner_model_initial },
+ );
+ defer alloc.free(model_label);
- try stdout.writeAll("\n> ");
- try stdout_file.flush();
- }
+ // The render engine writes through a buffered file writer; flush after the
+ // loop so the final frame and teardown sequences reach the terminal.
+ defer tui_file.interface.flush() catch {};
+
+ tui_app.runLoop(&app, &term, .{
+ .agent = agent,
+ .cmd_registry = &cmd_registry,
+ .cmd_ctx = &cmd_ctx,
+ .cmd_capture = &cmd_capture,
+ .model_label = model_label,
+ }) catch |err| switch (err) {
+ // Clean user-initiated exit (Ctrl+C / Ctrl+D). Not an error.
+ error.UserExit => {},
+ else => {
+ // Restore the terminal before surfacing the diagnostic so the
+ // message is readable.
+ term.deinit();
+ tui_file.interface.flush() catch {};
+ std.debug.print("\n[fatal: {s}]\n", .{@errorName(err)});
+ return err;
+ },
+ };
}
// -----------------------------------------------------------------------------