From 1f340cc75a55246ab46b0828534eea98fb6c810a Mon Sep 17 00:00:00 2001 From: nielpattin <66520542+nielpattin@users.noreply.github.com> Date: Wed, 24 Dec 2025 09:32:57 +0700 Subject: [PATCH 1/3] fix: better handle MSYS/Git Bash/Cygwin paths on Windows --- .../src/cli/cmd/tui/routes/session/index.tsx | 20 ++++++++++--- packages/opencode/src/tool/edit.ts | 5 ++-- packages/opencode/src/tool/glob.ts | 7 +++-- packages/opencode/src/tool/grep.ts | 3 +- packages/opencode/src/tool/ls.ts | 5 ++-- packages/opencode/src/tool/patch.ts | 2 +- packages/opencode/src/tool/read.ts | 4 +-- packages/opencode/src/tool/write.ts | 5 ++-- packages/opencode/src/util/filesystem.ts | 28 +++++++++++++++---- 9 files changed, 57 insertions(+), 22 deletions(-) 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 c685d8c66cc..a0ed07ab34f 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1859,12 +1859,24 @@ 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) || "." + // Convert MSYS/Cygwin/GitBash paths on Windows before processing + const normalized = Filesystem.toNativePath(input) + let result = normalized + if (path.isAbsolute(normalized)) { + result = Filesystem.safeRelative(process.cwd(), normalized) || "." } - return input + // Abbreviate long paths, keep filename, truncate middle of parent path + if (result.length > maxLength) { + const parts = result.split(path.sep).filter(Boolean) + const last = parts.at(-1)! + const rest = parts.slice(0, -1).join(path.sep) + if (rest) { + result = Locale.truncateMiddle(rest, maxLength - last.length - 1) + path.sep + last + } + } + return result } 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..03885acb044 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,16 @@ export namespace Filesystem { return !relative(parent, child).startsWith("..") } + // Safe relative path that handles cross-drive paths on Windows + 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 + } + return relative(from, to) + } + export async function findUp(target: string, start: string, stop?: string) { let current = start const result = [] From 03b81d6f3459553acd362289f2769f0aa8da19e6 Mon Sep 17 00:00:00 2001 From: nielpattin <66520542+nielpattin@users.noreply.github.com> Date: Wed, 24 Dec 2025 10:24:32 +0700 Subject: [PATCH 2/3] fix: improve path display in TUI on Windows - Add safeRelative() for cross-drive and deep parent traversal paths - Fix TUI path display with truncation for long paths --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 2 +- packages/opencode/src/util/filesystem.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) 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 a0ed07ab34f..d4fcb1a4824 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1591,7 +1591,7 @@ ToolRegistry.register({ return ( <> - Wrote {props.input.filePath} + Wrote {normalizePath(props.input.filePath ?? "")} diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 03885acb044..3a826f2787b 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -34,14 +34,17 @@ export namespace Filesystem { return !relative(parent, child).startsWith("..") } - // Safe relative path that handles cross-drive paths on Windows + // 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 } - return relative(from, 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) { From 687fcf8396956a80442e90663c12ea9fd5501c09 Mon Sep 17 00:00:00 2001 From: nielpattin <66520542+nielpattin@users.noreply.github.com> Date: Thu, 25 Dec 2025 06:49:50 +0700 Subject: [PATCH 3/3] refactor: use early returns in normalizePath per style guide --- .../src/cli/cmd/tui/routes/session/index.tsx | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) 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 d4fcb1a4824..b2cf610632b 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1861,22 +1861,14 @@ ToolRegistry.register({ function normalizePath(input?: string, maxLength = 50) { if (!input) return "" - // Convert MSYS/Cygwin/GitBash paths on Windows before processing const normalized = Filesystem.toNativePath(input) - let result = normalized - if (path.isAbsolute(normalized)) { - result = Filesystem.safeRelative(process.cwd(), normalized) || "." - } - // Abbreviate long paths, keep filename, truncate middle of parent path - if (result.length > maxLength) { - const parts = result.split(path.sep).filter(Boolean) - const last = parts.at(-1)! - const rest = parts.slice(0, -1).join(path.sep) - if (rest) { - result = Locale.truncateMiddle(rest, maxLength - last.length - 1) + path.sep + last - } - } - return result + 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 {