feat(coding-agent): added OSC5522 enhanced paste support for local image and text inserts

- Added OSC5522 parsing and a payload controller that prefers image MIME and reassembles chunks.
- Added bracketed image-path detection and `onPasteImagePath` callback wiring in the editor.
- Added local image-path paste insertion with fallback to text paste on image-size errors.
- Added `Editor.pasteText()` and OSC5522 disable sequencing on terminal cleanup.
This commit is contained in:
can1357
2026-06-07 06:40:27 +02:00
parent f88cf724f3
commit 6adf98fd10
13 changed files with 520 additions and 62 deletions
+2
View File
@@ -44,4 +44,6 @@ app.stt.toggle: []
On Windows Terminal, `Ctrl+V` may be handled by the terminal paste command before `omp` sees it; use the `Alt+V` fallback when clipboard image paste appears to do nothing. Windows Terminal also swallows `Ctrl+Enter`, so the follow-up shortcut also binds `Ctrl+Q` — the same chord GitHub Copilot CLI uses. If your existing `keybindings.yml` already assigns `Ctrl+Q` to another action, that user remap wins and follow-up keeps `Ctrl+Enter` unless you explicitly bind `app.message.followUp`.
Terminals that implement OSC 5522 enhanced paste can send clipboard MIME data directly to `omp`; image pastes are attached as `[Image #N]`, while text/plain paste events keep normal paste behavior. When OSC 5522 is unavailable, bracketed paste still handles text, and a pasted single image-file path is loaded as an image when the file is readable from the `omp` host.
Older unqualified action names are migrated when `keybindings.yml` is loaded, but new docs and new configs should use the namespaced action IDs above. Existing `keybindings.json` files are still accepted and migrated to `keybindings.yml`; `keybindings.yaml` is also accepted.
+9 -8
View File
@@ -1,15 +1,18 @@
# Changelog
## [Unreleased]
### Added
- Added OSC 5522 enhanced paste handling in `InputController`, so terminal clipboard events are decoded as image or text payloads and inserted without passing raw paste sequences to the editor
- Added bracketed image-path paste support in `CustomEditor` so a single pasted image file path (PNG/JPEG/GIF/WEBP) is loaded from disk and inserted as an image candidate
- Added direct support for `Image #N` insertion from pasted local image paths by routing successful image-path pastes through the same image normalization and resize flow as clipboard image pastes
- Added `/fresh` to rotate the provider-facing session id and clear in-memory provider stream/cache state without changing the local session file.
- Added a `ChatBlock` transcript primitive (`modes/components/chat-block.ts`) and a single `ctx.present(...)` sink (with `ctx.resetTranscript()`) so chat output is mounted in one place instead of the repeated `chatContainer.addChild(...)` + `ui.requestRender()` pattern scattered across controllers. `ChatBlock` carries a React/Svelte-style lifecycle — `onMount` starts effects, `onCleanup` registers teardown, `finish()` self-completes (stops timers and freezes the block at its final content), and `dispose()`/`resetTranscript()` tears everything down — so animated blocks own their own resources instead of leaking `setInterval`/`requestRender` bookkeeping into callers. The MCP "Connecting…" spinner is now such a block.
- Added a `framedBlock` output-block helper (`tui/output-block.ts`) plus a `borderColor` override and `applyBg: false` (no background fill) on output blocks, a `renderStatusLine` `iconOverride`, and an `icon.search` (magnifier) theme symbol — so tool renderers can draw self-contained muted-outline frames and search-family tools can show a magnifier instead of a checkmark.
### Changed
- Changed the `todo` tool result renderer to stop redrawing every phase's full task list on each update: when a multi-phase list is rendered collapsed (the default, not manually expanded), only phases the latest update touched — the phase holding the in_progress task, any phase with a just-completed task, and phases named by the ops that ran (`init` counts as touching all) — render their tasks; untouched phases collapse to a one-line `N. Name done/total` summary. When call args are unavailable (e.g. transcript rebuilds) it falls back to the in_progress/completed-transition signals, and the manual expand toggle still shows every task. Also dropped the blank separator line previously inserted between phases.
- Changed non-agent API operations (title and commit-message generation, image generation, web search, eval `llm()`, auto-thinking classifier, memory consolidation) to use session-aware API key resolution with auth retries via `registry.resolver()` / `authStorage.resolver()`, refreshing the active credential before rotating to another account
- Changed image generation to wrap every provider fetch branch in `withAuth`, so 401 / usage-limit errors trigger credential force-refresh and rotation for authStorage-backed providers (OpenAI-hosted, antigravity, xai-oauth) while env-only providers (openrouter, gemini) stay single-attempt
- Changed web-search providers using `authStorage.getApiKey` (anthropic, exa, tavily, parallel, synthetic, zai, kimi) to wrap HTTP calls in `withAuth` for automatic credential rotation on 401 / usage-limit errors
@@ -22,8 +25,13 @@
- Changed the in-progress `task` tool block to keep the shared `context` brief (`# Goal` / `# Constraints` background) visible after the first progress snapshot arrives, instead of dropping it the moment the streaming call view was replaced by the result frame, and to stop animating a spinner/clock next to the `Task` frame header while running — the per-agent body lines already carry their own running spinner, so the header now shows a static state icon (matching the completed/failed header icons). The context is rendered through a shared `buildContextSection` helper that also undoes per-field double-encoding, so the brief reads cleanly in the result frame even though `renderResult` receives the raw (un-repaired) tool args.
- Changed the messaging shown when you press Esc to interrupt a streaming turn from the ambiguous `Operation aborted` / `Tool execution was aborted: Request was aborted` to `Interrupted by user`, so a deliberate user interrupt no longer reads like an internal failure. Every Esc/flush interrupt path (`onEscape` while streaming, the queued-message restore-and-abort path, and the empty-submit queue flush) threads the reason through `AgentSession.abort({ reason })` → `Agent.abort(reason)` so it rides the `AbortController` onto the aborted assistant message's `errorMessage`; the turn label renders it verbatim on both the live and replay paths, and the synthetic placeholder results paired with in-flight tool calls now read `Tool execution was aborted: Interrupted by user`. Aborts that carry no reason still fall back to the retry-aware `Operation aborted` generic. Transcript label resolution is centralized in `resolveAbortLabel` (`session/messages.ts`).
### Removed
- Removed the `/background` (and `/bg`) slash command and the background-mode subsystem it was the sole entry point for — `InteractiveMode.isBackgrounded`, `createBackgroundUiContext`, `handleBackgroundEvent`, and every `isBackgrounded` guard across the input/event/extension-UI controllers and UI helpers. The command suspended the whole process group via `SIGTSTP` (a leftover testing shortcut) instead of detaching the running agent, which is not the expected workflow — use terminal panes or a multiplexer instead.
### Fixed
- Fixed pasted image-file paths that cannot be loaded to fall back to normal text paste with status feedback instead of disappearing.
- Fixed tool-output file paths not being clickable OSC 8 `file://` hyperlinks in several renderers. `read` titles for plain text and image files (the common case) emitted no link at all because the renderer only linked when a `resolvedPath` was recorded — which the ordinary file/image read paths never set, keeping the absolute path only in `meta.source`; the renderer now falls back to that source path. `write` headers were never wrapped in a hyperlink and now link to the absolute path written (file, archive entry, SQLite, and conflict resolutions). `edit`/`apply_patch` headers wrapped the model-supplied (often cwd-relative) argument path, producing a root-anchored `file:///rel/path` URI; they now link the absolute `details.path` instead. Finally, `search`, `ast_grep`, and `ast_edit` produced doubled link targets (`/proj/src/src/file.ts`) for searches scoped to a subdirectory, because the renderer resolved the cwd-relative display paths against the scope directory rather than cwd — the scoped-search base is now the session cwd (with the scoped file's absolute path still seeding single-file body lines).
- Fixed `omp dry-balance --bench` to recover from 401 token failures by re-minting the failing OAuth credential in place before switching accounts
- Fixed the bash tool corrupting commands that embed multi-byte UTF-8 (e.g. `✓`/`×` inside a `grep -E` pattern) ahead of a trailing `| head`/`| tail`. The `bash.stripTrailingHeadTail` rewrite cut at char-offset positions reported by `brush-parser` while slicing the command by byte offset, so the trailing-pipe strip landed mid-pattern and dropped the closing quote — turning `… |✓|×|XCTAssert" | tail -80` into `… |✓|×-80` and making execution fail with `pi-natives:command: unterminated double quote`. Fixed in `pi_shell::fixup` (`@oh-my-pi/pi-natives`).
@@ -36,15 +44,8 @@
- Fixed the collapsed tool-output expand hint rendering double brackets (e.g. `((Ctrl+O for more))`) — the `EXPAND_HINT` text already carried its own parentheses and then `formatExpandHint` wrapped it again with the theme's bracket glyphs. The hint now resolves the key actually bound to `app.tools.expand` at render time and reads `⟨<key>: Expand⟩` (e.g. `⟨Ctrl+O: Expand⟩`), so a single bracket pair surrounds it and a user remap of the expand keybinding is reflected instead of a hard-coded `Ctrl+O`.
- Fixed the `edit`/`apply_patch` tool dropping its outlined frame while streaming/in-progress (only the final result was framed); the in-progress diff preview now renders inside the same muted frame as the completed result.
- Fixed the `todo` and `job` tools rendering a success icon and success styling on a failed/error result; error results now show the error icon and a red frame border.
### Fixed
- Fixed `debug` tool refusing every `dlv` launch on Go modules. The launch handler ran `validateLaunchProgram` before adapter selection and rejected any directory program with `launch program resolves to a directory`, while dlv's default `mode=debug` requires a Go package path (a directory or `.go` source file). Adapter resolution now precedes validation, directory programs prefer adapters that advertise `acceptsDirectoryProgram` before falling back to native extensionless debuggers, the rejection only fires when the resolved adapter does not advertise that flag (set on `dlv` in `dap/defaults.json`), and dlv's `mode` is derived from the program shape — directories and `.go` files launch as `mode=debug`, other files as `mode=exec` — so `omp` can debug both Go packages and pre-built binaries ([#2020](https://github.com/can1357/oh-my-pi/issues/2020)).
### Removed
- Removed the `/background` (and `/bg`) slash command and the background-mode subsystem it was the sole entry point for — `InteractiveMode.isBackgrounded`, `createBackgroundUiContext`, `handleBackgroundEvent`, and every `isBackgrounded` guard across the input/event/extension-UI controllers and UI helpers. The command suspended the whole process group via `SIGTSTP` (a leftover testing shortcut) instead of detaching the running agent, which is not the expected workflow — use terminal panes or a multiplexer instead.
## [15.10.0] - 2026-06-06
### Breaking Changes
@@ -47,6 +47,21 @@ const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
"app.clipboard.copyPrompt": ["alt+shift+c"],
};
const BRACKETED_PASTE_START = "\x1b[200~";
const BRACKETED_PASTE_END = "\x1b[201~";
const BRACKETED_IMAGE_PATH_REGEX = /\.(?:png|jpe?g|gif|webp)$/i;
export function extractBracketedImagePastePath(data: string): string | undefined {
if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
if (endIndex === -1 || endIndex + BRACKETED_PASTE_END.length !== data.length) return undefined;
const pasted = data.slice(BRACKETED_PASTE_START.length, endIndex).trim();
if (!pasted || /[\r\n]/.test(pasted)) return undefined;
if (!BRACKETED_IMAGE_PATH_REGEX.test(pasted)) return undefined;
return pasted;
}
/**
* Custom editor that handles configurable app-level shortcuts for coding-agent.
*/
@@ -82,6 +97,8 @@ export class CustomEditor extends Editor {
onCopyPrompt?: () => void;
/** Called when the configured image-paste shortcut is pressed. */
onPasteImage?: () => Promise<boolean>;
/** Called when a bracketed paste contains exactly one image-file path. */
onPasteImagePath?: (path: string) => void;
/** Called when the configured raw text-paste shortcut is pressed. */
onPasteTextRaw?: () => void;
/** Called when the configured dequeue shortcut is pressed. */
@@ -137,6 +154,12 @@ export class CustomEditor extends Editor {
return;
}
const pastedImagePath = extractBracketedImagePastePath(data);
if (pastedImagePath && this.onPasteImagePath) {
this.onPasteImagePath(pastedImagePath);
return;
}
// Intercept configured image paste (async - fires and handles result)
if (this.#matchesAction(data, "app.clipboard.pasteImage") && this.onPasteImage) {
void this.onPasteImage();
@@ -1,4 +1,5 @@
import * as fs from "node:fs/promises";
import type { ImageContent } from "@oh-my-pi/pi-ai";
import type { AutocompleteProvider, SlashCommand } from "@oh-my-pi/pi-tui";
import { $env, logger, sanitizeText } from "@oh-my-pi/pi-utils";
import { getRoleInfo } from "../../config/model-registry";
@@ -17,7 +18,8 @@ import { tinyTitleClient } from "../../tiny/title-client";
import type { TinyTitleProgressEvent } from "../../tiny/title-protocol";
import { copyToClipboard, readImageFromClipboard, readTextFromClipboard } from "../../utils/clipboard";
import { getEditorCommand, openInEditor } from "../../utils/external-editor";
import { ensureSupportedImageInput } from "../../utils/image-loading";
import { EnhancedPasteController } from "../../utils/enhanced-paste";
import { ensureSupportedImageInput, ImageInputTooLargeError, loadImageInput } from "../../utils/image-loading";
import { resizeImage } from "../../utils/image-resize";
import { generateSessionTitle, setSessionTerminalTitle } from "../../utils/title-generator";
@@ -39,6 +41,8 @@ const TINY_TITLE_PROGRESS_REVEAL_DELAY_MS = 1_000;
export class InputController {
constructor(private ctx: InteractiveModeContext) {}
#enhancedPaste?: EnhancedPasteController;
#showTinyTitleDownloadProgress(modelKey: string): void {
if (!isTinyTitleLocalModelKey(modelKey)) return;
const component = new TinyTitleDownloadProgressComponent(modelKey);
@@ -175,6 +179,7 @@ export class InputController {
this.ctx.keybindings.getKeys("app.clipboard.pasteImage"),
);
this.ctx.editor.onPasteImage = () => this.handleImagePaste();
this.ctx.editor.onPasteImagePath = path => void this.handleImagePathPaste(path);
this.ctx.editor.setActionKeys(
"app.clipboard.pasteTextRaw",
this.ctx.keybindings.getKeys("app.clipboard.pasteTextRaw"),
@@ -222,6 +227,8 @@ export class InputController {
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showSessionObserver());
}
this.#setupEnhancedPaste();
this.ctx.editor.onChange = (text: string) => {
const wasBashMode = this.ctx.isBashMode;
const wasPythonMode = this.ctx.isPythonMode;
@@ -234,6 +241,24 @@ export class InputController {
};
}
#setupEnhancedPaste(): void {
if (this.#enhancedPaste) return;
this.#enhancedPaste = new EnhancedPasteController({
write: data => this.ctx.ui.terminal.write(data),
pasteText: text => {
this.ctx.editor.pasteText(text);
this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
},
pasteImage: async image => {
await this.#normalizeAndInsertPastedImage(image, `Unsupported pasted image format: ${image.mimeType}`);
},
showStatus: message => this.ctx.showStatus(message),
});
this.ctx.ui.addInputListener(data => (this.#enhancedPaste?.handleInput(data) ? { consume: true } : undefined));
this.ctx.ui.addStartListener(() => this.#enhancedPaste?.enable());
}
setupEditorSubmitHandler(): void {
this.ctx.editor.onSubmit = async (text: string) => {
text = text.trim();
@@ -613,62 +638,92 @@ export class InputController {
return allQueued.length;
}
async handleImagePaste(): Promise<boolean> {
try {
const image = await readImageFromClipboard();
if (image) {
const base64Data = image.data.toBase64();
let imageData = await ensureSupportedImageInput({
type: "image",
data: base64Data,
mimeType: image.mimeType,
});
if (!imageData) {
this.ctx.showStatus(`Unsupported clipboard image format: ${image.mimeType}`);
return false;
}
if (settings.get("images.autoResize")) {
try {
const resized = await resizeImage({
type: "image",
data: imageData.data,
mimeType: imageData.mimeType,
});
imageData = { type: "image", data: resized.data, mimeType: resized.mimeType };
} catch {
// Keep the normalized image when resize fails.
}
}
async #insertPendingImage(imageData: ImageContent): Promise<void> {
const imageLink = (
await materializeImageReferenceLinks(
[
{
type: "image",
data: imageData.data,
mimeType: imageData.mimeType,
},
],
this.ctx.sessionManager.putBlob.bind(this.ctx.sessionManager),
)
)?.[0];
this.ctx.pendingImages.push({
type: "image",
data: imageData.data,
mimeType: imageData.mimeType,
});
this.ctx.pendingImageLinks.push(imageLink);
this.ctx.editor.imageLinks = this.ctx.pendingImageLinks;
const imageNum = this.ctx.pendingImages.length;
this.ctx.editor.insertText(`[Image #${imageNum}] `);
this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
}
const imageLink = (
await materializeImageReferenceLinks(
[
{
type: "image",
data: imageData.data,
mimeType: imageData.mimeType,
},
],
this.ctx.sessionManager.putBlob.bind(this.ctx.sessionManager),
)
)?.[0];
this.ctx.pendingImages.push({
async #normalizeAndInsertPastedImage(image: ImageContent, unsupportedMessage: string): Promise<boolean> {
let imageData = await ensureSupportedImageInput(image);
if (!imageData) {
this.ctx.showStatus(unsupportedMessage);
return false;
}
if (settings.get("images.autoResize")) {
try {
const resized = await resizeImage({
type: "image",
data: imageData.data,
mimeType: imageData.mimeType,
});
this.ctx.pendingImageLinks.push(imageLink);
this.ctx.editor.imageLinks = this.ctx.pendingImageLinks;
// Insert placeholder at cursor like Claude does
const imageNum = this.ctx.pendingImages.length;
const placeholder = `[Image #${imageNum}]`;
this.ctx.editor.insertText(`${placeholder} `);
this.ctx.ui.requestRender();
return true;
imageData = { type: "image", data: resized.data, mimeType: resized.mimeType };
} catch {
// Keep the normalized image when resize fails.
}
// No image in clipboard - show hint
this.ctx.showStatus("No image in clipboard (use terminal paste for text)");
return false;
}
await this.#insertPendingImage(imageData);
return true;
}
async handleImagePathPaste(path: string): Promise<void> {
try {
const image = await loadImageInput({
path,
cwd: this.ctx.sessionManager.getCwd(),
autoResize: false,
});
if (!image) {
this.ctx.editor.pasteText(path);
this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
this.ctx.showStatus("Pasted path is not a supported image");
return;
}
await this.#normalizeAndInsertPastedImage(
{ type: "image", data: image.data, mimeType: image.mimeType },
`Unsupported pasted image format: ${image.mimeType}`,
);
} catch (error) {
this.ctx.editor.pasteText(path);
this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
this.ctx.showStatus(error instanceof ImageInputTooLargeError ? error.message : "Failed to read pasted image path");
}
}
async handleImagePaste(): Promise<boolean> {
try {
const image = await readImageFromClipboard();
if (!image) {
this.ctx.showStatus("No image in clipboard (use terminal paste for text)");
return false;
}
return await this.#normalizeAndInsertPastedImage(
{
type: "image",
data: image.data.toBase64(),
mimeType: image.mimeType,
},
`Unsupported clipboard image format: ${image.mimeType}`,
);
} catch {
this.ctx.showStatus("Failed to read clipboard");
return false;
@@ -0,0 +1,202 @@
import type { ImageContent } from "@oh-my-pi/pi-ai";
const OSC5522_PREFIX = "\x1b]5522;";
const OSC_TERMINATOR_ST = "\x1b\\";
const OSC_TERMINATOR_BEL = "\x07";
const PASTE_EVENT_NAME_BASE64 = Buffer.from("Paste event", "utf8").toString("base64");
const IMAGE_MIME_PRIORITY = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const;
const TEXT_MIME_TYPE = "text/plain";
type PasteReadKind = "image" | "text";
export interface Osc5522Packet {
metadata: Map<string, string>;
payload: string;
}
interface PasteListingState {
phase: "listing";
mimes: string[];
pw?: string;
loc?: string;
}
interface PasteReadState {
phase: "reading";
kind: PasteReadKind;
mimeType: string;
chunks: string[];
}
type PasteState = PasteListingState | PasteReadState;
export interface EnhancedPasteHandlers {
write(data: string): void;
pasteText(text: string): void;
pasteImage(image: ImageContent): void | Promise<void>;
showStatus(message: string): void;
}
export function isOsc5522Packet(data: string): boolean {
return data.startsWith(OSC5522_PREFIX) && (data.endsWith(OSC_TERMINATOR_ST) || data.endsWith(OSC_TERMINATOR_BEL));
}
function decodeBase64Utf8(value: string): string | undefined {
try {
return Buffer.from(value, "base64").toString("utf8");
} catch {
return undefined;
}
}
function parseMetadata(raw: string): Map<string, string> {
const metadata = new Map<string, string>();
for (const part of raw.split(":")) {
const eq = part.indexOf("=");
if (eq <= 0) continue;
metadata.set(part.slice(0, eq), part.slice(eq + 1));
}
return metadata;
}
export function parseOsc5522Packet(data: string): Osc5522Packet | undefined {
if (!isOsc5522Packet(data)) return undefined;
const bodyEnd = data.endsWith(OSC_TERMINATOR_BEL) ? data.length - 1 : data.length - OSC_TERMINATOR_ST.length;
const body = data.slice(OSC5522_PREFIX.length, bodyEnd);
const separator = body.indexOf(";");
const metadataRaw = separator === -1 ? body : body.slice(0, separator);
const payload = separator === -1 ? "" : body.slice(separator + 1);
return { metadata: parseMetadata(metadataRaw), payload };
}
function choosePasteMime(mimes: readonly string[]): { kind: PasteReadKind; mimeType: string } | undefined {
for (const mimeType of IMAGE_MIME_PRIORITY) {
if (mimes.includes(mimeType)) return { kind: "image", mimeType };
}
return mimes.includes(TEXT_MIME_TYPE) ? { kind: "text", mimeType: TEXT_MIME_TYPE } : undefined;
}
export class EnhancedPasteController {
#state: PasteState | undefined;
#handlers: EnhancedPasteHandlers;
constructor(handlers: EnhancedPasteHandlers) {
this.#handlers = handlers;
}
enable(): void {
this.#handlers.write("\x1b[?5522h");
}
disable(): void {
this.#handlers.write("\x1b[?5522l");
this.#state = undefined;
}
handleInput(data: string): boolean {
const packet = parseOsc5522Packet(data);
if (!packet) return false;
void this.#handlePacket(packet);
return true;
}
async #handlePacket(packet: Osc5522Packet): Promise<void> {
const type = packet.metadata.get("type");
if (type !== "read") return;
const status = packet.metadata.get("status");
if (status === "OK") {
this.#handleOk(packet);
return;
}
if (status === "DATA") {
this.#handleData(packet);
return;
}
if (status === "DONE") {
await this.#handleDone();
return;
}
if (status) {
this.#state = undefined;
this.#handlers.showStatus(`Enhanced paste failed: ${status}`);
}
}
#handleOk(packet: Osc5522Packet): void {
if (this.#state?.phase === "reading") return;
const loc = packet.metadata.get("loc");
this.#state = {
phase: "listing",
mimes: [],
pw: packet.metadata.get("pw"),
loc: loc === "primary" ? loc : undefined,
};
}
#handleData(packet: Osc5522Packet): void {
const state = this.#state;
if (!state) return;
const encodedMime = packet.metadata.get("mime");
if (!encodedMime) return;
const mimeType = decodeBase64Utf8(encodedMime);
if (!mimeType) return;
if (state.phase === "listing") {
state.mimes.push(mimeType);
return;
}
if (state.mimeType === mimeType && packet.payload) {
state.chunks.push(packet.payload);
}
}
async #handleDone(): Promise<void> {
const state = this.#state;
if (!state) return;
if (state.phase === "listing") {
this.#finishListing(state);
return;
}
this.#state = undefined;
const bytes = Buffer.concat(state.chunks.map(chunk => Buffer.from(chunk, "base64")));
if (bytes.byteLength === 0) {
this.#handlers.showStatus("Clipboard paste was empty");
return;
}
if (state.kind === "text") {
this.#handlers.pasteText(bytes.toString("utf8"));
return;
}
await this.#handlers.pasteImage({
type: "image",
data: bytes.toString("base64"),
mimeType: state.mimeType,
});
}
#finishListing(state: PasteListingState): void {
const selected = choosePasteMime(state.mimes);
if (!selected) {
this.#state = undefined;
this.#handlers.showStatus("Clipboard paste has no supported text or image data");
return;
}
this.#state = {
phase: "reading",
kind: selected.kind,
mimeType: selected.mimeType,
chunks: [],
};
const metadata = [`type=read`, `mime=${Buffer.from(selected.mimeType, "utf8").toString("base64")}`];
if (state.loc) metadata.push(`loc=${state.loc}`);
if (state.pw) {
metadata.push(`pw=${state.pw}`, `name=${PASTE_EVENT_NAME_BASE64}`);
}
this.#handlers.write(`${OSC5522_PREFIX}${metadata.join(":")}${OSC_TERMINATOR_ST}`);
}
}
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "bun:test";
import { defaultEditorTheme } from "../../tui/test/test-themes";
import { CustomEditor } from "../src/modes/components/custom-editor";
import { CustomEditor, extractBracketedImagePastePath } from "../src/modes/components/custom-editor";
function ctrl(key: string): string {
return String.fromCharCode(key.toLowerCase().charCodeAt(0) & 31);
@@ -20,6 +20,23 @@ describe("CustomEditor literal question mark input", () => {
});
});
describe("CustomEditor bracketed image path paste", () => {
it("routes a single pasted image path to the image-path handler", () => {
const editor = createEditor();
const paths: string[] = [];
editor.onPasteImagePath = path => paths.push(path);
editor.handleInput("\x1b[200~/tmp/screenshot.png\x1b[201~");
expect(paths).toEqual(["/tmp/screenshot.png"]);
expect(editor.getText()).toBe("");
});
it("leaves ordinary bracketed paste text on the editor path", () => {
expect(extractBracketedImagePastePath("\x1b[200~not an image.txt\x1b[201~")).toBeUndefined();
});
});
describe("CustomEditor temporary model selector keybinding", () => {
it("triggers the temporary selector from a remapped action key instead of Alt+P", () => {
const editor = createEditor();
@@ -0,0 +1,109 @@
import { describe, expect, it } from "bun:test";
import { EnhancedPasteController } from "../../src/utils/enhanced-paste";
const ST = "\x1b\\";
const OSC = "\x1b]5522;";
function packet(metadata: string, payload?: string): string {
return `${OSC}${metadata}${payload === undefined ? "" : `;${payload}`}${ST}`;
}
describe("EnhancedPasteController", () => {
it("requests image data from an OSC 5522 paste event and preserves chunk boundaries", () => {
const writes: string[] = [];
const pastedImages: Array<{ data: string; mimeType: string }> = [];
const statuses: string[] = [];
const controller = new EnhancedPasteController({
write: data => writes.push(data),
pasteText: () => statuses.push("unexpected text paste"),
pasteImage: image => {
pastedImages.push({ data: image.data, mimeType: image.mimeType });
},
showStatus: message => statuses.push(message),
});
controller.enable();
expect(writes).toEqual(["\x1b[?5522h"]);
const imageMime = Buffer.from("image/png", "utf8").toString("base64");
const textMime = Buffer.from("text/plain", "utf8").toString("base64");
const password = Buffer.from("secret123", "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=DATA:mime=${imageMime}`));
controller.handleInput(packet("type=read:status=DONE"));
const pasteEventName = Buffer.from("Paste event", "utf8").toString("base64");
expect(writes.at(-1)).toBe(`${OSC}type=read:mime=${imageMime}:pw=${password}:name=${pasteEventName}${ST}`);
controller.handleInput(packet("type=read:status=OK"));
controller.handleInput(
packet(`type=read:status=DATA:mime=${imageMime}`, Buffer.from("image-", "utf8").toString("base64")),
);
controller.handleInput(
packet(`type=read:status=DATA:mime=${imageMime}`, Buffer.from("bytes", "utf8").toString("base64")),
);
controller.handleInput(packet("type=read:status=DONE"));
expect(pastedImages).toEqual([
{
data: Buffer.from("image-bytes", "utf8").toString("base64"),
mimeType: "image/png",
},
]);
expect(statuses).toEqual([]);
});
it("falls back to text/plain and carries primary-selection location into the read request", () => {
const writes: string[] = [];
const pastedText: string[] = [];
const controller = new EnhancedPasteController({
write: data => writes.push(data),
pasteText: text => pastedText.push(text),
pasteImage: () => {
throw new Error("unexpected image paste");
},
showStatus: message => pastedText.push(`status:${message}`),
});
const textMime = Buffer.from("text/plain", "utf8").toString("base64");
const password = Buffer.from("secret456", "utf8").toString("base64");
expect(controller.handleInput("plain text")).toBe(false);
controller.handleInput(packet(`type=read:status=OK:loc=primary:pw=${password}`));
controller.handleInput(packet(`type=read:status=DATA:mime=${textMime}`));
controller.handleInput(packet("type=read:status=DONE"));
expect(writes).toHaveLength(1);
expect(writes[0]).toContain(`mime=${textMime}`);
expect(writes[0]).toContain("loc=primary");
expect(writes[0]).toContain(`pw=${password}`);
controller.handleInput(packet("type=read:status=OK"));
controller.handleInput(
packet(`type=read:status=DATA:mime=${textMime}`, Buffer.from("hello ", "utf8").toString("base64")),
);
controller.handleInput(
packet(`type=read:status=DATA:mime=${textMime}`, Buffer.from("world", "utf8").toString("base64")),
);
controller.handleInput(packet("type=read:status=DONE"));
expect(pastedText).toEqual(["hello world"]);
});
it("reports unsupported paste events instead of leaking OSC packets to the editor", () => {
const statuses: string[] = [];
const controller = new EnhancedPasteController({
write: () => {},
pasteText: () => {},
pasteImage: () => {},
showStatus: message => statuses.push(message),
});
const htmlMime = Buffer.from("text/html", "utf8").toString("base64");
expect(controller.handleInput(packet("type=read:status=OK"))).toBe(true);
expect(controller.handleInput(packet(`type=read:status=DATA:mime=${htmlMime}`))).toBe(true);
expect(controller.handleInput(packet("type=read:status=DONE"))).toBe(true);
expect(statuses).toEqual(["Clipboard paste has no supported text or image data"]);
});
});
+3 -1
View File
@@ -1,7 +1,6 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Removed Kitty temp-file image transmission, its startup support probe, the `PI_KITTY_IMAGE_TRANSMISSION` override, and the temp-file helper exports. Kitty/Ghostty image payloads now stay on in-band base64 before placeholder/direct placement, avoiding blank first renders from temp-file load races.
@@ -9,6 +8,8 @@
### Added
- Added `TUI.addStartListener()` so feature hooks can re-enable terminal modes after temporary stop/start cycles such as external-editor handoffs.
- Added `Editor.pasteText()` to apply terminal-style paste handling for text inserted from non-bracketed paste transports
- Added an optional `dispose()` lifecycle method to `Component` so components can release timers and subscriptions during permanent teardown
- Added `Container.dispose()` to propagate teardown to child components when a component tree is permanently discarded
- Added `Loader.dispose()` to stop the loader animation timer when the component is disposed
@@ -24,6 +25,7 @@
### Fixed
- Fixed terminal stop and restore cleanup to disable enhanced paste mode so it does not remain enabled after shutdown
- Removed the per-frame line-fit `Map` cache from the render timer path to avoid forcing JSC rope-string hashing during scheduled viewport repaints.
- Fixed `visibleWidth()` so terminal column measurements for ANSI and OSC text now match the native truncation/wrapping helpers, including OSC 66 text-sizing spans being counted at their scaled payload width
- Fixed cursor, padding, and line-fit behavior when strings contain tabs or OSC escapes by aligning `visibleWidth()` with the native text-width model
+5
View File
@@ -1495,6 +1495,11 @@ export class Editor implements Component, Focusable {
this.#insertTextAtCursor(text);
}
/** Apply terminal paste semantics to text from non-bracketed paste transports. */
pasteText(text: string): void {
this.#handlePaste(text);
}
// All the editor methods from before...
#insertCharacter(char: string): void {
this.#exitHistoryForEditing();
+2
View File
@@ -39,6 +39,7 @@ export function emergencyTerminalRestore(): void {
"\x1b[?2004l" + // Disable bracketed paste
"\x1b[?2031l" + // Disable Mode 2031 appearance notifications
"\x1b[?2048l" + // Disable in-band resize notifications
"\x1b[?5522l" + // Disable enhanced paste notifications
"\x1b[<u" + // Pop kitty keyboard protocol
"\x1b[>4;0m" + // Disable modifyOtherKeys fallback
"\x1b[?25h", // Show cursor
@@ -884,6 +885,7 @@ export class ProcessTerminal implements Terminal {
// Disable bracketed paste mode
this.#safeWrite("\x1b[?2004l");
this.#safeWrite("\x1b[?5522l");
// Disable Mode 2031 appearance change notifications
this.#safeWrite("\x1b[?2031l");
+16
View File
@@ -76,6 +76,7 @@ const MOUSE_TRACKING_OFF = "\x1b[?1006l\x1b[?1000l";
type InputListenerResult = { consume?: boolean; data?: string } | undefined;
type InputListener = (data: string) => InputListenerResult;
type StartListener = () => void;
export interface RenderTimer {
cancel(): void;
@@ -437,6 +438,7 @@ export class TUI extends Container {
#previousHeight = 0;
#focusedComponent: Component | null = null;
#inputListeners = new Set<InputListener>();
#startListeners = new Set<StartListener>();
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
onDebug?: () => void;
@@ -806,12 +808,26 @@ export class TUI extends Container {
this.requestRender(true);
},
);
for (const listener of this.#startListeners) {
try {
listener();
} catch {
// Startup listeners are feature hooks; one broken hook must not prevent rendering.
}
}
this.terminal.hideCursor();
this.#querySixelSupport();
this.#queryCellSize();
this.requestRender(true);
}
addStartListener(listener: StartListener): () => void {
this.#startListeners.add(listener);
return () => {
this.#startListeners.delete(listener);
};
}
addInputListener(listener: InputListener): () => void {
this.#inputListeners.add(listener);
return () => {
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "bun:test";
import { TUI } from "@oh-my-pi/pi-tui";
import { VirtualTerminal } from "./virtual-terminal";
describe("TUI start listeners", () => {
it("fires registered hooks on initial start and restart", () => {
const tui = new TUI(new VirtualTerminal(80, 24));
let starts = 0;
tui.addStartListener(() => {
starts++;
});
try {
tui.start();
expect(starts).toBe(1);
tui.stop();
tui.start();
expect(starts).toBe(2);
} finally {
tui.stop();
}
});
});
+1 -1
View File
@@ -134,7 +134,7 @@ export class VirtualTerminal implements Terminal {
}
stop(): void {
this.#engineWrite("\x1b[?2004l");
this.#engineWrite("\x1b[?2004l\x1b[?5522l");
this.#inputHandler = undefined;
this.#resizeHandler = undefined;
}