summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/main.zig15
-rw-r--r--src/tui_app.zig95
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;