diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/main.zig | 15 | ||||
| -rw-r--r-- | src/tui_app.zig | 95 |
2 files changed, 102 insertions, 8 deletions
diff --git a/src/main.zig b/src/main.zig index dc611b4..58502ad 100644 --- a/src/main.zig +++ b/src/main.zig @@ -569,6 +569,20 @@ pub fn main(init: std.process.Init) !void { // loop so the final frame and teardown sequences reach the terminal. defer tui_file.interface.flush() catch {}; + // Turn-time auth resolution. The manager resolves the active provider's + // named auth session (api_key: no-op; oauth_device: refresh/exchange or an + // interactive device login) into the live config before each turn. + var home_layout = try panto_home.resolve(alloc, init.environ_map); + defer home_layout.deinit(); + var auth_mgr = auth_manager.AuthManager.init( + alloc, + io, + panto.httpClient(), + home_layout.auth_dir, + &app_config, + ); + defer auth_mgr.deinit(); + tui_app.runLoop(&app, &term, .{ .agent = agent, .cmd_registry = &cmd_registry, @@ -578,6 +592,7 @@ pub fn main(init: std.process.Init) !void { .cwd = cwd, .io = io, .environ = init.environ_map, + .auth_mgr = &auth_mgr, }) catch |err| switch (err) { // Clean user-initiated exit (Ctrl+C / Ctrl+D). Not an error. error.UserExit => {}, diff --git a/src/tui_app.zig b/src/tui_app.zig index b7c297b..e14afb1 100644 --- a/src/tui_app.zig +++ b/src/tui_app.zig @@ -65,6 +65,7 @@ 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 auth_manager = @import("auth_manager.zig"); const models_toml = @import("models_toml.zig"); const tui_key = @import("tui_key.zig"); @@ -1348,6 +1349,11 @@ pub const RunOptions = struct { /// Process environment, used to resolve `$EDITOR` (and `$VISUAL`) for the /// Ctrl+G round-trip. Borrowed for the loop's lifetime. environ: *const std.process.Environ.Map, + /// Optional auth manager. When set, the active provider's named auth + /// session is resolved (refresh/exchange, or interactive device login) + /// before each turn, and re-resolved with a forced refresh once on a + /// provider auth failure. Null disables turn-time auth resolution. + auth_mgr: ?*auth_manager.AuthManager = null, }; /// Run the interactive chat loop against a real terminal until EOF / Ctrl+D / @@ -1720,12 +1726,74 @@ fn handleSubmittedLine(app: *App, line: []const u8, opts: RunOptions) !void { app.beginTurn(); try app.renderNow(); - driveTurn(app, opts.agent, .{ .text = line }) catch |err| { + // Resolve the active provider's auth before sending (refresh/exchange, or + // an interactive device login when no token is stored). On failure, surface + // it and abort the turn rather than sending an unauthenticated request. + resolveAuthForTurn(app, opts, false) catch |err| { + try app.routeError(err); + try app.renderNow(); + return; + }; + + driveTurn(app, opts, .{ .text = line }) catch |err| { try app.routeError(err); }; try app.renderNow(); } +/// The provider name (left of `provider:alias`) currently selected. +fn currentProviderName(app: *App, opts: RunOptions) []const u8 { + const label = if (app.selectors) |c| c.model_label else opts.model_label; + const colon = std.mem.indexOfScalar(u8, label, ':') orelse return label; + return label[0..colon]; +} + +/// Device-code presenter that renders the verification URL + user code into +/// the transcript as status lines (the TUI is live during an inline login). +const TuiPresenter = struct { + app: *App, + + fn deviceCode(ptr: *anyopaque, prompt: panto.DeviceCodePrompt) void { + const app: *App = @ptrCast(@alignCast(ptr)); + const msg = std.fmt.allocPrint( + app.alloc, + "[login] open {s} and enter code {s} — waiting…", + .{ prompt.verification_uri, prompt.user_code }, + ) catch return; + defer app.alloc.free(msg); + _ = app.spawnStatus(msg) catch {}; + app.renderNow() catch {}; + } + + fn status(ptr: *anyopaque, msg: []const u8) void { + const app: *App = @ptrCast(@alignCast(ptr)); + _ = app.spawnStatus(msg) catch {}; + app.renderNow() catch {}; + } + + const vtable: panto.Presenter.VTable = .{ + .on_device_code = deviceCode, + .on_status = status, + }; + + fn presenter(self: *TuiPresenter) panto.Presenter { + return .{ .ptr = self, .vtable = &vtable }; + } +}; + +/// Resolve the active provider's auth into the live config and re-point the +/// agent at it. No-op when no auth manager is wired or there is no selector +/// controller to supply the live config. `force` forces a refresh/exchange +/// (used after a provider auth failure). +fn resolveAuthForTurn(app: *App, opts: RunOptions, force: bool) !void { + const mgr = opts.auth_mgr orelse return; + const ctrl = app.selectors orelse return; + const provider = currentProviderName(app, opts); + var tp = TuiPresenter{ .app = app }; + try mgr.resolveInto(ctrl.live, provider, force, tp.presenter()); + ctrl.agent.setConfig(ctrl.live); +} + /// 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 @@ -1743,15 +1811,17 @@ fn tryAdaptiveFallback(app: *App, err: anyerror) bool { /// component state until it terminates, rendering coalesced frames as deltas /// arrive. The stream is always `deinit`ed (persisting the turn tail) on every /// exit path — agent persistence is untouched. -fn driveTurn(app: *App, agent: *panto.Agent, message: panto.UserMessage) !void { - var stream = try agent.run(message); +fn driveTurn(app: *App, opts: RunOptions, message: panto.UserMessage) !void { + var stream = try opts.agent.run(message); defer stream.deinit(); - // 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. + // Two single-shot fallbacks can re-open the SAME turn (no user-message + // duplication): + // - `ProviderBadRequest` from an anthropic model rejecting adaptive + // thinking: rewrite the live config to manual thinking. + // - `ProviderAuthFailed` (401/403): force an auth refresh/exchange. + // A second failure of either kind propagates. var fallback_used = false; + var auth_retry_used = false; while (true) { const ev = stream.next() catch |err| { if (!fallback_used and tryAdaptiveFallback(app, err)) { @@ -1759,6 +1829,15 @@ fn driveTurn(app: *App, agent: *panto.Agent, message: panto.UserMessage) !void { try stream.reopen(); continue; } + if (!auth_retry_used and err == error.ProviderAuthFailed and opts.auth_mgr != null) { + auth_retry_used = true; + // Force a refresh/exchange (no presenter: don't start an + // interactive login mid-turn). If it can't refresh, surface + // the original auth error. + resolveAuthForTurn(app, opts, true) catch return err; + try stream.reopen(); + continue; + } return err; }; const e = ev orelse break; |
