diff options
| author | t <t@tjp.lol> | 2026-06-12 10:38:28 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-12 11:06:18 -0600 |
| commit | 7343898524f9f692620f4cb276f56cfde30f6269 (patch) | |
| tree | 8cf0621d33c4ae7f292c95f4684e1e9590175ae8 /src/tui_app.zig | |
| parent | af8ba87475bfae9c1b4fa70c88fb4c59630a5670 (diff) | |
fuzzy selectors for models and reasoning levels
Diffstat (limited to 'src/tui_app.zig')
| -rw-r--r-- | src/tui_app.zig | 410 |
1 files changed, 405 insertions, 5 deletions
diff --git a/src/tui_app.zig b/src/tui_app.zig index 5a9b7f4..42dc929 100644 --- a/src/tui_app.zig +++ b/src/tui_app.zig @@ -63,6 +63,10 @@ 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 selectors_mod = @import("tui_selectors.zig"); +const config_file = @import("config_file.zig"); +const models_toml = @import("models_toml.zig"); +const tui_key = @import("tui_key.zig"); const Terminal = terminal_mod.Terminal; const Engine = engine_mod.Engine; @@ -77,6 +81,8 @@ const Thinking = components.Thinking; const CompactionSummary = components.CompactionSummary; const ToolUse = components.ToolUse; const Component = component.Component; +const Selector = components.Selector; +const SelectorItem = components.SelectorItem; const EventBus = ui_event.EventBus; const UIEvent = ui_event.Event; const Payload = ui_event.Payload; @@ -289,6 +295,11 @@ pub const App = struct { flush_ctx: ?*anyopaque = null, flush_fn: ?*const fn (ctx: *anyopaque) void = null, + /// Optional runtime model/reasoning selector controller (installed during + /// real-terminal bring-up; tests leave it null). Owns the live config and + /// the picker overlays. + selectors: ?*SelectorController = null, + /// Whether the input box currently participates in the engine list. It is /// removed during an in-flight turn (so streaming output appends below the /// transcript) and re-added when the turn completes. P1 keeps it simple: @@ -467,9 +478,12 @@ pub const App = struct { // `Engine.syncComponents`. var comps: std.ArrayList(Component) = .empty; defer comps.deinit(self.alloc); - try comps.ensureTotalCapacity(self.alloc, self.transcript.items.len + 2); + try comps.ensureTotalCapacity(self.alloc, self.transcript.items.len + 3); for (self.transcript.items) |e| comps.appendAssumeCapacity(e.comp()); comps.appendAssumeCapacity(self.input_box.comp()); + // An open selector overlay renders between the input box and the + // footer (a modal picker pinned just above the footer). + if (self.activeSelector()) |sel| comps.appendAssumeCapacity(sel.comp()); comps.appendAssumeCapacity(self.footer.comp()); try self.engine.syncComponents(comps.items); } @@ -726,6 +740,18 @@ pub const App = struct { self.scheduler.requestRender(); } + /// The currently open selector overlay (model/reasoning picker), or null. + pub fn activeSelector(self: *App) ?*Selector { + const ctrl = self.selectors orelse return null; + return ctrl.active; + } + + /// Install the runtime selector controller. Called once during real- + /// terminal bring-up; tests that don't exercise selectors leave it unset. + pub fn setSelectors(self: *App, ctrl: *SelectorController) void { + self.selectors = ctrl; + } + // -- the render pump ---------------------------------------------------- /// Render a frame if one is pending. Returns true if a frame was drawn. @@ -965,6 +991,262 @@ pub const App = struct { }; // =========================================================================== +// SelectorController — runtime model / reasoning pickers +// =========================================================================== + +/// Owns the runtime model/reasoning picker overlays and the LIVE provider +/// config. A pick rebuilds a `panto.ProviderConfig`, stamps it into the +/// owned `panto.Config`, and pushes it to the agent via `setConfig` (effective +/// next turn). Nothing is persisted to `config.toml`. +/// +/// Lifetime: borrows the long-lived `config_file.Config`, `models.toml` +/// `ModelRegistry`, and `panto.Agent` (all owned by `main`). The model labels +/// and provider/model wire strings the rebuilt config borrows therefore stay +/// valid for the whole session. +pub const SelectorController = struct { + alloc: std.mem.Allocator, + app: *App, + agent: *panto.Agent, + /// The app config (providers + defaults). Borrowed; supplies provider + /// transport/auth and per-alias knobs lookups via `buildProviderConfig`. + file_cfg: *const config_file.Config, + defs: *const models_toml.ModelRegistry, + /// The live agent config snapshot, owned here. `agent` holds a pointer to + /// it; we mutate `provider` in place and re-`setConfig` so the change is + /// observed at the next turn. + live: *panto.Config, + + /// Built selector item list for the model picker (label = "provider:alias", + /// detail = "<wire> <knobs>"). Owned: labels/details are allocated here. + model_items: []SelectorItem, + /// Backing storage for the model item label/detail strings. + model_strings: std.ArrayList([]u8) = .empty, + /// Reasoning picker items for the CURRENTLY OPEN reasoning picker, rebuilt + /// from the active provider's option list each time it opens. The labels/ + /// details are static (borrowed from `ReasoningOption`), so this slice only + /// owns the `SelectorItem` headers. Empty when no reasoning picker is open. + reasoning_items: []SelectorItem = &.{}, + /// The provider-style option list backing `reasoning_items` (parallel to + /// it; consulted on accept to apply the chosen option). + reasoning_opts: []const selectors_mod.ReasoningOption = &.{}, + + /// 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, + + /// The current model label ("provider:alias"), for the footer/preselect. + model_label: []u8, + + pub fn init( + alloc: std.mem.Allocator, + app: *App, + agent: *panto.Agent, + file_cfg: *const config_file.Config, + defs: *const models_toml.ModelRegistry, + live: *panto.Config, + initial_label: []const u8, + ) !*SelectorController { + const self = try alloc.create(SelectorController); + errdefer alloc.destroy(self); + self.* = .{ + .alloc = alloc, + .app = app, + .agent = agent, + .file_cfg = file_cfg, + .defs = defs, + .live = live, + .model_items = &.{}, + .model_label = try alloc.dupe(u8, initial_label), + }; + try self.buildModelItems(); + try self.refreshFooter(); + return self; + } + + /// Recompose the footer label as "provider:alias (reasoning)" from the + /// live model label and the active reasoning option. + fn refreshFooter(self: *SelectorController) !void { + const reasoning = selectors_mod.ReasoningOption.activeLabel(self.live.provider); + const label = if (reasoning.len != 0) + try std.fmt.allocPrint(self.alloc, "{s} ({s})", .{ self.model_label, reasoning }) + else + try self.alloc.dupe(u8, self.model_label); + defer self.alloc.free(label); + try self.app.footer.setModel(label); + } + + pub fn deinit(self: *SelectorController) void { + if (self.active) |sel| { + sel.deinit(); + self.alloc.destroy(sel); + } + for (self.model_strings.items) |s| self.alloc.free(s); + self.model_strings.deinit(self.alloc); + self.alloc.free(self.model_items); + self.alloc.free(self.reasoning_items); + self.alloc.free(self.model_label); + self.alloc.destroy(self); + } + + /// Build the model picker items from every `models.toml` definition. + fn buildModelItems(self: *SelectorController) !void { + const a = self.alloc; + var items: std.ArrayList(SelectorItem) = .empty; + defer items.deinit(a); + for (self.defs.entries.items) |d| { + const label = try std.fmt.allocPrint(a, "{s}:{s}", .{ d.provider, d.alias }); + try self.model_strings.append(a, label); + const detail = try formatModelDetail(a, d); + try self.model_strings.append(a, detail); + try items.append(a, .{ .label = label, .detail = detail }); + } + self.model_items = try items.toOwnedSlice(a); + } + + /// Open the model picker, preselecting the current model. + pub fn openModel(self: *SelectorController) !void { + try self.open(.model, "select model", self.model_items, self.model_label); + } + + /// Open the reasoning picker, built from the ACTIVE provider's option list + /// (so every level it supports is reachable), preselecting the live value. + pub fn openReasoning(self: *SelectorController) !void { + const opts = selectors_mod.ReasoningOption.forStyle(self.live.provider.style()); + // Rebuild the parallel SelectorItem headers (labels/details are static). + self.alloc.free(self.reasoning_items); + self.reasoning_items = try self.alloc.alloc(SelectorItem, opts.len); + for (opts, 0..) |opt, i| { + self.reasoning_items[i] = .{ .label = opt.label, .detail = opt.detail }; + } + self.reasoning_opts = opts; + const active = selectors_mod.ReasoningOption.activeLabel(self.live.provider); + try self.open(.reasoning, "select reasoning", self.reasoning_items, active); + } + + fn open( + self: *SelectorController, + kind: @TypeOf(self.kind), + title: []const u8, + items: []const SelectorItem, + preselect: []const u8, + ) !void { + self.close(); // a second hotkey replaces any open picker + const sel = try self.alloc.create(Selector); + errdefer self.alloc.destroy(sel); + sel.* = Selector.init(self.alloc, title, items); + try sel.selectLabel(preselect); + self.active = sel; + self.kind = kind; + try self.app.rebuildEngineList(); + self.app.scheduler.requestRender(); + } + + /// Close (dismiss) any open picker without applying. + pub fn close(self: *SelectorController) void { + if (self.active) |sel| { + sel.deinit(); + self.alloc.destroy(sel); + self.active = null; + } + self.kind = .none; + } + + /// Route one decoded key to the open picker. Returns true if the key was + /// 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); + switch (action) { + .none => {}, + .cancel => self.dismissAndRebuild(), + .accept => { + const idx = sel.selectedIndex(); + const which = self.kind; + self.dismissAndRebuild(); + if (idx) |i| try self.apply(which, i); + }, + } + self.app.scheduler.requestRender(); + return true; + } + + fn dismissAndRebuild(self: *SelectorController) void { + self.close(); + self.app.rebuildEngineList() catch {}; + } + + /// Apply a pick: rebuild and install the live config. + fn apply(self: *SelectorController, which: @TypeOf(self.kind), index: usize) !void { + switch (which) { + .model => try self.applyModel(index), + .reasoning => try self.applyReasoning(index), + .none => {}, + } + } + + fn applyModel(self: *SelectorController, index: usize) !void { + const d = self.defs.entries.items[index]; + const ref: config_file.ModelRef = .{ .provider = d.provider, .model = d.alias }; + const new_provider = config_file.buildProviderConfig(self.file_cfg, self.defs, ref) catch |err| { + try self.statusf("[model switch failed: {s}]", .{@errorName(err)}); + return; + }; + self.live.provider = new_provider; + // 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). + self.agent.setConfig(self.live); + // Update the label ("provider:alias") and footer. + const label = try std.fmt.allocPrint(self.alloc, "{s}:{s}", .{ d.provider, d.alias }); + self.alloc.free(self.model_label); + self.model_label = label; + try self.refreshFooter(); + try self.statusf("[model -> {s}]", .{label}); + } + + fn applyReasoning(self: *SelectorController, index: usize) !void { + if (index >= self.reasoning_opts.len) return; + const opt = self.reasoning_opts[index]; + opt.apply(&self.live.provider); + self.agent.setConfig(self.live); + try self.refreshFooter(); + try self.statusf("[reasoning -> {s}]", .{opt.label}); + } + + fn statusf(self: *SelectorController, comptime fmt: []const u8, args: anytype) !void { + const msg = try std.fmt.allocPrint(self.alloc, fmt, args); + defer self.alloc.free(msg); + _ = self.app.spawnStatus(msg) catch {}; + } +}; + +/// Format the dim detail string for a model item: the wire model id plus the +/// reasoning/thinking knobs declared in `models.toml`. +fn formatModelDetail(alloc: std.mem.Allocator, d: models_toml.ModelDef) ![]u8 { + // Anthropic-style entries advertise thinking/effort; openai-style ones + // advertise reasoning. We don't know the provider's API style here, so we + // show whatever knobs are non-default. + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(alloc); + try buf.appendSlice(alloc, d.model); + if (d.thinking != .disabled) { + try buf.appendSlice(alloc, " thinking:"); + try buf.appendSlice(alloc, @tagName(d.thinking)); + if (d.thinking == .adaptive) { + try buf.appendSlice(alloc, " effort:"); + try buf.appendSlice(alloc, @tagName(d.effort)); + } + } else if (d.reasoning != .default) { + try buf.appendSlice(alloc, " reasoning:"); + try buf.appendSlice(alloc, @tagName(d.reasoning)); + } + return buf.toOwnedSlice(alloc); +} + + +// =========================================================================== // TurnRouter — block-index -> component map (no "active component") // =========================================================================== @@ -1105,7 +1387,10 @@ pub fn runLoop(app: *App, term: *Terminal, opts: RunOptions) !void { term.hideCursor(); defer term.showCursor(); - try app.footer.setModel(opts.model_label); + // The footer's model label: when the selector controller is installed it + // already composed "model (reasoning)" during install, so don't clobber + // that here. Otherwise seed the plain model label. + if (app.selectors == null) try app.footer.setModel(opts.model_label); // Session-start welcome banner as the first transcript entry. cwd is read // from the process; the model label comes from the run options. (Version @@ -1148,7 +1433,27 @@ pub fn runLoop(app: *App, term: *Terminal, opts: RunOptions) !void { // 2. Poll for input (short timeout so renders/resize stay responsive). const ready = pollReadable(term.fd, 16) catch true; - if (!ready) continue; + if (!ready) { + // ESC-timeout: a lone ESC byte sitting in the tail is ambiguous + // (it could begin a CSI/SS3 sequence), so the decoder defers it + // until more bytes arrive. When the poll times out with nothing + // more, commit it as a real Escape keypress instead of waiting for + // the next keystroke to disambiguate it (which made Escape feel + // 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. + if (app.selectors) |ctrl| { + if (ctrl.active != null) { + _ = try ctrl.handleKey(.{ .code = .escape }); + _ = try app.maybeRender(); + } + } + } + continue; + } const n = posix.read(term.fd, &read_buf) catch |err| switch (err) { error.WouldBlock => continue, @@ -1180,12 +1485,35 @@ fn handleBytes(app: *App, term: *Terminal, hs: *Handshake, bytes: []const u8, op const step = input_mod.decodeOne(bytes[off..]) orelse break; // partial tail switch (step.decoded) { .key => |k| { + // An open selector overlay is MODAL: it consumes every key + // (typeahead filter + navigation + accept/cancel) before the + // app-level chords or the input box see it. Ctrl+C/Ctrl+D still + // fall through to exit, as a safety hatch. + if (app.selectors) |ctrl| { + if (ctrl.active != null and !(k.isCtrl('c') or k.isCtrl('d'))) { + _ = try ctrl.handleKey(k); + off += step.consumed; + continue; + } + } // App-level control keys. if (k.isCtrl('c') or k.isCtrl('d')) { // Clean exit: restore handled by deferred teardown + the // terminal's deinit in main. Signal EOF by closing the loop. return error.UserExit; } + if (k.isCtrl('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 {}; + 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 @@ -1394,6 +1722,19 @@ fn handleSubmittedLine(app: *App, line: []const u8, opts: RunOptions) !void { try app.renderNow(); } +/// Try the anthropic adaptive-thinking fallback after a turn open failed with +/// a bad-request error. Returns true if it rewrote the live config (the caller +/// should then `reopen` + continue the stream). No-op (false) when there is no +/// selector controller, the active config is not anthropic+adaptive, or the +/// error isn't a bad request. +fn tryAdaptiveFallback(app: *App, err: anyerror) bool { + if (err != error.ProviderBadRequest) return false; + const ctrl = app.selectors orelse return false; + if (!selectors_mod.adaptiveFallback(&ctrl.live.provider)) return false; + ctrl.agent.setConfig(ctrl.live); + return true; +} + /// Drive one whole turn: open the pull stream, route every event into /// component state until it terminates, rendering coalesced frames as deltas /// arrive. The stream is always `deinit`ed (persisting the turn tail) on every @@ -1401,8 +1742,23 @@ fn handleSubmittedLine(app: *App, line: []const u8, opts: RunOptions) !void { fn driveTurn(app: *App, agent: *panto.Agent, message: panto.UserMessage) !void { var stream = try agent.run(message); defer stream.deinit(); - while (try stream.next()) |ev| { - try app.routeEvent(ev); + // The turn open can fail with `ProviderBadRequest` when an anthropic model + // rejects adaptive thinking. In that case we silently rewrite the live + // config to manual ("enabled") thinking with an effort-scaled budget and + // re-open the SAME turn once (no user-message duplication). A second + // failure propagates. + var fallback_used = false; + while (true) { + const ev = stream.next() catch |err| { + if (!fallback_used and tryAdaptiveFallback(app, err)) { + fallback_used = true; + try stream.reopen(); + continue; + } + return err; + }; + const e = ev orelse break; + try app.routeEvent(e); _ = try app.maybeRender(); } } @@ -1832,6 +2188,50 @@ test "streaming a new block with expanded tools stays differential (no scrollbac try testing.expectEqual(@as(usize, 0), clears); } +fn testModelDef(provider: []const u8, alias: []const u8, model: []const u8) models_toml.ModelDef { + return .{ + .provider = provider, + .alias = alias, + .model = model, + .reasoning = .default, + .max_tokens = null, + .api_version = null, + .thinking = .disabled, + .effort = .medium, + .thinking_budget_tokens = null, + .thinking_interleaved = false, + }; +} + +test "formatModelDetail: shows wire model + non-default knobs" { + const alloc = testing.allocator; + // Plain entry: just the wire model id. + { + const d = testModelDef("openai", "gpt", "gpt-4o"); + const s = try formatModelDetail(alloc, d); + defer alloc.free(s); + try testing.expectEqualStrings("gpt-4o", s); + } + // openai reasoning knob surfaced. + { + var d = testModelDef("openai", "o3", "o3"); + d.reasoning = .high; + const s = try formatModelDetail(alloc, d); + defer alloc.free(s); + try testing.expect(std.mem.indexOf(u8, s, "reasoning:high") != null); + } + // anthropic adaptive thinking + effort surfaced. + { + var d = testModelDef("anthropic", "opus", "claude-opus-4"); + d.thinking = .adaptive; + d.effort = .xhigh; + const s = try formatModelDetail(alloc, d); + defer alloc.free(s); + try testing.expect(std.mem.indexOf(u8, s, "thinking:adaptive") != null); + try testing.expect(std.mem.indexOf(u8, s, "effort:xhigh") != null); + } +} + test "toggleToolCollapse flips every tool component globally" { const alloc = testing.allocator; const h = try Harness.make(alloc); |
