diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8235501a0..e3c060dda 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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)). diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 84d9a2c1b..958db6d63 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -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 { + 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); diff --git a/packages/coding-agent/test/suite/regressions/5433-extension-oauth-prompt-input.test.ts b/packages/coding-agent/test/suite/regressions/5433-extension-oauth-prompt-input.test.ts new file mode 100644 index 000000000..06562b3d3 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/5433-extension-oauth-prompt-input.test.ts @@ -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"); + }); +}); diff --git a/packages/coding-agent/vitest.config.ts b/packages/coding-agent/vitest.config.ts index d3857107b..67ce0fca8 100644 --- a/packages/coding-agent/vitest.config.ts +++ b/packages/coding-agent/vitest.config.ts @@ -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 }, ], }, });