From 9a7bbb2839ce8194da62375a116e3d89a3e7b8df Mon Sep 17 00:00:00 2001 From: Nico Bailon Date: Sat, 13 Dec 2025 13:04:01 -0800 Subject: [PATCH] coding-agent: fix macOS screenshot filenames with unicode spaces (#181) --- .../coding-agent/src/cli/file-processor.ts | 18 ++----- .../coding-agent/src/core/hooks/loader.ts | 20 ++++---- packages/coding-agent/src/core/tools/edit.ts | 15 +----- packages/coding-agent/src/core/tools/find.ts | 15 +----- packages/coding-agent/src/core/tools/grep.ts | 15 +----- packages/coding-agent/src/core/tools/ls.ts | 15 +----- .../coding-agent/src/core/tools/path-utils.ts | 48 +++++++++++++++++++ packages/coding-agent/src/core/tools/read.ts | 17 +------ packages/coding-agent/src/core/tools/write.ts | 15 +----- 9 files changed, 70 insertions(+), 108 deletions(-) create mode 100644 packages/coding-agent/src/core/tools/path-utils.ts diff --git a/packages/coding-agent/src/cli/file-processor.ts b/packages/coding-agent/src/cli/file-processor.ts index 2e96dfbf..09c39470 100644 --- a/packages/coding-agent/src/cli/file-processor.ts +++ b/packages/coding-agent/src/cli/file-processor.ts @@ -5,8 +5,8 @@ import type { Attachment } from "@mariozechner/pi-agent-core"; import chalk from "chalk"; import { existsSync, readFileSync, statSync } from "fs"; -import { homedir } from "os"; import { extname, resolve } from "path"; +import { resolveReadPath } from "../core/tools/path-utils.js"; /** Map of file extensions to MIME types for common image formats */ const IMAGE_MIME_TYPES: Record = { @@ -23,17 +23,6 @@ function isImageFile(filePath: string): string | null { return IMAGE_MIME_TYPES[ext] || null; } -/** Expand ~ to home directory */ -function expandPath(filePath: string): string { - if (filePath === "~") { - return homedir(); - } - if (filePath.startsWith("~/")) { - return homedir() + filePath.slice(1); - } - return filePath; -} - export interface ProcessedFiles { textContent: string; imageAttachments: Attachment[]; @@ -45,9 +34,8 @@ export function processFileArguments(fileArgs: string[]): ProcessedFiles { const imageAttachments: Attachment[] = []; for (const fileArg of fileArgs) { - // Expand and resolve path - const expandedPath = expandPath(fileArg); - const absolutePath = resolve(expandedPath); + // Expand and resolve path (handles ~ expansion and macOS screenshot Unicode spaces) + const absolutePath = resolve(resolveReadPath(fileArg)); // Check if file exists if (!existsSync(absolutePath)) { diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index c86e13da..8ed97d97 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -44,17 +44,21 @@ export interface LoadHooksResult { errors: Array<{ path: string; error: string }>; } -/** - * Expand path with ~ support. - */ +const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; + +function normalizeUnicodeSpaces(str: string): string { + return str.replace(UNICODE_SPACES, " "); +} + function expandPath(p: string): string { - if (p.startsWith("~/")) { - return path.join(os.homedir(), p.slice(2)); + const normalized = normalizeUnicodeSpaces(p); + if (normalized.startsWith("~/")) { + return path.join(os.homedir(), normalized.slice(2)); } - if (p.startsWith("~")) { - return path.join(os.homedir(), p.slice(1)); + if (normalized.startsWith("~")) { + return path.join(os.homedir(), normalized.slice(1)); } - return p; + return normalized; } /** diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index c544adca..31fda7ed 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -1,23 +1,10 @@ -import * as os from "node:os"; import type { AgentTool } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; import * as Diff from "diff"; import { constants } from "fs"; import { access, readFile, writeFile } from "fs/promises"; import { resolve as resolvePath } from "path"; - -/** - * Expand ~ to home directory - */ -function expandPath(filePath: string): string { - if (filePath === "~") { - return os.homedir(); - } - if (filePath.startsWith("~/")) { - return os.homedir() + filePath.slice(1); - } - return filePath; -} +import { expandPath } from "./path-utils.js"; /** * Generate a unified diff string with line numbers and context diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index 6b81dada..de15513a 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -3,24 +3,11 @@ import { Type } from "@sinclair/typebox"; import { spawnSync } from "child_process"; import { existsSync } from "fs"; import { globSync } from "glob"; -import { homedir } from "os"; import path from "path"; import { ensureTool } from "../../utils/tools-manager.js"; +import { expandPath } from "./path-utils.js"; import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js"; -/** - * Expand ~ to home directory - */ -function expandPath(filePath: string): string { - if (filePath === "~") { - return homedir(); - } - if (filePath.startsWith("~/")) { - return homedir() + filePath.slice(1); - } - return filePath; -} - const findSchema = Type.Object({ pattern: Type.String({ description: "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'", diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index 4560d6f3..73be92ff 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -3,9 +3,9 @@ import type { AgentTool } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; import { spawn } from "child_process"; import { readFileSync, type Stats, statSync } from "fs"; -import { homedir } from "os"; import path from "path"; import { ensureTool } from "../../utils/tools-manager.js"; +import { expandPath } from "./path-utils.js"; import { DEFAULT_MAX_BYTES, formatSize, @@ -15,19 +15,6 @@ import { truncateLine, } from "./truncate.js"; -/** - * Expand ~ to home directory - */ -function expandPath(filePath: string): string { - if (filePath === "~") { - return homedir(); - } - if (filePath.startsWith("~/")) { - return homedir() + filePath.slice(1); - } - return filePath; -} - const grepSchema = Type.Object({ pattern: Type.String({ description: "Search pattern (regex or literal string)" }), path: Type.Optional(Type.String({ description: "Directory or file to search (default: current directory)" })), diff --git a/packages/coding-agent/src/core/tools/ls.ts b/packages/coding-agent/src/core/tools/ls.ts index 9b9c4b56..2be75755 100644 --- a/packages/coding-agent/src/core/tools/ls.ts +++ b/packages/coding-agent/src/core/tools/ls.ts @@ -1,23 +1,10 @@ import type { AgentTool } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; import { existsSync, readdirSync, statSync } from "fs"; -import { homedir } from "os"; import nodePath from "path"; +import { expandPath } from "./path-utils.js"; import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js"; -/** - * Expand ~ to home directory - */ -function expandPath(filePath: string): string { - if (filePath === "~") { - return homedir(); - } - if (filePath.startsWith("~/")) { - return homedir() + filePath.slice(1); - } - return filePath; -} - const lsSchema = Type.Object({ path: Type.Optional(Type.String({ description: "Directory to list (default: current directory)" })), limit: Type.Optional(Type.Number({ description: "Maximum number of entries to return (default: 500)" })), diff --git a/packages/coding-agent/src/core/tools/path-utils.ts b/packages/coding-agent/src/core/tools/path-utils.ts new file mode 100644 index 00000000..bdc7b4fa --- /dev/null +++ b/packages/coding-agent/src/core/tools/path-utils.ts @@ -0,0 +1,48 @@ +import { accessSync, constants } from "node:fs"; +import * as os from "node:os"; + +const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; +const NARROW_NO_BREAK_SPACE = "\u202F"; + +function normalizeUnicodeSpaces(str: string): string { + return str.replace(UNICODE_SPACES, " "); +} + +function tryMacOSScreenshotPath(filePath: string): string { + return filePath.replace(/ (AM|PM)\./g, `${NARROW_NO_BREAK_SPACE}$1.`); +} + +function fileExists(filePath: string): boolean { + try { + accessSync(filePath, constants.F_OK); + return true; + } catch { + return false; + } +} + +export function expandPath(filePath: string): string { + const normalized = normalizeUnicodeSpaces(filePath); + if (normalized === "~") { + return os.homedir(); + } + if (normalized.startsWith("~/")) { + return os.homedir() + normalized.slice(1); + } + return normalized; +} + +export function resolveReadPath(filePath: string): string { + const expanded = expandPath(filePath); + + if (fileExists(expanded)) { + return expanded; + } + + const macOSVariant = tryMacOSScreenshotPath(expanded); + if (macOSVariant !== expanded && fileExists(macOSVariant)) { + return macOSVariant; + } + + return expanded; +} diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index 2634000d..cf27ea25 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -1,24 +1,11 @@ -import * as os from "node:os"; import type { AgentTool, ImageContent, TextContent } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; import { constants } from "fs"; import { access, readFile } from "fs/promises"; import { extname, resolve as resolvePath } from "path"; +import { resolveReadPath } from "./path-utils.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js"; -/** - * Expand ~ to home directory - */ -function expandPath(filePath: string): string { - if (filePath === "~") { - return os.homedir(); - } - if (filePath.startsWith("~/")) { - return os.homedir() + filePath.slice(1); - } - return filePath; -} - /** * Map of file extensions to MIME types for common image formats */ @@ -58,7 +45,7 @@ export const readTool: AgentTool = { { path, offset, limit }: { path: string; offset?: number; limit?: number }, signal?: AbortSignal, ) => { - const absolutePath = resolvePath(expandPath(path)); + const absolutePath = resolvePath(resolveReadPath(path)); const mimeType = isImageFile(absolutePath); return new Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }>( diff --git a/packages/coding-agent/src/core/tools/write.ts b/packages/coding-agent/src/core/tools/write.ts index b70a9fff..39d7f840 100644 --- a/packages/coding-agent/src/core/tools/write.ts +++ b/packages/coding-agent/src/core/tools/write.ts @@ -1,21 +1,8 @@ -import * as os from "node:os"; import type { AgentTool } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; import { mkdir, writeFile } from "fs/promises"; import { dirname, resolve as resolvePath } from "path"; - -/** - * Expand ~ to home directory - */ -function expandPath(filePath: string): string { - if (filePath === "~") { - return os.homedir(); - } - if (filePath.startsWith("~/")) { - return os.homedir() + filePath.slice(1); - } - return filePath; -} +import { expandPath } from "./path-utils.js"; const writeSchema = Type.Object({ path: Type.String({ description: "Path to the file to write (relative or absolute)" }),