summaryrefslogtreecommitdiff
path: root/src/tui_app.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-12 10:38:28 -0600
committert <t@tjp.lol>2026-06-12 11:06:18 -0600
commit7343898524f9f692620f4cb276f56cfde30f6269 (patch)
tree8cf0621d33c4ae7f292c95f4684e1e9590175ae8 /src/tui_app.zig
parentaf8ba87475bfae9c1b4fa70c88fb4c59630a5670 (diff)
fuzzy selectors for models and reasoning levels
Diffstat (limited to 'src/tui_app.zig')
-rw-r--r--src/tui_app.zig410
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);