From 104001d25c9c8cb5ec45ced1678f7c7b70888808 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 8 Jun 2026 14:48:33 -0600 Subject: keybinding fixes --- src/main.zig | 3 + src/tui_app.zig | 628 ++++++++++++++++++++++-- src/tui_components.zig | 1244 +++++++++++++++++++++++++++++++++++++++++++++++- src/tui_engine.zig | 231 ++++++++- src/tui_input.zig | 111 ++++- src/tui_terminal.zig | 25 + src/tui_theme.zig | 13 + 7 files changed, 2175 insertions(+), 80 deletions(-) (limited to 'src') diff --git a/src/main.zig b/src/main.zig index f1527d1..7905347 100644 --- a/src/main.zig +++ b/src/main.zig @@ -510,6 +510,9 @@ pub fn main(init: std.process.Init) !void { .cmd_ctx = &cmd_ctx, .cmd_capture = &cmd_capture, .model_label = model_label, + .cwd = cwd, + .io = io, + .environ = init.environ_map, }) catch |err| switch (err) { // Clean user-initiated exit (Ctrl+C / Ctrl+D). Not an error. error.UserExit => {}, diff --git a/src/tui_app.zig b/src/tui_app.zig index 788595a..a9d9a42 100644 --- a/src/tui_app.zig +++ b/src/tui_app.zig @@ -28,17 +28,28 @@ //! (`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) +//! ## Thinking / tool / compaction display (P2) //! -//! 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: ` 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. +//! The full built-in component set is wired here: +//! - a Thinking block streams its deltas into a dedicated dim `Thinking` +//! component, +//! - a ToolUse block drives a `ToolUse` component (one per call) through its +//! `tool (?)…` -> `tool () ` -> `+ ` progression; +//! the component is collapsible via a GLOBAL ctrl+o toggle (default +//! collapsed, showing the last 5 output lines), +//! - a CompactionSummary block (or a compaction provider-retry) renders a +//! `CompactionSummary` component. +//! +//! ## Tool-result correlation (no "active component") +//! +//! ToolResult blocks do NOT arrive via `block_start`/`block_complete`; the +//! agent assembles them and delivers them together in the +//! `tool_dispatch_complete` event's user `Message`. Each `ToolResultBlock` +//! carries a `tool_use_id` linking back to its `ToolUseBlock.id`. The router +//! therefore keeps a SECOND map, tool-call id -> *ToolUse component, populated +//! when the tool name/id resolve; on `tool_dispatch_complete` we walk the +//! result blocks and feed each one's text to the matching component by id. +//! Nothing is keyed by a single "current" component (plan §6 invariant). const std = @import("std"); const posix = std.posix; @@ -60,6 +71,10 @@ const AssistantText = components.AssistantText; const UserText = components.UserText; const InputBox = components.InputBox; const Footer = components.Footer; +const Welcome = components.Welcome; +const Thinking = components.Thinking; +const CompactionSummary = components.CompactionSummary; +const ToolUse = components.ToolUse; const Component = component.Component; const Event = panto.Event; @@ -103,20 +118,47 @@ 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). + /// A dim status/retry line (provider retries, command output, errors). status: *AssistantText, + /// Session-start banner. + welcome: *Welcome, + /// Streaming thinking block (dim). + thinking: *Thinking, + /// A tool call + result (collapsible). + tool: *ToolUse, + /// A compaction summary. + compaction: *CompactionSummary, fn comp(self: Entry) Component { return switch (self) { .user => |p| p.comp(), .assistant => |p| p.comp(), .status => |p| p.comp(), + .welcome => |p| p.comp(), + .thinking => |p| p.comp(), + .tool => |p| p.comp(), + .compaction => |p| p.comp(), }; } fn deinit(self: Entry, alloc: std.mem.Allocator) void { switch (self) { + .welcome => |p| { + p.deinit(); + alloc.destroy(p); + }, + .thinking => |p| { + p.deinit(); + alloc.destroy(p); + }, + .tool => |p| { + p.deinit(); + alloc.destroy(p); + }, + .compaction => |p| { + p.deinit(); + alloc.destroy(p); + }, .user => |p| { p.deinit(); alloc.destroy(p); @@ -154,6 +196,11 @@ pub const App = struct { /// Per-turn block routing. Cleared at each turn boundary. router: TurnRouter, + /// Global tool-use collapse state (ctrl+o). Applies to EVERY tool-use + /// component at once (plan: collapse is a global toggle). Default true: + /// tool output starts collapsed to its last few lines. + tools_collapsed: bool = true, + /// 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. @@ -179,6 +226,7 @@ pub const App = struct { .input_box = input_box, .footer = footer, .router = TurnRouter.init(alloc), + .tools_collapsed = true, }; } @@ -263,6 +311,53 @@ pub const App = struct { try self.pushEntry(.{ .user = box }); } + /// Spawn the session-start welcome banner. Returns it so the caller can set + /// its fields (version / cwd / model). + fn spawnWelcome(self: *App) !*Welcome { + const box = try self.alloc.create(Welcome); + box.* = Welcome.init(self.alloc); + try self.pushEntry(.{ .welcome = box }); + return box; + } + + /// Spawn a streaming thinking entry. Keyed by block index in the router. + fn spawnThinking(self: *App) !*Thinking { + const box = try self.alloc.create(Thinking); + box.* = Thinking.init(self.alloc); + try self.pushEntry(.{ .thinking = box }); + return box; + } + + /// Spawn a tool-use entry. Inherits the app's current global collapse state + /// so a tool opened while everything is collapsed starts collapsed too. + fn spawnTool(self: *App) !*ToolUse { + const box = try self.alloc.create(ToolUse); + box.* = ToolUse.init(self.alloc); + box.setCollapsed(self.tools_collapsed); + try self.pushEntry(.{ .tool = box }); + return box; + } + + /// Spawn a compaction-summary entry seeded with `summary`. + fn spawnCompaction(self: *App, summary: []const u8) !*CompactionSummary { + const box = try self.alloc.create(CompactionSummary); + box.* = CompactionSummary.init(self.alloc); + try box.setSummary(summary); + try self.pushEntry(.{ .compaction = box }); + return box; + } + + /// Toggle the global tool-use collapse state (ctrl+o) and apply it to every + /// tool-use component in the transcript. No "active component": we iterate + /// the whole list and flip each one. Requests a render. + pub fn toggleToolCollapse(self: *App) void { + self.tools_collapsed = !self.tools_collapsed; + for (self.transcript.items) |e| { + if (e == .tool) e.tool.setCollapsed(self.tools_collapsed); + } + self.scheduler.requestRender(); + } + // -- the render pump ---------------------------------------------------- /// Render a frame if one is pending, feeding the footer the measured @@ -312,15 +407,13 @@ pub const App = struct { try self.router.put(b.index, .{ .assistant = box }); }, .Thinking => { - // Minimal P1 stand-in: a dim streaming status line. - const box = try self.spawnStatus("[thinking] "); + const box = try self.spawnThinking(); 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: …"); + // The name is unknown at start (streamed); the component + // renders `tool (?)…` until `tool_details` resolves it. + const box = try self.spawnTool(); try self.router.put(b.index, .{ .tool = box }); }, .ToolResult => {}, @@ -330,10 +423,11 @@ pub const App = struct { .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); + try box.setName(d.name); + // Register the id -> component mapping so a later + // ToolResult (out-of-band, keyed by tool_use_id) finds + // this exact component. + try self.router.putToolId(d.id, box); self.scheduler.requestRender(); }, else => {}, @@ -346,15 +440,15 @@ pub const App = struct { 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 => {}, + .tool => |box| { + // Tool args stream as deltas — they ARE the verbatim + // JSON input. Accumulate them into the component. + try box.appendInput(d.delta); + self.scheduler.requestRender(); + }, }; }, .block_complete => |b| { @@ -362,22 +456,37 @@ pub const App = struct { .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); + // Final authoritative name + input from the + // completed block (covers the case where + // tool_details never fired and replaces any + // partial streamed args with the final bytes). + try box.setName(tu.name); + try box.setInput(tu.input.items); + try self.router.putToolId(tu.id, box); self.scheduler.requestRender(); }, else => {}, }; }, + .CompactionSummary => |cs| { + _ = try self.spawnCompaction(cs.text.items); + self.scheduler.requestRender(); + }, else => {}, } }, - .message_complete => {}, + .message_complete => |mc| { + // Update the footer's context-window token count with the + // LATEST usage (plan §6): input + cache_read + cache_write + // (output/reasoning excluded — not "in the window"). Latest + // value wins; not accumulated. + if (mc.usage) |u| { + const ctx = u.input + u.cache_read + u.cache_write; + self.footer.setContextTokens(ctx); + self.scheduler.requestRender(); + } + }, .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 { @@ -392,8 +501,37 @@ pub const App = struct { } self.scheduler.requestRender(); }, - .tool_dispatch_start, .tool_dispatch_complete, .turn_complete => {}, + .tool_dispatch_complete => |info| { + // ToolResult blocks are delivered together here as the content + // of the appended user message. Correlate each back to its + // ToolUse component by tool_use_id and feed it the result text. + try self.routeToolResults(info.message); + }, + .tool_dispatch_start, .turn_complete => {}, + } + } + + /// Walk a tool-dispatch-complete user message and feed each `ToolResult` + /// block's text to the `ToolUse` component that issued the matching call + /// (looked up by `tool_use_id`). Honors the no-active-component invariant: + /// the correlation is purely by id. + fn routeToolResults(self: *App, message: panto.Message) !void { + var any = false; + for (message.content.items) |block| { + switch (block) { + .ToolResult => |tr| { + const box = self.router.getToolById(tr.tool_use_id) orelse continue; + // Concatenate the textual parts of the result. + var text: std.ArrayList(u8) = .empty; + defer text.deinit(self.alloc); + try tr.appendTextInto(self.alloc, &text); + try box.setOutput(text.items); + any = true; + }, + else => {}, + } } + if (any) self.scheduler.requestRender(); } /// Reset per-turn routing state. The transcript entries persist (they are @@ -421,25 +559,45 @@ pub const App = struct { /// 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, + /// Streaming thinking block. + thinking: *Thinking, + /// Tool-use block (drives its own ToolUse component). + tool: *ToolUse, }; +/// Block-index -> component routing, plus a SECOND map from tool-call id -> +/// the owning `ToolUse` component. The id map is what correlates a later +/// `ToolResult` (delivered out-of-band in `tool_dispatch_complete`, keyed by +/// `tool_use_id`) back to the component that issued the call — without any +/// "active component" (plan §6). +/// +/// The id map borrows transcript-owned `*ToolUse` pointers; both maps are +/// cleared at each turn boundary (the transcript entries themselves persist as +/// history). String keys are duped into an arena so they outlive the borrowed +/// libpanto event slices. pub const TurnRouter = struct { map: std.AutoHashMap(usize, BlockRef), + tool_by_id: std.StringHashMap(*ToolUse), + id_arena: std.heap.ArenaAllocator, pub fn init(alloc: std.mem.Allocator) TurnRouter { - return .{ .map = std.AutoHashMap(usize, BlockRef).init(alloc) }; + return .{ + .map = std.AutoHashMap(usize, BlockRef).init(alloc), + .tool_by_id = std.StringHashMap(*ToolUse).init(alloc), + .id_arena = std.heap.ArenaAllocator.init(alloc), + }; } pub fn deinit(self: *TurnRouter) void { self.map.deinit(); + self.tool_by_id.deinit(); + self.id_arena.deinit(); } pub fn reset(self: *TurnRouter) void { self.map.clearRetainingCapacity(); + self.tool_by_id.clearRetainingCapacity(); + _ = self.id_arena.reset(.retain_capacity); } pub fn put(self: *TurnRouter, index: usize, ref: BlockRef) !void { @@ -449,6 +607,19 @@ pub const TurnRouter = struct { pub fn get(self: *TurnRouter, index: usize) ?BlockRef { return self.map.get(index); } + + /// Register a tool-call id -> its `ToolUse` component for result + /// correlation. The id is duped into the router arena (the libpanto slice + /// is borrowed and transient). + pub fn putToolId(self: *TurnRouter, id: []const u8, box: *ToolUse) !void { + const key = try self.id_arena.allocator().dupe(u8, id); + try self.tool_by_id.put(key, box); + } + + /// Look up the `ToolUse` component that issued the call with this id. + pub fn getToolById(self: *TurnRouter, id: []const u8) ?*ToolUse { + return self.tool_by_id.get(id); + } }; // =========================================================================== @@ -467,6 +638,15 @@ pub const RunOptions = struct { /// status line, then cleared. See `runLoop` for the rationale. cmd_capture: *std.Io.Writer.Allocating, model_label: []const u8, + /// Working directory shown in the welcome banner. Borrowed for the loop. + cwd: []const u8, + /// panto version string for the welcome banner (empty = omit). + version: []const u8 = "", + /// The std.Io used to spawn `$EDITOR` for the Ctrl+G round-trip. + io: std.Io, + /// Process environment, used to resolve `$EDITOR` (and `$VISUAL`) for the + /// Ctrl+G round-trip. Borrowed for the loop's lifetime. + environ: *const std.process.Environ.Map, }; /// Run the interactive chat loop against a real terminal until EOF / Ctrl+D / @@ -507,6 +687,17 @@ pub fn runLoop(app: *App, term: *Terminal, opts: RunOptions) !void { defer term.showCursor(); try app.footer.setModel(opts.model_label); + + // Session-start welcome banner as the first transcript entry. cwd is read + // from the process; the model label comes from the run options. (Version + // is not threaded through the run options yet; the banner omits it.) + { + const welcome = try app.spawnWelcome(); + try welcome.setModel(opts.model_label); + if (opts.cwd.len != 0) try welcome.setCwd(opts.cwd); + if (opts.version.len != 0) try welcome.setVersion(opts.version); + } + app.input_box.setFocused(true); try app.rebuildEngineList(); try app.renderNow(); @@ -568,6 +759,29 @@ fn handleBytes(app: *App, term: *Terminal, hs: *Handshake, bytes: []const u8, op // terminal's deinit in main. Signal EOF by closing the loop. return error.UserExit; } + if (k.isCtrl('o')) { + // Global collapse/expand of all tool-use components. Consume + // the key (do NOT feed it to the input box) and request a + // render. + app.toggleToolCollapse(); + off += step.consumed; + continue; + } + if (k.isCtrl('g')) { + // Punt the editor buffer out to $EDITOR (markdown tempfile), + // then read it back. Consume the key; never feed it to the + // box. + editInExternalEditor(app, term, opts.io, opts.environ) catch |err| { + if (std.fmt.allocPrint(app.alloc, "[$EDITOR failed: {s}]", .{@errorName(err)})) |msg| { + defer app.alloc.free(msg); + _ = app.spawnStatus(msg) catch {}; + } else |_| { + _ = app.spawnStatus("[$EDITOR failed]") catch {}; + } + }; + off += step.consumed; + continue; + } // Feed the key to the focused input box. app.input_box.comp().handleInput(bytes[off .. off + step.consumed]); }, @@ -622,6 +836,96 @@ fn handleNegotiation(term: *Terminal, hs: *Handshake, neg: input_mod.Negotiation } } +/// Punt the input box's buffer to the user's `$EDITOR` (Ctrl+G), then read it +/// back. Mirrors pi's editor escape hatch. +/// +/// Flow: write the buffer to a `.md` tempfile -> drop the terminal to cooked +/// mode + show the cursor -> spawn `$EDITOR ` inheriting our stdio and +/// wait -> re-enter raw mode + hide the cursor -> read the file back into the +/// box (trimming a single trailing newline most editors add) -> delete the +/// tempfile -> force a full engine redraw (the child scribbled all over the +/// screen, so the differential baseline is stale). +/// +/// The terminal's signal/panic restore record stays armed with the ORIGINAL +/// (cooked) termios throughout (`suspendRawMode` does not clear it), so a crash +/// or signal while the editor is open still leaves a sane terminal. We re-enter +/// raw mode on every return path via `defer`. +fn editInExternalEditor( + app: *App, + term: *Terminal, + io: std.Io, + environ: *const std.process.Environ.Map, +) !void { + const editor = environ.get("VISUAL") orelse environ.get("EDITOR") orelse "vi"; + + // Build a tempfile path: $TMPDIR (or /tmp) + a pid/nanotime-unique name. + const tmp_dir = environ.get("TMPDIR") orelse "/tmp"; + const pid = std.c.getpid(); + const nanos = std.Io.Clock.now(.awake, io).nanoseconds; + const path = try std.fmt.allocPrint(app.alloc, "{s}/panto-edit-{d}-{d}.md", .{ + std.mem.trimEnd(u8, tmp_dir, "/"), + pid, + nanos, + }); + defer app.alloc.free(path); + + // Write the current buffer out. + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = app.input_box.buffer() }); + defer std.Io.Dir.cwd().deleteFile(io, path) catch {}; + + // Split `$EDITOR` on spaces so commands with flags (e.g. "code -w") work, + // then append the file path as the final argv entry. + var argv: std.ArrayList([]const u8) = .empty; + defer argv.deinit(app.alloc); + try splitEditorArgv(app.alloc, editor, path, &argv); + + // Drop to cooked mode for the child; always re-enter raw mode + force a + // full redraw afterward. + term.suspendRawMode(); + app.flushSink(); + defer { + term.resumeRawMode() catch {}; + app.engine.forceFullRedraw(); + app.renderNow() catch {}; + } + + var child = try std.process.spawn(io, .{ + .argv = argv.items, + .stdin = .inherit, + .stdout = .inherit, + .stderr = .inherit, + }); + _ = try child.wait(io); + + // Read the edited file back. Cap the read so a pathological file can't OOM + // us; 16 MiB is far past any reasonable prompt. + const edited = std.Io.Dir.cwd().readFileAlloc(io, path, app.alloc, .limited(16 * 1024 * 1024)) catch |err| switch (err) { + else => return err, + }; + defer app.alloc.free(edited); + + // Trim a single trailing newline (the convention most editors add on save). + const trimmed = if (std.mem.endsWith(u8, edited, "\n")) edited[0 .. edited.len - 1] else edited; + try app.input_box.setBuffer(trimmed); +} + +/// Build the argv for the `$EDITOR` spawn: split `editor` on spaces (so +/// commands with flags like `"code -w"` work), fall back to `vi` when empty, +/// then append `path` as the final argument. Split out as a pure helper so the +/// arg-splitting seam is unit-testable without a PTY (the spawn + raw-mode +/// round-trip itself is interactive-only). +fn splitEditorArgv( + alloc: std.mem.Allocator, + editor: []const u8, + path: []const u8, + argv: *std.ArrayList([]const u8), +) !void { + var it = std.mem.tokenizeScalar(u8, editor, ' '); + while (it.next()) |part| try argv.append(alloc, part); + if (argv.items.len == 0) try argv.append(alloc, "vi"); + try argv.append(alloc, path); +} + /// 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; @@ -776,33 +1080,38 @@ test "routeEvent: two text blocks key by index, no active-component clobber" { 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" { +test "routeEvent: thinking deltas stream into a dedicated Thinking component" { 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")); + try h.app.routeEvent(delta(0, "reason")); + try h.app.routeEvent(delta(0, "ing")); 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); + try testing.expectEqualStrings("reasoning", ref.thinking.buffer.items); } -test "routeEvent: tool block renders a minimal tool: status (no crash)" { +test "routeEvent: tool block accumulates verbatim args and resolves its name" { 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. + // Tool args stream as deltas and accumulate verbatim into the component. try h.app.routeEvent(delta(0, "{\"path\":")); + try h.app.routeEvent(delta(0, "\"x\"}")); 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); + try testing.expect(ref.tool.name != null); + try testing.expectEqualStrings("read", ref.tool.name.?.items); + try testing.expectEqualStrings("{\"path\":\"x\"}", ref.tool.input.items); + // The id was registered for result correlation. + try testing.expect(h.app.router.getToolById("t1") == ref.tool); } test "routeEvent: provider_retry adds a dim status line" { @@ -874,3 +1183,226 @@ test "maybeRender feeds the footer a frame time and respects coalescing" { // Footer received a frame time (>= 0). try testing.expect(h.app.footer.frame_ms != null); } + +test "routeEvent: tool result correlates to its ToolUse component by id" { + const alloc = testing.allocator; + const h = try Harness.make(alloc); + defer h.teardown(alloc); + + // Open a tool call, resolve its id/name, accumulate args. + try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } }); + try h.app.routeEvent(delta(0, "{\"q\":1}")); + try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "call-1", .name = "search" } }); + + // Build a tool_dispatch_complete user message carrying a ToolResult for + // call-1 (the out-of-band delivery path). + var msg: panto.Message = .{ .role = .user }; + defer msg.deinit(alloc); + var parts: std.ArrayList(panto.ResultPartStored) = .empty; + var text: panto.TextualBlock = .empty; + try text.appendSlice(alloc, "the result body"); + try parts.append(alloc, .{ .text = text }); + const id = try alloc.dupe(u8, "call-1"); + try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = id, .parts = parts } }); + + try h.app.routeEvent(.{ .tool_dispatch_complete = .{ .message = msg } }); + + // The matching component received the output. + const box = h.app.router.getToolById("call-1").?; + try testing.expect(box.output != null); + try testing.expectEqualStrings("the result body", box.output.?.items); +} + +test "routeEvent: two concurrent tool calls route results to their OWN component by id" { + // The highest-risk no-active-component case (plan §6): with MULTIPLE tool + // calls in flight, each ToolResult must land on the component that issued + // the matching id — never "the" tool component. We deliberately deliver the + // results in the REVERSE order of the calls and assert no cross-talk. + const alloc = testing.allocator; + const h = try Harness.make(alloc); + defer h.teardown(alloc); + + // Open two tool calls at distinct block indices; resolve distinct ids. + try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } }); + try h.app.routeEvent(delta(0, "{\"a\":1}")); + try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "call-A", .name = "read" } }); + try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 1 } }); + try h.app.routeEvent(delta(1, "{\"b\":2}")); + try h.app.routeEvent(.{ .tool_details = .{ .index = 1, .id = "call-B", .name = "write" } }); + + const box_a = h.app.router.getToolById("call-A").?; + const box_b = h.app.router.getToolById("call-B").?; + try testing.expect(box_a != box_b); + + // Deliver BOTH results in ONE tool_dispatch_complete user message, in the + // reverse order (B before A), each carrying its own tool_use_id. + var msg: panto.Message = .{ .role = .user }; + defer msg.deinit(alloc); + { + var parts_b: std.ArrayList(panto.ResultPartStored) = .empty; + var text_b: panto.TextualBlock = .empty; + try text_b.appendSlice(alloc, "result for B"); + try parts_b.append(alloc, .{ .text = text_b }); + try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "call-B"), .parts = parts_b } }); + + var parts_a: std.ArrayList(panto.ResultPartStored) = .empty; + var text_a: panto.TextualBlock = .empty; + try text_a.appendSlice(alloc, "result for A"); + try parts_a.append(alloc, .{ .text = text_a }); + try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "call-A"), .parts = parts_a } }); + } + try h.app.routeEvent(.{ .tool_dispatch_complete = .{ .message = msg } }); + + // Each result landed on its OWN component — no clobber, no cross-talk. + try testing.expect(box_a.output != null); + try testing.expect(box_b.output != null); + try testing.expectEqualStrings("result for A", box_a.output.?.items); + try testing.expectEqualStrings("result for B", box_b.output.?.items); + // And the inputs were never crossed either. + try testing.expectEqualStrings("{\"a\":1}", box_a.input.items); + try testing.expectEqualStrings("{\"b\":2}", box_b.input.items); +} + +test "routeEvent: an unmatched tool_use_id is ignored, matched siblings still route" { + // A result whose id has no live ToolUse must be skipped (orelse continue), + // never crash or smear onto another component. + 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 } }); + try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "known", .name = "read" } }); + const known = h.app.router.getToolById("known").?; + + var msg: panto.Message = .{ .role = .user }; + defer msg.deinit(alloc); + { + var p_unknown: std.ArrayList(panto.ResultPartStored) = .empty; + var t_unknown: panto.TextualBlock = .empty; + try t_unknown.appendSlice(alloc, "orphan"); + try p_unknown.append(alloc, .{ .text = t_unknown }); + try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "ghost"), .parts = p_unknown } }); + + var p_known: std.ArrayList(panto.ResultPartStored) = .empty; + var t_known: panto.TextualBlock = .empty; + try t_known.appendSlice(alloc, "real"); + try p_known.append(alloc, .{ .text = t_known }); + try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "known"), .parts = p_known } }); + } + try h.app.routeEvent(.{ .tool_dispatch_complete = .{ .message = msg } }); + + try testing.expect(known.output != null); + try testing.expectEqualStrings("real", known.output.?.items); +} + +test "toggleToolCollapse flips every tool component globally" { + const alloc = testing.allocator; + const h = try Harness.make(alloc); + defer h.teardown(alloc); + + // Two tool calls. Default collapsed == true. + try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } }); + try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 1 } }); + const a = h.app.router.get(0).?.tool; + const b = h.app.router.get(1).?.tool; + try testing.expect(a.collapsed and b.collapsed); + + // ctrl+o equivalent: expand all. + h.app.toggleToolCollapse(); + try testing.expect(!a.collapsed and !b.collapsed); + try testing.expect(!h.app.tools_collapsed); + + // Toggle again: collapse all. + h.app.toggleToolCollapse(); + try testing.expect(a.collapsed and b.collapsed); +} + +test "toggleToolCollapse: a tool spawned AFTER the toggle inherits the global state" { + // ctrl+o is a GLOBAL mode, not a per-component flip: a tool call that opens + // later must adopt whatever the current global collapse state is, so the + // whole transcript stays consistent. + const alloc = testing.allocator; + const h = try Harness.make(alloc); + defer h.teardown(alloc); + + // Default is collapsed; flip the global mode to EXPANDED before any tool. + h.app.toggleToolCollapse(); + try testing.expect(!h.app.tools_collapsed); + + // A tool that opens now must be expanded to match the global mode. + try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } }); + const late = h.app.router.get(0).?.tool; + try testing.expect(!late.collapsed); + + // Flip back to collapsed; a still-later tool must open collapsed. + h.app.toggleToolCollapse(); + try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 1 } }); + const later = h.app.router.get(1).?.tool; + try testing.expect(later.collapsed); + // And the earlier one flipped along with the global toggle. + try testing.expect(late.collapsed); +} + +test "spawnWelcome shows a session-start banner entry" { + const alloc = testing.allocator; + const h = try Harness.make(alloc); + defer h.teardown(alloc); + + const w = try h.app.spawnWelcome(); + try w.setModel("m"); + try testing.expectEqual(@as(usize, 1), h.app.transcript.items.len); + try testing.expect(h.app.transcript.items[0] == .welcome); +} + +test "routeEvent: compaction summary block spawns a compaction entry" { + const alloc = testing.allocator; + const h = try Harness.make(alloc); + defer h.teardown(alloc); + + var cs: panto.TextualBlock = .empty; + defer cs.deinit(alloc); + try cs.appendSlice(alloc, "old turns summarized"); + try h.app.routeEvent(.{ .block_complete = .{ + .index = 0, + .block = .{ .CompactionSummary = .{ .text = cs } }, + } }); + + try testing.expectEqual(@as(usize, 1), h.app.transcript.items.len); + try testing.expect(h.app.transcript.items[0] == .compaction); +} + +test "splitEditorArgv: splits flags, appends the path, and falls back to vi" { + const alloc = testing.allocator; + + // Bare editor name: [editor, path]. + { + var argv: std.ArrayList([]const u8) = .empty; + defer argv.deinit(alloc); + try splitEditorArgv(alloc, "nvim", "/tmp/panto-edit-1.md", &argv); + try testing.expectEqual(@as(usize, 2), argv.items.len); + try testing.expectEqualStrings("nvim", argv.items[0]); + try testing.expectEqualStrings("/tmp/panto-edit-1.md", argv.items[1]); + } + + // Editor with flags: each space-delimited token is its own argv entry, + // then the path is last (e.g. "code -w" -> [code, -w, path]). + { + var argv: std.ArrayList([]const u8) = .empty; + defer argv.deinit(alloc); + try splitEditorArgv(alloc, "code -w", "/tmp/x.md", &argv); + try testing.expectEqual(@as(usize, 3), argv.items.len); + try testing.expectEqualStrings("code", argv.items[0]); + try testing.expectEqualStrings("-w", argv.items[1]); + try testing.expectEqualStrings("/tmp/x.md", argv.items[2]); + } + + // Empty editor string: falls back to vi, then the path. + { + var argv: std.ArrayList([]const u8) = .empty; + defer argv.deinit(alloc); + try splitEditorArgv(alloc, "", "/tmp/y.md", &argv); + try testing.expectEqual(@as(usize, 2), argv.items.len); + try testing.expectEqualStrings("vi", argv.items[0]); + try testing.expectEqualStrings("/tmp/y.md", argv.items[1]); + } +} diff --git a/src/tui_components.zig b/src/tui_components.zig index 3dfe817..b2ed842 100644 --- a/src/tui_components.zig +++ b/src/tui_components.zig @@ -369,8 +369,20 @@ pub const InputBox = struct { /// or `takeSubmitted`. submitted: std.ArrayList(u8) = .empty, has_submitted: bool = false, + /// Maximum number of VISUAL rows the box renders at once (plan §6 / P2). + /// When the wrapped/`\n`-split buffer exceeds this many rows, the box + /// renders only a `line_cap`-tall SCROLL-WINDOW that follows the cursor + /// (so the cursor row stays visible). The default is 8. A single-row + /// buffer still renders one row — the cap is a ceiling, not a floor, so + /// the existing "single-row default, grow one row per line" behavior is + /// preserved up to the cap. + line_cap: usize = default_line_cap, cache: RenderCache, + /// Default visual-row cap (plan §6, P2): show at most the last 8 contiguous + /// lines once the buffer grows past the window. + pub const default_line_cap: usize = 8; + pub fn init(alloc: std.mem.Allocator) InputBox { return .{ .alloc = alloc, .cache = RenderCache.init(alloc) }; } @@ -406,6 +418,23 @@ pub const InputBox = struct { return self.submitted.items; } + // -- buffer access (for the Ctrl+G $EDITOR round-trip) ----------------- + + /// The current editor contents (box-owned; valid until the next edit). + /// Used by the app's Ctrl+G handler to seed the external-editor tempfile. + pub fn buffer(self: *const InputBox) []const u8 { + return self.text.items; + } + + /// Replace the whole editor buffer (e.g. with text edited in `$EDITOR`). + /// Places the cursor at the end and marks dirty. + pub fn setBuffer(self: *InputBox, bytes: []const u8) !void { + self.text.clearRetainingCapacity(); + try self.text.appendSlice(self.alloc, bytes); + self.cursor = self.text.items.len; + self.cache.markDirty(); + } + // -- editing primitives (also directly unit-testable) ------------------ fn insertText(self: *InputBox, bytes: []const u8) !void { @@ -457,6 +486,116 @@ pub const InputBox = struct { self.cache.markDirty(); } + /// Byte index of the start of the current LOGICAL line (the byte just after + /// the previous '\n', or 0). "Logical line" = a run delimited by '\n' in + /// the buffer, independent of visual wrapping. + fn lineStart(self: *const InputBox, at: usize) usize { + if (at == 0) return 0; + if (std.mem.lastIndexOfScalar(u8, self.text.items[0..at], '\n')) |nl| return nl + 1; + return 0; + } + + /// Byte index of the end of the current LOGICAL line (the next '\n' at or + /// after `at`, or the buffer end). + fn lineEnd(self: *const InputBox, at: usize) usize { + if (std.mem.indexOfScalarPos(u8, self.text.items, at, '\n')) |nl| return nl; + return self.text.items.len; + } + + /// Move the cursor to the start of the current logical line (Ctrl+A). + fn moveLineStart(self: *InputBox) void { + const dest = self.lineStart(self.cursor); + if (dest == self.cursor) return; + self.cursor = dest; + self.cache.markDirty(); + } + + /// Move the cursor to the end of the current logical line (Ctrl+E). + fn moveLineEnd(self: *InputBox) void { + const dest = self.lineEnd(self.cursor); + if (dest == self.cursor) return; + self.cursor = dest; + self.cache.markDirty(); + } + + /// Whether the codepoint starting at byte `i` is "word" whitespace for + /// word-motion. We treat ASCII spaces, tabs, and newlines as separators. + fn isWordSep(self: *const InputBox, i: usize) bool { + const b = self.text.items[i]; + return b == ' ' or b == '\t' or b == '\n'; + } + + /// Byte index one word to the LEFT of `from` (standard word-motion: skip a + /// run of separators, then a run of non-separators). Returns 0 at the + /// start. Operates on codepoint boundaries. + fn prevWord(self: *const InputBox, from: usize) usize { + var i = from; + // Skip separators immediately to the left. + while (i > 0) { + const p = self.prevBoundary(i); + if (!self.isWordSep(p)) break; + i = p; + } + // Skip the word (non-separators) to the left. + while (i > 0) { + const p = self.prevBoundary(i); + if (self.isWordSep(p)) break; + i = p; + } + return i; + } + + /// Byte index one word to the RIGHT of `from` (skip a run of non-separators, + /// then a run of separators). Returns the buffer end at the end. Operates on + /// codepoint boundaries. + fn nextWord(self: *const InputBox, from: usize) usize { + var i = from; + const len = self.text.items.len; + // Skip the word (non-separators) to the right. + while (i < len and !self.isWordSep(i)) i = self.nextBoundary(i); + // Skip trailing separators. + while (i < len and self.isWordSep(i)) i = self.nextBoundary(i); + return i; + } + + /// Move one word left (Alt+Left / Ctrl+Left). + fn moveWordLeft(self: *InputBox) void { + if (self.cursor == 0) return; + self.cursor = self.prevWord(self.cursor); + self.cache.markDirty(); + } + + /// Move one word right (Alt+Right / Ctrl+Right). + fn moveWordRight(self: *InputBox) void { + if (self.cursor >= self.text.items.len) return; + self.cursor = self.nextWord(self.cursor); + self.cache.markDirty(); + } + + /// Delete from the cursor back to the start of the current logical line + /// (Ctrl+U). A PLAIN delete — no kill-ring / yank buffer is kept. + fn deleteToLineStart(self: *InputBox) void { + const start = self.lineStart(self.cursor); + if (start == self.cursor) return; + 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(); + } + + /// Delete the previous word (Ctrl+W). A PLAIN delete — no kill-ring. + fn deletePrevWord(self: *InputBox) void { + if (self.cursor == 0) return; + const start = self.prevWord(self.cursor); + if (start == self.cursor) return; + 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 submit(self: *InputBox) !void { self.submitted.clearRetainingCapacity(); try self.submitted.appendSlice(self.alloc, self.text.items); @@ -491,7 +630,52 @@ pub const InputBox = struct { 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 + // Ctrl/Alt chord bindings (standard editing shortcuts). These + // are handled BEFORE the printable-insert path, which rejects + // modified chars. NONE of these keep a kill-ring / yank buffer + // — they are plain moves/deletes (plan P2: no kill-ring/undo). + if (k.mods.ctrl and !k.mods.alt and !k.mods.super) { + switch (std.ascii.toLower(@intCast(k.code.char & 0x7f))) { + 'u' => { + self.deleteToLineStart(); // delete to line start + return; + }, + 'w' => { + self.deletePrevWord(); // delete previous word + return; + }, + 'a' => { + self.moveLineStart(); // start of line + return; + }, + 'e' => { + self.moveLineEnd(); // end of line + return; + }, + else => return, // other ctrl chords: ignore (Ctrl+G + // handled at the app level, never reaches the box). + } + } + // Alt-chord word bindings. Many terminals (notably Ghostty + // and macOS terminals) send Alt+Left/Right as the classic + // readline `ESC b` / `ESC f` rather than a modified arrow CSI, + // so they arrive here as alt+b / alt+f char keys. Map them to + // the same word-motion as Alt/Ctrl+Arrow so word navigation + // works regardless of which form the terminal emits. + if (k.mods.alt and !k.mods.ctrl and !k.mods.super) { + switch (std.ascii.toLower(@intCast(k.code.char & 0x7f))) { + 'b' => { + self.moveWordLeft(); + return; + }, + 'f' => { + self.moveWordRight(); + return; + }, + else => return, // other alt chords: ignore (not text) + } + } + if (k.mods.alt or k.mods.super) return; // not a printable insert if (k.text) |t| { try self.insertText(t); } else { @@ -508,13 +692,18 @@ pub const InputBox = struct { try self.submit(); } }, - .backspace => self.backspace(), + // Alt/Ctrl+Backspace deletes the previous word (readline + // convention); plain Backspace deletes one codepoint. + .backspace => if (k.mods.alt or k.mods.ctrl) self.deletePrevWord() else self.backspace(), .delete => self.deleteForward(), - .left => self.moveLeft(), - .right => self.moveRight(), + // Word-motion: Alt+Left/Right (xterm/kitty) and Ctrl+Left/Right + // (many terminals send `1;5D`/`1;5C`). Plain Left/Right move by one + // codepoint. + .left => if (k.mods.alt or k.mods.ctrl) self.moveWordLeft() else self.moveLeft(), + .right => if (k.mods.alt or k.mods.ctrl) self.moveWordRight() else self.moveRight(), .home => self.moveHome(), .end => self.moveEnd(), - else => {}, // tab, arrows up/down, fkeys: ignored in P1 + else => {}, // tab, arrows up/down, fkeys: ignored } } @@ -545,6 +734,13 @@ pub const InputBox = struct { /// 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. + /// + /// Scroll-window (plan §6, P2): when the buffer produces more than + /// `line_cap` rows, only a `line_cap`-tall window is rendered. The window + /// FOLLOWS the cursor — it always contains the cursor's row — and defaults + /// to the LAST `line_cap` rows (so a freshly grown buffer shows its tail, + /// where the cursor usually is). A single-row buffer is unaffected: the cap + /// is a ceiling, not a floor. fn renderLines(self: *InputBox, width: usize) ![]const []const u8 { const a = self.alloc; var rows: std.ArrayList([]const u8) = .empty; @@ -558,8 +754,11 @@ pub const InputBox = struct { const focused = self.focusable.focused; // Walk lines, tracking byte offset so we know which row holds cursor. + // `cursor_row` records which produced row carries the cursor block, so + // the scroll-window below can keep it visible. var line_byte_start: usize = 0; var produced_any = false; + var cursor_row: usize = 0; var it = std.mem.splitScalar(u8, self.text.items, '\n'); while (it.next()) |line| { const line_start = line_byte_start; @@ -572,6 +771,7 @@ pub const InputBox = struct { // line's start unless this is the last line. (self.cursor < line_end or it.peek() == null); + if (cursor_in_line) cursor_row = rows.items.len; 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; @@ -583,10 +783,38 @@ pub const InputBox = struct { try rows.append(a, row); } - try self.cache.store(rows.items); + // Apply the scroll-window: store at most `line_cap` rows, the window + // defaulting to the tail and sliding up to keep the focused cursor row + // visible. When unfocused there is no live cursor, so we never slide + // up — the tail window stands. + const window = self.scrollWindow(rows.items.len, if (focused) cursor_row else null); + try self.cache.store(rows.items[window.start..window.end]); return cacheLines(&self.cache); } + /// Compute the visible `[start, end)` row range for the scroll-window given + /// the total produced rows and (optionally) the focused cursor's row. + /// Returns the whole range when `total <= line_cap` (or the cap is + /// 0/disabled). Otherwise returns a `line_cap`-tall window biased toward the + /// TAIL: the default window is the last `line_cap` rows, sliding UP only as + /// far as needed to keep `cursor_row` visible. A null `cursor_row` (no live + /// cursor / unfocused) leaves the tail window in place. + const Window = struct { start: usize, end: usize }; + fn scrollWindow(self: *const InputBox, total: usize, cursor_row: ?usize) Window { + const cap = self.line_cap; + if (cap == 0 or total <= cap) return .{ .start = 0, .end = total }; + + // Default to the last `cap` rows. + var start = total - cap; + // Slide the window up if the focused cursor is above it (keep the cursor + // row in view). The cursor is never below the tail window, so no + // downward slide is needed. + if (cursor_row) |cr| { + if (cr < start) start = cr; + } + return .{ .start = start, .end = start + cap }; + } + /// 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 @@ -709,6 +937,11 @@ pub const Footer = struct { frame_ms: ?f64 = null, /// Model info string (borrowed; copied into a small owned buffer on set). model: std.ArrayList(u8) = .empty, + /// Latest context-window size in tokens (null = no usage reported yet). + /// Overwritten on each `message_complete` with the most recent value, not + /// accumulated. Defined (plan §6) as + /// `usage.input + usage.cache_read + usage.cache_write`. + context_tokens: ?u64 = null, pub fn init(alloc: std.mem.Allocator) Footer { return .{ .alloc = alloc, .cache = RenderCache.init(alloc) }; @@ -733,6 +966,24 @@ pub const Footer = struct { self.cache.markDirty(); } + /// Set the latest context-window token count (plan §6). The caller passes + /// the already-summed `input + cache_read + cache_write`. Overwrites the + /// previous value (latest-wins) and marks dirty so the footer repaints. + pub fn setContextTokens(self: *Footer, tokens: u64) void { + self.context_tokens = tokens; + self.cache.markDirty(); + } + + /// Format the context-window element: e.g. "12.3k ctx" for large counts, + /// "845 ctx" for small ones. "" (empty) when no usage reported yet, so the + /// element is simply absent until the first `message_complete`. + fn contextText(self: *const Footer, buf: []u8) []const u8 { + const n = self.context_tokens orelse return ""; + if (n < 1000) return std.fmt.bufPrint(buf, "{d} ctx", .{n}) catch ""; + const k = @as(f64, @floatFromInt(n)) / 1000.0; + return std.fmt.bufPrint(buf, "{d:.1}k ctx", .{k}) catch ""; + } + /// 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. @@ -751,14 +1002,21 @@ pub const Footer = struct { var fps_buf: [48]u8 = undefined; const fps = self.fpsText(&fps_buf); + var ctx_buf: [32]u8 = undefined; + const ctx = self.contextText(&ctx_buf); - // Build the PLAIN content: " " (model only if set). + // Build the PLAIN content: " " (model and ctx + // only when present). var plain: std.ArrayList(u8) = .empty; defer plain.deinit(a); if (self.model.items.len != 0) { try plain.appendSlice(a, self.model.items); try plain.appendSlice(a, " "); } + if (ctx.len != 0) { + try plain.appendSlice(a, ctx); + try plain.appendSlice(a, " "); + } try plain.appendSlice(a, fps); const vis = truncateToCols(plain.items, width); @@ -796,6 +1054,448 @@ pub const Footer = struct { } }; +// =========================================================================== +// Welcome — session-start banner (plan §6: "version, cwd, model info") +// =========================================================================== + +/// A static banner shown as the first transcript entry at session start. +/// Structured data in (version / cwd / model label via setters), lines out. +/// Re-rendered only when one of the fields changes (markDirty), which in +/// practice is once during bring-up. +pub const Welcome = struct { + alloc: std.mem.Allocator, + cache: RenderCache, + version: std.ArrayList(u8) = .empty, + cwd: std.ArrayList(u8) = .empty, + model: std.ArrayList(u8) = .empty, + + pub fn init(alloc: std.mem.Allocator) Welcome { + return .{ .alloc = alloc, .cache = RenderCache.init(alloc) }; + } + + pub fn deinit(self: *Welcome) void { + self.version.deinit(self.alloc); + self.cwd.deinit(self.alloc); + self.model.deinit(self.alloc); + self.cache.deinit(); + } + + fn setField(self: *Welcome, field: *std.ArrayList(u8), value: []const u8) !void { + field.clearRetainingCapacity(); + try field.appendSlice(self.alloc, value); + self.cache.markDirty(); + } + + /// Set the panto version string (e.g. "0.1.0"). + pub fn setVersion(self: *Welcome, value: []const u8) !void { + try self.setField(&self.version, value); + } + + /// Set the working directory shown in the banner. + pub fn setCwd(self: *Welcome, value: []const u8) !void { + try self.setField(&self.cwd, value); + } + + /// Set the model label shown in the banner. + pub fn setModel(self: *Welcome, value: []const u8) !void { + try self.setField(&self.model, value); + } + + fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { + _ = alloc; + const self: *Welcome = @ptrCast(@alignCast(ptr)); + const a = self.alloc; + const accent = theme.default.fg(.welcome); + const dim = theme.default.fg(.dim); + + // Transient owned lines; freed after the cache dupes them. + var lines: std.ArrayList([]const u8) = .empty; + defer { + for (lines.items) |l| a.free(l); + lines.deinit(a); + } + + // Title line: "panto v" in the accent color. + { + const title_plain = if (self.version.items.len != 0) + try std.fmt.allocPrint(a, "panto v{s}", .{self.version.items}) + else + try std.fmt.allocPrint(a, "panto", .{}); + defer a.free(title_plain); + const vis = truncateToCols(title_plain, width); + try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ accent.open(), vis, accent.close() })); + } + + // Detail lines (dim): cwd and model, only when set. + if (self.cwd.items.len != 0) { + const plain = try std.fmt.allocPrint(a, "cwd: {s}", .{self.cwd.items}); + defer a.free(plain); + const vis = truncateToCols(plain, width); + try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ dim.open(), vis, dim.close() })); + } + if (self.model.items.len != 0) { + const plain = try std.fmt.allocPrint(a, "model: {s}", .{self.model.items}); + defer a.free(plain); + const vis = truncateToCols(plain, width); + try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ dim.open(), vis, dim.close() })); + } + + try self.cache.store(lines.items); + return cacheLines(&self.cache); + } + + fn firstLineChangedImpl(ptr: *anyopaque) ?usize { + const self: *Welcome = @ptrCast(@alignCast(ptr)); + return self.cache.firstLineChanged(); + } + + fn invalidateImpl(ptr: *anyopaque) void { + const self: *Welcome = @ptrCast(@alignCast(ptr)); + self.cache.invalidate(); + } + + const vtable = Component.VTable{ + .render = renderImpl, + .firstLineChanged = firstLineChangedImpl, + .invalidate = invalidateImpl, + }; + + pub fn comp(self: *Welcome) Component { + return .{ .ptr = self, .vtable = &vtable }; + } +}; + +// =========================================================================== +// Thinking — streaming thinking deltas (plan §6: dimmed; streams) +// =========================================================================== + +/// Accumulates thinking content deltas into an internal buffer and renders the +/// wrapped text with the theme's `thinking` (dim) style. Shares AssistantText's +/// streaming-tail dirty model (`appendDelta` + `markDirtyAppend`), so the cut +/// stays near the tail while reasoning streams. It is its OWN component type +/// (not a styled AssistantText) so the component taxonomy is honest: the engine +/// and any future event/handler logic can distinguish a thinking block from an +/// assistant body. +pub const Thinking = struct { + alloc: std.mem.Allocator, + buffer: std.ArrayList(u8) = .empty, + cache: RenderCache, + + pub fn init(alloc: std.mem.Allocator) Thinking { + return .{ .alloc = alloc, .cache = RenderCache.init(alloc) }; + } + + pub fn deinit(self: *Thinking) void { + self.buffer.deinit(self.alloc); + self.cache.deinit(); + } + + /// Append a streaming thinking delta. Retains the baseline so the cache + /// diff recovers the true tail change point (firstLineChanged near the end). + pub fn appendDelta(self: *Thinking, delta: []const u8) !void { + try self.buffer.appendSlice(self.alloc, delta); + self.cache.markDirtyAppend(); + } + + /// Replace the whole buffer. Marks dirty. + pub fn setText(self: *Thinking, 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: *Thinking = @ptrCast(@alignCast(ptr)); + return renderStyledLines(&self.cache, self.buffer.items, theme.default.fg(.thinking), width, self.alloc); + } + + fn firstLineChangedImpl(ptr: *anyopaque) ?usize { + const self: *Thinking = @ptrCast(@alignCast(ptr)); + return self.cache.firstLineChanged(); + } + + fn invalidateImpl(ptr: *anyopaque) void { + const self: *Thinking = @ptrCast(@alignCast(ptr)); + self.cache.invalidate(); + } + + const vtable = Component.VTable{ + .render = renderImpl, + .firstLineChanged = firstLineChangedImpl, + .invalidate = invalidateImpl, + }; + + pub fn comp(self: *Thinking) Component { + return .{ .ptr = self, .vtable = &vtable }; + } +}; + +// =========================================================================== +// CompactionSummary — shown when context is compacted (plan §6) +// =========================================================================== + +/// Renders a compaction summary (the synthetic seed text that replaces a +/// compacted conversation prefix). Structured data in (the summary string via +/// `setSummary`), lines out. Styled as dim chrome with a short prefix so it +/// reads as a system event rather than assistant prose. +pub const CompactionSummary = struct { + alloc: std.mem.Allocator, + buffer: std.ArrayList(u8) = .empty, + cache: RenderCache, + + pub fn init(alloc: std.mem.Allocator) CompactionSummary { + return .{ .alloc = alloc, .cache = RenderCache.init(alloc) }; + } + + pub fn deinit(self: *CompactionSummary) void { + self.buffer.deinit(self.alloc); + self.cache.deinit(); + } + + /// Set the compaction summary text. Marks dirty. + pub fn setSummary(self: *CompactionSummary, 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: *CompactionSummary = @ptrCast(@alignCast(ptr)); + const a = self.alloc; + const style = theme.default.fg(.compaction); + + // Wrap a header line plus the summary body, all styled as compaction + // chrome. The header makes the event legible even when the summary is + // empty. + var plain: std.ArrayList([]const u8) = .empty; + defer plain.deinit(a); + try plain.append(a, "[context compacted]"); + try wrapBuffer(self.buffer.items, width, &plain, a); + + var styled: std.ArrayList([]const u8) = .empty; + defer { + for (styled.items) |s| a.free(s); + styled.deinit(a); + } + for (plain.items) |line| { + const vis = truncateToCols(line, width); + try styled.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ style.open(), vis, style.close() })); + } + + try self.cache.store(styled.items); + return cacheLines(&self.cache); + } + + fn firstLineChangedImpl(ptr: *anyopaque) ?usize { + const self: *CompactionSummary = @ptrCast(@alignCast(ptr)); + return self.cache.firstLineChanged(); + } + + fn invalidateImpl(ptr: *anyopaque) void { + const self: *CompactionSummary = @ptrCast(@alignCast(ptr)); + self.cache.invalidate(); + } + + const vtable = Component.VTable{ + .render = renderImpl, + .firstLineChanged = firstLineChangedImpl, + .invalidate = invalidateImpl, + }; + + pub fn comp(self: *CompactionSummary) Component { + return .{ .ptr = self, .vtable = &vtable }; + } +}; + +// =========================================================================== +// ToolUse — one component owns the whole call + result (plan §6, P2) +// =========================================================================== + +/// A single component that owns an entire tool call: its name, its streamed +/// input (verbatim JSON args), and its result output. Render progression +/// (plan §6 / P2 table): +/// +/// 1. At creation (block_start), name unknown: `tool (?)…` +/// 2. Once args finish streaming (name known): `tool () ` +/// followed by a blank line and `(…)` as a result placeholder. +/// 3. Once the result lands: `tool () ` +/// followed by a blank line and the result output text. +/// +/// The input JSON is rendered VERBATIM (no pretty-print), terminal-wrapped to +/// width. It accumulates from the ToolUse block's `content_delta`s (the deltas +/// ARE the streaming JSON args), or is set wholesale from the completed block. +/// +/// Collapsing (ctrl+o) is a GLOBAL toggle driven by the app: it calls +/// `setCollapsed(bool)` on every ToolUse component. Default is COLLAPSED. When +/// collapsed, only the LAST 5 lines of the wrapped output are shown (with a +/// leading `…` marker line when output was truncated); expanded shows all of +/// it. Collapsing is a length change — the RenderCache diff and the engine's +/// line-diff backstop handle the shrink; the component just re-renders fewer +/// lines and marks dirty. +/// +/// No "active component" (plan §6): the app keys each ToolUse instance by the +/// libpanto block index AND by tool-call id (for result correlation). This +/// component holds no global state. +pub const ToolUse = struct { + /// Number of trailing output lines shown when collapsed. + pub const collapsed_tail_lines: usize = 5; + + alloc: std.mem.Allocator, + cache: RenderCache, + /// Resolved tool name, or null until `tool_details`/completion. + name: ?std.ArrayList(u8) = null, + /// Accumulated verbatim input JSON (streamed args). + input: std.ArrayList(u8) = .empty, + /// Result output text, or null until the result lands. + output: ?std.ArrayList(u8) = null, + /// Whether the output is collapsed to its tail. Default true. + collapsed: bool = true, + + pub fn init(alloc: std.mem.Allocator) ToolUse { + return .{ .alloc = alloc, .cache = RenderCache.init(alloc) }; + } + + pub fn deinit(self: *ToolUse) void { + if (self.name) |*n| n.deinit(self.alloc); + self.input.deinit(self.alloc); + if (self.output) |*o| o.deinit(self.alloc); + self.cache.deinit(); + } + + /// Resolve the tool name (from `tool_details` or the completed block). + pub fn setName(self: *ToolUse, name: []const u8) !void { + if (self.name == null) self.name = .empty; + self.name.?.clearRetainingCapacity(); + try self.name.?.appendSlice(self.alloc, name); + self.cache.markDirty(); + } + + /// Append a streaming args delta (verbatim JSON bytes). + pub fn appendInput(self: *ToolUse, delta: []const u8) !void { + try self.input.appendSlice(self.alloc, delta); + self.cache.markDirty(); + } + + /// Replace the input verbatim (e.g. from the completed block's `input`). + pub fn setInput(self: *ToolUse, value: []const u8) !void { + self.input.clearRetainingCapacity(); + try self.input.appendSlice(self.alloc, value); + self.cache.markDirty(); + } + + /// Set the result output text. Transitions render stage 2 -> 3. + pub fn setOutput(self: *ToolUse, value: []const u8) !void { + if (self.output == null) self.output = .empty; + self.output.?.clearRetainingCapacity(); + try self.output.?.appendSlice(self.alloc, value); + self.cache.markDirty(); + } + + /// Global collapse toggle target. The app calls this on every ToolUse + /// component when ctrl+o is pressed. A no-op state change skips the dirty. + pub fn setCollapsed(self: *ToolUse, value: bool) void { + if (self.collapsed == value) return; + self.collapsed = value; + self.cache.markDirty(); + } + + fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { + _ = alloc; + const self: *ToolUse = @ptrCast(@alignCast(ptr)); + const a = self.alloc; + const tool_style = theme.default.fg(.tool); + const dim = theme.default.fg(.dim); + + // Transient owned lines; the cache dupes them and we free here. + var lines: std.ArrayList([]const u8) = .empty; + defer { + for (lines.items) |l| a.free(l); + lines.deinit(a); + } + + // -- Header: `tool (?)…` or `tool () ` ------------ + if (self.name == null) { + const plain = "tool (?)…"; + const vis = truncateToCols(plain, width); + try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ tool_style.open(), vis, tool_style.close() })); + try self.cache.store(lines.items); + return cacheLines(&self.cache); + } + + // Name known: header is `tool () `, wrapped to width. + // The whole header (including verbatim JSON) is one logical paragraph + // that we wrap; it is styled with the tool accent. + { + const header_plain = try std.fmt.allocPrint(a, "tool ({s}) {s}", .{ self.name.?.items, self.input.items }); + defer a.free(header_plain); + var wrapped: std.ArrayList([]const u8) = .empty; + defer wrapped.deinit(a); + try wrapParagraph(header_plain, width, &wrapped, a); + for (wrapped.items) |line| { + const vis = truncateToCols(line, width); + try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ tool_style.open(), vis, tool_style.close() })); + } + } + + // -- Blank separator line ------------------------------------------ + try lines.append(a, try a.dupe(u8, "")); + + // -- Result region: `(…)` placeholder or the output text ----------- + if (self.output == null) { + const vis = truncateToCols("(…)", width); + try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ dim.open(), vis, dim.close() })); + } else { + // Wrap the full output, then optionally collapse to the tail. + var out_lines: std.ArrayList([]const u8) = .empty; + defer out_lines.deinit(a); + try wrapBuffer(self.output.?.items, width, &out_lines, a); + + var start: usize = 0; + var truncated = false; + if (self.collapsed and out_lines.items.len > collapsed_tail_lines) { + start = out_lines.items.len - collapsed_tail_lines; + truncated = true; + } + if (truncated) { + // A leading marker so the user knows output was elided. + const vis = truncateToCols("…", width); + try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ dim.open(), vis, dim.close() })); + } + for (out_lines.items[start..]) |line| { + const vis = truncateToCols(line, width); + // Output uses plain assistant style (no escape) so it reads as + // content; truncate enforces the width contract. + try lines.append(a, try a.dupe(u8, vis)); + } + } + + try self.cache.store(lines.items); + return cacheLines(&self.cache); + } + + fn firstLineChangedImpl(ptr: *anyopaque) ?usize { + const self: *ToolUse = @ptrCast(@alignCast(ptr)); + return self.cache.firstLineChanged(); + } + + fn invalidateImpl(ptr: *anyopaque) void { + const self: *ToolUse = @ptrCast(@alignCast(ptr)); + self.cache.invalidate(); + } + + const vtable = Component.VTable{ + .render = renderImpl, + .firstLineChanged = firstLineChangedImpl, + .invalidate = invalidateImpl, + }; + + pub fn comp(self: *ToolUse) Component { + return .{ .ptr = self, .vtable = &vtable }; + } +}; + // =========================================================================== // Tests // =========================================================================== @@ -1012,6 +1712,270 @@ test "InputBox: cursor block fits within width at end of a full line" { try testing.expect(vw(lines[0]) <= 5); } +fn ctrlKey(letter: u8) Key { + return .{ .code = .{ .char = letter }, .mods = .{ .ctrl = true } }; +} + +fn typeStr(ib: *InputBox, s: []const u8) !void { + for (s) |c| try ib.applyKey(charKey(c, &[_]u8{c})); +} + +test "InputBox: alt+left / alt+right move by word" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + try typeStr(&ib, "foo bar baz"); // cursor at 11 (end) + try testing.expectEqual(@as(usize, 11), ib.cursor); + // Alt+Left: jump to start of "baz" (byte 8). + try ib.applyKey(.{ .code = .left, .mods = .{ .alt = true } }); + try testing.expectEqual(@as(usize, 8), ib.cursor); + // Again: start of "bar" (byte 4). + try ib.applyKey(.{ .code = .left, .mods = .{ .alt = true } }); + try testing.expectEqual(@as(usize, 4), ib.cursor); + // Alt+Right: skip "bar" + the trailing space -> start of "baz" (byte 8). + try ib.applyKey(.{ .code = .right, .mods = .{ .alt = true } }); + try testing.expectEqual(@as(usize, 8), ib.cursor); + // Ctrl+Left also performs word-motion (many terminals send 1;5D). + try ib.applyKey(.{ .code = .left, .mods = .{ .ctrl = true } }); + try testing.expectEqual(@as(usize, 4), ib.cursor); +} + +test "InputBox: alt+arrow via RAW BYTES moves by word (handleInput pipeline)" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + try typeStr(&ib, "foo bar baz"); // cursor at 11 + try testing.expectEqual(@as(usize, 11), ib.cursor); + + // Kitty functional alt+left: CSI 57350 ; 3 u -> word-left to byte 8. + ib.comp().handleInput("\x1b[57350;3u"); + try testing.expectEqual(@as(usize, 8), ib.cursor); + + // Legacy CSI alt+left: 1;3D -> word-left to byte 4. + ib.comp().handleInput("\x1b[1;3D"); + try testing.expectEqual(@as(usize, 4), ib.cursor); + + // Alt+right via raw bytes -> back to byte 8. + ib.comp().handleInput("\x1b[1;3C"); + try testing.expectEqual(@as(usize, 8), ib.cursor); +} + +test "InputBox: ESC b / ESC f (readline alt-word form) moves by word" { + // Ghostty and most macOS terminals send Alt+Left/Right as the classic + // readline `ESC b` / `ESC f`, which decode to alt+b / alt+f CHAR keys + // rather than modified-arrow CSIs. These must move by word (and must NOT + // be inserted as literal text). + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + try typeStr(&ib, "foo bar baz"); // cursor at 11 + try testing.expectEqual(@as(usize, 11), ib.cursor); + + ib.comp().handleInput("\x1bb"); // alt+b -> word-left to byte 8 + try testing.expectEqual(@as(usize, 8), ib.cursor); + ib.comp().handleInput("\x1bb"); // -> byte 4 + try testing.expectEqual(@as(usize, 4), ib.cursor); + ib.comp().handleInput("\x1bf"); // alt+f -> word-right to byte 8 + try testing.expectEqual(@as(usize, 8), ib.cursor); + + // The alt-char must not have inserted any literal 'b'/'f' bytes. + try testing.expectEqualStrings("foo bar baz", ib.text.items); +} + +test "InputBox: alt/ctrl+backspace deletes the previous word" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + try typeStr(&ib, "foo bar baz"); // cursor at 11 + try ib.applyKey(.{ .code = .backspace, .mods = .{ .alt = true } }); + try testing.expectEqualStrings("foo bar ", ib.text.items); + try ib.applyKey(.{ .code = .backspace, .mods = .{ .ctrl = true } }); + try testing.expectEqualStrings("foo ", ib.text.items); + // Plain backspace still deletes a single codepoint. + try ib.applyKey(.{ .code = .backspace }); + try testing.expectEqualStrings("foo", ib.text.items); +} + +test "InputBox: arrow press+release moves ONCE, not twice (no key-up double-move)" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + try typeStr(&ib, "abcdef"); // cursor at 6 + try testing.expectEqual(@as(usize, 6), ib.cursor); + + // A physical left-arrow press under the Kitty protocol would, with event + // reporting on, arrive as a PRESS then a RELEASE. Feeding both must move + // the cursor only once (the release is dropped). This guards the + // double-move regression at the raw-bytes pipeline level even if a + // terminal still emits releases. + ib.comp().handleInput("\x1b[57350u"); // functional left press + try testing.expectEqual(@as(usize, 5), ib.cursor); + ib.comp().handleInput("\x1b[57350;1:3u"); // functional left RELEASE + try testing.expectEqual(@as(usize, 5), ib.cursor); // unchanged + + // Same property for the legacy CSI release form via applyKey directly. + try ib.applyKey(.{ .code = .left, .event = .release }); + try testing.expectEqual(@as(usize, 5), ib.cursor); +} + +test "InputBox: word-nav boundary cases (multiple spaces, newlines, buffer ends)" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + // Multiple spaces between words and a newline-separated logical line. + try typeStr(&ib, "foo bar"); + try ib.applyKey(.{ .code = .enter, .mods = .{ .shift = true } }); // '\n' at byte 9 + try typeStr(&ib, "baz"); // cursor at end (byte 13) + try testing.expectEqual(@as(usize, 13), ib.cursor); + + // Word-left from end: start of "baz" (byte 10, just after the '\n'). + ib.moveWordLeft(); + try testing.expectEqual(@as(usize, 10), ib.cursor); + // Again: crosses the newline and the multi-space run to the start of "bar" + // (byte 6). + ib.moveWordLeft(); + try testing.expectEqual(@as(usize, 6), ib.cursor); + // Again: start of "foo" (byte 0). + ib.moveWordLeft(); + try testing.expectEqual(@as(usize, 0), ib.cursor); + // At the start, word-left is a clamped no-op. + ib.moveWordLeft(); + try testing.expectEqual(@as(usize, 0), ib.cursor); + + // Word-right skips "foo" + the multi-space run -> start of "bar" (byte 6). + ib.moveWordRight(); + try testing.expectEqual(@as(usize, 6), ib.cursor); + // Jump to end, then word-right is a clamped no-op. + ib.moveEnd(); + const end = ib.cursor; + ib.moveWordRight(); + try testing.expectEqual(end, ib.cursor); +} + +test "InputBox: ctrl+u on the FIRST logical line clears to byte 0" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + // Single logical line; ctrl+u from the end clears the whole line to 0 + // (the first-line case, complementing the later-line case below). + try typeStr(&ib, "hello world"); + try ib.applyKey(ctrlKey('u')); + try testing.expectEqualStrings("", ib.text.items); + try testing.expectEqual(@as(usize, 0), ib.cursor); +} + +test "InputBox: focused render emits CURSOR_MARKER exactly once" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + ib.setFocused(true); + // Multi-row buffer so we exercise the per-row cursor placement: the marker + // must appear on exactly ONE row, once. + try typeStr(&ib, "alpha"); + try ib.applyKey(.{ .code = .enter, .mods = .{ .shift = true } }); + try typeStr(&ib, "beta"); + const lines = try ib.comp().render(20, testing.allocator); + var count: usize = 0; + for (lines) |l| { + var idx: usize = 0; + while (std.mem.indexOfPos(u8, l, idx, CURSOR_MARKER)) |at| { + count += 1; + idx = at + CURSOR_MARKER.len; + } + } + try testing.expectEqual(@as(usize, 1), count); +} + +test "InputBox: ctrl+u deletes to start of the current logical line" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + // Two logical lines; cursor mid-second-line. + try typeStr(&ib, "first"); + try ib.applyKey(.{ .code = .enter, .mods = .{ .shift = true } }); // newline + try typeStr(&ib, "second"); + // Cursor at end of "second"; ctrl+u clears just "second", keeping "first\n". + try ib.applyKey(ctrlKey('u')); + try testing.expectEqualStrings("first\n", ib.text.items); + try testing.expectEqual(@as(usize, 6), ib.cursor); // just after the '\n' +} + +test "InputBox: ctrl+w deletes the previous word (plain, no kill-ring)" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + try typeStr(&ib, "hello world"); + try ib.applyKey(ctrlKey('w')); // delete "world" + try testing.expectEqualStrings("hello ", ib.text.items); + try ib.applyKey(ctrlKey('w')); // delete "hello " + try testing.expectEqualStrings("", ib.text.items); +} + +test "InputBox: ctrl+a / ctrl+e move to line start / end" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + try typeStr(&ib, "abc"); + try ib.applyKey(.{ .code = .enter, .mods = .{ .shift = true } }); + try typeStr(&ib, "defg"); // second line, cursor at end (byte 8) + try ib.applyKey(ctrlKey('a')); + try testing.expectEqual(@as(usize, 4), ib.cursor); // start of "defg" + try ib.applyKey(ctrlKey('e')); + try testing.expectEqual(@as(usize, 8), ib.cursor); // end of "defg" +} + +test "InputBox: line cap renders only the last cap rows by default" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + ib.line_cap = 3; + // 5 logical lines: L0..L4. Cursor ends on L4. + try typeStr(&ib, "L0"); + for (0..4) |i| { + try ib.applyKey(.{ .code = .enter, .mods = .{ .shift = true } }); + var b: [2]u8 = .{ 'L', @intCast('1' + i) }; + try typeStr(&ib, &b); + } + const lines = try ib.comp().render(20, testing.allocator); + // Only `cap` rows rendered (the tail window: L2, L3, L4). + try testing.expectEqual(@as(usize, 3), lines.len); + try testing.expect(std.mem.indexOf(u8, lines[0], "L2") != null); + try testing.expect(std.mem.indexOf(u8, lines[2], "L4") != null); + for (lines) |ln| try testing.expect(vw(ln) <= 20); +} + +test "InputBox: scroll-window slides up to keep the cursor visible" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + ib.setFocused(true); + ib.line_cap = 3; + try typeStr(&ib, "L0"); + for (0..4) |i| { + try ib.applyKey(.{ .code = .enter, .mods = .{ .shift = true } }); + var b: [2]u8 = .{ 'L', @intCast('1' + i) }; + try typeStr(&ib, &b); + } + // Move the cursor up to the top (L0) via Home then re-anchor: move cursor + // to byte 0 so cursor_row == 0, above the default tail window. + ib.moveHome(); + const lines = try ib.comp().render(20, testing.allocator); + try testing.expectEqual(@as(usize, 3), lines.len); + // Window slid up so the cursor row (L0) is visible at the TOP: the cursor + // block + marker render on row 0 (the cursor splits "L0", so the marker is + // the reliable signal), and the rows below are L1, L2 — proving the window + // is [0, 3) not the default tail [2, 5). + try testing.expect(std.mem.indexOf(u8, lines[0], CURSOR_MARKER) != null); + try testing.expect(std.mem.indexOf(u8, lines[1], "L1") != null); + try testing.expect(std.mem.indexOf(u8, lines[2], "L2") != null); +} + +test "InputBox: single-row default is unaffected by the cap" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + try testing.expectEqual(InputBox.default_line_cap, ib.line_cap); + try typeStr(&ib, "just one line"); + const lines = try ib.comp().render(40, testing.allocator); + try testing.expectEqual(@as(usize, 1), lines.len); +} + +test "InputBox: setBuffer/buffer round-trip for the $EDITOR hook" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + try typeStr(&ib, "old"); + try ib.setBuffer("new multi\nline text"); + try testing.expectEqualStrings("new multi\nline text", ib.buffer()); + // Cursor lands at the end. + try testing.expectEqual(ib.text.items.len, ib.cursor); +} + // -- Footer ----------------------------------------------------------------- test "Footer: renders fps from frame time, inverted, within width" { @@ -1058,6 +2022,67 @@ test "Footer: setFrameTime dirties; stable re-render is clean" { try testing.expectEqual(@as(?usize, 0), ft.comp().firstLineChanged()); } +test "Footer: context tokens absent until set, then shown alongside fps" { + var ft = Footer.init(testing.allocator); + defer ft.deinit(); + var buf: [32]u8 = undefined; + // Absent until usage is reported. + try testing.expectEqualStrings("", ft.contextText(&buf)); + ft.setFrameTime(8.0); + { + const lines = try ft.comp().render(80, testing.allocator); + try testing.expect(std.mem.indexOf(u8, lines[0], "ctx") == null); + } + // Small count rendered verbatim. + ft.setContextTokens(845); + try testing.expectEqualStrings("845 ctx", ft.contextText(&buf)); + { + const lines = try ft.comp().render(80, testing.allocator); + try testing.expect(vw(lines[0]) <= 80); + try testing.expect(std.mem.indexOf(u8, lines[0], "845 ctx") != null); + // fps element still present alongside. + try testing.expect(std.mem.indexOf(u8, lines[0], "fps:") != null); + } +} + +test "Footer: contextText formatting boundaries (0, 999, 1000 -> k)" { + var ft = Footer.init(testing.allocator); + defer ft.deinit(); + var buf: [32]u8 = undefined; + // Zero is a real measured value (not "absent") -> "0 ctx". + ft.setContextTokens(0); + try testing.expectEqualStrings("0 ctx", ft.contextText(&buf)); + // Just below the k threshold stays verbatim. + ft.setContextTokens(999); + try testing.expectEqualStrings("999 ctx", ft.contextText(&buf)); + // Exactly 1000 crosses into the k suffix. + ft.setContextTokens(1000); + try testing.expectEqualStrings("1.0k ctx", ft.contextText(&buf)); +} + +test "Footer: large context token counts format as k; latest wins" { + var ft = Footer.init(testing.allocator); + defer ft.deinit(); + var buf: [32]u8 = undefined; + ft.setContextTokens(12345); + try testing.expectEqualStrings("12.3k ctx", ft.contextText(&buf)); + // Overwritten (latest-wins), not accumulated. + ft.setContextTokens(2000); + try testing.expectEqualStrings("2.0k ctx", ft.contextText(&buf)); +} + +test "Footer: setContextTokens dirties; stable re-render is clean" { + var ft = Footer.init(testing.allocator); + defer ft.deinit(); + ft.setFrameTime(8.0); + ft.setContextTokens(1000); + _ = try ft.comp().render(80, testing.allocator); + _ = try ft.comp().render(80, testing.allocator); + try testing.expectEqual(@as(?usize, null), ft.comp().firstLineChanged()); + ft.setContextTokens(2000); + 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" { @@ -1102,3 +2127,208 @@ test "components drive the real engine without a TTY" { const out2 = buf.written(); try testing.expect(std.mem.indexOf(u8, out2, "world") != null); } + +// -- Welcome / Thinking / CompactionSummary / ToolUse (P2) ------------------ + +test "Welcome: renders title + cwd + model, all within width" { + var w = Welcome.init(testing.allocator); + defer w.deinit(); + try w.setVersion("0.1.0"); + try w.setCwd("/tmp/project"); + try w.setModel("anthropic:claude"); + const lines = try w.comp().render(40, testing.allocator); + try testing.expectEqual(@as(usize, 3), lines.len); + for (lines) |l| try testing.expect(vw(l) <= 40); + try testing.expect(std.mem.indexOf(u8, lines[0], "panto v0.1.0") != null); + try testing.expect(std.mem.indexOf(u8, lines[1], "/tmp/project") != null); + try testing.expect(std.mem.indexOf(u8, lines[2], "anthropic:claude") != null); +} + +test "Welcome: title only when cwd/model unset" { + var w = Welcome.init(testing.allocator); + defer w.deinit(); + const lines = try w.comp().render(20, testing.allocator); + try testing.expectEqual(@as(usize, 1), lines.len); + try testing.expect(std.mem.indexOf(u8, lines[0], "panto") != null); +} + +test "Welcome: honors the width contract at a tiny width" { + var w = Welcome.init(testing.allocator); + defer w.deinit(); + try w.setVersion("0.1.0"); + try w.setCwd("/a/very/long/working/directory/path/that/overflows"); + try w.setModel("anthropic:claude-some-very-long-model-id"); + // Width 6: every banner row (title + cwd + model) must truncate to fit. + const lines = try w.comp().render(6, testing.allocator); + try testing.expectEqual(@as(usize, 3), lines.len); + for (lines) |l| try testing.expect(vw(l) <= 6); +} + +test "Thinking: streams dim, firstLineChanged stays near the tail" { + var t = Thinking.init(testing.allocator); + defer t.deinit(); + try t.appendDelta("line one is fairly long so it wraps across"); + _ = try t.comp().render(20, testing.allocator); + // A clean re-render reports no change. + _ = try t.comp().render(20, testing.allocator); + try testing.expectEqual(@as(?usize, null), t.comp().firstLineChanged()); + // Appending a delta should dirty near the tail (not line 0). + try t.appendDelta(" more"); + const flc = t.comp().firstLineChanged(); + try testing.expect(flc != null and flc.? > 0); + const lines = try t.comp().render(20, testing.allocator); + for (lines) |l| try testing.expect(vw(l) <= 20); +} + +test "CompactionSummary: header + wrapped summary within width" { + var c = CompactionSummary.init(testing.allocator); + defer c.deinit(); + try c.setSummary("summarized prior turns here"); + const lines = try c.comp().render(20, testing.allocator); + try testing.expect(lines.len >= 2); + try testing.expect(std.mem.indexOf(u8, lines[0], "compacted") != null); + for (lines) |l| try testing.expect(vw(l) <= 20); +} + +test "ToolUse: stage 1 renders tool (?) before the name resolves" { + var t = ToolUse.init(testing.allocator); + defer t.deinit(); + const lines = try t.comp().render(40, testing.allocator); + try testing.expectEqual(@as(usize, 1), lines.len); + try testing.expect(std.mem.indexOf(u8, lines[0], "tool (?)") != null); +} + +test "ToolUse: stage 2 shows name + verbatim json + placeholder" { + var t = ToolUse.init(testing.allocator); + defer t.deinit(); + try t.setName("read"); + try t.appendInput("{\"path\":\"a\"}"); + const lines = try t.comp().render(60, testing.allocator); + // header line, blank, placeholder + try testing.expect(lines.len >= 3); + try testing.expect(std.mem.indexOf(u8, lines[0], "tool (read) {\"path\":\"a\"}") != null); + try testing.expectEqualStrings("", lines[lines.len - 2]); + try testing.expect(std.mem.indexOf(u8, lines[lines.len - 1], "(…)") != null); + for (lines) |l| try testing.expect(vw(l) <= 60); +} + +test "ToolUse: collapsed shows only the last 5 output lines (default)" { + var t = ToolUse.init(testing.allocator); + defer t.deinit(); + try t.setName("read"); + try t.setInput("{}"); + // 8 short output lines -> collapsed shows the marker + last 5. + try t.setOutput("l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8"); + const collapsed = try t.comp().render(40, testing.allocator); + // header, blank, marker, l4..l8 = 3 + 5 + try testing.expect(std.mem.indexOf(u8, collapsed[collapsed.len - 1], "l8") != null); + try testing.expect(std.mem.indexOf(u8, collapsed[collapsed.len - 5], "l4") != null); + // The earliest output lines are elided when collapsed. + var has_l1 = false; + for (collapsed) |l| { + if (std.mem.indexOf(u8, l, "l1") != null) has_l1 = true; + } + try testing.expect(!has_l1); + + // Expanding shows everything. + t.setCollapsed(false); + const expanded = try t.comp().render(40, testing.allocator); + try testing.expect(expanded.len > collapsed.len); + var has_l1_exp = false; + for (expanded) |l| { + if (std.mem.indexOf(u8, l, "l1") != null) has_l1_exp = true; + } + try testing.expect(has_l1_exp); +} + +test "ToolUse: short output is shown whole even when collapsed" { + var t = ToolUse.init(testing.allocator); + defer t.deinit(); + try t.setName("ls"); + try t.setInput("{}"); + try t.setOutput("only\ntwo"); + const lines = try t.comp().render(40, testing.allocator); + var seen_only = false; + var seen_two = false; + for (lines) |l| { + if (std.mem.indexOf(u8, l, "only") != null) seen_only = true; + if (std.mem.indexOf(u8, l, "two") != null) seen_two = true; + } + try testing.expect(seen_only and seen_two); +} + +test "ToolUse: collapse/expand is a length change with a cache-derived firstLineChanged" { + // Expanding/collapsing changes the rendered LINE COUNT (plan §3.3). A + // collapse toggle is a structural change (the whole output region shifts), + // so `setCollapsed` re-dirties via `markDirty` — dropping the baseline — and + // the post-render `firstLineChanged` is therefore 0 (cache-derived: a full + // drop reports from the top). That is correct and cheap for a small tool + // component; the engine's line-diff backstop (plan §3.3) still handles the + // length delta. The KEY guarantees this test pins: the line COUNT changes + // across the toggle, the signal is cache-derived (0 after a full drop, null + // after a stable render), and there is no hand-managed drift. + var t = ToolUse.init(testing.allocator); + defer t.deinit(); + try t.setName("read"); + try t.setInput("{}"); + try t.setOutput("l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8"); + + // Default collapsed: header + blank + marker + last 5 = 8 rows. + const collapsed = try t.comp().render(40, testing.allocator); + try testing.expectEqual(@as(usize, 8), collapsed.len); + // A stable re-render is clean (cache-derived, no drift). + _ = try t.comp().render(40, testing.allocator); + try testing.expectEqual(@as(?usize, null), t.comp().firstLineChanged()); + + // Expand: header + blank + all 8 output rows = 10 rows (a length GROWTH). + t.setCollapsed(false); + // While dirty (full drop), the signal is the cache-derived 0. + try testing.expectEqual(@as(?usize, 0), t.comp().firstLineChanged()); + const expanded = try t.comp().render(40, testing.allocator); + try testing.expectEqual(@as(usize, 10), expanded.len); + // After the render the baseline was dropped on the toggle, so the diff + // reports from 0 — cache-derived, not a hand-managed value. + try testing.expectEqual(@as(?usize, 0), t.comp().firstLineChanged()); + // Stable re-render is clean again. + _ = try t.comp().render(40, testing.allocator); + try testing.expectEqual(@as(?usize, null), t.comp().firstLineChanged()); + + // Collapse again: shrink back to 8 rows (the length-change shrink path). + t.setCollapsed(true); + const recollapsed = try t.comp().render(40, testing.allocator); + try testing.expectEqual(@as(usize, 8), recollapsed.len); +} + +test "ToolUse: args are rendered VERBATIM (no pretty-print) and within width" { + // The input JSON must pass through byte-for-byte (no reflow of the JSON + // structure), only terminal-wrapped. We use a compact object with no spaces + // and assert the exact substring survives in the joined header. + var t = ToolUse.init(testing.allocator); + defer t.deinit(); + try t.setName("search"); + try t.appendInput("{\"q\":\"a b\","); + try t.appendInput("\"n\":10}"); + const verbatim = "{\"q\":\"a b\",\"n\":10}"; + try testing.expectEqualStrings(verbatim, t.input.items); + + // Wide render: the verbatim args appear unmodified on the header line. + const wide = try t.comp().render(80, testing.allocator); + try testing.expect(std.mem.indexOf(u8, wide[0], verbatim) != null); + + // Narrow render: header wraps across rows but every row honors the width + // contract (no pretty-print expansion, just wrapping). + const narrow = try t.comp().render(12, testing.allocator); + for (narrow) |l| try testing.expect(vw(l) <= 12); +} + +test "ToolUse: long output lines honor the width contract" { + var t = ToolUse.init(testing.allocator); + defer t.deinit(); + try t.setName("read"); + try t.setInput("{}"); + t.setCollapsed(false); + try t.setOutput("a very long single output line that must be wrapped to fit the narrow width"); + const lines = try t.comp().render(10, testing.allocator); + for (lines) |l| try testing.expect(vw(l) <= 10); +} + diff --git a/src/tui_engine.zig b/src/tui_engine.zig index fbe8474..0e1becd 100644 --- a/src/tui_engine.zig +++ b/src/tui_engine.zig @@ -253,9 +253,24 @@ pub const Engine = struct { /// 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). + /// Forces a full redraw on the next frame (first paint, layout change, + /// resize). A full redraw reprints every line from the top of the working + /// area; it does NOT by itself clear the screen/scrollback. force_full: bool = true, + /// When a forced full redraw should ALSO clear the screen + scrollback + /// (`\x1b[2J\x1b[H\x1b[3J`). True only for cases where the prior on-screen + /// layout is no longer valid and reprinting in place would corrupt it: + /// width/height resize (wrapping/viewport changes) and explicit + /// `forceFullRedraw` (e.g. after an external program scribbled the screen). + /// + /// Crucially this is FALSE on first paint and on ordinary layout changes + /// (add/remove component), so panto NEVER wipes the user's pre-launch + /// shell scrollback: the first frame is printed where the cursor already + /// sits and earlier output stays scrollable above (plan §1/§3.1; mirrors + /// pi-tui's first render, which calls its `fullRender(false)` — no clear). + force_clear: bool = false, + /// 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 @@ -323,14 +338,24 @@ pub const Engine = struct { pub fn resize(self: *Engine, width: usize, height: usize) void { if (width != self.width or height != self.height) { self.force_full = true; + // A resize invalidates the on-screen layout (wrapping / viewport + // alignment), so this is one of the few redraws that legitimately + // clears the screen before reprinting. + self.force_clear = true; } self.width = width; self.height = height; } - /// Force the next frame to be a full redraw (e.g. on unrecoverable doubt). + /// Force the next frame to be a full redraw that also CLEARS the screen + + /// scrollback. Use only when the on-screen content is known to be corrupt + /// (e.g. an external program like `$EDITOR` drew over the terminal); an + /// ordinary forced reprint that should preserve scrollback does not belong + /// here. For a plain layout-change reprint, the engine sets `force_full` + /// without `force_clear` itself. pub fn forceFullRedraw(self: *Engine) void { self.force_full = true; + self.force_clear = true; } // -- the render -------------------------------------------------------- @@ -415,19 +440,41 @@ pub const Engine = struct { } // 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). + // Full redraw is forced by: first paint / layout change / resize + // (`force_full`), or a change that lands above `viewport_top` + // (off-limits to differential repaint). var full = self.force_full; - if (!full) { - if (cut) |c| { - if (c < self.viewport_top) full = true; + // Whether that full redraw also clears the screen + scrollback. + var clear = self.force_clear; + + // Viewport-escape: a change at/above `viewport_top` touches lines that + // have already scrolled into native scrollback and cannot be patched + // in place. Reprinting from the top WITHOUT a clear would re-emit + // those scrolled-away lines below their original copy, DUPLICATING + // them in scrollback. So such a change must force a CLEARING full + // redraw (matches pi-tui's viewport-escape path = fullRender(true)). + // + // This is checked independently of `force_full`: a forced full redraw + // (e.g. an in-session layout change via addComponent/rebuild) is by + // default a NON-clearing reprint, but if the cut lands above the + // viewport it must escalate to a clearing one — otherwise the layout + // change duplicates scrollback. Only first paint (viewport_top == 0) + // and changes at/below the viewport stay clear-free. + if (cut) |c| { + if (c < self.viewport_top) { + full = true; + clear = true; } } try self.beginFrame(); if (full) { - try self.fullRedraw(frames, new_total); + // Clear the screen/scrollback only when required (resize, + // `forceFullRedraw`, or a change above the viewport). First paint + // and ordinary layout changes are full reprints WITHOUT a clear, + // so pre-launch scrollback survives (plan §1/§3.1). + try self.fullRedraw(frames, new_total, clear); } else { try self.differential(frames, cut, new_total); } @@ -452,6 +499,7 @@ pub const Engine = struct { if (new_total > self.max_lines_rendered) self.max_lines_rendered = new_total; self.recomputeViewportTop(); self.force_full = false; + self.force_clear = false; } /// Dupe `new_lines` into the slot's owned baseline, freeing the old copy. @@ -514,16 +562,22 @@ pub const Engine = struct { 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. + /// Full redraw: optionally clear screen + scrollback, home cursor, then + /// print everything from the top. Resets the viewport. + /// + /// `clear` controls whether the screen + scrollback are wiped first + /// (`full_clear`). It is TRUE for resize / explicit `forceFullRedraw`, and + /// FALSE for first paint and layout-change reprints — so the very first + /// frame prints where the cursor already is and the user's prior shell + /// scrollback is preserved (plan §1/§3.1; mirrors pi-tui's first render). /// /// 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); + fn fullRedraw(self: *Engine, frames: anytype, new_total: usize, clear: bool) Error!void { + if (clear) try self.writer.writeAll(terminal.seq.full_clear); self.cursor_hint = null; var global_line: usize = 0; var first = true; @@ -683,7 +737,11 @@ test "visibleWidth strips CSI and counts codepoints" { try testing.expectEqual(@as(usize, 3), visibleWidth("aé✓")); } -test "first paint is a full redraw clearing scrollback" { +test "first paint is a full redraw that does NOT clear scrollback" { + // Plan §1/§3.1: the first frame must print where the cursor already sits + // and preserve the user's pre-launch shell scrollback. It is a full + // reprint, but it must NOT emit the screen+scrollback clear (mirrors + // pi-tui's first render = fullRender(false)). var buf = std.Io.Writer.Allocating.init(testing.allocator); defer buf.deinit(); var eng = makeEngine(&buf, 80, 24); @@ -697,12 +755,157 @@ test "first paint is a full redraw clearing scrollback" { try eng.render(); const out = buf.written(); - try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) != null); + // No scrollback wipe on first paint. + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); + // But the content is printed. 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 "resize forces a full redraw that DOES clear scrollback" { + // A width/height change invalidates the on-screen layout, so this is one + // of the few redraws that legitimately clears before reprinting. + 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" }, &.{ "line one", "line two" } }, + .first_changed = &.{ 0, null }, + }; + try eng.addComponent(fc.comp()); + try eng.render(); // first paint: no clear + try testing.expect(std.mem.indexOf(u8, buf.written(), terminal.seq.full_clear) == null); + + fc.advance(); + buf.clearRetainingCapacity(); + eng.resize(100, 24); // width change -> clear on next frame + try eng.render(); + try testing.expect(std.mem.indexOf(u8, buf.written(), terminal.seq.full_clear) != null); +} + +test "in-session layout change (addComponent) does NOT clear scrollback" { + // The app calls rebuildEngineList (drain + re-add all slots) on EVERY new + // transcript entry, which sets force_full each time. That forced full + // redraw must be a plain reprint WITHOUT a screen/scrollback clear, or the + // user's history would flash-wipe on every streamed message. Plan §1/§3.1. + 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(); + + var body = FakeComponent{ .scripts = &.{&.{ "b0", "b1" }}, .first_changed = &.{0} }; + try eng.addComponent(body.comp()); + try eng.render(); // first paint (no clear) + try testing.expect(std.mem.indexOf(u8, buf.written(), terminal.seq.full_clear) == null); + + // Add a second component (a layout change => force_full, NOT force_clear). + var footer = FakeComponent{ .scripts = &.{&.{"f0"}}, .first_changed = &.{0} }; + buf.clearRetainingCapacity(); + try eng.addComponent(footer.comp()); + try eng.render(); + + const out = buf.written(); + // Layout-change reprint: full content, but NO scrollback wipe. + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); + try testing.expect(std.mem.indexOf(u8, out, "b0") != null); + try testing.expect(std.mem.indexOf(u8, out, "f0") != null); +} + +test "layout change with scrolled content: change above viewport_top clears (no scrollback duplication)" { + // The scrollback-duplication hazard: when earlier lines have already + // scrolled into native scrollback (viewport_top > 0), a forced full + // reprint from the top WITHOUT a clear would re-emit those scrolled-away + // lines below their original copy, duplicating them in scrollback. + // + // The engine must NOT do that. When a change (here a fresh first-render of + // re-added slots after a drain+rebuild) lands at/above viewport_top, the + // render path must clear before reprinting. We simulate the drain+rebuild + // by removing and re-adding the component, which resets its baseline so it + // first-renders at line 0 (i.e. the "change" is at line 0, above + // viewport_top). + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + // Short viewport so content scrolls: 5 lines, height 2 => viewport_top 3. + var eng = makeEngine(&buf, 80, 2); + defer eng.deinit(); + + var body = FakeComponent{ + .scripts = &.{ &.{ "l0", "l1", "l2", "l3", "l4" }, &.{ "l0", "l1", "l2", "l3", "l4" } }, + .first_changed = &.{ 0, 0 }, + }; + try eng.addComponent(body.comp()); + try eng.render(); // first paint + try testing.expectEqual(@as(usize, 3), eng.viewport_top); + + // Drain + re-add (the rebuildEngineList shape). This resets the slot + // baseline so the re-added component first-renders from line 0. + _ = eng.removeComponent(body.comp()); + body.advance(); + try eng.addComponent(body.comp()); + buf.clearRetainingCapacity(); + try eng.render(); + + const out = buf.written(); + // The change is at line 0 (above viewport_top == 3), so the engine MUST + // clear before reprinting to avoid duplicating the scrolled-away lines. + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) != null); + // And exactly one copy of each line is emitted (no duplication). Count l0. + try testing.expectEqual(@as(usize, 1), std.mem.count(u8, out, "l0")); + try testing.expectEqual(@as(usize, 1), std.mem.count(u8, out, "l4")); +} + +test "forceFullRedraw clears scrollback (e.g. $EDITOR return path)" { + // After an external program ($EDITOR) scribbles the screen, the app calls + // engine.forceFullRedraw() to repaint from a known-clean slate. That path + // MUST clear (the on-screen content is corrupt), unlike ordinary layout + // changes. + 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, null }, + }; + try eng.addComponent(body.comp()); + try eng.render(); // first paint: no clear + try testing.expect(std.mem.indexOf(u8, buf.written(), terminal.seq.full_clear) == null); + + body.advance(); + buf.clearRetainingCapacity(); + eng.forceFullRedraw(); + try eng.render(); + try testing.expect(std.mem.indexOf(u8, buf.written(), terminal.seq.full_clear) != null); +} + +test "first paint preserves the cursor-resting invariant (no trailing newline)" { + // The no-clear first paint must still leave the cursor at the END of the + // last content line (last line written WITHOUT a trailing newline), since + // the differential cursor math on the next frame depends on it. + 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 = &.{&.{ "l0", "l1", "l2" }}, .first_changed = &.{0} }; + try eng.addComponent(body.comp()); + try eng.render(); + + const out = buf.written(); + // No clear, and the frame must NOT end with a trailing newline after the + // last line (it ends with the last line's content, or the sync-end marker + // when synchronized; this engine has sync off). + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); + try testing.expect(!std.mem.endsWith(u8, out, "\r\n")); + try testing.expect(std.mem.endsWith(u8, out, "l2")); + // Lines are joined by \r\n, so exactly (count-1) separators for 3 lines. + try testing.expectEqual(@as(usize, 2), std.mem.count(u8, out, "\r\n")); +} + 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(); diff --git a/src/tui_input.zig b/src/tui_input.zig index 276b7e1..b05131c 100644 --- a/src/tui_input.zig +++ b/src/tui_input.zig @@ -19,7 +19,8 @@ //! Shift+Enter: in the bare legacy protocol Enter and Shift+Enter send the same //! byte (`\r`), so they're indistinguishable. To recover the distinction we //! negotiate at startup (see `negotiate_query`): we push the Kitty keyboard -//! protocol (flags 1|2|4) and query the terminal; if it confirms Kitty we use +//! protocol (flags 1|4 — disambiguate + report-alternates; NOT report-events, +//! see `enable_kitty_keyboard`) and query the terminal; if it confirms Kitty we use //! that, otherwise we fall back to xterm modifyOtherKeys mode 2. Either way: //! - Kitty-protocol terminals (ghostty/kitty/foot/…) send `\x1b[13;2u`. //! - xterm and tmux (which does NOT honor the Kitty push but DOES forward @@ -76,16 +77,25 @@ pub const paste_end = "\x1b[201~"; /// Kitty keyboard protocol: push a flags entry that asks for: /// flag 1 (0b001) disambiguate escape codes, -/// flag 2 (0b010) report event types (press/repeat/release), /// flag 4 (0b100) report alternate keys (shifted/base-layout key). -/// 1|2|4 = 7. This matches what pi requests. Flag 1 makes the terminal report -/// modified special keys — including Shift+Enter — as distinct CSI-u sequences -/// (`\x1b[13;2u`), which is exactly what we need; we deliberately do NOT set -/// flag 8 (report-all-keys), since that suppresses normal text input and forces -/// every printable key through the escape-sequence path. We push (not set) so -/// teardown can pop cleanly. Best-effort; unsupported terminals ignore it and -/// we fall back to modifyOtherKeys / legacy decoding. -pub const enable_kitty_keyboard = "\x1b[>7u"; +/// 1|4 = 5. Flag 1 makes the terminal report modified special keys — including +/// Shift+Enter — as distinct CSI-u sequences (`\x1b[13;2u`), which is exactly +/// what we need. +/// +/// We deliberately do NOT set flag 2 (report event types / press-repeat- +/// release). Nothing in the TUI consumes key-release events (no component sets +/// `Component.wantsKeyRelease`), and requesting them makes the terminal emit a +/// release sequence for every key. Under flag 2 a single arrow press produces +/// BOTH a press and a release event; since the editor treated each decoded +/// arrow as a motion, the cursor moved twice per physical press (once on key- +/// down, once on key-up). Dropping flag 2 removes the release events at the +/// source. `applyKey` still drops any `.release` it sees as defense-in-depth. +/// +/// We also do NOT set flag 8 (report-all-keys), since that suppresses normal +/// text input and forces every printable key through the escape-sequence path. +/// We push (not set) so teardown can pop cleanly. Best-effort; unsupported +/// terminals ignore it and we fall back to modifyOtherKeys / legacy decoding. +pub const enable_kitty_keyboard = "\x1b[>5u"; /// Query the terminal's currently-active Kitty flags: it replies `\x1b[?u`. pub const query_kitty_flags = "\x1b[?u"; /// Primary Device Attributes query. Every terminal answers this (`\x1b[?...c`), @@ -109,7 +119,8 @@ pub const disable_modify_other_keys = "\x1b[>4;0m"; /// Startup negotiation to write once raw mode is engaged. We: /// 1. enable bracketed paste, -/// 2. push the Kitty flags we want (1|2|4), +/// 2. push the Kitty flags we want (1|4 = disambiguate + report-alternates; +/// deliberately NOT flag 2 report-events, see `enable_kitty_keyboard`), /// 3. query the now-active Kitty flags, then /// 4. send a Primary Device Attributes query as a sentinel. /// The app then reads the responses (`Decoded.negotiation`): a non-zero Kitty @@ -618,6 +629,67 @@ test "csi home/end and modified arrow" { try std.testing.expect(s.mods.ctrl); } +test "modified arrows: alt and ctrl, legacy CSI 1; form" { + // Alt+Left = CSI 1;3D (mods field 3 = 1 + alt) + const al = decodeOne("\x1b[1;3D").?.decoded.key; + try std.testing.expectEqual(KeyCode.left, al.code); + try std.testing.expect(al.mods.alt); + try std.testing.expect(!al.mods.ctrl); + // Alt+Right = CSI 1;3C + const ar = decodeOne("\x1b[1;3C").?.decoded.key; + try std.testing.expectEqual(KeyCode.right, ar.code); + try std.testing.expect(ar.mods.alt); + // Ctrl+Left = CSI 1;5D (tmux/modifyOtherKeys word-motion path) + const cl = decodeOne("\x1b[1;5D").?.decoded.key; + try std.testing.expectEqual(KeyCode.left, cl.code); + try std.testing.expect(cl.mods.ctrl); + try std.testing.expect(!cl.mods.alt); +} + +test "modified arrows: kitty functional CSU-u form (alt/ctrl)" { + // Some terminals (Kitty/Ghostty under disambiguate) report arrows as the + // functional-key codepoints 57350..57353 with a `;` field. + // Alt+Left = CSI 57350 ; 3 u + const al = decodeOne("\x1b[57350;3u").?.decoded.key; + try std.testing.expectEqual(KeyCode.left, al.code); + try std.testing.expect(al.mods.alt); + // Ctrl+Right = CSI 57351 ; 5 u + const cr = decodeOne("\x1b[57351;5u").?.decoded.key; + try std.testing.expectEqual(KeyCode.right, cr.code); + try std.testing.expect(cr.mods.ctrl); + // Plain functional Left = CSI 57350 u (no mods). + const pl = decodeOne("\x1b[57350u").?.decoded.key; + try std.testing.expectEqual(KeyCode.left, pl.code); + try std.testing.expect(!pl.mods.any()); + try std.testing.expectEqual(key.KeyEvent.press, pl.event); +} + +test "modified arrows: ESC-prefixed alt forms" { + // A terminal can emit alt+arrow as ESC + the arrow sequence. The recursive + // alt-prefix path in decodeEscape must OR in alt. + // ESC ESC [ D (alt + legacy left) + const a1 = decodeOne("\x1b\x1b[D").?.decoded.key; + try std.testing.expectEqual(KeyCode.left, a1.code); + try std.testing.expect(a1.mods.alt); + // ESC ESC O C (alt + SS3 right) + const a2 = decodeOne("\x1b\x1bOC").?.decoded.key; + try std.testing.expectEqual(KeyCode.right, a2.code); + try std.testing.expect(a2.mods.alt); +} + +test "arrow release events are tagged .release (so the editor can drop them)" { + // Even though we no longer REQUEST release events (flag 2 dropped), the + // decoder must still classify a release form correctly if one arrives, so + // applyKey's release guard is effective. Functional Left release: + // CSI 57350 ; 1 : 3 u (mods field 1 = none, event 3 = release) + const rel = decodeOne("\x1b[57350;1:3u").?.decoded.key; + try std.testing.expectEqual(KeyCode.left, rel.code); + try std.testing.expectEqual(key.KeyEvent.release, rel.event); + // A press (event 1) is a press. + const pre = decodeOne("\x1b[57350;1:1u").?.decoded.key; + try std.testing.expectEqual(key.KeyEvent.press, pre.event); +} + 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); @@ -657,6 +729,22 @@ test "kitty release event" { try std.testing.expectEqual(key.KeyEvent.release, s.event); } +test "negotiate_query pushes Kitty flags >5u (1|4, NOT report-events flag 2)" { + // Root-cause regression guard for the arrow double-move bug: we must NOT + // request flag 2 (report event types), or the terminal emits a key-RELEASE + // for every press and arrows move twice. The pushed flag set is + // disambiguate (1) | report-alternates (4) = 5, encoded as `\x1b[>5u`. + try std.testing.expectEqualStrings("\x1b[>5u", enable_kitty_keyboard); + // It must appear verbatim inside the startup negotiation string. + try std.testing.expect(std.mem.indexOf(u8, negotiate_query, "\x1b[>5u") != null); + // And the report-events form (>7u, i.e. 1|2|4) must NOT be pushed. + try std.testing.expect(std.mem.indexOf(u8, negotiate_query, "\x1b[>7u") == null); + // Sanity: the numeric flag set is exactly 1|4 with bit 2 (0b010) clear. + const flags: u8 = 1 | 4; + try std.testing.expectEqual(@as(u8, 5), flags); + try std.testing.expectEqual(@as(u8, 0), flags & 2); +} + test "negotiation: kitty flags reply" { const s = decodeOne("\x1b[?7u").?; try std.testing.expectEqual(@as(usize, 5), s.consumed); @@ -800,3 +888,4 @@ test "splitter: batched read yields individual keys" { off += s3.consumed; try std.testing.expectEqual(input.len, off); } + diff --git a/src/tui_terminal.zig b/src/tui_terminal.zig index 0f8b3d9..0b00bb4 100644 --- a/src/tui_terminal.zig +++ b/src/tui_terminal.zig @@ -252,6 +252,31 @@ pub const Terminal = struct { if (active_restore == &self.restore) active_restore = null; } + /// Temporarily return the tty to its ORIGINAL (cooked) termios so a child + /// process (e.g. `$EDITOR`) can drive the terminal normally, and show the + /// cursor. Pairs with `resumeRawMode`. Unlike `deinit`, this does NOT clear + /// the global restore record or mark the restore done — the signal/panic + /// restore path stays armed with the original termios (restoring to cooked + /// is exactly what we'd want on a crash mid-editor), and we re-enter raw + /// mode afterward. + /// + /// No-op if not currently in raw mode. + pub fn suspendRawMode(self: *Terminal) void { + if (!self.raw_mode) return; + posix.tcsetattr(self.fd, .FLUSH, self.restore.original) catch {}; + self.raw_mode = false; + self.writeAll(seq.show_cursor); + } + + /// Re-enter raw mode after `suspendRawMode` and hide the cursor again. The + /// global restore record and signal handlers are already installed (they + /// were never torn down), so this just reapplies the raw termios. + pub fn resumeRawMode(self: *Terminal) Error!void { + if (self.raw_mode) return; + try self.enableRawMode(); + self.writeAll(seq.hide_cursor); + } + // -- output helpers ----------------------------------------------------- /// Raw write of `bytes` to the tty. Returns the count written (short diff --git a/src/tui_theme.zig b/src/tui_theme.zig index 1acbca0..168b4f4 100644 --- a/src/tui_theme.zig +++ b/src/tui_theme.zig @@ -41,6 +41,13 @@ pub const StyleName = enum { cursor, /// Error / retry text. err, + /// Welcome banner accent (session start chrome). + welcome, + /// Thinking block body (dimmed, distinct entry so the taxonomy is honest + /// even though it currently resolves to the same dim escape). + thinking, + /// Compaction-summary chrome. + compaction, }; /// A resolved style: the opening escape and (implicitly) the `reset` close. @@ -104,6 +111,12 @@ fn styleFor(name: StyleName) Style { // Reverse video — the virtual cursor block. .cursor => .{ .open_seq = "\x1b[7m" }, .err => .{ .open_seq = "\x1b[31m" }, + // Welcome banner: cyan accent, matching the tool accent family. + .welcome => .{ .open_seq = "\x1b[36m" }, + // Thinking body: dimmed, like status chrome. + .thinking => .{ .open_seq = "\x1b[2m" }, + // Compaction chrome: dimmed. + .compaction => .{ .open_seq = "\x1b[2m" }, }; } -- cgit v1.3