fix(coding-agent): stabilize OAuth login prompt rows

closes #5433
This commit is contained in:
Mario Zechner
2026-06-09 13:39:18 +02:00
Unverified
parent 64b51efb6e
commit 9632bddd38
4 changed files with 107 additions and 1 deletions
+1
View File
@@ -9,6 +9,7 @@
### Fixed
- Fixed extension OAuth login prompts to keep previous submitted prompt rows stable instead of mirroring the active input value ([#5433](https://github.com/earendil-works/pi/issues/5433)).
- Fixed `/reload` to apply updated `steeringMode` and `followUpMode` settings to the current session ([#5377](https://github.com/earendil-works/pi/issues/5377)).
- Fixed invalid `models.json` syntax to skip startup config migrations and report the normal file-path-aware models error instead of a raw JSON parse stack trace ([#5418](https://github.com/earendil-works/pi/issues/5418)).
- Fixed GitHub release notes and interactive changelog links to resolve package-relative documentation URLs correctly ([#5516](https://github.com/earendil-works/pi/issues/5516)).
@@ -56,7 +56,9 @@ export class LoginDialogComponent extends Container implements Focusable {
this.input = new Input();
this.input.onSubmit = () => {
if (this.inputResolver) {
this.inputResolver(this.input.getValue());
const value = this.input.getValue();
this.replaceInputWithSubmittedText(value);
this.inputResolver(value);
this.inputResolver = undefined;
this.inputRejecter = undefined;
}
@@ -73,6 +75,12 @@ export class LoginDialogComponent extends Container implements Focusable {
return this.abortController.signal;
}
private replaceInputWithSubmittedText(value: string): void {
this.contentContainer.children = this.contentContainer.children.map((child) =>
child === this.input ? new Text(`> ${value}`, 0, 0) : child,
);
}
private cancel(): void {
this.abortController.abort();
if (this.inputRejecter) {
@@ -128,6 +136,7 @@ export class LoginDialogComponent extends Container implements Focusable {
* Show input for manual code/URL entry (for callback server providers)
*/
showManualInput(prompt: string): Promise<string> {
this.input.setValue("");
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0));
this.contentContainer.addChild(this.input);
@@ -0,0 +1,93 @@
import { setKeybindings, type TUI } from "@earendil-works/pi-tui";
import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { KeybindingsManager } from "../../../src/core/keybindings.ts";
import { LoginDialogComponent } from "../../../src/modes/interactive/components/login-dialog.ts";
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../../../src/utils/ansi.ts";
vi.mock("../../../src/utils/open-browser.ts", () => ({
openBrowser: vi.fn(),
}));
function createDialog(): LoginDialogComponent {
return new LoginDialogComponent(
{ requestRender: vi.fn() } as unknown as TUI,
"prompt-repro",
() => {},
"Prompt Repro",
);
}
function renderDialog(dialog: LoginDialogComponent): string[] {
return stripAnsi(dialog.render(120).join("\n"))
.split("\n")
.map((line) => line.trimEnd());
}
function countRenderedValue(lines: string[], value: string): number {
return lines.filter((line) => line.trim() === `> ${value}`).length;
}
describe("LoginDialogComponent OAuth prompts", () => {
beforeAll(() => {
initTheme("dark");
});
beforeEach(() => {
setKeybindings(new KeybindingsManager());
});
test("keeps previous prompt input stable when a later prompt is active", async () => {
const dialog = createDialog();
const firstPrompt = dialog.showPrompt("First prompt:", "first-value");
dialog.handleInput("first-value");
dialog.handleInput("\n");
await expect(firstPrompt).resolves.toBe("first-value");
const secondPrompt = dialog.showPrompt("Second prompt:");
dialog.handleInput("second-secret-demo");
const lines = renderDialog(dialog);
expect(lines.join("\n")).toContain("First prompt:");
expect(lines.join("\n")).toContain("Second prompt:");
expect(countRenderedValue(lines, "first-value")).toBe(1);
expect(countRenderedValue(lines, "second-secret-demo")).toBe(1);
dialog.handleInput("\n");
await expect(secondPrompt).resolves.toBe("second-secret-demo");
});
test("preserves auth instructions when showing a prompt", () => {
const dialog = createDialog();
dialog.showAuth("https://example.invalid/login", "Authorize the extension");
dialog.showPrompt("First prompt:");
const output = renderDialog(dialog).join("\n");
expect(output).toContain("https://example.invalid/login");
expect(output).toContain("Authorize the extension");
expect(output).toContain("First prompt:");
});
test("keeps previous manual input stable when a later prompt is active", async () => {
const dialog = createDialog();
const manualInput = dialog.showManualInput("Paste callback URL:");
dialog.handleInput("callback-value");
dialog.handleInput("\n");
await expect(manualInput).resolves.toBe("callback-value");
const prompt = dialog.showPrompt("Second prompt:");
dialog.handleInput("second-secret-demo");
const lines = renderDialog(dialog);
expect(lines.join("\n")).toContain("Paste callback URL:");
expect(lines.join("\n")).toContain("Second prompt:");
expect(countRenderedValue(lines, "callback-value")).toBe(1);
expect(countRenderedValue(lines, "second-secret-demo")).toBe(1);
dialog.handleInput("\n");
await expect(prompt).resolves.toBe("second-secret-demo");
});
});
+3
View File
@@ -4,6 +4,7 @@ import { defineConfig } from "vitest/config";
const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url));
const aiSrcOAuth = fileURLToPath(new URL("../ai/src/oauth.ts", import.meta.url));
const agentSrcIndex = fileURLToPath(new URL("../agent/src/index.ts", import.meta.url));
const tuiSrcIndex = fileURLToPath(new URL("../tui/src/index.ts", import.meta.url));
export default defineConfig({
test: {
@@ -21,9 +22,11 @@ export default defineConfig({
{ find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex },
{ find: /^@earendil-works\/pi-ai\/oauth$/, replacement: aiSrcOAuth },
{ find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex },
{ find: /^@earendil-works\/pi-tui$/, replacement: tuiSrcIndex },
{ find: /^@mariozechner\/pi-ai$/, replacement: aiSrcIndex },
{ find: /^@mariozechner\/pi-ai\/oauth$/, replacement: aiSrcOAuth },
{ find: /^@mariozechner\/pi-agent-core$/, replacement: agentSrcIndex },
{ find: /^@mariozechner\/pi-tui$/, replacement: tuiSrcIndex },
],
},
});