diff options
| author | t <t@tjp.lol> | 2026-06-08 10:41:30 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-08 12:25:55 -0600 |
| commit | e5ed00c52bb10aec811734c5568c881c41e58474 (patch) | |
| tree | f51342f759efac79ec175dcc3ede7ebebc0bc470 /src/main.zig | |
| parent | a5881243f9bb4642f90fa94179f7a5bff23e4657 (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.zig | 280 |
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; + }, + }; } // ----------------------------------------------------------------------------- |
