diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx
index 037b08c723a..2e41b29ecbd 100644
--- a/packages/app/src/pages/directory-layout.tsx
+++ b/packages/app/src/pages/directory-layout.tsx
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "@solidjs/router"
import { SDKProvider, useSDK } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local"
+import { useLayout } from "@/context/layout"
import { DataProvider } from "@opencode-ai/ui/context"
import { iife } from "@opencode-ai/util/iife"
@@ -14,6 +15,7 @@ import { useLanguage } from "@/context/language"
export default function Layout(props: ParentProps) {
const params = useParams()
const navigate = useNavigate()
+ const layout = useLayout()
const language = useLanguage()
const directory = createMemo(() => {
return decode64(params.dir) ?? ""
@@ -51,6 +53,34 @@ export default function Layout(props: ParentProps) {
navigate(`/${params.dir}/session/${sessionID}`)
}
+ const openFile = (path: string) => {
+ // Only works when we're in a session context
+ const sessionId = params.id
+ if (!sessionId) return
+
+ const sessionKey = `${params.dir}${sessionId ? "/" + sessionId : ""}`
+ const tabs = layout.tabs(sessionKey)
+
+ // Normalize path the same way file.tab() does
+ let normalized = path
+ if (normalized.startsWith("file://")) {
+ normalized = normalized.slice(7)
+ }
+ const root = directory()
+ if (root && normalized.startsWith(root)) {
+ normalized = normalized.slice(root.length)
+ }
+ if (normalized.startsWith("./")) {
+ normalized = normalized.slice(2)
+ }
+ if (normalized.startsWith("/")) {
+ normalized = normalized.slice(1)
+ }
+
+ const tabValue = `file://${normalized}`
+ tabs.open(tabValue)
+ }
+
return (
{props.children}
diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css
index ef43187336e..544699dace5 100644
--- a/packages/ui/src/components/markdown.css
+++ b/packages/ui/src/components/markdown.css
@@ -166,6 +166,7 @@
font-feature-settings: var(--font-family-mono--font-feature-settings);
color: var(--syntax-string);
font-weight: var(--font-weight-medium);
+ cursor: pointer;
/* font-size: 13px; */
/* padding: 2px 2px; */
@@ -175,6 +176,11 @@
/* box-shadow: 0 0 0 0.5px var(--border-weak-base); */
}
+ :not(pre) > code:hover {
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ }
+
/* Tables */
table {
width: 100%;
diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx
index e3102214bf5..34ca94362dc 100644
--- a/packages/ui/src/components/markdown.tsx
+++ b/packages/ui/src/components/markdown.tsx
@@ -1,4 +1,5 @@
import { useMarked } from "../context/marked"
+import { useData } from "../context"
import { useI18n } from "../context/i18n"
import DOMPurify from "dompurify"
import morphdom from "morphdom"
@@ -6,6 +7,21 @@ import { checksum } from "@opencode-ai/util/encode"
import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js"
import { isServer } from "solid-js/web"
+// Matches common file path patterns:
+// - ./path/file.ext, ../path/file.ext, /absolute/path.ext
+// - relative/path/file.ext, file.ext
+function isFilePath(text: string): boolean {
+ // Must have a file extension
+ if (!/\.\w+$/.test(text)) return false
+ // Match paths starting with ./, ../, or /
+ if (/^\.{1,2}\/[\w\-./]+$/.test(text)) return true
+ // Match absolute paths
+ if (/^\/[\w\-./]+$/.test(text)) return true
+ // Match relative paths (word/word.ext pattern, must have at least one slash or be a simple filename)
+ if (/^[\w\-]+(\/[\w\-./]+)?$/.test(text)) return true
+ return false
+}
+
type Entry = {
hash: string
html: string
@@ -169,8 +185,24 @@ export function Markdown(
) {
const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"])
const marked = useMarked()
+ const data = useData()
const i18n = useI18n()
const [root, setRoot] = createSignal()
+
+ const handleClick = (e: MouseEvent) => {
+ if (!data?.openFile) return
+
+ const target = e.target as HTMLElement
+ // Check if clicked on inline code (not inside a code block)
+ if (target.tagName === "CODE" && target.parentElement?.tagName !== "PRE") {
+ const text = target.textContent?.trim()
+ if (text && isFilePath(text)) {
+ e.preventDefault()
+ data.openFile(text)
+ }
+ }
+ }
+
const [html] = createResource(
() => local.text,
async (markdown) => {
@@ -258,6 +290,7 @@ export function Markdown(
[local.class ?? ""]: !!local.class,
}}
ref={setRoot}
+ onClick={handleClick}
{...others}
/>
)
diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx
index dcb9adb39c8..24e3c7bfa1a 100644
--- a/packages/ui/src/context/data.tsx
+++ b/packages/ui/src/context/data.tsx
@@ -48,6 +48,8 @@ export type QuestionRejectFn = (input: { requestID: string }) => void
export type NavigateToSessionFn = (sessionID: string) => void
+export type OpenFileFn = (path: string) => void
+
export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data",
init: (props: {
@@ -57,6 +59,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
onQuestionReply?: QuestionReplyFn
onQuestionReject?: QuestionRejectFn
onNavigateToSession?: NavigateToSessionFn
+ onOpenFile?: OpenFileFn
}) => {
return {
get store() {
@@ -69,6 +72,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
replyToQuestion: props.onQuestionReply,
rejectQuestion: props.onQuestionReject,
navigateToSession: props.onNavigateToSession,
+ openFile: props.onOpenFile,
}
},
})