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:
@@ -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.
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user