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 +
) } diff --git a/packages/ui/src/components/amicode-bridge.tsx b/packages/ui/src/components/amicode-bridge.tsx new file mode 100644 index 0000000000..5de433e95e --- /dev/null +++ b/packages/ui/src/components/amicode-bridge.tsx @@ -0,0 +1,3 @@ +// AMICODE: re-export shim (same wildcard-export pattern as amicode-card.tsx). +// Logic lives in ../amicode/bridge.ts. +export { openFileInEditor } from "../amicode/bridge"