diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index 60e76cacd3..d33175dc1a 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -75,6 +75,7 @@ import { postBugReportPoke } from "@/utils/amicode-bug-report"
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
import { NewHome } from "@/pages/home"
import { LegacyHome } from "@/pages/home/legacy-home"
+import { AmicodeFileRefBridge } from "@/components/amicode-file-ref-bridge"
const NewSession = lazy(() => import("@/pages/new-session"))
@@ -175,7 +176,10 @@ function SelectedServerProviders(props: ParentProps) {
return (
- {props.children}
+
+
+ {props.children}
+
)
diff --git a/packages/app/src/components/amicode-file-ref-bridge.tsx b/packages/app/src/components/amicode-file-ref-bridge.tsx
new file mode 100644
index 0000000000..eadf0cbb96
--- /dev/null
+++ b/packages/app/src/components/amicode-file-ref-bridge.tsx
@@ -0,0 +1,33 @@
+// AMICODE: registers the chat file-reference resolver with session-ui's
+// markdown renderer. Rides the same per-call server pick as the vault browser
+// (focused server, else first healthy, else first), so a server switch is
+// picked up by the very next resolution; resolutions never block render.
+// Mounted under SelectedServerProviders (needs the server + global contexts);
+// with the bridge unmounted no resolver is registered and pills stay pills.
+import { onCleanup, onMount } from "solid-js"
+import { registerFileRefResolver } from "@opencode-ai/session-ui/markdown-file-refs"
+import { ServerConnection, useServer } from "@/context/server"
+import { useGlobal } from "@/context/global"
+import { amicodeGet } from "@/utils/amicode-fetch"
+import { pickVaultServer } from "@/components/vault-browser-model"
+
+export function AmicodeFileRefBridge() {
+ const server = useServer()
+ const global = useGlobal()
+ onMount(() => {
+ registerFileRefResolver(async (text) => {
+ const conn = pickVaultServer({
+ current: server.current,
+ list: server.list,
+ healthy: (c) => global.servers.health[ServerConnection.key(c)]?.healthy === true,
+ })
+ if (!conn) return null
+ const raw = (await amicodeGet(conn, `/amicode/resolve-file?path=${encodeURIComponent(text)}`)) as
+ | { ok?: boolean; found?: boolean; path?: unknown }
+ | undefined
+ return raw?.ok === true && raw.found === true && typeof raw.path === "string" ? raw.path : null
+ })
+ })
+ onCleanup(() => registerFileRefResolver(undefined))
+ return null
+}
diff --git a/packages/opencode/src/server/amicode/file-resolve.ts b/packages/opencode/src/server/amicode/file-resolve.ts
new file mode 100644
index 0000000000..1873509d51
--- /dev/null
+++ b/packages/opencode/src/server/amicode/file-resolve.ts
@@ -0,0 +1,163 @@
+// AMICODE: chat file-reference resolver (GET /amicode/resolve-file?path=).
+// Resolves a path-like string from chat markdown (an inline-code pill, or a
+// relative authored link) to an absolute on-disk path, so the app can wrap it
+// in a file:// link and the VS Code bridge can open it. RESOLVES ONLY — never
+// reads content. Contract (same as vault-browser.ts): the body builder returns
+// a JSON string and never throws.
+//
+// Resolution tiers, in order:
+// 1. absolute path (/…) → existsSync
+// 2. ~ / ~/… → home expansion, then existsSync
+// 3. mount-prefixed (/) → under that mount (containment-guarded)
+// 4. relative with a directory part → first-hit relpath across mounts in
+// precedence order, then /amicode/ (problem & demo cards),
+// then project-directory-relative (the server's cwd)
+// 5. bare filename → typed-prefix vault search ONLY
+// (insight-* → insights/, spec-* → specs/, … per the amico-vault folder
+// contract). A bare name with no typed prefix never resolves — a random
+// `result.toml` must not become a link into the vault.
+import { realpathSync, statSync } from "node:fs"
+import { homedir } from "node:os"
+import path from "node:path"
+import { browseAllowed, mountDir } from "./vault-browser"
+import { listMounts } from "./vaults"
+
+const MAX_LEN = 4_096
+
+function vaultsRoot(): string {
+ return process.env.AMICO_VAULTS_ROOT || path.join(homedir(), ".amico", "vaults")
+}
+
+const err = (code: string, detail: string) => JSON.stringify({ ok: false, error: `${code}: ${detail}` })
+
+/** Vault naming contract (amico-vault skill, folder table): a timestamped
+ * note's filename prefix maps to its typed folder. Order irrelevant except
+ * that `morning-brief-` must precede nothing — prefixes are disjoint. */
+const TYPED_PREFIX_DIRS: ReadonlyArray = [
+ ["experiment-", "experiments"],
+ ["method-", "methods"],
+ ["paper-", "papers"],
+ ["insight-", "insights"],
+ ["spec-", "specs"],
+ ["plan-", "plans"],
+ ["meeting-", "meetings"],
+ ["hypothesis-", "hypotheses"],
+ ["hopper-", "hopper"],
+ ["retro-", "retrospectives"],
+ ["morning-brief-", "briefs"],
+ ["person-", "people"],
+ ["org-", "orgs"],
+ ["session-", "sessions"],
+]
+
+export type ResolvedRef = { path: string; mount?: string; kind: "file" | "dir" }
+
+/** stat an absolute candidate; undefined when absent or not file/dir. */
+function statRef(abs: string, mount?: string): ResolvedRef | undefined {
+ let st
+ try {
+ st = statSync(abs)
+ } catch {
+ return undefined
+ }
+ if (st.isDirectory()) return { path: abs, mount, kind: "dir" }
+ if (st.isFile()) return { path: abs, mount, kind: "file" }
+ return undefined
+}
+
+/** Join + realpath containment guard (mounts may be symlinked, and a chat
+ * string may carry `..`): returns the REAL path when the candidate exists and
+ * stays inside the mount, else undefined. Same idiom as vaultFileBody. */
+function containedExisting(mountDirAbs: string, rel: string): string | undefined {
+ let realRoot: string
+ let realTarget: string
+ try {
+ realRoot = realpathSync(mountDirAbs)
+ realTarget = realpathSync(path.resolve(mountDirAbs, rel))
+ } catch {
+ return undefined
+ }
+ if (realTarget !== realRoot && !realTarget.startsWith(realRoot + path.sep)) return undefined
+ return realTarget
+}
+
+/** The project directory the server was launched with (extension spawns
+ * `opencode serve` with cwd = the project). */
+function projectDir(): string {
+ return process.env.AMICODE_PROJECT_DIR || process.cwd()
+}
+
+export function resolveFileRef(
+ text: string,
+ opts: { vaultRoot?: string; cwd?: string; home?: string } = {},
+): ResolvedRef | undefined {
+ const home = opts.home ?? homedir()
+ const vaultRoot = opts.vaultRoot ?? vaultsRoot()
+ const cwd = opts.cwd ?? projectDir()
+ const t = text.trim()
+ if (!t || t.length > MAX_LEN) return undefined
+ // scheme-ful strings (https://, mailto:, …) are not file refs
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(t) || /^mailto:/i.test(t)) return undefined
+
+ const expanded = t === "~" ? home : t.startsWith("~/") ? path.join(home, t.slice(2)) : t
+
+ // tiers 1–2: absolute / home-expanded
+ if (path.isAbsolute(expanded)) return statRef(expanded)
+
+ // normalize away a leading ./ so segment logic sees the real first segment
+ const rel = expanded.startsWith("./") ? expanded.slice(2) : expanded
+ if (!rel || rel === "." || rel === "..") return undefined
+ const segs = rel.split("/").filter((s) => s !== "")
+ if (segs.length === 0) return undefined
+
+ // tier 3: first segment names an attached mount
+ const mounted = mountDir(segs[0], vaultRoot)
+ if (mounted) {
+ if (segs.length === 1) return statRef(mounted, segs[0])
+ const inside = containedExisting(mounted, segs.slice(1).join("/"))
+ if (inside) return statRef(inside, segs[0])
+ return undefined // an explicit mount reference does not fall through
+ }
+
+ // tier 4: relative with a directory part — vault precedence first-hit, then
+ // the mount's amicode/ state dir (problem cards, demo cards), then the
+ // project directory.
+ if (segs.length > 1) {
+ for (const m of listMounts(vaultRoot)) {
+ const hit = containedExisting(m.dir, rel)
+ if (hit) return statRef(hit, m.id)
+ }
+ for (const m of listMounts(vaultRoot)) {
+ const hit = containedExisting(m.dir, path.join("amicode", rel))
+ if (hit) return statRef(hit, m.id)
+ }
+ return statRef(path.resolve(cwd, rel))
+ }
+
+ // tier 5: bare filename — typed-prefix vault search only
+ for (const [prefix, dir] of TYPED_PREFIX_DIRS) {
+ if (!rel.startsWith(prefix)) continue
+ for (const m of listMounts(vaultRoot)) {
+ const hit = containedExisting(m.dir, path.join(dir, rel))
+ if (hit) return statRef(hit, m.id)
+ }
+ return undefined // one prefix matched; never try other prefixes or dirs
+ }
+ return undefined
+}
+
+/** Route body builder: `{ok:true, found, path?, mount?, kind?}` on success,
+ * `{ok:false, error}` on bad input or a gated deployment. Never throws. */
+export function resolveFileBody(text: string | undefined): string {
+ if (!browseAllowed())
+ return err("forbidden", "file resolution serves loopback servers only (set AMICO_VAULT_BROWSER=1 to override)")
+ if (!text) return err("bad_request", "path is required")
+ let hit: ResolvedRef | undefined
+ try {
+ hit = resolveFileRef(text)
+ } catch (e) {
+ return err("resolve_failed", String(e))
+ }
+ if (!hit) return JSON.stringify({ ok: true, found: false })
+ return JSON.stringify({ ok: true, found: true, path: hit.path, mount: hit.mount ?? null, kind: hit.kind })
+}
diff --git a/packages/opencode/src/server/amicode/vaults.ts b/packages/opencode/src/server/amicode/vaults.ts
index 651a7b2e81..02eafdd164 100644
--- a/packages/opencode/src/server/amicode/vaults.ts
+++ b/packages/opencode/src/server/amicode/vaults.ts
@@ -20,19 +20,19 @@ const KIND_RANK: Record = { personal: 0, engagement: 1, project:
const kindRank = (k: string) => KIND_RANK[k] ?? 6
const writableByKind = (k: string) => k === "personal" || k === "project" || k === "engagement"
-/** CLI-less mount listing: scan each vault dir under the vaults root for its
- * `.amico-vault.toml` marker and emit the same `{ok,mounts}` wire shape the
- * Vaults tab parses. Parity with the extension's resolveMountStack (kind rank +
- * writable-by-kind); ordering is kind-rank then name. `last_sync` is unknown
- * without the CLI. Never throws. */
-export function scanMounts(root: string = vaultsRoot()): string {
+export type MountInfo = { id: string; kind: string; writable: boolean; dir: string }
+
+/** Structured mount listing (precedence-ordered: kind-rank then name) for
+ * in-process consumers — the chat file-ref resolver walks this for
+ * first-hit resolution. `dir` is the mount's on-disk directory. */
+export function listMounts(root: string = vaultsRoot()): MountInfo[] {
let entries: string[]
try {
entries = readdirSync(root).sort()
} catch {
- return JSON.stringify({ ok: true, mounts: [], error: null })
+ return []
}
- const mounts: { id: string; kind: string; writable: boolean; last_sync: string }[] = []
+ const mounts: MountInfo[] = []
for (const base of entries) {
let text: string
try {
@@ -43,9 +43,19 @@ export function scanMounts(root: string = vaultsRoot()): string {
const kind = text.match(/^\s*kind\s*=\s*"([^"]*)"/m)?.[1] ?? ""
if (!kind) continue
const name = text.match(/^\s*name\s*=\s*"([^"]*)"/m)?.[1] || base
- mounts.push({ id: name, kind, writable: writableByKind(kind), last_sync: "unknown" })
+ mounts.push({ id: name, kind, writable: writableByKind(kind), dir: path.join(root, base) })
}
mounts.sort((a, b) => kindRank(a.kind) - kindRank(b.kind) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
+ return mounts
+}
+
+/** CLI-less mount listing: scan each vault dir under the vaults root for its
+ * `.amico-vault.toml` marker and emit the same `{ok,mounts}` wire shape the
+ * Vaults tab parses. Parity with the extension's resolveMountStack (kind rank +
+ * writable-by-kind); ordering is kind-rank then name. `last_sync` is unknown
+ * without the CLI. Never throws. */
+export function scanMounts(root: string = vaultsRoot()): string {
+ const mounts = listMounts(root).map(({ id, kind, writable }) => ({ id, kind, writable, last_sync: "unknown" }))
return JSON.stringify({ ok: true, mounts, error: null })
}
diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts
index 820c2f0e1a..7d1833949b 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/server.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts
@@ -72,6 +72,7 @@ import { serveUIEffect } from "@/server/shared/ui"
import * as AmicodeVaults from "@/server/amicode/vaults"
import * as AmicodeWarrants from "@/server/amicode/warrants"
import * as AmicodeVaultBrowser from "@/server/amicode/vault-browser"
+import * as AmicodeFileResolve from "@/server/amicode/file-resolve"
import * as AmicodeProblems from "@/server/amicode/problems"
import * as AmicodeWidgets from "@/server/amicode/widgets"
import * as AmicodeDashboard from "@/server/amicode/dashboard"
@@ -261,6 +262,17 @@ const amicodeVaultsRoute = HttpRouter.use((router) =>
})
}),
)
+ // Chat file-reference linkifier: resolve a path-like string from chat
+ // markdown (inline-code pill, relative authored link) to an absolute
+ // on-disk path so the app can render it as a file:// link — the VS Code
+ // bridge opens the file from there. Same loopback gate as the vault
+ // browser; RESOLVES only, never reads.
+ yield* router.add("GET", "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/amicode/resolve-file", (request) =>
+ Effect.sync(() => {
+ const p = new URL(request.url, "http://localhost").searchParams.get("path") ?? undefined
+ return HttpServerResponse.text(AmicodeFileResolve.resolveFileBody(p), { contentType: "application/json" })
+ }),
+ )
// amicode#203: New-project creation — mkdir + best-effort git init. JSON
// body {name, parentDir}; never rejects (failures come back as ok:false).
yield* router.add("POST", "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/amicode/project", (request) =>
diff --git a/packages/opencode/test/server/amicode-file-resolve.test.ts b/packages/opencode/test/server/amicode-file-resolve.test.ts
new file mode 100644
index 0000000000..f5aac1f0da
--- /dev/null
+++ b/packages/opencode/test/server/amicode-file-resolve.test.ts
@@ -0,0 +1,132 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"
+import { tmpdir } from "node:os"
+import path from "node:path"
+import { resolveFileRef, resolveFileBody } from "@/server/amicode/file-resolve"
+import { setBindHostname } from "@/server/amicode/connections"
+
+// Two mounts so precedence (personal outranks team) is observable; a project
+// dir stands in for the server's cwd.
+function fixture() {
+ // realpath: the resolver's containment guard returns REAL paths, and macOS
+ // tmpdir is a /var → /private/var symlink — compare like with like.
+ const vaultRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "file-resolve-vaults-")))
+ const personal = path.join(vaultRoot, "armonia-personal")
+ const team = path.join(vaultRoot, "armonissima")
+ for (const [dir, marker] of [
+ [personal, 'kind = "personal"\nname = "armonia-personal"\n'],
+ [team, 'kind = "team"\nname = "armonissima"\n'],
+ ] as const) {
+ mkdirSync(path.join(dir, "insights"), { recursive: true })
+ mkdirSync(path.join(dir, "amicode", "problems"), { recursive: true })
+ writeFileSync(path.join(dir, ".amico-vault.toml"), marker)
+ }
+ // same relpath in both mounts → personal must win
+ writeFileSync(path.join(personal, "insights", "shared.md"), "# personal\n")
+ writeFileSync(path.join(team, "insights", "shared.md"), "# team\n")
+ writeFileSync(path.join(team, "insights", "team-only.md"), "# team only\n")
+ writeFileSync(path.join(personal, "amicode", "problems", "x-gate-transmon.md"), "# card\n")
+ const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "file-resolve-proj-")))
+ mkdirSync(path.join(cwd, "src"), { recursive: true })
+ writeFileSync(path.join(cwd, "src", "main.ts"), "x\n")
+ const home = realpathSync(mkdtempSync(path.join(tmpdir(), "file-resolve-home-")))
+ writeFileSync(path.join(home, "todo.md"), "x\n")
+ return { vaultRoot, personal, team, cwd, home }
+}
+
+const opts = (f: ReturnType) => ({ vaultRoot: f.vaultRoot, cwd: f.cwd, home: f.home })
+
+// The suite runs with no listener bound (in-process = loopback-equivalent), so
+// the browse gate is open by default; the gate test restores after.
+afterEach(() => setBindHostname(undefined))
+
+describe("resolveFileRef tiers", () => {
+ test("absolute paths resolve directly, files and dirs", () => {
+ const f = fixture()
+ const file = resolveFileRef(path.join(f.personal, "insights", "shared.md"), opts(f))
+ expect(file).toEqual({ path: path.join(f.personal, "insights", "shared.md"), mount: undefined, kind: "file" })
+ const dir = resolveFileRef(f.cwd, opts(f))
+ expect(dir?.kind).toBe("dir")
+ expect(resolveFileRef(path.join(f.cwd, "nope.md"), opts(f))).toBeUndefined()
+ })
+
+ test("~ expands against the injected home", () => {
+ const f = fixture()
+ expect(resolveFileRef("~/todo.md", opts(f))?.path).toBe(path.join(f.home, "todo.md"))
+ expect(resolveFileRef("~/nope.md", opts(f))).toBeUndefined()
+ })
+
+ test("mount-prefixed paths resolve under that mount (and nowhere else)", () => {
+ const f = fixture()
+ const hit = resolveFileRef("armonissima/insights/team-only.md", opts(f))
+ expect(hit).toEqual({ path: path.join(f.team, "insights", "team-only.md"), mount: "armonissima", kind: "file" })
+ // a directory pill, trailing slash and all
+ expect(resolveFileRef("armonissima/insights/", opts(f))).toEqual({
+ path: path.join(f.team, "insights"),
+ mount: "armonissima",
+ kind: "dir",
+ })
+ // explicit mount references do NOT fall through to other tiers
+ expect(resolveFileRef("armonissima/no/such/file.md", opts(f))).toBeUndefined()
+ // traversal out of the mount is refused
+ expect(resolveFileRef("armonissima/../../etc/hostname", opts(f))).toBeUndefined()
+ })
+
+ test("relative paths first-hit across mounts in precedence order", () => {
+ const f = fixture()
+ expect(resolveFileRef("insights/shared.md", opts(f))).toEqual({
+ path: path.join(f.personal, "insights", "shared.md"),
+ mount: "armonia-personal",
+ kind: "file",
+ })
+ // a miss in personal falls to team
+ expect(resolveFileRef("insights/team-only.md", opts(f))?.mount).toBe("armonissima")
+ })
+
+ test("relative paths fall back to the mount's amicode/ state dir, then the project dir", () => {
+ const f = fixture()
+ expect(resolveFileRef("problems/x-gate-transmon.md", opts(f))).toEqual({
+ path: path.join(f.personal, "amicode", "problems", "x-gate-transmon.md"),
+ mount: "armonia-personal",
+ kind: "file",
+ })
+ expect(resolveFileRef("src/main.ts", opts(f))).toEqual({
+ path: path.join(f.cwd, "src", "main.ts"),
+ mount: undefined,
+ kind: "file",
+ })
+ expect(resolveFileRef("src/nope.ts", opts(f))).toBeUndefined()
+ })
+
+ test("bare filenames resolve only via the typed-prefix table", () => {
+ const f = fixture()
+ writeFileSync(path.join(f.personal, "insights", "insight-20260804-120000-mitten.md"), "# x\n")
+ expect(resolveFileRef("insight-20260804-120000-mitten.md", opts(f))?.path).toBe(
+ path.join(f.personal, "insights", "insight-20260804-120000-mitten.md"),
+ )
+ // a bare name with no typed prefix never links into the vault, even when
+ // the file exists at a mount root
+ writeFileSync(path.join(f.personal, "STRATEGY.md"), "# x\n")
+ expect(resolveFileRef("STRATEGY.md", opts(f))).toBeUndefined()
+ expect(resolveFileRef("result.toml", opts(f))).toBeUndefined()
+ })
+
+ test("non-paths are refused", () => {
+ const f = fixture()
+ expect(resolveFileRef("https://example.com/x.md", opts(f))).toBeUndefined()
+ expect(resolveFileRef("mailto:a@b.c", opts(f))).toBeUndefined()
+ expect(resolveFileRef("", opts(f))).toBeUndefined()
+ expect(resolveFileRef(" ", opts(f))).toBeUndefined()
+ })
+})
+
+describe("resolveFileBody (route contract)", () => {
+ test("found / not-found shapes", () => {
+ expect(JSON.parse(resolveFileBody("definitely-not-a-file-anywhere.xyz")).found).toBe(false)
+ expect(JSON.parse(resolveFileBody(undefined)).error).toMatch(/^bad_request: path is required/)
+ })
+ test("an exposed bind refuses with the forbidden body", () => {
+ setBindHostname("0.0.0.0")
+ expect(JSON.parse(resolveFileBody("x.md")).error).toMatch(/^forbidden: file resolution/)
+ })
+})
diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json
index cec400af42..6b2f78a57c 100644
--- a/packages/session-ui/package.json
+++ b/packages/session-ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/session-ui",
- "version": "1.18.12",
+ "version": "1.18.10",
"private": true,
"type": "module",
"license": "MIT",
@@ -11,6 +11,7 @@
"./message-part-text": "./src/components/message-part-text.ts",
"./markdown-stream": "./src/components/markdown-stream.ts",
"./markdown-cache": "./src/components/markdown-cache.tsx",
+ "./markdown-file-refs": "./src/components/markdown-file-refs.ts",
"./line-comment-styles": "./src/components/line-comment-styles.ts",
"./pierre": "./src/pierre/index.ts",
"./pierre/*": "./src/pierre/*.ts",
diff --git a/packages/session-ui/src/components/markdown-file-refs.test.ts b/packages/session-ui/src/components/markdown-file-refs.test.ts
new file mode 100644
index 0000000000..a733980d35
--- /dev/null
+++ b/packages/session-ui/src/components/markdown-file-refs.test.ts
@@ -0,0 +1,68 @@
+import { beforeEach, describe, expect, test } from "bun:test"
+import {
+ cachedFileRef,
+ clearFileRefCache,
+ fileRefResolver,
+ fileRefUrl,
+ registerFileRefResolver,
+ resolveFileRefCached,
+} from "./markdown-file-refs"
+
+beforeEach(() => {
+ registerFileRefResolver(undefined)
+ clearFileRefCache()
+})
+
+describe("resolver registry", () => {
+ test("no resolver registered → resolves to null, uncached", async () => {
+ expect(fileRefResolver()).toBeUndefined()
+ expect(await resolveFileRefCached("foo.md")).toBeNull()
+ expect(cachedFileRef("foo.md")).toBeUndefined()
+ })
+
+ test("resolution results are cached, hits and misses alike", async () => {
+ let calls = 0
+ registerFileRefResolver(async (text) => {
+ calls++
+ return text.startsWith("real") ? `/abs/${text}` : null
+ })
+ expect(await resolveFileRefCached("real.md")).toBe("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/abs/real.md")
+ expect(await resolveFileRefCached("ghost.md")).toBeNull()
+ expect(await resolveFileRefCached("real.md")).toBe("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/abs/real.md")
+ expect(cachedFileRef("real.md")).toBe("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/abs/real.md")
+ expect(cachedFileRef("ghost.md")).toBeNull()
+ expect(calls).toBe(2) // second real.md call came from cache
+ })
+
+ test("in-flight resolutions dedupe", async () => {
+ let calls = 0
+ registerFileRefResolver(async (text) => {
+ calls++
+ await new Promise((r) => setTimeout(r, 10))
+ return `/abs/${text}`
+ })
+ const [a, b] = await Promise.all([resolveFileRefCached("x.md"), resolveFileRefCached("x.md")])
+ expect(a).toBe("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/abs/x.md")
+ expect(b).toBe("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/abs/x.md")
+ expect(calls).toBe(1)
+ })
+
+ test("resolver failures stay UNCACHED so the next render retries", async () => {
+ let calls = 0
+ registerFileRefResolver(async () => {
+ calls++
+ if (calls === 1) throw new Error("server down")
+ return "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/abs/recovered.md"
+ })
+ expect(await resolveFileRefCached("recovered.md")).toBeNull()
+ expect(cachedFileRef("recovered.md")).toBeUndefined()
+ expect(await resolveFileRefCached("recovered.md")).toBe("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/abs/recovered.md")
+ expect(calls).toBe(2)
+ })
+})
+
+describe("fileRefUrl", () => {
+ test("posix absolute paths encode spaces, keep slashes", () => {
+ expect(fileRefUrl("/Users/aaron/My Notes/insight-1.md")).toBe("file:///Users/aaron/My%20Notes/insight-1.md")
+ })
+})
diff --git a/packages/session-ui/src/components/markdown-file-refs.ts b/packages/session-ui/src/components/markdown-file-refs.ts
new file mode 100644
index 0000000000..327190e85e
--- /dev/null
+++ b/packages/session-ui/src/components/markdown-file-refs.ts
@@ -0,0 +1,69 @@
+// AMICODE: file-reference linkification for chat markdown.
+// The app registers a resolver (it owns the server connection + auth); the
+// Markdown component then wraps path-like inline-code pills and relative
+// authored links in file:// anchors that the VS Code bridge opens in the
+// editor. session-ui stays server-agnostic: with no resolver registered
+// (plain browser embed, stories, tests) nothing linkifies and pills stay pills.
+
+export type FileRefResolver = (text: string) => Promise
+
+let resolver: FileRefResolver | undefined
+
+/** App-side registration — called once at shell mount, `undefined` on
+ * unmount. The resolver must resolve its server connection PER CALL (a server
+ * switch is picked up by the very next resolution). */
+export function registerFileRefResolver(next: FileRefResolver | undefined): void {
+ resolver = next
+}
+
+export function fileRefResolver(): FileRefResolver | undefined {
+ return resolver
+}
+
+// text → absolute path (null = resolved-to-nothing). Bounded FIFO: pill text
+// is unbounded agent/user prose, so the cache must not grow with the session.
+const CACHE_MAX = 500
+const cache = new Map()
+const inflight = new Map>()
+
+/** undefined = not yet resolved; null = known-unresolvable; string = absolute path. */
+export function cachedFileRef(text: string): string | null | undefined {
+ return cache.get(text)
+}
+
+/** Resolve once, dedupe in-flight, cache definitive answers. Transient
+ * failures (server down mid-fetch) stay UNCACHED so the next render retries. */
+export function resolveFileRefCached(text: string): Promise {
+ const hit = cache.get(text)
+ if (hit !== undefined) return Promise.resolve(hit)
+ const pending = inflight.get(text)
+ if (pending) return pending
+ const resolve = resolver
+ if (!resolve) return Promise.resolve(null)
+ const p = resolve(text)
+ .then((abs) => {
+ if (cache.size >= CACHE_MAX) {
+ const oldest = cache.keys().next()
+ if (!oldest.done) cache.delete(oldest.value)
+ }
+ cache.set(text, abs)
+ return abs
+ })
+ .catch(() => null)
+ .finally(() => inflight.delete(text))
+ inflight.set(text, p)
+ return p
+}
+
+/** The bridge (extension chat_bridge.ts) decodes via new URL(...).pathname +
+ * decodeURIComponent, so percent-encode here; posix-only fleet, keep the
+ * slashes readable. */
+export function fileRefUrl(abs: string): string {
+ return "file://" + encodeURI(abs)
+}
+
+/** Test hook. */
+export function clearFileRefCache(): void {
+ cache.clear()
+ inflight.clear()
+}
diff --git a/packages/session-ui/src/components/markdown.css b/packages/session-ui/src/components/markdown.css
index 89bf947455..74bc3f88ac 100644
--- a/packages/session-ui/src/components/markdown.css
+++ b/packages/session-ui/src/components/markdown.css
@@ -263,6 +263,19 @@
text-underline-offset: 2px;
}
+ /* AMICODE: a resolved file reference keeps the path-pill tint and earns a
+ * quiet underline + pointer — a link that looks like a pill, not a URL. */
+ a.external-link > code[data-inline-code-kind="path"] {
+ text-decoration: underline;
+ text-decoration-color: color-mix(in oklch, var(--markdown-inline-code-path-color) 30%, transparent);
+ text-underline-offset: 2px;
+ cursor: pointer;
+ }
+
+ a.external-link:hover > code[data-inline-code-kind="path"] {
+ text-decoration-color: var(--markdown-inline-code-path-color);
+ }
+
/* Tables */
table {
width: 100%;
diff --git a/packages/session-ui/src/components/markdown.tsx b/packages/session-ui/src/components/markdown.tsx
index 8723413b4d..9a97b4e5ac 100644
--- a/packages/session-ui/src/components/markdown.tsx
+++ b/packages/session-ui/src/components/markdown.tsx
@@ -33,6 +33,7 @@ import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol
import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state"
import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache"
import { inlineCodeKind } from "./markdown-inline-code-kind"
+import { cachedFileRef, fileRefResolver, fileRefUrl, resolveFileRefCached } from "./markdown-file-refs"
type RenderedBlock =
| (MarkdownCacheEntry & { key: string; mode: Exclude })
@@ -272,6 +273,63 @@ function markInlineCode(root: HTMLDivElement) {
}
}
+// AMICODE: linkify file references. Path-kind inline-code pills get wrapped in
+// file:// anchors when the app-registered resolver finds them on disk; authored
+// relative links ([x](plans/foo.md)) get their sandbox-dead relative href
+// rewritten to file://. Unresolvable references stay plain pills/links — never
+// a dead anchor. Runs on the LIVE container after morphdom (unlike the other
+// decorators, which run on the detached block): async resolutions patch nodes
+// in place when they land, and morphdom rebuilds get re-wrapped from the
+// (synchronous) cache on the next pass. Must stay AFTER markCodeLinks in any
+// shared pass — that function unwraps code spans inside anchors when the text
+// isn't a URL.
+function wrapCodeInFileLink(code: HTMLElement, abs: string) {
+ const link = document.createElement("a")
+ link.href = fileRefUrl(abs)
+ link.className = "external-link"
+ link.title = abs
+ code.parentNode?.replaceChild(link, code)
+ link.appendChild(code)
+}
+
+function markFileLinks(root: HTMLDivElement) {
+ if (!fileRefResolver()) return
+ const codes = Array.from(root.querySelectorAll(":not(pre) > code[data-inline-code-kind=\"path\"]"))
+ for (const code of codes) {
+ if (!(code instanceof HTMLElement)) continue
+ if (code.parentElement instanceof HTMLAnchorElement) continue
+ const text = code.textContent ?? ""
+ if (!text) continue
+ const cached = cachedFileRef(text)
+ if (cached !== undefined) {
+ if (cached) wrapCodeInFileLink(code, cached)
+ continue
+ }
+ void resolveFileRefCached(text).then((abs) => {
+ if (!abs || !code.isConnected) return
+ if (code.parentElement instanceof HTMLAnchorElement) return
+ wrapCodeInFileLink(code, abs)
+ })
+ }
+ // authored relative links — DOMPurify keeps relative hrefs, but a relative
+ // navigation is dead in the sandboxed iframe. Rewrite to file:// on resolve.
+ const anchors = Array.from(root.querySelectorAll("a.external-link"))
+ for (const anchor of anchors) {
+ if (!(anchor instanceof HTMLAnchorElement)) continue
+ const href = anchor.getAttribute("href") ?? ""
+ if (!href || href.startsWith("#") || /^(https?:\/\/|mailto:|file:)/i.test(href)) continue
+ const cached = cachedFileRef(href)
+ if (cached !== undefined) {
+ if (cached) anchor.href = fileRefUrl(cached)
+ continue
+ }
+ void resolveFileRefCached(href).then((abs) => {
+ if (!abs || !anchor.isConnected) return
+ anchor.href = fileRefUrl(abs)
+ })
+ }
+}
+
function decorate(root: HTMLDivElement, labels: CopyLabels) {
const blocks = Array.from(root.querySelectorAll("pre"))
for (const block of blocks) {
@@ -521,6 +579,9 @@ export function Markdown(
copied: i18n.t("ui.message.copied"),
}))
if (!linkCleanup) linkCleanup = setupExternalLinks(container)
+ // File-reference linkification rides the same new-layout gate as the
+ // inline-code pill tagging (decorate) whose data attributes it consumes.
+ if (document.body.hasAttribute("data-new-layout")) markFileLinks(container)
})
onCleanup(() => {
diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css
index 6a04faecdb..5b0a24618e 100644
--- a/packages/session-ui/src/components/message-part.css
+++ b/packages/session-ui/src/components/message-part.css
@@ -487,6 +487,23 @@
font-weight: var(--font-weight-regular);
}
+ /* AMICODE: the clickable variant (opens the file via the bridge) — strip the
+ * button chrome, keep the filename look, earn a hover underline. */
+ button[data-slot="message-part-title-filename"] {
+ appearance: none;
+ background: none;
+ border: none;
+ padding: 0;
+ font: inherit;
+ color: inherit;
+ cursor: pointer;
+ }
+
+ button[data-slot="message-part-title-filename"]:hover {
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ }
+
[data-slot="message-part-path"] {
display: flex;
flex-grow: 1;
@@ -1141,26 +1158,6 @@
}
}
- /* Text-card (kind: "text") — the textarea is the ONLY affordance, so it
- needs visible chrome (border + background) unlike the choice-card's
- secondary custom-input which is borderless/transparent. */
- [data-slot="question-text-form"] {
- padding: 4px 8px;
- }
-
- [data-slot="question-text-form"] > [data-slot="question-custom-input"] {
- border: 1px solid var(--v2-border-border-base);
- border-radius: var(--radius-md);
- padding: 8px 12px;
- background: var(--v2-background-bg-layer-02);
- min-height: 36px;
-
- &:focus-visible {
- border-color: var(--v2-border-border-focus);
- outline: none;
- }
- }
-
[data-slot="question-footer"] {
display: flex;
align-items: center;
@@ -1395,6 +1392,23 @@
}
}
+/* AMICODE: loaded-file rows are buttons now (open the file via the bridge) —
+ * strip the button chrome, keep the row look, earn a hover tint. */
+button[data-component="tool-loaded-file"] {
+ appearance: none;
+ background: none;
+ border: none;
+ width: 100%;
+ text-align: left;
+ cursor: pointer;
+}
+
+button[data-component="tool-loaded-file"]:hover span {
+ color: var(--v2-text-text-base);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
body[data-new-layout] [data-component="user-message"] {
font-weight: 440;
@@ -1609,16 +1623,6 @@ body:not([data-new-layout]) {
outline: 1px solid var(--border-interactive-base);
}
}
-
- [data-slot="question-text-form"] > [data-slot="question-custom-input"] {
- border-color: var(--border-weak-base);
- background: var(--surface-weak);
-
- &:focus-visible {
- border-color: var(--border-interactive-base);
- outline: none;
- }
- }
}
[data-component="question-answers"] {
diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx
index 6e70654e70..c80e756dfe 100644
--- a/packages/session-ui/src/components/message-part.tsx
+++ b/packages/session-ui/src/components/message-part.tsx
@@ -44,6 +44,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { type UiI18n, useI18n } from "@opencode-ai/ui/context/i18n"
import { BasicTool, GenericTool } from "./basic-tool"
import { AmicodeToolCard, AmicoSkillChip } from "@opencode-ai/ui/amicode-card"
+import { openFileInEditor } from "@opencode-ai/ui/amicode-bridge"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { Collapsible } from "@opencode-ai/ui/collapsible"
@@ -2182,6 +2183,29 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
)
}
+// AMICODE: the filename in an edit/write trigger opens the file in the editor
+// via the bridge (the chat iframe can't). stopPropagation so the tool body's
+// expand/collapse doesn't fire alongside.
+function OpenableFilename(props: { filename: string; path: string }) {
+ return (
+ {props.filename}}>
+
+
+ )
+}
+
ToolRegistry.register({
name: "read",
render(props) {
@@ -2209,12 +2233,22 @@ ToolRegistry.register({
/>
{(filepath) => (
-
+
+
)}
>
@@ -2624,7 +2658,7 @@ ToolRegistry.register({
- {filename()}
+
@@ -2691,7 +2725,7 @@ ToolRegistry.register({
- {filename()}
+
@@ -3038,11 +3072,17 @@ ToolRegistry.register({
// always renders, with the skill's own name beside it.
const name = createMemo(() => props.input.name?.trim() || "")
const body = createMemo(() => skillBody(props.output))
+ // AMICODE: the skill tool result carries its base dir in metadata — the chip
+ // links to the SKILL.md source file (opens in the editor via the bridge).
+ const skillPath = createMemo(() => {
+ const dir = props.metadata?.dir
+ return typeof dir === "string" && dir !== "" ? `${dir}/SKILL.md` : undefined
+ })
const trigger = () => (
-
+
)
diff --git a/packages/ui/src/amicode/bridge.ts b/packages/ui/src/amicode/bridge.ts
new file mode 100644
index 0000000000..8528c939b0
--- /dev/null
+++ b/packages/ui/src/amicode/bridge.ts
@@ -0,0 +1,9 @@
+// AMICODE: imperative side of the iframe→extension bridge for OPENING FILES.
+// Anchors handle chat-markdown links declaratively (session-ui markdown.tsx
+// posts on click); chips and tool cards outside markdown call this instead.
+// No-op outside the framed VS Code webview. The host (extension chat_bridge.ts)
+// validates absolute + exists on every message, so a stale path fails quiet.
+export function openFileInEditor(absPath: string): void {
+ if (window.parent === window) return
+ window.parent.postMessage({ source: "amicode", kind: "open-file", url: "file://" + encodeURI(absPath) }, "*")
+}
diff --git a/packages/ui/src/amicode/card.tsx b/packages/ui/src/amicode/card.tsx
index 53406b2b78..c8fc60720b 100644
--- a/packages/ui/src/amicode/card.tsx
+++ b/packages/ui/src/amicode/card.tsx
@@ -1,4 +1,5 @@
import { For, Match, Show, Switch, createMemo } from "solid-js"
+import { openFileInEditor } from "./bridge"
import { amicodeStage } from "./stage"
import { parseAskInput } from "./ask"
import { AmicodeAskCard } from "./ask-card"
@@ -262,22 +263,32 @@ function Chip(props: { tool: string; status?: string; output?: string; count?: n
// receipts wear. Reads "" like every other chip: the label names the kind,
// the detail names the skill.
//
-// Inert by construction. Unlike a receipt there is no entity to open, so this is the plain
-// shell with no chevron rather than the clickable