diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 2b31d078cb..eb5cb44e8d 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -24,6 +24,7 @@ import { DialogProvider as DialogProviderList } from "@tui/component/dialog-prov
import { ErrorComponent } from "@tui/component/error-component"
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
import { ProjectProvider } from "@tui/context/project"
+import { EditorContextProvider } from "@tui/context/editor"
import { useEvent } from "@tui/context/event"
import { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading"
@@ -177,7 +178,9 @@ export function tui(input: {
-
+
+
+
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
index 305d076223..4a5e1a4ca6 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
@@ -1,9 +1,11 @@
import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
import { pathToFileURL } from "bun"
import fuzzysort from "fuzzysort"
+import path from "path"
import { firstBy } from "remeda"
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
+import { useEditorContext } from "@tui/context/editor"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
import { getScrollAcceleration } from "../../util/scroll"
@@ -77,6 +79,7 @@ export function Autocomplete(props: {
agentStyleId: number
promptPartTypeId: () => number
}) {
+ const editor = useEditorContext()
const sdk = useSDK()
const sync = useSync()
const command = useCommandDialog()
@@ -221,6 +224,70 @@ export function Autocomplete(props: {
}
}
+ function createFilePart(item: string, lineRange?: { startLine: number; endLine?: number }) {
+ const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
+ const fullPath = path.isAbsolute(item) ? item : path.join(baseDir, item)
+ const urlObj = pathToFileURL(fullPath)
+ const filename =
+ lineRange && !item.endsWith("/")
+ ? `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
+ : item
+
+ if (lineRange && !item.endsWith("/")) {
+ urlObj.searchParams.set("start", String(lineRange.startLine))
+ if (lineRange.endLine !== undefined) {
+ urlObj.searchParams.set("end", String(lineRange.endLine))
+ }
+ }
+
+ return {
+ filename,
+ url: urlObj.href,
+ part: {
+ type: "file" as const,
+ mime: "text/plain",
+ filename,
+ url: urlObj.href,
+ source: {
+ type: "file" as const,
+ text: {
+ start: 0,
+ end: 0,
+ value: "",
+ },
+ path: item,
+ },
+ },
+ }
+ }
+
+ function normalizeMentionPath(filePath: string) {
+ const baseDir = sync.path.directory || process.cwd()
+ const absolute = path.resolve(filePath)
+ const relative = path.relative(baseDir, absolute)
+
+ if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
+ return relative.split(path.sep).join("/")
+ }
+
+ return absolute.split(path.sep).join("/")
+ }
+
+ function insertFileMention(input: { filePath: string; lineStart: number; lineEnd: number }) {
+ const item = normalizeMentionPath(input.filePath)
+ const lineRange = {
+ startLine: input.lineStart,
+ endLine: input.lineEnd > input.lineStart ? input.lineEnd : undefined,
+ }
+ const { filename, part } = createFilePart(item, lineRange)
+ const index = store.visible === "@" ? store.index : props.input().cursorOffset
+
+ command.keybinds(true)
+ setStore("visible", false)
+ setStore("index", index)
+ insertPart(filename, part)
+ }
+
const [files] = createResource(
() => search(),
async (query) => {
@@ -250,18 +317,7 @@ export function Autocomplete(props: {
const width = props.anchor().width - 4
options.push(
...sortedFiles.map((item): AutocompleteOption => {
- const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
- const fullPath = `${baseDir}/${item}`
- const urlObj = pathToFileURL(fullPath)
- let filename = item
- if (lineRange && !item.endsWith("/")) {
- filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
- urlObj.searchParams.set("start", String(lineRange.startLine))
- if (lineRange.endLine !== undefined) {
- urlObj.searchParams.set("end", String(lineRange.endLine))
- }
- }
- const url = urlObj.href
+ const { filename, url, part } = createFilePart(item, lineRange)
const isDir = item.endsWith("/")
return {
@@ -270,21 +326,7 @@ export function Autocomplete(props: {
isDirectory: isDir,
path: item,
onSelect: () => {
- insertPart(filename, {
- type: "file",
- mime: "text/plain",
- filename,
- url,
- source: {
- type: "file",
- text: {
- start: 0,
- end: 0,
- value: "",
- },
- path: item,
- },
- })
+ insertPart(filename, part)
},
}
}),
@@ -501,6 +543,14 @@ export function Autocomplete(props: {
}
onMount(() => {
+ const unsubscribeMention = editor.onMention((mention) => {
+ insertFileMention(mention)
+ })
+
+ onCleanup(() => {
+ unsubscribeMention()
+ })
+
props.ref({
get visible() {
return store.visible
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index 2e08e66a4a..5288a819b3 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -12,6 +12,7 @@ import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
+import { useEditorContext } from "@tui/context/editor"
import { MessageID, PartID } from "@/session/schema"
import { createStore, produce, unwrap } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
@@ -21,7 +22,7 @@ import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useCommandDialog } from "../dialog-command"
-import { useRenderer, type JSX } from "@opentui/solid"
+import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import * as Editor from "@tui/util/editor"
import { useExit } from "../../context/exit"
import * as Clipboard from "../../util/clipboard"
@@ -94,6 +95,7 @@ export function Prompt(props: PromptProps) {
const local = useLocal()
const args = useArgs()
const sdk = useSDK()
+ const editor = useEditorContext()
const route = useRoute()
const project = useProject()
const sync = useSync()
@@ -104,11 +106,34 @@ export function Prompt(props: PromptProps) {
const stash = usePromptStash()
const command = useCommandDialog()
const renderer = useRenderer()
+ const dimensions = useTerminalDimensions()
const { theme, syntax } = useTheme()
const kv = useKV()
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
+ const editorPath = createMemo(() => editor.selection()?.filePath)
+ const editorSelectionLabel = createMemo(() => {
+ const selection = editor.selection()?.selection
+ if (!selection) return
+ if (selection.start.line === selection.end.line && selection.start.character === selection.end.character) return
+ if (selection.start.line === selection.end.line) return `#${selection.start.line}`
+ return `#${selection.start.line}-${selection.end.line}`
+ })
+ const editorFileLabel = createMemo(() => {
+ const value = editorPath()
+ if (!value) return
+ const filename = path.basename(value)
+ const file = /^index\.[^./]+$/.test(filename)
+ ? [path.basename(path.dirname(value)), filename].filter(Boolean).join("/")
+ : filename
+ return `${file.split(path.sep).join("/")}${editorSelectionLabel() ?? ""}`
+ })
+ const editorFileLabelDisplay = createMemo(() => {
+ const file = editorFileLabel()
+ if (!file) return
+ return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3))))
+ })
const [auto, setAuto] = createSignal()
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
@@ -721,6 +746,27 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset
const currentMode = store.mode
const variant = local.model.variant.current()
+ const editorSelection = editor.selection()
+ const editorParts = editorSelection
+ ? [
+ {
+ id: PartID.ascending(),
+ type: "text" as const,
+ text: (() => {
+ const start = editorSelection.selection.start
+ const end = editorSelection.selection.end
+ if (start.line === end.line && start.character === end.character) {
+ return `Note: The user opened the file "${editorSelection.filePath}".`
+ }
+ if (start.line === end.line) {
+ return `Note: The user selected line ${start.line} from "${editorSelection.filePath}": ${editorSelection.text}`
+ }
+ return `Note: The user selected lines ${start.line} to ${end.line} from "${editorSelection.filePath}": ${editorSelection.text}`
+ })(),
+ synthetic: true,
+ },
+ ]
+ : []
if (store.mode === "shell") {
void sdk.client.session.shell({
@@ -773,6 +819,7 @@ export function Prompt(props: PromptProps) {
model: selectedModel,
variant,
parts: [
+ ...editorParts,
{
id: PartID.ascending(),
type: "text",
@@ -1332,6 +1379,7 @@ export function Prompt(props: PromptProps) {
+ {(file) => {file()}}
diff --git a/packages/opencode/src/cli/cmd/tui/context/editor.ts b/packages/opencode/src/cli/cmd/tui/context/editor.ts
new file mode 100644
index 0000000000..663b0f0fac
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/context/editor.ts
@@ -0,0 +1,318 @@
+import { readdirSync, readFileSync, statSync } from "node:fs"
+import os from "node:os"
+import path from "node:path"
+import { onCleanup, onMount } from "solid-js"
+import { createStore } from "solid-js/store"
+import z from "zod"
+import { createSimpleContext } from "./helper"
+
+const MCP_PROTOCOL_VERSION = "2025-11-25"
+
+const JsonRpcMessageSchema = z.object({
+ id: z.union([z.number(), z.string(), z.null()]).optional(),
+ method: z.string().optional(),
+ params: z.unknown().optional(),
+ result: z.unknown().optional(),
+ error: z
+ .object({
+ code: z.number().optional(),
+ message: z.string().optional(),
+ })
+ .optional(),
+})
+
+const PositionSchema = z.object({
+ line: z.number(),
+ character: z.number(),
+})
+
+const EditorSelectionSchema = z.object({
+ text: z.string(),
+ filePath: z.string(),
+ selection: z.object({
+ start: PositionSchema,
+ end: PositionSchema,
+ }),
+})
+
+const EditorMentionSchema = z.object({
+ filePath: z.string(),
+ lineStart: z.number(),
+ lineEnd: z.number(),
+})
+
+const EditorServerInfoSchema = z.object({
+ protocolVersion: z.string().optional(),
+ serverInfo: z
+ .object({
+ name: z.string().optional(),
+ version: z.string().optional(),
+ })
+ .optional(),
+})
+
+type JsonRpcMessage = z.infer
+export type EditorSelection = z.infer
+export type EditorMention = z.infer
+type EditorServerInfo = z.infer
+
+type EditorConnection = {
+ url: string
+ authToken?: string
+ source: string
+}
+
+type EditorLockFile = {
+ port: number
+ authToken?: string
+ transport?: string
+ workspaceFolders: string[]
+ mtimeMs: number
+}
+
+export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({
+ name: "EditorContext",
+ init: () => {
+ const mentionListeners = new Set<(mention: EditorMention) => void>()
+ const [store, setStore] = createStore<{
+ status: "disabled" | "connecting" | "connected"
+ selection: EditorSelection | undefined
+ server: EditorServerInfo | undefined
+ }>({
+ status: "disabled",
+ selection: undefined,
+ server: undefined,
+ })
+
+ onMount(() => {
+ let socket: WebSocket | undefined
+ let closed = false
+ let reconnect: ReturnType | undefined
+ let attempt = 0
+ let requestID = 0
+ const pending = new Map()
+
+ const send = (payload: JsonRpcMessage) => {
+ if (!socket || socket.readyState !== WebSocket.OPEN) return
+ socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
+ }
+
+ const request = (method: string, params?: unknown) => {
+ requestID += 1
+ pending.set(requestID, method)
+ send({ id: requestID, method, params })
+ }
+
+ const scheduleReconnect = (delay: number) => {
+ if (closed) return
+ if (reconnect) clearTimeout(reconnect)
+ reconnect = setTimeout(connect, delay)
+ }
+
+ const connect = () => {
+ if (closed) return
+
+ const connection = resolveEditorConnection()
+ if (!connection) {
+ setStore("status", "disabled")
+ scheduleReconnect(1000)
+ return
+ }
+
+ setStore("status", "connecting")
+ const current = openEditorSocket(connection)
+ socket = current
+
+ current.addEventListener("open", () => {
+ if (socket !== current) {
+ current.close()
+ return
+ }
+
+ attempt = 0
+ setStore("status", "connected")
+ request("initialize", {
+ protocolVersion: MCP_PROTOCOL_VERSION,
+ capabilities: {},
+ clientInfo: { name: "opencode", version: "0.0.0" },
+ })
+ })
+
+ current.addEventListener("message", (event) => {
+ const message = parseMessage(event.data)
+ if (!message) return
+
+ const selection = message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
+ if (selection?.success) {
+ setStore("selection", selection.data)
+ return
+ }
+
+ const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
+ if (mention?.success) {
+ mentionListeners.forEach((listener) => listener(mention.data))
+ return
+ }
+
+ if (typeof message.id !== "number") return
+
+ const method = pending.get(message.id)
+ if (!method) return
+
+ pending.delete(message.id)
+ if (message.error) return
+
+ const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
+ if (initialize?.success) {
+ setStore("server", initialize.data)
+ send({ method: "notifications/initialized" })
+ return
+ }
+ })
+
+ current.addEventListener("close", () => {
+ if (socket !== current) return
+
+ socket = undefined
+ pending.clear()
+ if (closed) return
+
+ setStore("status", "connecting")
+ attempt += 1
+ const delay = Math.min(1000 * 2 ** (attempt - 1), 30000)
+ scheduleReconnect(delay)
+ })
+ }
+
+ scheduleReconnect(0)
+
+ onCleanup(() => {
+ closed = true
+ if (reconnect) clearTimeout(reconnect)
+ socket?.close()
+ })
+ })
+
+ return {
+ enabled() {
+ return Boolean(resolveEditorConnection())
+ },
+ connected() {
+ return store.status === "connected"
+ },
+ selection() {
+ return store.selection
+ },
+ onMention(listener: (mention: EditorMention) => void) {
+ mentionListeners.add(listener)
+ return () => mentionListeners.delete(listener)
+ },
+ server() {
+ return store.server
+ },
+ }
+ },
+})
+
+function parsePort(value: string | undefined) {
+ if (!value) return
+
+ const parsed = Number.parseInt(value, 10)
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) return
+ return parsed
+}
+
+function resolveEditorConnection(): EditorConnection | undefined {
+ const lock = resolveEditorLockFile()
+ if (lock) {
+ return {
+ url: `ws://127.0.0.1:${lock.port}`,
+ authToken: lock.authToken,
+ source: `lock:${lock.port}`,
+ }
+ }
+
+ const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT)
+ if (!port) return
+ return {
+ url: `ws://127.0.0.1:${port}`,
+ source: `env:${port}`,
+ }
+}
+
+function resolveEditorLockFile() {
+ const directory = path.join(os.homedir(), ".claude", "ide")
+ let entries: string[]
+
+ try {
+ entries = readdirSync(directory)
+ } catch {
+ return
+ }
+
+ const cwd = process.cwd()
+ const locks = entries
+ .filter((entry) => entry.endsWith(".lock"))
+ .map((entry) => readEditorLockFile(path.join(directory, entry)))
+ .filter((entry): entry is EditorLockFile => Boolean(entry))
+ .sort((left, right) => scoreEditorLock(right, cwd) - scoreEditorLock(left, cwd))
+
+ return locks[0]
+}
+
+function readEditorLockFile(filePath: string): EditorLockFile | undefined {
+ const port = parsePort(path.basename(filePath, ".lock"))
+ if (!port) return
+
+ try {
+ const parsed = JSON.parse(readFileSync(filePath, "utf-8")) as unknown
+ if (!isRecord(parsed)) return
+ if (parsed.transport !== undefined && parsed.transport !== "ws") return
+
+ return {
+ port,
+ authToken: typeof parsed.authToken === "string" ? parsed.authToken : undefined,
+ transport: typeof parsed.transport === "string" ? parsed.transport : undefined,
+ workspaceFolders: Array.isArray(parsed.workspaceFolders)
+ ? parsed.workspaceFolders.filter((value): value is string => typeof value === "string")
+ : [],
+ mtimeMs: statSync(filePath).mtimeMs,
+ }
+ } catch {
+ return
+ }
+}
+
+function scoreEditorLock(lock: EditorLockFile, cwd: string) {
+ const workspaceMatch = lock.workspaceFolders.some((folder) => pathContains(folder, cwd)) ? 1 : 0
+ return workspaceMatch * 1_000_000_000_000 + lock.mtimeMs
+}
+
+function pathContains(parent: string, child: string) {
+ const relative = path.relative(path.resolve(parent), path.resolve(child))
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
+}
+
+function openEditorSocket(connection: EditorConnection) {
+ if (!connection.authToken) return new WebSocket(connection.url)
+
+ return new WebSocket(connection.url, {
+ headers: {
+ "x-claude-code-ide-authorization": connection.authToken,
+ },
+ } as any)
+}
+
+function parseMessage(value: unknown) {
+ if (typeof value !== "string") return
+
+ try {
+ return JsonRpcMessageSchema.parse(JSON.parse(value))
+ } catch {
+ return
+ }
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+}