//! The TUI application loop (plan §2/§9): wires the libpanto pull `Stream` //! into component state and drives the differential render engine. //! //! This module is the NEW app/chat loop that `main.zig` shrinks to wiring //! around. It owns: //! - a `Terminal` (raw mode + bracketed paste + SIGWINCH/restore), //! - a `tui_engine.Engine` driving a LIST of components, //! - the transcript (heap-allocated user/assistant/status components that //! persist for the engine to borrow), //! - a pinned `InputBox` (focused) and `Footer` (fps element), //! - the libpanto stream pump that routes each `Event` to component state. //! //! ## No "active component" invariant (plan §6) //! //! Streaming state is keyed by libpanto BLOCK INDEX (and tool call identity), //! never a single mutable "current component" pointer. `TurnRouter` holds a //! `block_index -> *transcript entry` map, so when parallel tool calls or //! interleaved blocks arrive later (P2), each delta lands on the right //! component without restructuring. P1 only spawns the minimal component set //! (user/assistant/input/footer + minimal status lines), but the routing //! structure is already parallel-safe. //! //! ## Streaming -> component state (plan §8) //! //! There is no per-delta render method. The pump consumes the pull `Stream` //! and, for each event, MUTATES component state and calls //! `scheduler.requestRender()`. The engine's append fast path //! (`firstLineChanged` near the tail via the render cache + the line-diff //! backstop) repaints only the dirty tail. stdout is never written directly. //! //! ## Thinking / tool / compaction display (P2) //! //! The full built-in component set is wired here: //! - a Thinking block streams its deltas into a dedicated dim `Thinking` //! component, //! - a ToolUse block drives a `ToolUse` component (one per call) through its //! `tool (?)…` -> `tool () ` -> `+ ` progression; //! the component is collapsible via a GLOBAL ctrl+o toggle (default //! collapsed, showing the last 5 output lines), //! - a CompactionSummary block (or a compaction provider-retry) renders a //! `CompactionSummary` component. //! //! ## Tool-result correlation (no "active component") //! //! ToolResult blocks do NOT arrive via `block_start`/`block_complete`; the //! agent assembles them and delivers them together in the //! `tool_dispatch_complete` event's user `Message`. Each `ToolResultBlock` //! carries a `tool_use_id` linking back to its `ToolUseBlock.id`. The router //! therefore keeps a SECOND map, tool-call id -> *ToolUse component, populated //! when the tool name/id resolve; on `tool_dispatch_complete` we walk the //! result blocks and feed each one's text to the matching component by id. //! Nothing is keyed by a single "current" component (plan §6 invariant). const std = @import("std"); const posix = std.posix; const panto = @import("panto"); const terminal_mod = @import("tui_terminal.zig"); const engine_mod = @import("tui_engine.zig"); const components = @import("tui_components.zig"); const input_mod = @import("tui_input.zig"); const theme = @import("tui_theme.zig"); const component = @import("tui_component.zig"); const command = @import("command.zig"); const Terminal = terminal_mod.Terminal; const Engine = engine_mod.Engine; const Scheduler = engine_mod.Scheduler; const Clock = engine_mod.Clock; const AssistantText = components.AssistantText; const UserText = components.UserText; const InputBox = components.InputBox; const Footer = components.Footer; const Welcome = components.Welcome; const Thinking = components.Thinking; const CompactionSummary = components.CompactionSummary; const ToolUse = components.ToolUse; const Component = component.Component; const Event = panto.Event; // =========================================================================== // IoClock — the real monotonic clock for the engine's scheduler // =========================================================================== /// Wraps `std.Io`'s monotonic (`.awake`) clock as an engine `Clock`. The /// engine stays Io-agnostic; this is the app-side adapter that supplies real /// time. Store one by value and pass `clock()` into the engine/`App`. pub const IoClock = struct { io: std.Io, pub fn init(io: std.Io) IoClock { return .{ .io = io }; } fn nowFn(ptr: *anyopaque) i128 { const self: *IoClock = @ptrCast(@alignCast(ptr)); return @intCast(std.Io.Clock.now(.awake, self.io).nanoseconds); } pub fn clock(self: *IoClock) Clock { return .{ .ptr = self, .nowFn = nowFn }; } }; // =========================================================================== // Transcript // =========================================================================== /// A heap-allocated transcript entry. The engine borrows each entry's /// `comp()`; the entry must outlive its time in the engine's list, so the /// transcript owns the boxes on the heap and frees them on `deinit`. /// /// `StatusText` reuses `AssistantText` but is styled by the caller via a /// leading style escape baked into the text (we keep it as a plain /// AssistantText for P1 and prefix a dim/style run in the seeded text). const Entry = union(enum) { user: *UserText, /// Assistant message body (streaming text block). assistant: *AssistantText, /// A dim status/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); }, .assistant => |p| { p.deinit(); alloc.destroy(p); }, .status => |p| { p.deinit(); alloc.destroy(p); }, } } }; // =========================================================================== // App // =========================================================================== pub const App = struct { alloc: std.mem.Allocator, engine: *Engine, scheduler: Scheduler, clock: Clock, /// Owned transcript entries (boxes the engine borrows). Top-to-bottom. transcript: std.ArrayList(Entry) = .empty, /// Pinned, persistent components. Owned here (by value); the engine /// borrows their `comp()`. input_box: *InputBox, footer: *Footer, /// Per-turn block routing. Cleared at each turn boundary. router: TurnRouter, /// 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. flush_ctx: ?*anyopaque = null, flush_fn: ?*const fn (ctx: *anyopaque) void = null, /// Whether the input box currently participates in the engine list. It is /// removed during an in-flight turn (so streaming output appends below the /// transcript) and re-added when the turn completes. P1 keeps it simple: /// input + footer are always present and pinned at the bottom. pub fn init( alloc: std.mem.Allocator, engine: *Engine, clock: Clock, input_box: *InputBox, footer: *Footer, ) App { return .{ .alloc = alloc, .engine = engine, .scheduler = Scheduler.init(8 * std.time.ns_per_ms), .clock = clock, .input_box = input_box, .footer = footer, .router = TurnRouter.init(alloc), .tools_collapsed = true, }; } pub fn deinit(self: *App) void { for (self.transcript.items) |e| e.deinit(self.alloc); self.transcript.deinit(self.alloc); self.router.deinit(); } /// Install a sink flusher (the buffered terminal file writer). Called once /// during real-terminal bring-up; tests leave it unset. pub fn setFlusher(self: *App, ctx: *anyopaque, f: *const fn (ctx: *anyopaque) void) void { self.flush_ctx = ctx; self.flush_fn = f; } fn flushSink(self: *App) void { if (self.flush_fn) |f| f(self.flush_ctx.?); } // -- transcript spawning ------------------------------------------------ /// Append a fresh transcript entry and register it with the engine, /// keeping the pinned input box + footer at the very bottom. Returns the /// new entry (still owned by the transcript). fn pushEntry(self: *App, entry: Entry) !void { try self.transcript.append(self.alloc, entry); try self.rebuildEngineList(); } /// Rebuild the engine's component list: all transcript entries top-to- /// bottom, then the pinned input box, then the footer. Called whenever the /// transcript layout changes (a layout change forces a full redraw inside /// the engine, which is correct here). fn rebuildEngineList(self: *App) !void { // Clear and re-add. `removeComponent` is O(n) per call, so clear by // re-initializing the slot list via repeated pops is awkward; instead // remove the pinned components, then append the new entry, then re-add // the pinned ones. To keep it simple and correct we drain & rebuild. while (self.engine.componentCount() > 0) { const first = self.engine.slots.items[0].comp; _ = self.engine.removeComponent(first); } for (self.transcript.items) |e| try self.engine.addComponent(e.comp()); try self.engine.addComponent(self.input_box.comp()); try self.engine.addComponent(self.footer.comp()); } /// Spawn a new assistant-text entry for the given block index and return /// it. Keyed by index in the router so deltas route without an "active /// component" pointer. fn spawnAssistant(self: *App) !*AssistantText { const box = try self.alloc.create(AssistantText); box.* = AssistantText.init(self.alloc); try self.pushEntry(.{ .assistant = box }); return box; } /// Spawn a dim status line seeded with `text`. Used for thinking blocks, /// tool-call status, retry notices, command output, and errors. Returns /// the box so streaming callers (thinking) can append more. fn spawnStatus(self: *App, text: []const u8) !*AssistantText { const box = try self.alloc.create(AssistantText); box.* = AssistantText.init(self.alloc); // Seed with a dim run so the status reads as chrome, not assistant // prose. The component renders plain assistant style, so we bake the // dim escape into the text itself (a documented P1 minimal stand-in // for a real status component). const dim = theme.default.fg(.dim); const seeded = try std.fmt.allocPrint(self.alloc, "{s}{s}{s}", .{ dim.open(), text, dim.close() }); defer self.alloc.free(seeded); try box.setText(seeded); try self.pushEntry(.{ .status = box }); return box; } /// Spawn a user-message entry seeded with `text`. fn spawnUser(self: *App, text: []const u8) !void { const box = try self.alloc.create(UserText); box.* = UserText.init(self.alloc); try box.setText(text); try self.pushEntry(.{ .user = box }); } /// 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 /// render time. Returns true if a frame was drawn. pub fn maybeRender(self: *App) !bool { const now = self.clock.now(); if (!self.scheduler.shouldRenderNow(now)) return false; const start = self.clock.now(); try self.engine.render(); self.flushSink(); const end = self.clock.now(); const ms = @as(f64, @floatFromInt(end - start)) / @as(f64, std.time.ns_per_ms); // Feed the footer the last frame's render time. This dirties the // footer for NEXT frame; we don't recursively render here (the next // pending frame picks it up), keeping the fps readout one frame // behind, which is acceptable for the perf-validation surface. self.footer.setFrameTime(ms); self.scheduler.noteRendered(self.clock.now()); return true; } /// Force a render now (e.g. after a turn boundary or resize), bypassing /// the coalescing window. pub fn renderNow(self: *App) !void { self.scheduler.requestRender(); const start = self.clock.now(); try self.engine.render(); self.flushSink(); const end = self.clock.now(); const ms = @as(f64, @floatFromInt(end - start)) / @as(f64, std.time.ns_per_ms); self.footer.setFrameTime(ms); self.scheduler.noteRendered(self.clock.now()); } // -- event routing ------------------------------------------------------ /// Route one libpanto `Event` to component state (plan §8). NEVER writes /// to stdout; mutates components and requests a render. Keyed by block /// index via `router` so there is no "active component" pointer. pub fn routeEvent(self: *App, ev: Event) !void { switch (ev) { .message_start => {}, .block_start => |b| { switch (b.block_type) { .Text => { const box = try self.spawnAssistant(); try self.router.put(b.index, .{ .assistant = box }); }, .Thinking => { const box = try self.spawnThinking(); try self.router.put(b.index, .{ .thinking = box }); }, .ToolUse => { // 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 => {}, } self.scheduler.requestRender(); }, .tool_details => |d| { if (self.router.get(d.index)) |ref| switch (ref) { .tool => |box| { 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 => {}, }; }, .content_delta => |d| { if (self.router.get(d.index)) |ref| switch (ref) { .assistant => |box| { try box.appendDelta(d.delta); self.scheduler.requestRender(); }, .thinking => |box| { try box.appendDelta(d.delta); self.scheduler.requestRender(); }, .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| { switch (b.block) { .ToolUse => |tu| { if (self.router.get(b.index)) |ref| switch (ref) { .tool => |box| { // 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 => |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| { if (info.compaction) { _ = try self.spawnStatus("context overflow: compacting and retrying"); } else { const secs = @as(f64, @floatFromInt(info.delay_ms)) / 1000.0; const msg = try std.fmt.allocPrint( self.alloc, "provider unavailable ({s}): retrying in {d:.1}s (attempt {d}/{d})", .{ @errorName(info.err), secs, info.attempt + 1, info.max_attempts }, ); defer self.alloc.free(msg); _ = try self.spawnStatus(msg); } self.scheduler.requestRender(); }, .tool_dispatch_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 /// the chat history); only the block-index map is cleared. pub fn beginTurn(self: *App) void { self.router.reset(); } /// Surface a turn error as a dim status line in the transcript. pub fn routeError(self: *App, err: anyerror) !void { const msg = try std.fmt.allocPrint(self.alloc, "[error: {s}]", .{@errorName(err)}); defer self.alloc.free(msg); _ = try self.spawnStatus(msg); self.scheduler.requestRender(); } }; // =========================================================================== // TurnRouter — block-index -> component map (no "active component") // =========================================================================== /// A reference to the transcript component a libpanto block is streaming into. /// Keyed by block index in `TurnRouter`. This is the structure that makes the /// loop parallel-tool-call ready: each block index has its own sink, so there /// is never a single mutable "current" component. pub const BlockRef = union(enum) { assistant: *AssistantText, /// 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), .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 { try self.map.put(index, ref); } 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); } }; // =========================================================================== // Driving the loop (real terminal) // =========================================================================== /// Inputs the loop needs from `main.zig` (kept as a struct so the wiring stays /// a single call). The agent, command registry, and command context are /// borrowed for the loop's lifetime. pub const RunOptions = struct { agent: *panto.Agent, cmd_registry: *const command.Registry, cmd_ctx: *command.Context, /// In-memory writer that command handlers write to (their `stdout`). After /// each dispatch the captured text is flushed into the transcript as a dim /// status line, then cleared. See `runLoop` for the rationale. cmd_capture: *std.Io.Writer.Allocating, model_label: []const u8, /// 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 / /// Ctrl+C. Restores the terminal on every exit path (the `Terminal` installs /// signal + the caller installs panic restore). /// /// Loop shape (single-threaded, poll-based): /// 1. Render any pending frame (feeding the footer the frame time). /// 2. Poll the tty for input with a short timeout (so coalesced renders and /// SIGWINCH are serviced promptly even with no keypress). /// 3. Decode buffered bytes -> keys -> the focused input box. /// 4. On a submitted line: drive a turn (or dispatch a slash command), /// pumping the stream's events into component state. /// Keyboard-protocol handshake state for one session. Resolved from the /// terminal's replies to the startup `negotiate_query`. const Handshake = struct { /// Kitty keyboard protocol confirmed active (non-zero flags reply seen). kitty: bool = false, /// We enabled the modifyOtherKeys fallback (must reset it on teardown). mok_enabled: bool = false, /// Handshake has resolved (a DA sentinel reply was seen). resolved: bool = false, }; pub fn runLoop(app: *App, term: *Terminal, opts: RunOptions) !void { var hs: Handshake = .{}; // Start the keyboard-protocol handshake: enable bracketed paste, push the // Kitty flags we want, then query (Kitty flags + DA sentinel). The replies // are consumed in `handleBytes`, which enables the modifyOtherKeys fallback // iff the terminal turns out not to support Kitty. term.writeAll(input_mod.negotiate_query); defer { input_mod.setKittyActive(false); if (hs.mok_enabled) term.writeAll(input_mod.disable_modify_other_keys); term.writeAll(input_mod.negotiate_teardown); } term.hideCursor(); 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(); var read_buf: [4096]u8 = undefined; // Retained partial-sequence tail across reads (a CSI/UTF-8 split across // read() boundaries). var tail: std.ArrayList(u8) = .empty; defer tail.deinit(app.alloc); while (true) { // 1. Service a pending coalesced frame. _ = try app.maybeRender(); // 1b. SIGWINCH -> resize -> full redraw. if (term.takeResized()) { const size = term.refreshSize(); app.engine.resize(size.cols, size.rows); try app.renderNow(); } // 2. Poll for input (short timeout so renders/resize stay responsive). const ready = pollReadable(term.fd, 16) catch true; if (!ready) continue; const n = posix.read(term.fd, &read_buf) catch |err| switch (err) { error.WouldBlock => continue, else => return, }; if (n == 0) break; // EOF (Ctrl+D on an empty line closes the tty) // 3. Decode. Prepend any retained tail, decode all complete sequences, // retain the unconsumed tail for the next read. try tail.appendSlice(app.alloc, read_buf[0..n]); const consumed = try handleBytes(app, term, &hs, tail.items, opts); // Keep the unconsumed tail. const leftover = tail.items.len - consumed; std.mem.copyForwards(u8, tail.items[0..leftover], tail.items[consumed..]); tail.items.len = leftover; // 4. A frame may now be pending (input edited the box / a turn ran). _ = try app.maybeRender(); } } /// Decode `bytes` into keys, route control keys (Ctrl+C/Ctrl+D) at the app /// level, feed the rest to the focused input box, and act on any submitted /// line. Returns the number of bytes consumed (the unconsumed partial tail is /// retained by the caller). fn handleBytes(app: *App, term: *Terminal, hs: *Handshake, bytes: []const u8, opts: RunOptions) !usize { var off: usize = 0; while (off < bytes.len) { const step = input_mod.decodeOne(bytes[off..]) orelse break; // partial tail switch (step.decoded) { .key => |k| { // App-level control keys. if (k.isCtrl('c') or k.isCtrl('d')) { // Clean exit: restore handled by deferred teardown + the // terminal's deinit in main. Signal EOF by closing the loop. return error.UserExit; } 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]); }, .paste => { app.input_box.comp().handleInput(bytes[off .. off + step.consumed]); }, .negotiation => |neg| { handleNegotiation(term, hs, neg); off += step.consumed; continue; // not a keypress; no render / submit check }, } off += step.consumed; app.scheduler.requestRender(); // Did the box submit a line? if (app.input_box.takeSubmitted()) |line_borrowed| { // Copy: the box may reuse its buffer. const line = try app.alloc.dupe(u8, line_borrowed); defer app.alloc.free(line); try handleSubmittedLine(app, line, opts); } } return off; } /// React to a keyboard-protocol negotiation reply from the terminal. /// /// - A non-zero Kitty flags reply confirms the Kitty protocol: mark it active /// and do NOT enable modifyOtherKeys (they can conflict). /// - The DA reply is the handshake sentinel: it always arrives. If we reach it /// without having confirmed Kitty, the terminal lacks Kitty support, so we /// enable the modifyOtherKeys fallback (e.g. for tmux/xterm). fn handleNegotiation(term: *Terminal, hs: *Handshake, neg: input_mod.Negotiation) void { switch (neg) { .kitty_flags => |flags| { if (flags != 0 and !hs.kitty) { hs.kitty = true; input_mod.setKittyActive(true); term.caps.kitty_keyboard = true; } }, .device_attributes => { if (hs.resolved) return; hs.resolved = true; if (!hs.kitty and !hs.mok_enabled) { term.writeAll(input_mod.enable_modify_other_keys); hs.mok_enabled = true; term.caps.kitty_keyboard = false; } }, } } /// Punt the input box's buffer to the user's `$EDITOR` (Ctrl+G), then read it /// back. Mirrors pi's editor escape hatch. /// /// Flow: write the buffer to a `.md` tempfile -> drop the terminal to cooked /// mode + show the cursor -> spawn `$EDITOR ` inheriting our stdio and /// wait -> re-enter raw mode + hide the cursor -> read the file back into the /// box (trimming a single trailing newline most editors add) -> delete the /// tempfile -> force a full engine redraw (the child scribbled all over the /// screen, so the differential baseline is stale). /// /// The terminal's signal/panic restore record stays armed with the ORIGINAL /// (cooked) termios throughout (`suspendRawMode` does not clear it), so a crash /// or signal while the editor is open still leaves a sane terminal. We re-enter /// raw mode on every return path via `defer`. fn editInExternalEditor( app: *App, term: *Terminal, io: std.Io, environ: *const std.process.Environ.Map, ) !void { const editor = environ.get("VISUAL") orelse environ.get("EDITOR") orelse "vi"; // Build a tempfile path: $TMPDIR (or /tmp) + a pid/nanotime-unique name. const tmp_dir = environ.get("TMPDIR") orelse "/tmp"; const pid = std.c.getpid(); const nanos = std.Io.Clock.now(.awake, io).nanoseconds; const path = try std.fmt.allocPrint(app.alloc, "{s}/panto-edit-{d}-{d}.md", .{ std.mem.trimEnd(u8, tmp_dir, "/"), pid, nanos, }); defer app.alloc.free(path); // Write the current buffer out. try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = app.input_box.buffer() }); defer std.Io.Dir.cwd().deleteFile(io, path) catch {}; // Split `$EDITOR` on spaces so commands with flags (e.g. "code -w") work, // then append the file path as the final argv entry. var argv: std.ArrayList([]const u8) = .empty; defer argv.deinit(app.alloc); try splitEditorArgv(app.alloc, editor, path, &argv); // Drop to cooked mode for the child; always re-enter raw mode + force a // full redraw afterward. term.suspendRawMode(); app.flushSink(); defer { term.resumeRawMode() catch {}; app.engine.forceFullRedraw(); app.renderNow() catch {}; } var child = try std.process.spawn(io, .{ .argv = argv.items, .stdin = .inherit, .stdout = .inherit, .stderr = .inherit, }); _ = try child.wait(io); // Read the edited file back. Cap the read so a pathological file can't OOM // us; 16 MiB is far past any reasonable prompt. const edited = std.Io.Dir.cwd().readFileAlloc(io, path, app.alloc, .limited(16 * 1024 * 1024)) catch |err| switch (err) { else => return err, }; defer app.alloc.free(edited); // Trim a single trailing newline (the convention most editors add on save). const trimmed = if (std.mem.endsWith(u8, edited, "\n")) edited[0 .. edited.len - 1] else edited; try app.input_box.setBuffer(trimmed); } /// Build the argv for the `$EDITOR` spawn: split `editor` on spaces (so /// commands with flags like `"code -w"` work), fall back to `vi` when empty, /// then append `path` as the final argument. Split out as a pure helper so the /// arg-splitting seam is unit-testable without a PTY (the spawn + raw-mode /// round-trip itself is interactive-only). fn splitEditorArgv( alloc: std.mem.Allocator, editor: []const u8, path: []const u8, argv: *std.ArrayList([]const u8), ) !void { var it = std.mem.tokenizeScalar(u8, editor, ' '); while (it.next()) |part| try argv.append(alloc, part); if (argv.items.len == 0) try argv.append(alloc, "vi"); try argv.append(alloc, path); } /// Handle a submitted input line: slash command vs. model turn. fn handleSubmittedLine(app: *App, line: []const u8, opts: RunOptions) !void { if (line.len == 0) return; if (std.mem.startsWith(u8, line, "/")) { // Slash command. Output is captured into `opts.cmd_capture` (the // command Context's stdout) and flushed into the transcript as a dim // status line — TUI-safe (no raw stdout writes during a frame). opts.cmd_capture.clearRetainingCapacity(); opts.cmd_registry.dispatch(line, opts.cmd_ctx) catch |err| switch (err) { command.Error.CommandNotFound => { const msg = try std.fmt.allocPrint(app.alloc, "[unknown command: {s}]", .{line}); defer app.alloc.free(msg); _ = try app.spawnStatus(msg); }, else => { const msg = try std.fmt.allocPrint(app.alloc, "[command error: {s}]", .{@errorName(err)}); defer app.alloc.free(msg); _ = try app.spawnStatus(msg); }, }; // Surface any captured command output. const captured = opts.cmd_capture.written(); if (captured.len != 0) { _ = try app.spawnStatus(captured); } try app.renderNow(); return; } // Model turn. Echo the user message, then pump the stream into components. try app.spawnUser(line); app.beginTurn(); try app.renderNow(); driveTurn(app, opts.agent, .{ .text = line }) catch |err| { try app.routeError(err); }; try app.renderNow(); } /// Drive one whole turn: open the pull stream, route every event into /// component state until it terminates, rendering coalesced frames as deltas /// arrive. The stream is always `deinit`ed (persisting the turn tail) on every /// exit path — agent persistence is untouched. fn driveTurn(app: *App, agent: *panto.Agent, message: panto.UserMessage) !void { var stream = try agent.run(message); defer stream.deinit(); while (try stream.next()) |ev| { try app.routeEvent(ev); _ = try app.maybeRender(); } } /// Poll the fd for readability with a millisecond timeout. Returns true when /// data is available. Uses `poll(2)`. fn pollReadable(fd: posix.fd_t, timeout_ms: i32) !bool { var fds = [_]posix.pollfd{.{ .fd = fd, .events = posix.POLL.IN, .revents = 0 }}; const n = try posix.poll(&fds, timeout_ms); if (n == 0) return false; return (fds[0].revents & posix.POLL.IN) != 0; } // =========================================================================== // Tests // =========================================================================== const testing = std.testing; /// A test clock that advances by a fixed step each `now()` call so the /// scheduler's coalescing logic is deterministic. const TestClock = struct { t: i128 = 0, step: i128 = 1, fn now(ptr: *anyopaque) i128 { const self: *TestClock = @ptrCast(@alignCast(ptr)); const v = self.t; self.t += self.step; return v; } fn clock(self: *TestClock) Clock { return .{ .ptr = self, .nowFn = now }; } }; /// Build an App backed by an in-memory engine writer (no TTY) for routing /// tests. Caller owns the returned pieces and must call `teardown`. const Harness = struct { buf: std.Io.Writer.Allocating, engine: Engine, input_box: InputBox, footer: Footer, test_clock: TestClock, app: App, fn make(alloc: std.mem.Allocator) !*Harness { const h = try alloc.create(Harness); h.buf = std.Io.Writer.Allocating.init(alloc); h.engine = Engine.init(alloc, &h.buf.writer, 80, 24, false); h.input_box = InputBox.init(alloc); h.footer = Footer.init(alloc); h.test_clock = .{ .t = 0, .step = 100 }; h.app = App.init(alloc, &h.engine, h.test_clock.clock(), &h.input_box, &h.footer); return h; } fn teardown(h: *Harness, alloc: std.mem.Allocator) void { h.app.deinit(); h.engine.deinit(); h.input_box.deinit(); h.footer.deinit(); h.buf.deinit(); alloc.destroy(h); } }; fn delta(index: usize, text: []const u8) Event { return .{ .content_delta = .{ .index = index, .delta = text } }; } test "routeEvent: text block + deltas append to an assistant component" { const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } }); try h.app.routeEvent(delta(0, "hello")); try h.app.routeEvent(delta(0, " world")); // One transcript entry (assistant), buffer accumulated both deltas. try testing.expectEqual(@as(usize, 1), h.app.transcript.items.len); const ref = h.app.router.get(0).?; try testing.expectEqualStrings("hello world", ref.assistant.buffer.items); } test "routeEvent: two text blocks key by index, no active-component clobber" { const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); // Two interleaved text blocks (the no-active-component invariant: deltas // for index 0 must NOT land on index 1 even after block 1 opened). try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } }); try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 1 } }); try h.app.routeEvent(delta(1, "B")); try h.app.routeEvent(delta(0, "A")); try h.app.routeEvent(delta(0, "A2")); try testing.expectEqualStrings("AA2", h.app.router.get(0).?.assistant.buffer.items); try testing.expectEqualStrings("B", h.app.router.get(1).?.assistant.buffer.items); } test "routeEvent: thinking deltas 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, "reason")); try h.app.routeEvent(delta(0, "ing")); const ref = h.app.router.get(0).?; try testing.expect(ref == .thinking); try testing.expectEqualStrings("reasoning", ref.thinking.buffer.items); } 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 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(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" { const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); try h.app.routeEvent(.{ .provider_retry = .{ .err = error.ConnectionResetByPeer, .delay_ms = 1500, .attempt = 0, .max_attempts = 3, .compaction = false, } }); try testing.expectEqual(@as(usize, 1), h.app.transcript.items.len); const e = h.app.transcript.items[0]; try testing.expect(e == .status); try testing.expect(std.mem.indexOf(u8, e.status.buffer.items, "retrying") != null); } test "routeEvent: full event stream renders through the real engine, no stdout" { const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); // Pin input + footer like the real loop. h.app.input_box.setFocused(true); try h.app.rebuildEngineList(); h.app.beginTurn(); try h.app.routeEvent(.{ .message_start = .assistant }); try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } }); try h.app.routeEvent(delta(0, "Hi there")); try h.app.routeEvent(.{ .turn_complete = {} }); try h.app.renderNow(); const out = h.buf.written(); // The assistant text reached the engine output (not stdout). try testing.expect(std.mem.indexOf(u8, out, "Hi there") != null); } test "beginTurn clears the block-index map but keeps transcript history" { const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } }); try h.app.routeEvent(delta(0, "first turn")); try testing.expect(h.app.router.get(0) != null); h.app.beginTurn(); // Router cleared... try testing.expect(h.app.router.get(0) == null); // ...but the transcript entry persists as history. try testing.expectEqual(@as(usize, 1), h.app.transcript.items.len); } test "maybeRender feeds the footer a frame time and respects coalescing" { const alloc = testing.allocator; const h = try Harness.make(alloc); defer h.teardown(alloc); try h.app.rebuildEngineList(); // No pending frame => no render. try testing.expect(!(try h.app.maybeRender())); h.app.scheduler.requestRender(); try testing.expect(try h.app.maybeRender()); // idle => renders // Footer received a frame time (>= 0). try testing.expect(h.app.footer.frame_ms != null); } 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]); } }