Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1570,7 +1570,7 @@ ToolRegistry.register<typeof WriteTool>({
return (
<>
<ToolTitle icon="←" fallback="Preparing write..." when={done}>
Wrote {props.input.filePath}
Wrote {normalizePath(props.input.filePath ?? "")}
</ToolTitle>
<Show when={done}>
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
Expand Down Expand Up @@ -1838,12 +1838,16 @@ ToolRegistry.register<typeof TodoWriteTool>({
},
})

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<string, any>, omit?: string[]): string {
Expand Down
5 changes: 3 additions & 2 deletions packages/opencode/src/tool/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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,
}
},
Expand Down
7 changes: 4 additions & 3 deletions packages/opencode/src/tool/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = []
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/tool/grep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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]
Expand Down
5 changes: 3 additions & 2 deletions packages/opencode/src/tool/ls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/tool/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/src/tool/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
5 changes: 3 additions & 2 deletions packages/opencode/src/tool/write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 26 additions & 5 deletions packages/opencode/src/util/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 = []
Expand Down