diff --git a/docs/keybindings.md b/docs/keybindings.md index 9fa872698..dfc881bbe 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -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. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7c664e057..1632c86fe 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 `⟨: 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 diff --git a/packages/coding-agent/src/modes/components/custom-editor.ts b/packages/coding-agent/src/modes/components/custom-editor.ts index aaf0511a9..835ea4ec3 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.ts @@ -47,6 +47,21 @@ const DEFAULT_ACTION_KEYS: Record = { "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; + /** 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(); diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 70b166eea..a16f52467 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -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 { - 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 { + 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 { + 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 { + 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 { + 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; diff --git a/packages/coding-agent/src/utils/enhanced-paste.ts b/packages/coding-agent/src/utils/enhanced-paste.ts new file mode 100644 index 000000000..16fdf416e --- /dev/null +++ b/packages/coding-agent/src/utils/enhanced-paste.ts @@ -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; + 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; + 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 { + const metadata = new Map(); + 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 { + 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 { + 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}`); + } +} diff --git a/packages/coding-agent/test/custom-editor-keybindings.test.ts b/packages/coding-agent/test/custom-editor-keybindings.test.ts index 19db03cff..3b32a5282 100644 --- a/packages/coding-agent/test/custom-editor-keybindings.test.ts +++ b/packages/coding-agent/test/custom-editor-keybindings.test.ts @@ -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(); diff --git a/packages/coding-agent/test/utils/enhanced-paste.test.ts b/packages/coding-agent/test/utils/enhanced-paste.test.ts new file mode 100644 index 000000000..d319c1362 --- /dev/null +++ b/packages/coding-agent/test/utils/enhanced-paste.test.ts @@ -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"]); + }); +}); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 16fef49df..bbcc401e3 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 326ade5f7..2ea05e43e 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -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(); diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 8f8ac4337..2069f6370 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -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[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"); diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 91a5a1b44..c4bc76d99 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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(); + #startListeners = new Set(); /** 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 () => { diff --git a/packages/tui/test/start-listener.test.ts b/packages/tui/test/start-listener.test.ts new file mode 100644 index 000000000..3e5f372b1 --- /dev/null +++ b/packages/tui/test/start-listener.test.ts @@ -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(); + } + }); +}); diff --git a/packages/tui/test/virtual-terminal.ts b/packages/tui/test/virtual-terminal.ts index f4c6d8945..fec8bf054 100644 --- a/packages/tui/test/virtual-terminal.ts +++ b/packages/tui/test/virtual-terminal.ts @@ -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; }