-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Description
Description
HI
When using the OpenCode TUI on Windows connected to a remote opencode serve instance on Linux, file references using @file.py or @folder/file.py are incorrectly resolved on the Windows client instead of the server.
This happens even though the TUI is able to correctly list and browse files FROM the server using @.
So:
@shows files from server → ✔ works- Referencing the SAME file in a message → ❌ fails (path is expanded locally on Windows)
Error shown on the server
ENOENT: no such file or directory, statx '/C:/Users/myuser/myproject/module/file.py'
path: "/C:/Users/myuser/myproject/module/file.py",
syscall: "statx",
errno: -2,
code: "ENOENT"
Note: The referenced file does exist on the server at /root/myproject/module/file.py.
Expected behavior
- When connected to a remote server,
@path/to/file.pyshould be resolved server-side, using the server’s working directory and filesystem. - TUI should NOT expand paths locally on Windows (e.g. adding
C:\Users\...).
Actual behavior
The TUI:
- Lists files from the server correctly using
@(proves remote file access works). - But when referencing a file using
@file.pyin the prompt, it expands the path locally on Windows, converts it to an absolute Windows path (e.g.,C:/Users/...), and sends this invalid path to the Linux server.
Steps to reproduce
- On a Linux server:
opencode serve --hostname 0.0.0.0 --port 4700
- On a Windows machine:
opencode attach http://<vps-ip>:4700
- In TUI:
- Type
@→ browse files on server project directory → ✔ works - Select a file like
module/file.py - Submit the prompt:
@module/file.py
- Type
- Server logs:
ENOENT: no such file or directory, statx '/C:/Users/.../module/file.py'
Environment
- Server: Ubuntu 22.04, OpenCode CLI 1.0.106
- Client: Windows 11, OpenCode 1.0.97 & 1.0.106 (pre-release) via Chocolatey.
Proposed Solution / Workaround
(I developed this workaround with assistance from AI to debug the path resolution logic)
The issue lies in packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx. The submit function uses the local environment to resolve paths even when connected remotely.
I found a working fix that:
- Dynamic Root Detection: Extracts the remote server's working directory from
sync.data.session. - Path Normalization: Strips the local Windows CWD from the file path to force it to be relative.
- URL Construction: Rebuilds the
file://URL using the remote root so the Linux server can locate it.
Important Note on the Workaround:
For this dynamic fix to work effectively, the sync.data.session object must be populated with the correct remote directory. I found that currently, this requires a specific workaround: an AGENTS.md file must exist in the project directory and it must include the filePath parameter pointing to itself, for example:
<parameter name="filePath">/root/your_project/AGENTS.md</parameter>Without this specific parameter present in AGENTS.md, the remote path auto-detection often fails to retrieve the correct server-side root.
Code Changes
In packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx:
1. Add Import
import * as path from "path"2. Replace the submit function with:
async function submit() {
if (props.disabled) return
if (autocomplete.visible) return
if (!store.prompt.input) return
const sessionID = props.sessionID
? props.sessionID
: await (async () => {
const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id)
return sessionID
})()
const messageID = Identifier.ascending("message")
let inputText = store.prompt.input
// -------------------------------------------------------------------------
// FIX: Dynamic Remote Root Detection
// Extract the server's working directory from the current or latest session.
// -------------------------------------------------------------------------
let REMOTE_ROOT = ""
if (sync.data.session && sync.data.session.length > 0) {
// Try to find the directory for the specific session we are in,
// otherwise use the most recent session's directory as the project root.
const activeSession = sync.data.session.find((s) => s.id === sessionID) || sync.data.session[0]
if (activeSession?.directory) {
REMOTE_ROOT = activeSession.directory
}
}
// -------------------------------------------------------------------------
// Expand pasted text inline before submitting
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start)
for (const extmark of sortedExtmarks) {
const partIndex = store.extmarkToPartIndex.get(extmark.id)
if (partIndex !== undefined) {
const part = store.prompt.parts[partIndex]
if (part?.type === "text" && part.text) {
const before = inputText.slice(0, extmark.start)
const after = inputText.slice(extmark.end)
inputText = before + part.text + after
}
}
}
// FIX: Normalize file paths (Windows Client -> Linux Server)
const cwd = process.cwd().replace(/\\/g, "/")
const cwdLower = cwd.toLowerCase()
const nonTextParts = store.prompt.parts
.filter((part) => part.type !== "text")
.map((part) => {
if (part.type === "file" && part.source?.path) {
let p = part.source.path.replace(/\\/g, "/")
const pLower = p.toLowerCase()
// 1. Strip local Windows CWD to get a clean relative path
if (pLower.startsWith(cwdLower) || (pLower.startsWith("/") && pLower.includes(cwdLower))) {
const idx = pLower.indexOf(cwdLower)
let rel = p.slice(idx + cwdLower.length)
rel = rel.replace(/^\/+/, "") // Remove leading slashes
// If we found a dirty absolute path, replace it in the message text
if (rel !== p) {
const escapeRegExp = (string: string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const originalPathRegex = new RegExp(escapeRegExp(part.source.path), 'gi')
const normalizedPathRegex = new RegExp(escapeRegExp(p), 'gi')
inputText = inputText.replace(originalPathRegex, rel)
inputText = inputText.replace(normalizedPathRegex, rel)
p = rel
}
} else if (path.isAbsolute(part.source.path)) {
// Fallback using path.relative logic
const rel = path.relative(process.cwd(), part.source.path).split(path.sep).join("/")
const escapeRegExp = (string: string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
inputText = inputText.replace(new RegExp(escapeRegExp(part.source.path), 'gi'), rel)
p = rel
}
// 2. Construct valid Linux Absolute URL using the Dynamic REMOTE_ROOT
const cleanRemoteRoot = REMOTE_ROOT.replace(/\/$/, "")
const cleanRelativePath = p.replace(/^\//, "")
// Combine root + relative path
let absoluteRemotePath = cleanRelativePath
if (cleanRemoteRoot) {
absoluteRemotePath = `${cleanRemoteRoot}/${cleanRelativePath}`
}
// Use 3 slashes for Linux file protocol if remote path implies root
const newUrl = `file://${absoluteRemotePath.startsWith('/') ? '' : '/'}${absoluteRemotePath}`
return {
...part,
url: newUrl,
source: {
...part.source,
path: p, // Keep path relative for display
},
}
}
return part
})
if (store.mode === "shell") {
sdk.client.session.shell({
path: {
id: sessionID,
},
body: {
agent: local.agent.current().name,
model: {
providerID: local.model.current().providerID,
modelID: local.model.current().modelID,
},
command: inputText,
},
})
setStore("mode", "normal")
} else if (
inputText.startsWith("/") &&
iife(() => {
const command = inputText.split(" ")[0].slice(1)
return sync.data.command.some((x) => x.name === command)
})
) {
let [command, ...args] = inputText.split(" ")
sdk.client.session.command({
path: {
id: sessionID,
},
body: {
command: command.slice(1),
arguments: args.join(" "),
agent: local.agent.current().name,
model: `${local.model.current().providerID}/${local.model.current().modelID}`,
messageID,
},
})
} else {
sdk.client.session.prompt({
path: {
id: sessionID,
},
body: {
...local.model.current(),
messageID,
agent: local.agent.current().name,
model: local.model.current(),
parts: [
{
id: Identifier.ascending("part"),
type: "text",
text: inputText,
},
...nonTextParts.map((x) => ({
id: Identifier.ascending("part"),
...x,
})),
],
},
})
}
history.append(store.prompt)
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
props.onSubmit?.()
// temporary hack to make sure the message is sent
if (!props.sessionID)
setTimeout(() => {
route.navigate({
type: "session",
sessionID,
})
}, 50)
input.clear()
}OpenCode version
1.0.106
Steps to reproduce
- sending @file command
Screenshot and/or share link
No response
Operating System
windows as TUI and Linux as server
Terminal
No response