diff options
Diffstat (limited to 'src/tui_app.zig')
| -rw-r--r-- | src/tui_app.zig | 638 |
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); |
