summaryrefslogtreecommitdiff
path: root/src/tui_app.zig
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/tui_app.zig
parent1ed07e2e4473b91c669c062bbfef6bb499f7d2b7 (diff)
big cli/tui gaps project
Diffstat (limited to 'src/tui_app.zig')
-rw-r--r--src/tui_app.zig638
1 files changed, 599 insertions, 39 deletions
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);