diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 818b96da43b..07a8801a523 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1570,7 +1570,7 @@ ToolRegistry.register({ return ( <> - Wrote {props.input.filePath} + Wrote {normalizePath(props.input.filePath ?? "")} @@ -1838,12 +1838,16 @@ ToolRegistry.register({ }, }) -function normalizePath(input?: string) { +function normalizePath(input?: string, maxLength = 50) { if (!input) return "" - if (path.isAbsolute(input)) { - return path.relative(process.cwd(), input) || "." - } - return input + const normalized = Filesystem.toNativePath(input) + const relative = path.isAbsolute(normalized) ? Filesystem.safeRelative(process.cwd(), normalized) || "." : normalized + if (relative.length <= maxLength) return relative + const parts = relative.split(path.sep).filter(Boolean) + const last = parts.at(-1)! + const rest = parts.slice(0, -1).join(path.sep) + if (!rest) return relative + return Locale.truncateMiddle(rest, maxLength - last.length - 1) + path.sep + last } function input(input: Record, omit?: string[]): string { diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index b49bd7abe00..a7d596da5b0 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -43,7 +43,8 @@ export const EditTool = Tool.define("edit", { const agent = await Agent.get(ctx.agent) - const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) + const rawPath = Filesystem.toNativePath(params.filePath) + const filePath = path.isAbsolute(rawPath) ? rawPath : path.join(Instance.directory, rawPath) if (!Filesystem.contains(Instance.directory, filePath)) { const parentDir = path.dirname(filePath) if (agent.permission.external_directory === "ask") { @@ -168,7 +169,7 @@ export const EditTool = Tool.define("edit", { diff, filediff, }, - title: `${path.relative(Instance.worktree, filePath)}`, + title: `${Filesystem.safeRelative(Instance.worktree, filePath)}`, output, } }, diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 11c12f19ac4..e659bdaade4 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -4,6 +4,7 @@ import { Tool } from "./tool" import DESCRIPTION from "./glob.txt" import { Ripgrep } from "../file/ripgrep" import { Instance } from "../project/instance" +import { Filesystem } from "../util/filesystem" export const GlobTool = Tool.define("glob", { description: DESCRIPTION, @@ -17,8 +18,8 @@ export const GlobTool = Tool.define("glob", { ), }), async execute(params) { - let search = params.path ?? Instance.directory - search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search) + const rawPath = params.path ? Filesystem.toNativePath(params.path) : Instance.directory + const search = path.isAbsolute(rawPath) ? rawPath : path.resolve(Instance.directory, rawPath) const limit = 100 const files = [] @@ -54,7 +55,7 @@ export const GlobTool = Tool.define("glob", { } return { - title: path.relative(Instance.worktree, search), + title: Filesystem.safeRelative(Instance.worktree, search), metadata: { count: files.length, truncated, diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index d73bc161683..10bd25147cd 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -4,6 +4,7 @@ import { Ripgrep } from "../file/ripgrep" import DESCRIPTION from "./grep.txt" import { Instance } from "../project/instance" +import { Filesystem } from "../util/filesystem" const MAX_LINE_LENGTH = 2000 @@ -19,7 +20,7 @@ export const GrepTool = Tool.define("grep", { throw new Error("pattern is required") } - const searchPath = params.path || Instance.directory + const searchPath = params.path ? Filesystem.toNativePath(params.path) : Instance.directory const rgPath = await Ripgrep.filepath() const args = ["-nH", "--field-match-separator=|", "--regexp", params.pattern] diff --git a/packages/opencode/src/tool/ls.ts b/packages/opencode/src/tool/ls.ts index 95c36e74593..bc3b6f89cd9 100644 --- a/packages/opencode/src/tool/ls.ts +++ b/packages/opencode/src/tool/ls.ts @@ -4,6 +4,7 @@ import * as path from "path" import DESCRIPTION from "./ls.txt" import { Instance } from "../project/instance" import { Ripgrep } from "../file/ripgrep" +import { Filesystem } from "../util/filesystem" export const IGNORE_PATTERNS = [ "node_modules/", @@ -41,7 +42,7 @@ export const ListTool = Tool.define("list", { ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(), }), async execute(params) { - const searchPath = path.resolve(Instance.directory, params.path || ".") + const searchPath = path.resolve(Instance.directory, Filesystem.toNativePath(params.path || ".")) const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || []) const files = [] @@ -99,7 +100,7 @@ export const ListTool = Tool.define("list", { const output = `${searchPath}/\n` + renderDir(".", 0) return { - title: path.relative(Instance.worktree, searchPath), + title: Filesystem.safeRelative(Instance.worktree, searchPath), metadata: { count: files.length, truncated: files.length >= LIMIT, diff --git a/packages/opencode/src/tool/patch.ts b/packages/opencode/src/tool/patch.ts index 93888f60bd2..344f4d33ddb 100644 --- a/packages/opencode/src/tool/patch.ts +++ b/packages/opencode/src/tool/patch.ts @@ -219,7 +219,7 @@ export const PatchTool = Tool.define("patch", { } // Generate output summary - const relativePaths = changedFiles.map((filePath) => path.relative(Instance.worktree, filePath)) + const relativePaths = changedFiles.map((filePath) => Filesystem.safeRelative(Instance.worktree, filePath)) const summary = `${fileChanges.length} files changed` return { diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index fd81c4864a4..2f03fb142ba 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -23,11 +23,11 @@ export const ReadTool = Tool.define("read", { limit: z.coerce.number().describe("The number of lines to read (defaults to 2000)").optional(), }), async execute(params, ctx) { - let filepath = params.filePath + let filepath = Filesystem.toNativePath(params.filePath) if (!path.isAbsolute(filepath)) { filepath = path.join(process.cwd(), filepath) } - const title = path.relative(Instance.worktree, filepath) + const title = Filesystem.safeRelative(Instance.worktree, filepath) const agent = await Agent.get(ctx.agent) if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) { diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 6b8fd3dd111..3b8e2c194a4 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -23,7 +23,8 @@ export const WriteTool = Tool.define("write", { async execute(params, ctx) { const agent = await Agent.get(ctx.agent) - const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) + const rawPath = Filesystem.toNativePath(params.filePath) + const filepath = path.isAbsolute(rawPath) ? rawPath : path.join(Instance.directory, rawPath) if (!Filesystem.contains(Instance.directory, filepath)) { const parentDir = path.dirname(filepath) if (agent.permission.external_directory === "ask") { @@ -98,7 +99,7 @@ export const WriteTool = Tool.define("write", { } return { - title: path.relative(Instance.worktree, filepath), + title: Filesystem.safeRelative(Instance.worktree, filepath), metadata: { diagnostics, filepath, diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 98fbe533de3..3a826f2787b 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -3,11 +3,19 @@ import { exists } from "fs/promises" import { dirname, join, relative } from "path" export namespace Filesystem { - /** - * On Windows, normalize a path to its canonical casing using the filesystem. - * This is needed because Windows paths are case-insensitive but LSP servers - * may return paths with different casing than what we send them. - */ + // Convert MSYS2/Git Bash/Cygwin paths to Windows paths (no-op on other platforms) + export function toNativePath(p: string): string { + if (process.platform !== "win32") return p + if (/^\/[a-zA-Z]\//.test(p)) { + return p.replace(/^\/([a-zA-Z])\//, (_, d) => `${d.toUpperCase()}:\\`).replace(/\//g, "\\") + } + if (/^\/cygdrive\/[a-zA-Z]\//.test(p)) { + return p.replace(/^\/cygdrive\/([a-zA-Z])\//, (_, d) => `${d.toUpperCase()}:\\`).replace(/\//g, "\\") + } + return p + } + + // Normalize path casing on Windows using filesystem export function normalizePath(p: string): string { if (process.platform !== "win32") return p try { @@ -26,6 +34,19 @@ export namespace Filesystem { return !relative(parent, child).startsWith("..") } + // Safe relative path - returns absolute if cross-drive or too many parent traversals + export function safeRelative(from: string, to: string): string { + if (process.platform === "win32") { + const fromDrive = from.match(/^([a-zA-Z]):/)?.[1]?.toUpperCase() + const toDrive = to.match(/^([a-zA-Z]):/)?.[1]?.toUpperCase() + if (fromDrive && toDrive && fromDrive !== toDrive) return to + } + const rel = relative(from, to) + // If path has 3+ parent traversals, use absolute path instead + if (/^(\.\.[/\\]){3,}/.test(rel)) return to + return rel + } + export async function findUp(target: string, start: string, stop?: string) { let current = start const result = []