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