From 4f20d104544e6980f8f0d4d71c534130748234e1 Mon Sep 17 00:00:00 2001 From: roboomp Date: Wed, 24 Jun 2026 14:21:45 +0000 Subject: [PATCH] fix(tui): attached pasted file paths Converted bracketed non-image filesystem path pastes into session-local attachment references while preserving the existing image path flow. Added regression coverage for editor routing and controller local file attachment behavior. Fixes #3360 --- packages/coding-agent/CHANGELOG.md | 2 + .../modes/components/custom-editor.test.ts | 19 ++- .../src/modes/components/custom-editor.ts | 122 +++++++++++------- .../src/modes/controllers/input-controller.ts | 75 ++++++++++- .../test/input-controller-large-paste.test.ts | 41 +++++- 5 files changed, 209 insertions(+), 50 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 03ec72a40..3a004b1e4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixed +- Fixed pasted or dragged non-image file paths in the TUI prompt staying as inert raw text; existing files now attach as clean `local://attachment-N.` references while image paths keep the image attachment flow. ([#3360](https://github.com/can1357/oh-my-pi/issues/3360)) + - Fixed all extension loading silently failing on the cross-compiled `omp-darwin-arm64` release binary (downloaded directly or via a Homebrew tap wrapper) because `__computeBunfsPackageRoot` mis-handled `import.meta.dir = "//root/omp-darwin-arm64"`. Bun 1.3.14 reports `/` for the compiled entry's `import.meta.dir`, but the pre-fix function joined `metaDir + "packages"` and produced `/root/omp-darwin-arm64/packages` — the binary basename was baked into every bunfs path, so the TypeBox/legacy-pi shims and every `@oh-my-pi/pi-*` package-root override failed `existsSync` validation and `resolveCanonicalPiSpecifier` fell through to a bunfs `Bun.resolveSync` that also could not find the module. The function now detects the bunfs-root + binary-basename shape (`path.basename(path.dirname(metaDir)) === "root"`) and strips the trailing binary segment by slicing the original `metaDir`; the production bunfs shim join path also preserves Bun's bunfs-native `//root` / `B:\~BUN\root` prefix that `path.join` would otherwise collapse. ([#3329](https://github.com/can1357/oh-my-pi/issues/3329)) - Fixed llama.cpp discovery to prefer per-model `/v1/models` `meta.n_ctx`/`meta.n_ctx_train` values, refresh selected models after lazy load, and bypass fresh-cache reuse so server restarts update context windows. ([#3310](https://github.com/can1357/oh-my-pi/issues/3310)) - Fixed `task.maxConcurrency: 0` serializing subagent spawns instead of running them unbounded. The settings UI labels `0` as "Unlimited", but the session-scoped spawn `Semaphore` clamped `max` via `Math.max(1, max)`, so the second subagent body in a batch always waited for the first to release the seat. The constructor now treats `max <= 0` (and any non-finite input) as unbounded via `Number.POSITIVE_INFINITY`, matching the eval `parallel()`/`pipeline()` worker-pool semantics ([#3305](https://github.com/can1357/oh-my-pi/issues/3305)). diff --git a/packages/coding-agent/src/modes/components/custom-editor.test.ts b/packages/coding-agent/src/modes/components/custom-editor.test.ts index d779d53ea..13a3b4a5b 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.test.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.test.ts @@ -4,6 +4,7 @@ import { getEditorTheme, initTheme } from "../theme/theme"; import { CustomEditor, extractBracketedImagePastePaths, + extractBracketedPastePaths, SPACE_HOLD_MECHANICAL_RUN, SPACE_HOLD_RELEASE_MS, SPACE_REPEAT_MAX_GAP_MS, @@ -73,7 +74,7 @@ describe("CustomEditor placeholder decoration", () => { }); }); -describe("CustomEditor bracketed image-path paste", () => { +describe("CustomEditor bracketed path paste", () => { it("leaves a pasted bare .png filename on the normal text path", () => { expect(extractBracketedImagePastePaths(bracketedPaste("icon-photo-default.png"))).toBeUndefined(); }); @@ -86,6 +87,22 @@ describe("CustomEditor bracketed image-path paste", () => { "C:\\Users\\me\\icon-photo-default.png", ]); }); + + it("extracts explicit non-image paths without classifying them as image paths", () => { + expect(extractBracketedPastePaths(bracketedPaste("/tmp/report.csv"))).toEqual(["/tmp/report.csv"]); + expect(extractBracketedImagePastePaths(bracketedPaste("/tmp/report.csv"))).toBeUndefined(); + }); + + it("routes non-image path pastes through the file-path hook", async () => { + const { editor } = makeEditor(); + const pasted = Promise.withResolvers(); + editor.onPasteFilePath = path => pasted.resolve(path); + + editor.handleInput(bracketedPaste("/tmp/report.csv")); + + expect(await pasted.promise).toBe("/tmp/report.csv"); + expect(editor.getText()).toBe(""); + }); }); describe("CustomEditor space-hold push-to-talk", () => { diff --git a/packages/coding-agent/src/modes/components/custom-editor.ts b/packages/coding-agent/src/modes/components/custom-editor.ts index 88353b391..1e7beca30 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.ts @@ -62,7 +62,6 @@ function buildMatchKeys(keys: readonly KeyId[]): Set { const BRACKETED_PASTE_START = "\x1b[200~"; const BRACKETED_PASTE_END = "\x1b[201~"; const BRACKETED_IMAGE_PATH_REGEX = /\.(?:png|jpe?g|gif|webp)$/i; -const BRACKETED_IMAGE_PATH_BOUNDARY_REGEX = /\.(?:png|jpe?g|gif|webp)(?=$|["']?\s)/gi; const SHELL_ESCAPED_PATH_CHAR_REGEX = /\\([\\\s'"()[\]{}&;<>|?*!$`])/g; const URI_SCHEME_REGEX = /^[a-z][a-z0-9+.-]*:/i; const FILE_URI_REGEX = /^file:\/\//i; @@ -100,19 +99,7 @@ function isPastedPathSeparator(char: string | undefined): boolean { return char === undefined || char === " " || char === "\t" || char === "\r" || char === "\n"; } -function imagePathBoundaryEnd(payload: string, segmentStart: number, extensionEnd: number): number | undefined { - const quote = payload[segmentStart]; - const afterExtension = payload[extensionEnd]; - if (quote === '"' || quote === "'") { - return afterExtension === quote && isPastedPathSeparator(payload[extensionEnd + 1]) - ? extensionEnd + 1 - : undefined; - } - if (isPastedPathSeparator(afterExtension)) return extensionEnd; - return undefined; -} - -function normalizePastedImagePath(path: string): string { +function normalizePastedPath(path: string): string { const trimmed = path.trim(); const first = trimmed[0]; const last = trimmed[trimmed.length - 1]; @@ -121,13 +108,60 @@ function normalizePastedImagePath(path: string): string { return unquoted.replace(SHELL_ESCAPED_PATH_CHAR_REGEX, "$1"); } -function isExplicitPastedImagePath(path: string): boolean { +function isExplicitPastedPath(path: string): boolean { if (WINDOWS_DRIVE_PATH_REGEX.test(path) || FILE_URI_REGEX.test(path)) return true; if (URI_SCHEME_REGEX.test(path)) return false; return path.includes("/") || path.includes("\\"); } -export function extractBracketedImagePastePaths(data: string): string[] | undefined { +function isImagePath(path: string): boolean { + return BRACKETED_IMAGE_PATH_REGEX.test(path); +} + +function splitPastedPathSegments(payload: string): string[] | undefined { + const segments: string[] = []; + let segment = ""; + let quote: string | undefined; + let escaped = false; + + for (let i = 0; i < payload.length; i++) { + const char = payload[i]; + if (escaped) { + segment += char; + escaped = false; + continue; + } + if (char === "\\") { + segment += char; + escaped = true; + continue; + } + if (quote) { + segment += char; + if (char === quote) quote = undefined; + continue; + } + if (char === '"' || char === "'") { + segment += char; + quote = char; + continue; + } + if (isPastedPathSeparator(char)) { + if (segment) { + segments.push(segment); + segment = ""; + } + continue; + } + segment += char; + } + + if (escaped || quote) return undefined; + if (segment) segments.push(segment); + return segments.length > 0 ? segments : undefined; +} + +export function extractBracketedPastePaths(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; @@ -135,33 +169,23 @@ export function extractBracketedImagePastePaths(data: string): string[] | undefi const pasted = data.slice(BRACKETED_PASTE_START.length, endIndex).trim(); if (!pasted) return undefined; + const segments = splitPastedPathSegments(pasted); + if (!segments) return undefined; + const paths: string[] = []; - let segmentStart = 0; - BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.lastIndex = 0; - for ( - let match = BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.exec(pasted); - match; - match = BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.exec(pasted) - ) { - const extensionEnd = match.index + match[0].length; - const boundaryEnd = imagePathBoundaryEnd(pasted, segmentStart, extensionEnd); - if (boundaryEnd === undefined) continue; - - const path = normalizePastedImagePath(pasted.slice(segmentStart, boundaryEnd)); - if (!path || !BRACKETED_IMAGE_PATH_REGEX.test(path) || !isExplicitPastedImagePath(path)) return undefined; + for (const segment of segments) { + const path = normalizePastedPath(segment); + if (!path || !isExplicitPastedPath(path)) return undefined; paths.push(path); - - segmentStart = boundaryEnd; - while (segmentStart < pasted.length && isPastedPathSeparator(pasted[segmentStart])) { - segmentStart++; - } - BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.lastIndex = segmentStart; } - - if (paths.length === 0 || segmentStart !== pasted.length) return undefined; return paths; } +export function extractBracketedImagePastePaths(data: string): string[] | undefined { + const paths = extractBracketedPastePaths(data); + return paths?.every(isImagePath) ? paths : undefined; +} + export function extractBracketedImagePastePath(data: string): string | undefined { const paths = extractBracketedImagePastePaths(data); return paths?.length === 1 ? paths[0] : undefined; @@ -294,6 +318,8 @@ export class CustomEditor extends Editor { onPasteImage?: () => Promise; /** Called when a bracketed paste contains one or more image-file paths. */ onPasteImagePath?: (path: string) => void | Promise; + /** Called when a bracketed paste contains one or more non-image file paths. */ + onPasteFilePath?: (path: string) => void | Promise; /** Called when the configured raw text-paste shortcut is pressed. */ onPasteTextRaw?: () => void; /** Called when the configured dequeue shortcut is pressed. */ @@ -479,14 +505,20 @@ export class CustomEditor extends Editor { return; } - const pastedImagePaths = extractBracketedImagePastePaths(data); - if (pastedImagePaths && this.onPasteImagePath) { - void (async () => { - for (const path of pastedImagePaths) { - await this.onPasteImagePath?.(path); - } - })(); - return; + const pastedPaths = extractBracketedPastePaths(data); + if (pastedPaths) { + const canHandlePaths = pastedPaths.every(path => + isImagePath(path) ? this.onPasteImagePath !== undefined : this.onPasteFilePath !== undefined, + ); + if (canHandlePaths) { + void (async () => { + for (const path of pastedPaths) { + if (isImagePath(path)) await this.onPasteImagePath?.(path); + else await this.onPasteFilePath?.(path); + } + })(); + return; + } } const parsedKey = parseKey(data); diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 88c54933a..cafc4b18a 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -1,5 +1,6 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; +import { fileURLToPath } from "node:url"; import type { ImageContent } from "@oh-my-pi/pi-ai"; import { type AutocompleteProvider, matchesKey, type SlashCommand } from "@oh-my-pi/pi-tui"; import { $env, isEnoent, logger, sanitizeText } from "@oh-my-pi/pi-utils"; @@ -19,6 +20,7 @@ import { isTinyTitleLocalModelKey } from "../../tiny/models"; import { isLowSignalTitleInput } from "../../tiny/text"; import { tinyTitleClient } from "../../tiny/title-client"; import type { TinyTitleProgressEvent } from "../../tiny/title-protocol"; +import { resolveReadPath } from "../../tools/path-utils"; import { shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils"; import { copyToClipboard, readImageFromClipboard, readTextFromClipboard } from "../../utils/clipboard"; import { EnhancedPasteController } from "../../utils/enhanced-paste"; @@ -69,6 +71,20 @@ function wrapPasteInAttachmentBlock(content: string): string { return `\n${content}\n`; } +const FILE_URI_REGEX = /^file:\/\//i; + +function pastedFileAttachmentExtension(sourcePath: string): string { + const ext = path.extname(sourcePath); + const bareExt = ext.slice(1); + if (!bareExt || bareExt.length > 32 || !/^[a-z0-9][a-z0-9._-]*$/i.test(bareExt)) return ""; + return ext; +} + +function resolvePastedFilePath(filePath: string, cwd: string): string { + if (FILE_URI_REGEX.test(filePath)) return fileURLToPath(filePath); + return resolveReadPath(filePath, cwd); +} + const TINY_TITLE_PROGRESS_DONE_TTL_MS = 3_000; // A cached model fires its file-load events in a short burst and then goes silent // while onnxruntime builds the session; a genuine download keeps streaming progress @@ -104,8 +120,8 @@ export class InputController { // (>= LEFT_DOUBLE_TAP_MAX_GAP_MS) starts a fresh sequence. See // #detectLeftDoubleTap. #leftTapCount = 0; - // Sequential index for `local://attachment-N` references created by the large-paste local-file - // action. Seeded from 0 and bumped past any existing attachment files in #attachPasteAsFile. + // Sequential index for `local://attachment-N` references created by large-paste and + // pasted-file attachments. Seeded from 0 and bumped past existing attachment files. #attachmentCounter = 0; #showTinyTitleDownloadProgress(modelKey: string): void { @@ -343,6 +359,7 @@ export class InputController { ); this.ctx.editor.onPasteImage = () => this.handleImagePaste(); this.ctx.editor.onPasteImagePath = path => this.handleImagePathPaste(path); + this.ctx.editor.onPasteFilePath = path => this.handleFilePathPaste(path); this.ctx.editor.setActionKeys( "app.clipboard.pasteTextRaw", this.ctx.keybindings.getKeys("app.clipboard.pasteTextRaw"), @@ -1203,6 +1220,37 @@ export class InputController { } } + async handleFilePathPaste(filePath: string): Promise { + try { + const resolvedPath = resolvePastedFilePath(filePath, this.ctx.sessionManager.getCwd()); + const stat = await Bun.file(resolvedPath).stat(); + if (!stat.isFile()) { + this.ctx.editor.pasteText(filePath); + this.ctx.ui.requestRender(); + this.ctx.showStatus("Pasted path is not a file"); + return; + } + + const reference = await this.#attachExistingFileAsLocal(resolvedPath); + this.ctx.editor.insertText(`${reference} `); + this.ctx.ui.requestRender(); + this.ctx.showStatus(`Attached file as ${reference}`); + } catch (error) { + if (isEnoent(error)) { + this.ctx.editor.pasteText(filePath); + this.ctx.ui.requestRender(); + this.ctx.showStatus("Pasted file path was not found"); + return; + } + logger.warn("failed to attach pasted file path", { + error: error instanceof Error ? error.message : String(error), + }); + this.ctx.editor.pasteText(filePath); + this.ctx.ui.requestRender(); + this.ctx.showError("Failed to attach pasted file path — pasted path inline instead"); + } + } + async handleImagePathPaste(path: string): Promise { try { const image = await loadImageInput({ @@ -1373,6 +1421,29 @@ export class InputController { this.ctx.ui.requestRender(); } + async #attachExistingFileAsLocal(sourcePath: string): Promise { + const localRoot = resolveLocalRoot({ + getArtifactsDir: () => this.ctx.sessionManager.getArtifactsDir(), + getSessionId: () => this.ctx.sessionManager.getSessionId(), + }); + await fs.mkdir(localRoot, { recursive: true }); + const ext = pastedFileAttachmentExtension(sourcePath); + let name: string; + let filePath: string; + do { + this.#attachmentCounter++; + name = `attachment-${this.#attachmentCounter}${ext}`; + filePath = path.join(localRoot, name); + } while (await Bun.file(filePath).exists()); + + try { + await fs.link(sourcePath, filePath); + } catch { + await fs.copyFile(sourcePath, filePath); + } + return `local://${name}`; + } + /** * Save a large paste to the session's `local://` store and insert a clean `local://attachment-N` * reference into the editor so the agent can `read` it on demand — instead of inlining the text or diff --git a/packages/coding-agent/test/input-controller-large-paste.test.ts b/packages/coding-agent/test/input-controller-large-paste.test.ts index 498983317..d20e80af5 100644 --- a/packages/coding-agent/test/input-controller-large-paste.test.ts +++ b/packages/coding-agent/test/input-controller-large-paste.test.ts @@ -15,15 +15,17 @@ import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/typ function createContext(options?: { threshold?: number; choice?: string; artifactsDir?: string }) { const insertPaste = vi.fn(); const insertText = vi.fn(); + const pasteText = vi.fn(); const requestRender = vi.fn(); const showStatus = vi.fn(); const showError = vi.fn(); const showHookSelector = vi.fn(async (_title: string, _options: unknown, _dialog?: unknown) => options?.choice); const ctx = { - editor: { insertPaste, insertText } as unknown as InteractiveModeContext["editor"], + editor: { insertPaste, insertText, pasteText } as unknown as InteractiveModeContext["editor"], ui: { requestRender } as unknown as InteractiveModeContext["ui"], settings: { get: () => options?.threshold ?? 100 } as unknown as InteractiveModeContext["settings"], sessionManager: { + getCwd: () => process.cwd(), getArtifactsDir: () => options?.artifactsDir ?? null, getSessionId: () => "test-session", } as unknown as InteractiveModeContext["sessionManager"], @@ -32,7 +34,10 @@ function createContext(options?: { threshold?: number; choice?: string; artifact showError, } as unknown as InteractiveModeContext; const controller = new InputController(ctx); - return { controller, spies: { insertPaste, insertText, requestRender, showStatus, showError, showHookSelector } }; + return { + controller, + spies: { insertPaste, insertText, pasteText, requestRender, showStatus, showError, showHookSelector }, + }; } afterEach(() => { @@ -144,4 +149,36 @@ describe("InputController.presentLargePasteMenu file attachment", () => { expect(await Bun.file(path.join(dir, "local", "attachment-1")).text()).toBe("previous"); expect(await Bun.file(path.join(dir, "local", "attachment-2")).text()).toBe("fresh"); }); + + it("attaches an existing pasted file as a local:// reference with its extension", async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-file-paste-test-")); + const sourceDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-file-paste-source-")); + const sourcePath = path.join(sourceDir, "report.csv"); + await Bun.write(sourcePath, "name,value\nalpha,1\n"); + const { controller, spies } = createContext({ artifactsDir: dir }); + + try { + await controller.handleFilePathPaste(sourcePath); + } finally { + await fs.rm(sourceDir, { recursive: true, force: true }); + } + + expect(spies.insertText).toHaveBeenCalledWith("local://attachment-1.csv "); + expect(spies.pasteText).not.toHaveBeenCalled(); + expect(spies.requestRender).toHaveBeenCalled(); + expect(spies.showStatus).toHaveBeenCalledWith("Attached file as local://attachment-1.csv"); + expect(await Bun.file(path.join(dir, "local", "attachment-1.csv")).text()).toBe("name,value\nalpha,1\n"); + }); + + it("falls back to inline text when the pasted file path does not exist", async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-file-paste-test-")); + const missing = path.join(dir, "missing.txt"); + const { controller, spies } = createContext({ artifactsDir: dir }); + + await controller.handleFilePathPaste(missing); + + expect(spies.insertText).not.toHaveBeenCalled(); + expect(spies.pasteText).toHaveBeenCalledWith(missing); + expect(spies.showStatus).toHaveBeenCalledWith("Pasted file path was not found"); + }); });