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