fix(tui): route enhanced-paste text to the focused Input prompt

On kitty (Linux/Wayland) and any terminal supporting OSC 5522 enhanced
paste, the /login API-key prompt for OpenCode Zen — and any other modal
Input prompt (Perplexity OTP, GitHub Enterprise URL, manual OAuth
redirect URL, …) — silently dropped pasted content. The user could not
paste their API key; on Enter or Esc the key surfaced in the main
prompt.

Cause: InputController.#setupEnhancedPaste enables kitty's enhanced
clipboard protocol on TUI start and consumes the resulting OSC 5522
packets in an addInputListener that runs *before* focus dispatch in
tui.#handleInput. The controller's pasteText callback then routed
unconditionally to this.ctx.editor.pasteText(text) — the main
CustomEditor — even when selector-controller had cleared editorContainer
and focused a temporary Input. The text accumulated in the detached
editor and only resurfaced when the modal was dismissed.

Fix:
- TUI.getFocused() exposes the currently focused component.
- Input.pasteText(text) mirrors Editor.pasteText so any Input can absorb
  a payload from a non-bracketed transport.
- InputController's enhanced-paste callback consults getFocused() and
  routes text to the focused component when it exposes pasteText,
  falling back to the editor only when no modal target is in focus.
- Image pastes refuse with a status message when a modal Input is
  focused, instead of stuffing a binary blob into the hidden editor.

Regression tests in packages/tui/test/input.test.ts and
packages/coding-agent/test/issue-2127-repro.test.ts pin both the new
TUI/Input contract and the InputController routing source.

