summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/main.zig18
-rw-r--r--src/tui_app.zig410
-rw-r--r--src/tui_components.zig449
-rw-r--r--src/tui_selectors.zig279
4 files changed, 1151 insertions, 5 deletions
diff --git a/src/main.zig b/src/main.zig
index 693e88c..e867c0e 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -27,6 +27,7 @@ const tui_engine = @import("tui_engine.zig");
const tui_components = @import("tui_components.zig");
const tui_event = @import("tui_event.zig");
const tui_app = @import("tui_app.zig");
+const tui_selectors = @import("tui_selectors.zig");
// Shorthand alias for the Lua C API. The bridge module owns the actual
// `@cImport`; we re-use it here so the smoke check uses identical types.
@@ -66,6 +67,7 @@ test {
_ = tui_components;
_ = tui_event;
_ = tui_app;
+ _ = tui_selectors;
}
/// Spin up a Lua interpreter, run a no-op, tear it down. Catches
@@ -545,6 +547,22 @@ pub fn main(init: std.process.Init) !void {
);
defer alloc.free(model_label);
+ // Runtime model/reasoning selectors (Ctrl+M / Ctrl+R). Live-session only:
+ // a pick rebuilds the provider config and pushes it to the agent via
+ // `setConfig`; nothing is written back to config.toml. Borrows the
+ // long-lived `app_config`, `models.defs`, and `active_config`.
+ const selector_ctrl = try tui_app.SelectorController.init(
+ alloc,
+ &app,
+ agent,
+ &app_config,
+ &models.defs,
+ &active_config,
+ model_label,
+ );
+ defer selector_ctrl.deinit();
+ app.setSelectors(selector_ctrl);
+
// The render engine writes through a buffered file writer; flush after the
// loop so the final frame and teardown sequences reach the terminal.
defer tui_file.interface.flush() catch {};
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);
diff --git a/src/tui_components.zig b/src/tui_components.zig
index 30dd60a..e64b5bd 100644
--- a/src/tui_components.zig
+++ b/src/tui_components.zig
@@ -1278,6 +1278,360 @@ pub const Footer = struct {
};
// ===========================================================================
+// Selector — fuzzy-filter list overlay (model / reasoning pickers)
+// ===========================================================================
+
+/// One selectable row: a primary `label` plus optional dim `detail` text
+/// shown after it (e.g. the upstream model id and reasoning knobs). Both
+/// slices are BORROWED from the caller for the selector's lifetime.
+pub const SelectorItem = struct {
+ label: []const u8,
+ detail: []const u8 = "",
+};
+
+/// The action a key produced against an open selector.
+pub const SelectorAction = enum {
+ /// Nothing actionable yet (filter/navigation updated; keep the overlay open).
+ none,
+ /// The user pressed Enter on the highlighted item (`selectedIndex`).
+ accept,
+ /// The user pressed Escape (dismiss without applying).
+ cancel,
+};
+
+/// A modal fuzzy-filter list. Holds a borrowed item slice, an owned filter
+/// buffer, and a selection cursor over the FILTERED view. It renders a
+/// titled, bordered overlay: a title line, the live filter line, then the
+/// filtered items (highlighted selection + dim detail), capped to a window.
+///
+/// Keys (driven via `applyKey`, returning a `SelectorAction`):
+/// - printable / backspace edit the filter (real-time typeahead),
+/// - Ctrl+N / Down move the selection down, Ctrl+P / Up move it up,
+/// - Enter accepts, Escape cancels.
+///
+/// Filtering is a case-insensitive SUBSEQUENCE (fuzzy) match against the
+/// label; an empty filter matches everything. The filtered order preserves
+/// the source order.
+pub const Selector = struct {
+ alloc: std.mem.Allocator,
+ cache: RenderCache,
+ /// Borrowed for the selector's lifetime.
+ items: []const SelectorItem,
+ title: []const u8,
+ /// Live filter text (owned).
+ filter: std.ArrayList(u8) = .empty,
+ /// Selection cursor into the FILTERED list.
+ selected: usize = 0,
+ /// Scratch: indices of items passing the current filter (owned).
+ filtered: std.ArrayList(usize) = .empty,
+ /// Whether `filtered` has been computed at least once.
+ initialized: bool = false,
+ /// Max item rows shown at once.
+ window: usize = 10,
+
+ pub fn init(alloc: std.mem.Allocator, title: []const u8, items: []const SelectorItem) Selector {
+ return .{
+ .alloc = alloc,
+ .cache = RenderCache.init(alloc),
+ .items = items,
+ .title = title,
+ };
+ }
+
+ pub fn deinit(self: *Selector) void {
+ self.filter.deinit(self.alloc);
+ self.filtered.deinit(self.alloc);
+ self.cache.deinit();
+ }
+
+ /// Pre-select the row whose label equals `label` (no-op if absent). Called
+ /// right after `init` so the picker opens on the current value.
+ pub fn selectLabel(self: *Selector, label: []const u8) !void {
+ try self.recompute();
+ for (self.filtered.items, 0..) |idx, i| {
+ if (std.mem.eql(u8, self.items[idx].label, label)) {
+ self.selected = i;
+ self.cache.markDirty();
+ return;
+ }
+ }
+ }
+
+ /// The source-item index currently highlighted, or null when the filtered
+ /// list is empty.
+ pub fn selectedIndex(self: *const Selector) ?usize {
+ if (self.selected >= self.filtered.items.len) return null;
+ return self.filtered.items[self.selected];
+ }
+
+ /// Case-insensitive subsequence test: every char of `needle` appears in
+ /// `haystack` in order. Empty needle always matches.
+ fn fuzzyMatch(haystack: []const u8, needle: []const u8) bool {
+ if (needle.len == 0) return true;
+ var ni: usize = 0;
+ for (haystack) |hc| {
+ if (ni >= needle.len) break;
+ if (std.ascii.toLower(hc) == std.ascii.toLower(needle[ni])) ni += 1;
+ }
+ return ni == needle.len;
+ }
+
+ /// Rebuild `filtered` from the current filter text, clamping `selected`.
+ fn recompute(self: *Selector) !void {
+ self.initialized = true;
+ self.filtered.clearRetainingCapacity();
+ for (self.items, 0..) |it, idx| {
+ if (fuzzyMatch(it.label, self.filter.items)) {
+ try self.filtered.append(self.alloc, idx);
+ }
+ }
+ if (self.filtered.items.len == 0) {
+ self.selected = 0;
+ } else if (self.selected >= self.filtered.items.len) {
+ self.selected = self.filtered.items.len - 1;
+ }
+ }
+
+ /// Feed one decoded key. Returns the resulting action. Edits the filter,
+ /// moves the selection, or signals accept/cancel.
+ pub fn applyKey(self: *Selector, k: key.Key) !SelectorAction {
+ if (!self.initialized) try self.recompute();
+ switch (k.code) {
+ .escape => return .cancel,
+ .enter => return if (self.selectedIndex() == null) .none else .accept,
+ .up => {
+ self.moveUp();
+ return .none;
+ },
+ .down => {
+ self.moveDown();
+ return .none;
+ },
+ .backspace => {
+ self.deleteFilterChar();
+ try self.recompute();
+ self.cache.markDirty();
+ return .none;
+ },
+ .char => |cp| {
+ // Ctrl-chords: navigation + the readline line-editing niceties.
+ if (k.mods.ctrl) {
+ if (k.isCtrl('n')) {
+ self.moveDown();
+ } else if (k.isCtrl('p')) {
+ self.moveUp();
+ } else if (k.isCtrl('u')) {
+ // Clear the whole filter.
+ self.clearFilter();
+ try self.recompute();
+ self.cache.markDirty();
+ } else if (k.isCtrl('w')) {
+ // Delete the trailing word.
+ self.deleteFilterWord();
+ try self.recompute();
+ self.cache.markDirty();
+ } else if (k.isCtrl('h')) {
+ // Ctrl+H is the ASCII backspace some terminals emit.
+ self.deleteFilterChar();
+ try self.recompute();
+ self.cache.markDirty();
+ }
+ // Other ctrl chords: ignored.
+ return .none;
+ }
+ // Alt+Backspace-as-word-delete: some terminals send `ESC <key>`
+ // forms, but the common word-delete in a finder is Ctrl+W
+ // (handled above). Alt-modified chars are not filter input.
+ if (k.mods.alt or k.mods.super) return .none;
+ // Append the printable codepoint to the filter.
+ if (k.text) |t| {
+ try self.filter.appendSlice(self.alloc, t);
+ } else {
+ var buf: [4]u8 = undefined;
+ const n = std.unicode.utf8Encode(cp, &buf) catch return .none;
+ try self.filter.appendSlice(self.alloc, buf[0..n]);
+ }
+ try self.recompute();
+ self.cache.markDirty();
+ return .none;
+ },
+ else => return .none,
+ }
+ }
+
+ fn moveUp(self: *Selector) void {
+ if (self.filtered.items.len == 0) return;
+ if (self.selected == 0) {
+ self.selected = self.filtered.items.len - 1; // wrap to bottom
+ } else {
+ self.selected -= 1;
+ }
+ self.cache.markDirty();
+ }
+
+ fn moveDown(self: *Selector) void {
+ if (self.filtered.items.len == 0) return;
+ self.selected = (self.selected + 1) % self.filtered.items.len;
+ self.cache.markDirty();
+ }
+
+ fn deleteFilterChar(self: *Selector) void {
+ if (self.filter.items.len == 0) return;
+ // Drop one UTF-8 codepoint from the tail.
+ var i = self.filter.items.len;
+ i -= 1;
+ while (i > 0 and (self.filter.items[i] & 0xC0) == 0x80) i -= 1;
+ self.filter.items.len = i;
+ }
+
+ /// Ctrl+U: clear the entire filter.
+ fn clearFilter(self: *Selector) void {
+ self.filter.clearRetainingCapacity();
+ }
+
+ /// Ctrl+W: delete the trailing "word" — first any trailing spaces, then the
+ /// run of non-space bytes before them (readline word-kill semantics).
+ fn deleteFilterWord(self: *Selector) void {
+ var i = self.filter.items.len;
+ while (i > 0 and self.filter.items[i - 1] == ' ') i -= 1;
+ while (i > 0 and self.filter.items[i - 1] != ' ') i -= 1;
+ self.filter.items.len = i;
+ }
+
+ /// Compute the `[start, end)` window over the filtered list keeping the
+ /// selection visible (mirrors the input box scroll-window).
+ fn windowRange(self: *const Selector) struct { start: usize, end: usize } {
+ const total = self.filtered.items.len;
+ if (total <= self.window) return .{ .start = 0, .end = total };
+ var start: usize = 0;
+ if (self.selected >= self.window) start = self.selected - self.window + 1;
+ const end = @min(start + self.window, total);
+ return .{ .start = start, .end = end };
+ }
+
+ fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
+ _ = alloc;
+ const self: *Selector = @ptrCast(@alignCast(ptr));
+ const a = self.alloc;
+ // The selector may render before any key arrives; ensure `filtered` is
+ // populated.
+ if (!self.initialized) try self.recompute();
+
+ const dim = theme.default.fg(.dim);
+ const accent = theme.default.fg(.welcome);
+ const sel_style = theme.default.fg(.cursor); // reverse video highlight
+
+ var rows: std.ArrayList([]const u8) = .empty;
+ defer {
+ for (rows.items) |r| a.free(r);
+ rows.deinit(a);
+ }
+
+ // Title line: "<title> (n)". Build PLAIN, truncate, then style (escapes
+ // are zero-width but `truncateToCols` counts bytes, so we must truncate
+ // the plain text first — mirrors the Footer).
+ {
+ const plain = try std.fmt.allocPrint(a, "{s} ({d})", .{ self.title, self.filtered.items.len });
+ defer a.free(plain);
+ const vis = truncateToCols(plain, width);
+ try rows.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ accent.open(), vis, accent.close() }));
+ }
+ // Filter line: "> <filter>".
+ {
+ const plain = try std.fmt.allocPrint(a, "> {s}", .{self.filter.items});
+ defer a.free(plain);
+ const vis = truncateToCols(plain, width);
+ try rows.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ dim.open(), vis, dim.close() }));
+ }
+
+ const win = self.windowRange();
+ for (self.filtered.items[win.start..win.end], win.start..) |idx, i| {
+ const it = self.items[idx];
+ const is_sel = (i == self.selected);
+ const marker = if (is_sel) "› " else " ";
+ try rows.append(a, try self.renderItemRow(marker, it, is_sel, width, sel_style, dim));
+ }
+ if (self.filtered.items.len == 0) {
+ const vis = truncateToCols(" (no matches)", width);
+ try rows.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ dim.open(), vis, dim.close() }));
+ }
+
+ try self.cache.store(rows.items);
+ return cacheLines(&self.cache);
+ }
+
+ /// Render one item row. Computes column budgets on PLAIN text so the
+ /// dim-detail span and reverse-video highlight survive width truncation.
+ fn renderItemRow(
+ self: *Selector,
+ marker: []const u8,
+ it: SelectorItem,
+ is_sel: bool,
+ width: usize,
+ sel_style: Style,
+ dim: Style,
+ ) ![]u8 {
+ const a = self.alloc;
+ // Truncate label to fit after the marker.
+ const marker_w = displayWidth(marker);
+ const label_budget = if (width > marker_w) width - marker_w else 0;
+ const label = truncateToCols(it.label, label_budget);
+ var used = marker_w + displayWidth(label);
+
+ // Detail rides after two spaces if there is room.
+ var detail: []const u8 = "";
+ if (it.detail.len != 0 and used + 2 < width) {
+ detail = truncateToCols(it.detail, width - used - 2);
+ if (detail.len != 0) used += 2 + displayWidth(detail);
+ }
+
+ var buf: std.ArrayList(u8) = .empty;
+ errdefer buf.deinit(a);
+ if (is_sel) {
+ // Whole row reverse-video (detail included).
+ try buf.appendSlice(a, sel_style.open());
+ try buf.appendSlice(a, marker);
+ try buf.appendSlice(a, label);
+ if (detail.len != 0) {
+ try buf.appendSlice(a, " ");
+ try buf.appendSlice(a, detail);
+ }
+ try buf.appendSlice(a, sel_style.close());
+ } else {
+ try buf.appendSlice(a, marker);
+ try buf.appendSlice(a, label);
+ if (detail.len != 0) {
+ try buf.appendSlice(a, " ");
+ try buf.appendSlice(a, dim.open());
+ try buf.appendSlice(a, detail);
+ try buf.appendSlice(a, dim.close());
+ }
+ }
+ return buf.toOwnedSlice(a);
+ }
+
+ fn firstLineChangedImpl(ptr: *anyopaque) ?usize {
+ const self: *Selector = @ptrCast(@alignCast(ptr));
+ return self.cache.firstLineChanged();
+ }
+
+ fn invalidateImpl(ptr: *anyopaque) void {
+ const self: *Selector = @ptrCast(@alignCast(ptr));
+ self.cache.invalidate();
+ }
+
+ const vtable = Component.VTable{
+ .render = renderImpl,
+ .firstLineChanged = firstLineChangedImpl,
+ .invalidate = invalidateImpl,
+ };
+
+ pub fn comp(self: *Selector) Component {
+ return .{ .ptr = self, .vtable = &vtable };
+ }
+};
+
+// ===========================================================================
// Welcome — session-start banner (plan §6: "version, cwd, model info")
// ===========================================================================
@@ -2377,6 +2731,101 @@ test "Footer: setContextTokens dirties; stable re-render is clean" {
try testing.expectEqual(@as(?usize, 0), ft.comp().firstLineChanged());
}
+// -- Selector ---------------------------------------------------------------
+
+const sel_items = [_]SelectorItem{
+ .{ .label = "anthropic:sonnet", .detail = "claude-sonnet-4 effort:high" },
+ .{ .label = "anthropic:opus", .detail = "claude-opus-4" },
+ .{ .label = "openai:gpt", .detail = "gpt-4o reasoning:medium" },
+};
+
+fn selKey(c: u21, text: []const u8) Key {
+ return .{ .code = .{ .char = c }, .text = text };
+}
+
+test "Selector: empty filter matches all; selectLabel preselects" {
+ var s = Selector.init(testing.allocator, "model", &sel_items);
+ defer s.deinit();
+ try s.selectLabel("openai:gpt");
+ try testing.expectEqual(@as(usize, 2), s.selected);
+ try testing.expectEqual(@as(?usize, 2), s.selectedIndex());
+ const lines = try s.comp().render(80, testing.allocator);
+ // title + filter + 3 items.
+ try testing.expectEqual(@as(usize, 5), lines.len);
+ for (lines) |ln| try testing.expect(vw(ln) <= 80);
+}
+
+test "Selector: fuzzy typeahead filters; selection clamps" {
+ var s = Selector.init(testing.allocator, "model", &sel_items);
+ defer s.deinit();
+ // Type "ops" -> subsequence of "anthropic:opus" (o,p,u,s contains o,p,s? )
+ // Use "gpt" which only matches openai:gpt.
+ for ("gpt") |c| _ = try s.applyKey(selKey(c, &[_]u8{c}));
+ try testing.expectEqual(@as(usize, 1), s.filtered.items.len);
+ try testing.expectEqual(@as(?usize, 2), s.selectedIndex()); // openai:gpt
+ // Backspace clears one char -> "gp" still only openai:gpt.
+ _ = try s.applyKey(.{ .code = .backspace });
+ try testing.expectEqual(@as(usize, 1), s.filtered.items.len);
+}
+
+fn selCtrl(letter: u8) Key {
+ return .{ .code = .{ .char = letter }, .mods = .{ .ctrl = true } };
+}
+
+test "Selector: Ctrl+U clears the whole filter" {
+ var s = Selector.init(testing.allocator, "model", &sel_items);
+ defer s.deinit();
+ for ("anth") |c| _ = try s.applyKey(selKey(c, &[_]u8{c}));
+ try testing.expectEqual(@as(usize, 2), s.filtered.items.len); // both anthropic
+ _ = try s.applyKey(selCtrl('u'));
+ try testing.expectEqual(@as(usize, 0), s.filter.items.len);
+ // Empty filter matches everything again.
+ try testing.expectEqual(@as(usize, 3), s.filtered.items.len);
+}
+
+test "Selector: Ctrl+W deletes the trailing word" {
+ var s = Selector.init(testing.allocator, "model", &sel_items);
+ defer s.deinit();
+ for ("foo bar") |c| _ = try s.applyKey(selKey(c, &[_]u8{c}));
+ _ = try s.applyKey(selCtrl('w'));
+ try testing.expectEqualStrings("foo ", s.filter.items);
+ // A second Ctrl+W eats the space and the remaining word.
+ _ = try s.applyKey(selCtrl('w'));
+ try testing.expectEqualStrings("", s.filter.items);
+}
+
+test "Selector: navigation wraps and respects filtered order" {
+ var s = Selector.init(testing.allocator, "model", &sel_items);
+ defer s.deinit();
+ _ = try s.applyKey(.{ .code = .{ .char = 'n' }, .mods = .{ .ctrl = true } }); // ctrl+n down
+ try testing.expectEqual(@as(usize, 1), s.selected);
+ _ = try s.applyKey(.{ .code = .down });
+ try testing.expectEqual(@as(usize, 2), s.selected);
+ _ = try s.applyKey(.{ .code = .down }); // wrap to 0
+ try testing.expectEqual(@as(usize, 0), s.selected);
+ _ = try s.applyKey(.{ .code = .{ .char = 'p' }, .mods = .{ .ctrl = true } }); // ctrl+p up -> wrap to bottom
+ try testing.expectEqual(@as(usize, 2), s.selected);
+}
+
+test "Selector: Enter accepts, Escape cancels" {
+ var s = Selector.init(testing.allocator, "model", &sel_items);
+ defer s.deinit();
+ try testing.expectEqual(SelectorAction.accept, try s.applyKey(.{ .code = .enter }));
+ try testing.expectEqual(SelectorAction.cancel, try s.applyKey(.{ .code = .escape }));
+}
+
+test "Selector: no matches yields a placeholder and null selection" {
+ var s = Selector.init(testing.allocator, "model", &sel_items);
+ defer s.deinit();
+ for ("zzzz") |c| _ = try s.applyKey(selKey(c, &[_]u8{c}));
+ try testing.expectEqual(@as(usize, 0), s.filtered.items.len);
+ try testing.expectEqual(@as(?usize, null), s.selectedIndex());
+ // Enter on an empty list is a no-op (not accept).
+ try testing.expectEqual(SelectorAction.none, try s.applyKey(.{ .code = .enter }));
+ const lines = try s.comp().render(40, testing.allocator);
+ try testing.expect(std.mem.indexOf(u8, lines[lines.len - 1], "no matches") != null);
+}
+
// -- Integration with the real Engine (no TTY) ------------------------------
test "components drive the real engine without a TTY" {
diff --git a/src/tui_selectors.zig b/src/tui_selectors.zig
new file mode 100644
index 0000000..a37acb8
--- /dev/null
+++ b/src/tui_selectors.zig
@@ -0,0 +1,279 @@
+//! Runtime model / reasoning selectors for the TUI.
+//!
+//! These pickers let the user switch the active `provider:model` and the
+//! reasoning ("effort") level for the LIVE SESSION only — nothing is written
+//! back to `config.toml`. A pick rebuilds a `panto.ProviderConfig`, stamps it
+//! into an App-owned `panto.Config`, and hands the new config to the agent via
+//! `Agent.setConfig` (which takes effect at the start of the next turn).
+//!
+//! ## Provider-specific reasoning
+//!
+//! The two providers express reasoning on DIFFERENT scales, and we do not try
+//! to flatten them into one enum (that loses Anthropic's `xhigh`/`max`):
+//!
+//! - OpenAI (`openai_chat`): a single `reasoning` enum
+//! (off / minimal / low / medium / high).
+//! - Anthropic (`anthropic_messages`): `thinking` is `.adaptive` with an
+//! `effort` level (low / medium / high / xhigh / max), or `off` which maps
+//! to `thinking = .disabled`.
+//!
+//! The reasoning picker is therefore built from the ACTIVE provider's option
+//! list (`ReasoningOption.forStyle`), so every level the provider supports is
+//! reachable. Each option carries a closure-free `apply` that mutates the
+//! provider config in place.
+//!
+//! ## Adaptive-thinking fallback (anthropic)
+//!
+//! Older Anthropic models reject adaptive thinking with an HTTP 400. Rather
+//! than surface that error, the turn driver SILENTLY rewrites the config:
+//! `thinking = .enabled` with `thinking_budget_tokens` set to a fraction of
+//! the model's `max_tokens` (scaled by the chosen effort), then retries the
+//! turn once. See `adaptiveFallback`.
+
+const std = @import("std");
+const panto = @import("panto");
+
+pub const APIStyle = panto.APIStyle;
+pub const ReasoningEffort = panto.ReasoningEffort;
+pub const Thinking = panto.Thinking;
+pub const Effort = panto.Effort;
+pub const ProviderConfig = panto.ProviderConfig;
+
+/// One reasoning level for ONE provider style. The picker renders `label`; on
+/// accept, `apply` writes the level into a provider config of the matching
+/// style. The two payload variants never mix (an openai option is only applied
+/// to an openai config, etc.).
+pub const ReasoningOption = struct {
+ label: []const u8,
+ detail: []const u8,
+ value: Value,
+
+ pub const Value = union(APIStyle) {
+ /// OpenAI `reasoning` enum value.
+ openai_chat: ReasoningEffort,
+ /// Anthropic thinking selection: either disabled, or adaptive at an
+ /// effort level.
+ anthropic_messages: Anthropic,
+ };
+
+ pub const Anthropic = union(enum) {
+ /// `thinking = .disabled`.
+ off,
+ /// `thinking = .adaptive` at this effort.
+ adaptive: Effort,
+ };
+
+ /// The option list for OpenAI-style providers, in display order.
+ pub const openai = [_]ReasoningOption{
+ .{ .label = "off", .detail = "no reasoning", .value = .{ .openai_chat = .off } },
+ .{ .label = "minimal", .detail = "least reasoning", .value = .{ .openai_chat = .minimal } },
+ .{ .label = "low", .detail = "low effort", .value = .{ .openai_chat = .low } },
+ .{ .label = "medium", .detail = "balanced", .value = .{ .openai_chat = .medium } },
+ .{ .label = "high", .detail = "most reasoning", .value = .{ .openai_chat = .high } },
+ };
+
+ /// The option list for Anthropic-style providers, in display order.
+ pub const anthropic = [_]ReasoningOption{
+ .{ .label = "off", .detail = "no extended thinking", .value = .{ .anthropic_messages = .off } },
+ .{ .label = "low", .detail = "adaptive: low effort", .value = .{ .anthropic_messages = .{ .adaptive = .low } } },
+ .{ .label = "medium", .detail = "adaptive: balanced", .value = .{ .anthropic_messages = .{ .adaptive = .medium } } },
+ .{ .label = "high", .detail = "adaptive: high effort", .value = .{ .anthropic_messages = .{ .adaptive = .high } } },
+ .{ .label = "xhigh", .detail = "adaptive: extra-high effort", .value = .{ .anthropic_messages = .{ .adaptive = .xhigh } } },
+ .{ .label = "max", .detail = "adaptive: maximum effort", .value = .{ .anthropic_messages = .{ .adaptive = .max } } },
+ };
+
+ /// The reasoning options offered for a provider config's API style.
+ pub fn forStyle(style: APIStyle) []const ReasoningOption {
+ return switch (style) {
+ .openai_chat => &openai,
+ .anthropic_messages => &anthropic,
+ };
+ }
+
+ /// Write this option into a provider config (must be the same style).
+ pub fn apply(self: ReasoningOption, cfg: *ProviderConfig) void {
+ switch (self.value) {
+ .openai_chat => |r| switch (cfg.*) {
+ .openai_chat => |*c| c.reasoning = r,
+ .anthropic_messages => {},
+ },
+ .anthropic_messages => |sel| switch (cfg.*) {
+ .anthropic_messages => |*c| switch (sel) {
+ .off => c.thinking = .disabled,
+ .adaptive => |e| {
+ c.thinking = .adaptive;
+ c.effort = e;
+ },
+ },
+ .openai_chat => {},
+ },
+ }
+ }
+
+ /// True when this option matches what `cfg` currently holds (used to
+ /// preselect the picker on the active value).
+ pub fn matches(self: ReasoningOption, cfg: ProviderConfig) bool {
+ switch (self.value) {
+ .openai_chat => |r| return cfg == .openai_chat and cfg.openai_chat.reasoning == r,
+ .anthropic_messages => |sel| {
+ if (cfg != .anthropic_messages) return false;
+ const c = cfg.anthropic_messages;
+ return switch (sel) {
+ .off => c.thinking == .disabled,
+ .adaptive => |e| c.thinking == .adaptive and c.effort == e,
+ };
+ },
+ }
+ }
+
+ /// The label of the option currently active in `cfg` (for the footer /
+ /// preselect). Falls back to "" if nothing matches.
+ pub fn activeLabel(cfg: ProviderConfig) []const u8 {
+ for (forStyle(cfg.style())) |opt| {
+ if (opt.matches(cfg)) return opt.label;
+ }
+ return "";
+ }
+};
+
+/// Fraction of `max_tokens` to budget for manual ("enabled") thinking in the
+/// adaptive-unsupported fallback. Scales with the adaptive effort level.
+fn budgetFraction(effort: Effort) f32 {
+ return switch (effort) {
+ .low => 0.25,
+ .medium => 0.5,
+ .high => 0.75,
+ .xhigh => 0.9,
+ .max => 0.95,
+ };
+}
+
+/// Rewrite an anthropic config that uses adaptive thinking into the manual
+/// ("enabled") form with a token budget derived from the current adaptive
+/// effort, for models that reject adaptive thinking. Returns true if a
+/// rewrite was applied (i.e. the config was anthropic + adaptive); false
+/// otherwise (nothing to fall back from).
+pub fn adaptiveFallback(cfg: *ProviderConfig) bool {
+ switch (cfg.*) {
+ .anthropic_messages => |*c| {
+ if (c.thinking != .adaptive) return false;
+ c.thinking = .enabled;
+ const frac = budgetFraction(c.effort);
+ const max = c.max_tokens;
+ const raw = @as(f32, @floatFromInt(max)) * frac;
+ // Anthropic's floor is 1024 thinking tokens; cap at max_tokens - 1.
+ var budget: u32 = @intFromFloat(@max(raw, 1024.0));
+ if (budget >= max) budget = if (max > 1) max - 1 else 1;
+ c.thinking_budget_tokens = budget;
+ return true;
+ },
+ .openai_chat => return false,
+ }
+}
+
+/// The wire model id carried by a provider config (the `model` field of either
+/// variant).
+pub fn wireModel(cfg: ProviderConfig) []const u8 {
+ return switch (cfg) {
+ inline else => |c| c.model,
+ };
+}
+
+// ===========================================================================
+// Tests
+// ===========================================================================
+
+const testing = std.testing;
+
+fn anthropicCfg() ProviderConfig {
+ return .{ .anthropic_messages = .{
+ .api_key = "k",
+ .base_url = "u",
+ .model = "claude",
+ .max_tokens = 64_000,
+ .thinking = .disabled,
+ .effort = .medium,
+ } };
+}
+
+fn openaiCfg() ProviderConfig {
+ return .{ .openai_chat = .{
+ .api_key = "k",
+ .base_url = "u",
+ .model = "gpt",
+ .reasoning = .medium,
+ .max_tokens = 64_000,
+ } };
+}
+
+test "forStyle yields the full provider-specific option list" {
+ try testing.expectEqual(@as(usize, 5), ReasoningOption.forStyle(.openai_chat).len);
+ // Anthropic exposes off + 5 adaptive efforts (incl. xhigh/max).
+ try testing.expectEqual(@as(usize, 6), ReasoningOption.forStyle(.anthropic_messages).len);
+}
+
+test "apply: openai option writes the reasoning enum" {
+ var c = openaiCfg();
+ for (ReasoningOption.openai) |opt| {
+ if (std.mem.eql(u8, opt.label, "high")) opt.apply(&c);
+ }
+ try testing.expectEqual(ReasoningEffort.high, c.openai_chat.reasoning);
+}
+
+test "apply: anthropic options reach xhigh and max" {
+ var c = anthropicCfg();
+ for (ReasoningOption.anthropic) |opt| {
+ if (std.mem.eql(u8, opt.label, "max")) opt.apply(&c);
+ }
+ try testing.expectEqual(Thinking.adaptive, c.anthropic_messages.thinking);
+ try testing.expectEqual(Effort.max, c.anthropic_messages.effort);
+
+ for (ReasoningOption.anthropic) |opt| {
+ if (std.mem.eql(u8, opt.label, "off")) opt.apply(&c);
+ }
+ try testing.expectEqual(Thinking.disabled, c.anthropic_messages.thinking);
+}
+
+test "matches / activeLabel preselect the live value" {
+ var c = anthropicCfg();
+ for (ReasoningOption.anthropic) |opt| {
+ if (std.mem.eql(u8, opt.label, "xhigh")) opt.apply(&c);
+ }
+ try testing.expectEqualStrings("xhigh", ReasoningOption.activeLabel(c));
+
+ var o = openaiCfg();
+ for (ReasoningOption.openai) |opt| {
+ if (std.mem.eql(u8, opt.label, "low")) opt.apply(&o);
+ }
+ try testing.expectEqualStrings("low", ReasoningOption.activeLabel(o));
+}
+
+test "adaptiveFallback rewrites adaptive -> enabled with a budget" {
+ var c = anthropicCfg();
+ for (ReasoningOption.anthropic) |opt| {
+ if (std.mem.eql(u8, opt.label, "high")) opt.apply(&c);
+ }
+ try testing.expect(adaptiveFallback(&c));
+ try testing.expectEqual(Thinking.enabled, c.anthropic_messages.thinking);
+ // 0.75 * 64000 = 48000.
+ try testing.expectEqual(@as(?u32, 48_000), c.anthropic_messages.thinking_budget_tokens);
+ // A second call is a no-op (no longer adaptive).
+ try testing.expect(!adaptiveFallback(&c));
+}
+
+test "adaptiveFallback uses effort-scaled fractions (max => 0.95)" {
+ var c = anthropicCfg();
+ for (ReasoningOption.anthropic) |opt| {
+ if (std.mem.eql(u8, opt.label, "max")) opt.apply(&c);
+ }
+ try testing.expect(adaptiveFallback(&c));
+ // 0.95 * 64000 = 60800.
+ try testing.expectEqual(@as(?u32, 60_800), c.anthropic_messages.thinking_budget_tokens);
+}
+
+test "adaptiveFallback no-ops for openai and disabled thinking" {
+ var oc = openaiCfg();
+ try testing.expect(!adaptiveFallback(&oc));
+ var ac = anthropicCfg(); // disabled
+ try testing.expect(!adaptiveFallback(&ac));
+}