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 | |
| 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.
| -rw-r--r-- | src/main.zig | 280 | ||||
| -rw-r--r-- | src/tui_app.zig | 824 | ||||
| -rw-r--r-- | src/tui_component.zig | 337 | ||||
| -rw-r--r-- | src/tui_components.zig | 1101 | ||||
| -rw-r--r-- | src/tui_engine.zig | 1116 | ||||
| -rw-r--r-- | src/tui_input.zig | 489 | ||||
| -rw-r--r-- | src/tui_key.zig | 129 | ||||
| -rw-r--r-- | src/tui_terminal.zig | 405 | ||||
| -rw-r--r-- | src/tui_theme.zig | 136 |
9 files changed, 4648 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; + }, + }; } // ----------------------------------------------------------------------------- diff --git a/src/tui_app.zig b/src/tui_app.zig new file mode 100644 index 0000000..03b32c5 --- /dev/null +++ b/src/tui_app.zig @@ -0,0 +1,824 @@ +//! The TUI application loop (plan §2/§9): wires the libpanto pull `Stream` +//! into component state and drives the differential render engine. +//! +//! This module is the NEW app/chat loop that `main.zig` shrinks to wiring +//! around. It owns: +//! - a `Terminal` (raw mode + bracketed paste + SIGWINCH/restore), +//! - a `tui_engine.Engine` driving a LIST of components, +//! - the transcript (heap-allocated user/assistant/status components that +//! persist for the engine to borrow), +//! - a pinned `InputBox` (focused) and `Footer` (fps element), +//! - the libpanto stream pump that routes each `Event` to component state. +//! +//! ## No "active component" invariant (plan §6) +//! +//! Streaming state is keyed by libpanto BLOCK INDEX (and tool call identity), +//! never a single mutable "current component" pointer. `TurnRouter` holds a +//! `block_index -> *transcript entry` map, so when parallel tool calls or +//! interleaved blocks arrive later (P2), each delta lands on the right +//! component without restructuring. P1 only spawns the minimal component set +//! (user/assistant/input/footer + minimal status lines), but the routing +//! structure is already parallel-safe. +//! +//! ## Streaming -> component state (plan §8) +//! +//! There is no per-delta render method. The pump consumes the pull `Stream` +//! and, for each event, MUTATES component state and calls +//! `scheduler.requestRender()`. The engine's append fast path +//! (`firstLineChanged` near the tail via the render cache + the line-diff +//! backstop) repaints only the dirty tail. stdout is never written directly. +//! +//! ## Thinking / tool deltas in P1 (deferred display, non-crashing) +//! +//! P1's minimal component set is user/assistant/input/footer. There is no +//! dedicated thinking or collapsible tool-use component yet (P2). To avoid +//! crashing on those blocks while keeping the loop honest: +//! - a Thinking block streams its deltas into a DIM status line (one +//! `AssistantText`-style component styled dim), and +//! - a ToolUse block renders a single dim `tool: <name>` status line +//! (name resolved at `tool_details` / `block_complete`). +//! The full thinking component and the collapsible tool-use component are +//! deferred to P2; this is the documented minimal stand-in. + +const std = @import("std"); +const posix = std.posix; +const panto = @import("panto"); + +const terminal_mod = @import("tui_terminal.zig"); +const engine_mod = @import("tui_engine.zig"); +const components = @import("tui_components.zig"); +const input_mod = @import("tui_input.zig"); +const theme = @import("tui_theme.zig"); +const component = @import("tui_component.zig"); +const command = @import("command.zig"); + +const Terminal = terminal_mod.Terminal; +const Engine = engine_mod.Engine; +const Scheduler = engine_mod.Scheduler; +const Clock = engine_mod.Clock; +const AssistantText = components.AssistantText; +const UserText = components.UserText; +const InputBox = components.InputBox; +const Footer = components.Footer; +const Component = component.Component; + +const Event = panto.Event; + +// =========================================================================== +// IoClock — the real monotonic clock for the engine's scheduler +// =========================================================================== + +/// Wraps `std.Io`'s monotonic (`.awake`) clock as an engine `Clock`. The +/// engine stays Io-agnostic; this is the app-side adapter that supplies real +/// time. Store one by value and pass `clock()` into the engine/`App`. +pub const IoClock = struct { + io: std.Io, + + pub fn init(io: std.Io) IoClock { + return .{ .io = io }; + } + + fn nowFn(ptr: *anyopaque) i128 { + const self: *IoClock = @ptrCast(@alignCast(ptr)); + return @intCast(std.Io.Clock.now(.awake, self.io).nanoseconds); + } + + pub fn clock(self: *IoClock) Clock { + return .{ .ptr = self, .nowFn = nowFn }; + } +}; + +// =========================================================================== +// Transcript +// =========================================================================== + +/// A heap-allocated transcript entry. The engine borrows each entry's +/// `comp()`; the entry must outlive its time in the engine's list, so the +/// transcript owns the boxes on the heap and frees them on `deinit`. +/// +/// `StatusText` reuses `AssistantText` but is styled by the caller via a +/// leading style escape baked into the text (we keep it as a plain +/// AssistantText for P1 and prefix a dim/style run in the seeded text). +const Entry = union(enum) { + user: *UserText, + /// Assistant message body (streaming text block). + assistant: *AssistantText, + /// A dim status/thinking/tool/retry line (minimal P1 stand-in; not a full + /// component — see module docs). + status: *AssistantText, + + fn comp(self: Entry) Component { + return switch (self) { + .user => |p| p.comp(), + .assistant => |p| p.comp(), + .status => |p| p.comp(), + }; + } + + fn deinit(self: Entry, alloc: std.mem.Allocator) void { + switch (self) { + .user => |p| { + p.deinit(); + alloc.destroy(p); + }, + .assistant => |p| { + p.deinit(); + alloc.destroy(p); + }, + .status => |p| { + p.deinit(); + alloc.destroy(p); + }, + } + } +}; + +// =========================================================================== +// App +// =========================================================================== + +pub const App = struct { + alloc: std.mem.Allocator, + engine: *Engine, + scheduler: Scheduler, + clock: Clock, + + /// Owned transcript entries (boxes the engine borrows). Top-to-bottom. + transcript: std.ArrayList(Entry) = .empty, + + /// Pinned, persistent components. Owned here (by value); the engine + /// borrows their `comp()`. + input_box: *InputBox, + footer: *Footer, + + /// Per-turn block routing. Cleared at each turn boundary. + router: TurnRouter, + + /// Optional sink flusher. The real terminal's engine writer is a buffered + /// file writer that must be flushed after each frame for output to reach + /// the tty; tests inject an in-memory writer and leave this null. + flush_ctx: ?*anyopaque = null, + flush_fn: ?*const fn (ctx: *anyopaque) void = null, + + /// Whether the input box currently participates in the engine list. It is + /// removed during an in-flight turn (so streaming output appends below the + /// transcript) and re-added when the turn completes. P1 keeps it simple: + /// input + footer are always present and pinned at the bottom. + pub fn init( + alloc: std.mem.Allocator, + engine: *Engine, + clock: Clock, + input_box: *InputBox, + footer: *Footer, + ) App { + return .{ + .alloc = alloc, + .engine = engine, + .scheduler = Scheduler.init(8 * std.time.ns_per_ms), + .clock = clock, + .input_box = input_box, + .footer = footer, + .router = TurnRouter.init(alloc), + }; + } + + pub fn deinit(self: *App) void { + for (self.transcript.items) |e| e.deinit(self.alloc); + self.transcript.deinit(self.alloc); + self.router.deinit(); + } + + /// Install a sink flusher (the buffered terminal file writer). Called once + /// during real-terminal bring-up; tests leave it unset. + pub fn setFlusher(self: *App, ctx: *anyopaque, f: *const fn (ctx: *anyopaque) void) void { + self.flush_ctx = ctx; + self.flush_fn = f; + } + + fn flushSink(self: *App) void { + if (self.flush_fn) |f| f(self.flush_ctx.?); + } + + // -- transcript spawning ------------------------------------------------ + + /// Append a fresh transcript entry and register it with the engine, + /// keeping the pinned input box + footer at the very bottom. Returns the + /// new entry (still owned by the transcript). + fn pushEntry(self: *App, entry: Entry) !void { + try self.transcript.append(self.alloc, entry); + try self.rebuildEngineList(); + } + + /// Rebuild the engine's component list: all transcript entries top-to- + /// bottom, then the pinned input box, then the footer. Called whenever the + /// transcript layout changes (a layout change forces a full redraw inside + /// the engine, which is correct here). + fn rebuildEngineList(self: *App) !void { + // Clear and re-add. `removeComponent` is O(n) per call, so clear by + // re-initializing the slot list via repeated pops is awkward; instead + // remove the pinned components, then append the new entry, then re-add + // the pinned ones. To keep it simple and correct we drain & rebuild. + while (self.engine.componentCount() > 0) { + const first = self.engine.slots.items[0].comp; + _ = self.engine.removeComponent(first); + } + for (self.transcript.items) |e| try self.engine.addComponent(e.comp()); + try self.engine.addComponent(self.input_box.comp()); + try self.engine.addComponent(self.footer.comp()); + } + + /// Spawn a new assistant-text entry for the given block index and return + /// it. Keyed by index in the router so deltas route without an "active + /// component" pointer. + fn spawnAssistant(self: *App) !*AssistantText { + const box = try self.alloc.create(AssistantText); + box.* = AssistantText.init(self.alloc); + try self.pushEntry(.{ .assistant = box }); + return box; + } + + /// Spawn a dim status line seeded with `text`. Used for thinking blocks, + /// tool-call status, retry notices, command output, and errors. Returns + /// the box so streaming callers (thinking) can append more. + fn spawnStatus(self: *App, text: []const u8) !*AssistantText { + const box = try self.alloc.create(AssistantText); + box.* = AssistantText.init(self.alloc); + // Seed with a dim run so the status reads as chrome, not assistant + // prose. The component renders plain assistant style, so we bake the + // dim escape into the text itself (a documented P1 minimal stand-in + // for a real status component). + const dim = theme.default.fg(.dim); + const seeded = try std.fmt.allocPrint(self.alloc, "{s}{s}{s}", .{ dim.open(), text, dim.close() }); + defer self.alloc.free(seeded); + try box.setText(seeded); + try self.pushEntry(.{ .status = box }); + return box; + } + + /// Spawn a user-message entry seeded with `text`. + fn spawnUser(self: *App, text: []const u8) !void { + const box = try self.alloc.create(UserText); + box.* = UserText.init(self.alloc); + try box.setText(text); + try self.pushEntry(.{ .user = box }); + } + + // -- the render pump ---------------------------------------------------- + + /// Render a frame if one is pending, feeding the footer the measured + /// render time. Returns true if a frame was drawn. + pub fn maybeRender(self: *App) !bool { + const now = self.clock.now(); + if (!self.scheduler.shouldRenderNow(now)) return false; + const start = self.clock.now(); + try self.engine.render(); + self.flushSink(); + const end = self.clock.now(); + const ms = @as(f64, @floatFromInt(end - start)) / @as(f64, std.time.ns_per_ms); + // Feed the footer the last frame's render time. This dirties the + // footer for NEXT frame; we don't recursively render here (the next + // pending frame picks it up), keeping the fps readout one frame + // behind, which is acceptable for the perf-validation surface. + self.footer.setFrameTime(ms); + self.scheduler.noteRendered(self.clock.now()); + return true; + } + + /// Force a render now (e.g. after a turn boundary or resize), bypassing + /// the coalescing window. + pub fn renderNow(self: *App) !void { + self.scheduler.requestRender(); + const start = self.clock.now(); + try self.engine.render(); + self.flushSink(); + const end = self.clock.now(); + const ms = @as(f64, @floatFromInt(end - start)) / @as(f64, std.time.ns_per_ms); + self.footer.setFrameTime(ms); + self.scheduler.noteRendered(self.clock.now()); + } + + // -- event routing ------------------------------------------------------ + + /// Route one libpanto `Event` to component state (plan §8). NEVER writes + /// to stdout; mutates components and requests a render. Keyed by block + /// index via `router` so there is no "active component" pointer. + pub fn routeEvent(self: *App, ev: Event) !void { + switch (ev) { + .message_start => {}, + .block_start => |b| { + switch (b.block_type) { + .Text => { + const box = try self.spawnAssistant(); + try self.router.put(b.index, .{ .assistant = box }); + }, + .Thinking => { + // Minimal P1 stand-in: a dim streaming status line. + const box = try self.spawnStatus("[thinking] "); + try self.router.put(b.index, .{ .thinking = box }); + }, + .ToolUse => { + // Minimal P1 stand-in: a dim one-line tool status. The + // name is unknown at start (streamed); fill it in at + // tool_details / block_complete. + const box = try self.spawnStatus("tool: …"); + try self.router.put(b.index, .{ .tool = box }); + }, + .ToolResult => {}, + } + self.scheduler.requestRender(); + }, + .tool_details => |d| { + if (self.router.get(d.index)) |ref| switch (ref) { + .tool => |box| { + const dim = theme.default.fg(.dim); + const line = try std.fmt.allocPrint(self.alloc, "{s}tool: {s}{s}", .{ dim.open(), d.name, dim.close() }); + defer self.alloc.free(line); + try box.setText(line); + self.scheduler.requestRender(); + }, + else => {}, + }; + }, + .content_delta => |d| { + if (self.router.get(d.index)) |ref| switch (ref) { + .assistant => |box| { + try box.appendDelta(d.delta); + self.scheduler.requestRender(); + }, + .thinking => |box| { + // Append thinking deltas (still dim — the seed kept the + // dim run open; we append raw text, which renders plain + // assistant style. Acceptable P1 stand-in). + try box.appendDelta(d.delta); + self.scheduler.requestRender(); + }, + // Tool args stream as deltas too; P1 doesn't display the + // streamed JSON args (deferred to the P2 tool component). + .tool => {}, + }; + }, + .block_complete => |b| { + switch (b.block) { + .ToolUse => |tu| { + if (self.router.get(b.index)) |ref| switch (ref) { + .tool => |box| { + const dim = theme.default.fg(.dim); + const line = try std.fmt.allocPrint(self.alloc, "{s}tool: {s}{s}", .{ dim.open(), tu.name, dim.close() }); + defer self.alloc.free(line); + try box.setText(line); + self.scheduler.requestRender(); + }, + else => {}, + }; + }, + else => {}, + } + }, + .message_complete => {}, + .provider_retry => |info| { + // Preserve the existing dim retry messaging meaning as a status + // line in the transcript. + if (info.compaction) { + _ = try self.spawnStatus("context overflow: compacting and retrying"); + } else { + const secs = @as(f64, @floatFromInt(info.delay_ms)) / 1000.0; + const msg = try std.fmt.allocPrint( + self.alloc, + "provider unavailable ({s}): retrying in {d:.1}s (attempt {d}/{d})", + .{ @errorName(info.err), secs, info.attempt + 1, info.max_attempts }, + ); + defer self.alloc.free(msg); + _ = try self.spawnStatus(msg); + } + self.scheduler.requestRender(); + }, + .tool_dispatch_start, .tool_dispatch_complete, .turn_complete => {}, + } + } + + /// Reset per-turn routing state. The transcript entries persist (they are + /// the chat history); only the block-index map is cleared. + pub fn beginTurn(self: *App) void { + self.router.reset(); + } + + /// Surface a turn error as a dim status line in the transcript. + pub fn routeError(self: *App, err: anyerror) !void { + const msg = try std.fmt.allocPrint(self.alloc, "[error: {s}]", .{@errorName(err)}); + defer self.alloc.free(msg); + _ = try self.spawnStatus(msg); + self.scheduler.requestRender(); + } +}; + +// =========================================================================== +// TurnRouter — block-index -> component map (no "active component") +// =========================================================================== + +/// A reference to the transcript component a libpanto block is streaming into. +/// Keyed by block index in `TurnRouter`. This is the structure that makes the +/// loop parallel-tool-call ready: each block index has its own sink, so there +/// is never a single mutable "current" component. +pub const BlockRef = union(enum) { + assistant: *AssistantText, + /// Thinking block (dim status stand-in for P1). + thinking: *AssistantText, + /// Tool-use block (one-line status stand-in for P1). + tool: *AssistantText, +}; + +pub const TurnRouter = struct { + map: std.AutoHashMap(usize, BlockRef), + + pub fn init(alloc: std.mem.Allocator) TurnRouter { + return .{ .map = std.AutoHashMap(usize, BlockRef).init(alloc) }; + } + + pub fn deinit(self: *TurnRouter) void { + self.map.deinit(); + } + + pub fn reset(self: *TurnRouter) void { + self.map.clearRetainingCapacity(); + } + + pub fn put(self: *TurnRouter, index: usize, ref: BlockRef) !void { + try self.map.put(index, ref); + } + + pub fn get(self: *TurnRouter, index: usize) ?BlockRef { + return self.map.get(index); + } +}; + +// =========================================================================== +// Driving the loop (real terminal) +// =========================================================================== + +/// Inputs the loop needs from `main.zig` (kept as a struct so the wiring stays +/// a single call). The agent, command registry, and command context are +/// borrowed for the loop's lifetime. +pub const RunOptions = struct { + agent: *panto.Agent, + cmd_registry: *const command.Registry, + cmd_ctx: *command.Context, + /// In-memory writer that command handlers write to (their `stdout`). After + /// each dispatch the captured text is flushed into the transcript as a dim + /// status line, then cleared. See `runLoop` for the rationale. + cmd_capture: *std.Io.Writer.Allocating, + model_label: []const u8, +}; + +/// Run the interactive chat loop against a real terminal until EOF / Ctrl+D / +/// Ctrl+C. Restores the terminal on every exit path (the `Terminal` installs +/// signal + the caller installs panic restore). +/// +/// Loop shape (single-threaded, poll-based): +/// 1. Render any pending frame (feeding the footer the frame time). +/// 2. Poll the tty for input with a short timeout (so coalesced renders and +/// SIGWINCH are serviced promptly even with no keypress). +/// 3. Decode buffered bytes -> keys -> the focused input box. +/// 4. On a submitted line: drive a turn (or dispatch a slash command), +/// pumping the stream's events into component state. +pub fn runLoop(app: *App, term: *Terminal, opts: RunOptions) !void { + // Negotiate bracketed paste (+ opportunistic Kitty). Teardown on exit. + term.writeAll(input_mod.negotiate_setup); + defer term.writeAll(input_mod.negotiate_teardown); + term.hideCursor(); + defer term.showCursor(); + + try app.footer.setModel(opts.model_label); + app.input_box.setFocused(true); + try app.rebuildEngineList(); + try app.renderNow(); + + var read_buf: [4096]u8 = undefined; + // Retained partial-sequence tail across reads (a CSI/UTF-8 split across + // read() boundaries). + var tail: std.ArrayList(u8) = .empty; + defer tail.deinit(app.alloc); + + while (true) { + // 1. Service a pending coalesced frame. + _ = try app.maybeRender(); + + // 1b. SIGWINCH -> resize -> full redraw. + if (term.takeResized()) { + const size = term.refreshSize(); + app.engine.resize(size.cols, size.rows); + try app.renderNow(); + } + + // 2. Poll for input (short timeout so renders/resize stay responsive). + const ready = pollReadable(term.fd, 16) catch true; + if (!ready) continue; + + const n = posix.read(term.fd, &read_buf) catch |err| switch (err) { + error.WouldBlock => continue, + else => return, + }; + if (n == 0) break; // EOF (Ctrl+D on an empty line closes the tty) + + // 3. Decode. Prepend any retained tail, decode all complete sequences, + // retain the unconsumed tail for the next read. + try tail.appendSlice(app.alloc, read_buf[0..n]); + const consumed = try handleBytes(app, tail.items, opts); + // Keep the unconsumed tail. + const leftover = tail.items.len - consumed; + std.mem.copyForwards(u8, tail.items[0..leftover], tail.items[consumed..]); + tail.items.len = leftover; + + // 4. A frame may now be pending (input edited the box / a turn ran). + _ = try app.maybeRender(); + } +} + +/// Decode `bytes` into keys, route control keys (Ctrl+C/Ctrl+D) at the app +/// level, feed the rest to the focused input box, and act on any submitted +/// line. Returns the number of bytes consumed (the unconsumed partial tail is +/// retained by the caller). +fn handleBytes(app: *App, bytes: []const u8, opts: RunOptions) !usize { + var off: usize = 0; + while (off < bytes.len) { + const step = input_mod.decodeOne(bytes[off..]) orelse break; // partial tail + switch (step.decoded) { + .key => |k| { + // App-level control keys. + if (k.isCtrl('c') or k.isCtrl('d')) { + // Clean exit: restore handled by deferred teardown + the + // terminal's deinit in main. Signal EOF by closing the loop. + return error.UserExit; + } + // Feed the key to the focused input box. + app.input_box.comp().handleInput(bytes[off .. off + step.consumed]); + }, + .paste => { + app.input_box.comp().handleInput(bytes[off .. off + step.consumed]); + }, + } + off += step.consumed; + app.scheduler.requestRender(); + + // Did the box submit a line? + if (app.input_box.takeSubmitted()) |line_borrowed| { + // Copy: the box may reuse its buffer. + const line = try app.alloc.dupe(u8, line_borrowed); + defer app.alloc.free(line); + try handleSubmittedLine(app, line, opts); + } + } + return off; +} + +/// Handle a submitted input line: slash command vs. model turn. +fn handleSubmittedLine(app: *App, line: []const u8, opts: RunOptions) !void { + if (line.len == 0) return; + + if (std.mem.startsWith(u8, line, "/")) { + // Slash command. Output is captured into `opts.cmd_capture` (the + // command Context's stdout) and flushed into the transcript as a dim + // status line — TUI-safe (no raw stdout writes during a frame). + opts.cmd_capture.clearRetainingCapacity(); + opts.cmd_registry.dispatch(line, opts.cmd_ctx) catch |err| switch (err) { + command.Error.CommandNotFound => { + const msg = try std.fmt.allocPrint(app.alloc, "[unknown command: {s}]", .{line}); + defer app.alloc.free(msg); + _ = try app.spawnStatus(msg); + }, + else => { + const msg = try std.fmt.allocPrint(app.alloc, "[command error: {s}]", .{@errorName(err)}); + defer app.alloc.free(msg); + _ = try app.spawnStatus(msg); + }, + }; + // Surface any captured command output. + const captured = opts.cmd_capture.written(); + if (captured.len != 0) { + _ = try app.spawnStatus(captured); + } + try app.renderNow(); + return; + } + + // Model turn. Echo the user message, then pump the stream into components. + try app.spawnUser(line); + app.beginTurn(); + try app.renderNow(); + + driveTurn(app, opts.agent, .{ .text = line }) catch |err| { + try app.routeError(err); + }; + try app.renderNow(); +} + +/// Drive one whole turn: open the pull stream, route every event into +/// component state until it terminates, rendering coalesced frames as deltas +/// arrive. The stream is always `deinit`ed (persisting the turn tail) on every +/// exit path — agent persistence is untouched. +fn driveTurn(app: *App, agent: *panto.Agent, message: panto.UserMessage) !void { + var stream = try agent.run(message); + defer stream.deinit(); + while (try stream.next()) |ev| { + try app.routeEvent(ev); + _ = try app.maybeRender(); + } +} + +/// Poll the fd for readability with a millisecond timeout. Returns true when +/// data is available. Uses `poll(2)`. +fn pollReadable(fd: posix.fd_t, timeout_ms: i32) !bool { + var fds = [_]posix.pollfd{.{ .fd = fd, .events = posix.POLL.IN, .revents = 0 }}; + const n = try posix.poll(&fds, timeout_ms); + if (n == 0) return false; + return (fds[0].revents & posix.POLL.IN) != 0; +} + +// =========================================================================== +// Tests +// =========================================================================== + +const testing = std.testing; + +/// A test clock that advances by a fixed step each `now()` call so the +/// scheduler's coalescing logic is deterministic. +const TestClock = struct { + t: i128 = 0, + step: i128 = 1, + + fn now(ptr: *anyopaque) i128 { + const self: *TestClock = @ptrCast(@alignCast(ptr)); + const v = self.t; + self.t += self.step; + return v; + } + + fn clock(self: *TestClock) Clock { + return .{ .ptr = self, .nowFn = now }; + } +}; + +/// Build an App backed by an in-memory engine writer (no TTY) for routing +/// tests. Caller owns the returned pieces and must call `teardown`. +const Harness = struct { + buf: std.Io.Writer.Allocating, + engine: Engine, + input_box: InputBox, + footer: Footer, + test_clock: TestClock, + app: App, + + fn make(alloc: std.mem.Allocator) !*Harness { + const h = try alloc.create(Harness); + h.buf = std.Io.Writer.Allocating.init(alloc); + h.engine = Engine.init(alloc, &h.buf.writer, 80, 24, false); + h.input_box = InputBox.init(alloc); + h.footer = Footer.init(alloc); + h.test_clock = .{ .t = 0, .step = 100 }; + h.app = App.init(alloc, &h.engine, h.test_clock.clock(), &h.input_box, &h.footer); + return h; + } + + fn teardown(h: *Harness, alloc: std.mem.Allocator) void { + h.app.deinit(); + h.engine.deinit(); + h.input_box.deinit(); + h.footer.deinit(); + h.buf.deinit(); + alloc.destroy(h); + } +}; + +fn delta(index: usize, text: []const u8) Event { + return .{ .content_delta = .{ .index = index, .delta = text } }; +} + +test "routeEvent: text block + deltas append to an assistant component" { + const alloc = testing.allocator; + const h = try Harness.make(alloc); + defer h.teardown(alloc); + + try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } }); + try h.app.routeEvent(delta(0, "hello")); + try h.app.routeEvent(delta(0, " world")); + + // One transcript entry (assistant), buffer accumulated both deltas. + try testing.expectEqual(@as(usize, 1), h.app.transcript.items.len); + const ref = h.app.router.get(0).?; + try testing.expectEqualStrings("hello world", ref.assistant.buffer.items); +} + +test "routeEvent: two text blocks key by index, no active-component clobber" { + const alloc = testing.allocator; + const h = try Harness.make(alloc); + defer h.teardown(alloc); + + // Two interleaved text blocks (the no-active-component invariant: deltas + // for index 0 must NOT land on index 1 even after block 1 opened). + try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } }); + try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 1 } }); + try h.app.routeEvent(delta(1, "B")); + try h.app.routeEvent(delta(0, "A")); + try h.app.routeEvent(delta(0, "A2")); + + try testing.expectEqualStrings("AA2", h.app.router.get(0).?.assistant.buffer.items); + try testing.expectEqualStrings("B", h.app.router.get(1).?.assistant.buffer.items); +} + +test "routeEvent: thinking deltas do not crash and stream to a status line" { + const alloc = testing.allocator; + const h = try Harness.make(alloc); + defer h.teardown(alloc); + + try h.app.routeEvent(.{ .block_start = .{ .block_type = .Thinking, .index = 0 } }); + try h.app.routeEvent(delta(0, "reasoning")); + + const ref = h.app.router.get(0).?; + try testing.expect(ref == .thinking); + // The status line buffer contains the seed + appended delta. + try testing.expect(std.mem.indexOf(u8, ref.thinking.buffer.items, "reasoning") != null); +} + +test "routeEvent: tool block renders a minimal tool: <name> status (no crash)" { + const alloc = testing.allocator; + const h = try Harness.make(alloc); + defer h.teardown(alloc); + + try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } }); + // Tool args streaming as deltas must be dropped silently, not crash. + try h.app.routeEvent(delta(0, "{\"path\":")); + try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "t1", .name = "read" } }); + + const ref = h.app.router.get(0).?; + try testing.expect(ref == .tool); + try testing.expect(std.mem.indexOf(u8, ref.tool.buffer.items, "tool: read") != null); +} + +test "routeEvent: provider_retry adds a dim status line" { + const alloc = testing.allocator; + const h = try Harness.make(alloc); + defer h.teardown(alloc); + + try h.app.routeEvent(.{ .provider_retry = .{ + .err = error.ConnectionResetByPeer, + .delay_ms = 1500, + .attempt = 0, + .max_attempts = 3, + .compaction = false, + } }); + try testing.expectEqual(@as(usize, 1), h.app.transcript.items.len); + const e = h.app.transcript.items[0]; + try testing.expect(e == .status); + try testing.expect(std.mem.indexOf(u8, e.status.buffer.items, "retrying") != null); +} + +test "routeEvent: full event stream renders through the real engine, no stdout" { + const alloc = testing.allocator; + const h = try Harness.make(alloc); + defer h.teardown(alloc); + + // Pin input + footer like the real loop. + h.app.input_box.setFocused(true); + try h.app.rebuildEngineList(); + + h.app.beginTurn(); + try h.app.routeEvent(.{ .message_start = .assistant }); + try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } }); + try h.app.routeEvent(delta(0, "Hi there")); + try h.app.routeEvent(.{ .turn_complete = {} }); + + try h.app.renderNow(); + const out = h.buf.written(); + // The assistant text reached the engine output (not stdout). + try testing.expect(std.mem.indexOf(u8, out, "Hi there") != null); +} + +test "beginTurn clears the block-index map but keeps transcript history" { + const alloc = testing.allocator; + const h = try Harness.make(alloc); + defer h.teardown(alloc); + + try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } }); + try h.app.routeEvent(delta(0, "first turn")); + try testing.expect(h.app.router.get(0) != null); + + h.app.beginTurn(); + // Router cleared... + try testing.expect(h.app.router.get(0) == null); + // ...but the transcript entry persists as history. + try testing.expectEqual(@as(usize, 1), h.app.transcript.items.len); +} + +test "maybeRender feeds the footer a frame time and respects coalescing" { + const alloc = testing.allocator; + const h = try Harness.make(alloc); + defer h.teardown(alloc); + try h.app.rebuildEngineList(); + + // No pending frame => no render. + try testing.expect(!(try h.app.maybeRender())); + + h.app.scheduler.requestRender(); + try testing.expect(try h.app.maybeRender()); // idle => renders + // Footer received a frame time (>= 0). + try testing.expect(h.app.footer.frame_ms != null); +} diff --git a/src/tui_component.zig b/src/tui_component.zig new file mode 100644 index 0000000..a2b0ea4 --- /dev/null +++ b/src/tui_component.zig @@ -0,0 +1,337 @@ +//! The core component contract for the TUI. +//! +//! Dispatch is a vtable of function pointers over `*anyopaque` (decided in +//! plan §4.5; a tagged union was rejected so out-of-tree extensions can define +//! their own components later without editing a central enum). +//! +//! The render engine (later sub-phase) holds a list of `Component`s, asks each +//! to `render(width)` into lines, and uses `firstLineChanged` to do a +//! differential repaint. This file defines only the interface plus a small +//! reusable cache/dirty mixin; it implements no concrete components and no +//! engine. + +const std = @import("std"); + +/// Invisible cursor marker. +/// +/// A focused `Focusable` component embeds this marker in its rendered output +/// at the virtual cursor location. It is an APC string (`ESC _ ... ESC \`), +/// which terminals ignore (zero visible width), so it survives in the line +/// buffer until the engine scans for it. In P3 the engine will locate this +/// marker, strip it from the emitted bytes, and position the hardware cursor +/// there; for P1 the marker simply ships and is treated as zero-width. +/// +/// The payload `panto-cursor` disambiguates it from any other APC a child +/// component might legitimately emit. +pub const CURSOR_MARKER = "\x1b_panto-cursor\x1b\\"; + +/// The component vtable. Concrete components store their own state behind +/// `ptr` and provide function pointers that recover the concrete type via +/// `@ptrCast`/`@alignCast`. +/// +/// Line / width contract (plan §3.1): every line returned by `render` MUST +/// have visible width <= the `width` argument. The engine validates this and +/// treats overflow as a hard error, so components are responsible for +/// truncating. The returned `[]const []const u8` and its lines are owned by +/// the component (typically backed by its render cache); they must remain +/// valid until the next `render`/`invalidate` call on that component. +pub const Component = struct { + ptr: *anyopaque, + vtable: *const VTable, + + pub const VTable = struct { + /// Render the component at `width` columns, returning one slice per + /// visible line. Each line's visible width must be <= `width`. + /// `alloc` is the engine's per-frame/render allocator; whether the + /// component caches into its own storage or allocates fresh each call + /// is up to it, but the returned slices must outlive the call until + /// the next render/invalidate. + render: *const fn (ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8, + + /// Lowest component-local line index whose rendered output differs + /// since the last successful render, or null if nothing changed. + /// + /// This MUST be derived from the component's render cache (see + /// `RenderCache`), not a separately hand-managed integer that can + /// drift from reality. + firstLineChanged: *const fn (ptr: *anyopaque) ?usize, + + /// Drop cached render state, forcing a full re-render and re-dirtying + /// the component. + invalidate: *const fn (ptr: *anyopaque) void, + + /// Optional: feed raw input bytes (already routed to this component by + /// the engine) for the component to interpret. Null when the component + /// does not accept input. + handleInput: ?*const fn (ptr: *anyopaque, data: []const u8) void = null, + + /// Capability: when true the engine should deliver key *release* + /// events to this component (most components only want press/repeat). + wantsKeyRelease: bool = false, + }; + + pub fn render(self: Component, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { + return self.vtable.render(self.ptr, width, alloc); + } + + pub fn firstLineChanged(self: Component) ?usize { + return self.vtable.firstLineChanged(self.ptr); + } + + pub fn invalidate(self: Component) void { + self.vtable.invalidate(self.ptr); + } + + pub fn handleInput(self: Component, data: []const u8) void { + if (self.vtable.handleInput) |f| f(self.ptr, data); + } + + pub fn wantsKeyRelease(self: Component) bool { + return self.vtable.wantsKeyRelease; + } +}; + +/// The focus contract. A component that can hold focus exposes `focused`, +/// which the engine flips on focus changes. When focused, the component emits +/// `CURSOR_MARKER` at its virtual cursor position inside its `render` output. +/// +/// This is a thin embeddable struct: a focusable component contains one and +/// the engine sets `.focused` via the component's own setter (the vtable does +/// not yet carry focus methods — focus routing wiring is part of the engine +/// sub-phase; what ships in P1 is the flag + the marker constant + the +/// documented contract). +pub const Focusable = struct { + focused: bool = false, + + pub fn setFocused(self: *Focusable, value: bool) void { + self.focused = value; + } +}; + +/// Reusable cache + dirty bookkeeping for components. +/// +/// firstLineChanged lifecycle (mirrors pi's `invalidate()` model): +/// - State mutation calls `markDirty()` (or `invalidate()`), which clears +/// the cache. While dirty, `firstLineChanged()` reports the recorded +/// dirty line (0 by default — "everything from the top changed"). +/// - A *successful* render calls `store(lines)`, which: +/// 1. diffs the new lines against the previously cached lines, +/// 2. records the lowest differing index as the "changed-from" value, +/// 3. replaces the cache with a copy of the new lines, +/// 4. marks the cache clean. +/// - After a clean render with no diff, `firstLineChanged()` returns null. +/// +/// Because `firstLineChanged` is computed purely from cache state, it cannot +/// drift from a hand-managed field. Components embed this struct and route +/// their vtable through `cacheRender`/`markDirty`. +/// +/// The cache owns its copies of the line bytes (allocated with the allocator +/// passed to `init`); call `deinit` to free them. +pub const RenderCache = struct { + alloc: std.mem.Allocator, + /// Owned copies of the last rendered lines, or null when never rendered or + /// invalidated. + lines: ?[][]u8 = null, + /// Lowest line index that changed at the last `store`, or 0 when dirty + /// with no prior cache. Null only when clean with no change. + changed_from: ?usize = null, + dirty: bool = true, + + pub fn init(alloc: std.mem.Allocator) RenderCache { + return .{ .alloc = alloc }; + } + + pub fn deinit(self: *RenderCache) void { + self.freeLines(); + } + + fn freeLines(self: *RenderCache) void { + if (self.lines) |lines| { + for (lines) |line| self.alloc.free(line); + self.alloc.free(lines); + self.lines = null; + } + } + + /// Mark the component dirty (e.g. on any state mutation). Drops the cache + /// so the next render is a full render starting at `from` (default 0). + pub fn markDirty(self: *RenderCache) void { + self.markDirtyFrom(0); + } + + /// Mark dirty starting at a specific line. The cache is dropped (we no + /// longer trust any cached line), and `firstLineChanged` will report + /// `from` until the next successful render. + pub fn markDirtyFrom(self: *RenderCache, from: usize) void { + self.dirty = true; + self.changed_from = from; + self.freeLines(); + } + + /// Mark dirty for an APPEND-style mutation that only affects the tail of + /// the rendered output (e.g. a streaming content delta appended to the + /// end of a buffer). + /// + /// Unlike `markDirty`/`markDirtyFrom`, this RETAINS the cached baseline + /// lines so the next `store` can diff against them and recover the TRUE + /// lowest-changed index (which, for an append, is near the tail). While + /// dirty, `firstLineChanged` reports a conservative TAIL HINT — the index + /// of the last cached line (or 0 when there is no baseline yet) — so the + /// engine re-renders and the cut stays near the end rather than rolling to + /// line 0. The post-render diff in `store` then replaces the hint with the + /// exact change point. `firstLineChanged` therefore remains cache-derived: + /// the dirty hint comes from the retained cache's length, and the precise + /// value comes from the diff. + pub fn markDirtyAppend(self: *RenderCache) void { + self.dirty = true; + if (self.lines) |lines| { + // Tail hint: the last existing line is the lowest line an append + // can change. Keep the baseline for the diff in `store`. + self.changed_from = if (lines.len == 0) 0 else lines.len - 1; + } else { + self.changed_from = 0; + } + } + + /// Equivalent to `markDirty` — full drop + re-dirty. Provided so a + /// component's `invalidate` vtable entry can forward here verbatim. + pub fn invalidate(self: *RenderCache) void { + self.markDirty(); + } + + /// Derived dirty signal. Returns the lowest changed line index, or null + /// when the cache is clean and nothing changed at the last store. + pub fn firstLineChanged(self: *const RenderCache) ?usize { + if (self.dirty) return self.changed_from orelse 0; + return self.changed_from; + } + + /// Record a successful render. Diffs `new_lines` against the cache, + /// updates `changed_from` to the lowest differing index, copies the new + /// lines into the cache, and marks it clean. + pub fn store(self: *RenderCache, new_lines: []const []const u8) !void { + const diff = self.computeFirstDiff(new_lines); + + // Build the new owned copy first; only swap in on success. + var copies = try self.alloc.alloc([]u8, new_lines.len); + var made: usize = 0; + errdefer { + for (copies[0..made]) |c| self.alloc.free(c); + self.alloc.free(copies); + } + for (new_lines, 0..) |line, i| { + copies[i] = try self.alloc.dupe(u8, line); + made = i + 1; + } + + self.freeLines(); + self.lines = copies; + self.changed_from = diff; + self.dirty = false; + } + + /// Lowest index at which `new_lines` differs from the cached lines. + /// Returns null when they are identical. When there is no prior cache (or + /// the line counts differ at index 0), returns 0. + fn computeFirstDiff(self: *const RenderCache, new_lines: []const []const u8) ?usize { + const old = self.lines orelse return 0; + const n = @min(old.len, new_lines.len); + var i: usize = 0; + while (i < n) : (i += 1) { + if (!std.mem.eql(u8, old[i], new_lines[i])) return i; + } + // Common prefix matched; if lengths differ, the first extra/missing + // line index is the change point. + if (old.len != new_lines.len) return n; + return null; + } +}; + +test "CURSOR_MARKER is an APC string" { + try std.testing.expect(std.mem.startsWith(u8, CURSOR_MARKER, "\x1b_")); + try std.testing.expect(std.mem.endsWith(u8, CURSOR_MARKER, "\x1b\\")); +} + +test "RenderCache: starts dirty from 0" { + var c = RenderCache.init(std.testing.allocator); + defer c.deinit(); + try std.testing.expectEqual(@as(?usize, 0), c.firstLineChanged()); +} + +test "RenderCache: store cleans, no-change render returns null" { + var c = RenderCache.init(std.testing.allocator); + defer c.deinit(); + + const a = [_][]const u8{ "one", "two" }; + try c.store(&a); + // First store after empty cache => changed from 0. + try std.testing.expectEqual(@as(?usize, 0), c.firstLineChanged()); + + // Re-store identical => no change. + try c.store(&a); + try std.testing.expectEqual(@as(?usize, null), c.firstLineChanged()); +} + +test "RenderCache: store reports lowest differing line" { + var c = RenderCache.init(std.testing.allocator); + defer c.deinit(); + + const a = [_][]const u8{ "one", "two", "three" }; + try c.store(&a); + const b = [_][]const u8{ "one", "TWO", "three" }; + try c.store(&b); + try std.testing.expectEqual(@as(?usize, 1), c.firstLineChanged()); +} + +test "RenderCache: line-count change reports the boundary" { + var c = RenderCache.init(std.testing.allocator); + defer c.deinit(); + + const a = [_][]const u8{ "one", "two" }; + try c.store(&a); + const b = [_][]const u8{ "one", "two", "three" }; + try c.store(&b); + try std.testing.expectEqual(@as(?usize, 2), c.firstLineChanged()); +} + +test "RenderCache: markDirtyAppend keeps baseline and reports a tail hint, diff recovers exact change" { + var c = RenderCache.init(std.testing.allocator); + defer c.deinit(); + + const a = [_][]const u8{ "l0", "l1", "l2", "l3" }; + try c.store(&a); + // No baseline change yet; append-dirty reports the tail hint (last line). + c.markDirtyAppend(); + try std.testing.expectEqual(@as(?usize, 3), c.firstLineChanged()); + // The baseline is retained for the diff. + try std.testing.expect(c.lines != null); + + // A render that only adds a tail line: diff recovers the exact boundary (4), + // NOT 0 — the streaming-tail property. + const b = [_][]const u8{ "l0", "l1", "l2", "l3", "l4" }; + try c.store(&b); + try std.testing.expectEqual(@as(?usize, 4), c.firstLineChanged()); +} + +test "RenderCache: markDirtyAppend with no baseline reports 0" { + var c = RenderCache.init(std.testing.allocator); + defer c.deinit(); + c.markDirtyAppend(); + try std.testing.expectEqual(@as(?usize, 0), c.firstLineChanged()); +} + +test "RenderCache: markDirty re-dirties from 0" { + var c = RenderCache.init(std.testing.allocator); + defer c.deinit(); + const a = [_][]const u8{"x"}; + try c.store(&a); + c.markDirty(); + try std.testing.expectEqual(@as(?usize, 0), c.firstLineChanged()); +} + +test "Focusable flips" { + var f: Focusable = .{}; + try std.testing.expect(!f.focused); + f.setFocused(true); + try std.testing.expect(f.focused); +} diff --git a/src/tui_components.zig b/src/tui_components.zig new file mode 100644 index 0000000..1832fc2 --- /dev/null +++ b/src/tui_components.zig @@ -0,0 +1,1101 @@ +//! Built-in P1 components for the TUI (plan §6). +//! +//! Each component satisfies the `Component` vtable (`tui_component.zig`) and +//! implements the cache-derived dirty model exactly as `RenderCache` defines +//! it: any state mutation calls `markDirty`/`markDirtyFrom` (drops the cache), +//! and a successful `render` calls `cache.store(lines)` (diffs the new lines +//! against the prior cache, records the lowest differing index, re-populates +//! the cache, and marks it clean). `firstLineChanged` is therefore derived +//! purely from cache state and never a hand-managed integer that can drift. +//! +//! Data-in / lines-out: each component takes STRUCTURED DATA IN via setters or +//! delta-appenders and produces LINES OUT from `render(width, alloc)`. Every +//! returned line's visible width is <= `width` (we TRUNCATE; the engine treats +//! overflow as a hard error per plan §3.1). +//! +//! Render storage convention: a component renders into a transient list, calls +//! `cache.store(lines)` (which dupes the bytes into cache-owned storage), then +//! returns `cache.lines` re-typed as `[]const []const u8`. The returned slices +//! are owned by the cache and stay valid until the next `render`/`invalidate` +//! — satisfying the vtable's lifetime contract. + +const std = @import("std"); +const component = @import("tui_component.zig"); +const theme = @import("tui_theme.zig"); +const input = @import("tui_input.zig"); +const key = @import("tui_key.zig"); + +const Component = component.Component; +const Focusable = component.Focusable; +const RenderCache = component.RenderCache; +const CURSOR_MARKER = component.CURSOR_MARKER; +const Style = theme.Style; +const Key = key.Key; +const KeyCode = key.KeyCode; + +// =========================================================================== +// Shared helpers +// =========================================================================== + +/// Number of display columns occupied by `text`, counted as one column per +/// UTF-8 codepoint. `text` here is assumed to be PLAIN (no escape sequences); +/// components wrap on plain text and only add styling escapes afterward, so +/// this is a faithful visible width. Mirrors the engine's P1 approximation +/// (1 col per codepoint; wide CJK/emoji width is a deferred refinement). +pub fn displayWidth(text: []const u8) usize { + var cols: usize = 0; + var i: usize = 0; + while (i < text.len) { + const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1; + cols += 1; + i += @min(seq_len, text.len - i); + } + return cols; +} + +/// Truncate `text` to at most `max_cols` display columns, returning a byte +/// slice of `text` that ends on a codepoint boundary. Never splits a multibyte +/// codepoint. +pub fn truncateToCols(text: []const u8, max_cols: usize) []const u8 { + var cols: usize = 0; + var i: usize = 0; + while (i < text.len and cols < max_cols) { + const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1; + const adv = @min(seq_len, text.len - i); + i += adv; + cols += 1; + } + return text[0..i]; +} + +/// Wrap `text` (a single logical paragraph, no embedded newlines) into lines of +/// at most `width` display columns, appending each produced line to `out`. +/// Greedy word-wrap on ASCII spaces; a word longer than `width` is hard-split. +/// An empty paragraph yields one empty line. Lines pushed to `out` are slices +/// borrowed from `text` (no allocation of line bytes here; `out` only stores +/// the slice headers). +fn wrapParagraph(text: []const u8, width: usize, out: *std.ArrayList([]const u8), alloc: std.mem.Allocator) !void { + if (width == 0) { + try out.append(alloc, ""); + return; + } + if (text.len == 0) { + try out.append(alloc, ""); + return; + } + + // Greedy word-wrap. We accumulate a line by byte range [line_start, i); on + // overflow we break at the last space that fits, or hard-split a word that + // is wider than `width`. + var line_start: usize = 0; + var line_cols: usize = 0; + var last_break: ?usize = null; // byte index of the last space on this line + var i: usize = 0; + + while (i < text.len) { + const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1; + const adv = @min(seq_len, text.len - i); + const is_space = adv == 1 and text[i] == ' '; + + if (is_space and line_cols == width) { + // The overflowing glyph is the inter-word space itself: break here + // and consume the space (standard word-wrap discards it) so the + // current word group fills the line exactly. + try out.append(alloc, text[line_start..i]); + line_start = i + adv; + last_break = null; + line_cols = 0; + i += adv; + continue; + } + if (line_cols + 1 > width) { + // Adding this glyph would overflow; break the line first. + if (last_break) |brk| { + // Break at the last space: emit up to (not including) it, and + // start the next line just after it. + try out.append(alloc, text[line_start..brk]); + line_start = brk + 1; + last_break = null; + line_cols = displayWidth(text[line_start..i]); + } else { + // No space on this line: hard-split before the current glyph. + try out.append(alloc, text[line_start..i]); + line_start = i; + line_cols = 0; + } + } + + if (is_space) last_break = i; + line_cols += 1; + i += adv; + } + // Flush the final line (always emit, even if empty/trailing fragment). + try out.append(alloc, text[line_start..]); +} + +/// Split `buffer` on newlines into paragraphs and wrap each to `width`, +/// appending all produced lines to `out`. A trailing newline produces a final +/// empty line (so a freshly-typed "\n" shows a blank row). An empty buffer +/// produces no lines. +fn wrapBuffer(buffer: []const u8, width: usize, out: *std.ArrayList([]const u8), alloc: std.mem.Allocator) !void { + if (buffer.len == 0) return; + var it = std.mem.splitScalar(u8, buffer, '\n'); + while (it.next()) |para| { + try wrapParagraph(para, width, out, alloc); + } +} + +/// Build the cache-owned line set for a styled text block: each wrapped plain +/// line is wrapped in `style.open()`/`style.close()` and stored via the cache. +/// Returns the cache's owned lines re-typed for the vtable. +/// +/// `width` bounds the *visible* width: the plain text is truncated to `width` +/// columns BEFORE styling escapes are added (escapes are zero visible width). +fn renderStyledLines( + cache: *RenderCache, + buffer: []const u8, + style: Style, + width: usize, + alloc: std.mem.Allocator, +) ![]const []const u8 { + // 1. Wrap plain text into borrowed slices. + var plain: std.ArrayList([]const u8) = .empty; + defer plain.deinit(alloc); + try wrapBuffer(buffer, width, &plain, alloc); + + // 2. Style each line into a transient owned buffer. + var styled: std.ArrayList([]const u8) = .empty; + defer { + for (styled.items) |s| alloc.free(s); + styled.deinit(alloc); + } + for (plain.items) |line| { + // Defensive truncate (wrap already bounds it, but a hard contract). + const vis = truncateToCols(line, width); + const composed = try std.fmt.allocPrint(alloc, "{s}{s}{s}", .{ style.open(), vis, style.close() }); + try styled.append(alloc, composed); + } + + // 3. Commit to the cache (dupes), then return the cache's owned copy. + try cache.store(styled.items); + return cacheLines(cache); +} + +/// Re-type the cache's owned `[][]u8` lines as `[]const []const u8` for the +/// vtable return. The cache guarantees these outlive the call until the next +/// render/invalidate. +fn cacheLines(cache: *RenderCache) []const []const u8 { + const owned = cache.lines orelse return &.{}; + return @ptrCast(owned); +} + +// =========================================================================== +// AssistantText — streaming assistant message (plan §6, §8) +// =========================================================================== + +/// Accumulates assistant content deltas into an internal buffer and renders +/// the wrapped text with the theme's assistant style. +/// +/// Streaming-tail dirty model (plan §3.3): a delta is appended to the buffer +/// and the cache is marked dirty; the engine then requests a render. Because +/// appended text only changes the LAST wrapped line(s) and leaves earlier +/// wrapped lines byte-identical, `RenderCache.store`'s diff naturally reports +/// `firstLineChanged` near the TAIL, not line 0 — the cut stays near the end +/// during streaming. (There is no per-delta render method on the interface; +/// the delta just mutates state + dirties, per plan §8.) +/// +/// Markdown hosting (plan §8, DEFERRED): the buffer/render are structured so a +/// later pass can cache finished blocks and only re-render the last open block. +/// For P1 this is plain text + word wrap; the tail-near firstLineChanged +/// property already holds via the cache diff, so the markdown upgrade slots in +/// without changing the dirty model. +pub const AssistantText = struct { + alloc: std.mem.Allocator, + buffer: std.ArrayList(u8) = .empty, + cache: RenderCache, + + pub fn init(alloc: std.mem.Allocator) AssistantText { + return .{ .alloc = alloc, .cache = RenderCache.init(alloc) }; + } + + pub fn deinit(self: *AssistantText) void { + self.buffer.deinit(self.alloc); + self.cache.deinit(); + } + + /// Append a streaming content delta. Mutates the buffer and marks the cache + /// dirty (the engine will requestRender). The cache diff keeps + /// firstLineChanged near the tail. + pub fn appendDelta(self: *AssistantText, delta: []const u8) !void { + try self.buffer.appendSlice(self.alloc, delta); + // markDirtyAppend RETAINS the baseline so the post-render diff recovers + // the true tail change point; while dirty it reports a tail hint, so + // the engine's cut stays near the end during streaming (plan §3.3/§8). + self.cache.markDirtyAppend(); + } + + /// Replace the whole buffer (e.g. a non-streaming set). Marks dirty. + pub fn setText(self: *AssistantText, text: []const u8) !void { + self.buffer.clearRetainingCapacity(); + try self.buffer.appendSlice(self.alloc, text); + self.cache.markDirty(); + } + + fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { + _ = alloc; + const self: *AssistantText = @ptrCast(@alignCast(ptr)); + return renderStyledLines(&self.cache, self.buffer.items, theme.default.fg(.assistant), width, self.alloc); + } + + fn firstLineChangedImpl(ptr: *anyopaque) ?usize { + const self: *AssistantText = @ptrCast(@alignCast(ptr)); + return self.cache.firstLineChanged(); + } + + fn invalidateImpl(ptr: *anyopaque) void { + const self: *AssistantText = @ptrCast(@alignCast(ptr)); + self.cache.invalidate(); + } + + const vtable = Component.VTable{ + .render = renderImpl, + .firstLineChanged = firstLineChangedImpl, + .invalidate = invalidateImpl, + }; + + pub fn comp(self: *AssistantText) Component { + return .{ .ptr = self, .vtable = &vtable }; + } +}; + +// =========================================================================== +// UserText — submitted user message (plan §6) +// =========================================================================== + +/// A submitted user message, rendered with the theme's user style. Static once +/// set; `setText` replaces it and marks dirty. +pub const UserText = struct { + alloc: std.mem.Allocator, + buffer: std.ArrayList(u8) = .empty, + cache: RenderCache, + + pub fn init(alloc: std.mem.Allocator) UserText { + return .{ .alloc = alloc, .cache = RenderCache.init(alloc) }; + } + + pub fn deinit(self: *UserText) void { + self.buffer.deinit(self.alloc); + self.cache.deinit(); + } + + /// Set the (static) message text. Marks dirty. + pub fn setText(self: *UserText, text: []const u8) !void { + self.buffer.clearRetainingCapacity(); + try self.buffer.appendSlice(self.alloc, text); + self.cache.markDirty(); + } + + fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { + _ = alloc; + const self: *UserText = @ptrCast(@alignCast(ptr)); + return renderStyledLines(&self.cache, self.buffer.items, theme.default.fg(.user), width, self.alloc); + } + + fn firstLineChangedImpl(ptr: *anyopaque) ?usize { + const self: *UserText = @ptrCast(@alignCast(ptr)); + return self.cache.firstLineChanged(); + } + + fn invalidateImpl(ptr: *anyopaque) void { + const self: *UserText = @ptrCast(@alignCast(ptr)); + self.cache.invalidate(); + } + + const vtable = Component.VTable{ + .render = renderImpl, + .firstLineChanged = firstLineChangedImpl, + .invalidate = invalidateImpl, + }; + + pub fn comp(self: *UserText) Component { + return .{ .ptr = self, .vtable = &vtable }; + } +}; + +// =========================================================================== +// InputBox — editable single-row+ input (plan §6, §3.5) +// =========================================================================== + +/// A `Focusable` editor. Single row by default; ENTER submits, SHIFT+ENTER +/// inserts a newline (grows one row per line). Growth is UNBOUNDED in P1 (the +/// cap + scroll-window is deferred to P2). +/// +/// Editing (raw keys via `handleInput`, decoded by `tui_input`): +/// - printable chars (UTF-8) insert at the cursor +/// - backspace deletes the codepoint before the cursor +/// - delete removes the codepoint at the cursor +/// - left/right move by one codepoint; home/end jump to start/end of the +/// current visual buffer (P1: whole buffer, not per-line) +/// - ENTER submits the whole buffer (see "Submit mechanism") +/// - SHIFT+ENTER inserts a '\n' +/// +/// Cursor (plan §3.5): the box draws its OWN cursor as a reverse-video block +/// (theme `.cursor` style) over the glyph at the cursor position. When focused +/// it also emits `CURSOR_MARKER` (zero visible width) at the cursor location in +/// its render output, so the engine can later position the hardware cursor. +/// +/// SHIFT+ENTER limitation: on terminals without the Kitty protocol, Enter and +/// Shift+Enter send identical bytes (`\r`); the decoder cannot distinguish them +/// and both arrive as `.enter` with no shift modifier, so only plain submit is +/// possible there. When Kitty IS active, Shift+Enter arrives as CSI-u +/// (`\x1b[13;2u`) with `mods.shift` set and this box inserts a newline. The +/// logic works wherever the distinction is available. +/// +/// Submit mechanism: a POLLABLE buffer. On ENTER the current editor contents +/// are moved into `submitted` and the editor is cleared. The app calls +/// `takeSubmitted()` once per frame; it returns the submitted bytes (owned by +/// the box, valid until the next `takeSubmitted`/edit) and clears the pending +/// flag, or null if nothing was submitted. This avoids callback re-entrancy +/// into the render loop. +pub const InputBox = struct { + alloc: std.mem.Allocator, + focusable: Focusable = .{}, + /// Editor contents (may contain '\n' for multi-line input). UTF-8. + text: std.ArrayList(u8) = .empty, + /// Cursor position as a BYTE offset into `text` (always on a codepoint + /// boundary). + cursor: usize = 0, + /// Pending submitted line, owned by the box. Valid until the next submit + /// or `takeSubmitted`. + submitted: std.ArrayList(u8) = .empty, + has_submitted: bool = false, + cache: RenderCache, + + pub fn init(alloc: std.mem.Allocator) InputBox { + return .{ .alloc = alloc, .cache = RenderCache.init(alloc) }; + } + + pub fn deinit(self: *InputBox) void { + self.text.deinit(self.alloc); + self.submitted.deinit(self.alloc); + self.cache.deinit(); + } + + // -- focus ------------------------------------------------------------- + + /// Set focus. Re-dirties because the cursor block + marker only render when + /// focused, so focus changes alter the output. + pub fn setFocused(self: *InputBox, value: bool) void { + if (self.focusable.focused != value) { + self.focusable.setFocused(value); + self.cache.markDirty(); + } + } + + pub fn isFocused(self: *const InputBox) bool { + return self.focusable.focused; + } + + // -- submit polling ---------------------------------------------------- + + /// Poll the submitted line. Returns the bytes (box-owned) and clears the + /// pending flag, or null if nothing was submitted since the last poll. + pub fn takeSubmitted(self: *InputBox) ?[]const u8 { + if (!self.has_submitted) return null; + self.has_submitted = false; + return self.submitted.items; + } + + // -- editing primitives (also directly unit-testable) ------------------ + + fn insertText(self: *InputBox, bytes: []const u8) !void { + try self.text.insertSlice(self.alloc, self.cursor, bytes); + self.cursor += bytes.len; + self.cache.markDirty(); + } + + fn backspace(self: *InputBox) void { + if (self.cursor == 0) return; + const start = self.prevBoundary(self.cursor); + const removed = self.cursor - start; + std.mem.copyForwards(u8, self.text.items[start..], self.text.items[self.cursor..]); + self.text.items.len -= removed; + self.cursor = start; + self.cache.markDirty(); + } + + fn deleteForward(self: *InputBox) void { + if (self.cursor >= self.text.items.len) return; + const next = self.nextBoundary(self.cursor); + const removed = next - self.cursor; + std.mem.copyForwards(u8, self.text.items[self.cursor..], self.text.items[next..]); + self.text.items.len -= removed; + self.cache.markDirty(); + } + + fn moveLeft(self: *InputBox) void { + if (self.cursor == 0) return; + self.cursor = self.prevBoundary(self.cursor); + self.cache.markDirty(); + } + + fn moveRight(self: *InputBox) void { + if (self.cursor >= self.text.items.len) return; + self.cursor = self.nextBoundary(self.cursor); + self.cache.markDirty(); + } + + fn moveHome(self: *InputBox) void { + if (self.cursor == 0) return; + self.cursor = 0; + self.cache.markDirty(); + } + + fn moveEnd(self: *InputBox) void { + if (self.cursor == self.text.items.len) return; + self.cursor = self.text.items.len; + self.cache.markDirty(); + } + + fn submit(self: *InputBox) !void { + self.submitted.clearRetainingCapacity(); + try self.submitted.appendSlice(self.alloc, self.text.items); + self.has_submitted = true; + self.text.clearRetainingCapacity(); + self.cursor = 0; + self.cache.markDirty(); + } + + /// Byte index of the codepoint boundary before `i` (i > 0). + fn prevBoundary(self: *const InputBox, i: usize) usize { + var j = i - 1; + while (j > 0 and isContinuation(self.text.items[j])) : (j -= 1) {} + return j; + } + + /// Byte index of the next codepoint boundary after `i` (i < len). + fn nextBoundary(self: *const InputBox, i: usize) usize { + const seq_len = std.unicode.utf8ByteSequenceLength(self.text.items[i]) catch 1; + return @min(i + seq_len, self.text.items.len); + } + + fn isContinuation(b: u8) bool { + return (b & 0xc0) == 0x80; + } + + // -- input handling ---------------------------------------------------- + + /// Apply one decoded key. Split out so tests can drive editing without raw + /// byte sequences. + pub fn applyKey(self: *InputBox, k: Key) !void { + if (k.event == .release) return; + switch (k.code) { + .char => { + if (k.mods.ctrl or k.mods.alt or k.mods.super) return; // not a printable insert + if (k.text) |t| { + try self.insertText(t); + } else { + // Encode the codepoint ourselves when no text was carried. + var buf: [4]u8 = undefined; + const n = std.unicode.utf8Encode(k.code.char, &buf) catch return; + try self.insertText(buf[0..n]); + } + }, + .enter => { + if (k.mods.shift) { + try self.insertText("\n"); // shift+enter newline + } else { + try self.submit(); + } + }, + .backspace => self.backspace(), + .delete => self.deleteForward(), + .left => self.moveLeft(), + .right => self.moveRight(), + .home => self.moveHome(), + .end => self.moveEnd(), + else => {}, // tab, arrows up/down, fkeys: ignored in P1 + } + } + + fn handleInputImpl(ptr: *anyopaque, data: []const u8) void { + const self: *InputBox = @ptrCast(@alignCast(ptr)); + var off: usize = 0; + while (off < data.len) { + const step = input.decodeOne(data[off..]) orelse break; // partial tail: drop in P1 + off += step.consumed; + switch (step.decoded) { + .key => |k| self.applyKey(k) catch return, + .paste => |p| self.insertText(p) catch return, + } + } + } + + // -- render ------------------------------------------------------------ + + fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { + _ = alloc; + const self: *InputBox = @ptrCast(@alignCast(ptr)); + return self.renderLines(width); + } + + /// Render the editor: split on '\n' into visual rows, place the styled + /// cursor block + CURSOR_MARKER at the cursor row/column when focused. + /// Truncates each row to `width` columns. + fn renderLines(self: *InputBox, width: usize) ![]const []const u8 { + const a = self.alloc; + var rows: std.ArrayList([]const u8) = .empty; + defer { + for (rows.items) |r| a.free(r); + rows.deinit(a); + } + + const cursor_style = theme.default.fg(.cursor); + // Locate the cursor's (row, byte-col-in-row). + const focused = self.focusable.focused; + + // Walk lines, tracking byte offset so we know which row holds cursor. + var line_byte_start: usize = 0; + var produced_any = false; + var it = std.mem.splitScalar(u8, self.text.items, '\n'); + while (it.next()) |line| { + const line_start = line_byte_start; + const line_end = line_start + line.len; + const cursor_in_line = focused and self.cursor >= line_start and self.cursor <= line_end and + // The cursor belongs to the FIRST line whose range contains it + // (at a '\n' boundary it stays on the line before the break, + // i.e. == line_end). Disambiguate the boundary: if cursor == + // line_end and there are more lines, it belongs to the NEXT + // line's start unless this is the last line. + (self.cursor < line_end or it.peek() == null); + + const row = try self.renderRow(line, if (cursor_in_line) self.cursor - line_start else null, cursor_style, width, focused); + try rows.append(a, row); + produced_any = true; + line_byte_start = line_end + 1; // skip the '\n' + } + if (!produced_any) { + // Empty buffer: a single (possibly cursor-bearing) row. + const row = try self.renderRow("", if (focused) @as(?usize, 0) else null, cursor_style, width, focused); + try rows.append(a, row); + } + + try self.cache.store(rows.items); + return cacheLines(&self.cache); + } + + /// Render one visual row. `cursor_col` is the byte offset within `line` + /// where the cursor sits (null if the cursor isn't on this row). When + /// present and focused, draws a reverse-video block over the glyph at the + /// cursor (or a space at end-of-line) and emits CURSOR_MARKER there. + fn renderRow(self: *InputBox, line: []const u8, cursor_col: ?usize, cursor_style: Style, width: usize, focused: bool) ![]u8 { + const a = self.alloc; + // The cursor block consumes one visible column, so usable text width + // is width-1 when the cursor sits at/after the truncated end and we + // must show the block. To keep it simple and always-safe: truncate the + // plain line to `width` columns; if a cursor block would push us to + // width+1, the block replaces the last column instead. + const vis = truncateToCols(line, width); + + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(a); + + if (cursor_col == null or !focused) { + try buf.appendSlice(a, vis); + return buf.toOwnedSlice(a); + } + + // Cursor is on this row. Find the byte position within `vis`. + const cc = cursor_col.?; + const before_cols = displayWidth(line[0..@min(cc, line.len)]); + + if (cc >= line.len) { + // Cursor at end-of-line: block over a trailing space. Ensure room: + // if the visible text already fills `width`, drop its last column. + var head = vis; + if (before_cols >= width) { + head = truncateToCols(line, width - 1); + } + try buf.appendSlice(a, head); + try buf.appendSlice(a, CURSOR_MARKER); + try buf.appendSlice(a, cursor_style.open()); + try buf.appendSlice(a, " "); + try buf.appendSlice(a, cursor_style.close()); + return buf.toOwnedSlice(a); + } + + // Cursor over an interior glyph. Split: head | glyph | tail. + const glyph_len = blk: { + const sl = std.unicode.utf8ByteSequenceLength(line[cc]) catch 1; + break :blk @min(sl, line.len - cc); + }; + const head = line[0..cc]; + const glyph = line[cc .. cc + glyph_len]; + const tail = line[cc + glyph_len ..]; + + // Compose head + marker + [reverse]glyph[/] + tail, then truncate the + // whole visible width to `width` columns (escapes + marker are + // zero-width, so truncation acts on glyphs). + try buf.appendSlice(a, head); + try buf.appendSlice(a, CURSOR_MARKER); + try buf.appendSlice(a, cursor_style.open()); + try buf.appendSlice(a, glyph); + try buf.appendSlice(a, cursor_style.close()); + try buf.appendSlice(a, tail); + + // The composed row's visible width == displayWidth(line). If that + // exceeds `width`, rebuild with a width-bounded tail. (Rare: cursor + // near a long line's start.) Simpler safe path: if over, truncate the + // tail. + if (displayWidth(line) > width) { + buf.clearRetainingCapacity(); + // Keep head+glyph; truncate tail to remaining columns. + const used = before_cols + 1; // head cols + the glyph + try buf.appendSlice(a, head); + try buf.appendSlice(a, CURSOR_MARKER); + try buf.appendSlice(a, cursor_style.open()); + try buf.appendSlice(a, glyph); + try buf.appendSlice(a, cursor_style.close()); + if (used < width) { + const remaining = width - used; + try buf.appendSlice(a, truncateToCols(tail, remaining)); + } + } + return buf.toOwnedSlice(a); + } + + fn firstLineChangedImpl(ptr: *anyopaque) ?usize { + const self: *InputBox = @ptrCast(@alignCast(ptr)); + return self.cache.firstLineChanged(); + } + + fn invalidateImpl(ptr: *anyopaque) void { + const self: *InputBox = @ptrCast(@alignCast(ptr)); + self.cache.invalidate(); + } + + const vtable = Component.VTable{ + .render = renderImpl, + .firstLineChanged = firstLineChangedImpl, + .invalidate = invalidateImpl, + .handleInput = handleInputImpl, + }; + + pub fn comp(self: *InputBox) Component { + return .{ .ptr = self, .vtable = &vtable }; + } +}; + +// =========================================================================== +// Footer — persistent bottom line with frame-timing element (plan §6) +// =========================================================================== + +/// The persistent bottom line. For P1 it renders a FRAME-TIMING element: the +/// last frame's render time as a theoretical-max fps (1000/ms), shown inverted +/// (reverse-video). It optionally shows model info passed in by the app. The +/// fps element is TEMPORARY (removed after perf validation) but REQUIRED for +/// P1. +/// +/// Frame-time input: the app calls `setFrameTime(ms)` after each rendered frame +/// with the measured render duration in milliseconds; this updates the fps and +/// marks dirty so the footer repaints. `setModel(name)` sets the model info. +pub const Footer = struct { + alloc: std.mem.Allocator, + cache: RenderCache, + /// Last frame's render time in milliseconds (null = not measured yet). + frame_ms: ?f64 = null, + /// Model info string (borrowed; copied into a small owned buffer on set). + model: std.ArrayList(u8) = .empty, + + pub fn init(alloc: std.mem.Allocator) Footer { + return .{ .alloc = alloc, .cache = RenderCache.init(alloc) }; + } + + pub fn deinit(self: *Footer) void { + self.model.deinit(self.alloc); + self.cache.deinit(); + } + + /// Feed the last frame's render time (milliseconds). Marks dirty so the + /// footer's fps element repaints next frame. + pub fn setFrameTime(self: *Footer, ms: f64) void { + self.frame_ms = ms; + self.cache.markDirty(); + } + + /// Set the model info shown in the footer. + pub fn setModel(self: *Footer, name: []const u8) !void { + self.model.clearRetainingCapacity(); + try self.model.appendSlice(self.alloc, name); + self.cache.markDirty(); + } + + /// Format the theoretical-max fps element from the last frame time. + /// `fps = 1000 / ms`; a zero/sub-millisecond frame is reported as a capped + /// ">9999" sentinel rather than infinity. "--" when unmeasured. + fn fpsText(self: *const Footer, buf: []u8) []const u8 { + const ms = self.frame_ms orelse return std.fmt.bufPrint(buf, "fps: --", .{}) catch "fps: --"; + if (ms <= 0.0) return std.fmt.bufPrint(buf, "fps: >9999", .{}) catch "fps: >9999"; + const fps = 1000.0 / ms; + if (fps > 9999.0) return std.fmt.bufPrint(buf, "fps: >9999", .{}) catch "fps: >9999"; + return std.fmt.bufPrint(buf, "fps: {d:.0} ({d:.2}ms)", .{ fps, ms }) catch "fps: ?"; + } + + fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { + _ = alloc; + const self: *Footer = @ptrCast(@alignCast(ptr)); + const a = self.alloc; + + var fps_buf: [48]u8 = undefined; + const fps = self.fpsText(&fps_buf); + + // Build the PLAIN content: "<fps> <model>" (model only if set). + var plain: std.ArrayList(u8) = .empty; + defer plain.deinit(a); + try plain.appendSlice(a, fps); + if (self.model.items.len != 0) { + try plain.appendSlice(a, " "); + try plain.appendSlice(a, self.model.items); + } + + const vis = truncateToCols(plain.items, width); + + // The fps element is shown INVERTED (reverse video). The whole footer + // line uses reverse video so the timing element stands out; the model + // rides along in the same inverted run. (Temporary perf chrome.) + const cursor_style = theme.default.fg(.cursor); // reverse video + const composed = try std.fmt.allocPrint(a, "{s}{s}{s}", .{ cursor_style.open(), vis, cursor_style.close() }); + defer a.free(composed); + + const lines = [_][]const u8{composed}; + try self.cache.store(&lines); + return cacheLines(&self.cache); + } + + fn firstLineChangedImpl(ptr: *anyopaque) ?usize { + const self: *Footer = @ptrCast(@alignCast(ptr)); + return self.cache.firstLineChanged(); + } + + fn invalidateImpl(ptr: *anyopaque) void { + const self: *Footer = @ptrCast(@alignCast(ptr)); + self.cache.invalidate(); + } + + const vtable = Component.VTable{ + .render = renderImpl, + .firstLineChanged = firstLineChangedImpl, + .invalidate = invalidateImpl, + }; + + pub fn comp(self: *Footer) Component { + return .{ .ptr = self, .vtable = &vtable }; + } +}; + +// =========================================================================== +// Tests +// =========================================================================== + +const testing = std.testing; +const engine = @import("tui_engine.zig"); + +/// Visible width of a rendered (possibly styled, possibly marker-bearing) line, +/// reusing the engine's authoritative measure. +fn vw(line: []const u8) usize { + return engine.visibleWidth(line); +} + +// -- helpers --------------------------------------------------------------- + +test "displayWidth counts codepoints; truncateToCols respects boundaries" { + try testing.expectEqual(@as(usize, 3), displayWidth("abc")); + try testing.expectEqual(@as(usize, 3), displayWidth("aé✓")); + try testing.expectEqualStrings("aé", truncateToCols("aé✓", 2)); + try testing.expectEqualStrings("abc", truncateToCols("abcdef", 3)); + // Never splits a multibyte codepoint. + const t = truncateToCols("é", 1); + try testing.expectEqualStrings("é", t); +} + +test "wrapParagraph word-wraps and hard-splits long words" { + var out: std.ArrayList([]const u8) = .empty; + defer out.deinit(testing.allocator); + try wrapParagraph("hello world foo", 7, &out, testing.allocator); + try testing.expectEqual(@as(usize, 3), out.items.len); + try testing.expectEqualStrings("hello", out.items[0]); + try testing.expectEqualStrings("world", out.items[1]); + try testing.expectEqualStrings("foo", out.items[2]); + + out.clearRetainingCapacity(); + try wrapParagraph("abcdefghij", 4, &out, testing.allocator); + try testing.expectEqual(@as(usize, 3), out.items.len); + try testing.expectEqualStrings("abcd", out.items[0]); + try testing.expectEqualStrings("efgh", out.items[1]); + try testing.expectEqualStrings("ij", out.items[2]); +} + +// -- AssistantText --------------------------------------------------------- + +test "AssistantText: renders wrapped text within width" { + var at = AssistantText.init(testing.allocator); + defer at.deinit(); + try at.setText("hello world foo"); + const lines = try at.comp().render(7, testing.allocator); + try testing.expectEqual(@as(usize, 3), lines.len); + for (lines) |l| try testing.expect(vw(l) <= 7); + // First render after empty cache => changed from 0. + try testing.expectEqual(@as(?usize, 0), at.comp().firstLineChanged()); +} + +test "AssistantText: streaming keeps firstLineChanged near the tail, not 0" { + var at = AssistantText.init(testing.allocator); + defer at.deinit(); + // Seed several wrapped lines. + try at.setText("alpha beta gamma delta epsilon"); + _ = try at.comp().render(11, testing.allocator); + const lines1 = at.cache.lines.?.len; + try testing.expect(lines1 >= 3); + + // Append a delta to the tail; earlier wrapped lines stay byte-identical. + try at.appendDelta(" zeta"); + // While dirty, firstLineChanged reports 0 (full markDirty). The KEY + // property is what the cache reports AFTER the render: the lowest line that + // actually changed must be near the tail, not 0. + _ = try at.comp().render(11, testing.allocator); + const fc = at.comp().firstLineChanged(); + // The change landed on the last line(s); the cut must be > 0. + try testing.expect(fc == null or fc.? > 0); + // Stronger: the first changed line should be at the tail region. + if (fc) |v| try testing.expect(v >= lines1 - 1); +} + +test "AssistantText: width truncation on a long unbroken word" { + var at = AssistantText.init(testing.allocator); + defer at.deinit(); + try at.setText("supercalifragilistic"); + const lines = try at.comp().render(5, testing.allocator); + for (lines) |l| try testing.expect(vw(l) <= 5); +} + +// -- UserText --------------------------------------------------------------- + +test "UserText: renders user-styled lines within width" { + var ut = UserText.init(testing.allocator); + defer ut.deinit(); + try ut.setText("a user message that wraps"); + const lines = try ut.comp().render(10, testing.allocator); + for (lines) |l| try testing.expect(vw(l) <= 10); + // Static: re-render with no change => clean. + _ = try ut.comp().render(10, testing.allocator); + try testing.expectEqual(@as(?usize, null), ut.comp().firstLineChanged()); +} + +// -- InputBox --------------------------------------------------------------- + +fn charKey(c: u21, text: []const u8) Key { + return .{ .code = .{ .char = c }, .text = text }; +} + +test "InputBox: insert printable chars and render with cursor block when focused" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + ib.setFocused(true); + try ib.applyKey(charKey('h', "h")); + try ib.applyKey(charKey('i', "i")); + const lines = try ib.comp().render(20, testing.allocator); + try testing.expectEqual(@as(usize, 1), lines.len); + try testing.expect(vw(lines[0]) <= 20); + // Focused => emits CURSOR_MARKER and reverse-video style. + try testing.expect(std.mem.indexOf(u8, lines[0], CURSOR_MARKER) != null); + try testing.expect(std.mem.indexOf(u8, lines[0], "\x1b[7m") != null); + try testing.expect(std.mem.indexOf(u8, lines[0], "hi") != null); +} + +test "InputBox: not focused emits no cursor marker" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + try ib.applyKey(charKey('x', "x")); + const lines = try ib.comp().render(20, testing.allocator); + try testing.expect(std.mem.indexOf(u8, lines[0], CURSOR_MARKER) == null); +} + +test "InputBox: backspace, delete, and cursor movement" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + for ("abc") |c| try ib.applyKey(charKey(c, &[_]u8{c})); + try testing.expectEqual(@as(usize, 3), ib.cursor); + ib.backspace(); // "ab" + try testing.expectEqualStrings("ab", ib.text.items); + try testing.expectEqual(@as(usize, 2), ib.cursor); + ib.moveLeft(); // cursor at 1 + try testing.expectEqual(@as(usize, 1), ib.cursor); + ib.deleteForward(); // delete 'b' -> "a" + try testing.expectEqualStrings("a", ib.text.items); + ib.moveHome(); + try testing.expectEqual(@as(usize, 0), ib.cursor); + ib.moveEnd(); + try testing.expectEqual(@as(usize, 1), ib.cursor); +} + +test "InputBox: multibyte backspace removes a whole codepoint" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + try ib.applyKey(charKey('é', "é")); // 2 bytes + try testing.expectEqual(@as(usize, 2), ib.cursor); + ib.backspace(); + try testing.expectEqual(@as(usize, 0), ib.text.items.len); +} + +test "InputBox: shift+enter inserts newline, enter submits" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + for ("ab") |c| try ib.applyKey(charKey(c, &[_]u8{c})); + // Shift+Enter => newline (grows a row). + try ib.applyKey(.{ .code = .enter, .mods = .{ .shift = true } }); + for ("cd") |c| try ib.applyKey(charKey(c, &[_]u8{c})); + try testing.expectEqualStrings("ab\ncd", ib.text.items); + const lines = try ib.comp().render(20, testing.allocator); + try testing.expectEqual(@as(usize, 2), lines.len); + + // Plain Enter => submit, editor cleared, pollable buffer set. + try ib.applyKey(.{ .code = .enter }); + const got = ib.takeSubmitted(); + try testing.expect(got != null); + try testing.expectEqualStrings("ab\ncd", got.?); + try testing.expectEqual(@as(usize, 0), ib.text.items.len); + // Second poll returns null. + try testing.expect(ib.takeSubmitted() == null); +} + +test "InputBox: handleInput decodes raw bytes (typing + enter)" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + ib.comp().handleInput("hi\r"); // 'h' 'i' Enter + const got = ib.takeSubmitted(); + try testing.expect(got != null); + try testing.expectEqualStrings("hi", got.?); +} + +test "InputBox: handleInput kitty shift+enter inserts newline" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + ib.comp().handleInput("a\x1b[13;2ub"); // 'a', shift+enter, 'b' + try testing.expectEqualStrings("a\nb", ib.text.items); + try testing.expect(ib.takeSubmitted() == null); // no plain enter yet +} + +test "InputBox: firstLineChanged is cache-derived (clean after stable render)" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + ib.setFocused(true); + try ib.applyKey(charKey('x', "x")); + _ = try ib.comp().render(20, testing.allocator); + // Re-render with no state change => clean. + _ = try ib.comp().render(20, testing.allocator); + try testing.expectEqual(@as(?usize, null), ib.comp().firstLineChanged()); + // Edit => dirty again. + try ib.applyKey(charKey('y', "y")); + try testing.expectEqual(@as(?usize, 0), ib.comp().firstLineChanged()); +} + +test "InputBox: cursor block fits within width at end of a full line" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + ib.setFocused(true); + for ("abcde") |c| try ib.applyKey(charKey(c, &[_]u8{c})); + // width 5, cursor at end: the block must not overflow. + const lines = try ib.comp().render(5, testing.allocator); + try testing.expect(vw(lines[0]) <= 5); +} + +// -- Footer ----------------------------------------------------------------- + +test "Footer: renders fps from frame time, inverted, within width" { + var ft = Footer.init(testing.allocator); + defer ft.deinit(); + ft.setFrameTime(8.0); // 1000/8 = 125 fps + const lines = try ft.comp().render(80, testing.allocator); + try testing.expectEqual(@as(usize, 1), lines.len); + try testing.expect(vw(lines[0]) <= 80); + // Inverted (reverse video) styling present. + try testing.expect(std.mem.indexOf(u8, lines[0], "\x1b[7m") != null); + // fps value 125 present. + try testing.expect(std.mem.indexOf(u8, lines[0], "125") != null); +} + +test "Footer: unmeasured frame shows placeholder; submillisecond capped" { + var ft = Footer.init(testing.allocator); + defer ft.deinit(); + var buf: [48]u8 = undefined; + try testing.expectEqualStrings("fps: --", ft.fpsText(&buf)); + ft.setFrameTime(0.0); + try testing.expectEqualStrings("fps: >9999", ft.fpsText(&buf)); + ft.setFrameTime(0.05); // 20000 fps -> capped + try testing.expectEqualStrings("fps: >9999", ft.fpsText(&buf)); +} + +test "Footer: shows model info and truncates to width" { + var ft = Footer.init(testing.allocator); + defer ft.deinit(); + ft.setFrameTime(10.0); + try ft.setModel("gpt-test-model"); + const lines = try ft.comp().render(12, testing.allocator); + try testing.expect(vw(lines[0]) <= 12); +} + +test "Footer: setFrameTime dirties; stable re-render is clean" { + var ft = Footer.init(testing.allocator); + defer ft.deinit(); + ft.setFrameTime(8.0); + _ = try ft.comp().render(80, testing.allocator); + _ = try ft.comp().render(80, testing.allocator); + try testing.expectEqual(@as(?usize, null), ft.comp().firstLineChanged()); + ft.setFrameTime(16.0); + try testing.expectEqual(@as(?usize, 0), ft.comp().firstLineChanged()); +} + +// -- Integration with the real Engine (no TTY) ------------------------------ + +test "components drive the real engine without a TTY" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = engine.Engine.init(testing.allocator, &buf.writer, 40, 24, false); + defer eng.deinit(); + + var user = UserText.init(testing.allocator); + defer user.deinit(); + var assistant = AssistantText.init(testing.allocator); + defer assistant.deinit(); + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + var footer = Footer.init(testing.allocator); + defer footer.deinit(); + + try user.setText("hi there"); + try assistant.appendDelta("hello"); + ib.setFocused(true); + try ib.applyKey(charKey('q', "q")); + footer.setFrameTime(8.0); + + try eng.addComponent(user.comp()); + try eng.addComponent(assistant.comp()); + try eng.addComponent(ib.comp()); + try eng.addComponent(footer.comp()); + + try eng.render(); // first paint: must not error (width contract holds) + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, "hi there") != null); + try testing.expect(std.mem.indexOf(u8, out, "hello") != null); + // Cursor marker is consumed by the engine and recorded as a hint. + try testing.expect(eng.cursor_hint != null); + + // Stream another delta -> only the assistant should re-render; the engine + // stays on the differential path (no full clear after first paint). + try assistant.appendDelta(" world"); + footer.setFrameTime(9.0); + buf.clearRetainingCapacity(); + try eng.render(); + const out2 = buf.written(); + try testing.expect(std.mem.indexOf(u8, out2, "world") != null); +} diff --git a/src/tui_engine.zig b/src/tui_engine.zig new file mode 100644 index 0000000..fbe8474 --- /dev/null +++ b/src/tui_engine.zig @@ -0,0 +1,1116 @@ +//! The differential render engine for the TUI (plan §3). +//! +//! The engine owns a LIST of live `Component`s (plan invariant: there is NO +//! single "active component" — multiple tool calls render in parallel later, +//! so the engine always walks a list, even when the list has one element). +//! Each frame it walks the list top-to-bottom, asks each component to +//! `render(width)` into lines, and uses `firstLineChanged` plus an old-vs-new +//! line diff to repaint only what changed. +//! +//! Output is abstracted behind a `*std.Io.Writer` sink so the engine is +//! unit-testable without a real TTY: tests inject an in-memory writer and +//! assert on the emitted bytes. The real terminal is one implementation of the +//! sink (a `Terminal`-backed writer, wired in the app sub-phase). +//! +//! ## The render passes (plan §3.3) +//! +//! A single top-to-bottom walk: +//! 1. Render each component (or reuse its cached lines) and accumulate a +//! global line offset for each. +//! 2. Compute `cut = min(offset_i + firstLineChanged_i)` across ALL +//! components whose `firstLineChanged` is non-null. NOT just the first +//! dirty component: a ticking footer below an otherwise-clean transcript +//! is the counter-example. +//! 3. Reprint from `cut` downward. Components fully above the cut are +//! untouched. The component owning the cut and any dirty component below +//! re-render from their local `firstLineChanged`. CLEAN components below +//! the cut reuse their CACHED lines verbatim (reprinted because they sit +//! below the rolled-back point, but never re-rendered — no component CPU). +//! 4. Length deltas: when a component returns fewer lines than before, the +//! orphaned trailing lines are cleared and offsets below recomputed. +//! 5. Line-diff backstop: from `cut` downward we still diff old-vs-new +//! lines. `firstLineChanged` decides WHERE re-rendering starts (the fast +//! path); the diff is the CORRECTNESS FLOOR that defends against an +//! inaccurate signal and handles length deltas. +//! +//! ## Viewport & scrollback (plan §3.2) +//! +//! Lines above `viewport_top` have scrolled into the terminal's NATIVE +//! scrollback and are off-limits to differential updates. If a change lands +//! above `viewport_top`, the engine falls back to a full redraw. We never +//! implement our own scrollback — content that grows past the top scrolls up +//! into the real terminal scrollback. +//! +//! ## Output discipline (plan §3.1) +//! +//! Every frame is wrapped in synchronized output. A full redraw happens ONLY +//! when forced (first paint, width change, height change, or a change above +//! `viewport_top`) and clears scrollback; ordinary frames NEVER clear +//! scrollback. + +const std = @import("std"); +const component = @import("tui_component.zig"); +const terminal = @import("tui_terminal.zig"); + +const Component = component.Component; +const CURSOR_MARKER = component.CURSOR_MARKER; + +pub const Error = error{ + /// A component returned a line whose visible width exceeds the render + /// width. Components must truncate; the engine treats overflow as a hard + /// error (plan §3.1). The caller is expected to restore the terminal and + /// surface a diagnostic. + LineOverflow, +} || std.mem.Allocator.Error || std.Io.Writer.Error; + +/// Visible (display-column) width of a rendered line, ignoring escape +/// sequences and the zero-width cursor marker. +/// +/// P1 scope: strips CSI (`ESC [ ... final`), SS2/SS3 (`ESC N`/`ESC O` + 1 +/// byte), OSC (`ESC ] ... BEL|ST`), and APC/PM/DCS-style strings (`ESC _`/`ESC +/// ^`/`ESC P` ... `ST`) — the last covers `CURSOR_MARKER`. Remaining bytes are +/// counted as UTF-8 codepoints, one column each. Wide-character (CJK/emoji) +/// width is a documented P1 approximation; refining it is deferred. +pub fn visibleWidth(line: []const u8) usize { + var cols: usize = 0; + var i: usize = 0; + while (i < line.len) { + const b = line[i]; + if (b == 0x1b) { + i += skipEscape(line[i..]); + continue; + } + // Count one column per UTF-8 codepoint start byte. + const seq_len = std.unicode.utf8ByteSequenceLength(b) catch 1; + cols += 1; + i += @min(seq_len, line.len - i); + } + return cols; +} + +/// Returns the number of bytes the escape sequence at the start of `s` +/// (s[0] == ESC) occupies. `s` is guaranteed non-empty with s[0] == 0x1b. +fn skipEscape(s: []const u8) usize { + if (s.len < 2) return s.len; // lone trailing ESC + switch (s[1]) { + '[' => { + // CSI: params/intermediates until a final byte 0x40..0x7e. + var i: usize = 2; + while (i < s.len) : (i += 1) { + if (s[i] >= 0x40 and s[i] <= 0x7e) return i + 1; + } + return s.len; + }, + ']' => { + // OSC: terminated by BEL or ST (ESC \). + var i: usize = 2; + while (i < s.len) : (i += 1) { + if (s[i] == 0x07) return i + 1; + if (s[i] == 0x1b and i + 1 < s.len and s[i + 1] == '\\') return i + 2; + } + return s.len; + }, + '_', '^', 'P' => { + // APC / PM / DCS: terminated by ST (ESC \). Covers CURSOR_MARKER. + var i: usize = 2; + while (i < s.len) : (i += 1) { + if (s[i] == 0x1b and i + 1 < s.len and s[i + 1] == '\\') return i + 2; + } + return s.len; + }, + 'N', 'O' => { + // SS2 / SS3: one following byte. + return @min(@as(usize, 3), s.len); + }, + else => { + // ESC + single byte (e.g. ESC c) — consume both. + return 2; + }, + } +} + +/// Strip the cursor marker from `line` into `out`, returning the written +/// slice. P1 only removes the marker so it never prints; locating it for +/// hardware-cursor placement is the P3 hook (see `cursor_hint`). +fn stripCursorMarker(line: []const u8, out: []u8) []const u8 { + if (std.mem.indexOf(u8, line, CURSOR_MARKER)) |idx| { + const before = line[0..idx]; + const after = line[idx + CURSOR_MARKER.len ..]; + @memcpy(out[0..before.len], before); + @memcpy(out[before.len .. before.len + after.len], after); + return out[0 .. before.len + after.len]; + } + return line; +} + +/// Per-component bookkeeping carried across frames. +const Slot = struct { + comp: Component, + /// Global (engine-wide) line offset of this component's first line at the + /// last render. + offset: usize = 0, + /// Line count this component produced at the last render. + line_count: usize = 0, + /// The lines this component produced at the last render. These are the + /// diff baseline AND the cache the engine reprints verbatim for a clean + /// component below the cut. Owned by the engine (duped per render). + lines: [][]u8 = &.{}, +}; + +/// A monotonic clock abstraction so the coalescing scheduler is testable +/// without a real clock. `now()` returns nanoseconds. +pub const Clock = struct { + ptr: *anyopaque, + nowFn: *const fn (ptr: *anyopaque) i128, + + pub fn now(self: Clock) i128 { + return self.nowFn(self.ptr); + } + + // A real monotonic clock is provided by the app sub-phase as an + // `Io`-backed `Clock` (this std's monotonic clock lives behind the `Io` + // interface, `std.Io.Clock.now(.awake, io)`, so it can't be a free + // function here). The engine stays Io-agnostic: it only consumes the + // injected `Clock` vtable. Tests inject a deterministic clock directly. +}; + +/// Frame coalescing scheduler (plan §3.4). +/// +/// `requestRender` records that a frame is wanted. `shouldRenderNow` is the +/// pure decision function: render immediately when idle (no frame drawn within +/// the coalescing window), otherwise defer until the window since the last +/// render elapses. The window is a CEILING so we never redraw faster than the +/// terminal can drain (~120fps default). The actual sleeping/event-loop +/// integration belongs to the app sub-phase; this struct only decides. +pub const Scheduler = struct { + /// Coalescing window in nanoseconds (default ~8ms ≈ 120fps). + window_ns: i128 = 8 * std.time.ns_per_ms, + /// Timestamp of the last render, or null if none yet. + last_render: ?i128 = null, + /// Whether a render has been requested since the last one ran. + pending: bool = false, + + pub fn init(window_ns: i128) Scheduler { + return .{ .window_ns = window_ns }; + } + + /// Record that a frame is wanted. + pub fn requestRender(self: *Scheduler) void { + self.pending = true; + } + + /// Pure decision: given the current time, may we render now? Renders + /// immediately when idle; under burst, defers until the window elapses. + pub fn shouldRenderNow(self: *const Scheduler, now: i128) bool { + if (!self.pending) return false; + const last = self.last_render orelse return true; // idle: render now + return now - last >= self.window_ns; + } + + /// Nanoseconds until the next render is permitted, or 0 if allowed now. + /// Useful for an event loop's sleep duration. Returns null when no frame + /// is pending. + pub fn nextDeadline(self: *const Scheduler, now: i128) ?i128 { + if (!self.pending) return null; + const last = self.last_render orelse return 0; + const elapsed = now - last; + if (elapsed >= self.window_ns) return 0; + return self.window_ns - elapsed; + } + + /// Mark that a render ran at `now`, clearing the pending flag. + pub fn noteRendered(self: *Scheduler, now: i128) void { + self.last_render = now; + self.pending = false; + } +}; + +/// The differential render engine. +pub const Engine = struct { + alloc: std.mem.Allocator, + /// Output sink. The real terminal is one implementation; tests inject an + /// in-memory writer. + writer: *std.Io.Writer, + /// Whether to wrap frames in synchronized-output escapes. + synchronized_output: bool, + + /// Live components, top-to-bottom. The engine holds a LIST (never a single + /// active component). + slots: std.ArrayList(Slot) = .empty, + + /// Render width in columns. Components are asked to render at this width + /// and every returned line must fit. + width: usize, + /// Terminal height in rows. + height: usize, + + /// Global line index of the first line still inside the visible viewport. + /// Lines below this index are addressable for differential repaint; lines + /// above have scrolled into native scrollback and are off-limits. + viewport_top: usize = 0, + /// Total lines produced by the last render pass. + total_lines: usize = 0, + /// Highest `total_lines` we've ever rendered (used to clear orphaned + /// trailing lines on shrink). + max_lines_rendered: usize = 0, + /// Forces a full redraw on the next frame (first paint, resize). + force_full: bool = true, + + /// P3 hook: the global (line, col) where the focused component drew its + /// cursor marker, or null. P1 records it but leaves the hardware cursor at + /// end-of-content (the documented safe deferral); P3 wires hardware + /// positioning. + cursor_hint: ?struct { line: usize, col: usize } = null, + + pub fn init( + alloc: std.mem.Allocator, + writer: *std.Io.Writer, + width: usize, + height: usize, + synchronized_output: bool, + ) Engine { + return .{ + .alloc = alloc, + .writer = writer, + .synchronized_output = synchronized_output, + .width = width, + .height = height, + }; + } + + pub fn deinit(self: *Engine) void { + for (self.slots.items) |*slot| self.freeSlotLines(slot); + self.slots.deinit(self.alloc); + } + + fn freeSlotLines(self: *Engine, slot: *Slot) void { + for (slot.lines) |line| self.alloc.free(line); + if (slot.lines.len != 0) self.alloc.free(slot.lines); + slot.lines = &.{}; + } + + // -- component list management ----------------------------------------- + + /// Append a component to the live list. The engine does not take ownership + /// of the component's backing state, only of its slot bookkeeping. + pub fn addComponent(self: *Engine, comp: Component) !void { + try self.slots.append(self.alloc, .{ .comp = comp }); + self.force_full = true; // layout changed + } + + /// Remove the component matching `comp.ptr` from the list. Returns true if + /// found. Forces a full redraw (layout changed). + pub fn removeComponent(self: *Engine, comp: Component) bool { + for (self.slots.items, 0..) |slot, i| { + if (slot.comp.ptr == comp.ptr) { + var removed = self.slots.orderedRemove(i); + self.freeSlotLines(&removed); + self.force_full = true; + return true; + } + } + return false; + } + + pub fn componentCount(self: *const Engine) usize { + return self.slots.items.len; + } + + // -- resize ------------------------------------------------------------ + + /// Apply a new terminal size. A width change (wrapping changes) or height + /// change (viewport realignment) forces a full redraw on the next frame. + pub fn resize(self: *Engine, width: usize, height: usize) void { + if (width != self.width or height != self.height) { + self.force_full = true; + } + self.width = width; + self.height = height; + } + + /// Force the next frame to be a full redraw (e.g. on unrecoverable doubt). + pub fn forceFullRedraw(self: *Engine) void { + self.force_full = true; + } + + // -- the render -------------------------------------------------------- + + /// Render a frame. Walks the component list, computes the differential + /// repaint, and writes it (wrapped in synchronized output) to the sink. + /// + /// Returns `anyerror` because a component's `render` may surface an + /// arbitrary error; engine-originated failures are the narrower + /// `Engine.Error` (notably `Error.LineOverflow` for the width contract). + pub fn render(self: *Engine) anyerror!void { + // 1. Collect this frame's lines per component, computing global + // offsets. Clean components below the eventual cut keep their cached + // lines (no re-render); only dirty/first-render components render. + const Frame = struct { + new_lines: []const []const u8, // borrowed (component- or cache-owned) + first_changed: ?usize, // local first-changed signal + old_count: usize, + offset: usize, + }; + var frames = try self.alloc.alloc(Frame, self.slots.items.len); + defer self.alloc.free(frames); + + var offset: usize = 0; + var cut: ?usize = null; + + for (self.slots.items, 0..) |*slot, idx| { + const first_changed = slot.comp.firstLineChanged(); + // Render only when there's a reason to: dirty signal, or first + // paint (no cached lines yet), or a forced full redraw. + const must_render = first_changed != null or slot.lines.len == 0 or self.force_full; + + var new_lines: []const []const u8 = undefined; + if (must_render) { + new_lines = try slot.comp.render(self.width, self.alloc); + } else { + // CLEAN below-cut reuse path: no re-render, reprint the cache. + new_lines = slot.lines; + } + + // Width contract (plan §3.1): enforce every line fits. + for (new_lines) |line| { + if (visibleWidth(line) > self.width) return Error.LineOverflow; + } + + frames[idx] = .{ + .new_lines = new_lines, + .first_changed = first_changed, + .old_count = slot.line_count, + .offset = offset, + }; + + // 2. cut = min across ALL components (not just the first dirty). + if (first_changed) |fc| { + const global = offset + fc; + cut = if (cut) |c| @min(c, global) else global; + } + // A length delta is itself a change point even when the component + // reports firstLineChanged == null (the diff backstop will catch + // the content, but the boundary must roll the cut back). + if (new_lines.len != slot.line_count) { + const boundary = offset + @min(new_lines.len, slot.line_count); + cut = if (cut) |c| @min(c, boundary) else boundary; + } + + offset += new_lines.len; + } + const new_total = offset; + + // 2b. Line-diff backstop (plan §3.3 step 5): the CORRECTNESS FLOOR. + // `firstLineChanged` is the fast-path *input* deciding where + // re-rendering starts, but a component can misreport it. From the + // signal-derived cut downward we diff the flattened OLD global lines + // against the NEW global lines and lower `cut` to the first true + // divergence. For clean (not re-rendered) components new == old, so + // they contribute no false divergence; only a component that + // actually re-rendered to different bytes (or changed length) can + // move the cut here. This both defends a lying signal and is what + // ultimately guarantees correctness. + if (try self.lineDiffBackstop(frames)) |diff_cut| { + cut = if (cut) |c| @min(c, diff_cut) else diff_cut; + } + + // 3. Decide full vs differential. + // Full redraw is forced by: first paint / resize (`force_full`), or + // a change that lands above `viewport_top` (off-limits to diff). + var full = self.force_full; + if (!full) { + if (cut) |c| { + if (c < self.viewport_top) full = true; + } + } + + try self.beginFrame(); + + if (full) { + try self.fullRedraw(frames, new_total); + } else { + try self.differential(frames, cut, new_total); + } + + try self.endFrame(); + + // 4. Commit: store each component's new lines as the next baseline and + // record offsets. Clean-reuse slots keep their existing owned copy. + var commit_offset: usize = 0; + for (self.slots.items, 0..) |*slot, idx| { + const f = frames[idx]; + if (f.new_lines.ptr != slot.lines.ptr or f.new_lines.len != slot.lines.len) { + // The component rendered fresh lines; dupe them as the baseline. + try self.storeSlotLines(slot, f.new_lines); + } + slot.offset = commit_offset; + slot.line_count = f.new_lines.len; + commit_offset += f.new_lines.len; + } + + self.total_lines = new_total; + if (new_total > self.max_lines_rendered) self.max_lines_rendered = new_total; + self.recomputeViewportTop(); + self.force_full = false; + } + + /// Dupe `new_lines` into the slot's owned baseline, freeing the old copy. + fn storeSlotLines(self: *Engine, slot: *Slot, new_lines: []const []const u8) !void { + var copies = try self.alloc.alloc([]u8, new_lines.len); + var made: usize = 0; + errdefer { + for (copies[0..made]) |c| self.alloc.free(c); + self.alloc.free(copies); + } + for (new_lines, 0..) |line, i| { + copies[i] = try self.alloc.dupe(u8, line); + made = i + 1; + } + self.freeSlotLines(slot); + slot.lines = copies; + } + + /// Line-diff backstop (the CORRECTNESS FLOOR). Flattens the previous + /// baseline lines (`slot.lines`) and the new frame lines into global arrays + /// and returns the FIRST global index where they actually differ, or null + /// if identical. + /// + /// It scans from index 0 (NOT from the signal cut) precisely because the + /// signal can be wrong: a component may claim its change starts lower than + /// it really does. The caller takes `min(signal_cut, backstop)`, so this + /// can only roll the cut earlier, never later. For clean (not re-rendered) + /// components the new slice IS the old baseline, so they contribute no + /// spurious divergence; only a component that actually produced different + /// bytes — or changed length — moves the cut here. + fn lineDiffBackstop(self: *Engine, frames: anytype) Error!?usize { + // Build old and new flattened global line arrays. + var old_lines: std.ArrayList([]const u8) = .empty; + defer old_lines.deinit(self.alloc); + var new_lines: std.ArrayList([]const u8) = .empty; + defer new_lines.deinit(self.alloc); + + for (self.slots.items) |slot| { + for (slot.lines) |l| try old_lines.append(self.alloc, l); + } + for (frames) |f| { + for (f.new_lines) |l| try new_lines.append(self.alloc, l); + } + + const n = @min(old_lines.items.len, new_lines.items.len); + var i: usize = 0; + while (i < n) : (i += 1) { + if (!std.mem.eql(u8, old_lines.items[i], new_lines.items[i])) return i; + } + // Lengths differ beyond the common prefix: first extra/missing line. + if (old_lines.items.len != new_lines.items.len) return n; + return null; + } + + fn beginFrame(self: *Engine) Error!void { + if (self.synchronized_output) try self.writer.writeAll(terminal.seq.sync_begin); + } + + fn endFrame(self: *Engine) Error!void { + if (self.synchronized_output) try self.writer.writeAll(terminal.seq.sync_end); + } + + /// Full redraw: clear screen + scrollback, home cursor, print everything + /// from the top. Resets the viewport. + /// + /// Cursor-resting invariant (shared with `differential`): the last line is + /// written WITHOUT a trailing newline, so after the frame the hardware + /// cursor rests at the end of the last content line (global row + /// `total_lines - 1`), not one row below it. `differential` relies on this + /// to compute how far to move up. + fn fullRedraw(self: *Engine, frames: anytype, new_total: usize) Error!void { + try self.writer.writeAll(terminal.seq.full_clear); + self.cursor_hint = null; + var global_line: usize = 0; + var first = true; + for (frames) |f| { + for (f.new_lines) |line| { + if (!first) try self.writer.writeAll("\r\n"); + try self.writeLineNoNewline(line, global_line); + first = false; + global_line += 1; + } + } + // After a full redraw nothing is orphaned; the screen is clean. + _ = new_total; + } + + /// Differential redraw: from `cut` downward, move the cursor up to the cut + /// line, clear to end of screen, and reprint every line at/after the cut. + /// Components above the cut are untouched. Clean components below the cut + /// were not re-rendered (their cached lines flow through `frames`), but are + /// reprinted here because they sit below the rolled-back point. + fn differential(self: *Engine, frames: anytype, cut: ?usize, new_total: usize) Error!void { + const cut_line = cut orelse { + // Nothing changed and no length delta: emit an empty (but + // synchronized) frame. This is the no-op fast path. + return; + }; + + // Cursor-resting invariant: after the previous frame the hardware + // cursor rests at the END of the last content line (global row + // `prev_total - 1`), because the last line is written without a + // trailing newline. To reach the cut line we move up by + // `(prev_total - 1) - cut_line`, then carriage-return to column 0. + const prev_total = self.total_lines; + const last_row = if (prev_total == 0) 0 else prev_total - 1; + const up = last_row - @min(cut_line, last_row); + if (up > 0) try self.cursorUp(up); + try self.writer.writeAll(terminal.seq.carriage_return); + // Clear from the cut downward; this also handles orphaned trailing + // lines when the content shrank (plan §3.3 step 4). + try self.writer.writeAll(terminal.seq.clear_to_end); + + self.cursor_hint = null; + var global_line: usize = 0; + var first = true; + for (frames) |f| { + for (f.new_lines) |line| { + if (global_line >= cut_line) { + if (!first) try self.writer.writeAll("\r\n"); + try self.writeLineNoNewline(line, global_line); + first = false; + } + global_line += 1; + } + } + _ = new_total; + } + + /// Write a line WITHOUT a trailing newline. Strips the cursor marker (P1) + /// and records the cursor hint for P3. Both render paths join lines with an + /// explicit `\r\n` between them and omit the trailing newline after the + /// last line, to maintain the cursor-resting invariant (see `fullRedraw`). + fn writeLineNoNewline(self: *Engine, line: []const u8, global_line: usize) Error!void { + if (std.mem.indexOf(u8, line, CURSOR_MARKER)) |idx| { + // P1: record where the cursor would go (hook for P3), strip the + // marker so it never prints. Column is the visible width of the + // text before the marker. + self.cursor_hint = .{ .line = global_line, .col = visibleWidth(line[0..idx]) }; + const buf = try self.alloc.alloc(u8, line.len); + defer self.alloc.free(buf); + const stripped = stripCursorMarker(line, buf); + try self.writer.writeAll(stripped); + } else { + try self.writer.writeAll(line); + } + } + + fn cursorUp(self: *Engine, n: usize) Error!void { + var buf: [16]u8 = undefined; + const s = std.fmt.bufPrint(&buf, "\x1b[{d}A", .{n}) catch return; + try self.writer.writeAll(s); + } + + /// Recompute `viewport_top`: lines beyond the terminal height have scrolled + /// into native scrollback and are off-limits to future differential + /// updates. The bottom `height` lines remain addressable. + fn recomputeViewportTop(self: *Engine) void { + if (self.total_lines > self.height) { + self.viewport_top = self.total_lines - self.height; + } else { + self.viewport_top = 0; + } + } +}; + +// =========================================================================== +// Tests +// =========================================================================== + +const testing = std.testing; + +/// A scripted fake component for tests. Returns a programmed sequence of line +/// sets across successive renders and a programmed `firstLineChanged`. Counts +/// render calls to prove the no-re-render-of-clean-below-cut property. +const FakeComponent = struct { + scripts: []const []const []const u8, + first_changed: []const ?usize, + step: usize = 0, + render_calls: usize = 0, + + fn render(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { + const self: *FakeComponent = @ptrCast(@alignCast(ptr)); + _ = width; + _ = alloc; + self.render_calls += 1; + const idx = @min(self.step, self.scripts.len - 1); + return self.scripts[idx]; + } + + fn firstLineChanged(ptr: *anyopaque) ?usize { + const self: *FakeComponent = @ptrCast(@alignCast(ptr)); + const idx = @min(self.step, self.first_changed.len - 1); + return self.first_changed[idx]; + } + + fn invalidate(ptr: *anyopaque) void { + _ = ptr; + } + + const vtable = Component.VTable{ + .render = render, + .firstLineChanged = firstLineChanged, + .invalidate = invalidate, + }; + + fn comp(self: *FakeComponent) Component { + return .{ .ptr = self, .vtable = &vtable }; + } + + /// Advance to the next scripted step (simulating the app mutating state + + /// the cache recomputing firstLineChanged). + fn advance(self: *FakeComponent) void { + self.step += 1; + } +}; + +fn makeEngine(buf: *std.Io.Writer.Allocating, width: usize, height: usize) Engine { + return Engine.init(testing.allocator, &buf.writer, width, height, false); +} + +test "visibleWidth strips CSI and counts codepoints" { + try testing.expectEqual(@as(usize, 5), visibleWidth("hello")); + try testing.expectEqual(@as(usize, 5), visibleWidth("\x1b[2mhello\x1b[0m")); + try testing.expectEqual(@as(usize, 0), visibleWidth("\x1b[0m")); + // CURSOR_MARKER (APC string) is zero-width. + try testing.expectEqual(@as(usize, 2), visibleWidth("a" ++ CURSOR_MARKER ++ "b")); + // multibyte UTF-8 counts one column per codepoint. + try testing.expectEqual(@as(usize, 3), visibleWidth("aé✓")); +} + +test "first paint is a full redraw clearing scrollback" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 24); + defer eng.deinit(); + + var fc = FakeComponent{ + .scripts = &.{&.{ "line one", "line two" }}, + .first_changed = &.{0}, + }; + try eng.addComponent(fc.comp()); + try eng.render(); + + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) != null); + try testing.expect(std.mem.indexOf(u8, out, "line one") != null); + try testing.expect(std.mem.indexOf(u8, out, "line two") != null); + try testing.expectEqual(@as(usize, 1), fc.render_calls); +} + +test "cut is the min across ALL components (ticking footer below clean body)" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 100); // tall: nothing scrolls off + defer eng.deinit(); + + // Body: clean after first paint. Footer: dirty at local line 0 each step. + var body = FakeComponent{ + .scripts = &.{ &.{ "b0", "b1" }, &.{ "b0", "b1" } }, + .first_changed = &.{ 0, null }, // first paint dirty, then clean + }; + var footer = FakeComponent{ + .scripts = &.{ &.{"f-0"}, &.{"f-1"} }, + .first_changed = &.{ 0, 0 }, // always dirty at local 0 + }; + try eng.addComponent(body.comp()); + try eng.addComponent(footer.comp()); + + try eng.render(); // first paint (full) + body.advance(); + footer.advance(); + buf.clearRetainingCapacity(); + try eng.render(); // differential + + // The footer occupies global line 2 (after body's 2 lines). The cut must + // be 2 (min across all), NOT 0, and NOT "first dirty component" (body is + // clean now). So the body lines must NOT be reprinted. + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, "f-1") != null); + try testing.expect(std.mem.indexOf(u8, out, "b0") == null); + try testing.expect(std.mem.indexOf(u8, out, "b1") == null); + // Body was clean and below... no: body is ABOVE the cut, so untouched and + // NOT re-rendered. + try testing.expectEqual(@as(usize, 1), body.render_calls); // only first paint +} + +test "clean components below the cut are reprinted but NOT re-rendered" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 100); + defer eng.deinit(); + + // Header is dirty (forces the cut up to line 0); body is clean but sits + // below the cut, so its CACHED lines must be reprinted without re-render. + var header = FakeComponent{ + .scripts = &.{ &.{"h-0"}, &.{"h-1"} }, + .first_changed = &.{ 0, 0 }, + }; + var body = FakeComponent{ + .scripts = &.{ &.{ "b0", "b1" }, &.{ "b0", "b1" } }, + .first_changed = &.{ 0, null }, + }; + try eng.addComponent(header.comp()); + try eng.addComponent(body.comp()); + + try eng.render(); // first paint + header.advance(); + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); // differential, cut == 0 + + const out = buf.written(); + // Header changed and reprinted. + try testing.expect(std.mem.indexOf(u8, out, "h-1") != null); + // Body is below the cut => reprinted verbatim from cache... + try testing.expect(std.mem.indexOf(u8, out, "b0") != null); + try testing.expect(std.mem.indexOf(u8, out, "b1") != null); + // ...but NOT re-rendered: render_calls stayed at 1 (the first paint). + try testing.expectEqual(@as(usize, 1), body.render_calls); +} + +test "shrinking component clears orphaned trailing lines" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 100); + defer eng.deinit(); + + // Body collapses from 3 lines to 1. A shrinking component marks dirty + // (RenderCache reports the divergence boundary), so it re-renders; the + // engine must clear the orphaned trailing lines and recompute totals. + var body = FakeComponent{ + .scripts = &.{ &.{ "x0", "x1", "x2" }, &.{"x0"} }, + .first_changed = &.{ 0, 1 }, + }; + try eng.addComponent(body.comp()); + + try eng.render(); + try testing.expectEqual(@as(usize, 3), eng.total_lines); + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); + + // Orphans removed: total dropped from 3 to 1. + try testing.expectEqual(@as(usize, 1), eng.total_lines); + const out = buf.written(); + // The differential frame must clear to end of screen to erase the two + // orphaned trailing lines (x1, x2). + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.clear_to_end) != null); + // x1/x2 must NOT be reprinted (they were cleared). + try testing.expect(std.mem.indexOf(u8, out, "x1") == null); + try testing.expect(std.mem.indexOf(u8, out, "x2") == null); + // No full redraw was needed — shrink stays on the differential path. + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); +} + +test "line-diff backstop corrects an inaccurate firstLineChanged" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 100); + defer eng.deinit(); + + // The component IS re-rendered (it reports dirty), but its signal LIES + // about WHERE the change is: it claims the change starts at line 2 while + // the real divergence is at line 0. The signal alone would reprint only + // from line 2 down and leave the stale line 0 on screen; the line-diff + // backstop must roll the cut back to 0 so "A0" actually paints. + var body = FakeComponent{ + .scripts = &.{ &.{ "a0", "a1", "a2" }, &.{ "A0", "a1", "a2" } }, + .first_changed = &.{ 0, 2 }, // lie: real change is at line 0 + }; + try eng.addComponent(body.comp()); + + try eng.render(); + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); + + const out = buf.written(); + // Backstop rolled the cut from the claimed line 2 back to line 0, so the + // new first line is reprinted. + try testing.expect(std.mem.indexOf(u8, out, "A0") != null); + // And no full redraw was needed — this stayed on the differential path. + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); +} + +test "consecutive differential frames move the cursor up correctly (streaming append)" { + // Regression: the differential path must account for the cursor resting at + // the END of the last content line (no trailing newline), not one row + // below it. The original code assumed cursor-at-`prev_total`, so the second + // (and every subsequent) differential frame moved up one row too few, + // clobbering everything but the last line — the streaming bug. + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 100); // tall: nothing scrolls off + defer eng.deinit(); + + // A footer that ticks at local line 0 on every frame, sitting below a body + // that grows by one line each step (the streaming transcript shape). + var body = FakeComponent{ + .scripts = &.{ + &.{"l0"}, + &.{ "l0", "l1" }, + &.{ "l0", "l1", "l2" }, + }, + // append-only: each step's first change is the new tail line. + .first_changed = &.{ 0, 1, 2 }, + }; + try eng.addComponent(body.comp()); + + try eng.render(); // first paint (full): l0 + try testing.expectEqual(@as(usize, 1), eng.total_lines); + + // Frame 2 (differential): append l1. prev_total==1 => last_row==0 => up==0. + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); + { + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); + // cut is line 1; cursor was on row 0 (end of l0), so NO up-move at all. + try testing.expect(std.mem.indexOf(u8, out, "\x1b[1A") == null); + try testing.expect(std.mem.indexOf(u8, out, "l1") != null); + } + try testing.expectEqual(@as(usize, 2), eng.total_lines); + + // Frame 3 (differential): append l2. prev_total==2 => last_row==1, cut==2. + // up == last_row - min(cut,last_row) == 1 - 1 == 0 (cut is below cursor). + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); + { + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); + try testing.expect(std.mem.indexOf(u8, out, "l2") != null); + // l0 must NOT be reprinted — it is above the cut and untouched. This is + // the assertion that fails under the old (over-eager) cursor math, + // which clobbered everything above the last line. + try testing.expect(std.mem.indexOf(u8, out, "l0") == null); + } + try testing.expectEqual(@as(usize, 3), eng.total_lines); +} + +test "differential edit of an upper line moves the cursor up the right distance" { + // A change at line 0 of a 3-line body, on the SECOND differential frame, so + // the cursor starts at row 2 (end of last line) and must climb 2 rows. + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 100); + defer eng.deinit(); + + var body = FakeComponent{ + .scripts = &.{ + &.{ "a0", "a1", "a2" }, + &.{ "a0", "a1", "A2" }, // first diff frame: change the tail + &.{ "X0", "a1", "A2" }, // second diff frame: change the head + }, + .first_changed = &.{ 0, 2, 0 }, + }; + try eng.addComponent(body.comp()); + + try eng.render(); // full + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); // diff: tail change (cursor stays low) + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); // diff: head change (must climb to line 0) + + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); + // Cursor rested at end of row 2 (A2); to reach cut line 0 it must move up 2. + try testing.expect(std.mem.indexOf(u8, out, "\x1b[2A") != null); + try testing.expect(std.mem.indexOf(u8, out, "X0") != null); +} + +test "width change forces a full redraw" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 24); + defer eng.deinit(); + + var body = FakeComponent{ + .scripts = &.{ &.{"hi"}, &.{"hi"} }, + .first_changed = &.{ 0, null }, + }; + try eng.addComponent(body.comp()); + try eng.render(); + + body.advance(); + eng.resize(60, 24); // width change + buf.clearRetainingCapacity(); + try eng.render(); + + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) != null); +} + +test "height change forces a full redraw" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 24); + defer eng.deinit(); + + var body = FakeComponent{ + .scripts = &.{ &.{"hi"}, &.{"hi"} }, + .first_changed = &.{ 0, null }, + }; + try eng.addComponent(body.comp()); + try eng.render(); + + body.advance(); + eng.resize(80, 30); // height change + buf.clearRetainingCapacity(); + try eng.render(); + + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) != null); +} + +test "change above viewport_top forces a full redraw" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + // Short viewport so most content scrolls into native scrollback. + var eng = makeEngine(&buf, 80, 2); + defer eng.deinit(); + + // 5 lines, viewport height 2 => viewport_top becomes 3 after first paint. + var body = FakeComponent{ + .scripts = &.{ &.{ "l0", "l1", "l2", "l3", "l4" }, &.{ "L0", "l1", "l2", "l3", "l4" } }, + .first_changed = &.{ 0, 0 }, // change lands at line 0, above viewport_top + }; + try eng.addComponent(body.comp()); + try eng.render(); + try testing.expectEqual(@as(usize, 3), eng.viewport_top); + + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); + + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) != null); +} + +test "width overflow is a hard error" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 4, 24); + defer eng.deinit(); + + var body = FakeComponent{ + .scripts = &.{&.{"way too wide"}}, + .first_changed = &.{0}, + }; + try eng.addComponent(body.comp()); + try testing.expectError(Error.LineOverflow, eng.render()); +} + +test "engine holds a LIST of components" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 24); + defer eng.deinit(); + + var a = FakeComponent{ .scripts = &.{&.{"a"}}, .first_changed = &.{0} }; + var b = FakeComponent{ .scripts = &.{&.{"b"}}, .first_changed = &.{0} }; + try eng.addComponent(a.comp()); + try eng.addComponent(b.comp()); + try testing.expectEqual(@as(usize, 2), eng.componentCount()); + + try testing.expect(eng.removeComponent(a.comp())); + try testing.expectEqual(@as(usize, 1), eng.componentCount()); + try testing.expect(!eng.removeComponent(a.comp())); +} + +test "cursor marker is stripped from output and recorded as a hint" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 24); + defer eng.deinit(); + + var body = FakeComponent{ + .scripts = &.{&.{"ab" ++ CURSOR_MARKER ++ "cd"}}, + .first_changed = &.{0}, + }; + try eng.addComponent(body.comp()); + try eng.render(); + + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, CURSOR_MARKER) == null); // stripped + try testing.expect(std.mem.indexOf(u8, out, "abcd") != null); + try testing.expect(eng.cursor_hint != null); + try testing.expectEqual(@as(usize, 2), eng.cursor_hint.?.col); +} + +test "every frame is wrapped in synchronized output when enabled" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = Engine.init(testing.allocator, &buf.writer, 80, 24, true); + defer eng.deinit(); + + var body = FakeComponent{ .scripts = &.{&.{"x"}}, .first_changed = &.{0} }; + try eng.addComponent(body.comp()); + try eng.render(); + + const out = buf.written(); + try testing.expect(std.mem.startsWith(u8, out, terminal.seq.sync_begin)); + try testing.expect(std.mem.endsWith(u8, out, terminal.seq.sync_end)); + // Ordinary content never clears scrollback on a forced full first paint is + // fine, but the sync frame must wrap it. +} + +test "ordinary differential frame never clears scrollback" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 100); + defer eng.deinit(); + + var body = FakeComponent{ + .scripts = &.{ &.{ "a", "b" }, &.{ "a", "B" } }, + .first_changed = &.{ 0, 1 }, + }; + try eng.addComponent(body.comp()); + try eng.render(); + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); + + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); + try testing.expect(std.mem.indexOf(u8, out, "B") != null); +} + +// -- Scheduler tests -------------------------------------------------------- + +test "scheduler: idle renders immediately" { + var s = Scheduler.init(8 * std.time.ns_per_ms); + try testing.expect(!s.shouldRenderNow(0)); // nothing pending + s.requestRender(); + try testing.expect(s.shouldRenderNow(0)); // idle => now +} + +test "scheduler: burst coalesces within the window" { + var s = Scheduler.init(8 * std.time.ns_per_ms); + s.requestRender(); + s.noteRendered(1000); + s.requestRender(); + // Within the window: defer. + try testing.expect(!s.shouldRenderNow(1000 + 4 * std.time.ns_per_ms)); + // Past the window: render. + try testing.expect(s.shouldRenderNow(1000 + 8 * std.time.ns_per_ms)); +} + +test "scheduler: nextDeadline reports remaining window" { + var s = Scheduler.init(8 * std.time.ns_per_ms); + try testing.expectEqual(@as(?i128, null), s.nextDeadline(0)); // not pending + s.requestRender(); + s.noteRendered(0); + s.requestRender(); + try testing.expectEqual(@as(?i128, 8 * std.time.ns_per_ms), s.nextDeadline(0)); + try testing.expectEqual(@as(?i128, 0), s.nextDeadline(8 * std.time.ns_per_ms)); +} diff --git a/src/tui_input.zig b/src/tui_input.zig new file mode 100644 index 0000000..fdd174c --- /dev/null +++ b/src/tui_input.zig @@ -0,0 +1,489 @@ +//! Input layer for the TUI: raw stdin bytes -> `Key` values. +//! +//! Scope (P1, intentionally minimal but correct): +//! - printable chars (UTF-8, multi-byte) +//! - enter, tab, backspace, delete +//! - arrows, home, end, page up/down (CSI + a couple legacy forms) +//! - Esc (standalone), Ctrl+C, Ctrl+D, and other Ctrl+<letter> +//! - bracketed paste: enable/disable + recognizing begin/end markers and +//! surfacing pasted bytes as one literal-text `Key` rather than parsing +//! each byte as a keypress. +//! +//! Deferred to a later phase (documented, not done here): +//! - Full Kitty keyboard-protocol disambiguation (key release, exact +//! modifier reporting, super/hyper, alternate-key reporting). We *send* a +//! modest Kitty enable+query during negotiation but degrade gracefully: +//! nothing here requires a response, and unrecognized sequences are +//! consumed and dropped rather than mis-decoded. +//! +//! Shift+Enter limitation: without the Kitty protocol most terminals send the +//! exact same bytes for Enter and Shift+Enter (`\r`), so this decoder cannot +//! distinguish them and reports both as `.enter` with no shift modifier. When +//! the Kitty protocol IS active, Shift+Enter arrives as a CSI-u sequence +//! (`\x1b[13;2u`) which we DO decode, setting `mods.shift`. The `Key`/`Mods` +//! model can therefore represent the distinction even on terminals that can't +//! produce it; the input-box component (later sub-phase) keys off +//! `mods.shift` on an `.enter` press. On terminals that can't express it, +//! Enter submits and a separate binding (e.g. Alt+Enter / a config key) must +//! insert a newline — that policy is the component's, not ours. +//! +//! The splitter (`decodeOne`) takes a byte buffer and returns the next decoded +//! key plus how many bytes it consumed, so a batched read of several escape +//! sequences is split into individual `Key`s and a partial trailing sequence +//! can be retained for the next read. + +const std = @import("std"); +const key = @import("tui_key.zig"); +const Key = key.Key; +const KeyCode = key.KeyCode; +const Mods = key.Mods; + +// ---- Terminal control sequences this layer owns -------------------------- + +/// Enable bracketed paste. +pub const enable_bracketed_paste = "\x1b[?2004h"; +/// Disable bracketed paste. +pub const disable_bracketed_paste = "\x1b[?2004l"; + +/// Bracketed-paste begin / end markers. +pub const paste_begin = "\x1b[200~"; +pub const paste_end = "\x1b[201~"; + +/// Kitty keyboard protocol: push a flags entry that asks for disambiguated +/// escape codes (flag 1). We push (not set) so teardown can pop cleanly. This +/// is best-effort; terminals that don't support it ignore it. +pub const enable_kitty_keyboard = "\x1b[>1u"; +/// Pop the Kitty keyboard flags entry we pushed. +pub const disable_kitty_keyboard = "\x1b[<u"; + +/// Modest negotiation setup to write at startup. Bracketed paste is the only +/// thing P1 strictly needs; the Kitty enable is opportunistic. Order: paste +/// first, then Kitty. +pub const negotiate_setup = enable_bracketed_paste ++ enable_kitty_keyboard; + +/// Teardown to write on exit; the reverse of `negotiate_setup`. +pub const negotiate_teardown = disable_kitty_keyboard ++ disable_bracketed_paste; + +// ---- Decode results ------------------------------------------------------- + +/// What a single decode step produced. +pub const Decoded = union(enum) { + /// A decoded key. + key: Key, + /// A run of pasted text (between bracketed-paste markers), surfaced + /// literally. `text` borrows from the input buffer. + paste: []const u8, +}; + +/// Result of one decode step: what was produced, and how many input bytes it +/// consumed. `null` from `decodeOne` means "need more bytes" (a partial +/// sequence at the end of the buffer); the caller should retain the unconsumed +/// tail and append the next read. +pub const Step = struct { + decoded: Decoded, + consumed: usize, +}; + +// ---- The splitter / decoder ---------------------------------------------- + +/// Decode the next key or paste run from the front of `buf`. +/// +/// Returns: +/// - `Step` with `consumed > 0` on success. +/// - `null` when `buf` holds only a partial/incomplete sequence and more +/// bytes are needed (retain the tail and read more). An empty `buf` also +/// returns `null`. +/// +/// Borrowing: any `[]const u8` in the result (printable `Key.text`, paste +/// text) points into `buf`. Copy before the next read overwrites the buffer. +pub fn decodeOne(buf: []const u8) ?Step { + if (buf.len == 0) return null; + + const b = buf[0]; + + // ESC: could be a CSI/SS3 sequence, a bracketed paste, an Alt+<key>, or a + // lone Escape. + if (b == 0x1b) return decodeEscape(buf); + + // Control bytes (C0). + switch (b) { + '\r', '\n' => return keyStep(.{ .code = .enter }, 1), + '\t' => return keyStep(.{ .code = .tab }, 1), + 0x7f, 0x08 => return keyStep(.{ .code = .backspace }, 1), + 0x03 => return keyStep(.{ .code = .{ .char = 'c' }, .mods = .{ .ctrl = true } }, 1), // Ctrl+C + 0x04 => return keyStep(.{ .code = .{ .char = 'd' }, .mods = .{ .ctrl = true } }, 1), // Ctrl+D + else => {}, + } + + // Other C0 control bytes => Ctrl+<letter>. 0x01..0x1a map to a..z. + if (b >= 0x01 and b <= 0x1a) { + const letter: u21 = @intCast('a' + (b - 1)); + return keyStep(.{ .code = .{ .char = letter }, .mods = .{ .ctrl = true } }, 1); + } + + // Printable: decode one UTF-8 codepoint. + const seq_len = std.unicode.utf8ByteSequenceLength(b) catch { + // Invalid lead byte — consume it as a Latin-1-ish fallback so we never + // wedge on garbage input. + return keyStep(.{ .code = .{ .char = b }, .text = buf[0..1] }, 1); + }; + if (buf.len < seq_len) return null; // partial UTF-8 at buffer end + const cp = std.unicode.utf8Decode(buf[0..seq_len]) catch { + return keyStep(.{ .code = .{ .char = b }, .text = buf[0..1] }, 1); + }; + return keyStep(.{ .code = .{ .char = cp }, .text = buf[0..seq_len] }, seq_len); +} + +fn keyStep(k: Key, consumed: usize) Step { + return .{ .decoded = .{ .key = k }, .consumed = consumed }; +} + +/// Decode a sequence beginning with ESC (`buf[0] == 0x1b`). +fn decodeEscape(buf: []const u8) ?Step { + std.debug.assert(buf[0] == 0x1b); + + // Lone ESC with nothing after it: ambiguous (could be the start of a + // longer sequence). We only commit to "Escape key" if we can see it's not + // the start of CSI/SS3. With just one byte we can't tell -> need more. + if (buf.len == 1) return null; + + const c1 = buf[1]; + + // CSI: ESC [ + if (c1 == '[') return decodeCSI(buf); + // SS3: ESC O (some terminals send arrows/home/end as SS3 in app mode) + if (c1 == 'O') return decodeSS3(buf); + + // ESC followed by anything else: treat as Alt+<that key>. Decode the rest + // recursively and OR in alt. (A real lone-Escape keypress is followed by + // nothing more, handled below.) + if (decodeOne(buf[1..])) |inner| { + switch (inner.decoded) { + .key => |ik| { + var k = ik; + k.mods.alt = true; + return .{ .decoded = .{ .key = k }, .consumed = inner.consumed + 1 }; + }, + // A paste right after ESC is nonsensical; fall through to Escape. + .paste => {}, + } + } + + // Couldn't make sense of what follows: report a lone Escape, consuming + // just the ESC byte. + return keyStep(.{ .code = .escape }, 1); +} + +/// Decode an SS3 sequence: ESC O <final>. Used by some terminals for arrows, +/// home/end and F1..F4 in application-cursor mode. +fn decodeSS3(buf: []const u8) ?Step { + // buf[0]=ESC buf[1]='O' + if (buf.len < 3) return null; // need the final byte + const final = buf[2]; + const code: ?KeyCode = switch (final) { + 'A' => .up, + 'B' => .down, + 'C' => .right, + 'D' => .left, + 'H' => .home, + 'F' => .end, + 'P' => .f1, + 'Q' => .f2, + 'R' => .f3, + 'S' => .f4, + else => null, + }; + if (code) |c| return keyStep(.{ .code = c }, 3); + // Unknown SS3 final: consume the 3 bytes and drop (degrade gracefully). + return keyStep(.{ .code = .escape }, 3); +} + +/// Decode a CSI sequence: ESC [ <params> <final>. +/// +/// Handles: +/// - arrows / home / end / page up-down (with optional `1;<mods>` modifier) +/// - the `~`-terminated numeric forms (Home=1~/7~, End=4~/8~, Ins, Del=3~, +/// PgUp=5~, PgDn=6~, F5..F12) +/// - the `[A`..`[D` arrows without params +/// - bracketed paste begin (`200~`) — returns a `.paste` run spanning to the +/// end marker +/// - CSI-u (Kitty) for the keys we care about (notably Shift+Enter = +/// `13;2u`) +/// +/// Unknown but well-formed CSI sequences are consumed and dropped. +fn decodeCSI(buf: []const u8) ?Step { + // buf[0]=ESC buf[1]='[' + // Find the final byte: the first byte in 0x40..0x7e after the params. + // Params/intermediates are 0x20..0x3f. If we run off the end, need more. + var i: usize = 2; + while (i < buf.len and isCSIParamOrIntermediate(buf[i])) : (i += 1) {} + if (i >= buf.len) return null; // incomplete: no final byte yet + const final = buf[i]; + const params = buf[2..i]; + const total = i + 1; + + // Bracketed paste begin: CSI 200 ~ -> scan to the end marker. + if (final == '~' and std.mem.eql(u8, params, "200")) { + return decodePaste(buf, total); + } + + // CSI-u (Kitty): final 'u', params = "<codepoint>[;<mods>[:<event>]]". + if (final == 'u') return decodeKittyU(params, total); + + // Modifier suffix: many sequences are "1;<mods><final>" or + // "<num>;<mods>~". Split params on ';'. + var first: []const u8 = params; + var mod_field: ?[]const u8 = null; + if (std.mem.indexOfScalar(u8, params, ';')) |semi| { + first = params[0..semi]; + mod_field = params[semi + 1 ..]; + } + const mods = parseMods(mod_field); + + // Letter-final forms: A/B/C/D arrows, H home, F end. + const letter_code: ?KeyCode = switch (final) { + 'A' => .up, + 'B' => .down, + 'C' => .right, + 'D' => .left, + 'H' => .home, + 'F' => .end, + 'P' => .f1, + 'Q' => .f2, + 'R' => .f3, + 'S' => .f4, + else => null, + }; + if (letter_code) |c| return keyStep(.{ .code = c, .mods = mods }, total); + + // Tilde-final numeric forms. + if (final == '~') { + const n = std.fmt.parseInt(u16, first, 10) catch return keyStep(.{ .code = .escape }, total); + const code: ?KeyCode = switch (n) { + 1, 7 => .home, + 2 => null, // Insert — not in our KeyCode set; drop + 3 => .delete, + 4, 8 => .end, + 5 => .page_up, + 6 => .page_down, + 11 => .f1, + 12 => .f2, + 13 => .f3, + 14 => .f4, + 15 => .f5, + 17 => .f6, + 18 => .f7, + 19 => .f8, + 20 => .f9, + 21 => .f10, + 23 => .f11, + 24 => .f12, + else => null, + }; + if (code) |c| return keyStep(.{ .code = c, .mods = mods }, total); + return keyStep(.{ .code = .escape }, total); // unknown ~ form: drop + } + + // Well-formed but unhandled CSI: consume and drop. + return keyStep(.{ .code = .escape }, total); +} + +/// Scan a bracketed-paste body starting after the `CSI 200~` begin marker +/// (which spans `[0..begin_len)`), up to the `CSI 201~` end marker. Returns a +/// `.paste` step covering the text between the markers. If the end marker +/// isn't present yet, returns null (need more bytes). +fn decodePaste(buf: []const u8, begin_len: usize) ?Step { + const body = buf[begin_len..]; + const end_idx = std.mem.indexOf(u8, body, paste_end) orelse return null; + const text = body[0..end_idx]; + const consumed = begin_len + end_idx + paste_end.len; + return .{ .decoded = .{ .paste = text }, .consumed = consumed }; +} + +/// Decode a Kitty CSI-u sequence's params (without the trailing `u`). We only +/// resolve the subset P1 needs: a printable codepoint, Enter (13), Tab (9), +/// Backspace (127), and Escape (27), with modifiers and (optionally) the +/// release event. Anything else is consumed and dropped. +fn decodeKittyU(params: []const u8, total: usize) ?Step { + // params: "<cp>[:<alt-cp>][;<mods>[:<event>]]" + var cp_field: []const u8 = params; + var rest: ?[]const u8 = null; + if (std.mem.indexOfScalar(u8, params, ';')) |semi| { + cp_field = params[0..semi]; + rest = params[semi + 1 ..]; + } + // The codepoint field may carry alternates after ':'; take the first. + if (std.mem.indexOfScalar(u8, cp_field, ':')) |colon| cp_field = cp_field[0..colon]; + + const cp = std.fmt.parseInt(u21, cp_field, 10) catch return keyStep(.{ .code = .escape }, total); + + // Modifier + event live in `rest` as "<mods>[:<event>]". + var mod_field: ?[]const u8 = rest; + var event = key.KeyEvent.press; + if (rest) |r| { + if (std.mem.indexOfScalar(u8, r, ':')) |colon| { + mod_field = r[0..colon]; + event = parseKittyEvent(r[colon + 1 ..]); + } + } + const mods = parseMods(mod_field); + + const code: KeyCode = switch (cp) { + 13 => .enter, + 9 => .tab, + 127 => .backspace, + 27 => .escape, + else => .{ .char = cp }, + }; + return .{ .decoded = .{ .key = .{ .code = code, .mods = mods, .event = event } }, .consumed = total }; +} + +fn parseKittyEvent(field: []const u8) key.KeyEvent { + const n = std.fmt.parseInt(u8, field, 10) catch return .press; + return switch (n) { + 1 => .press, + 2 => .repeat, + 3 => .release, + else => .press, + }; +} + +/// Parse a CSI modifier field. The terminal encoding is `1 + bitmask` where +/// the bitmask is shift=1, alt=2, ctrl=4, super=8. A null/empty field means no +/// modifiers. +fn parseMods(field: ?[]const u8) Mods { + const f = field orelse return .{}; + if (f.len == 0) return .{}; + const raw = std.fmt.parseInt(u16, f, 10) catch return .{}; + if (raw == 0) return .{}; + const bits = raw - 1; + return .{ + .shift = (bits & 1) != 0, + .alt = (bits & 2) != 0, + .ctrl = (bits & 4) != 0, + .super = (bits & 8) != 0, + }; +} + +fn isCSIParamOrIntermediate(c: u8) bool { + return c >= 0x20 and c <= 0x3f; +} + +// ---- Tests ---------------------------------------------------------------- + +test "printable ascii" { + const s = decodeOne("a").?; + try std.testing.expectEqual(@as(usize, 1), s.consumed); + try std.testing.expectEqual(@as(u21, 'a'), s.decoded.key.code.char); + try std.testing.expectEqualStrings("a", s.decoded.key.text.?); +} + +test "multibyte utf8 printable" { + // é = U+00E9 = 0xC3 0xA9 + const s = decodeOne("\xc3\xa9rest").?; + try std.testing.expectEqual(@as(usize, 2), s.consumed); + try std.testing.expectEqual(@as(u21, 0xe9), s.decoded.key.code.char); +} + +test "partial utf8 needs more bytes" { + try std.testing.expect(decodeOne("\xc3") == null); +} + +test "enter, tab, backspace" { + try std.testing.expectEqual(KeyCode.enter, decodeOne("\r").?.decoded.key.code); + try std.testing.expectEqual(KeyCode.enter, decodeOne("\n").?.decoded.key.code); + try std.testing.expectEqual(KeyCode.tab, decodeOne("\t").?.decoded.key.code); + try std.testing.expectEqual(KeyCode.backspace, decodeOne("\x7f").?.decoded.key.code); +} + +test "ctrl-c and ctrl-d" { + const c = decodeOne("\x03").?.decoded.key; + try std.testing.expect(c.isCtrl('c')); + const d = decodeOne("\x04").?.decoded.key; + try std.testing.expect(d.isCtrl('d')); +} + +test "csi arrows" { + try std.testing.expectEqual(KeyCode.up, decodeOne("\x1b[A").?.decoded.key.code); + try std.testing.expectEqual(KeyCode.down, decodeOne("\x1b[B").?.decoded.key.code); + try std.testing.expectEqual(KeyCode.right, decodeOne("\x1b[C").?.decoded.key.code); + try std.testing.expectEqual(KeyCode.left, decodeOne("\x1b[D").?.decoded.key.code); +} + +test "csi home/end and modified arrow" { + try std.testing.expectEqual(KeyCode.home, decodeOne("\x1b[H").?.decoded.key.code); + try std.testing.expectEqual(KeyCode.end, decodeOne("\x1b[F").?.decoded.key.code); + // Ctrl+Right = CSI 1;5C + const s = decodeOne("\x1b[1;5C").?.decoded.key; + try std.testing.expectEqual(KeyCode.right, s.code); + try std.testing.expect(s.mods.ctrl); +} + +test "csi tilde delete / pageup / pagedown" { + try std.testing.expectEqual(KeyCode.delete, decodeOne("\x1b[3~").?.decoded.key.code); + try std.testing.expectEqual(KeyCode.page_up, decodeOne("\x1b[5~").?.decoded.key.code); + try std.testing.expectEqual(KeyCode.page_down, decodeOne("\x1b[6~").?.decoded.key.code); +} + +test "ss3 arrows and f-keys" { + try std.testing.expectEqual(KeyCode.up, decodeOne("\x1bOA").?.decoded.key.code); + try std.testing.expectEqual(KeyCode.f1, decodeOne("\x1bOP").?.decoded.key.code); +} + +test "lone escape" { + // ESC followed by a non-sequence byte: Alt+x is decoded preferentially. + const alt = decodeOne("\x1bx").?.decoded.key; + try std.testing.expect(alt.mods.alt); + try std.testing.expectEqual(@as(u21, 'x'), alt.code.char); +} + +test "incomplete csi needs more bytes" { + try std.testing.expect(decodeOne("\x1b[1;5") == null); + try std.testing.expect(decodeOne("\x1b[") == null); +} + +test "kitty shift+enter distinguishes from plain enter" { + // CSI 13 ; 2 u -> Enter with shift. + const s = decodeOne("\x1b[13;2u").?.decoded.key; + try std.testing.expectEqual(KeyCode.enter, s.code); + try std.testing.expect(s.mods.shift); + // Plain enter has no shift. + try std.testing.expect(!decodeOne("\r").?.decoded.key.mods.shift); +} + +test "kitty release event" { + // CSI 97 ; 1 : 3 u -> 'a' release. + const s = decodeOne("\x1b[97;1:3u").?.decoded.key; + try std.testing.expectEqual(@as(u21, 'a'), s.code.char); + try std.testing.expectEqual(key.KeyEvent.release, s.event); +} + +test "bracketed paste surfaces literal text" { + const input = paste_begin ++ "hello\nworld" ++ paste_end ++ "x"; + const s = decodeOne(input).?; + try std.testing.expectEqualStrings("hello\nworld", s.decoded.paste); + // Next decode should land on the trailing 'x'. + const rest = input[s.consumed..]; + try std.testing.expectEqual(@as(u21, 'x'), decodeOne(rest).?.decoded.key.code.char); +} + +test "incomplete paste needs more bytes" { + const input = paste_begin ++ "partial"; + try std.testing.expect(decodeOne(input) == null); +} + +test "splitter: batched read yields individual keys" { + // Up, Down, 'a' in one buffer. + const input = "\x1b[A\x1b[Ba"; + var off: usize = 0; + const s1 = decodeOne(input[off..]).?; + try std.testing.expectEqual(KeyCode.up, s1.decoded.key.code); + off += s1.consumed; + const s2 = decodeOne(input[off..]).?; + try std.testing.expectEqual(KeyCode.down, s2.decoded.key.code); + off += s2.consumed; + const s3 = decodeOne(input[off..]).?; + try std.testing.expectEqual(@as(u21, 'a'), s3.decoded.key.code.char); + off += s3.consumed; + try std.testing.expectEqual(input.len, off); +} diff --git a/src/tui_key.zig b/src/tui_key.zig new file mode 100644 index 0000000..e5056cd --- /dev/null +++ b/src/tui_key.zig @@ -0,0 +1,129 @@ +//! Input value types for the TUI. +//! +//! This is the *rich* key model. The P1 decoder (`tui_input.zig`) only +//! populates a subset of it (printable chars, enter, backspace, arrows, +//! home/end, escape, Ctrl+C, Ctrl+D), but the model is intentionally complete +//! so later phases (full Kitty disambiguation, key-release events, super/hyper +//! modifiers) can populate the rest without changing the type. Components +//! built on this model will keep compiling. + +const std = @import("std"); + +/// A logical key, independent of the raw bytes that produced it. +/// +/// `char` carries a Unicode codepoint (the printable case). All other +/// variants are non-printable named keys. The decoder resolves the *display +/// text* of a printable key separately into `Key.text` (so e.g. a pasted or +/// composed grapheme can be carried verbatim), while `char` holds the single +/// decoded codepoint. +pub const KeyCode = union(enum) { + /// A printable character, as a Unicode codepoint. + char: u21, + enter, + escape, + tab, + backspace, + delete, + up, + down, + left, + right, + home, + end, + page_up, + page_down, + f1, + f2, + f3, + f4, + f5, + f6, + f7, + f8, + f9, + f10, + f11, + f12, +}; + +/// Modifier flags. Packed so it round-trips cheaply and compares by value. +/// +/// `super` is Cmd/Win, `hyper` is the (rare) Hyper modifier; both are only +/// expressible under protocols like Kitty's, so the P1 decoder leaves them +/// false. They exist so the model can represent them later. +pub const Mods = packed struct { + ctrl: bool = false, + alt: bool = false, + shift: bool = false, + super: bool = false, + hyper: bool = false, + + pub const none: Mods = .{}; + + pub fn eql(a: Mods, b: Mods) bool { + return @as(u5, @bitCast(a)) == @as(u5, @bitCast(b)); + } + + pub fn any(self: Mods) bool { + return @as(u5, @bitCast(self)) != 0; + } +}; + +/// Press / repeat / release. Terminals that don't report key-release (most, +/// without the Kitty protocol) only ever produce `.press`. A component opts +/// into receiving `.release` via `Component.wantsKeyRelease`. +pub const KeyEvent = enum { + press, + repeat, + release, +}; + +/// A fully decoded key. +/// +/// `text` is the resolved text to insert for a printable key (UTF-8 encoding +/// of `code.char`, or pasted text surfaced as a literal run). It is null for +/// non-printable keys. `text`, when non-null, is borrowed from the input +/// buffer the decoder was handed; callers must copy it if they need it to +/// outlive that buffer. +pub const Key = struct { + code: KeyCode, + mods: Mods = .{}, + event: KeyEvent = .press, + text: ?[]const u8 = null, + + /// Convenience: is this a plain (unmodified) press of `code`? + pub fn isPlain(self: Key, code: KeyCode) bool { + return self.event == .press and !self.mods.any() and std.meta.eql(self.code, code); + } + + /// Convenience: Ctrl+<letter> press, e.g. `isCtrl('c')`. `letter` is + /// matched case-insensitively against the printable codepoint. + pub fn isCtrl(self: Key, letter: u8) bool { + if (self.event == .release) return false; + if (!self.mods.ctrl) return false; + return switch (self.code) { + .char => |cp| cp == std.ascii.toLower(@intCast(letter & 0x7f)) or + cp == std.ascii.toUpper(@intCast(letter & 0x7f)), + else => false, + }; + } +}; + +test "Mods eql / any" { + try std.testing.expect(Mods.none.eql(.{})); + try std.testing.expect(!Mods.none.any()); + const c: Mods = .{ .ctrl = true }; + try std.testing.expect(c.any()); + try std.testing.expect(!c.eql(.{ .shift = true })); +} + +test "Key.isPlain / isCtrl" { + const a: Key = .{ .code = .{ .char = 'a' } }; + try std.testing.expect(a.isPlain(.{ .char = 'a' })); + try std.testing.expect(!a.isPlain(.enter)); + + const ctrl_c: Key = .{ .code = .{ .char = 'c' }, .mods = .{ .ctrl = true } }; + try std.testing.expect(ctrl_c.isCtrl('c')); + try std.testing.expect(ctrl_c.isCtrl('C')); + try std.testing.expect(!ctrl_c.isCtrl('d')); +} diff --git a/src/tui_terminal.zig b/src/tui_terminal.zig new file mode 100644 index 0000000..c4545e9 --- /dev/null +++ b/src/tui_terminal.zig @@ -0,0 +1,405 @@ +//! Terminal control for the TUI. +//! +//! Responsibilities: +//! - enter / exit raw mode via termios (Unix only; Windows out of scope) +//! - restore the original termios on normal exit AND on crash: +//! * `defer term.deinit()` for the happy path +//! * SIGINT / SIGTERM / SIGHUP handlers that restore and re-raise +//! * a panic hook (`installPanicRestore`) the program's panic handler +//! can call so a Zig panic still restores the terminal +//! Restore is idempotent — calling it twice is safe and the second call +//! is a no-op. +//! - NEVER use the alternate screen buffer (no `?1049h` / `?47h`). We render +//! inline in the primary buffer. +//! - synchronized-output framing, cursor movement, line/screen clearing, +//! cursor show/hide, absolute positioning. +//! - query the window size (TIOCGWINSZ) and expose it; expose a SIGWINCH +//! "resized" flag the render engine can poll. +//! - best-effort capability detection (synchronized output, Kitty keyboard). +//! +//! All escape output goes straight to the tty fd via `std.c.write` (libc is +//! linked). The signal/panic restore path also uses `std.c.write` / +//! `std.c.tcsetattr`, which are async-signal-safe. +//! +//! Threading / global-state note: termios restore from a signal handler needs +//! the saved state at a fixed address. We keep a single file-scope +//! `active_restore` pointer to the live `Terminal`'s saved termios + fd. This +//! means ONE `Terminal` is "active" for signal restore at a time (the TUI only +//! ever creates one). `init` registers it; `deinit` clears it. + +const std = @import("std"); +const builtin = @import("builtin"); +const posix = std.posix; + +/// Raw escape sequences. Kept as named constants so the engine and tests can +/// reference them and so the "no alt screen" rule is auditable in one place. +pub const seq = struct { + /// Begin / end synchronized output (DEC private mode 2026). Wrapping a + /// frame in these tells the terminal to buffer and present atomically, + /// avoiding tearing. + pub const sync_begin = "\x1b[?2026h"; + pub const sync_end = "\x1b[?2026l"; + + /// Full-redraw clear: clear screen, home cursor, AND clear scrollback + /// (`3J`). Used on first paint / width change / height change. + pub const full_clear = "\x1b[2J\x1b[H\x1b[3J"; + + /// Carriage return (column 0, same row). + pub const carriage_return = "\r"; + /// Clear from cursor to end of line. + pub const clear_line = "\x1b[K"; + /// Clear from cursor to end of screen. + pub const clear_to_end = "\x1b[J"; + + pub const hide_cursor = "\x1b[?25l"; + pub const show_cursor = "\x1b[?25h"; +}; + +pub const Size = struct { + rows: u16, + cols: u16, +}; + +pub const Error = error{ + NotATty, + TermiosGetFailed, + TermiosSetFailed, +}; + +// ---- Global state for signal/panic restore ------------------------------- + +/// Minimal restore record: the fd whose termios we need to restore plus the +/// saved original. Pointed to by `active_restore` while a Terminal is live. +const Restore = struct { + fd: posix.fd_t, + original: posix.termios, + /// Set once restore has run, so it's idempotent across signal + deinit. + done: bool = false, +}; + +/// The single active restore record (see module note). `null` when no Terminal +/// is in raw mode. +var active_restore: ?*Restore = null; + +/// Async-signal-safe restore: reset termios and show the cursor. Safe to call +/// multiple times. Uses only `tcsetattr` + `write`, both signal-safe. +fn doRestore(r: *Restore) void { + if (r.done) return; + r.done = true; + // Best-effort; ignore errors (we may be unwinding a crash). + posix.tcsetattr(r.fd, .FLUSH, r.original) catch {}; + _ = std.c.write(r.fd, seq.show_cursor.ptr, seq.show_cursor.len); +} + +fn signalRestoreHandler(sig: posix.SIG) callconv(.c) void { + if (active_restore) |r| doRestore(r); + // Re-raise with the default disposition so the process dies as expected. + posix.sigaction(sig, &.{ + .handler = .{ .handler = posix.SIG.DFL }, + .mask = posix.sigemptyset(), + .flags = 0, + }, null); + _ = std.c.raise(sig); +} + +/// SIGWINCH flag. Set by the handler, polled (and cleared) by the engine via +/// `Terminal.takeResized`. +var winch_flag: std.atomic.Value(bool) = .init(false); + +fn winchHandler(sig: posix.SIG) callconv(.c) void { + _ = sig; + winch_flag.store(true, .seq_cst); +} + +// ---- Capability detection ------------------------------------------------- + +pub const Capabilities = struct { + /// Terminal is believed to support synchronized output (mode 2026). + synchronized_output: bool, + /// Terminal is believed to support the Kitty keyboard protocol. + kitty_keyboard: bool, +}; + +/// Best-effort capability detection by environment sniffing. +/// +/// We do NOT block on a terminal query response here (that risks hanging on +/// terminals that don't reply). Assumptions: +/// - Synchronized output is widely supported by modern emulators; we treat +/// it as available unless `TERM` looks like a dumb/legacy terminal. The +/// sequences are no-ops on terminals that don't understand them, so a +/// false positive is harmless. +/// - Kitty keyboard support is inferred from `TERM`/`TERM_PROGRAM` env hints +/// (kitty, foot, ghostty, wezterm, recent alacritty). A false negative +/// just means we fall back to legacy decoding; a false positive is safe +/// because the enable sequence is ignored by terminals that don't grok it +/// and our decoder degrades gracefully on unrecognized input. +pub fn detectCapabilities(env: std.process.Environ.Map) Capabilities { + const term = env.get("TERM") orelse ""; + const term_program = env.get("TERM_PROGRAM") orelse ""; + + const dumb = std.mem.eql(u8, term, "") or + std.mem.eql(u8, term, "dumb"); + + const kitty = containsAny(term, &.{ "kitty", "foot", "ghostty", "wezterm" }) or + containsAny(term_program, &.{ "kitty", "foot", "ghostty", "WezTerm", "ghostty" }) or + env.get("KITTY_WINDOW_ID") != null; + + return .{ + .synchronized_output = !dumb, + .kitty_keyboard = kitty, + }; +} + +fn containsAny(haystack: []const u8, needles: []const []const u8) bool { + for (needles) |n| { + if (std.ascii.indexOfIgnoreCase(haystack, n) != null) return true; + } + return false; +} + +// ---- The Terminal -------------------------------------------------------- + +pub const Terminal = struct { + fd: posix.fd_t, + restore: Restore, + /// Whether raw mode is currently engaged. + raw_mode: bool = false, + size: Size, + caps: Capabilities, + /// Saved prior SIGWINCH disposition is not restored (handler is process- + /// global and harmless); we only track that we installed ours. + installed_signals: bool = false, + + /// Open the controlling terminal on `fd` (typically stdin) and capture its + /// current termios. Does NOT enter raw mode yet — call `enableRawMode`. + pub fn init(fd: posix.fd_t, env: std.process.Environ.Map) Error!Terminal { + if (std.c.isatty(fd) == 0) return Error.NotATty; + const original = posix.tcgetattr(fd) catch return Error.TermiosGetFailed; + const size = querySize(fd) orelse Size{ .rows = 24, .cols = 80 }; + return .{ + .fd = fd, + .restore = .{ .fd = fd, .original = original }, + .size = size, + .caps = detectCapabilities(env), + }; + } + + /// Enter raw mode and install signal handlers + the global restore record. + /// Hides nothing on its own; the engine controls the cursor. + pub fn enableRawMode(self: *Terminal) Error!void { + if (self.raw_mode) return; + + var raw = self.restore.original; + // Input flags: no break->int, no CR->NL, no parity check, no strip, + // no flow control. + raw.iflag.BRKINT = false; + raw.iflag.ICRNL = false; + raw.iflag.INPCK = false; + raw.iflag.ISTRIP = false; + raw.iflag.IXON = false; + // Output: keep post-processing OFF so our explicit \r\n is exact. + raw.oflag.OPOST = false; + // Control: 8-bit chars. + raw.cflag.CSIZE = .CS8; + // Local flags: no echo, non-canonical, no signals from keys (we read + // Ctrl+C ourselves), no extended processing. + raw.lflag.ECHO = false; + raw.lflag.ICANON = false; + raw.lflag.ISIG = false; + raw.lflag.IEXTEN = false; + // Non-blocking-ish read: return as soon as >=1 byte is available. + raw.cc[@intFromEnum(posix.V.MIN)] = 1; + raw.cc[@intFromEnum(posix.V.TIME)] = 0; + + posix.tcsetattr(self.fd, .FLUSH, raw) catch return Error.TermiosSetFailed; + self.raw_mode = true; + self.restore.done = false; + active_restore = &self.restore; + self.installSignalHandlers(); + } + + fn installSignalHandlers(self: *Terminal) void { + const restore_act = posix.Sigaction{ + .handler = .{ .handler = signalRestoreHandler }, + .mask = posix.sigemptyset(), + .flags = 0, + }; + posix.sigaction(posix.SIG.INT, &restore_act, null); + posix.sigaction(posix.SIG.TERM, &restore_act, null); + posix.sigaction(posix.SIG.HUP, &restore_act, null); + + const winch_act = posix.Sigaction{ + .handler = .{ .handler = winchHandler }, + .mask = posix.sigemptyset(), + .flags = 0, + }; + posix.sigaction(posix.SIG.WINCH, &winch_act, null); + self.installed_signals = true; + } + + /// Restore the terminal: leave raw mode, show the cursor. Idempotent. + /// Safe to call from `defer`. Does not touch the alternate screen (we + /// never entered it). + pub fn deinit(self: *Terminal) void { + doRestore(&self.restore); + self.raw_mode = false; + if (active_restore == &self.restore) active_restore = null; + } + + // -- output helpers ----------------------------------------------------- + + /// Raw write of `bytes` to the tty. Returns the count written (short + /// writes are possible on a tty but rare for small control sequences; + /// callers writing large frames should loop or use a buffered writer). + pub fn writeAll(self: *Terminal, bytes: []const u8) void { + var off: usize = 0; + while (off < bytes.len) { + const n = std.c.write(self.fd, bytes.ptr + off, bytes.len - off); + if (n <= 0) break; + off += @intCast(n); + } + } + + pub fn beginSync(self: *Terminal) void { + if (self.caps.synchronized_output) self.writeAll(seq.sync_begin); + } + + pub fn endSync(self: *Terminal) void { + if (self.caps.synchronized_output) self.writeAll(seq.sync_end); + } + + pub fn hideCursor(self: *Terminal) void { + self.writeAll(seq.hide_cursor); + } + + pub fn showCursor(self: *Terminal) void { + self.writeAll(seq.show_cursor); + } + + pub fn carriageReturn(self: *Terminal) void { + self.writeAll(seq.carriage_return); + } + + pub fn clearLine(self: *Terminal) void { + self.writeAll(seq.clear_line); + } + + pub fn clearToEndOfScreen(self: *Terminal) void { + self.writeAll(seq.clear_to_end); + } + + /// Full redraw clear (clears scrollback too). Use only on first paint / + /// width change / height change. + pub fn fullClear(self: *Terminal) void { + self.writeAll(seq.full_clear); + } + + /// Move the cursor up `n` rows (no-op when `n == 0`). Writes into `buf`. + pub fn cursorUp(self: *Terminal, n: usize) void { + if (n == 0) return; + var buf: [16]u8 = undefined; + const s = std.fmt.bufPrint(&buf, "\x1b[{d}A", .{n}) catch return; + self.writeAll(s); + } + + /// Move the cursor down `n` rows (no-op when `n == 0`). + pub fn cursorDown(self: *Terminal, n: usize) void { + if (n == 0) return; + var buf: [16]u8 = undefined; + const s = std.fmt.bufPrint(&buf, "\x1b[{d}B", .{n}) catch return; + self.writeAll(s); + } + + /// Move to absolute (row, col), 1-based (CUP). + pub fn moveTo(self: *Terminal, row: usize, col: usize) void { + var buf: [32]u8 = undefined; + const s = std.fmt.bufPrint(&buf, "\x1b[{d};{d}H", .{ row, col }) catch return; + self.writeAll(s); + } + + // -- size / resize ------------------------------------------------------ + + /// Re-query the window size from the kernel and update `self.size`. + /// Returns the new size. Falls back to the previous size on failure. + pub fn refreshSize(self: *Terminal) Size { + if (querySize(self.fd)) |s| self.size = s; + return self.size; + } + + /// Poll-and-clear the SIGWINCH flag. Returns true exactly once per resize + /// burst the engine hasn't yet observed. + pub fn takeResized(self: *Terminal) bool { + _ = self; + return winch_flag.swap(false, .seq_cst); + } + + /// Install a panic-time restore. Call this from the program's panic + /// handler (or once at startup if your panic handler is the default and + /// you want best effort). It restores the currently-active terminal. + pub fn installPanicRestore() void { + if (active_restore) |r| doRestore(r); + } +}; + +/// Query the terminal size via TIOCGWINSZ. Returns null on failure. +fn querySize(fd: posix.fd_t) ?Size { + var ws: posix.winsize = undefined; + const rc = std.c.ioctl(fd, std.c.T.IOCGWINSZ, @intFromPtr(&ws)); + if (rc != 0) return null; + if (ws.row == 0 or ws.col == 0) return null; + return .{ .rows = ws.row, .cols = ws.col }; +} + +// ---- Tests ---------------------------------------------------------------- + +test "no alt screen sequences anywhere in seq" { + // Guardrail: the alternate-screen private modes must never appear. + inline for (.{ + seq.sync_begin, seq.sync_end, seq.full_clear, seq.clear_line, + seq.clear_to_end, seq.hide_cursor, seq.show_cursor, + }) |s| { + try std.testing.expect(std.mem.indexOf(u8, s, "?1049") == null); + try std.testing.expect(std.mem.indexOf(u8, s, "?47") == null); + } +} + +test "full clear includes scrollback (3J)" { + try std.testing.expect(std.mem.indexOf(u8, seq.full_clear, "\x1b[3J") != null); +} + +test "sync framing values" { + try std.testing.expectEqualStrings("\x1b[?2026h", seq.sync_begin); + try std.testing.expectEqualStrings("\x1b[?2026l", seq.sync_end); +} + +test "capability detection: dumb terminal" { + var env = std.process.Environ.Map.init(std.testing.allocator); + defer env.deinit(); + try env.put("TERM", "dumb"); + const caps = detectCapabilities(env); + try std.testing.expect(!caps.synchronized_output); + try std.testing.expect(!caps.kitty_keyboard); +} + +test "capability detection: kitty" { + var env = std.process.Environ.Map.init(std.testing.allocator); + defer env.deinit(); + try env.put("TERM", "xterm-kitty"); + const caps = detectCapabilities(env); + try std.testing.expect(caps.synchronized_output); + try std.testing.expect(caps.kitty_keyboard); +} + +test "resize flag round-trips" { + winch_flag.store(false, .seq_cst); + var t: Terminal = .{ + .fd = 0, + .restore = .{ .fd = 0, .original = undefined }, + .size = .{ .rows = 24, .cols = 80 }, + .caps = .{ .synchronized_output = true, .kitty_keyboard = false }, + }; + try std.testing.expect(!t.takeResized()); + winch_flag.store(true, .seq_cst); + try std.testing.expect(t.takeResized()); + try std.testing.expect(!t.takeResized()); +} 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()); +} |
