diff options
| -rw-r--r-- | build.zig | 1 | ||||
| -rw-r--r-- | docs/cli-tui-gaps.md | 321 | ||||
| -rw-r--r-- | libpanto/src/file_system_jsonl_store.zig | 98 | ||||
| -rw-r--r-- | libpanto/src/provider_openai_chat.zig | 52 | ||||
| -rw-r--r-- | src/builtin_commands.zig | 169 | ||||
| -rw-r--r-- | src/command.zig | 45 | ||||
| -rw-r--r-- | src/config_file.zig | 117 | ||||
| -rw-r--r-- | src/extension_loader.zig | 9 | ||||
| -rw-r--r-- | src/lua_runtime.zig | 13 | ||||
| -rw-r--r-- | src/luarocks_runtime.zig | 19 | ||||
| -rw-r--r-- | src/main.zig | 870 | ||||
| -rw-r--r-- | src/models_toml.zig | 7 | ||||
| -rw-r--r-- | src/subcommand.zig | 133 | ||||
| -rw-r--r-- | src/tui_app.zig | 638 | ||||
| -rw-r--r-- | src/tui_components.zig | 245 | ||||
| -rw-r--r-- | src/tui_selectors.zig | 10 |
16 files changed, 2062 insertions, 685 deletions
@@ -65,6 +65,7 @@ pub fn build(b: *std.Build) void { // Constants module: makes Zig know the Lua and luarocks versions // without duplicating literals everywhere. const versions_mod = b.addOptions(); + versions_mod.addOption([]const u8, "panto_version", @import("build.zig.zon").version); versions_mod.addOption([]const u8, "lua_version", lua_version); versions_mod.addOption([]const u8, "lua_short_version", lua_short_version); versions_mod.addOption([]const u8, "luarocks_version", luarocks_version); diff --git a/docs/cli-tui-gaps.md b/docs/cli-tui-gaps.md deleted file mode 100644 index 6791941..0000000 --- a/docs/cli-tui-gaps.md +++ /dev/null @@ -1,321 +0,0 @@ -# panto CLI/TUI: gaps and proposals - -Survey of the current CLI/TUI surface (2026-07-03) and what to do about it. - -Framing first, because it decides everything below: panto's job is not to -do everything, it's to make everything possible. It is a minimal -interactive shell over libpanto plus a powerful Lua extension API, in the -spirit of pi (https://pi.dev) — users build their own ideal agent on top, -and that agent need not be a coding agent at all (the shipped coding tools -are themselves deny-able Lua extensions). So every "gap" lands in one of -three buckets: - -- **Core** — makes the shell itself usable and scriptable. Baseline, - not coding-specific. Ships in Zig (and should generally also be - reachable from Lua). -- **Extension API** — core shouldn't do the thing; core should make the - thing *buildable*. New API surfaces are co-designed with Travis before - building. -- **Not ours** — coding-agent conveniences. Belongs in `../panto-agent` - or other out-of-tree extensions; listed only so we stop re-proposing it. - -Priorities within a bucket: **P0** = bug or table-stakes, **P1** = next -tranche, **P2** = deliberate deferral. - ---- - -## 1. Bugs (core, P0, all small) - -Fix regardless of anything else below. - -1. **Duplicate short session IDs.** `panto sessions` shows `id[0..8]` - (`src/subcommand.zig:374`), but IDs are UUIDv7 and the first 8 hex chars - are the top 32 bits of the millisecond timestamp — no randomness until - char 15. Two sessions created within ~65 s in one project display - identical short IDs. Fix: shortest-unambiguous prefix per listing - (extend past 8 while any two collide). - -2. **`AmbiguousSessionId` exits silently.** `--resume <prefix>` matching - two files propagates the error to a bare `exit(1)` at - `src/main.zig:282-283` — no message. Only `SessionNotFound` gets a tidy - error (`main.zig:724-727`). Fix: print the candidates and exit. - -3. **`--resume` (bare) and `panto sessions` disagree on "most recent".** - `findMostRecentSession` takes the lexicographic filename max = - newest-*created* (`file_system_jsonl_store.zig:981-1010`); the listing - sorts by newest-*modified*. Fix: make `latest()` use `modified`. - -4. **`trimCreated` doesn't trim.** Promises `YYYY-MM-DD HH:MM`, returns - `iso[0..16]` with the `T` intact (`src/subcommand.zig:386-394`). - -5. **`--resume` flag peeking can't rewind.** A `-`-leading token after - `--resume` is warned-and-dropped instead of parsed as its own flag - (`main.zig:676-688`). Becomes real once a second agent flag exists (§2). - -6. **Base config template comment says `[tools]`** where the code reads - `[extensions]` (`luarocks_runtime.zig:460` vs `config_file.zig:201`). - ---- - -## 2. Core: command-line flags - -Today the entire agent-mode flag surface is `--resume [<id>]` -(`main.zig:658-697`). For an "interactive agent SDK" the bar is: launch it, -point it at a model, script it, resume it. That's about five flags. - -### P0 - -- **`-p, --print <prompt>` — one-shot non-interactive mode.** One agent - turn (tools and all), text to stdout, meaningful exit code; prompt from - the arg or stdin when piped. panto currently *requires* a tty - (`main.zig:494-503`) and cannot be scripted at all — the biggest gap in - the product. Composes with `--resume` for headless continuation. Pairs - with libpanto's "one-shot simple API" todo (`docs/todos.md:12`). -- **`-m, --model <provider:alias>`.** Already anticipated: - `selectModel(model_override)` exists and `main.zig:227` passes `null`. - Wire it through; accept a bare alias when unambiguous. -- **`--version`.** Embed at build time; print `panto <ver>`. -- **Unknown flags error, don't warn-and-continue** (`main.zig:686,693`). - A typo silently launching a TUI is how scripts hang. - -### P1 - -- **`-c, --continue`** — ergonomic alias for bare `--resume`. -- **`--no-extensions`** — skip the Lua runtime: fast `-p` in CI, and the - escape hatch when an extension breaks startup. Given that even the - shipped tools are extensions, define semantics as "built-ins only, - policy-denied everything else". -- **`--effort <level>`** — same pattern as `--model`. - -### Skip - -- `--config <path>` — the 4-layer TOML merge already covers it. -- `--output-format json` for `-p` — wait for a real consumer. -- `--cwd` — `cd && panto`. - -### `panto sessions` output (P0) - -Currently `<8-char-id> <created-with-T> <N> messages`, while the useful -fields (`last_user_message`, `model`, `modified`) are computed by -`buildFileInfo` and discarded. Proposal: - -``` -ID MODIFIED MSGS MODEL LAST MESSAGE -0197c2a4 2026-07-01 14:32 18 anthropic:sonnet fix the segfault in tui_engine when… -0197b1f0 2026-06-30 09:15 4 openai:gpt add a --version flag -``` - -Sort and *show* by modified; shortest-unique IDs (§1.1); truncate last -message to width; plain aligned columns, no box-drawing. Print the sessions -dir path as the last line so the storage is discoverable. Keep resume as a -flag (it composes with `-p`/`-m`; `sessions` stays list-only) — the real -contract is that listed IDs paste into `--resume` without ambiguity. - ---- - -## 3. Core: slash commands - -Current inventory: **`/compact`** (`src/compaction.zig:13`). The framework -(registry, Lua `panto.ext.register_command`, capture-to-transcript, error -rendering) is complete — and mostly-empty is correct for panto. But a shell -whose product *is* its extension points still has to be self-describing and -steerable. These are baseline and coding-agnostic, so they're core Zig; -each should also be achievable from Lua via `panto.ext.agent` (the -sufficiency audit in §7). - -### P0 - -- **`/help`** — registered commands (`Registry.list()` exists solely for - this and tab completion) plus the key bindings. Today nothing in the - product reveals that `/compact` or Ctrl+G exist. For an - extensions-first tool, discoverability of what's currently loaded is not - a convenience — it's how users see their own extensions. -- **`/quit`** — trivial; the only exit today is Ctrl+C/D. -- **`/model [provider:alias]`**, **`/reasoning [level]`** — no arg opens - the existing selector; with arg sets directly. Makes the selectors - discoverable and terminal-agnostic (see the Ctrl+M problem, §4). -- **`/new`** — fresh session in place; today you quit and relaunch. - -### P1 - -- **`/resume [<id>]`** — in-TUI session switcher; no arg opens a fuzzy - `Selector` over sessions (short-id + modified + last message rows). -- **`/status`** — provider, model, reasoning, session id, message count, - context used vs window. All already in `command.Context` or computable. - -### Completion and typeahead (P1) - -The fuzzy machinery already exists and is good — the model selector does -case-insensitive subsequence filtering with Ctrl+N/P + Enter. Extend the -same feel to the input box (`docs/todos.md:22` tracks this): - -- **Leading-`/` typeahead**: when the buffer starts with `/`, surface a - live-filtered command list (from `Registry.list()`, which exists for - exactly this) in the same selector idiom; Tab or Enter-on-selection - completes. Registered Lua commands appear automatically — this is - another discoverability channel for extensions, like `/help`. -- **Tab completes file/dir names from cwd** anywhere in the buffer: - complete the token under the cursor against the filesystem, common-prefix - first, selector on ambiguity. Coding-agnostic (paths are universal). -- Later: **per-command argument completion** declared by the command - (e.g. `/model` completing `provider:alias`, Lua commands supplying a - completion function). That's an extension API surface — §7. - ---- - -## 4. Core: key bindings - -Input box editing is solid (word motion, Ctrl+A/E/U/W, Shift+Enter -multiline, bracketed paste). Gaps: - -### P0 - -- **Input history (Up/Down recall).** Decoded and dropped today - (`tui_components.zig:1207`). Up at buffer-top / Down at buffer-bottom - navigate history so multiline arrow-editing still works; in-memory - per-run is enough to start. -- **Idle Escape clears the input buffer.** Makes Escape uniformly "cancel - the thing in progress" (it already interrupts turns and closes - selectors). -- **Fix Ctrl+M.** Ctrl+M *is* carriage return; the model-selector binding - only exists on Kitty-protocol terminals and silently vanishes on plain - xterm/tmux. With `/model` (§3) as the discoverable path, move the chord - to a legacy-safe key (Ctrl+T is free) or accept slash-only on legacy - terminals — either way `/help` documents reality. - -### P1 - -- **Ctrl+L** — clear/redraw (engine already has full-redraw machinery). -- **Buffer typed input during a turn** and restore it to the input box - after; full mid-turn steering waits on libpanto queueing - (`docs/todos.md:15`). -- **Dedup the chord dispatch**: Ctrl+O/M/R are re-implemented in both - `handleBytes` (`tui_app.zig:1801-1842`) and `pumpTurnKeys` - (`tui_app.zig:2286-2304`). One `appChord(key) -> ?Action` both call. - This is also the seam the keybinding extension API (§7) slots into. - -### Deliberate non-features - -- Kill-ring/yank, undo — already deferred in-code ("plan P2"); agreed. -- In-app transcript scrolling — engine delegates to native terminal - scrollback by design (`tui_engine.zig:36-42`); keep. - ---- - -## 5. Core: config.toml settings - -The `[defaults]`/`[compaction]`/`[extensions]`/`[providers]`/`[auth]` -sections cover the agent; the TUI itself has zero settings. A halfway -decent agent TUI supports at least: - -### P1 - -- **`[defaults] reasoning = "<level>"`** — today reasoning comes only from - models.toml per-alias or the Ctrl+R selector; a session default belongs - next to `defaults.model`. -- **`[tui] editor = "..."`** — Ctrl+G honors only `$EDITOR`; a config - override is the conventional courtesy. -- **`[tui] tools_collapsed = true|false`** — the Ctrl+O collapse state's - starting value. -- **`[tui] theme`** — the palette is deliberately comptime-fixed today - (`tui_theme.zig:5-6` says centralization, not theming). A minimal - version: named color overrides for the existing `StyleName` slots. - P1 only if cheap; a full theming system is P2 at best. - -### Skip - -- Keymap section — subsumed by the keybinding extension API (§7). -- Footer layout config — subsumed by the footer segment API (§7). -- Anything per-command/per-tool — that's extension config, which - extensions can already read themselves (Lua reads files). - ---- - -## 6. Core: the footer - -Today the footer is one dim line: `provider:alias (reasoning)`, latest -context tokens, session tokens, session cost (`tui_components.zig:1533`, -all pushed from Zig via `setModel`/`setContextTokens`/...). Worth adding: - -### P1 - -- **Context as a fraction, not a raw count.** `12.3k ctx` is meaningless - without the window; models.toml already has `context_window`. Show - `12.3k/200k ctx` or a percentage — this is the number that tells you - when compaction looms, arguably the footer's whole job. -- **Session short-id** — pairs with `/status` and `panto sessions`; makes - "which session am I in" free. -- **Transient key-hint slot** — e.g. `esc interrupt` while a turn runs. - One hint at a time, context-dependent; not a permanent hint bar. - -### P2 / skip - -- git branch, cwd — coding/shell-flavored and knowable from the terminal; - an extension can add these once the segment API (§7) exists. Skip in - core. - ---- - -## 7. Extension API gaps (make it possible) - -The survey surfaced these as missing Lua surfaces. Per AGENTS.md, each gets -co-designed before building — this list is scope, not spec. - -- **Keybinding registration.** Wanted: extensions can bind chords to Lua - handlers (and presumably rebind/shadow core ones). Blocked on the - `appChord` central dispatch (§4) existing first. Design questions for - the co-design pass: conflict policy vs core chords, per-context bindings - (idle vs during-turn vs selector), and whether declared bindings - auto-appear in `/help`. -- **Footer segments.** The footer is entirely Zig-fed today; extensions - have no way to add a readout (git branch, active skill, queue depth). - Likely shape: register a segment that returns a short string, repainted - on demand — rhymes with the existing screen-component API. -- **Completion providers.** Per-command argument completion for Lua slash - commands (§3): a command optionally supplies a completion function. -- **Session store access** from Lua (list/resolve/load). Needed for an - extension to build its own `/resume`-like or archival behaviors; today - sessions are invisible to extensions. -- **Selector/prompt UI for extensions.** TUI screen components are done - (`docs/todos.md:31`); a fuzzy-pick-from-list primitive is the missing - interactive piece extensions keep needing (model pickers, skill pickers, - session pickers all rhyme). - -Not a gap (previously misreported here): the current agent and -conversation **are** exposed — `panto.ext.agent` wraps the live session -agent with the full method surface (`lua_runtime.zig:219-242`), and the -conversation hangs off it. What's worth auditing instead is whether that -surface is *sufficient* for §3-parity from Lua: model/config switching, -starting a fresh session, reading usage/config for a `/status`-alike. - ---- - -## 8. Not ours (so we stop re-proposing it) - -- **`/usage`, `/cost`** — buildable today-ish from the Lua usage-metrics - API + models.toml pricing; panto-agent material. -- **`/init`, `/review`, `/pr`, rules/skills injection** — coding-agent - features; panto-agent already owns this space (`agent.rules`, - `agent.skill`). -- **`/undo` / checkpointing** — real design work and coding-flavored; - out-of-tree when someone wants it. -- **User keymap config file** — subsumed by the keybinding extension API - (§7); a TOML keymap section can be revisited if non-extension users ask. - ---- - -## 9. Sequencing - -1. §1 bug fixes + `panto sessions` output + shortest-unique IDs — one PR, - pure fixes. -2. `--model`, `--version`, strict flag parsing — small PR. -3. `-p/--print` — the headline; with libpanto's one-shot todo or a thin - plain-text presenter over the existing loop. -4. Slash commands P0 (`/help`, `/quit`, `/model`, `/reasoning`, `/new`). -5. Keybindings P0 (history, idle-Escape, Ctrl+M move) + `appChord` dedup. -6. Footer P1 (context fraction, session id) + config.toml `[defaults] - reasoning` / `[tui]` basics. -7. `/resume` switcher, `/status`, completion/typeahead (§3). -8. Extension API co-design round: keybindings, footer segments, completion - providers, session store, selector primitive (§7) — then panto-agent - picks up §8. diff --git a/libpanto/src/file_system_jsonl_store.zig b/libpanto/src/file_system_jsonl_store.zig index 5b7f450..9bc9436 100644 --- a/libpanto/src/file_system_jsonl_store.zig +++ b/libpanto/src/file_system_jsonl_store.zig @@ -554,7 +554,11 @@ pub const SessionFile = struct { if (self.flushed) { try self.persistEntries(entries); } else { - try self.flushBufferedMany(entries); + // File is created on the first assistant message (see module docs). + for (messages) |m| if (m.role == .assistant) { + try self.flushBufferedMany(entries); + break; + }; } for (entries, 0..) |entry, i| { @@ -976,39 +980,6 @@ fn buildFileInfo( // Recent / resume helpers // ============================================================================= -/// Find the most recent session file in `session_dir`. Returns null if -/// none exist. Caller owns the returned path. -pub fn findMostRecentSession(allocator: Allocator, io: Io, session_dir: []const u8) !?[]u8 { - var dir = Io.Dir.cwd().openDir(io, session_dir, .{}) catch |err| switch (err) { - error.FileNotFound => return null, - else => return err, - }; - defer dir.close(io); - - var best_name: ?[]u8 = null; - errdefer if (best_name) |b| allocator.free(b); - - var it = dir.iterate(); - while (try it.next(io)) |entry| { - if (entry.kind != .file) continue; - if (!std.mem.endsWith(u8, entry.name, ".jsonl")) continue; - if (best_name) |b| { - // Lexicographic compare. UUIDv7 filenames sort chronologically. - if (std.mem.order(u8, entry.name, b) == .gt) { - allocator.free(b); - best_name = try allocator.dupe(u8, entry.name); - } - } else { - best_name = try allocator.dupe(u8, entry.name); - } - } - - const name = best_name orelse return null; - defer allocator.free(name); - best_name = null; - return try std.fs.path.join(allocator, &.{ session_dir, name }); -} - /// Resolve a (possibly abbreviated) session id to a session file path /// within `session_dir`. Errors if no match or ambiguous prefix. pub fn resolveSessionId( @@ -1209,9 +1180,16 @@ pub const FileSystemJSONLStore = struct { fn latestVT(ctx: *anyopaque) anyerror!?session_store_mod.Session { const self: *FileSystemJSONLStore = @ptrCast(@alignCast(ctx)); - const path = (try findMostRecentSession(self.allocator, self.io, self.dir)) orelse return null; - defer self.allocator.free(path); - return try self.sessionFromPath(path); + // Most-recently-*modified* wins, matching the `list()` sort order — + // not newest-created (lexicographic UUIDv7 filename). + const fis = try listSessions(self.allocator, self.io, self.dir, null); + defer { + for (fis) |fi| fi.deinit(self.allocator); + self.allocator.free(fis); + } + if (fis.len == 0) return null; + const info = try self.infoFromFileInfo(fis[0]); + return .{ .info = info, .store = self.store() }; } fn sessionFromPath(self: *FileSystemJSONLStore, path: []const u8) !?session_store_mod.Session { @@ -1436,25 +1414,25 @@ test "SessionFile: full flow — buffer, flush on assistant, append, resume" { ); defer mgr.deinit(); - // First append flushes the header + entry to disk immediately. + // System message buffers in memory — nothing on disk yet. const sys_blocks = try testing.allocator.alloc(StoredContentBlock, 1); sys_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "You are helpful.") } }; _ = try mgr.appendMessage( .{ .role = .system, .content = sys_blocks }, null, ); - try testing.expect(mgr.isFlushed()); + try testing.expect(!mgr.isFlushed()); - // User message — appended to the already-flushed file. + // User message — still buffered. const usr_blocks = try testing.allocator.alloc(StoredContentBlock, 1); usr_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi there") } }; _ = try mgr.appendMessage( .{ .role = .user, .content = usr_blocks }, oaStamp(), ); - try testing.expect(mgr.isFlushed()); + try testing.expect(!mgr.isFlushed()); - // Assistant message. + // Assistant message — first flush: header + all buffered entries. const a_blocks = try testing.allocator.alloc(StoredContentBlock, 1); a_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hello") } }; _ = try mgr.appendMessage( @@ -1698,7 +1676,7 @@ test "listSessions: returns most recent first, with counts" { try testing.expect(std.mem.order(u8, infos[0].modified, infos[1].modified) != .lt); } -test "findMostRecentSession: picks lexicographically greatest" { +test "latest: picks most-recently-modified, not newest-created" { const io = testing.io; var td = try TmpSessionDir.init(testing.allocator); @@ -1706,12 +1684,17 @@ test "findMostRecentSession: picks lexicographically greatest" { const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" }); defer testing.allocator.free(sessions); - // Pre-resolution before any sessions exist → null. - try testing.expect((try findMostRecentSession(testing.allocator, io, sessions)) == null); + var catalog = try FileSystemJSONLStore.init(testing.allocator, io, sessions); + defer catalog.deinit(); + const st = catalog.store(); - // Create two. - var second_file: ?[]u8 = null; - defer if (second_file) |s| testing.allocator.free(s); + // Before any sessions exist → null. + try testing.expect((try st.latest()) == null); + + // Create session A, then session B (newer id), then append to A again + // so A is the most recently *modified*. + var first_id: ?[]u8 = null; + defer if (first_id) |s| testing.allocator.free(s); for (0..2) |i| { var mgr = try SessionFile.init(testing.allocator, io, sessions, null); @@ -1721,14 +1704,23 @@ test "findMostRecentSession: picks lexicographically greatest" { _ = try mgr.appendMessage(.{ .role = .user, .content = u }, oaStamp()); const a = try testing.allocator.alloc(StoredContentBlock, 1); a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a") } }; - _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null); - if (i == 1) second_file = try testing.allocator.dupe(u8, mgr.getSessionFile()); + _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, oaStamp()); + if (i == 0) first_id = try testing.allocator.dupe(u8, mgr.header.id); io.sleep(.fromMilliseconds(2), .real) catch {}; } + { + const path = try resolveSessionId(testing.allocator, io, sessions, first_id.?); + defer testing.allocator.free(path); + var mgr = try SessionFile.open(testing.allocator, io, path); + defer mgr.deinit(); + const a = try testing.allocator.alloc(StoredContentBlock, 1); + a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a") } }; + _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, oaStamp()); + } - const found = (try findMostRecentSession(testing.allocator, io, sessions)).?; - defer testing.allocator.free(found); - try testing.expectEqualStrings(second_file.?, found); + var latest = (try st.latest()).?; + defer latest.info.deinit(testing.allocator); + try testing.expectEqualStrings(first_id.?, latest.info.id); } test "SessionFile: tool-use round-trip — assistant w/ ToolUse, user w/ ToolResult, assistant" { diff --git a/libpanto/src/provider_openai_chat.zig b/libpanto/src/provider_openai_chat.zig index f653ff6..a05c7ba 100644 --- a/libpanto/src/provider_openai_chat.zig +++ b/libpanto/src/provider_openai_chat.zig @@ -674,23 +674,23 @@ fn handleEvent( try out.push(.{ .message_start = .assistant }); } - if (d.reasoning_content) |rc| { + if (d.reasoning_content) |rc| if (rc.len > 0) { if (!state.started) { state.started = true; try out.push(.{ .message_start = .assistant }); } try state.openBlock(.thinking, out); try state.appendDelta(out, rc); - } + }; - if (d.content) |c| { + if (d.content) |c| if (c.len > 0) { if (!state.started) { state.started = true; try out.push(.{ .message_start = .assistant }); } try state.openBlock(.text, out); try state.appendDelta(out, c); - } + }; if (d.tool_calls.len > 0) { if (!state.started) { @@ -909,6 +909,50 @@ test "openai_chat: omitted stream usage yields null on message_complete" { try testing.expectEqualStrings("msg_complete[usage:null]", found.?); } +test "openai_chat: empty content alongside reasoning does not split thinking block" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try addUserText(&conv, "think"); + + var rec = EventRecorder{ .allocator = allocator }; + defer rec.deinit(); + + const events = [_][]const u8{ + \\{"choices":[{"delta":{"role":"assistant"}}]} + , + \\{"choices":[{"delta":{"reasoning_content":"rea","content":""}}]} + , + \\{"choices":[{"delta":{"reasoning_content":"son","content":""}}]} + , + \\{"choices":[{"delta":{"reasoning_content":"ing","content":""}}]} + , + \\{"choices":[{"delta":{},"finish_reason":"stop"}]} + , + "[DONE]", + }; + try runStreamedTurn(allocator, &conv, &rec, &events); + + const expected = [_][]const u8{ + "msg_start", + "block_start[0]:Thinking", + "delta[0]:rea", + "delta[0]:son", + "delta[0]:ing", + "block_complete[0]", + "msg_complete[usage:null]", + }; + try testing.expectEqual(expected.len, rec.events.items.len); + for (expected, rec.events.items) |want, got| { + try testing.expectEqualStrings(want, got); + } + + const asst = conv.messages.items[1]; + try testing.expectEqual(@as(usize, 1), asst.content.items.len); + try testing.expectEqualStrings("reasoning", asst.content.items[0].Thinking.text.items); +} + test "fragmented tool_call id and name are reassembled" { // Lenient OpenAI-compatible providers occasionally split `id` and // `function.name` across multiple deltas instead of sending them whole diff --git a/src/builtin_commands.zig b/src/builtin_commands.zig new file mode 100644 index 0000000..8f9f851 --- /dev/null +++ b/src/builtin_commands.zig @@ -0,0 +1,169 @@ +//! Builtin TUI slash commands: `/help`, `/quit`, `/model`, `/reasoning`, +//! `/new`. Registered by `main.zig` alongside `/compact` +//! (`compaction.zig`). These steer the live TUI, so their handlers cast +//! `Context.tui` back to the `*tui_app.App` installed at bring-up; output +//! goes through `ctx.stdout` (the transcript capture buffer). + +const std = @import("std"); +const panto = @import("panto"); +const command = @import("command.zig"); +const tui_app = @import("tui_app.zig"); +const selectors_mod = @import("tui_selectors.zig"); + +pub fn register(registry: *command.Registry) !void { + try registry.register(.{ + .name = "help", + .description = "List commands and key bindings.", + .run = runHelp, + }); + try registry.register(.{ + .name = "quit", + .description = "Exit panto.", + .run = runQuit, + }); + try registry.register(.{ + .name = "model", + .description = "Open the model selector, or switch directly: /model <provider:alias>.", + .run = runModel, + }); + try registry.register(.{ + .name = "reasoning", + .description = "Open the reasoning selector, or set directly: /reasoning <level>.", + .run = runReasoning, + }); + try registry.register(.{ + .name = "new", + .description = "Start a fresh session in place (new agent, extensions reloaded).", + .run = runNew, + }); + try registry.register(.{ + .name = "resume", + .description = "Switch sessions: /resume opens a picker, /resume <id> resolves a prefix.", + .run = runResume, + }); + try registry.register(.{ + .name = "status", + .description = "Show provider, model, reasoning, session, and context usage.", + .run = runStatus, + }); +} + +fn theApp(ctx: *command.Context) !*tui_app.App { + const ptr = ctx.tui orelse return error.NoTui; + return @ptrCast(@alignCast(ptr)); +} + +/// `/help` — the command list straight from the registry (so it can't rot) +/// plus the key bindings table maintained next to the chord dispatch. +fn runHelp(_: []const u8, ctx: *command.Context) anyerror!void { + const reg = ctx.registry orelse return error.NoRegistry; + var width: usize = 0; + for (reg.list()) |cmd| width = @max(width, cmd.name.len); + + try ctx.stdout.writeAll("commands:\n"); + for (reg.list()) |cmd| { + try ctx.stdout.print(" /{[n]s:<[w]}{[d]s}\n", .{ .n = cmd.name, .w = width + 2, .d = cmd.description }); + } + + width = 0; + for (tui_app.key_bindings) |kb| width = @max(width, kb.chord.len); + try ctx.stdout.writeAll("\nkeys:\n"); + for (tui_app.key_bindings) |kb| { + try ctx.stdout.print(" {[c]s:<[w]}{[d]s}\n", .{ .c = kb.chord, .w = width + 2, .d = kb.desc }); + } +} + +/// `/quit` — the dispatch site in `tui_app.handleSubmittedLine` re-raises +/// `error.UserExit`. +fn runQuit(_: []const u8, _: *command.Context) anyerror!void { + return error.UserExit; +} + +fn runModel(args: []const u8, ctx: *command.Context) anyerror!void { + const app = try theApp(ctx); + const ctrl = app.selectors orelse return error.NoSelectors; + if (args.len == 0) return ctrl.openModel(); + ctrl.setModelByName(args) catch |err| switch (err) { + error.UnknownModel => try ctx.stdout.print( + "unknown model '{s}' (/model with no argument lists them)\n", + .{args}, + ), + error.AmbiguousModel => try ctx.stdout.print( + "ambiguous alias '{s}': use provider:alias\n", + .{args}, + ), + else => return err, + }; +} + +fn runReasoning(args: []const u8, ctx: *command.Context) anyerror!void { + const app = try theApp(ctx); + const ctrl = app.selectors orelse return error.NoSelectors; + if (args.len == 0) return ctrl.openReasoning(); + ctrl.setReasoningByLabel(args) catch |err| switch (err) { + error.UnknownReasoning => { + try ctx.stdout.print("unknown reasoning level '{s}'; available:", .{args}); + for (selectors_mod.ReasoningOption.forStyle(ctrl.live.provider.style())) |opt| { + try ctx.stdout.print(" {s}", .{opt.label}); + } + try ctx.stdout.writeAll("\n"); + }, + else => return err, + }; +} + +fn runResume(args: []const u8, ctx: *command.Context) anyerror!void { + const app = try theApp(ctx); + const ctrl = app.selectors orelse return error.NoSelectors; + if (args.len == 0) return ctrl.openSessions(); + + const store = ctx.session_store; + const maybe = store.resolve(args) catch |err| switch (err) { + error.AmbiguousSessionId => return printAmbiguousSessions(store, args, ctx.stdout), + else => return err, + }; + const session = maybe orelse { + try ctx.stdout.print("no session matching '{s}'\n", .{args}); + return; + }; + try ctrl.resumeTo(session); +} + +/// Print the session ids matching `prefix` (shared by `/resume` and the +/// `--resume` path in `main.zig` for ambiguity reporting). +pub fn printAmbiguousSessions(store: panto.SessionStore, prefix: []const u8, out: *std.Io.Writer) !void { + try out.print("'{s}' matches multiple sessions:\n", .{prefix}); + const infos = try store.list(); + defer store.freeSessionInfos(infos); + for (infos) |info| { + if (!std.mem.startsWith(u8, info.id, prefix)) continue; + try out.print(" {s}\n", .{info.id}); + } +} + +fn runStatus(_: []const u8, ctx: *command.Context) anyerror!void { + const app = try theApp(ctx); + const ctrl = app.selectors orelse return error.NoSelectors; + try ctx.stdout.print("model: {s}\n", .{ctrl.model_label}); + const r = selectors_mod.ReasoningOption.activeLabel(ctrl.live.provider); + try ctx.stdout.print("reasoning: {s}\n", .{if (r.len != 0) r else "default"}); + try ctx.stdout.print("session: {s}\n", .{ctx.agent.sessionId()}); + try ctx.stdout.print("messages: {d}\n", .{ctx.agent.conversation.messages.items.len}); + if (app.footer.context_window) |w| { + try ctx.stdout.print("context: {d} / {d} tokens\n", .{ app.footer.context_tokens orelse 0, w }); + } else if (app.footer.context_tokens) |t| { + try ctx.stdout.print("context: {d} tokens\n", .{t}); + } +} + +/// `/new` — fresh session in place, boot-equivalent: mint a session from +/// the store and rebuild the whole session world (fresh Agent, fresh Lua +/// runtime; the system prompt is re-derived from the prompt layers + +/// extension activation, exactly as a relaunch would). The rebuild hook +/// repoints `ctx.agent`, so the id printed below is the fresh session's. +fn runNew(_: []const u8, ctx: *command.Context) anyerror!void { + const app = try theApp(ctx); + try app.rebuildSession(ctx.session_store.create(), null); + const sid = ctx.agent.sessionId(); + try ctx.stdout.print("[new session {s}]\n", .{sid[0..@min(8, sid.len)]}); +} diff --git a/src/command.zig b/src/command.zig index 2e63cea..43997ed 100644 --- a/src/command.zig +++ b/src/command.zig @@ -53,6 +53,19 @@ pub const Context = struct { /// handler. `null` when no extensions loaded; Lua commands are only /// registered when it is non-null, so handlers may assume it is set. lua_rt: ?*anyopaque = null, + + /// The registry dispatching this command (for `/help` listings). + /// Stamped by `Registry.dispatch`; no wiring needed. + registry: ?*const Registry = null, + + /// The session store the active session came from (for `/new`). + session_store: panto.SessionStore, + + /// The live TUI `*tui_app.App`, when running under the TUI (same + /// `anyopaque` idiom as `lua_rt`: keeps this module free of TUI + /// concerns). Commands that steer the TUI (`/model`, `/new`, ...) + /// cast it back in `builtin_commands.zig`. Null in print mode. + tui: ?*anyopaque = null, }; /// A single slash command. @@ -120,6 +133,21 @@ pub const Registry = struct { }); } + /// Remove every Lua-backed command, keeping native commands in place. + /// Called when the session's Lua runtime is torn down (`/new`, + /// `/resume`): the handler refs die with that runtime, and the next + /// session's harvest re-registers its own set — so `/help` always + /// lists exactly one copy. + pub fn clearLua(self: *Registry) void { + var i: usize = self.commands.items.len; + while (i > 0) { + i -= 1; + if (self.commands.items[i].lua_ref != null) { + _ = self.commands.orderedRemove(i); + } + } + } + /// Find a command by name (without leading `/`). Null if absent. pub fn find(self: *const Registry, name: []const u8) ?*const Command { for (self.commands.items) |*cmd| { @@ -150,6 +178,7 @@ pub const Registry = struct { const args = std.mem.trim(u8, body[name_end..], " \t"); const cmd = self.find(name) orelse return Error.CommandNotFound; + ctx.registry = self; if (cmd.lua_ref) |ref| { try runLuaCommand(ref, args, ctx); return; @@ -231,6 +260,22 @@ test "registerLua records name, description, and ref; collides with builtins" { try testing.expectError(error.DuplicateCommand, reg.registerLua("compact", "x", 1)); } +test "clearLua removes Lua-backed commands, keeps natives, allows re-harvest" { + var reg = Registry.init(testing.allocator); + defer reg.deinit(); + + try reg.register(.{ .name = "compact", .description = "d", .run = testHandler }); + try reg.registerLua("hello", "d", 1); + try reg.registerLua("world", "d", 2); + + reg.clearLua(); + try testing.expectEqual(@as(usize, 1), reg.list().len); + try testing.expect(reg.find("compact") != null); + // A rebuilt session's harvest re-registers without DuplicateCommand. + try reg.registerLua("hello", "d", 3); + try testing.expectEqual(@as(?c_int, 3), reg.find("hello").?.lua_ref); +} + test "dispatch splits name and args" { var reg = Registry.init(testing.allocator); defer reg.deinit(); diff --git a/src/config_file.zig b/src/config_file.zig index deca908..c606317 100644 --- a/src/config_file.zig +++ b/src/config_file.zig @@ -346,6 +346,15 @@ pub const Config = struct { /// `defaults.model` storage, owned. `default_model_ref` slices into it. default_model: ?[]const u8, default_model_ref: ?ModelRef, + /// `defaults.reasoning`: the session-default reasoning level LABEL (the + /// same names the Ctrl+R picker shows, e.g. "high"). Owned. Applied at + /// boot/model-switch unless the alias sets an explicit per-alias knob in + /// models.toml (`reasoning_explicit`); `--effort` beats both. + default_reasoning: ?[]const u8 = null, + /// `[tui] editor`: overrides `$VISUAL`/`$EDITOR` for Ctrl+G. Owned. + tui_editor: ?[]const u8 = null, + /// `[tui] tools_collapsed`: initial Ctrl+O collapse state (null = default). + tui_tools_collapsed: ?bool = null, /// Extension availability policy (the `[extensions]` section), resolved /// across all config layers. Governs every lua-authored capability. extensions: Policy, @@ -368,6 +377,8 @@ pub const Config = struct { self.allocator.free(self.providers); self.allocator.free(self.auths); if (self.default_model) |m| self.allocator.free(m); + if (self.default_reasoning) |r| self.allocator.free(r); + if (self.tui_editor) |e| self.allocator.free(e); if (self.compaction_model) |m| self.allocator.free(m); self.extensions.deinit(self.allocator); freeLayeredStrs(self.allocator, self.ext_paths); @@ -394,7 +405,8 @@ pub const Config = struct { } /// Choose the model reference to start with. Precedence: - /// 1. an explicit `model_override` (e.g. a future `--model` flag), + /// 1. an explicit `model_override` (`--model`; either "provider:alias" + /// or a bare alias that is unique across providers in `defs`), /// 2. `defaults.model` from config, /// 3. if exactly one provider resolved AND it has exactly one model /// alias in `defs`, use that, @@ -407,7 +419,18 @@ pub const Config = struct { model_override: ?[]const u8, ) ResolveError!ModelRef { if (model_override) |m| { - return parseModelRef(m) catch return error.NoModelSelected; + if (std.mem.indexOfScalar(u8, m, ':') != null) { + return parseModelRef(m) catch return error.NoModelSelected; + } + // Bare alias: accept when exactly one (provider, alias) matches. + var found: ?ModelRef = null; + for (defs.entries.items) |d| { + if (std.mem.eql(u8, d.alias, m)) { + if (found != null) return error.AmbiguousModelAlias; + found = .{ .provider = d.provider, .model = d.alias }; + } + } + return found orelse error.UnknownModelAlias; } if (self.default_model_ref) |ref| return ref; @@ -449,6 +472,10 @@ pub const Error = error{ pub const ResolveError = error{ NoModelSelected, UnknownProvider, + /// A bare `--model <alias>` matched aliases under multiple providers. + AmbiguousModelAlias, + /// A bare `--model <alias>` matched nothing in models.toml. + UnknownModelAlias, }; /// The filesystem errors that can surface while reading a config layer. @@ -715,6 +742,8 @@ fn resolve( var default_model: ?[]const u8 = null; errdefer if (default_model) |m| allocator.free(m); var default_model_ref: ?ModelRef = null; + var default_reasoning: ?[]const u8 = null; + errdefer if (default_reasoning) |r| allocator.free(r); if (root.get("defaults")) |defaults_tbl| { if (defaults_tbl.get("model")) |model_v| { if (model_v.* == .string) { @@ -722,6 +751,26 @@ fn resolve( default_model_ref = try parseModelRef(default_model.?); } } + if (defaults_tbl.get("reasoning")) |r_v| { + if (r_v.* == .string) { + default_reasoning = try allocator.dupe(u8, r_v.string); + } + } + } + + // `[tui]` settings. + var tui_editor: ?[]const u8 = null; + errdefer if (tui_editor) |e| allocator.free(e); + var tui_tools_collapsed: ?bool = null; + if (root.get("tui")) |tui_tbl| { + if (tui_tbl.get("editor")) |e_v| { + if (e_v.* == .string) { + tui_editor = try allocator.dupe(u8, e_v.string); + } + } + if (tui_tbl.get("tools_collapsed")) |tc| { + if (tc.asBool()) |b| tui_tools_collapsed = b; + } } // `[compaction]` settings. @@ -782,6 +831,9 @@ fn resolve( .auth_arena = auth_arena, .default_model = default_model, .default_model_ref = default_model_ref, + .default_reasoning = default_reasoning, + .tui_editor = tui_editor, + .tui_tools_collapsed = tui_tools_collapsed, .extensions = extensions, .ext_paths = ext_paths, .ext_rocks = ext_rocks, @@ -1929,6 +1981,43 @@ test "selectModel: no default and no providers errors" { try testing.expectError(error.NoModelSelected, cfg.selectModel(&models, null)); } +test "selectModel: bare alias override resolves when unique, errors otherwise" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + const empty = try parseDoc(a, ""); + defer empty.deinit(); + var cfg = try resolveDoc(a, &env, empty.root); + defer cfg.deinit(); + + var models = models_toml.ModelRegistry.init(a); + defer models.deinit(); + for ([_][2][]const u8{ + .{ "openai", "gpt" }, + .{ "anthropic", "sonnet" }, + .{ "openrouter", "sonnet" }, + }) |pair| { + try models.entries.append(a, .{ + .provider = try a.dupe(u8, pair[0]), + .alias = try a.dupe(u8, pair[1]), + .model = try a.dupe(u8, pair[1]), + .reasoning = .default, + .max_tokens = null, + .api_version = null, + .thinking = .disabled, + .effort = .medium, + .thinking_budget_tokens = null, + .thinking_interleaved = false, + }); + } + + const ref = try cfg.selectModel(&models, "gpt"); + try testing.expectEqualStrings("openai", ref.provider); + try testing.expectEqualStrings("gpt", ref.model); + try testing.expectError(error.AmbiguousModelAlias, cfg.selectModel(&models, "sonnet")); + try testing.expectError(error.UnknownModelAlias, cfg.selectModel(&models, "nope")); +} + test "loadFromPaths: missing files are skipped" { const a = testing.allocator; const io = testing.io; @@ -1967,6 +2056,30 @@ test "resolve: [compaction] keep_verbatim and model parse" { try testing.expectEqualStrings("haiku", cfg.compaction_model_ref.?.model); } +test "resolve: [defaults] reasoning and [tui] keys parse" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + try env.put("ANTHROPIC_API_KEY", "sk-ant-xyz"); + + const src = anthropic_env_src ++ + \\ + \\[defaults] + \\reasoning = "high" + \\ + \\[tui] + \\editor = "code -w" + \\tools_collapsed = false + ; + const doc = try parseDoc(a, src); + defer doc.deinit(); + var cfg = try resolveDoc(a, &env, doc.root); + defer cfg.deinit(); + try testing.expectEqualStrings("high", cfg.default_reasoning.?); + try testing.expectEqualStrings("code -w", cfg.tui_editor.?); + try testing.expectEqual(false, cfg.tui_tools_collapsed.?); +} + test "resolve: [compaction] absent leaves defaults null" { const a = testing.allocator; var env = emptyEnv(a); diff --git a/src/extension_loader.zig b/src/extension_loader.zig index 1c29ceb..25c01e3 100644 --- a/src/extension_loader.zig +++ b/src/extension_loader.zig @@ -32,6 +32,15 @@ //! policy), call `entry.activate()`. //! //! Symlinks: followed normally. Dotfiles and `_`-prefixed files: skipped. +//! +//! ## Lifetime contract (extension authors, read this) +//! +//! The Lua state lives exactly as long as the session. `/new` and `/resume` +//! tear the interpreter down and boot a fresh one through this same loader — +//! entry evaluation and `activate()` run again, against the new session's +//! agent. Module-global registries and other Lua-side state are therefore +//! per-session by construction; nothing survives a session switch except +//! what an extension itself persisted outside the VM. const std = @import("std"); const panto = @import("panto"); diff --git a/src/lua_runtime.zig b/src/lua_runtime.zig index e2b7ec8..7363281 100644 --- a/src/lua_runtime.zig +++ b/src/lua_runtime.zig @@ -1,11 +1,14 @@ -//! Long-lived Lua runtime, registered with libpanto as a single +//! Session-lived Lua runtime, registered with libpanto as a single //! `ToolSource`. //! //! This replaces the per-call `lua_State` model of phase 3 (LuaTool + -//! LuaStatePool). The CLI maintains exactly one `lua_State` for its -//! entire lifetime. Every extension is loaded into it once; extension -//! top-level code runs exactly once at startup. Tool handlers are -//! stored in the Lua registry and looked up by tool name on each call. +//! LuaStatePool). The CLI maintains exactly one `lua_State` per SESSION: +//! the runtime's lifetime IS the session's, and `/new`//`/resume` tear it +//! down and boot a fresh one through the startup path (see the lifetime +//! contract in `extension_loader.zig`). Every extension is loaded into it +//! once; extension top-level code runs exactly once at session start. Tool +//! handlers are stored in the Lua registry and looked up by tool name on +//! each call. //! //! libpanto delivers all tool calls targeting Lua-defined tools in one //! `invoke_batch` per turn, on a single thread (see diff --git a/src/luarocks_runtime.zig b/src/luarocks_runtime.zig index 96b2e37..1611778 100644 --- a/src/luarocks_runtime.zig +++ b/src/luarocks_runtime.zig @@ -61,6 +61,19 @@ pub const LuarocksRuntime = struct { self.layout.deinit(); self.allocator.destroy(self); } + + /// Wire one `lua_State` with the per-state half of the bootstrap: the + /// embedded luarocks searcher, the injected `luarocks.core.hardcoded`, + /// and the tree's `package.path`/`package.cpath` entries. `bootstrap` + /// runs this on its own state; each per-session interpreter (whose + /// lifetime is the session — see `extension_loader.zig`) gets the same + /// wiring so `require` resolves the staged `panto.so` and installed + /// rocks. Disk-side staging is process-level and NOT repeated here. + pub fn attachState(self: *LuarocksRuntime, L: *c.lua_State) !void { + try installEmbeddedSearcher(L, &self.modules); + try injectHardcoded(L, self.layout); + try configurePackagePaths(self.allocator, L, self.layout); + } }; /// Errors surfaced by the bootstrap pipeline. The `Luarocks*` variants @@ -164,9 +177,7 @@ pub fn bootstrap( self.modules.putAssumeCapacityNoClobber(entry.name, entry.contents); } - try installEmbeddedSearcher(L, &self.modules); - try injectHardcoded(L, layout); - try configurePackagePaths(allocator, L, layout); + try self.attachState(L); // Reconcile the batteries manifest. This may take a while on a // fresh install; subsequent runs no-op. Runs in-process — we @@ -457,7 +468,7 @@ const default_base_config = \\# account_id_jwt_claim = "https://api.openai.com/auth" \\ \\# Tool/extension availability (glob patterns). Empty allow = allow all. - \\# [tools] + \\# [extensions] \\# allow = ["std.*"] \\# deny = [] \\ diff --git a/src/main.zig b/src/main.zig index 02c0045..09b1fad 100644 --- a/src/main.zig +++ b/src/main.zig @@ -17,6 +17,7 @@ const glob = @import("glob.zig"); const system_prompt = @import("system_prompt.zig"); const command = @import("command.zig"); const command_compaction = @import("compaction.zig"); +const builtin_commands = @import("builtin_commands.zig"); const debug_log = @import("debug_log.zig"); /// Route the process-wide log stream. `PANTO_DEBUG!=0` redirects it to a @@ -40,6 +41,10 @@ const tui_selectors = @import("tui_selectors.zig"); // `@cImport`; we re-use it here so the smoke check uses identical types. const lua = lua_bridge.c; +// `fflush(NULL)` flushes every open C-stdio stream; used to drain buffered +// luarocks output before restoring fd 1 after the print-mode bootstrap. +extern "c" fn fflush(stream: ?*anyopaque) c_int; + test { // Test contract: deliberate error-path tests should not produce visible // log output. Code logs at `.err` for genuine production failures and @@ -65,6 +70,7 @@ test { _ = system_prompt; _ = command; _ = command_compaction; + _ = builtin_commands; _ = debug_log; _ = pricing_format; _ = markdown; @@ -151,6 +157,14 @@ fn reportModelError(err: anyerror, cfg: *const config_file.Config) void { "error: the selected model names an unconfigured provider.\n", .{}, ), + error.UnknownModelAlias => std.debug.print( + "error: --model matches no alias in models.toml. Use \"provider:alias\" or a known alias.\n", + .{}, + ), + error.AmbiguousModelAlias => std.debug.print( + "error: --model alias exists under multiple providers; use \"provider:alias\".\n", + .{}, + ), else => std.debug.print("error: model selection failed: {s}\n", .{@errorName(err)}), } } @@ -183,7 +197,9 @@ pub fn main(init: std.process.Init) !void { .agent => {}, } - // Parse the agent-mode flags. Currently only `--resume [<id>]`. + // Parse the agent-mode flags (`--resume [<id>]`, `-c/--continue`, + // `-m/--model`, `--effort`). Unknown flags are fatal — a typo must not + // launch the TUI. const cli_flags = try parseAgentFlags(alloc, init.minimal.args); defer cli_flags.deinit(alloc); @@ -195,6 +211,27 @@ pub fn main(init: std.process.Init) !void { // need the stdin file handle, not a buffered reader. const stdin_handle = std.Io.File.stdin().handle; + // Resolve the print-mode prompt up front so a missing prompt fails fast, + // before any bootstrap work: flag argument first, else piped stdin. + var print_prompt_owned: ?[]u8 = null; + defer if (print_prompt_owned) |p| alloc.free(p); + const print_prompt: ?[]const u8 = if (!cli_flags.print_mode) null else blk: { + if (cli_flags.print_prompt) |p| break :blk p; + if (std.c.isatty(stdin_handle) != 0) { + std.debug.print("error: -p/--print needs a prompt argument or piped stdin\n", .{}); + std.process.exit(1); + } + var stdin_buf: [4096]u8 = undefined; + var stdin_reader = std.Io.File.stdin().readerStreaming(io, &stdin_buf); + const raw = try stdin_reader.interface.allocRemaining(alloc, .unlimited); + print_prompt_owned = raw; + if (std.mem.trim(u8, raw, " \t\r\n").len == 0) { + std.debug.print("error: -p/--print got an empty prompt on stdin\n", .{}); + std.process.exit(1); + } + break :blk raw; + }; + // Resolve where this project's sessions live. var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; const cwd_n = try std.process.currentPath(io, &cwd_buf); @@ -224,15 +261,40 @@ pub fn main(init: std.process.Init) !void { // surface per-turn cost yet (that lands with the TUI frontend). // Choose the active model and assemble the provider config. - const model_ref = app_config.selectModel(&models.defs, null) catch |err| { + const model_ref = app_config.selectModel(&models.defs, cli_flags.model) catch |err| { reportModelError(err, &app_config); std.process.exit(1); }; - const provider_config = config_file.buildProviderConfig(&app_config, &models.defs, model_ref) catch |err| { + var provider_config = config_file.buildProviderConfig(&app_config, &models.defs, model_ref) catch |err| { reportModelError(err, &app_config); std.process.exit(1); }; + // Reasoning-level precedence: `--effort` beats everything; otherwise a + // `[defaults] reasoning` session default applies unless models.toml set + // an explicit per-alias knob (which `buildProviderConfig` already baked + // in). Levels are the Ctrl+R picker's labels for the provider's style. + const effort_label: ?[]const u8 = cli_flags.effort orelse blk: { + if (models.defs.get(model_ref.provider, model_ref.model)) |d| { + if (d.reasoning_explicit) break :blk null; + } + break :blk app_config.default_reasoning; + }; + if (effort_label) |lvl| { + if (tui_selectors.ReasoningOption.byLabel(provider_config.style(), lvl)) |opt| { + opt.apply(&provider_config); + } else if (cli_flags.effort != null) { + std.debug.print("error: --effort '{s}' is not a reasoning level for this provider (available:", .{lvl}); + for (tui_selectors.ReasoningOption.forStyle(provider_config.style())) |opt| { + std.debug.print(" {s}", .{opt.label}); + } + std.debug.print(")\n", .{}); + std.process.exit(1); + } else { + std.log.warn("[defaults] reasoning '{s}' has no such level on this provider; ignored", .{lvl}); + } + } + // Resolve the optional compaction-model override into a ProviderConfig. // On any resolution failure we log and fall back to no override (the // active chat model is used for compaction). @@ -277,8 +339,6 @@ pub fn main(init: std.process.Init) !void { session_store, cli_flags, session_dir, - stdout, - &stdout_file, ) catch |err| switch (err) { error.SessionNotFound, error.AmbiguousSessionId => std.process.exit(1), else => return err, @@ -300,168 +360,120 @@ pub fn main(init: std.process.Init) !void { var adopted_conversation: ?panto.Conversation = null; if (is_resume) { adopted_conversation = try session.load(); - const sid = session.info.id; - try stdout.print( - "resumed session {s} ({d} messages)\n", - .{ sid[0..@min(8, sid.len)], session.info.message_count }, - ); + if (!cli_flags.print_mode) { + const sid = session.info.id; + try stdout.print( + "resumed session {s} ({d} messages)\n", + .{ sid[0..@min(8, sid.len)], session.info.message_count }, + ); + } } - // Assemble the active configuration snapshot and build the agent now, - // before luarocks bootstrap and extension loading. The agent adopts the - // session handle (for free persistence) and the resumed conversation - // (or starts fresh). The agent owns its own tool registry (post-R1); - // extensions register their tool source onto it *after* the agent is - // built but before the first turn, so in-place registration is visible. - // System-prompt seeding/reconciliation below runs *through* the agent - // so those entries persist. + // The active configuration snapshot the agent (re-)reads each turn. var active_config: panto.Config = .{ .provider = provider_config, .compaction = compaction_cfg, }; - const agent = try panto.Agent.init( - alloc, - io, - &active_config, - session, - adopted_conversation, - ); - defer agent.deinit(); - // Spin up the long-lived Lua runtime. All Lua extensions load into - // one `lua_State`; module-global state survives across calls. The - // runtime registers with the agent as a single `ToolSource` named - // `panto-lua`. - var rt = try lua_runtime.LuaRuntime.create(alloc); - defer rt.deinit(); + // Resolve the panto data home layout up front: the agent-tree dir feeds + // system-prompt sourcing (below) and the auth dir feeds the AuthManager. + var home_layout = try panto_home.resolve(alloc, init.environ_map); + defer home_layout.deinit(); - // Bootstrap luarocks against the Lua runtime's lua_State — same - // pipeline as `panto lua` and `panto bootstrap`. After this, - // `require("luarocks.*")` works and any pinned batteries from the - // manifest are installed under the panto data home. - const luarocks_rt = try luarocks_runtime.bootstrap( - alloc, - io, - init.environ_map, - rt.L, - panto_path, - ); - defer luarocks_rt.deinit(); + // Process-level luarocks runtime: stage the rocks/agent trees once and + // keep a dedicated driver `lua_State` for luarocks work (battery + // reconcile, `extensions.rocks` installs). Session interpreters are + // disposable (/new, /resume rebuild them), so the driver state must not + // ride on one of them. `--no-extensions` skips all of it: built-ins + // only, and since even the shipped tools are extensions the agent runs + // with no tools — the escape hatch when an extension breaks startup. + var luarocks_L: ?*lua.lua_State = null; + defer if (luarocks_L) |L| lua.lua_close(L); + var luarocks_rt: ?*luarocks_runtime.LuarocksRuntime = null; + defer if (luarocks_rt) |lr| lr.deinit(); + // In print mode, shunt fd 1 to stderr for the whole extension bootstrap: + // luarocks (first-run bootstrap, rock installs) prints compile/progress + // output to stdout via C stdio, which would pollute the scripted `-p` + // output. Restored below, before the print turn writes assistant text. + const saved_stdout: ?std.c.fd_t = if (cli_flags.print_mode and !cli_flags.no_extensions) blk: { + try stdout_file.flush(); + const saved = std.c.dup(std.posix.STDOUT_FILENO); + if (saved == -1) break :blk null; + if (std.c.dup2(std.posix.STDERR_FILENO, std.posix.STDOUT_FILENO) == -1) { + _ = std.c.close(saved); + break :blk null; + } + break :blk saved; + } else null; + if (!cli_flags.no_extensions) { + const L = lua.luaL_newstate() orelse return error.OutOfMemory; + luarocks_L = L; + lua.luaL_openlibs(L); - // Source the system prompt now that the base agent tree has been - // staged to `<data home>/agent` (the bootstrap above writes the - // bundled `SYSTEM.md` there). The prompt is sourced by convention - // from SYSTEM.md / APPEND_SYSTEM.md across the base/user/project - // layers; the base layer is `luarocks_rt.layout.agent_dir`. - var sp_arena = std.heap.ArenaAllocator.init(alloc); - defer sp_arena.deinit(); - if (is_resume) { - // SYSTEM.md / APPEND_SYSTEM.md may have changed since this log was - // created; reconcile without rewriting history. - try system_prompt.reconcileResume( - sp_arena.allocator(), - io, - init.environ_map, - luarocks_rt.layout.agent_dir, - agent, - ); - } else { - // Fresh session — source and install the system prompt. - try system_prompt.seedFresh( - sp_arena.allocator(), + // Bootstrap luarocks — same pipeline as `panto lua` and `panto + // bootstrap`. After this, `require("luarocks.*")` works on the + // driver state and any pinned batteries from the manifest are + // installed under the panto data home. + luarocks_rt = try luarocks_runtime.bootstrap( + alloc, io, init.environ_map, - luarocks_rt.layout.agent_dir, - agent, + L, + panto_path, ); - } - - // Bootstrap staged `panto.so` onto the embedded VM's cpath and - // configured `package.path`/`cpath`; now wire `require('panto')` to - // the native module + the CLI's `ext` subtable. Must run before - // `installScheduler` (which writes onto the module table) and before - // any extension loads (which `require('panto')`). - try rt.installPantoModule(); - - // Hand extensions the live session agent (`panto.ext.agent`) so an - // `activate()` can act on the session directly (add_system_message…). - try rt.setExtAgent(agent); - // luv is installed (or already present) at this point; wire the - // libuv-driven coroutine scheduler before any extensions get a - // chance to register tools that might want to yield. - try rt.installScheduler(); - - // Install any user rocks (`extensions.rocks`) into the shared tree so the - // loader can `require` them below. Best-effort: a rock that fails to - // install is logged and skipped — the REPL still starts. This is where - // registry code enters, so it is the code-execution trust boundary; the - // allow/deny policy is a feature toggle, not a security gate. - for (app_config.ext_rocks) |spec| { - _ = luarocks_runtime.installRockIfMissing(luarocks_rt, alloc, io, spec.value) catch |err| { - std.log.err("extensions.rocks: failed to install '{s}': {t}", .{ spec.value, err }); - }; - } - - // Discover Lua extensions across three layers — base - // (<data home>/agent), user ($XDG_CONFIG_HOME/panto or - // $HOME/.config/panto), and project (./.panto). Project shadows - // user shadows base; tool-name collisions across surviving - // entries abort startup. - const n_ext_tools = extension_loader.discoverAndLoad( - alloc, - io, - init.environ_map, - luarocks_rt.layout.agent_dir, - rt, - &app_config.extensions, - app_config.ext_paths, - app_config.ext_rocks, - ) catch |err| { - std.log.err("extension discovery failed: {t}", .{err}); - return err; - }; - std.log.debug("extensions: {d} tool(s) registered", .{n_ext_tools}); - - if (n_ext_tools > 0) { - try agent.registerToolSource(rt.toolSource()); + // Install any user rocks (`extensions.rocks`) into the shared tree + // so extension loading can `require` them. Best-effort: a rock that + // fails to install is logged and skipped — the REPL still starts. + // This is where registry code enters, so it is the code-execution + // trust boundary; the allow/deny policy is a feature toggle, not a + // security gate. + for (app_config.ext_rocks) |spec| { + _ = luarocks_runtime.installRockIfMissing(luarocks_rt.?, alloc, io, spec.value) catch |err| { + std.log.err("extensions.rocks: failed to install '{s}': {t}", .{ spec.value, err }); + }; + } } - // Resolve the compaction system prompt (COMPACTION.md across layers, - // last wins; built-in default otherwise) and arm automatic compaction. - // Owned by `sp_arena`, which lives for the whole REPL. - const compaction_prompt = try system_prompt.resolveCompaction( - sp_arena.allocator(), - io, - init.environ_map, - luarocks_rt.layout.agent_dir, - ); - // The compaction prompt is resolved after extension load; set it on the - // config the agent re-reads each turn (visible before the first turn). - active_config.compaction.compaction_prompt = compaction_prompt; + // Base layer for system-prompt sourcing. With extensions on, the + // bootstrap above staged the bundled agent tree (incl. SYSTEM.md) there; + // with `--no-extensions` the path may not exist — missing prompt files + // are simply skipped, and user/project layers still apply. + const agent_dir = if (luarocks_rt) |lr| lr.layout.agent_dir else home_layout.agent_dir; - // Build the slash-command registry and register builtins, then append - // any commands declared by Lua extensions (harvested at load time). + // Slash-command registry: process-lifetime. Builtins register once + // here; Lua-extension commands are harvested into it by each world + // build (and removed again at world teardown). var cmd_registry = command.Registry.init(alloc); defer cmd_registry.deinit(); try command_compaction.register(&cmd_registry); + try builtin_commands.register(&cmd_registry); - // Append slash commands declared by Lua extensions via - // `panto.ext.register_command`. A name collision with a builtin (or - // between two extensions) surfaces as `error.DuplicateCommand` and - // aborts startup, matching the tool-name collision policy. - for (rt.commandList()) |lua_cmd| { - cmd_registry.registerLua( - lua_cmd.name, - lua_cmd.description, - lua_cmd.handler_ref, - ) catch |err| { - std.log.err( - "lua: failed to register command '/{s}': {t}", - .{ lua_cmd.name, err }, - ); - return err; - }; + // Build the per-session world — Agent + Lua interpreter + derived + // system/compaction prompts — through the exact code path `/new` and + // `/resume` rebuild it (see `SessionWorld`). Declared before the TUI so + // the App tears down first (the teardown ordering contract below). + var world: SessionWorld = .{ + .alloc = alloc, + .io = io, + .environ = init.environ_map, + .app_config = &app_config, + .active_config = &active_config, + .agent_dir = agent_dir, + .luarocks_rt = luarocks_rt, + .registry = &cmd_registry, + }; + defer world.deinit(); + try world.build(session, adopted_conversation); + const agent = world.agent.?; + + // Point fd 1 back at the real stdout (see the dup above). Drain the C + // stdio buffer first so buffered luarocks output flushes to stderr, not + // into the restored stdout at exit. + if (saved_stdout) |fd| { + _ = fflush(null); + _ = std.c.dup2(fd, std.posix.STDOUT_FILENO); + _ = std.c.close(fd); } // Command output is captured into an in-memory buffer (the command @@ -477,12 +489,47 @@ pub fn main(init: std.process.Init) !void { .agent = agent, .stdout = &cmd_capture.writer, .stdout_file = &stdout_file, - .compaction_prompt = compaction_prompt, + .compaction_prompt = world.compaction_prompt, .provider_name = banner_provider_initial, .model_name = banner_model_initial, - .lua_rt = rt, + .lua_rt = world.rt, + .session_store = session_store, + // `.tui` is installed after TUI bring-up below; null in print mode. }; + // Turn-time auth resolution. The manager resolves the active provider's + // named auth session (api_key: no-op; oauth_device: refresh/exchange or an + // interactive device login) into the live config before each turn. + var auth_mgr = auth_manager.AuthManager.init( + alloc, + io, + panto.httpClient(), + home_layout.auth_dir, + &app_config, + ); + defer auth_mgr.deinit(); + + // -- Print mode (-p/--print) ------------------------------------------- + // + // One agent turn (tools and all) without the TUI: assistant text to + // stdout, errors to stderr, exit 0/1. Session persistence is identical to + // the TUI path — the agent owns it. + if (cli_flags.print_mode) { + runPrintTurn( + alloc, + agent, + &active_config, + model_ref.provider, + &auth_mgr, + &stdout_file, + print_prompt.?, + ) catch |err| { + std.debug.print("error: turn failed: {t}\n", .{err}); + std.process.exit(1); + }; + return; + } + // -- TUI bring-up ------------------------------------------------------ // // Full replacement of the print REPL (version control is the safety net). @@ -531,37 +578,42 @@ pub fn main(init: std.process.Init) !void { ); defer app.deinit(); - // Wire the Lua extension UI event bridge to the App's event bus: this - // registers every `panto.ext.on(...)` handler (harvested at extension - // load time) into the bus, in registration order, so extensions can - // wrap/replace built-in components and a Lua `panto.ext.emit(...)` can - // drive the same bus. With no Lua handlers this is a no-op. - try rt.eventBridge().attachBus(app.eventBus()); + // `[tui] tools_collapsed`: the Ctrl+O collapse state's starting value. + if (app_config.tui_tools_collapsed) |c| app.tools_collapsed = c; - // Install the override-release hook so a Lua-backed override that is - // SUPERSEDED by a later mid-stream swap (e.g. a `tool_details` handler - // replacing the `tool (?)` default's prior override) has its luaL_ref + - // RenderCache freed. Without this, each swapped Lua component would leak - // for the life of the runtime. The hook recognizes a bridged component by - // its vtable identity and ignores native components (see - // `EventBridge.releaseOverride`). + // Footer: the model's context window (for the fractional context + // readout; null = raw count). The session short-id is stamped by + // `rebuild_host.wire()` below. + if (models.defs.get(model_ref.provider, model_ref.model)) |d| { + footer.setContextWindow(d.context_window); + } + + // TUI-steering slash commands (/model, /reasoning, /new, /quit) reach + // the live App through the command Context (opaque, same idiom as + // `lua_rt`); the pointer is stable for the whole run loop. + cmd_ctx.tui = &app; + + // The Lua extension UI event bridge (bus handlers + override-release + // hook) is wired to the App by `rebuild_host.wire()` below — the same + // call the mid-run /new//resume rebuild uses. // // TEARDOWN ORDERING CONTRACT (load-bearing — do not reorder these decls): - // `app` is declared AFTER `rt`, so `defer app.deinit()` runs BEFORE - // `defer rt.deinit()` (defers are LIFO). The bridge (owned by `rt`) - // therefore outlives the App's teardown. This matters because the - // release hook below points into the bridge: if the bridge were freed - // first, any later hook invocation would be a use-after-free. - // It is safe today because `App.deinit` frees only its own default - // `kind` boxes and NEVER invokes `override_release_fn` — the surviving - // (non-superseded) Lua overrides are freed by `EventBridge.deinit` when - // `rt.deinit()` runs. The hook fires only during live mid-stream swaps, - // while both App and bridge are alive. If you ever make `App.deinit` - // call the release hook, or move `rt` to outlive `app`, revisit this. - app.setOverrideRelease( - @ptrCast(rt.eventBridge()), - lua_event_bridge.EventBridge.releaseOverrideThunk, - ); + // `app` is declared AFTER `world`, so `defer app.deinit()` runs BEFORE + // `defer world.deinit()` (defers are LIFO). The bridge (owned by the + // world's Lua runtime) therefore outlives the App's teardown. This + // matters because the release hook points into the bridge: if the + // bridge were freed first, any later hook invocation would be a + // use-after-free. It is safe today because `App.deinit` frees only its + // own default `kind` boxes and NEVER invokes `override_release_fn` — + // the surviving (non-superseded) Lua overrides are freed by + // `EventBridge.deinit` when the world tears its runtime down. The hook + // fires only during live mid-stream swaps, while both App and bridge + // are alive. A mid-run `/new`//`/resume` rebuild inverts the order (the + // runtime dies while the App lives), which is why `RebuildHost.rebuild` + // calls `app.resetSessionUi()` — dropping every transcript entry, bus + // handler, and this hook — BEFORE `world.deinit()`. If you ever make + // `App.deinit` call the release hook, or move the world to outlive + // `app`, revisit this. const Flusher = struct { fn flush(ctx: *anyopaque) void { @@ -578,7 +630,7 @@ pub fn main(init: std.process.Init) !void { ); defer alloc.free(model_label); - // Runtime model/reasoning selectors (Ctrl+M / Ctrl+R). Live-session only: + // Runtime model/reasoning selectors (Ctrl+T / Ctrl+R). Live-session only: // a pick rebuilds the provider config and pushes it to the agent via // `setConfig`; nothing is written back to config.toml. Borrows the // long-lived `app_config`, `models.defs`, and `active_config`. @@ -593,28 +645,24 @@ pub fn main(init: std.process.Init) !void { model_label, ); defer selector_ctrl.deinit(); + // Command-completion popup (leading-`/` typeahead) and the `/resume` + // session picker read these; both are stable for the loop's lifetime. + selector_ctrl.registry = &cmd_registry; + selector_ctrl.session_store = session_store; app.setSelectors(selector_ctrl); + // In-place session switching (/new, /resume): the host tears the world + // down, rebuilds it through the boot path above, and repoints every + // borrower of the old agent/runtime. Installed after all borrowers exist. + var rebuild_host = RebuildHost{ .world = &world, .app = &app, .cmd_ctx = &cmd_ctx }; + try rebuild_host.wire(); + app.setSessionRebuild(&rebuild_host, RebuildHost.rebuild); + // The render engine writes through a buffered file writer; flush after the // loop so the final frame and teardown sequences reach the terminal. defer tui_file.interface.flush() catch {}; - // Turn-time auth resolution. The manager resolves the active provider's - // named auth session (api_key: no-op; oauth_device: refresh/exchange or an - // interactive device login) into the live config before each turn. - var home_layout = try panto_home.resolve(alloc, init.environ_map); - defer home_layout.deinit(); - var auth_mgr = auth_manager.AuthManager.init( - alloc, - io, - panto.httpClient(), - home_layout.auth_dir, - &app_config, - ); - defer auth_mgr.deinit(); - tui_app.runLoop(&app, &term, .{ - .agent = agent, .cmd_registry = &cmd_registry, .cmd_ctx = &cmd_ctx, .cmd_capture = &cmd_capture, @@ -622,6 +670,7 @@ pub fn main(init: std.process.Init) !void { .cwd = cwd, .io = io, .environ = init.environ_map, + .editor_override = app_config.tui_editor, .auth_mgr = &auth_mgr, }) catch |err| switch (err) { // Clean user-initiated exit (Ctrl+C / Ctrl+D). Not an error. @@ -647,9 +696,23 @@ const AgentFlags = struct { /// Not present: start a new session. resume_kind: ResumeKind = .none, resume_id: ?[]const u8 = null, // owned + /// `-m/--model <provider:alias>` (or a bare alias): model override. + model: ?[]const u8 = null, // owned + /// `--effort <level>`: reasoning-level override (the Ctrl+R picker's + /// labels). Beats models.toml per-alias knobs and `[defaults] reasoning`. + effort: ?[]const u8 = null, // owned + /// `-p/--print [<prompt>]`: one-shot non-interactive mode. The prompt + /// comes from the flag's argument, or from stdin when piped. + print_mode: bool = false, + print_prompt: ?[]const u8 = null, // owned + /// `--no-extensions`: skip the Lua/luarocks runtime entirely (see bootstrap in main()). + no_extensions: bool = false, pub fn deinit(self: AgentFlags, alloc: std.mem.Allocator) void { if (self.resume_id) |id| alloc.free(id); + if (self.model) |m| alloc.free(m); + if (self.effort) |e| alloc.free(e); + if (self.print_prompt) |p| alloc.free(p); } }; @@ -659,44 +722,289 @@ fn parseAgentFlags(alloc: std.mem.Allocator, args: std.process.Args) !AgentFlags var flags: AgentFlags = .{}; errdefer flags.deinit(alloc); + // Materialize argv so `--resume`'s optional-id lookahead can decline a + // `-`-leading token without consuming it (the Args iterator can't rewind). var it = args.iterate(); defer it.deinit(); _ = it.next(); // argv[0] + var argv: std.ArrayList([]const u8) = .empty; + defer argv.deinit(alloc); + while (it.next()) |a| try argv.append(alloc, a); - while (it.next()) |a| { + var i: usize = 0; + while (i < argv.items.len) : (i += 1) { + const a = argv.items[i]; if (std.mem.eql(u8, a, "--resume")) { - // Peek at the next arg. If it exists and doesn't start with `-`, - // treat it as a session id (or prefix). - const next = it.next(); - if (next) |id| { - if (id.len > 0 and id[0] != '-') { - flags.resume_kind = .by_id; - flags.resume_id = try alloc.dupe(u8, id); - continue; - } else { - // Not an id; rewind by treating it as a separate flag. - // The Args API doesn't support rewind, so handle inline. - flags.resume_kind = .most_recent; - if (std.mem.eql(u8, id, "--resume")) { - // back-to-back --resume; second resets, fine. - continue; - } - // Otherwise, fall through and process this token as a flag. - // (Currently we don't have other flags; ignore unknowns.) - std.log.warn("panto: ignoring unknown argument '{s}'", .{id}); - continue; - } + flags.resume_kind = .most_recent; + // An id follows only if the next token doesn't look like a flag. + if (i + 1 < argv.items.len and argv.items[i + 1].len > 0 and argv.items[i + 1][0] != '-') { + if (flags.resume_id) |old| alloc.free(old); + flags.resume_id = try alloc.dupe(u8, argv.items[i + 1]); + flags.resume_kind = .by_id; + i += 1; } + continue; + } + if (std.mem.eql(u8, a, "-c") or std.mem.eql(u8, a, "--continue")) { flags.resume_kind = .most_recent; continue; } - // Future agent-mode flags would land here. Unknown args are tolerated - // (the user might be passing something we don't recognize yet). + if (std.mem.eql(u8, a, "-m") or std.mem.eql(u8, a, "--model")) { + if (i + 1 >= argv.items.len) { + std.debug.print("error: {s} requires a <provider:alias> argument\n", .{a}); + std.process.exit(1); + } + if (flags.model) |old| alloc.free(old); + flags.model = try alloc.dupe(u8, argv.items[i + 1]); + i += 1; + continue; + } + if (std.mem.eql(u8, a, "--effort")) { + if (i + 1 >= argv.items.len) { + std.debug.print("error: --effort requires a <level> argument\n", .{}); + std.process.exit(1); + } + if (flags.effort) |old| alloc.free(old); + flags.effort = try alloc.dupe(u8, argv.items[i + 1]); + i += 1; + continue; + } + if (std.mem.eql(u8, a, "-p") or std.mem.eql(u8, a, "--print")) { + flags.print_mode = true; + // An inline prompt follows only if the next token doesn't look + // like a flag; otherwise the prompt comes from piped stdin. + if (i + 1 < argv.items.len and argv.items[i + 1].len > 0 and argv.items[i + 1][0] != '-') { + if (flags.print_prompt) |old| alloc.free(old); + flags.print_prompt = try alloc.dupe(u8, argv.items[i + 1]); + i += 1; + } + continue; + } + if (std.mem.eql(u8, a, "--no-extensions")) { + flags.no_extensions = true; + continue; + } + // Future agent-mode flags land here. + std.debug.print("error: unknown argument '{s}' (run 'panto --help')\n", .{a}); + std.process.exit(1); } return flags; } // ----------------------------------------------------------------------------- +// SessionWorld — per-session boot wiring +// ----------------------------------------------------------------------------- + +/// Everything whose lifetime IS the session: the Agent, the Lua interpreter, +/// and the arena feeding the derived system/compaction prompts. `/new` and +/// `/resume` are "quit and relaunch, minus the process": tear the world down +/// and `build` a fresh one through this exact code path — the same one boot +/// uses. Process-level state (config/models, HTTP client, session store, +/// luarocks tree + its driver `lua_State`, the TUI, auth) lives outside and +/// is borrowed via the fields below. +const SessionWorld = struct { + // Borrowed process-level dependencies (valid for the process lifetime). + alloc: std.mem.Allocator, + io: std.Io, + environ: *const std.process.Environ.Map, + app_config: *const config_file.Config, + /// The live config snapshot the agent reads; `build` stamps the + /// re-resolved compaction prompt into it. + active_config: *panto.Config, + /// Base layer for system-prompt sourcing and extension discovery. + agent_dir: []const u8, + /// Non-null iff extensions are on (`--no-extensions` leaves it null and + /// the world builds with a null Lua runtime). Supplies the per-state + /// luarocks wiring (`attachState`) for each fresh interpreter. + luarocks_rt: ?*luarocks_runtime.LuarocksRuntime, + /// The process-lifetime slash-command registry. Builtins register once + /// (in `main`); `build` harvests Lua commands into it and `deinit` + /// removes exactly those again, so a rebuild never double-registers. + registry: *command.Registry, + + // Owned per-session state. Null/empty between `deinit` and `build`, so + // a failed rebuild leaves a world that is still safe to `deinit`. + agent: ?*panto.Agent = null, + rt: ?*lua_runtime.LuaRuntime = null, + sp_arena: ?std.heap.ArenaAllocator = null, + /// Resolved compaction prompt (owned by `sp_arena`). + compaction_prompt: []const u8 = "", + + /// Build the world around `session`, adopting `conv` when resuming + /// (boot's `--resume` and the `/resume` command both load the + /// conversation from the store first and hand it here; `Agent.init` + /// marks adopted history as already persisted, so nothing is re-written + /// to disk). On error nothing is left allocated. + fn build(self: *SessionWorld, session: panto.Session, conv: ?panto.Conversation) !void { + std.debug.assert(self.agent == null and self.rt == null and self.sp_arena == null); + const is_resume = conv != null; + + const agent = try panto.Agent.init(self.alloc, self.io, self.active_config, session, conv); + errdefer agent.deinit(); + + // One fresh interpreter per session: extensions may keep module- + // global state, and its lifetime contract is exactly the session's + // (see `extension_loader.zig`). + var rt: ?*lua_runtime.LuaRuntime = null; + errdefer if (rt) |r| r.deinit(); + if (self.luarocks_rt) |lr| { + const r = try lua_runtime.LuaRuntime.create(self.alloc); + rt = r; + // Per-state luarocks wiring (embedded searcher, hardcoded + // config, package.path/cpath) so `require` resolves the staged + // `panto.so` and installed rocks. Disk staging happened once at + // process bootstrap. + try lr.attachState(r.L); + } + + // Source the system prompt (SYSTEM.md / APPEND_SYSTEM.md across the + // base/user/project layers): seed a fresh session, or reconcile a + // resumed log without rewriting history. + var arena = std.heap.ArenaAllocator.init(self.alloc); + errdefer arena.deinit(); + if (is_resume) { + try system_prompt.reconcileResume(arena.allocator(), self.io, self.environ, self.agent_dir, agent); + } else { + try system_prompt.seedFresh(arena.allocator(), self.io, self.environ, self.agent_dir, agent); + } + + errdefer self.registry.clearLua(); + if (rt) |r| { + // Wire `require('panto')` to the native module + `ext` subtable, + // hand extensions the live session agent, then install the luv + // scheduler — in that order (see each fn's doc). + try r.installPantoModule(); + try r.setExtAgent(agent); + try r.installScheduler(); + + // Discover + activate Lua extensions across the base/user/ + // project layers; register their tools as one source. + const n_ext_tools = extension_loader.discoverAndLoad( + self.alloc, + self.io, + self.environ, + self.agent_dir, + r, + &self.app_config.extensions, + self.app_config.ext_paths, + self.app_config.ext_rocks, + ) catch |err| { + std.log.err("extension discovery failed: {t}", .{err}); + return err; + }; + std.log.debug("extensions: {d} tool(s) registered", .{n_ext_tools}); + if (n_ext_tools > 0) try agent.registerToolSource(r.toolSource()); + + // Slash commands declared via `panto.ext.register_command`. A + // name collision with a builtin (or between two extensions) + // aborts, matching the tool-name collision policy. + for (r.commandList()) |lua_cmd| { + self.registry.registerLua( + lua_cmd.name, + lua_cmd.description, + lua_cmd.handler_ref, + ) catch |err| { + std.log.err( + "lua: failed to register command '/{s}': {t}", + .{ lua_cmd.name, err }, + ); + return err; + }; + } + } + + // Resolve the compaction system prompt (COMPACTION.md across layers, + // last wins; built-in default otherwise) and stamp it into the + // config the agent re-reads each turn. + const compaction_prompt = try system_prompt.resolveCompaction( + arena.allocator(), + self.io, + self.environ, + self.agent_dir, + ); + self.active_config.compaction.compaction_prompt = compaction_prompt; + + self.agent = agent; + self.rt = rt; + self.sp_arena = arena; + self.compaction_prompt = compaction_prompt; + } + + /// Tear the session down: Lua commands out of the registry, then the + /// Lua interpreter (extension state, tool source, event bridge), the + /// prompt arena, and finally the agent — the same relative order the + /// process teardown always used (runtime dies before agent). Safe on a + /// never-built or build-failed world. + fn deinit(self: *SessionWorld) void { + self.registry.clearLua(); + if (self.rt) |r| r.deinit(); + self.rt = null; + if (self.sp_arena) |*a| a.deinit(); + self.sp_arena = null; + self.active_config.compaction.compaction_prompt = null; + self.compaction_prompt = ""; + if (self.agent) |a| a.deinit(); + self.agent = null; + } +}; + +/// The App's session-rebuild hook (/new, /resume): quit-and-relaunch minus +/// the process. Drops all session-scoped UI state, tears down the old world, +/// builds a fresh one through the boot path, and repoints every holder of +/// the old agent/runtime pointers. Commands dispatch only between turns +/// (`tui_app.handleSubmittedLine` is only reached from the idle input pump), +/// so no stream is live across this. +/// +/// A mid-rebuild failure leaves no half-dead session: whether it fails +/// before the old world's teardown (`resetSessionUi`) or after, +/// `tui_app.App.rebuildSession` notes the failure in the transcript and +/// surfaces `error.SessionRebuildFailed`, which exits the TUI loop cleanly +/// (process restart is the recovery — no partial rollback). +const RebuildHost = struct { + world: *SessionWorld, + app: *tui_app.App, + cmd_ctx: *command.Context, + + fn rebuild(ctx: *anyopaque, session: panto.Session, conv: ?panto.Conversation) anyerror!void { + const self: *RebuildHost = @ptrCast(@alignCast(ctx)); + // Transcript entries and bus handlers may reference Lua-bridge + // memory owned by the outgoing runtime; drop them BEFORE that + // runtime dies (the mid-run mirror of the boot-time teardown + // ordering contract, which keeps the bridge alive through the + // App's teardown). + try self.app.resetSessionUi(); + self.world.deinit(); + try self.world.build(session, conv); + try self.wire(); + } + + /// Repoint every borrower of the world's agent/runtime at the CURRENT + /// world: command Context, Lua event bridge (bus handlers + + /// override-release hook), selector controller, footer. Called once at + /// boot (after all borrowers exist) and again after every in-place + /// rebuild — any new borrower gets wired here, in one place. + fn wire(self: *RebuildHost) !void { + const agent = self.world.agent.?; + self.cmd_ctx.agent = agent; + self.cmd_ctx.lua_rt = self.world.rt; + self.cmd_ctx.compaction_prompt = self.world.compaction_prompt; + if (self.world.rt) |r| { + try r.eventBridge().attachBus(self.app.eventBus()); + self.app.setOverrideRelease( + @ptrCast(r.eventBridge()), + lua_event_bridge.EventBridge.releaseOverrideThunk, + ); + } + if (self.app.selectors) |ctrl| { + ctrl.agent = agent; + ctrl.resetSessionUsage(); + } + self.app.footer.setSessionId(agent.sessionId()) catch {}; + self.app.footer.setContextTokens(null); + } +}; + +// ----------------------------------------------------------------------------- // Session bootstrap // ----------------------------------------------------------------------------- @@ -704,27 +1012,141 @@ fn parseAgentFlags(alloc: std.mem.Allocator, args: std.process.Args) !AgentFlags /// Fresh sessions are created on demand; resume resolves by id or picks the /// most recent. The returned `Session` owns its `info` (freed via the /// agent's `deinit`, which adopts it). +/// Diagnostics go to stderr: in `-p` print mode stdout is the assistant +/// text a script captures, and stderr is right interactively too. fn openSession( store: panto.SessionStore, flags: AgentFlags, session_dir: []const u8, - stdout: *std.Io.Writer, - stdout_file: *std.Io.File.Writer, ) !panto.Session { switch (flags.resume_kind) { .none => return store.create(), .most_recent => { if (try store.latest()) |sess| return sess; - try stdout.print("no sessions to resume; starting fresh.\n", .{}); - try stdout_file.flush(); + std.debug.print("no sessions to resume; starting fresh.\n", .{}); return store.create(); }, .by_id => { const id = flags.resume_id.?; - if (try store.resolve(id)) |sess| return sess; - try stdout.print("error: no session matching '{s}' in {s}\n", .{ id, session_dir }); - try stdout_file.flush(); + const resolved = store.resolve(id) catch |err| switch (err) { + error.AmbiguousSessionId => { + var buf: [256]u8 = undefined; + const stderr = std.debug.lockStderr(&buf); + defer std.debug.unlockStderr(); + const w = &stderr.file_writer.interface; + w.writeAll("error: ") catch {}; + builtin_commands.printAmbiguousSessions(store, id, w) catch {}; + return error.AmbiguousSessionId; + }, + else => return err, + }; + if (resolved) |sess| return sess; + std.debug.print("error: no session matching '{s}' in {s}\n", .{ id, session_dir }); return error.SessionNotFound; }, } } + +// ----------------------------------------------------------------------------- +// Print mode (-p/--print): one-shot plain-text turn driver +// ----------------------------------------------------------------------------- + +/// Routes stream events to a writer, printing only assistant Text-block +/// deltas (thinking and tool traffic are dropped) and ensuring the output +/// ends each text block on a newline. +const TextPrinter = struct { + out: *std.Io.Writer, + /// Whether the currently open block is a Text block. + in_text: bool = false, + last_byte: u8 = '\n', + + fn onEvent(self: *TextPrinter, ev: panto.Event) !void { + switch (ev) { + .block_start => |bs| self.in_text = bs.block_type == .Text, + .content_delta => |cd| { + if (self.in_text) { + try self.out.writeAll(cd.delta); + if (cd.delta.len > 0) self.last_byte = cd.delta[cd.delta.len - 1]; + } + }, + .block_complete => { + if (self.in_text and self.last_byte != '\n') { + try self.out.writeAll("\n"); + self.last_byte = '\n'; + } + self.in_text = false; + }, + else => {}, + } + } +}; + +/// Drive one agent turn without the TUI: assistant text to `stdout`, errors +/// to the caller. Mirrors `tui_app.driveTurnBlocks`' two one-shot fallbacks +/// (adaptive-thinking rewrite, forced auth refresh). Persistence rides on the +/// agent exactly as in TUI mode. +fn runPrintTurn( + alloc: std.mem.Allocator, + agent: *panto.Agent, + active_config: *panto.Config, + provider_name: []const u8, + auth_mgr: *auth_manager.AuthManager, + stdout_file: *std.Io.File.Writer, + prompt: []const u8, +) !void { + const stdout = &stdout_file.interface; + // No presenter: an unauthenticated oauth provider fails with + // LoginRequired rather than starting an interactive device login. + try auth_mgr.resolveInto(active_config, provider_name, false, null); + agent.setConfig(active_config); + + var blocks = [_]panto.ContentBlock{ + .{ .Text = try panto.textualBlockFromSlice(alloc, prompt) }, + }; + var stream = try agent.run(.{ .blocks = &blocks }); + defer stream.deinit(); + + var printer: TextPrinter = .{ .out = stdout }; + var fallback_used = false; + var auth_retry_used = false; + while (true) { + const ev = stream.next() catch |err| { + if (!fallback_used and err == error.ProviderBadRequest and + tui_selectors.adaptiveFallback(&active_config.provider)) + { + fallback_used = true; + agent.setConfig(active_config); + try stream.reopen(); + continue; + } + if (!auth_retry_used and err == error.ProviderAuthFailed) { + auth_retry_used = true; + auth_mgr.resolveInto(active_config, provider_name, true, null) catch return err; + agent.setConfig(active_config); + try stream.reopen(); + continue; + } + return err; + }; + const e = ev orelse break; + try printer.onEvent(e); + try stdout_file.flush(); // stream text as it arrives + } + try stdout_file.flush(); +} + +test "TextPrinter prints only Text deltas, newline-terminated" { + var out = std.Io.Writer.Allocating.init(std.testing.allocator); + defer out.deinit(); + var p: TextPrinter = .{ .out = &out.writer }; + + try p.onEvent(.{ .message_start = .assistant }); + try p.onEvent(.{ .block_start = .{ .block_type = .Thinking, .index = 0 } }); + try p.onEvent(.{ .content_delta = .{ .index = 0, .delta = "pondering" } }); + try p.onEvent(.{ .block_start = .{ .block_type = .Text, .index = 1 } }); + try p.onEvent(.{ .content_delta = .{ .index = 1, .delta = "hello" } }); + try p.onEvent(.{ .content_delta = .{ .index = 1, .delta = " world" } }); + try p.onEvent(.{ .block_complete = .{ .index = 1, .block = undefined } }); + try p.onEvent(.turn_complete); + try std.testing.expectEqualStrings("hello world\n", out.written()); +} diff --git a/src/models_toml.zig b/src/models_toml.zig index 9b1b4f0..cd2de1a 100644 --- a/src/models_toml.zig +++ b/src/models_toml.zig @@ -99,6 +99,10 @@ pub const ModelDef = struct { /// TOML omitted `model`. model: []const u8, reasoning: ReasoningEffort, + /// True when the TOML explicitly set a reasoning knob (`reasoning`, + /// `thinking`, or `effort`) for this alias. An explicit per-alias knob + /// wins over the config.toml `[defaults] reasoning` session default. + reasoning_explicit: bool = false, /// Approximate total input context window, for picker/UI metadata. context_window: ?u32 = null, /// Per-request output token cap; null = use the provider/library default. @@ -410,6 +414,7 @@ fn ingestModel( .alias = alias_copy, .model = model_copy, .reasoning = reasoning, + .reasoning_explicit = v.get("reasoning") != null or v.get("thinking") != null or has_effort, .context_window = context_window, .max_tokens = max_tokens, .api_version = api_version_copy, @@ -476,12 +481,14 @@ test "parseInto: model defs carry wire name and knobs, keyed by provider.alias" const sonnet = models.defs.get("anthropic", "sonnet").?; try testing.expectEqualStrings("claude-sonnet-4-20250514", sonnet.model); try testing.expectEqual(ReasoningEffort.high, sonnet.reasoning); + try testing.expect(sonnet.reasoning_explicit); try testing.expectEqual(@as(?u32, 200000), sonnet.context_window); try testing.expectEqual(@as(?u32, 8192), sonnet.max_tokens); const gpt = models.defs.get("openai", "gpt").?; try testing.expectEqualStrings("gpt-4o", gpt.model); try testing.expectEqual(ReasoningEffort.default, gpt.reasoning); + try testing.expect(!gpt.reasoning_explicit); try testing.expectEqual(@as(?u32, null), gpt.max_tokens); // Pricing keyed on the wire model id. diff --git a/src/subcommand.zig b/src/subcommand.zig index 1372d72..46df8e0 100644 --- a/src/subcommand.zig +++ b/src/subcommand.zig @@ -30,6 +30,7 @@ const panto_home = @import("panto_home.zig"); const panto = @import("panto"); const c = lua_bridge.c; +const versions = @import("versions"); pub const Action = enum { /// Continue with the default agent REPL. @@ -93,6 +94,13 @@ pub fn dispatch( try runAuthSubcommand(allocator, io, environ_map, &it); return .done; } + if (std.mem.eql(u8, sub, "--version")) { + var buffer: [64]u8 = undefined; + var stdout_file = std.Io.File.stdout().writer(io, &buffer); + try stdout_file.interface.print("panto {s}\n", .{versions.panto_version}); + try stdout_file.flush(); + return .done; + } if (std.mem.eql(u8, sub, "--help") or std.mem.eql(u8, sub, "-h") or std.mem.eql(u8, sub, "help")) { try printHelp(io); return .done; @@ -111,6 +119,23 @@ fn printHelp(io: Io) !void { \\ panto Start a new conversation. \\ panto --resume Resume the most recent conversation in this directory. \\ panto --resume <id> Resume the conversation whose id begins with <id>. + \\ panto -c, --continue Alias for bare --resume. + \\ panto -p, --print [<prompt>] + \\ One-shot non-interactive mode: run one agent turn, + \\ print the assistant text, exit 0/1. The prompt comes + \\ from the argument or piped stdin. Composes with + \\ --resume/-c and -m. + \\ panto -m, --model <provider:alias> + \\ Override the model for this run. A bare alias is + \\ accepted when it is unique across providers. + \\ panto --effort <level> Override the reasoning level for this run (the + \\ Ctrl+R picker's levels, e.g. low/medium/high). + \\ Beats models.toml knobs and [defaults] reasoning. + \\ panto --no-extensions Skip the Lua/luarocks runtime entirely. Built-ins + \\ only; since the shipped tools are extensions too, + \\ the agent runs with no tools. Escape hatch when an + \\ extension breaks startup; fast -p in CI. + \\ panto --version Print the panto version. \\ panto sessions List saved sessions for this directory. \\ panto models sync Fetch models.dev and rebuild the base models.toml. \\ panto auth status Show configured auth sessions and login state. @@ -128,11 +153,12 @@ fn printHelp(io: Io) !void { \\ $XDG_DATA_HOME/panto/config.toml (base; auto-generated) \\ $XDG_CONFIG_HOME/panto/config.toml (user) \\ ./.panto/config.toml (project) - \\ Define providers under [providers.<name>], pick a default with - \\ [defaults] model = "<provider>:<alias>", and gate extensions with - \\ [extensions] allow/deny globs (plus [extensions] paths/rocks to add - \\ sources). Model aliases (wire name, reasoning, max_tokens, pricing) - \\ live in models.toml. + \\ Define providers under [providers.<name>], pick defaults with + \\ [defaults] model = "<provider>:<alias>" and reasoning = "<level>", + \\ set TUI knobs under [tui] (editor = "...", tools_collapsed = bool), + \\ and gate extensions with [extensions] allow/deny globs (plus + \\ [extensions] paths/rocks to add sources). Model aliases (wire name, + \\ reasoning, max_tokens, pricing) live in models.toml. \\ \\Environment: \\ OPENAI_API_KEY, ANTHROPIC_API_KEY Consumed by the default providers. @@ -335,12 +361,13 @@ fn runUpdateSubcommand( // `panto sessions` // --------------------------------------------------------------------------- -/// List sessions for the current working directory. +/// List sessions for the current working directory, newest-modified first: /// -/// Output format (one session per line): -/// <short-id> <created> <message-count> messages +/// ID MODIFIED MSGS MODEL LAST MESSAGE +/// 0197c2a4 2026-07-01 14:32 18 claude-sonnet-4 fix the segfault in… /// -/// where `<short-id>` is the first 8 hex chars of the session UUIDv7. +/// IDs are shortened to the shortest prefix (≥8 chars) that is unambiguous +/// across the listing, so any printed ID pastes into `--resume` directly. fn runSessionsSubcommand( allocator: Allocator, io: Io, @@ -365,32 +392,86 @@ fn runSessionsSubcommand( const stdout = &stdout_file.interface; if (infos.len == 0) { - try stdout.print("no sessions for {s}\n", .{cwd}); + try stdout.print("no sessions for {s}\n{s}\n", .{ cwd, session_dir }); try stdout_file.flush(); return; } + // Shortest prefix (≥8) that disambiguates every pair of IDs. UUIDv7 + // leads with a millisecond timestamp, so 8 chars alone can collide. + var id_w: usize = 8; + for (infos) |a| { + for (infos) |b| { + if (a.id.ptr == b.id.ptr) continue; + const cp = std.mem.indexOfDiff(u8, a.id, b.id) orelse a.id.len; + id_w = @max(id_w, @min(cp + 1, a.id.len)); + } + } + + var model_w: usize = "MODEL".len; + for (infos) |info| model_w = @max(model_w, @min(info.model.len, 24)); + + const term_w: usize = blk: { + const cols = environ_map.get("COLUMNS") orelse break :blk 120; + break :blk std.fmt.parseInt(usize, cols, 10) catch 120; + }; + const modified_w = "YYYY-MM-DD HH:MM".len; + const msgs_w = "MSGS".len; + const fixed = id_w + 2 + modified_w + 2 + msgs_w + 2 + model_w + 2; + const msg_w = term_w -| fixed; + + try stdout.print("{[id]s:<[idw]} {[mod]s:<[modw]} MSGS {[model]s:<[modelw]} LAST MESSAGE\n", .{ + .id = "ID", + .idw = id_w, + .mod = "MODIFIED", + .modw = modified_w, + .model = "MODEL", + .modelw = model_w, + }); + for (infos) |info| { - const short = info.id[0..@min(8, info.id.len)]; - // `created` is ISO 8601 (e.g. `2026-04-25T17:40:15.990Z`). Trim - // to `YYYY-MM-DD HH:MM` for terseness. - const created_short = trimCreated(info.created); - try stdout.print( - "{s} {s} {d} messages\n", - .{ short, created_short, info.message_count }, - ); + var ts_buf: [16]u8 = undefined; + try stdout.print("{[id]s:<[idw]} {[mod]s:<[modw]} {[n]d:>[nw]} {[model]s:<[modelw]} ", .{ + .id = info.id[0..@min(id_w, info.id.len)], + .idw = id_w, + .mod = trimIso(info.modified, &ts_buf), + .modw = modified_w, + .n = info.message_count, + .nw = msgs_w, + .model = truncateUtf8(info.model, model_w), + .modelw = model_w, + }); + + // Last user message: newlines/tabs flattened, truncated to width. + const flat = try allocator.dupe(u8, info.last_user_message); + defer allocator.free(flat); + for (flat) |*ch| switch (ch.*) { + '\n', '\r', '\t' => ch.* = ' ', + else => {}, + }; + const shown = truncateUtf8(flat, msg_w); + try stdout.writeAll(shown); + if (shown.len < flat.len) try stdout.writeAll("…"); + try stdout.writeByte('\n'); } + try stdout.print("{s}\n", .{session_dir}); try stdout_file.flush(); } -fn trimCreated(iso: []const u8) []const u8 { - if (iso.len < 16) return iso; - // `YYYY-MM-DDTHH:MM:...` → `YYYY-MM-DD HH:MM` (T → space). - // We can't mutate a borrowed slice, so just return a 16-byte slice - // of the original; the caller prints character-by-character via - // format, so the 'T' will still appear. Use a small buffer trick: - // return the slice unmodified — the 'T' is fine and unambiguous. - return iso[0..16]; +/// ISO 8601 → `YYYY-MM-DD HH:MM` (T → space); shorter input passes through. +pub fn trimIso(ts: []const u8, buf: *[16]u8) []const u8 { + if (ts.len < 16) return ts; + @memcpy(buf, ts[0..16]); + buf[10] = ' '; + return buf; +} + +/// Truncate to at most `max` bytes without splitting a UTF-8 sequence. +fn truncateUtf8(s: []const u8, max: usize) []const u8 { + if (s.len <= max) return s; + var end = max; + while (end > 0 and (s[end] & 0xC0) == 0x80) end -= 1; + return s[0..end]; } // --------------------------------------------------------------------------- diff --git a/src/tui_app.zig b/src/tui_app.zig index 98b94be..f044074 100644 --- a/src/tui_app.zig +++ b/src/tui_app.zig @@ -63,6 +63,7 @@ const theme = @import("tui_theme.zig"); const component = @import("tui_component.zig"); const ui_event = @import("tui_event.zig"); const command = @import("command.zig"); +const subcommand = @import("subcommand.zig"); const selectors_mod = @import("tui_selectors.zig"); const config_file = @import("config_file.zig"); const auth_manager = @import("auth_manager.zig"); @@ -323,6 +324,18 @@ pub const App = struct { /// the picker overlays. selectors: ?*SelectorController = null, + /// Session-rebuild hook (installed by `main` once the session world and + /// the TUI both exist; null in tests and print mode). Tears down the + /// current Agent + Lua runtime and boots a fresh pair around the given + /// session through the same code path startup uses — the guts of `/new` + /// and `/resume`. See `main.zig`'s `RebuildHost`. + session_rebuild_ctx: ?*anyopaque = null, + session_rebuild_fn: ?*const fn ( + ctx: *anyopaque, + session: panto.Session, + conv: ?panto.Conversation, + ) anyerror!void = null, + /// Optional hook the App invokes on every `message_complete` carrying /// a `Usage`. The hook (installed by the `SelectorController`) updates /// session-running totals keyed by the current `(provider, model)` and @@ -426,6 +439,70 @@ pub const App = struct { if (self.flush_fn) |f| f(self.flush_ctx.?); } + /// Install the session-rebuild hook (see `session_rebuild_fn`). + pub fn setSessionRebuild( + self: *App, + ctx: *anyopaque, + f: *const fn (ctx: *anyopaque, session: panto.Session, conv: ?panto.Conversation) anyerror!void, + ) void { + self.session_rebuild_ctx = ctx; + self.session_rebuild_fn = f; + } + + /// Switch to `session` in place: fresh Agent + fresh Lua interpreter, + /// built by the boot code path (ownership of `session` — and `conv`, + /// the loaded history when resuming — transfers to the new world's + /// agent). Commands dispatch only between turns, so no stream is live. + /// On failure the OLD world is gone (or was never torn down, if the + /// pre-teardown UI reset failed): the error is noted in the + /// transcript and `error.SessionRebuildFailed` is returned, which the + /// dispatch sites propagate out of the TUI loop (process restart is the + /// recovery — there is deliberately no partial rollback). + pub fn rebuildSession(self: *App, session: panto.Session, conv: ?panto.Conversation) !void { + const f = self.session_rebuild_fn.?; + f(self.session_rebuild_ctx.?, session, conv) catch |err| { + if (std.fmt.allocPrint( + self.alloc, + "[session rebuild failed: {s} — exiting]", + .{@errorName(err)}, + )) |msg| { + defer self.alloc.free(msg); + _ = self.spawnStatus(msg) catch {}; + } else |_| {} + self.renderNow() catch {}; + return error.SessionRebuildFailed; + }; + } + + /// Reset every piece of session-scoped UI state for an in-place session + /// switch (`/new`, `/resume` — the quit-and-relaunch path). Transcript + /// entries and event-bus handlers may reference components/handlers + /// owned by the outgoing Lua runtime's bridge, so the rebuild host calls + /// this BEFORE tearing that runtime down. The pinned input box, footer, + /// engine, and selector controller survive; the incoming runtime + /// re-attaches its bridge (bus handlers + override-release hook). + pub fn resetSessionUi(self: *App) !void { + // Detach the engine from the transcript boxes BEFORE freeing them: + // if the re-sync fails (OOM), the engine must not be left holding + // pointers into freed entries or the dying runtime's bridge + // components. On failure the entries stay alive, the engine list + // stays valid, and the error rides the rebuild's fail-and-exit path. + const entries = self.transcript; + self.transcript = .empty; + errdefer self.transcript = entries; + try self.rebuildEngineList(); + var old = entries; + for (old.items) |e| e.deinit(self.alloc); + old.deinit(self.alloc); + self.router.reset(); + self.clearPendingOverrides(); + self.bus.deinit(); + self.bus = EventBus.init(self.alloc); + self.override_release_ctx = null; + self.override_release_fn = null; + self.scheduler.requestRender(); + } + // -- transcript spawning ------------------------------------------------ /// Append a fresh transcript entry and register it with the engine, @@ -1174,6 +1251,9 @@ fn addTokenBucket( pub const SelectorController = struct { alloc: std.mem.Allocator, app: *App, + /// The live session agent. NOT stable for the controller's lifetime: + /// `/new`//`/resume` rebuild the session world and the rebuild host + /// repoints this field at the fresh agent. agent: *panto.Agent, /// The app config (providers + defaults). Borrowed; supplies provider /// transport/auth and per-alias knobs lookups via `buildProviderConfig`. @@ -1208,7 +1288,23 @@ pub const SelectorController = struct { /// The currently open picker, if any (owned; freed on close). active: ?*Selector = null, /// Which picker is open (so `apply` knows how to interpret the pick). - kind: enum { none, model, reasoning } = .none, + kind: enum { none, model, reasoning, command, session, path } = .none, + + /// Slash-command registry (for the leading-`/` typeahead) and the + /// session store (for the `/resume` picker). Installed by `main.zig` + /// after init; null in tests (the pickers simply don't open). + registry: ?*const command.Registry = null, + session_store: ?panto.SessionStore = null, + + /// Items for the dynamic pickers (command / session / path — mutually + /// exclusive, so they share one buffer), rebuilt on every open via + /// `clearDyn`. Strings owned in `dyn_strings`. `session_ids` holds the + /// parallel FULL session ids for exact resolution on accept; + /// `path_prefix_len` is the typed token prefix length for path accept. + dyn_items: []SelectorItem = &.{}, + dyn_strings: std.ArrayList([]u8) = .empty, + session_ids: []const []const u8 = &.{}, + path_prefix_len: usize = 0, /// The current model label ("provider:alias"), for the footer/preselect. model_label: []u8, @@ -1289,6 +1385,8 @@ pub const SelectorController = struct { self.alloc.free(self.model_items); self.alloc.free(self.model_entry_indices); self.alloc.free(self.reasoning_items); + self.clearDyn(); + self.dyn_strings.deinit(self.alloc); self.alloc.free(self.model_label); // The session token buckets own the (provider, model) key strings. var it = self.session_token_buckets.iterator(); @@ -1338,6 +1436,94 @@ pub const SelectorController = struct { try self.open(.reasoning, "select reasoning", self.reasoning_items, active); } + /// Free the shared dynamic-picker buffers (command/session/path) — + /// called by each open fn before rebuilding, and by deinit. The open + /// fns assign freshly allocated arrays to these fields immediately + /// (the field owns them from birth); an errdefer there would double + /// free with this function on a later failure. + fn clearDyn(self: *SelectorController) void { + for (self.dyn_strings.items) |s| self.alloc.free(s); + self.dyn_strings.clearRetainingCapacity(); + self.alloc.free(self.dyn_items); + self.dyn_items = &.{}; + self.alloc.free(self.session_ids); + self.session_ids = &.{}; + } + + /// Open the leading-`/` command-completion popup, seeded with whatever + /// the user already typed after the `/`. Items come straight from the + /// registry, so Lua-registered commands appear automatically. + pub fn openCommandCompletion(self: *SelectorController, initial_filter: []const u8) !void { + const reg = self.registry orelse return; + self.clearDyn(); + const cmds = reg.list(); + const items = try self.alloc.alloc(SelectorItem, cmds.len); + self.dyn_items = items; + for (cmds, 0..) |cmd, i| { + const label = try std.fmt.allocPrint(self.alloc, "/{s}", .{cmd.name}); + errdefer self.alloc.free(label); + try self.dyn_strings.append(self.alloc, label); + items[i] = .{ .label = label, .detail = cmd.description }; + } + try self.open(.command, "command", self.dyn_items, ""); + if (initial_filter.len != 0) try self.active.?.setFilter(initial_filter); + } + + /// Open the `/resume` session picker: one row per stored session, + /// newest-modified first, preselecting the current session. + pub fn openSessions(self: *SelectorController) !void { + const store = self.session_store orelse return; + const a = self.alloc; + const infos = try store.list(); // newest-modified first + defer store.freeSessionInfos(infos); + + self.clearDyn(); + + const items = try a.alloc(SelectorItem, infos.len); + self.dyn_items = items; + const ids = try a.alloc([]const u8, infos.len); + self.session_ids = ids; + for (infos, 0..) |info, i| { + const id = try a.dupe(u8, info.id); + try self.dyn_strings.append(a, id); + // "YYYY-MM-DD HH:MM <first line of the last user message>". + var ts_buf: [16]u8 = undefined; + const ts = subcommand.trimIso(info.modified, &ts_buf); + const nl = std.mem.indexOfScalar(u8, info.last_user_message, '\n') orelse + info.last_user_message.len; + const detail = try std.fmt.allocPrint(a, "{s} {s}", .{ ts, info.last_user_message[0..nl] }); + std.mem.replaceScalar(u8, detail, '\t', ' '); + std.mem.replaceScalar(u8, detail, '\r', ' '); + try self.dyn_strings.append(a, detail); + items[i] = .{ .label = id[0..@min(8, id.len)], .detail = detail }; + ids[i] = id; + } + const cur = self.agent.sessionId(); + try self.open(.session, "resume session", self.dyn_items, cur[0..@min(8, cur.len)]); + } + + /// Open the Tab path-completion picker over `candidates` (duped here). + /// `prefix_len` is the byte length of the token's basename part already + /// typed; accept inserts the remainder at the cursor. + pub fn openPathCompletion( + self: *SelectorController, + candidates: []const []const u8, + prefix_len: usize, + ) !void { + const a = self.alloc; + self.clearDyn(); + + const items = try a.alloc(SelectorItem, candidates.len); + self.dyn_items = items; + for (candidates, 0..) |cand, i| { + const owned = try a.dupe(u8, cand); + try self.dyn_strings.append(a, owned); + items[i] = .{ .label = owned }; + } + self.path_prefix_len = prefix_len; + try self.open(.path, "complete path", self.dyn_items, ""); + } + fn open( self: *SelectorController, kind: @TypeOf(self.kind), @@ -1370,10 +1556,44 @@ pub const SelectorController = struct { /// consumed by a picker (so the caller should not feed it to the input box). pub fn handleKey(self: *SelectorController, k: tui_key.Key) !bool { const sel = self.active orelse return false; - const action = try sel.applyKey(k); + // Command popup specials (checked BEFORE the generic key handling): + // - Backspace past the popup's empty filter deletes the `/` too, + // returning to a plain empty input box. + // - Space ends command entry: complete the selection (or keep the + // typed text verbatim) and hand the buffer back for arguments. + if (self.kind == .command and k.event != .release) { + if (k.code == .backspace and sel.filter.items.len == 0) { + self.dismissAndRebuild(); + self.spliceCommandToken("", .{}) catch {}; + self.app.scheduler.requestRender(); + return true; + } + if (k.code == .char and k.code.char == ' ' and !k.mods.ctrl and !k.mods.alt) { + const idx = sel.selectedIndex(); + if (idx == null) self.spliceCommandToken("/{s} ", .{sel.filter.items}) catch {}; + self.dismissAndRebuild(); + if (idx) |i| try self.apply(.command, i); + self.app.scheduler.requestRender(); + return true; + } + } + var action = try sel.applyKey(k); + // Completion pickers: Tab accepts like Enter. + if (action == .none and k.code == .tab and k.event != .release and + (self.kind == .command or self.kind == .path) and sel.selectedIndex() != null) + { + action = .accept; + } switch (action) { .none => {}, - .cancel => self.dismissAndRebuild(), + .cancel => { + // Dismissing the command popup keeps what was typed: + // "/" + the live filter goes back into the input box. + if (self.kind == .command and sel.filter.items.len != 0) { + self.spliceCommandToken("/{s}", .{sel.filter.items}) catch {}; + } + self.dismissAndRebuild(); + }, .accept => { const idx = sel.selectedIndex(); const which = self.kind; @@ -1390,15 +1610,87 @@ pub const SelectorController = struct { self.app.rebuildEngineList() catch {}; } - /// Apply a pick: rebuild and install the live config. + /// Replace the leading-`/` command token (buffer start through the + /// cursor — the span the popup was opened over) with `text`, keeping + /// any tail after the cursor (e.g. arguments Tab was pressed before). + /// The popup is modal, so the buffer/cursor are unchanged since open. + fn spliceCommandToken(self: *SelectorController, comptime fmt: []const u8, args: anytype) !void { + const box = self.app.input_box; + const text = try std.fmt.allocPrint(self.alloc, fmt, args); + defer self.alloc.free(text); + const line = try std.fmt.allocPrint(self.alloc, "{s}{s}", .{ text, box.buffer()[box.cursor..] }); + defer self.alloc.free(line); + try box.setBuffer(line); + box.cursor = text.len; + } + + /// Apply a pick: rebuild and install the live config (model/reasoning), + /// complete into the input box (command/path), or switch sessions. fn apply(self: *SelectorController, which: @TypeOf(self.kind), index: usize) !void { switch (which) { .model => try self.applyModel(index), .reasoning => try self.applyReasoning(index), + .command => { + if (index >= self.dyn_items.len) return; + try self.spliceCommandToken("{s} ", .{self.dyn_items[index].label}); + }, + .path => { + if (index >= self.dyn_items.len) return; + const name = self.dyn_items[index].label; + if (name.len > self.path_prefix_len) { + try self.app.input_box.insertText(name[self.path_prefix_len..]); + } + }, + .session => try self.applySession(index), .none => {}, } } + fn applySession(self: *SelectorController, index: usize) !void { + if (index >= self.session_ids.len) return; + const store = self.session_store orelse return; + const maybe = store.resolve(self.session_ids[index]) catch |err| { + try self.statusf("[resume failed: {s}]", .{@errorName(err)}); + return; + }; + const resolved = maybe orelse { + try self.statusf("[session no longer exists]", .{}); + return; + }; + self.resumeTo(resolved) catch |err| switch (err) { + // The old session world is gone; propagate so the loop exits. + error.SessionRebuildFailed => return err, + else => try self.statusf("[resume failed: {s}]", .{@errorName(err)}), + }; + } + + /// Switch the live session to `session` (ownership of `session.info` + /// transfers here): load its conversation from the store, then rebuild + /// the whole session world around it — fresh Agent and fresh Lua + /// runtime through the boot code path, with the loaded history adopted + /// exactly like boot's `--resume` (already persisted, so nothing is + /// re-written). The rebuild hook repoints `self.agent` and restarts the + /// footer session counters; the switch is noted in the (fresh) + /// transcript and the restored history seeded below the note. + pub fn resumeTo(self: *SelectorController, session: panto.Session) !void { + if (std.mem.eql(u8, session.info.id, self.agent.sessionId())) { + var info = session.info; + info.deinit(self.alloc); + try self.statusf("[already on this session]", .{}); + return; + } + var sess = session; + const conv = sess.load() catch |err| { + sess.info.deinit(self.alloc); + return err; + }; + const n_msgs = conv.messages.items.len; + try self.app.rebuildSession(sess, conv); // repoints self.agent + const sid = self.agent.sessionId(); + try self.statusf("[resumed session {s} ({d} messages)]", .{ sid[0..@min(8, sid.len)], n_msgs }); + try self.app.seedFromConversation(&self.agent.conversation); + } + fn applyModel(self: *SelectorController, index: usize) !void { if (index >= self.model_entry_indices.len) return; const d = self.defs.entries.items[self.model_entry_indices[index]]; @@ -1411,16 +1703,61 @@ pub const SelectorController = struct { // The freshly built provider carries the model's declared reasoning // knobs from models.toml; we keep those (a model switch resets to the // new model's defaults rather than forcing the prior model's effort, - // which may not even exist on the other API style). + // which may not even exist on the other API style). When the alias + // has NO explicit knob, the `[defaults] reasoning` session default + // applies — same precedence as boot. + if (!d.reasoning_explicit) { + if (self.file_cfg.default_reasoning) |lvl| { + if (selectors_mod.ReasoningOption.byLabel(self.live.provider.style(), lvl)) |opt| { + opt.apply(&self.live.provider); + } + } + } self.agent.setConfig(self.live); - // Update the label ("provider:alias") and footer. + // Update the label ("provider:alias"), footer, and context window. const label = try std.fmt.allocPrint(self.alloc, "{s}:{s}", .{ d.provider, d.alias }); self.alloc.free(self.model_label); self.model_label = label; + self.app.footer.setContextWindow(d.context_window); try self.refreshFooter(); try self.statusf("[model -> {s}]", .{label}); } + /// Switch model directly by name (`/model <ref>`): exact + /// "provider:alias", or a bare alias when exactly one provider has it. + /// Reuses the picker's apply path (rebuild ProviderConfig, `setConfig`, + /// footer + status line). `error.UnknownModel` / `error.AmbiguousModel` + /// when the name doesn't resolve. + pub fn setModelByName(self: *SelectorController, name: []const u8) !void { + const idx = (try matchModelLabel(self.model_items, name)) orelse + return error.UnknownModel; + try self.applyModel(idx); + } + + /// Set the reasoning level directly by option label (`/reasoning + /// <level>`), against the ACTIVE provider's option list — the same + /// levels the picker offers. `error.UnknownReasoning` on no match. + pub fn setReasoningByLabel(self: *SelectorController, label: []const u8) !void { + const opt = selectors_mod.ReasoningOption.byLabel(self.live.provider.style(), label) orelse + return error.UnknownReasoning; + opt.apply(&self.live.provider); + self.agent.setConfig(self.live); + try self.refreshFooter(); + try self.statusf("[reasoning -> {s}]", .{opt.label}); + } + + /// Zero the session-running usage totals (a `/new` session starts + /// fresh: buckets emptied, cost back to the pristine known-zero, footer + /// segments back to their "no usage yet" absent state). + pub fn resetSessionUsage(self: *SelectorController) void { + var it = self.session_token_buckets.iterator(); + while (it.next()) |entry| self.alloc.free(entry.key_ptr.*); + self.session_token_buckets.clearRetainingCapacity(); + self.session_cost = 0; + self.app.footer.setSessionTokens(null); + self.app.footer.setSessionCost(null); + } + fn applyReasoning(self: *SelectorController, index: usize) !void { if (index >= self.reasoning_opts.len) return; const opt = self.reasoning_opts[index]; @@ -1501,6 +1838,25 @@ pub const SelectorController = struct { } }; +/// Resolve a user-typed model name against the picker labels +/// ("provider:alias"): an exact label match wins; otherwise a bare alias +/// matches when exactly one label's alias part equals it. +fn matchModelLabel( + items: []const SelectorItem, + name: []const u8, +) error{AmbiguousModel}!?usize { + var alias_match: ?usize = null; + var alias_dup = false; + for (items, 0..) |item, i| { + if (std.mem.eql(u8, item.label, name)) return i; + const colon = std.mem.indexOfScalar(u8, item.label, ':') orelse continue; + if (std.mem.eql(u8, item.label[colon + 1 ..], name)) { + if (alias_match == null) alias_match = i else alias_dup = true; + } + } + if (alias_match != null and alias_dup) return error.AmbiguousModel; + return alias_match; +} /// Format the dim detail string for a model item: the wire model id, /// the reasoning/thinking knobs declared in `models.toml`, and the @@ -1544,7 +1900,6 @@ fn formatModelDetail( return buf.toOwnedSlice(alloc); } - // =========================================================================== // TurnRouter — block-index -> component map (no "active component") // =========================================================================== @@ -1623,10 +1978,11 @@ pub const TurnRouter = struct { // =========================================================================== /// 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. +/// a single call). The command registry and command context are borrowed for +/// the loop's lifetime. The AGENT is always read through `cmd_ctx.agent`: a +/// `/new` or `/resume` rebuilds the session world mid-loop and repoints that +/// field, so the loop must never cache the pointer across a dispatch. 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 @@ -1643,6 +1999,9 @@ pub const RunOptions = struct { /// 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, + /// `[tui] editor` from config.toml: overrides `$VISUAL`/`$EDITOR` for + /// Ctrl+G. Borrowed for the loop's lifetime. + editor_override: ?[]const u8 = null, /// Optional auth manager. When set, the active provider's named auth /// session is resolved (refresh/exchange, or interactive device login) /// before each turn, and re-resolved with a forced refresh once on a @@ -1709,7 +2068,7 @@ pub fn runLoop(app: *App, term: *Terminal, opts: RunOptions) !void { // On `--resume`, materialize the restored conversation into transcript // components below the banner, so the first paint shows the full history. - try app.seedFromConversation(&opts.agent.conversation); + try app.seedFromConversation(&opts.cmd_ctx.agent.conversation); app.input_box.setFocused(true); try app.rebuildEngineList(); @@ -1743,15 +2102,19 @@ pub fn runLoop(app: *App, term: *Terminal, opts: RunOptions) !void { // like it took two presses to register). if (tail.items.len == 1 and tail.items[0] == 0x1b) { tail.items.len = 0; // consume the lone ESC either way - // A modal picker is the only consumer of Escape; closing it is - // the behavior that matters. The input box ignores Escape, so - // with no picker open we simply drop the byte. + // Escape closes an open picker; with no picker open it clears + // the input buffer (idle "cancel the thing in progress"). + var esc_handled = false; if (app.selectors) |ctrl| { if (ctrl.active != null) { _ = try ctrl.handleKey(.{ .code = .escape }); - _ = try app.maybeRender(); + esc_handled = true; } } + if (!esc_handled and app.input_box.buffer().len != 0) { + app.input_box.setBuffer("") catch {}; + } + _ = try app.maybeRender(); } continue; } @@ -1780,6 +2143,51 @@ pub fn runLoop(app: *App, term: *Terminal, opts: RunOptions) !void { /// 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). +/// One `/help` row documenting a key binding. +pub const KeyBinding = struct { chord: []const u8, desc: []const u8 }; + +/// The key bindings as they actually exist, rendered by `/help`. Keep in +/// sync with `handleBytes`/`pumpTurnKeys` (app chords), `InputBox.applyKey` +/// (editing keys), and `Selector.applyKey` (picker keys). +pub const key_bindings = [_]KeyBinding{ + .{ .chord = "Enter", .desc = "submit; Shift+Enter inserts a newline" }, + .{ .chord = "Ctrl+C / Ctrl+D", .desc = "quit" }, + .{ .chord = "Esc", .desc = "interrupt the running turn; close an open selector; clear the input" }, + .{ .chord = "Up / Down", .desc = "input history (at buffer top/bottom); line motion in multiline input" }, + .{ .chord = "Tab", .desc = "complete a file path; with a leading '/', complete a command" }, + .{ .chord = "Ctrl+T", .desc = "model selector" }, + .{ .chord = "Ctrl+R", .desc = "reasoning selector" }, + .{ .chord = "Ctrl+O", .desc = "collapse/expand all tool output" }, + .{ .chord = "Ctrl+G", .desc = "edit the input buffer in $EDITOR" }, + .{ .chord = "Ctrl+A / Ctrl+E", .desc = "start / end of line" }, + .{ .chord = "Ctrl+U / Ctrl+W", .desc = "delete to line start / delete word back" }, + .{ .chord = "Alt+B / Alt+F", .desc = "word left / word right" }, + .{ .chord = "selector: Up/Down or Ctrl+N/P", .desc = "navigate; type to filter; Enter accepts" }, +}; + +/// App-level chords shared by the idle loop (`handleBytes`) and the mid-turn +/// pump (`pumpTurnKeys`). Returns true when the key was consumed. Exit +/// (Ctrl+C/D), Escape and Ctrl+G stay in their respective call sites — their +/// meaning differs between idle and mid-turn. +fn appChord(app: *App, k: tui_key.Key) bool { + if (k.isCtrl('t')) { + // Open the model selector (live session only). + if (app.selectors) |ctrl| ctrl.openModel() catch {}; + return true; + } + if (k.isCtrl('r')) { + // Open the reasoning/effort selector. + if (app.selectors) |ctrl| ctrl.openReasoning() catch {}; + return true; + } + if (k.isCtrl('o')) { + // Global collapse/expand of all tool-use components. + app.toggleToolCollapse(); + return true; + } + return false; +} + fn handleBytes(app: *App, term: *Terminal, hs: *Handshake, bytes: []const u8, opts: RunOptions) !usize { var off: usize = 0; while (off < bytes.len) { @@ -1803,31 +2211,24 @@ 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('m')) { - // Open the model selector (live session only). Consume. - if (app.selectors) |ctrl| ctrl.openModel() catch {}; - off += step.consumed; - continue; - } - if (k.isCtrl('r')) { - // Open the reasoning/effort selector. Consume. - if (app.selectors) |ctrl| ctrl.openReasoning() catch {}; + if (appChord(app, k)) { off += step.consumed; continue; } - 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(); + if (k.code == .escape and k.event != .release) { + // Idle Escape (Kitty-decoded form; the legacy lone-ESC + // timeout path in runLoop mirrors this): clear the input + // buffer. Consume; never feed Escape to the box. + if (app.input_box.buffer().len != 0) app.input_box.setBuffer("") catch {}; off += step.consumed; + app.scheduler.requestRender(); 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| { + editInExternalEditor(app, term, opts.io, opts.environ, opts.editor_override) catch |err| { if (std.fmt.allocPrint(app.alloc, "[$EDITOR failed: {s}]", .{@errorName(err)})) |msg| { defer app.alloc.free(msg); _ = app.spawnStatus(msg) catch {}; @@ -1838,10 +2239,40 @@ fn handleBytes(app: *App, term: *Terminal, hs: *Handshake, bytes: []const u8, op off += step.consumed; continue; } + if (k.code == .tab and k.event != .release) { + // Tab completion: a leading-`/` first token completes + // commands; anything else completes filesystem paths + // from cwd. Consume; never feed Tab to the box. + handleTabCompletion(app, opts.io) catch {}; + off += step.consumed; + app.scheduler.requestRender(); + continue; + } // Feed the key to the focused input box. app.input_box.comp().handleInput(bytes[off .. off + step.consumed]); + // Leading-`/` typeahead: typing `/` into an empty box opens + // the command-completion popup (Registry-fed, so Lua- + // registered commands appear automatically). + if (k.code == .char and k.code.char == '/' and k.event != .release and + std.mem.eql(u8, app.input_box.buffer(), "/")) + { + if (app.selectors) |ctrl| ctrl.openCommandCompletion("") catch {}; + } }, - .paste => { + .paste => |txt| paste: { + // The command popup is modal: a paste right after typing `/` + // extends its filter rather than landing invisibly in the + // occluded input box (where accept would clobber it). + if (app.selectors) |ctrl| { + if (ctrl.kind == .command) { + if (ctrl.active) |sel| { + const new = try std.mem.concat(app.alloc, u8, &.{ sel.filter.items, txt }); + defer app.alloc.free(new); + try sel.setFilter(new); + break :paste; + } + } + } app.input_box.comp().handleInput(bytes[off .. off + step.consumed]); }, .negotiation => |neg| { @@ -1911,8 +2342,10 @@ fn editInExternalEditor( term: *Terminal, io: std.Io, environ: *const std.process.Environ.Map, + editor_override: ?[]const u8, ) !void { - const editor = environ.get("VISUAL") orelse environ.get("EDITOR") orelse "vi"; + const editor = editor_override orelse + 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"; @@ -1982,6 +2415,94 @@ fn splitEditorArgv( try argv.append(alloc, path); } +/// Tab in the idle input box: complete the token under the cursor. +/// +/// A first token starting with `/` re-opens the command popup seeded with +/// what's typed. Anything else is completed as a filesystem path from cwd: +/// the shared prefix of all matching entries is inserted immediately; a +/// remaining ambiguity opens the path picker; no match is a no-op. +fn handleTabCompletion(app: *App, io: std.Io) !void { + const ctrl = app.selectors orelse return; + const buf = app.input_box.buffer(); + const cursor = app.input_box.cursor; + + // Token under the cursor: back to the previous whitespace. + var start = cursor; + while (start > 0 and !std.ascii.isWhitespace(buf[start - 1])) start -= 1; + const token = buf[start..cursor]; + + if (start == 0 and token.len > 0 and token[0] == '/' and + std.mem.lastIndexOfScalar(u8, token, '/').? == 0) + { + // "/wo<Tab>" — command completion, not a path (a second `/` in the + // token, e.g. "/usr/bi", means a path after all). + return ctrl.openCommandCompletion(token[1..]); + } + + var names: std.ArrayList([]u8) = .empty; + defer { + for (names.items) |n| app.alloc.free(n); + names.deinit(app.alloc); + } + const base_len = try collectPathCandidates(app.alloc, io, token, &names); + if (names.items.len == 0) return; // nothing matches: no-op + + // Insert the shared prefix beyond what's typed (no-op when none), then + // open the picker iff more than one candidate remains (lcp == base_len + // when nothing was inserted, so the picker prefix is right either way). + const lcp = commonPrefixLen(names.items); + if (lcp > base_len) try app.input_box.insertText(names.items[0][base_len..lcp]); + if (names.items.len > 1) try ctrl.openPathCompletion(names.items, lcp); +} + +/// List directory entries matching the path `token`'s basename prefix. +/// Directories get a trailing `/`; dotfiles only appear when the prefix +/// itself starts with `.`. Returns the basename prefix length (the part of +/// the token after its last `/`). Candidates are appended sorted. +fn collectPathCandidates( + alloc: std.mem.Allocator, + io: std.Io, + token: []const u8, + out: *std.ArrayList([]u8), +) !usize { + const slash = std.mem.lastIndexOfScalar(u8, token, '/'); + const dir_path: []const u8 = if (slash) |i| (if (i == 0) "/" else token[0..i]) else "."; + const base = if (slash) |i| token[i + 1 ..] else token; + + var dir = std.Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }) catch return base.len; + defer dir.close(io); + var iter = dir.iterate(); + while (iter.next(io) catch null) |entry| { + if (entry.name.len == 0) continue; + if (entry.name[0] == '.' and (base.len == 0 or base[0] != '.')) continue; + if (!std.mem.startsWith(u8, entry.name, base)) continue; + const name = if (entry.kind == .directory) + try std.fmt.allocPrint(alloc, "{s}/", .{entry.name}) + else + try alloc.dupe(u8, entry.name); + try out.append(alloc, name); + } + std.mem.sort([]u8, out.items, {}, struct { + fn lt(_: void, a: []u8, b: []u8) bool { + return std.mem.lessThan(u8, a, b); + } + }.lt); + return base.len; +} + +/// Byte length of the longest common prefix of `names`, trimmed back to a +/// UTF-8 codepoint boundary (so inserting it never splits a codepoint). +fn commonPrefixLen(names: []const []u8) usize { + if (names.len == 0) return 0; + var n = names[0].len; + for (names[1..]) |name| { + const m = @min(n, name.len); + n = std.mem.indexOfDiff(u8, names[0][0..m], name[0..m]) orelse m; + } + while (n > 0 and n < names[0].len and (names[0][n] & 0xC0) == 0x80) n -= 1; + return n; +} + /// Handle a submitted input line: slash command vs. model turn. fn handleSubmittedLine(app: *App, term: *Terminal, line: []const u8, opts: RunOptions) !void { if (line.len == 0) return; @@ -1992,6 +2513,12 @@ fn handleSubmittedLine(app: *App, term: *Terminal, line: []const u8, opts: RunOp // 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) { + // `/quit` exits by the same path as Ctrl+C/D. + error.UserExit => return error.UserExit, + // A failed `/new`//`/resume` world rebuild already tore the old + // session down; the failure is noted in the transcript and the + // only safe recovery is a process restart — exit the loop. + error.SessionRebuildFailed => return error.SessionRebuildFailed, command.Error.CommandNotFound => { const msg = try std.fmt.allocPrint(app.alloc, "[unknown command: {s}]", .{line}); defer app.alloc.free(msg); @@ -2011,7 +2538,9 @@ fn handleSubmittedLine(app: *App, term: *Terminal, line: []const u8, opts: RunOp // Drain any user message the command queued via // `panto.ext.agent:submit` and drive it as a native turn. A loop, // not an if: a handler firing during that turn may queue another. - while (try opts.agent.takeSubmission()) |blocks| { + // (`cmd_ctx.agent`, not a cached pointer: `/new`//`/resume` just + // swapped the agent out from under this dispatch.) + while (try opts.cmd_ctx.agent.takeSubmission()) |blocks| { try runSubmission(app, term, opts, blocks); } try app.renderNow(); @@ -2051,7 +2580,7 @@ fn handleSubmittedLine(app: *App, term: *Terminal, line: []const u8, opts: RunOp /// with the conversation allocator): `run` adopts the block contents, the /// slice is freed here. fn runSubmission(app: *App, term: *Terminal, opts: RunOptions, queued: []panto.ContentBlock) !void { - const alloc = opts.agent.conversation.allocator; + const alloc = opts.cmd_ctx.agent.conversation.allocator; var blocks = queued; defer alloc.free(blocks); @@ -2177,7 +2706,10 @@ fn driveTurn(app: *App, term: *Terminal, opts: RunOptions, message_text: []const /// `driveTurn` for a pre-built user message. `run` adopts the block /// contents; the slice itself stays the caller's. fn driveTurnBlocks(app: *App, term: *Terminal, opts: RunOptions, blocks: []panto.ContentBlock) !void { - var stream = try opts.agent.run(.{ .blocks = blocks }); + // Transient footer key hint for the duration of the turn. + app.footer.setHint("esc interrupt") catch {}; + defer app.footer.setHint("") catch {}; + var stream = try opts.cmd_ctx.agent.run(.{ .blocks = blocks }); defer stream.deinit(); // Two single-shot fallbacks can re-open the SAME turn (no user-message // duplication): @@ -2216,7 +2748,7 @@ fn driveTurnBlocks(app: *App, term: *Terminal, opts: RunOptions, blocks: []panto }; const e = ev orelse break; try app.routeEvent(e); - try applyPendingOverrides(app, opts.agent, e); + try applyPendingOverrides(app, opts.cmd_ctx.agent, e); _ = try app.maybeRender(); if (try pumpTurnKeys(app, term, &turn_input_tail)) { interrupted = true; @@ -2295,9 +2827,7 @@ fn pumpTurnKeys(app: *App, term: *Terminal, tail: *std.ArrayList(u8)) !bool { tail.items.len = leftover; return true; } - if (k.isCtrl('o')) app.toggleToolCollapse(); - if (k.isCtrl('m')) if (app.selectors) |ctrl| ctrl.openModel() catch {}; - if (k.isCtrl('r')) if (app.selectors) |ctrl| ctrl.openReasoning() catch {}; + _ = appChord(app, k); }, .paste => {}, .negotiation => {}, @@ -2819,6 +3349,36 @@ test "model picker rows sort by provider:alias and keep mapping" { try testing.expectEqual(@as(usize, 2), rows[2].def_index); } +test "matchModelLabel: exact ref wins, bare alias must be unique" { + const items = [_]SelectorItem{ + .{ .label = "anthropic:sonnet", .detail = "" }, + .{ .label = "openai:gpt", .detail = "" }, + .{ .label = "openrouter:sonnet", .detail = "" }, + }; + try testing.expectEqual(@as(?usize, 0), try matchModelLabel(&items, "anthropic:sonnet")); + try testing.expectEqual(@as(?usize, 1), try matchModelLabel(&items, "gpt")); + try testing.expectEqual(@as(?usize, null), try matchModelLabel(&items, "nope")); + try testing.expectError(error.AmbiguousModel, matchModelLabel(&items, "sonnet")); +} + +test "commonPrefixLen: shared prefix, exhausted shortest, utf8 boundary" { + var a = "src/".*; + var b = "src2".*; + var c = "src".*; + var names1 = [_][]u8{ &a, &b }; + try testing.expectEqual(@as(usize, 3), commonPrefixLen(&names1)); + var names2 = [_][]u8{ &a, &c }; + try testing.expectEqual(@as(usize, 3), commonPrefixLen(&names2)); + var only = [_][]u8{&a}; + try testing.expectEqual(@as(usize, 4), commonPrefixLen(&only)); + // "é" = 0xC3 0xA9, "è" = 0xC3 0xA8: bytes diverge INSIDE the codepoint, + // so the prefix must trim back to the boundary before the lead byte. + var ue1 = "\xc3\xa9x".*; + var ue2 = "\xc3\xa8x".*; + var names3 = [_][]u8{ &ue1, &ue2 }; + try testing.expectEqual(@as(usize, 0), commonPrefixLen(&names3)); +} + test "toggleToolCollapse flips every tool component globally" { const alloc = testing.allocator; const h = try Harness.make(alloc); diff --git a/src/tui_components.zig b/src/tui_components.zig index 6f298c3..b93c30b 100644 --- a/src/tui_components.zig +++ b/src/tui_components.zig @@ -870,6 +870,14 @@ pub const InputBox = struct { /// or `takeSubmitted`. submitted: std.ArrayList(u8) = .empty, has_submitted: bool = false, + /// Submitted-input history (oldest first, owned copies) for Up/Down + /// recall. In-memory per-run only. + history: std.ArrayList([]u8) = .empty, + /// Current position while browsing history; null = editing the live buffer. + hist_index: ?usize = null, + /// The live buffer, stashed when history browsing begins so Down past the + /// newest entry restores it. + hist_stash: std.ArrayList(u8) = .empty, /// 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 @@ -891,6 +899,9 @@ pub const InputBox = struct { pub fn deinit(self: *InputBox) void { self.text.deinit(self.alloc); self.submitted.deinit(self.alloc); + for (self.history.items) |h| self.alloc.free(h); + self.history.deinit(self.alloc); + self.hist_stash.deinit(self.alloc); self.cache.deinit(); } @@ -928,17 +939,21 @@ pub const InputBox = struct { } /// Replace the whole editor buffer (e.g. with text edited in `$EDITOR`). - /// Places the cursor at the end and marks dirty. + /// Places the cursor at the end and marks dirty. Ends any Up/Down history + /// browse: the buffer no longer shows the entry `hist_index` points at + /// (historyPrev/Next re-set the index after calling this). 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.hist_index = null; self.cache.markDirty(); } // -- editing primitives (also directly unit-testable) ------------------ - fn insertText(self: *InputBox, bytes: []const u8) !void { + /// Insert text at the cursor (also the app's Tab path-completion hook). + pub fn insertText(self: *InputBox, bytes: []const u8) !void { try self.text.insertSlice(self.alloc, self.cursor, bytes); self.cursor += bytes.len; self.cache.markDirty(); @@ -1101,11 +1116,77 @@ pub const InputBox = struct { self.submitted.clearRetainingCapacity(); try self.submitted.appendSlice(self.alloc, self.text.items); self.has_submitted = true; + // Record for Up/Down recall (skip empty and consecutive duplicates). + const line = self.text.items; + const last = if (self.history.items.len > 0) self.history.items[self.history.items.len - 1] else ""; + if (line.len != 0 and !std.mem.eql(u8, last, line)) { + try self.history.append(self.alloc, try self.alloc.dupe(u8, line)); + } + self.hist_index = null; self.text.clearRetainingCapacity(); self.cursor = 0; self.cache.markDirty(); } + // -- input history (Up/Down recall) ------------------------------------- + + /// Up at buffer-top: step to the previous history entry, stashing the live + /// buffer on first entry. + fn historyPrev(self: *InputBox) !void { + if (self.history.items.len == 0) return; + var target: usize = undefined; + if (self.hist_index) |i| { + if (i == 0) return; + target = i - 1; + } else { + self.hist_stash.clearRetainingCapacity(); + try self.hist_stash.appendSlice(self.alloc, self.text.items); + target = self.history.items.len - 1; + } + try self.setBuffer(self.history.items[target]); // resets hist_index + self.hist_index = target; + } + + /// Down at buffer-bottom: step to the next history entry; past the newest, + /// restore the stashed live buffer. + fn historyNext(self: *InputBox) !void { + const i = self.hist_index orelse return; + if (i + 1 < self.history.items.len) { + try self.setBuffer(self.history.items[i + 1]); // resets hist_index + self.hist_index = i + 1; + } else { + try self.setBuffer(self.hist_stash.items); // resets hist_index + } + } + + /// Move the cursor one '\n'-line up, keeping the byte column (clamped to + /// the target line, snapped back to a codepoint boundary). + fn moveLineUp(self: *InputBox) void { + const cur_start = self.lineStart(self.cursor); + if (cur_start == 0) return; + const col = self.cursor - cur_start; + const prev_start = self.lineStart(cur_start - 1); + self.cursor = @min(prev_start + col, cur_start - 1); + self.snapBack(); + self.cache.markDirty(); + } + + /// Move the cursor one '\n'-line down (same column policy as moveLineUp). + fn moveLineDown(self: *InputBox) void { + const nl = self.lineEnd(self.cursor); + if (nl == self.text.items.len) return; + const col = self.cursor - self.lineStart(self.cursor); + self.cursor = @min(nl + 1 + col, self.lineEnd(nl + 1)); + self.snapBack(); + self.cache.markDirty(); + } + + /// Snap the cursor back off any UTF-8 continuation byte it landed on. + fn snapBack(self: *InputBox) void { + while (self.cursor > 0 and self.cursor < self.text.items.len and + isContinuation(self.text.items[self.cursor])) self.cursor -= 1; + } + /// Byte index of the codepoint boundary before `i` (i > 0). fn prevBoundary(self: *const InputBox, i: usize) usize { var j = i - 1; @@ -1204,7 +1285,11 @@ pub const InputBox = struct { .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 + // Up/Down: line motion inside a multiline buffer; history recall + // at the buffer's top/bottom. + .up => if (self.lineStart(self.cursor) == 0) try self.historyPrev() else self.moveLineUp(), + .down => if (self.lineEnd(self.cursor) == self.text.items.len) try self.historyNext() else self.moveLineDown(), + else => {}, // tab, fkeys: ignored } } @@ -1540,6 +1625,15 @@ pub const Footer = struct { /// accumulated. Defined (plan §6) as /// `usage.input + usage.cache_read + usage.cache_write`. context_tokens: ?u64 = null, + /// The active model's declared context window (models.toml + /// `context_window`), so the context readout renders as a fraction + /// ("12.3k/200k ctx"). Null = window unknown; raw count shown. + context_window: ?u32 = null, + /// Session short-id (first 8 chars), shown as "#0197c2a4". Empty until set. + session_id: std.ArrayList(u8) = .empty, + /// One transient context-dependent key hint (e.g. "esc interrupt" while a + /// turn runs). Empty = no hint shown. + hint: std.ArrayList(u8) = .empty, /// Session-running sum of every reported `Usage`'s /// `input + output + cache_read + cache_write`. Grew monotonically /// across the session (including across model switches). Null until @@ -1558,6 +1652,8 @@ pub const Footer = struct { pub fn deinit(self: *Footer) void { self.model.deinit(self.alloc); + self.session_id.deinit(self.alloc); + self.hint.deinit(self.alloc); self.cache.deinit(); } @@ -1570,12 +1666,34 @@ pub const Footer = struct { /// 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 { + /// previous value (latest-wins; null = back to "no usage yet", e.g. after + /// `/new`) and marks dirty so the footer repaints. + pub fn setContextTokens(self: *Footer, tokens: ?u64) void { self.context_tokens = tokens; self.cache.markDirty(); } + /// Set (or clear) the active model's context window, for the fractional + /// context readout. Updated on model switch. + pub fn setContextWindow(self: *Footer, window: ?u32) void { + self.context_window = window; + self.cache.markDirty(); + } + + /// Set the session short-id shown in the footer (first 8 chars kept). + pub fn setSessionId(self: *Footer, id: []const u8) !void { + self.session_id.clearRetainingCapacity(); + try self.session_id.appendSlice(self.alloc, id[0..@min(8, id.len)]); + self.cache.markDirty(); + } + + /// Set the transient key hint ("" clears it). + pub fn setHint(self: *Footer, text: []const u8) !void { + self.hint.clearRetainingCapacity(); + try self.hint.appendSlice(self.alloc, text); + self.cache.markDirty(); + } + /// Set the session-running token total. The caller passes the /// already-accumulated `sum of every turn's input + output + /// cache_read + cache_write` (the display doesn't know about the @@ -1601,7 +1719,13 @@ pub const Footer = struct { /// 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 ""; - return formatTokenShort(buf, n, " ctx"); + const w = self.context_window orelse return formatTokenShort(buf, n, " ctx"); + var n_buf: [16]u8 = undefined; + var w_buf: [16]u8 = undefined; + return std.fmt.bufPrint(buf, "{s}/{s} ctx", .{ + formatTokenShort(&n_buf, n, ""), + formatTokenShort(&w_buf, w, ""), + }) catch ""; } /// Format the session token total: e.g. "1.2k tok", "12k tok", @@ -1646,7 +1770,7 @@ pub const Footer = struct { // leading and trailing separators are conditional on the next // segment being non-empty. // - // <model> <ctx> <session tokens> <session cost> + // <model> <ctx> <session tokens> <session cost> <#sid> <hint> var plain: std.ArrayList(u8) = .empty; defer plain.deinit(a); if (self.model.items.len != 0) try plain.appendSlice(a, self.model.items); @@ -1662,6 +1786,15 @@ pub const Footer = struct { if (plain.items.len != 0) try plain.appendSlice(a, " "); try plain.appendSlice(a, cost); } + if (self.session_id.items.len != 0) { + if (plain.items.len != 0) try plain.appendSlice(a, " "); + try plain.append(a, '#'); + try plain.appendSlice(a, self.session_id.items); + } + if (self.hint.items.len != 0) { + if (plain.items.len != 0) try plain.appendSlice(a, " "); + try plain.appendSlice(a, self.hint.items); + } const vis = truncateToCols(plain.items, width); @@ -1776,6 +1909,15 @@ pub const Selector = struct { } } + /// Seed the filter with already-typed text (the leading-`/` typeahead + /// opens mid-word). Recomputes the filtered view. + pub fn setFilter(self: *Selector, text: []const u8) !void { + self.filter.clearRetainingCapacity(); + try self.filter.appendSlice(self.alloc, text); + try self.recompute(); + self.cache.markDirty(); + } + /// The source-item index currently highlighted, or null when the filtered /// list is empty. pub fn selectedIndex(self: *const Selector) ?usize { @@ -3140,6 +3282,64 @@ test "InputBox: setBuffer/buffer round-trip for the $EDITOR hook" { try testing.expectEqual(ib.text.items.len, ib.cursor); } +test "InputBox: Up/Down history recall and multiline line motion" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + // Two submissions land in history (duplicates and empties skipped). + try typeStr(&ib, "one"); + try ib.applyKey(.{ .code = .enter }); + _ = ib.takeSubmitted(); + try typeStr(&ib, "two"); + try ib.applyKey(.{ .code = .enter }); + _ = ib.takeSubmitted(); + try ib.applyKey(.{ .code = .enter }); // empty: not recorded + _ = ib.takeSubmitted(); + try testing.expectEqual(@as(usize, 2), ib.history.items.len); + // Up walks back, stashing the live buffer; Down walks forward and + // restores it. + try typeStr(&ib, "draft"); + try ib.applyKey(.{ .code = .up }); + try testing.expectEqualStrings("two", ib.buffer()); + try ib.applyKey(.{ .code = .up }); + try testing.expectEqualStrings("one", ib.buffer()); + try ib.applyKey(.{ .code = .up }); // at oldest: no-op + try testing.expectEqualStrings("one", ib.buffer()); + try ib.applyKey(.{ .code = .down }); + try testing.expectEqualStrings("two", ib.buffer()); + try ib.applyKey(.{ .code = .down }); + try testing.expectEqualStrings("draft", ib.buffer()); + // Multiline: Up/Down inside the buffer move by line, not history. + try ib.setBuffer("ab\ncdef"); + try ib.applyKey(.{ .code = .up }); // from end of "cdef", col 4 -> clamp to end of "ab" + try testing.expectEqual(@as(usize, 2), ib.cursor); + try testing.expectEqualStrings("ab\ncdef", ib.buffer()); // no recall happened + try ib.applyKey(.{ .code = .down }); // col 2 on "cdef" + try testing.expectEqual(@as(usize, 5), ib.cursor); +} + +test "InputBox: external setBuffer resets history browsing" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + try typeStr(&ib, "a"); + try ib.applyKey(.{ .code = .enter }); + _ = ib.takeSubmitted(); + try typeStr(&ib, "b"); + try ib.applyKey(.{ .code = .enter }); + _ = ib.takeSubmitted(); + // Browse back to "a", then an external clear (idle Escape) intervenes. + try typeStr(&ib, "draft"); + try ib.applyKey(.{ .code = .up }); + try ib.applyKey(.{ .code = .up }); + try testing.expectEqualStrings("a", ib.buffer()); + try ib.setBuffer(""); + // Up starts from the newest entry again, not the stale index. + try ib.applyKey(.{ .code = .up }); + try testing.expectEqualStrings("b", ib.buffer()); + // Down past the newest restores the new (empty) stash, not "draft". + try ib.applyKey(.{ .code = .down }); + try testing.expectEqualStrings("", ib.buffer()); +} + // -- Footer ----------------------------------------------------------------- test "Footer: renders model dim, within width, no reverse video" { @@ -3213,6 +3413,37 @@ test "Footer: contextText formatting boundaries (0, 999, 1000 -> k)" { try testing.expectEqualStrings("1k ctx", ft.contextText(&buf)); } +test "Footer: context window renders a fraction; unknown window stays raw" { + 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)); + ft.setContextWindow(200_000); + try testing.expectEqualStrings("12.3k/200k ctx", ft.contextText(&buf)); + // Window cleared (e.g. switch to a model without context_window). + ft.setContextWindow(null); + try testing.expectEqualStrings("12.3k ctx", ft.contextText(&buf)); +} + +test "Footer: session id and hint segments render and clear" { + var ft = Footer.init(testing.allocator); + defer ft.deinit(); + try ft.setModel("m"); + try ft.setSessionId("0197c2a4-ffff"); + try ft.setHint("esc interrupt"); + { + const lines = try ft.comp().render(80, testing.allocator); + try testing.expect(std.mem.indexOf(u8, lines[0], "#0197c2a4") != null); + try testing.expect(std.mem.indexOf(u8, lines[0], "esc interrupt") != null); + } + try ft.setHint(""); + { + const lines = try ft.comp().render(80, testing.allocator); + try testing.expect(std.mem.indexOf(u8, lines[0], "esc interrupt") == null); + } +} + test "Footer: large context token counts format as k; latest wins" { var ft = Footer.init(testing.allocator); defer ft.deinit(); diff --git a/src/tui_selectors.zig b/src/tui_selectors.zig index e76f82d..4424695 100644 --- a/src/tui_selectors.zig +++ b/src/tui_selectors.zig @@ -94,6 +94,16 @@ pub const ReasoningOption = struct { }; } + /// Look up an option by label (case-insensitive) among the levels the + /// given style supports. Null when the style has no such level (e.g. + /// "xhigh" on an OpenAI provider). + pub fn byLabel(style: APIStyle, label: []const u8) ?ReasoningOption { + for (forStyle(style)) |opt| { + if (std.ascii.eqlIgnoreCase(opt.label, label)) return opt; + } + return null; + } + /// Write this option into a provider config (must be the same style). pub fn apply(self: ReasoningOption, cfg: *ProviderConfig) void { switch (self.value) { |
