summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/tui-refactor-plan.md24
-rw-r--r--src/main.zig3
-rw-r--r--src/tui_app.zig628
-rw-r--r--src/tui_components.zig1244
-rw-r--r--src/tui_engine.zig231
-rw-r--r--src/tui_input.zig111
-rw-r--r--src/tui_terminal.zig25
-rw-r--r--src/tui_theme.zig13
8 files changed, 2190 insertions, 89 deletions
diff --git a/docs/tui-refactor-plan.md b/docs/tui-refactor-plan.md
index 640db66..15c7e8e 100644
--- a/docs/tui-refactor-plan.md
+++ b/docs/tui-refactor-plan.md
@@ -290,10 +290,10 @@ output. Each takes **structured data in** and produces **lines out**.
| **thinking text** | accumulating thinking deltas | dimmed; streams |
| **assistant text** | accumulating assistant deltas | streams; markdown **deferred** (§8) but hosts it |
| **user text** | submitted user message | |
-| **tool use** | tool name, args, status, result | **one component owns call + result**; collapsible (ctrl+o) — collapse is a length change (§3.3) |
+| **tool use** | tool name, args, status, result | **one component owns call + result**; collapsible (ctrl+o, **global toggle**) — collapse is a length change (§3.3). Render progression: `tool (?)…` at start → `tool (<name>) <input json>` once args finish streaming → `tool (<name>) <input json>\n\n<output>` once the result lands. Args JSON is rendered **verbatim** (no pretty-print), terminal-wrapped. **Collapsed (the default)** shows only the **last 5 output lines**; expanded shows the full output |
| **compaction summary** | compaction event data | shown when context is compacted |
-| **input box** | editor buffer, cursor | `Focusable`; single-row by default, **shift+enter inserts a newline** (enter submits), grows one row per line. **P1:** unbounded growth (no cap). **P2:** configurable cap (default 8) then scrolls within that window showing the last 8 contiguous lines. Raw-key editing |
-| **footer** | model, git branch, token/usage stats | persistent bottom line(s); during P1 also renders a **frame-timing element** (last frame's render time shown inverted as a theoretical-max "fps"), used to validate perf then removed |
+| **input box** | editor buffer, cursor | `Focusable`; single-row by default, **shift+enter inserts a newline** (enter submits), grows one row per line. **P1:** unbounded growth (no cap). **P2:** configurable cap (default 8) then scrolls within that window showing the last 8 contiguous lines; plus standard editing bindings (word-nav via alt+←/→, **ctrl+u** delete-to-line-start, etc.) and **ctrl+g** to punt the buffer to `$EDITOR` as a markdown tempfile. **No kill-ring / undo** (dropped). Raw-key editing |
+| **footer** | model, context-window token count | persistent bottom line(s); shows the **latest context-window size in tokens** = `usage.input + usage.cache_read + usage.cache_write` from the most recent `message_complete`. **No git branch** (panto uses jj, not git; dropped). During P1 also renders a **frame-timing element** (last frame's render time shown inverted as a theoretical-max "fps"), used to validate perf then removed |
The chat transcript is a `Container` of these in order; the input box and footer
are pinned below it.
@@ -418,7 +418,7 @@ the vtable stays C-ABI-friendly so it can be added elsewhere later.
## 9. Phasing
-**P1 — Engine + input + chat skeleton (the core bet).**
+**P1 — Engine + input + chat skeleton (the core bet) ✅ COMPLETE.**
- Terminal raw mode, ANSI output, synchronized output, capability detect.
- Differential renderer: viewport/scrollback tracking, `firstLineChanged` dirty
model + cached-line reuse + line-diff backstop, full-redraw triggers.
@@ -436,13 +436,19 @@ the vtable stays C-ABI-friendly so it can be added elsewhere later.
acceptable for now; we expect to refine once the streaming TUI is exercised in
practice.
-**P2 — Full built-in set + overlays + editor.**
+**P2 — Full built-in set + editor. ✅ COMPLETE.**
- Remaining built-ins: welcome, thinking, tool-use (collapsible), compaction.
-- Overlay system (modal components composited over base content; positioning,
- focus stack).
+- ~~Overlay system~~ **TABLED** (not deferred — likely never picked up). The
+ design prefers the native terminal-scrollback style over composited modals,
+ which is also far simpler. Removed from scope.
- Full editor: configurable line cap (default 8) with scroll-window past it
- (show last 8 contiguous lines), word-nav, kill-ring/undo as warranted.
-- Footer with git branch + usage stats.
+ (show last 8 contiguous lines), word-nav (alt+←/→), standard editing
+ bindings (ctrl+u delete-to-line-start, etc.), and **ctrl+g** to edit the
+ buffer in `$EDITOR` (markdown tempfile). **No kill-ring / undo** (dropped —
+ keep the editor simple).
+- Footer with **context-window token count** (`usage.input + cache_read +
+ cache_write`, latest from `message_complete`). **No git branch** (panto uses
+ jj, not git).
**P3 — Extension components + cursor/IME polish.**
- Event system (`on`/`emit`, `event.get/setComponent`); built-in events wired at
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: <name>` status line
-//! (name resolved at `tool_details` / `block_complete`).
-//! The full thinking component and the collapsible tool-use component are
-//! deferred to P2; this is the documented minimal stand-in.
+//! 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 (<name>) <input json>` -> `+ <output>` 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 <file>` 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: <name> 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> <fps>" (model only if set).
+ // Build the PLAIN content: "<model> <ctx> <fps>" (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);
@@ -797,6 +1055,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<version>" 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 (<name>) <input json>`
+/// followed by a blank line and `(…)` as a result placeholder.
+/// 3. Once the result lands: `tool (<name>) <input json>`
+/// 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 (<name>) <input json>` ------------
+ 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 (<name>) <input json>`, 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[?<n>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;<mods> 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 `;<mods>` 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" },
};
}