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
This commit is contained in:
roboomp
2026-06-24 14:21:45 +00:00
parent c053afa087
commit 4f20d10454
5 changed files with 209 additions and 50 deletions
+2
View File
@@ -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.<ext>` 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 `<bunfs-root>/<binary-name>` 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)).
@@ -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<string>();
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", () => {
@@ -62,7 +62,6 @@ function buildMatchKeys(keys: readonly KeyId[]): Set<string> {
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<boolean>;
/** Called when a bracketed paste contains one or more image-file paths. */
onPasteImagePath?: (path: string) => void | Promise<void>;
/** Called when a bracketed paste contains one or more non-image file paths. */
onPasteFilePath?: (path: string) => void | Promise<void>;
/** 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);
@@ -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 `<attachment>\n${content}\n</attachment>`;
}
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<void> {
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<void> {
try {
const image = await loadImageInput({
@@ -1373,6 +1421,29 @@ export class InputController {
this.ctx.ui.requestRender();
}
async #attachExistingFileAsLocal(sourcePath: string): Promise<string> {
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
@@ -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");
});
});