summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-07-04 09:50:12 -0600
committert <t@tjp.lol>2026-07-05 16:23:49 -0600
commitf759f149377942c4e04802c45162cda1c9bfb2b3 (patch)
tree397dd2fc35839d8fcbf6a5c237bee363c6ed3c07 /src
parent1ed07e2e4473b91c669c062bbfef6bb499f7d2b7 (diff)
big cli/tui gaps project
Diffstat (limited to 'src')
-rw-r--r--src/builtin_commands.zig169
-rw-r--r--src/command.zig45
-rw-r--r--src/config_file.zig117
-rw-r--r--src/extension_loader.zig9
-rw-r--r--src/lua_runtime.zig13
-rw-r--r--src/luarocks_runtime.zig19
-rw-r--r--src/main.zig870
-rw-r--r--src/models_toml.zig7
-rw-r--r--src/subcommand.zig133
-rw-r--r--src/tui_app.zig638
-rw-r--r--src/tui_components.zig245
-rw-r--r--src/tui_selectors.zig10
12 files changed, 1968 insertions, 307 deletions
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) {