From 7343898524f9f692620f4cb276f56cfde30f6269 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 12 Jun 2026 10:38:28 -0600 Subject: fuzzy selectors for models and reasoning levels --- src/main.zig | 18 ++ src/tui_app.zig | 410 +++++++++++++++++++++++++++++++++++++++++++- src/tui_components.zig | 449 +++++++++++++++++++++++++++++++++++++++++++++++++ src/tui_selectors.zig | 279 ++++++++++++++++++++++++++++++ 4 files changed, 1151 insertions(+), 5 deletions(-) create mode 100644 src/tui_selectors.zig (limited to 'src') 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. @@ -964,6 +990,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 = " "). 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 @@ -1277,6 +1277,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 ` + // 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: " (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)); +} -- cgit v1.3