feat(oauth): show paste input immediately during OpenAI Codex login (#468)

Previously, users had to wait up to 60 seconds for the browser callback
to timeout before being prompted to paste the authorization code. This
was problematic for SSH/VPS sessions where the callback cannot work.

Now the paste input is shown immediately alongside the browser flow:
- Browser callback and manual paste race - whichever completes first wins
- Desktop users: browser callback succeeds, input is cleaned up
- SSH/VPS users: paste code immediately without waiting

Changes:
- Add cancelWait() to OAuth server for early termination of polling loop
- Add onManualCodeInput callback that races with browser callback
- Show paste input immediately in TUI for openai-codex provider
- Clean up input on success, error, or when browser callback wins

Co-authored-by: cc-vps <crcatala+vps@gmail.com>
This commit is contained in:
Christian Catalan 2026-01-05 12:47:58 -05:00 committed by GitHub
parent 817221b79f
commit 05b9d55656
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 121 additions and 4 deletions

View file

@ -2124,7 +2124,15 @@ export class InteractiveMode {
if (mode === "login") {
this.showStatus(`Logging in to ${providerId}...`);
// For openai-codex: promise that resolves when user pastes code manually
let manualCodeResolve: ((code: string) => void) | undefined;
const manualCodePromise = new Promise<string>((resolve) => {
manualCodeResolve = resolve;
});
let manualCodeInput: Input | undefined;
try {
await this.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
onAuth: (info: { url: string; instructions?: string }) => {
this.chatContainer.addChild(new Spacer(1));
@ -2155,8 +2163,39 @@ export class InteractiveMode {
? "start"
: "xdg-open";
exec(`${openCmd} "${info.url}"`);
// For openai-codex: show paste input immediately (races with browser callback)
if (providerId === "openai-codex") {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(
new Text(
theme.fg("dim", "Alternatively, paste the authorization code or redirect URL below:"),
1,
0,
),
);
manualCodeInput = new Input();
manualCodeInput.onSubmit = () => {
const code = manualCodeInput!.getValue();
if (code && manualCodeResolve) {
manualCodeResolve(code);
manualCodeResolve = undefined;
}
};
this.editorContainer.clear();
this.editorContainer.addChild(manualCodeInput);
this.ui.setFocus(manualCodeInput);
this.ui.requestRender();
}
},
onPrompt: async (prompt: { message: string; placeholder?: string }) => {
// Clean up manual code input if it exists (fallback case)
if (manualCodeInput) {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
manualCodeInput = undefined;
}
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("warning", prompt.message), 1, 0));
if (prompt.placeholder) {
@ -2183,7 +2222,17 @@ export class InteractiveMode {
this.chatContainer.addChild(new Text(theme.fg("dim", message), 1, 0));
this.ui.requestRender();
},
onManualCodeInput: () => manualCodePromise,
});
// Clean up manual code input if browser callback succeeded
if (manualCodeInput) {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.ui.setFocus(this.editor);
manualCodeInput = undefined;
}
// Refresh models to pick up new baseUrl (e.g., github-copilot)
this.session.modelRegistry.refresh();
this.chatContainer.addChild(new Spacer(1));
@ -2195,6 +2244,13 @@ export class InteractiveMode {
);
this.ui.requestRender();
} catch (error: unknown) {
// Clean up manual code input on error
if (manualCodeInput) {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.ui.setFocus(this.editor);
manualCodeInput = undefined;
}
this.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
}
} else {