Fixes #2127
This commit is contained in:
roboomp
2026-06-08 20:34:50 +00:00
parent 348ba44293
commit 336b993126
7 changed files with 177 additions and 1 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed `/login` API-key prompts (OpenCode Zen, Perplexity OTP, GitHub Enterprise URL, manual OAuth redirect URL, …) silently dropping pasted content on kitty/Linux/Wayland — and any other terminal supporting OSC 5522 enhanced paste. `InputController` enables kitty's enhanced clipboard protocol on TUI start and consumes the resulting OSC 5522 packets in an `addInputListener` that runs before focus dispatch, so the paste never reached the modal `Input`'s bracketed-paste handler; the routing then stuffed the text into the main `CustomEditor` unconditionally, even when `selector-controller` had detached the editor and focused a temporary OAuth input. The pasted API key accumulated in the hidden editor and only resurfaced in the main prompt when the user dismissed the modal with Enter or Esc. The enhanced-paste callback now consults `ui.getFocused()` and routes the text to the focused component when it exposes a `pasteText` hook, falling back to the editor only when no modal target is in focus; image pastes refuse with a status message instead of stuffing a binary blob into the hidden editor. ([#2127](https://github.com/can1357/oh-my-pi/issues/2127))
## [15.10.5] - 2026-06-08
### Added
@@ -32,6 +32,15 @@ function isExpandable(obj: unknown): obj is Expandable {
return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
}
/** Minimal contract for any component that can receive a paste payload directly. */
interface PasteTarget {
pasteText(text: string): void;
}
function hasPasteText(value: unknown): value is PasteTarget {
return typeof value === "object" && value !== null && typeof (value as PasteTarget).pasteText === "function";
}
const TINY_TITLE_PROGRESS_DONE_TTL_MS = 3_000;
// A cached model fires its file-load events in a short burst and then goes silent
// while onnxruntime builds the session; a genuine download keeps streaming progress
@@ -250,10 +259,24 @@ export class InputController {
this.#enhancedPaste = new EnhancedPasteController({
write: data => this.ctx.ui.terminal.write(data),
pasteText: text => {
this.ctx.editor.pasteText(text);
// Route enhanced-paste text to the currently focused component when it
// exposes a `pasteText` hook (modal Input prompts: OAuth API-key entry,
// Perplexity OTP, GitHub Enterprise URL, manual redirect URL). Falling
// back to the main editor would have buried the text in the detached
// editor while the modal Input had focus (#2127).
const focused = this.ctx.ui.getFocused();
const target = focused && focused !== this.ctx.editor && hasPasteText(focused) ? focused : this.ctx.editor;
target.pasteText(text);
this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
},
pasteImage: async image => {
// Images can only land in the main editor — when a modal Input is
// focused, refuse rather than dump the binary blob in a hidden buffer.
const focused = this.ctx.ui.getFocused();
if (focused && focused !== this.ctx.editor && hasPasteText(focused)) {
this.ctx.showStatus("Image paste is not supported in this prompt");
return;
}
await this.#normalizeAndInsertPastedImage(image, `Unsupported pasted image format: ${image.mimeType}`);
},
showStatus: message => this.ctx.showStatus(message),
@@ -0,0 +1,125 @@
import { describe, expect, it } from "bun:test";
import * as path from "node:path";
import { EnhancedPasteController } from "@oh-my-pi/pi-coding-agent/utils/enhanced-paste";
/**
* Regression for https://github.com/can1357/oh-my-pi/issues/2127
*
* On kitty (Linux/Wayland), the `/login` flow for OpenCode Zen (and any
* other modal `Input` prompt: Perplexity OTP, GitHub Enterprise URL,
* manual OAuth redirect URL, …) silently dropped pasted API keys into
* the main editor instead of the focused `Input`.
*
* Cause: `InputController.#setupEnhancedPaste` enables kitty's enhanced
* clipboard protocol (`ESC [ ? 5522 h`) on TUI start and consumes OSC
* 5522 packets via an `addInputListener` before focus dispatch. The
* controller's `pasteText` callback then routed unconditionally to the
* main `CustomEditor`, even when `selector-controller.ts` had cleared
* `editorContainer` and focused a temporary `Input` for the OAuth
* prompt. The text accumulated in the detached editor; on submit/cancel
* it resurfaced in the main prompt.
*
* Fix: the `pasteText` callback now queries `ui.getFocused()` and routes
* to the focused component when it exposes a `pasteText` hook (via the
* new `Input.pasteText` method), falling back to the editor only when no
* modal target is in focus.
*/
const ST = "\x1b\\";
const OSC = "\x1b]5522;";
function packet(metadata: string, payload?: string): string {
return `${OSC}${metadata}${payload === undefined ? "" : `;${payload}`}${ST}`;
}
class PasteRecorder {
pasted: string[] = [];
pasteText(text: string): void {
this.pasted.push(text);
}
}
describe("issue #2127 — enhanced-paste text must follow focus", () => {
function makeController(focused: { pasteText(text: string): void } | null, editor: PasteRecorder) {
// Mirror the wiring inside InputController.#setupEnhancedPaste: route to
// the focused component when it exposes a `pasteText` hook and isn't the
// main editor; otherwise fall back to the editor. Keeping the predicate
// identical to the implementation is the whole point of the regression
// test — drift here means the bug is back.
return new EnhancedPasteController({
write: () => {},
pasteText: text => {
const target = focused && focused !== editor && typeof focused.pasteText === "function" ? focused : editor;
target.pasteText(text);
},
pasteImage: async () => {},
showStatus: () => {},
});
}
function deliverApiKey(controller: EnhancedPasteController, apiKey: string): void {
const textMime = Buffer.from("text/plain", "utf8").toString("base64");
const password = Buffer.from("pw", "utf8").toString("base64");
controller.handleInput(packet(`type=read:status=OK:pw=${password}`));
controller.handleInput(packet(`type=read:status=DATA:mime=${textMime}`));
controller.handleInput(packet("type=read:status=DONE"));
// Read phase — controller now requests the chosen MIME and reads bytes.
controller.handleInput(packet(`type=read:status=OK:pw=${password}`));
controller.handleInput(
packet(`type=read:status=DATA:mime=${textMime}`, Buffer.from(apiKey, "utf8").toString("base64")),
);
controller.handleInput(packet("type=read:status=DONE"));
}
it("routes an OAuth API-key paste to the focused modal Input, not the main editor", () => {
const editor = new PasteRecorder();
const codeInput = new PasteRecorder();
const controller = makeController(codeInput, editor);
deliverApiKey(controller, "sk-opencode-test-123");
expect(codeInput.pasted).toEqual(["sk-opencode-test-123"]);
expect(editor.pasted).toEqual([]);
});
it("falls back to the editor when no modal Input is focused", () => {
const editor = new PasteRecorder();
const controller = makeController(editor, editor);
deliverApiKey(controller, "regular paste body");
expect(editor.pasted).toEqual(["regular paste body"]);
});
it("falls back to the editor when the focused component cannot accept a paste", () => {
const editor = new PasteRecorder();
// A focused component without `pasteText` (e.g. a passive overlay) must
// not silently swallow the payload — defer to the main editor.
const opaqueFocus = {} as { pasteText(text: string): void };
const controller = makeController(opaqueFocus, editor);
deliverApiKey(controller, "fallback body");
expect(editor.pasted).toEqual(["fallback body"]);
});
});
describe("issue #2127 — InputController wires enhanced-paste through focus", () => {
const packageDir = path.resolve(import.meta.dir, "..");
const controllerPath = path.join(packageDir, "src/modes/controllers/input-controller.ts");
it("input-controller routes the enhanced-paste text callback through ui.getFocused", async () => {
const source = await Bun.file(controllerPath).text();
// Anchor the assertion on the EnhancedPasteController construction block:
// the `pasteText` callback must consult the focused component, not stash
// every payload into `this.ctx.editor` unconditionally. A future refactor
// that drops `getFocused()` from this callback re-introduces the bug.
const constructionStart = source.indexOf("new EnhancedPasteController(");
expect(constructionStart, "InputController must still construct EnhancedPasteController").toBeGreaterThan(-1);
const constructionSlice = source.slice(constructionStart, constructionStart + 2_000);
expect(
constructionSlice.includes("getFocused()"),
"The enhanced-paste callback must consult ui.getFocused() so modal Input prompts (OAuth API-key entry, OTPs, redirect URLs) receive the pasted text instead of the detached main editor (#2127).",
).toBe(true);
});
});
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added `TUI.getFocused()` accessor and `Input.pasteText(text)` method so callers consuming non-bracketed paste transports (e.g. kitty's OSC 5522 enhanced clipboard) can route a paste payload to the currently focused modal Input rather than always to the primary editor. Mirrors the existing `Editor.pasteText` semantics: newlines stripped, tabs normalized, NFC normalization applied. ([#2127](https://github.com/can1357/oh-my-pi/issues/2127))
## [15.10.5] - 2026-06-08
### Added
+6
View File
@@ -187,6 +187,12 @@ export class Input implements Component, Focusable {
}
}
/** Apply terminal paste semantics to text from non-bracketed paste transports
* (e.g. kitty's OSC 5522 enhanced clipboard read). Mirrors `Editor.pasteText`. */
pasteText(text: string): void {
this.#handlePaste(text);
}
#insertCharacter(text: string): void {
const isWordChunk = [...segmenter.segment(text)].every(seg => getWordNavKind(seg.segment) !== "whitespace");
// Undo coalescing: consecutive word typing coalesces into one undo unit.
+5
View File
@@ -776,6 +776,11 @@ export class TUI extends Container {
}
}
/** Component currently receiving keyboard input, if any. */
getFocused(): Component | null {
return this.#focusedComponent;
}
/**
* Show an overlay component with configurable positioning and sizing.
* Returns a handle to control the overlay's visibility.
+9
View File
@@ -229,4 +229,13 @@ describe("Input component", () => {
expect(line.replaceAll(CURSOR_MARKER, "")).toContain("abc");
expect(input.getUseTerminalCursor()).toBe(true);
});
it("pasteText absorbs a payload from a non-bracketed transport (kitty OSC 5522)", () => {
// Regression for #2127: when kitty's enhanced clipboard read delivers the
// API key directly via `pasteText`, the modal Input must capture it just
// like a bracketed paste — newlines stripped, value inserted, cursor at end.
const input = setupAtEnd("");
input.pasteText("sk-line1\nsk-line2\r\nsk-line3");
expect(input.getValue()).toBe("sk-line1sk-line2sk-line3");
});
});