Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions packages/app/src/pages/session/context-tree-panel.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,11 +7,13 @@
// the real file (project files in a session tab, vault files in the Vault
// panel). Hovering a tool row in the log still glances at its node here via
// the same amicode:brain-hover event the strip used.
import { createEffect, createMemo, createSignal, onCleanup, onMount, For, Show } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, For, Show } from "solid-js"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useSync } from "@/context/sync"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useServer } from "@/context/server"
import { amicodeGet } from "@/utils/amicode-fetch"
import { amicoBrainRef } from "@opencode-ai/ui/brain-ref"
import {
createContextTreeEngine,
Expand DownExpand Up@@ -66,8 +68,33 @@ function ContextTreeFrame(props: { sessionID: string }) {
const sync = useSync()
const file = useFile()
const language = useLanguage()
const server = useServer()
const { tabs, view } = useSessionLayout()

// per-mount browsability from GET /amicode/vaults (`browsable`, stamped by
// the server's fail-closed law) — proprietary mounts mark their nodes
// locked upfront instead of dead-ending in a Vault-panel refusal on click
const [vaultsRaw] = createResource(
() => server.current,
(conn) => amicodeGet(conn, "/amicode/vaults").catch(() => undefined),
)
const browsableMounts = createMemo<Map<string, boolean | undefined> | undefined>(() => {
const raw = vaultsRaw() as { mounts?: { id?: string; browsable?: boolean }[] } | undefined
if (!raw || !Array.isArray(raw.mounts)) return undefined
return new Map(
raw.mounts.filter((m) => typeof m?.id === "string").map((m) => [m.id as string, m.browsable]),
)
})
const vaultLocked = (mount: string) => {
const map = browsableMounts()
// list unavailable → status quo (no lock claims we can't back);
// a mount the server doesn't list can't be browsed → locked;
// `browsable` absent (older server) → unknown, again no lock claim
if (!map) return false
if (!map.has(mount)) return true
return map.get(mount) === false
}

const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
const getParts = (msgId: string) => sync.data.part[msgId] ?? []
const busy = createMemo(() => (sync.data.session_status[props.sessionID]?.type ?? "idle") !== "idle")
Expand DownExpand Up@@ -134,7 +161,7 @@ function ContextTreeFrame(props: { sessionID: string }) {
setActive: (tab) => tabs().setActive(tab),
})
const onSelect = (node: ContextTreeSelection) => {
if (!node.path) return
if (!node.path || node.locked) return
const vaultRef = vaultRefFromPath(node.path)
if (vaultRef) {
vaultPanel.open({ mount: vaultRef.mount, path: vaultRef.rel })
Expand All@@ -161,7 +188,7 @@ function ContextTreeFrame(props: { sessionID: string }) {
window.addEventListener("amicode:brain-hover", onToolHover)
onCleanup(() => window.removeEventListener("amicode:brain-hover", onToolHover))

const tree = createMemo(() => buildContextTree(turns()))
const tree = createMemo(() => buildContextTree(turns(), { vaultLocked }))
createEffect(() => {
const brain = engine()
if (!brain) return
Expand All@@ -173,7 +200,8 @@ function ContextTreeFrame(props: { sessionID: string }) {
const flatNodes = createMemo(() => {
const out: ContextTreeSelection[] = []
const walk = (n: ContextTreeNodeInput) => {
if (n.kind !== "root") out.push({ id: n.id, label: n.label, kind: n.kind, path: n.path, vault: n.vault })
if (n.kind !== "root")
out.push({ id: n.id, label: n.label, kind: n.kind, path: n.path, vault: n.vault, locked: n.locked })
for (const c of n.children ?? []) walk(c)
}
walk(tree())
Expand All@@ -188,7 +216,11 @@ function ContextTreeFrame(props: { sessionID: string }) {
setKbIndex(next)
const node = list[next]
engine()?.focus(node.id)
setAnnounce(`${node.label} — ${node.kind}${node.path ? ", press Enter to open" : ""}`)
setAnnounce(
`${node.label} — ${node.kind}${
node.locked ? ", locked — this vault does not allow browsing" : node.path ? ", press Enter to open" : ""
}`,
)
}
const onCanvasKeyDown = (e: KeyboardEvent) => {
const list = flatNodes()
Expand Down
28 changes: 27 additions & 1 deletion packages/opencode/src/server/amicode/vaults.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFil
import { homedir } from "node:os"
import path from "node:path"
import { run } from "@/util/process"
import { browseAllowed, mountBrowseRefusal, mountDir } from "@/server/amicode/vault-browser"

const TIMEOUT_MS = 8_000
const CACHE_MS = 10_000
Expand DownExpand Up@@ -65,9 +66,34 @@ function resolveCli(): string | undefined {

let cache: { at: number; body: string } | undefined

/** Stamp each mount with `browsable`, computed by the vault-browser's
* fail-closed law (deployment gate + per-mount kind/marker rules), so the
* app can mark proprietary context locked UPFRONT — e.g. grey out context
* tree nodes — instead of discovering the refusal on click. Additive to the
* relayed wire shape; an unparseable body passes through untouched. */
export function annotateBrowsable(
body: string,
root: string = vaultsRoot(),
env: Record<string, string | undefined> = process.env,
): string {
try {
const parsed = JSON.parse(body) as { mounts?: { id?: unknown; browsable?: boolean }[] }
if (!Array.isArray(parsed.mounts)) return body
const allowed = browseAllowed(env)
for (const m of parsed.mounts) {
if (typeof m?.id !== "string") continue
const dir = allowed ? mountDir(m.id, root) : undefined
m.browsable = !!dir && !mountBrowseRefusal(m.id, dir, env)
}
return JSON.stringify(parsed)
} catch {
return body
}
}

export async function status(): Promise<string> {
if (cache && Date.now() - cache.at < CACHE_MS) return cache.body
const body = await statusUncached().catch((err) => synthesize("bad_output", String(err)))
const body = annotateBrowsable(await statusUncached().catch((err) => synthesize("bad_output", String(err))))
cache = { at: Date.now(), body }
return body
}
Expand Down
46 changes: 45 additions & 1 deletion packages/opencode/test/server/amicode-vaults.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,15 @@ import { describe, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, writeFileSync, existsSync, lstatSync } from "node:fs"
import { tmpdir, homedir } from "node:os"
import path from "node:path"
import { candidates, synthesize, normalizeRef, sanitizeVaultName, attachVault, scanMounts } from "@/server/amicode/vaults"
import {
annotateBrowsable,
candidates,
synthesize,
normalizeRef,
sanitizeVaultName,
attachVault,
scanMounts,
} from "@/server/amicode/vaults"

describe("synthesize", () => {
test("emits the plural failure shape the UI parser expects", () => {
Expand DownExpand Up@@ -118,3 +126,39 @@ describe("scanMounts (CLI-less fallback)", () => {
expect(out.mounts[1]).toMatchObject({ id: "armonissima", kind: "team", writable: false })
})
})

describe("annotateBrowsable", () => {
const root = mkdtempSync(path.join(tmpdir(), "vaults-annotate-"))
const mk = (name: string, marker: string) => {
mkdirSync(path.join(root, name), { recursive: true })
writeFileSync(path.join(root, name, ".amico-vault.toml"), marker)
}
mk("personal-v", 'kind = "personal"\nname = "personal-v"\n')
mk("team-dark", 'kind = "team"\nname = "team-dark"\n')
mk("team-open", 'kind = "team"\nname = "team-open"\nbrowse = true\n')
mk("personal-off", 'kind = "personal"\nname = "personal-off"\nbrowse = false\n')
const env = { AMICO_VAULT_BROWSER: "1" }

test("stamps browsable per the fail-closed law (kind default + browse override)", () => {
const out = JSON.parse(annotateBrowsable(scanMounts(root), root, env)) as {
mounts: { id: string; browsable: boolean }[]
}
const by = Object.fromEntries(out.mounts.map((m) => [m.id, m.browsable]))
expect(by["personal-v"]).toBe(true)
expect(by["team-dark"]).toBe(false)
expect(by["team-open"]).toBe(true)
expect(by["personal-off"]).toBe(false)
})
test("a mount the browser can't resolve is not browsable; junk passes through", () => {
const body = JSON.stringify({ ok: true, mounts: [{ id: "ghost", kind: "personal" }], error: null })
const out = JSON.parse(annotateBrowsable(body, root, env)) as { mounts: { browsable: boolean }[] }
expect(out.mounts[0].browsable).toBe(false)
expect(annotateBrowsable("not json", root, env)).toBe("not json")
})
test("deployment gate off (AMICO_VAULT_BROWSER=0) darkens every mount", () => {
const out = JSON.parse(annotateBrowsable(scanMounts(root), root, { AMICO_VAULT_BROWSER: "0" })) as {
mounts: { browsable: boolean }[]
}
for (const m of out.mounts) expect(m.browsable).toBe(false)
})
})
16 changes: 16 additions & 0 deletions packages/ui/src/amicode/context-tree-data.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,22 @@ describe("buildContextTree", () => {
])
expect(tree.children![0].children![0].vault).toBe(true)
})
test("vaultLocked marks non-browsable vault leaves locked; others untouched", () => {
const tree = buildContextTree(
[
turn("m1", [
{ label: "STRATEGY.md", type: "note", path: "/u/.amico/vaults/armonissima/STRATEGY.md" },
{ label: "notes.md", type: "note", path: "/u/.amico/vaults/armonia-kate/notes.md" },
{ label: "solve.jl", type: "package", path: "/p/solve.jl" },
]),
],
{ vaultLocked: (mount) => mount === "armonissima" },
)
const [team, personal, project] = tree.children![0].children!
expect(team.locked).toBe(true)
expect(personal.locked).toBe(false)
expect(project.locked).toBeUndefined() // not a vault file — predicate never consulted
})
test("marathon sessions fold old turns into one earlier branch", () => {
const turns = Array.from({ length: 30 }, (_, i) =>
turn(`m${i}`, [{ label: `f${i}.md`, type: "note", path: `/p/f${i}.md` }]),
Expand Down
16 changes: 12 additions & 4 deletions packages/ui/src/amicode/context-tree-data.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,7 +64,14 @@ export function vaultRefFromPath(path: string): { mount: string; rel: string } |
const dedupKey = (ref: ContextRef, kind: ContextTreeKind) =>
ref.path ? `p:${ref.path}` : `${kind}:${ref.label.toLowerCase()}`

export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: string } = {}): ContextTreeNodeInput {
export type ContextTreeOpts = {
rootLabel?: string
/** true when the mount refuses browsing (proprietary data/software) — its
* leaves render locked: dimmed, padlocked, not openable */
vaultLocked?: (mount: string) => boolean
}

export function buildContextTree(turns: ContextTurn[], opts: ContextTreeOpts = {}): ContextTreeNodeInput {
const root: ContextTreeNodeInput = {
id: "root",
label: opts.rootLabel ?? "amico",
Expand DownExpand Up@@ -95,7 +102,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
const key = dedupKey(ref, kind)
if (seen.has(key)) continue
seen.set(key, `ctx-${seen.size}`)
earlier.children!.push(leafOf(ref, kind, seen.get(key)!))
earlier.children!.push(leafOf(ref, kind, seen.get(key)!, opts))
}
}
const seen = seenOf(root)
Expand DownExpand Up@@ -123,7 +130,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
}
const id = `ctx-${seen.size}`
seen.set(key, id)
const leaf = leafOf(ref, kind, id)
const leaf = leafOf(ref, kind, id, opts)
node.children!.push(leaf)
lastLeaf = leaf
}
Expand All@@ -138,14 +145,15 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
return root
}

function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string): ContextTreeNodeInput {
function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string, opts: ContextTreeOpts): ContextTreeNodeInput {
const vault = ref.path ? vaultRefFromPath(ref.path) : undefined
return {
id,
label: ref.label.slice(0, 32),
kind,
path: ref.path,
vault: !!vault,
locked: vault && opts.vaultLocked ? opts.vaultLocked(vault.mount) : undefined,
}
}

Expand Down
43 changes: 35 additions & 8 deletions packages/ui/src/amicode/context-tree-engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,6 +44,9 @@ export type ContextTreeNodeInput = {
path?: string
/** the file lives in a vault mount (open via the Vault panel, not a tab) */
vault?: boolean
/** the vault refuses browsing (proprietary data/software) — the node renders
* dimmed with a padlock and is NOT openable, path or not */
locked?: boolean
/** where the agent currently works — wears the thought-color cursor */
active?: boolean
children?: ContextTreeNodeInput[]
Expand All@@ -57,6 +60,7 @@ export type ContextTreeSelection = {
kind: ContextTreeKind
path?: string
vault?: boolean
locked?: boolean
}

export interface ContextTreeEngineOptions {
Expand DownExpand Up@@ -181,6 +185,7 @@ interface TNode {
kind: ContextTreeKind
path?: string
vault?: boolean
locked?: boolean
active: boolean
depth: number
// world targets (tidy layout) and animated positions
Expand DownExpand Up@@ -333,6 +338,7 @@ export function createContextTreeEngine(
n.kind = input.kind
n.path = input.path
n.vault = input.vault
n.locked = input.locked
n.active = !!input.active
n.depth = depth
n.half = HALF[input.kind] ?? 4
Expand DownExpand Up@@ -456,7 +462,11 @@ export function createContextTreeEngine(
kind: n.kind,
path: n.path,
vault: n.vault,
locked: n.locked,
})
// openable = the click actually goes somewhere; everything else (turns,
// skills, agents, actions, locked vault files) must not wear the pointer
const openable = (n: TNode) => !!n.path && !n.locked
let dragging = false
let dragMoved = false
let lastPX = 0,
Expand DownExpand Up@@ -499,7 +509,8 @@ export function createContextTreeEngine(
const n = id ? (byId.get(id) ?? null) : null
if (n !== hovered) {
hovered = n
if (canvas.style) canvas.style.cursor = n && (n.path || n.kind === "turn") ? "pointer" : "grab"
// pointer only where a click opens something; a locked node says so
if (canvas.style) canvas.style.cursor = n && openable(n) ? "pointer" : n?.locked ? "not-allowed" : "grab"
opts.onHover?.(n ? selection(n) : null)
}
}
Expand All@@ -510,7 +521,9 @@ export function createContextTreeEngine(
const p = local(e)
const id = pick(p.x, p.y)
const n = id ? byId.get(id) : undefined
if (n) {
// only openable nodes acknowledge the click — a ring on a dead-end node
// would promise an action that never comes
if (n && openable(n)) {
n.ringT = beatNow
opts.onSelect?.(selection(n))
}
Expand DownExpand Up@@ -647,9 +660,11 @@ export function createContextTreeEngine(
ctx.lineWidth = 1
ctx.stroke()
} else {
ctx.fillStyle = rgba(color, hoveredNow ? 0.95 : 0.75)
// locked (non-browsable vault) leaves read clearly non-interactive:
// reduced emphasis, plus the padlock by the label (shape, not color)
ctx.fillStyle = rgba(color, n.locked ? 0.3 : hoveredNow ? 0.95 : 0.75)
ctx.fill()
ctx.strokeStyle = rgba(color, 0.9)
ctx.strokeStyle = rgba(color, n.locked ? 0.45 : 0.9)
ctx.lineWidth = 1
ctx.stroke()
}
Expand DownExpand Up@@ -690,13 +705,25 @@ export function createContextTreeEngine(
isRoot || n.kind === "turn" ? 0.85 : hoveredNow || glancedNow || n.active ? 1 : nearHover ? 0.85 : 0.6
ctx.font = `${n.kind === "turn" || isRoot ? "600 " : ""}10px JuliaMono, ui-monospace, SFMono-Regular, Menlo, monospace`
const text = n.label.length > 30 ? n.label.slice(0, 29) + "…" : n.label
const lockW = n.locked ? 10 : 0
const tw = ctx.measureText(text).width
const lx = x - tw / 2,
const lx = x - (tw + lockW) / 2,
ly = y + half + 10
ctx.fillStyle = css.labelHalo
ctx.fillRect(lx - 2, ly - 7, tw + 4, 14)
ctx.fillStyle = rgba(css.fg, Math.min(la, 1) * n.alpha)
ctx.fillText(text, lx, ly)
ctx.fillRect(lx - 2, ly - 7, tw + lockW + 4, 14)
const inkA = Math.min(la, 1) * n.alpha
if (n.locked) {
// padlock: shackle arc over a body — the non-color "cannot open" signal
ctx.strokeStyle = rgba(css.fg, inkA)
ctx.lineWidth = 1
ctx.beginPath()
ctx.arc(lx + 3, ly - 1.5, 2, Math.PI, 0)
ctx.stroke()
ctx.fillStyle = rgba(css.fg, inkA)
ctx.fillRect(lx, ly - 1.5, 6, 5)
}
ctx.fillStyle = rgba(css.fg, inkA)
ctx.fillText(text, lx + lockW, ly)
ctx.globalAlpha = 1
}

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions packages/app/src/pages/session/context-tree-panel.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,11 +7,13 @@
// the real file (project files in a session tab, vault files in the Vault
// panel). Hovering a tool row in the log still glances at its node here via
// the same amicode:brain-hover event the strip used.
import { createEffect, createMemo, createSignal, onCleanup, onMount, For, Show } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, For, Show } from "solid-js"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useSync } from "@/context/sync"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useServer } from "@/context/server"
import { amicodeGet } from "@/utils/amicode-fetch"
import { amicoBrainRef } from "@opencode-ai/ui/brain-ref"
import {
createContextTreeEngine,
Expand DownExpand Up@@ -66,8 +68,33 @@ function ContextTreeFrame(props: { sessionID: string }) {
const sync = useSync()
const file = useFile()
const language = useLanguage()
const server = useServer()
const { tabs, view } = useSessionLayout()

// per-mount browsability from GET /amicode/vaults (`browsable`, stamped by
// the server's fail-closed law) — proprietary mounts mark their nodes
// locked upfront instead of dead-ending in a Vault-panel refusal on click
const [vaultsRaw] = createResource(
() => server.current,
(conn) => amicodeGet(conn, "/amicode/vaults").catch(() => undefined),
)
const browsableMounts = createMemo<Map<string, boolean | undefined> | undefined>(() => {
const raw = vaultsRaw() as { mounts?: { id?: string; browsable?: boolean }[] } | undefined
if (!raw || !Array.isArray(raw.mounts)) return undefined
return new Map(
raw.mounts.filter((m) => typeof m?.id === "string").map((m) => [m.id as string, m.browsable]),
)
})
const vaultLocked = (mount: string) => {
const map = browsableMounts()
// list unavailable → status quo (no lock claims we can't back);
// a mount the server doesn't list can't be browsed → locked;
// `browsable` absent (older server) → unknown, again no lock claim
if (!map) return false
if (!map.has(mount)) return true
return map.get(mount) === false
}

const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
const getParts = (msgId: string) => sync.data.part[msgId] ?? []
const busy = createMemo(() => (sync.data.session_status[props.sessionID]?.type ?? "idle") !== "idle")
Expand DownExpand Up@@ -134,7 +161,7 @@ function ContextTreeFrame(props: { sessionID: string }) {
setActive: (tab) => tabs().setActive(tab),
})
const onSelect = (node: ContextTreeSelection) => {
if (!node.path) return
if (!node.path || node.locked) return
const vaultRef = vaultRefFromPath(node.path)
if (vaultRef) {
vaultPanel.open({ mount: vaultRef.mount, path: vaultRef.rel })
Expand All@@ -161,7 +188,7 @@ function ContextTreeFrame(props: { sessionID: string }) {
window.addEventListener("amicode:brain-hover", onToolHover)
onCleanup(() => window.removeEventListener("amicode:brain-hover", onToolHover))

const tree = createMemo(() => buildContextTree(turns()))
const tree = createMemo(() => buildContextTree(turns(), { vaultLocked }))
createEffect(() => {
const brain = engine()
if (!brain) return
Expand All@@ -173,7 +200,8 @@ function ContextTreeFrame(props: { sessionID: string }) {
const flatNodes = createMemo(() => {
const out: ContextTreeSelection[] = []
const walk = (n: ContextTreeNodeInput) => {
if (n.kind !== "root") out.push({ id: n.id, label: n.label, kind: n.kind, path: n.path, vault: n.vault })
if (n.kind !== "root")
out.push({ id: n.id, label: n.label, kind: n.kind, path: n.path, vault: n.vault, locked: n.locked })
for (const c of n.children ?? []) walk(c)
}
walk(tree())
Expand All@@ -188,7 +216,11 @@ function ContextTreeFrame(props: { sessionID: string }) {
setKbIndex(next)
const node = list[next]
engine()?.focus(node.id)
setAnnounce(`${node.label} — ${node.kind}${node.path ? ", press Enter to open" : ""}`)
setAnnounce(
`${node.label} — ${node.kind}${
node.locked ? ", locked — this vault does not allow browsing" : node.path ? ", press Enter to open" : ""
}`,
)
}
const onCanvasKeyDown = (e: KeyboardEvent) => {
const list = flatNodes()
Expand Down
28 changes: 27 additions & 1 deletion packages/opencode/src/server/amicode/vaults.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFil
import { homedir } from "node:os"
import path from "node:path"
import { run } from "@/util/process"
import { browseAllowed, mountBrowseRefusal, mountDir } from "@/server/amicode/vault-browser"

const TIMEOUT_MS = 8_000
const CACHE_MS = 10_000
Expand DownExpand Up@@ -65,9 +66,34 @@ function resolveCli(): string | undefined {

let cache: { at: number; body: string } | undefined

/** Stamp each mount with `browsable`, computed by the vault-browser's
* fail-closed law (deployment gate + per-mount kind/marker rules), so the
* app can mark proprietary context locked UPFRONT — e.g. grey out context
* tree nodes — instead of discovering the refusal on click. Additive to the
* relayed wire shape; an unparseable body passes through untouched. */
export function annotateBrowsable(
body: string,
root: string = vaultsRoot(),
env: Record<string, string | undefined> = process.env,
): string {
try {
const parsed = JSON.parse(body) as { mounts?: { id?: unknown; browsable?: boolean }[] }
if (!Array.isArray(parsed.mounts)) return body
const allowed = browseAllowed(env)
for (const m of parsed.mounts) {
if (typeof m?.id !== "string") continue
const dir = allowed ? mountDir(m.id, root) : undefined
m.browsable = !!dir && !mountBrowseRefusal(m.id, dir, env)
}
return JSON.stringify(parsed)
} catch {
return body
}
}

export async function status(): Promise<string> {
if (cache && Date.now() - cache.at < CACHE_MS) return cache.body
const body = await statusUncached().catch((err) => synthesize("bad_output", String(err)))
const body = annotateBrowsable(await statusUncached().catch((err) => synthesize("bad_output", String(err))))
cache = { at: Date.now(), body }
return body
}
Expand Down
46 changes: 45 additions & 1 deletion packages/opencode/test/server/amicode-vaults.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,15 @@ import { describe, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, writeFileSync, existsSync, lstatSync } from "node:fs"
import { tmpdir, homedir } from "node:os"
import path from "node:path"
import { candidates, synthesize, normalizeRef, sanitizeVaultName, attachVault, scanMounts } from "@/server/amicode/vaults"
import {
annotateBrowsable,
candidates,
synthesize,
normalizeRef,
sanitizeVaultName,
attachVault,
scanMounts,
} from "@/server/amicode/vaults"

describe("synthesize", () => {
test("emits the plural failure shape the UI parser expects", () => {
Expand DownExpand Up@@ -118,3 +126,39 @@ describe("scanMounts (CLI-less fallback)", () => {
expect(out.mounts[1]).toMatchObject({ id: "armonissima", kind: "team", writable: false })
})
})

describe("annotateBrowsable", () => {
const root = mkdtempSync(path.join(tmpdir(), "vaults-annotate-"))
const mk = (name: string, marker: string) => {
mkdirSync(path.join(root, name), { recursive: true })
writeFileSync(path.join(root, name, ".amico-vault.toml"), marker)
}
mk("personal-v", 'kind = "personal"\nname = "personal-v"\n')
mk("team-dark", 'kind = "team"\nname = "team-dark"\n')
mk("team-open", 'kind = "team"\nname = "team-open"\nbrowse = true\n')
mk("personal-off", 'kind = "personal"\nname = "personal-off"\nbrowse = false\n')
const env = { AMICO_VAULT_BROWSER: "1" }

test("stamps browsable per the fail-closed law (kind default + browse override)", () => {
const out = JSON.parse(annotateBrowsable(scanMounts(root), root, env)) as {
mounts: { id: string; browsable: boolean }[]
}
const by = Object.fromEntries(out.mounts.map((m) => [m.id, m.browsable]))
expect(by["personal-v"]).toBe(true)
expect(by["team-dark"]).toBe(false)
expect(by["team-open"]).toBe(true)
expect(by["personal-off"]).toBe(false)
})
test("a mount the browser can't resolve is not browsable; junk passes through", () => {
const body = JSON.stringify({ ok: true, mounts: [{ id: "ghost", kind: "personal" }], error: null })
const out = JSON.parse(annotateBrowsable(body, root, env)) as { mounts: { browsable: boolean }[] }
expect(out.mounts[0].browsable).toBe(false)
expect(annotateBrowsable("not json", root, env)).toBe("not json")
})
test("deployment gate off (AMICO_VAULT_BROWSER=0) darkens every mount", () => {
const out = JSON.parse(annotateBrowsable(scanMounts(root), root, { AMICO_VAULT_BROWSER: "0" })) as {
mounts: { browsable: boolean }[]
}
for (const m of out.mounts) expect(m.browsable).toBe(false)
})
})
16 changes: 16 additions & 0 deletions packages/ui/src/amicode/context-tree-data.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,22 @@ describe("buildContextTree", () => {
])
expect(tree.children![0].children![0].vault).toBe(true)
})
test("vaultLocked marks non-browsable vault leaves locked; others untouched", () => {
const tree = buildContextTree(
[
turn("m1", [
{ label: "STRATEGY.md", type: "note", path: "/u/.amico/vaults/armonissima/STRATEGY.md" },
{ label: "notes.md", type: "note", path: "/u/.amico/vaults/armonia-kate/notes.md" },
{ label: "solve.jl", type: "package", path: "/p/solve.jl" },
]),
],
{ vaultLocked: (mount) => mount === "armonissima" },
)
const [team, personal, project] = tree.children![0].children!
expect(team.locked).toBe(true)
expect(personal.locked).toBe(false)
expect(project.locked).toBeUndefined() // not a vault file — predicate never consulted
})
test("marathon sessions fold old turns into one earlier branch", () => {
const turns = Array.from({ length: 30 }, (_, i) =>
turn(`m${i}`, [{ label: `f${i}.md`, type: "note", path: `/p/f${i}.md` }]),
Expand Down
16 changes: 12 additions & 4 deletions packages/ui/src/amicode/context-tree-data.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,7 +64,14 @@ export function vaultRefFromPath(path: string): { mount: string; rel: string } |
const dedupKey = (ref: ContextRef, kind: ContextTreeKind) =>
ref.path ? `p:${ref.path}` : `${kind}:${ref.label.toLowerCase()}`

export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: string } = {}): ContextTreeNodeInput {
export type ContextTreeOpts = {
rootLabel?: string
/** true when the mount refuses browsing (proprietary data/software) — its
* leaves render locked: dimmed, padlocked, not openable */
vaultLocked?: (mount: string) => boolean
}

export function buildContextTree(turns: ContextTurn[], opts: ContextTreeOpts = {}): ContextTreeNodeInput {
const root: ContextTreeNodeInput = {
id: "root",
label: opts.rootLabel ?? "amico",
Expand DownExpand Up@@ -95,7 +102,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
const key = dedupKey(ref, kind)
if (seen.has(key)) continue
seen.set(key, `ctx-${seen.size}`)
earlier.children!.push(leafOf(ref, kind, seen.get(key)!))
earlier.children!.push(leafOf(ref, kind, seen.get(key)!, opts))
}
}
const seen = seenOf(root)
Expand DownExpand Up@@ -123,7 +130,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
}
const id = `ctx-${seen.size}`
seen.set(key, id)
const leaf = leafOf(ref, kind, id)
const leaf = leafOf(ref, kind, id, opts)
node.children!.push(leaf)
lastLeaf = leaf
}
Expand All@@ -138,14 +145,15 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
return root
}

function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string): ContextTreeNodeInput {
function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string, opts: ContextTreeOpts): ContextTreeNodeInput {
const vault = ref.path ? vaultRefFromPath(ref.path) : undefined
return {
id,
label: ref.label.slice(0, 32),
kind,
path: ref.path,
vault: !!vault,
locked: vault && opts.vaultLocked ? opts.vaultLocked(vault.mount) : undefined,
}
}

Expand Down
43 changes: 35 additions & 8 deletions packages/ui/src/amicode/context-tree-engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,6 +44,9 @@ export type ContextTreeNodeInput = {
path?: string
/** the file lives in a vault mount (open via the Vault panel, not a tab) */
vault?: boolean
/** the vault refuses browsing (proprietary data/software) — the node renders
* dimmed with a padlock and is NOT openable, path or not */
locked?: boolean
/** where the agent currently works — wears the thought-color cursor */
active?: boolean
children?: ContextTreeNodeInput[]
Expand All@@ -57,6 +60,7 @@ export type ContextTreeSelection = {
kind: ContextTreeKind
path?: string
vault?: boolean
locked?: boolean
}

export interface ContextTreeEngineOptions {
Expand DownExpand Up@@ -181,6 +185,7 @@ interface TNode {
kind: ContextTreeKind
path?: string
vault?: boolean
locked?: boolean
active: boolean
depth: number
// world targets (tidy layout) and animated positions
Expand DownExpand Up@@ -333,6 +338,7 @@ export function createContextTreeEngine(
n.kind = input.kind
n.path = input.path
n.vault = input.vault
n.locked = input.locked
n.active = !!input.active
n.depth = depth
n.half = HALF[input.kind] ?? 4
Expand DownExpand Up@@ -456,7 +462,11 @@ export function createContextTreeEngine(
kind: n.kind,
path: n.path,
vault: n.vault,
locked: n.locked,
})
// openable = the click actually goes somewhere; everything else (turns,
// skills, agents, actions, locked vault files) must not wear the pointer
const openable = (n: TNode) => !!n.path && !n.locked
let dragging = false
let dragMoved = false
let lastPX = 0,
Expand DownExpand Up@@ -499,7 +509,8 @@ export function createContextTreeEngine(
const n = id ? (byId.get(id) ?? null) : null
if (n !== hovered) {
hovered = n
if (canvas.style) canvas.style.cursor = n && (n.path || n.kind === "turn") ? "pointer" : "grab"
// pointer only where a click opens something; a locked node says so
if (canvas.style) canvas.style.cursor = n && openable(n) ? "pointer" : n?.locked ? "not-allowed" : "grab"
opts.onHover?.(n ? selection(n) : null)
}
}
Expand All@@ -510,7 +521,9 @@ export function createContextTreeEngine(
const p = local(e)
const id = pick(p.x, p.y)
const n = id ? byId.get(id) : undefined
if (n) {
// only openable nodes acknowledge the click — a ring on a dead-end node
// would promise an action that never comes
if (n && openable(n)) {
n.ringT = beatNow
opts.onSelect?.(selection(n))
}
Expand DownExpand Up@@ -647,9 +660,11 @@ export function createContextTreeEngine(
ctx.lineWidth = 1
ctx.stroke()
} else {
ctx.fillStyle = rgba(color, hoveredNow ? 0.95 : 0.75)
// locked (non-browsable vault) leaves read clearly non-interactive:
// reduced emphasis, plus the padlock by the label (shape, not color)
ctx.fillStyle = rgba(color, n.locked ? 0.3 : hoveredNow ? 0.95 : 0.75)
ctx.fill()
ctx.strokeStyle = rgba(color, 0.9)
ctx.strokeStyle = rgba(color, n.locked ? 0.45 : 0.9)
ctx.lineWidth = 1
ctx.stroke()
}
Expand DownExpand Up@@ -690,13 +705,25 @@ export function createContextTreeEngine(
isRoot || n.kind === "turn" ? 0.85 : hoveredNow || glancedNow || n.active ? 1 : nearHover ? 0.85 : 0.6
ctx.font = `${n.kind === "turn" || isRoot ? "600 " : ""}10px JuliaMono, ui-monospace, SFMono-Regular, Menlo, monospace`
const text = n.label.length > 30 ? n.label.slice(0, 29) + "…" : n.label
const lockW = n.locked ? 10 : 0
const tw = ctx.measureText(text).width
const lx = x - tw / 2,
const lx = x - (tw + lockW) / 2,
ly = y + half + 10
ctx.fillStyle = css.labelHalo
ctx.fillRect(lx - 2, ly - 7, tw + 4, 14)
ctx.fillStyle = rgba(css.fg, Math.min(la, 1) * n.alpha)
ctx.fillText(text, lx, ly)
ctx.fillRect(lx - 2, ly - 7, tw + lockW + 4, 14)
const inkA = Math.min(la, 1) * n.alpha
if (n.locked) {
// padlock: shackle arc over a body — the non-color "cannot open" signal
ctx.strokeStyle = rgba(css.fg, inkA)
ctx.lineWidth = 1
ctx.beginPath()
ctx.arc(lx + 3, ly - 1.5, 2, Math.PI, 0)
ctx.stroke()
ctx.fillStyle = rgba(css.fg, inkA)
ctx.fillRect(lx, ly - 1.5, 6, 5)
}
ctx.fillStyle = rgba(css.fg, inkA)
ctx.fillText(text, lx + lockW, ly)
ctx.globalAlpha = 1
}

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions packages/app/src/pages/session/context-tree-panel.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,11 +7,13 @@
// the real file (project files in a session tab, vault files in the Vault
// panel). Hovering a tool row in the log still glances at its node here via
// the same amicode:brain-hover event the strip used.
import { createEffect, createMemo, createSignal, onCleanup, onMount, For, Show } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, For, Show } from "solid-js"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useSync } from "@/context/sync"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useServer } from "@/context/server"
import { amicodeGet } from "@/utils/amicode-fetch"
import { amicoBrainRef } from "@opencode-ai/ui/brain-ref"
import {
createContextTreeEngine,
Expand DownExpand Up@@ -66,8 +68,33 @@ function ContextTreeFrame(props: { sessionID: string }) {
const sync = useSync()
const file = useFile()
const language = useLanguage()
const server = useServer()
const { tabs, view } = useSessionLayout()

// per-mount browsability from GET /amicode/vaults (`browsable`, stamped by
// the server's fail-closed law) — proprietary mounts mark their nodes
// locked upfront instead of dead-ending in a Vault-panel refusal on click
const [vaultsRaw] = createResource(
() => server.current,
(conn) => amicodeGet(conn, "/amicode/vaults").catch(() => undefined),
)
const browsableMounts = createMemo<Map<string, boolean | undefined> | undefined>(() => {
const raw = vaultsRaw() as { mounts?: { id?: string; browsable?: boolean }[] } | undefined
if (!raw || !Array.isArray(raw.mounts)) return undefined
return new Map(
raw.mounts.filter((m) => typeof m?.id === "string").map((m) => [m.id as string, m.browsable]),
)
})
const vaultLocked = (mount: string) => {
const map = browsableMounts()
// list unavailable → status quo (no lock claims we can't back);
// a mount the server doesn't list can't be browsed → locked;
// `browsable` absent (older server) → unknown, again no lock claim
if (!map) return false
if (!map.has(mount)) return true
return map.get(mount) === false
}

const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
const getParts = (msgId: string) => sync.data.part[msgId] ?? []
const busy = createMemo(() => (sync.data.session_status[props.sessionID]?.type ?? "idle") !== "idle")
Expand DownExpand Up@@ -134,7 +161,7 @@ function ContextTreeFrame(props: { sessionID: string }) {
setActive: (tab) => tabs().setActive(tab),
})
const onSelect = (node: ContextTreeSelection) => {
if (!node.path) return
if (!node.path || node.locked) return
const vaultRef = vaultRefFromPath(node.path)
if (vaultRef) {
vaultPanel.open({ mount: vaultRef.mount, path: vaultRef.rel })
Expand All@@ -161,7 +188,7 @@ function ContextTreeFrame(props: { sessionID: string }) {
window.addEventListener("amicode:brain-hover", onToolHover)
onCleanup(() => window.removeEventListener("amicode:brain-hover", onToolHover))

const tree = createMemo(() => buildContextTree(turns()))
const tree = createMemo(() => buildContextTree(turns(), { vaultLocked }))
createEffect(() => {
const brain = engine()
if (!brain) return
Expand All@@ -173,7 +200,8 @@ function ContextTreeFrame(props: { sessionID: string }) {
const flatNodes = createMemo(() => {
const out: ContextTreeSelection[] = []
const walk = (n: ContextTreeNodeInput) => {
if (n.kind !== "root") out.push({ id: n.id, label: n.label, kind: n.kind, path: n.path, vault: n.vault })
if (n.kind !== "root")
out.push({ id: n.id, label: n.label, kind: n.kind, path: n.path, vault: n.vault, locked: n.locked })
for (const c of n.children ?? []) walk(c)
}
walk(tree())
Expand All@@ -188,7 +216,11 @@ function ContextTreeFrame(props: { sessionID: string }) {
setKbIndex(next)
const node = list[next]
engine()?.focus(node.id)
setAnnounce(`${node.label} — ${node.kind}${node.path ? ", press Enter to open" : ""}`)
setAnnounce(
`${node.label} — ${node.kind}${
node.locked ? ", locked — this vault does not allow browsing" : node.path ? ", press Enter to open" : ""
}`,
)
}
const onCanvasKeyDown = (e: KeyboardEvent) => {
const list = flatNodes()
Expand Down
28 changes: 27 additions & 1 deletion packages/opencode/src/server/amicode/vaults.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFil
import { homedir } from "node:os"
import path from "node:path"
import { run } from "@/util/process"
import { browseAllowed, mountBrowseRefusal, mountDir } from "@/server/amicode/vault-browser"

const TIMEOUT_MS = 8_000
const CACHE_MS = 10_000
Expand DownExpand Up@@ -65,9 +66,34 @@ function resolveCli(): string | undefined {

let cache: { at: number; body: string } | undefined

/** Stamp each mount with `browsable`, computed by the vault-browser's
* fail-closed law (deployment gate + per-mount kind/marker rules), so the
* app can mark proprietary context locked UPFRONT — e.g. grey out context
* tree nodes — instead of discovering the refusal on click. Additive to the
* relayed wire shape; an unparseable body passes through untouched. */
export function annotateBrowsable(
body: string,
root: string = vaultsRoot(),
env: Record<string, string | undefined> = process.env,
): string {
try {
const parsed = JSON.parse(body) as { mounts?: { id?: unknown; browsable?: boolean }[] }
if (!Array.isArray(parsed.mounts)) return body
const allowed = browseAllowed(env)
for (const m of parsed.mounts) {
if (typeof m?.id !== "string") continue
const dir = allowed ? mountDir(m.id, root) : undefined
m.browsable = !!dir && !mountBrowseRefusal(m.id, dir, env)
}
return JSON.stringify(parsed)
} catch {
return body
}
}

export async function status(): Promise<string> {
if (cache && Date.now() - cache.at < CACHE_MS) return cache.body
const body = await statusUncached().catch((err) => synthesize("bad_output", String(err)))
const body = annotateBrowsable(await statusUncached().catch((err) => synthesize("bad_output", String(err))))
cache = { at: Date.now(), body }
return body
}
Expand Down
46 changes: 45 additions & 1 deletion packages/opencode/test/server/amicode-vaults.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,15 @@ import { describe, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, writeFileSync, existsSync, lstatSync } from "node:fs"
import { tmpdir, homedir } from "node:os"
import path from "node:path"
import { candidates, synthesize, normalizeRef, sanitizeVaultName, attachVault, scanMounts } from "@/server/amicode/vaults"
import {
annotateBrowsable,
candidates,
synthesize,
normalizeRef,
sanitizeVaultName,
attachVault,
scanMounts,
} from "@/server/amicode/vaults"

describe("synthesize", () => {
test("emits the plural failure shape the UI parser expects", () => {
Expand DownExpand Up@@ -118,3 +126,39 @@ describe("scanMounts (CLI-less fallback)", () => {
expect(out.mounts[1]).toMatchObject({ id: "armonissima", kind: "team", writable: false })
})
})

describe("annotateBrowsable", () => {
const root = mkdtempSync(path.join(tmpdir(), "vaults-annotate-"))
const mk = (name: string, marker: string) => {
mkdirSync(path.join(root, name), { recursive: true })
writeFileSync(path.join(root, name, ".amico-vault.toml"), marker)
}
mk("personal-v", 'kind = "personal"\nname = "personal-v"\n')
mk("team-dark", 'kind = "team"\nname = "team-dark"\n')
mk("team-open", 'kind = "team"\nname = "team-open"\nbrowse = true\n')
mk("personal-off", 'kind = "personal"\nname = "personal-off"\nbrowse = false\n')
const env = { AMICO_VAULT_BROWSER: "1" }

test("stamps browsable per the fail-closed law (kind default + browse override)", () => {
const out = JSON.parse(annotateBrowsable(scanMounts(root), root, env)) as {
mounts: { id: string; browsable: boolean }[]
}
const by = Object.fromEntries(out.mounts.map((m) => [m.id, m.browsable]))
expect(by["personal-v"]).toBe(true)
expect(by["team-dark"]).toBe(false)
expect(by["team-open"]).toBe(true)
expect(by["personal-off"]).toBe(false)
})
test("a mount the browser can't resolve is not browsable; junk passes through", () => {
const body = JSON.stringify({ ok: true, mounts: [{ id: "ghost", kind: "personal" }], error: null })
const out = JSON.parse(annotateBrowsable(body, root, env)) as { mounts: { browsable: boolean }[] }
expect(out.mounts[0].browsable).toBe(false)
expect(annotateBrowsable("not json", root, env)).toBe("not json")
})
test("deployment gate off (AMICO_VAULT_BROWSER=0) darkens every mount", () => {
const out = JSON.parse(annotateBrowsable(scanMounts(root), root, { AMICO_VAULT_BROWSER: "0" })) as {
mounts: { browsable: boolean }[]
}
for (const m of out.mounts) expect(m.browsable).toBe(false)
})
})
16 changes: 16 additions & 0 deletions packages/ui/src/amicode/context-tree-data.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,22 @@ describe("buildContextTree", () => {
])
expect(tree.children![0].children![0].vault).toBe(true)
})
test("vaultLocked marks non-browsable vault leaves locked; others untouched", () => {
const tree = buildContextTree(
[
turn("m1", [
{ label: "STRATEGY.md", type: "note", path: "/u/.amico/vaults/armonissima/STRATEGY.md" },
{ label: "notes.md", type: "note", path: "/u/.amico/vaults/armonia-kate/notes.md" },
{ label: "solve.jl", type: "package", path: "/p/solve.jl" },
]),
],
{ vaultLocked: (mount) => mount === "armonissima" },
)
const [team, personal, project] = tree.children![0].children!
expect(team.locked).toBe(true)
expect(personal.locked).toBe(false)
expect(project.locked).toBeUndefined() // not a vault file — predicate never consulted
})
test("marathon sessions fold old turns into one earlier branch", () => {
const turns = Array.from({ length: 30 }, (_, i) =>
turn(`m${i}`, [{ label: `f${i}.md`, type: "note", path: `/p/f${i}.md` }]),
Expand Down
16 changes: 12 additions & 4 deletions packages/ui/src/amicode/context-tree-data.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,7 +64,14 @@ export function vaultRefFromPath(path: string): { mount: string; rel: string } |
const dedupKey = (ref: ContextRef, kind: ContextTreeKind) =>
ref.path ? `p:${ref.path}` : `${kind}:${ref.label.toLowerCase()}`

export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: string } = {}): ContextTreeNodeInput {
export type ContextTreeOpts = {
rootLabel?: string
/** true when the mount refuses browsing (proprietary data/software) — its
* leaves render locked: dimmed, padlocked, not openable */
vaultLocked?: (mount: string) => boolean
}

export function buildContextTree(turns: ContextTurn[], opts: ContextTreeOpts = {}): ContextTreeNodeInput {
const root: ContextTreeNodeInput = {
id: "root",
label: opts.rootLabel ?? "amico",
Expand DownExpand Up@@ -95,7 +102,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
const key = dedupKey(ref, kind)
if (seen.has(key)) continue
seen.set(key, `ctx-${seen.size}`)
earlier.children!.push(leafOf(ref, kind, seen.get(key)!))
earlier.children!.push(leafOf(ref, kind, seen.get(key)!, opts))
}
}
const seen = seenOf(root)
Expand DownExpand Up@@ -123,7 +130,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
}
const id = `ctx-${seen.size}`
seen.set(key, id)
const leaf = leafOf(ref, kind, id)
const leaf = leafOf(ref, kind, id, opts)
node.children!.push(leaf)
lastLeaf = leaf
}
Expand All@@ -138,14 +145,15 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
return root
}

function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string): ContextTreeNodeInput {
function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string, opts: ContextTreeOpts): ContextTreeNodeInput {
const vault = ref.path ? vaultRefFromPath(ref.path) : undefined
return {
id,
label: ref.label.slice(0, 32),
kind,
path: ref.path,
vault: !!vault,
locked: vault && opts.vaultLocked ? opts.vaultLocked(vault.mount) : undefined,
}
}

Expand Down
43 changes: 35 additions & 8 deletions packages/ui/src/amicode/context-tree-engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,6 +44,9 @@ export type ContextTreeNodeInput = {
path?: string
/** the file lives in a vault mount (open via the Vault panel, not a tab) */
vault?: boolean
/** the vault refuses browsing (proprietary data/software) — the node renders
* dimmed with a padlock and is NOT openable, path or not */
locked?: boolean
/** where the agent currently works — wears the thought-color cursor */
active?: boolean
children?: ContextTreeNodeInput[]
Expand All@@ -57,6 +60,7 @@ export type ContextTreeSelection = {
kind: ContextTreeKind
path?: string
vault?: boolean
locked?: boolean
}

export interface ContextTreeEngineOptions {
Expand DownExpand Up@@ -181,6 +185,7 @@ interface TNode {
kind: ContextTreeKind
path?: string
vault?: boolean
locked?: boolean
active: boolean
depth: number
// world targets (tidy layout) and animated positions
Expand DownExpand Up@@ -333,6 +338,7 @@ export function createContextTreeEngine(
n.kind = input.kind
n.path = input.path
n.vault = input.vault
n.locked = input.locked
n.active = !!input.active
n.depth = depth
n.half = HALF[input.kind] ?? 4
Expand DownExpand Up@@ -456,7 +462,11 @@ export function createContextTreeEngine(
kind: n.kind,
path: n.path,
vault: n.vault,
locked: n.locked,
})
// openable = the click actually goes somewhere; everything else (turns,
// skills, agents, actions, locked vault files) must not wear the pointer
const openable = (n: TNode) => !!n.path && !n.locked
let dragging = false
let dragMoved = false
let lastPX = 0,
Expand DownExpand Up@@ -499,7 +509,8 @@ export function createContextTreeEngine(
const n = id ? (byId.get(id) ?? null) : null
if (n !== hovered) {
hovered = n
if (canvas.style) canvas.style.cursor = n && (n.path || n.kind === "turn") ? "pointer" : "grab"
// pointer only where a click opens something; a locked node says so
if (canvas.style) canvas.style.cursor = n && openable(n) ? "pointer" : n?.locked ? "not-allowed" : "grab"
opts.onHover?.(n ? selection(n) : null)
}
}
Expand All@@ -510,7 +521,9 @@ export function createContextTreeEngine(
const p = local(e)
const id = pick(p.x, p.y)
const n = id ? byId.get(id) : undefined
if (n) {
// only openable nodes acknowledge the click — a ring on a dead-end node
// would promise an action that never comes
if (n && openable(n)) {
n.ringT = beatNow
opts.onSelect?.(selection(n))
}
Expand DownExpand Up@@ -647,9 +660,11 @@ export function createContextTreeEngine(
ctx.lineWidth = 1
ctx.stroke()
} else {
ctx.fillStyle = rgba(color, hoveredNow ? 0.95 : 0.75)
// locked (non-browsable vault) leaves read clearly non-interactive:
// reduced emphasis, plus the padlock by the label (shape, not color)
ctx.fillStyle = rgba(color, n.locked ? 0.3 : hoveredNow ? 0.95 : 0.75)
ctx.fill()
ctx.strokeStyle = rgba(color, 0.9)
ctx.strokeStyle = rgba(color, n.locked ? 0.45 : 0.9)
ctx.lineWidth = 1
ctx.stroke()
}
Expand DownExpand Up@@ -690,13 +705,25 @@ export function createContextTreeEngine(
isRoot || n.kind === "turn" ? 0.85 : hoveredNow || glancedNow || n.active ? 1 : nearHover ? 0.85 : 0.6
ctx.font = `${n.kind === "turn" || isRoot ? "600 " : ""}10px JuliaMono, ui-monospace, SFMono-Regular, Menlo, monospace`
const text = n.label.length > 30 ? n.label.slice(0, 29) + "…" : n.label
const lockW = n.locked ? 10 : 0
const tw = ctx.measureText(text).width
const lx = x - tw / 2,
const lx = x - (tw + lockW) / 2,
ly = y + half + 10
ctx.fillStyle = css.labelHalo
ctx.fillRect(lx - 2, ly - 7, tw + 4, 14)
ctx.fillStyle = rgba(css.fg, Math.min(la, 1) * n.alpha)
ctx.fillText(text, lx, ly)
ctx.fillRect(lx - 2, ly - 7, tw + lockW + 4, 14)
const inkA = Math.min(la, 1) * n.alpha
if (n.locked) {
// padlock: shackle arc over a body — the non-color "cannot open" signal
ctx.strokeStyle = rgba(css.fg, inkA)
ctx.lineWidth = 1
ctx.beginPath()
ctx.arc(lx + 3, ly - 1.5, 2, Math.PI, 0)
ctx.stroke()
ctx.fillStyle = rgba(css.fg, inkA)
ctx.fillRect(lx, ly - 1.5, 6, 5)
}
ctx.fillStyle = rgba(css.fg, inkA)
ctx.fillText(text, lx + lockW, ly)
ctx.globalAlpha = 1
}

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions packages/app/src/pages/session/context-tree-panel.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,11 +7,13 @@
// the real file (project files in a session tab, vault files in the Vault
// panel). Hovering a tool row in the log still glances at its node here via
// the same amicode:brain-hover event the strip used.
import { createEffect, createMemo, createSignal, onCleanup, onMount, For, Show } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, For, Show } from "solid-js"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useSync } from "@/context/sync"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useServer } from "@/context/server"
import { amicodeGet } from "@/utils/amicode-fetch"
import { amicoBrainRef } from "@opencode-ai/ui/brain-ref"
import {
createContextTreeEngine,
Expand DownExpand Up@@ -66,8 +68,33 @@ function ContextTreeFrame(props: { sessionID: string }) {
const sync = useSync()
const file = useFile()
const language = useLanguage()
const server = useServer()
const { tabs, view } = useSessionLayout()

// per-mount browsability from GET /amicode/vaults (`browsable`, stamped by
// the server's fail-closed law) — proprietary mounts mark their nodes
// locked upfront instead of dead-ending in a Vault-panel refusal on click
const [vaultsRaw] = createResource(
() => server.current,
(conn) => amicodeGet(conn, "/amicode/vaults").catch(() => undefined),
)
const browsableMounts = createMemo<Map<string, boolean | undefined> | undefined>(() => {
const raw = vaultsRaw() as { mounts?: { id?: string; browsable?: boolean }[] } | undefined
if (!raw || !Array.isArray(raw.mounts)) return undefined
return new Map(
raw.mounts.filter((m) => typeof m?.id === "string").map((m) => [m.id as string, m.browsable]),
)
})
const vaultLocked = (mount: string) => {
const map = browsableMounts()
// list unavailable → status quo (no lock claims we can't back);
// a mount the server doesn't list can't be browsed → locked;
// `browsable` absent (older server) → unknown, again no lock claim
if (!map) return false
if (!map.has(mount)) return true
return map.get(mount) === false
}

const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
const getParts = (msgId: string) => sync.data.part[msgId] ?? []
const busy = createMemo(() => (sync.data.session_status[props.sessionID]?.type ?? "idle") !== "idle")
Expand DownExpand Up@@ -134,7 +161,7 @@ function ContextTreeFrame(props: { sessionID: string }) {
setActive: (tab) => tabs().setActive(tab),
})
const onSelect = (node: ContextTreeSelection) => {
if (!node.path) return
if (!node.path || node.locked) return
const vaultRef = vaultRefFromPath(node.path)
if (vaultRef) {
vaultPanel.open({ mount: vaultRef.mount, path: vaultRef.rel })
Expand All@@ -161,7 +188,7 @@ function ContextTreeFrame(props: { sessionID: string }) {
window.addEventListener("amicode:brain-hover", onToolHover)
onCleanup(() => window.removeEventListener("amicode:brain-hover", onToolHover))

const tree = createMemo(() => buildContextTree(turns()))
const tree = createMemo(() => buildContextTree(turns(), { vaultLocked }))
createEffect(() => {
const brain = engine()
if (!brain) return
Expand All@@ -173,7 +200,8 @@ function ContextTreeFrame(props: { sessionID: string }) {
const flatNodes = createMemo(() => {
const out: ContextTreeSelection[] = []
const walk = (n: ContextTreeNodeInput) => {
if (n.kind !== "root") out.push({ id: n.id, label: n.label, kind: n.kind, path: n.path, vault: n.vault })
if (n.kind !== "root")
out.push({ id: n.id, label: n.label, kind: n.kind, path: n.path, vault: n.vault, locked: n.locked })
for (const c of n.children ?? []) walk(c)
}
walk(tree())
Expand All@@ -188,7 +216,11 @@ function ContextTreeFrame(props: { sessionID: string }) {
setKbIndex(next)
const node = list[next]
engine()?.focus(node.id)
setAnnounce(`${node.label} — ${node.kind}${node.path ? ", press Enter to open" : ""}`)
setAnnounce(
`${node.label} — ${node.kind}${
node.locked ? ", locked — this vault does not allow browsing" : node.path ? ", press Enter to open" : ""
}`,
)
}
const onCanvasKeyDown = (e: KeyboardEvent) => {
const list = flatNodes()
Expand Down
28 changes: 27 additions & 1 deletion packages/opencode/src/server/amicode/vaults.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFil
import { homedir } from "node:os"
import path from "node:path"
import { run } from "@/util/process"
import { browseAllowed, mountBrowseRefusal, mountDir } from "@/server/amicode/vault-browser"

const TIMEOUT_MS = 8_000
const CACHE_MS = 10_000
Expand DownExpand Up@@ -65,9 +66,34 @@ function resolveCli(): string | undefined {

let cache: { at: number; body: string } | undefined

/** Stamp each mount with `browsable`, computed by the vault-browser's
* fail-closed law (deployment gate + per-mount kind/marker rules), so the
* app can mark proprietary context locked UPFRONT — e.g. grey out context
* tree nodes — instead of discovering the refusal on click. Additive to the
* relayed wire shape; an unparseable body passes through untouched. */
export function annotateBrowsable(
body: string,
root: string = vaultsRoot(),
env: Record<string, string | undefined> = process.env,
): string {
try {
const parsed = JSON.parse(body) as { mounts?: { id?: unknown; browsable?: boolean }[] }
if (!Array.isArray(parsed.mounts)) return body
const allowed = browseAllowed(env)
for (const m of parsed.mounts) {
if (typeof m?.id !== "string") continue
const dir = allowed ? mountDir(m.id, root) : undefined
m.browsable = !!dir && !mountBrowseRefusal(m.id, dir, env)
}
return JSON.stringify(parsed)
} catch {
return body
}
}

export async function status(): Promise<string> {
if (cache && Date.now() - cache.at < CACHE_MS) return cache.body
const body = await statusUncached().catch((err) => synthesize("bad_output", String(err)))
const body = annotateBrowsable(await statusUncached().catch((err) => synthesize("bad_output", String(err))))
cache = { at: Date.now(), body }
return body
}
Expand Down
46 changes: 45 additions & 1 deletion packages/opencode/test/server/amicode-vaults.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,15 @@ import { describe, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, writeFileSync, existsSync, lstatSync } from "node:fs"
import { tmpdir, homedir } from "node:os"
import path from "node:path"
import { candidates, synthesize, normalizeRef, sanitizeVaultName, attachVault, scanMounts } from "@/server/amicode/vaults"
import {
annotateBrowsable,
candidates,
synthesize,
normalizeRef,
sanitizeVaultName,
attachVault,
scanMounts,
} from "@/server/amicode/vaults"

describe("synthesize", () => {
test("emits the plural failure shape the UI parser expects", () => {
Expand DownExpand Up@@ -118,3 +126,39 @@ describe("scanMounts (CLI-less fallback)", () => {
expect(out.mounts[1]).toMatchObject({ id: "armonissima", kind: "team", writable: false })
})
})

describe("annotateBrowsable", () => {
const root = mkdtempSync(path.join(tmpdir(), "vaults-annotate-"))
const mk = (name: string, marker: string) => {
mkdirSync(path.join(root, name), { recursive: true })
writeFileSync(path.join(root, name, ".amico-vault.toml"), marker)
}
mk("personal-v", 'kind = "personal"\nname = "personal-v"\n')
mk("team-dark", 'kind = "team"\nname = "team-dark"\n')
mk("team-open", 'kind = "team"\nname = "team-open"\nbrowse = true\n')
mk("personal-off", 'kind = "personal"\nname = "personal-off"\nbrowse = false\n')
const env = { AMICO_VAULT_BROWSER: "1" }

test("stamps browsable per the fail-closed law (kind default + browse override)", () => {
const out = JSON.parse(annotateBrowsable(scanMounts(root), root, env)) as {
mounts: { id: string; browsable: boolean }[]
}
const by = Object.fromEntries(out.mounts.map((m) => [m.id, m.browsable]))
expect(by["personal-v"]).toBe(true)
expect(by["team-dark"]).toBe(false)
expect(by["team-open"]).toBe(true)
expect(by["personal-off"]).toBe(false)
})
test("a mount the browser can't resolve is not browsable; junk passes through", () => {
const body = JSON.stringify({ ok: true, mounts: [{ id: "ghost", kind: "personal" }], error: null })
const out = JSON.parse(annotateBrowsable(body, root, env)) as { mounts: { browsable: boolean }[] }
expect(out.mounts[0].browsable).toBe(false)
expect(annotateBrowsable("not json", root, env)).toBe("not json")
})
test("deployment gate off (AMICO_VAULT_BROWSER=0) darkens every mount", () => {
const out = JSON.parse(annotateBrowsable(scanMounts(root), root, { AMICO_VAULT_BROWSER: "0" })) as {
mounts: { browsable: boolean }[]
}
for (const m of out.mounts) expect(m.browsable).toBe(false)
})
})
16 changes: 16 additions & 0 deletions packages/ui/src/amicode/context-tree-data.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,22 @@ describe("buildContextTree", () => {
])
expect(tree.children![0].children![0].vault).toBe(true)
})
test("vaultLocked marks non-browsable vault leaves locked; others untouched", () => {
const tree = buildContextTree(
[
turn("m1", [
{ label: "STRATEGY.md", type: "note", path: "/u/.amico/vaults/armonissima/STRATEGY.md" },
{ label: "notes.md", type: "note", path: "/u/.amico/vaults/armonia-kate/notes.md" },
{ label: "solve.jl", type: "package", path: "/p/solve.jl" },
]),
],
{ vaultLocked: (mount) => mount === "armonissima" },
)
const [team, personal, project] = tree.children![0].children!
expect(team.locked).toBe(true)
expect(personal.locked).toBe(false)
expect(project.locked).toBeUndefined() // not a vault file — predicate never consulted
})
test("marathon sessions fold old turns into one earlier branch", () => {
const turns = Array.from({ length: 30 }, (_, i) =>
turn(`m${i}`, [{ label: `f${i}.md`, type: "note", path: `/p/f${i}.md` }]),
Expand Down
16 changes: 12 additions & 4 deletions packages/ui/src/amicode/context-tree-data.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,7 +64,14 @@ export function vaultRefFromPath(path: string): { mount: string; rel: string } |
const dedupKey = (ref: ContextRef, kind: ContextTreeKind) =>
ref.path ? `p:${ref.path}` : `${kind}:${ref.label.toLowerCase()}`

export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: string } = {}): ContextTreeNodeInput {
export type ContextTreeOpts = {
rootLabel?: string
/** true when the mount refuses browsing (proprietary data/software) — its
* leaves render locked: dimmed, padlocked, not openable */
vaultLocked?: (mount: string) => boolean
}

export function buildContextTree(turns: ContextTurn[], opts: ContextTreeOpts = {}): ContextTreeNodeInput {
const root: ContextTreeNodeInput = {
id: "root",
label: opts.rootLabel ?? "amico",
Expand DownExpand Up@@ -95,7 +102,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
const key = dedupKey(ref, kind)
if (seen.has(key)) continue
seen.set(key, `ctx-${seen.size}`)
earlier.children!.push(leafOf(ref, kind, seen.get(key)!))
earlier.children!.push(leafOf(ref, kind, seen.get(key)!, opts))
}
}
const seen = seenOf(root)
Expand DownExpand Up@@ -123,7 +130,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
}
const id = `ctx-${seen.size}`
seen.set(key, id)
const leaf = leafOf(ref, kind, id)
const leaf = leafOf(ref, kind, id, opts)
node.children!.push(leaf)
lastLeaf = leaf
}
Expand All@@ -138,14 +145,15 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
return root
}

function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string): ContextTreeNodeInput {
function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string, opts: ContextTreeOpts): ContextTreeNodeInput {
const vault = ref.path ? vaultRefFromPath(ref.path) : undefined
return {
id,
label: ref.label.slice(0, 32),
kind,
path: ref.path,
vault: !!vault,
locked: vault && opts.vaultLocked ? opts.vaultLocked(vault.mount) : undefined,
}
}

Expand Down
43 changes: 35 additions & 8 deletions packages/ui/src/amicode/context-tree-engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,6 +44,9 @@ export type ContextTreeNodeInput = {
path?: string
/** the file lives in a vault mount (open via the Vault panel, not a tab) */
vault?: boolean
/** the vault refuses browsing (proprietary data/software) — the node renders
* dimmed with a padlock and is NOT openable, path or not */
locked?: boolean
/** where the agent currently works — wears the thought-color cursor */
active?: boolean
children?: ContextTreeNodeInput[]
Expand All@@ -57,6 +60,7 @@ export type ContextTreeSelection = {
kind: ContextTreeKind
path?: string
vault?: boolean
locked?: boolean
}

export interface ContextTreeEngineOptions {
Expand DownExpand Up@@ -181,6 +185,7 @@ interface TNode {
kind: ContextTreeKind
path?: string
vault?: boolean
locked?: boolean
active: boolean
depth: number
// world targets (tidy layout) and animated positions
Expand DownExpand Up@@ -333,6 +338,7 @@ export function createContextTreeEngine(
n.kind = input.kind
n.path = input.path
n.vault = input.vault
n.locked = input.locked
n.active = !!input.active
n.depth = depth
n.half = HALF[input.kind] ?? 4
Expand DownExpand Up@@ -456,7 +462,11 @@ export function createContextTreeEngine(
kind: n.kind,
path: n.path,
vault: n.vault,
locked: n.locked,
})
// openable = the click actually goes somewhere; everything else (turns,
// skills, agents, actions, locked vault files) must not wear the pointer
const openable = (n: TNode) => !!n.path && !n.locked
let dragging = false
let dragMoved = false
let lastPX = 0,
Expand DownExpand Up@@ -499,7 +509,8 @@ export function createContextTreeEngine(
const n = id ? (byId.get(id) ?? null) : null
if (n !== hovered) {
hovered = n
if (canvas.style) canvas.style.cursor = n && (n.path || n.kind === "turn") ? "pointer" : "grab"
// pointer only where a click opens something; a locked node says so
if (canvas.style) canvas.style.cursor = n && openable(n) ? "pointer" : n?.locked ? "not-allowed" : "grab"
opts.onHover?.(n ? selection(n) : null)
}
}
Expand All@@ -510,7 +521,9 @@ export function createContextTreeEngine(
const p = local(e)
const id = pick(p.x, p.y)
const n = id ? byId.get(id) : undefined
if (n) {
// only openable nodes acknowledge the click — a ring on a dead-end node
// would promise an action that never comes
if (n && openable(n)) {
n.ringT = beatNow
opts.onSelect?.(selection(n))
}
Expand DownExpand Up@@ -647,9 +660,11 @@ export function createContextTreeEngine(
ctx.lineWidth = 1
ctx.stroke()
} else {
ctx.fillStyle = rgba(color, hoveredNow ? 0.95 : 0.75)
// locked (non-browsable vault) leaves read clearly non-interactive:
// reduced emphasis, plus the padlock by the label (shape, not color)
ctx.fillStyle = rgba(color, n.locked ? 0.3 : hoveredNow ? 0.95 : 0.75)
ctx.fill()
ctx.strokeStyle = rgba(color, 0.9)
ctx.strokeStyle = rgba(color, n.locked ? 0.45 : 0.9)
ctx.lineWidth = 1
ctx.stroke()
}
Expand DownExpand Up@@ -690,13 +705,25 @@ export function createContextTreeEngine(
isRoot || n.kind === "turn" ? 0.85 : hoveredNow || glancedNow || n.active ? 1 : nearHover ? 0.85 : 0.6
ctx.font = `${n.kind === "turn" || isRoot ? "600 " : ""}10px JuliaMono, ui-monospace, SFMono-Regular, Menlo, monospace`
const text = n.label.length > 30 ? n.label.slice(0, 29) + "…" : n.label
const lockW = n.locked ? 10 : 0
const tw = ctx.measureText(text).width
const lx = x - tw / 2,
const lx = x - (tw + lockW) / 2,
ly = y + half + 10
ctx.fillStyle = css.labelHalo
ctx.fillRect(lx - 2, ly - 7, tw + 4, 14)
ctx.fillStyle = rgba(css.fg, Math.min(la, 1) * n.alpha)
ctx.fillText(text, lx, ly)
ctx.fillRect(lx - 2, ly - 7, tw + lockW + 4, 14)
const inkA = Math.min(la, 1) * n.alpha
if (n.locked) {
// padlock: shackle arc over a body — the non-color "cannot open" signal
ctx.strokeStyle = rgba(css.fg, inkA)
ctx.lineWidth = 1
ctx.beginPath()
ctx.arc(lx + 3, ly - 1.5, 2, Math.PI, 0)
ctx.stroke()
ctx.fillStyle = rgba(css.fg, inkA)
ctx.fillRect(lx, ly - 1.5, 6, 5)
}
ctx.fillStyle = rgba(css.fg, inkA)
ctx.fillText(text, lx + lockW, ly)
ctx.globalAlpha = 1
}

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions packages/app/src/pages/session/context-tree-panel.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,11 +7,13 @@
// the real file (project files in a session tab, vault files in the Vault
// panel). Hovering a tool row in the log still glances at its node here via
// the same amicode:brain-hover event the strip used.
import { createEffect, createMemo, createSignal, onCleanup, onMount, For, Show } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, For, Show } from "solid-js"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useSync } from "@/context/sync"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useServer } from "@/context/server"
import { amicodeGet } from "@/utils/amicode-fetch"
import { amicoBrainRef } from "@opencode-ai/ui/brain-ref"
import {
createContextTreeEngine,
Expand DownExpand Up@@ -66,8 +68,33 @@ function ContextTreeFrame(props: { sessionID: string }) {
const sync = useSync()
const file = useFile()
const language = useLanguage()
const server = useServer()
const { tabs, view } = useSessionLayout()

// per-mount browsability from GET /amicode/vaults (`browsable`, stamped by
// the server's fail-closed law) — proprietary mounts mark their nodes
// locked upfront instead of dead-ending in a Vault-panel refusal on click
const [vaultsRaw] = createResource(
() => server.current,
(conn) => amicodeGet(conn, "/amicode/vaults").catch(() => undefined),
)
const browsableMounts = createMemo<Map<string, boolean | undefined> | undefined>(() => {
const raw = vaultsRaw() as { mounts?: { id?: string; browsable?: boolean }[] } | undefined
if (!raw || !Array.isArray(raw.mounts)) return undefined
return new Map(
raw.mounts.filter((m) => typeof m?.id === "string").map((m) => [m.id as string, m.browsable]),
)
})
const vaultLocked = (mount: string) => {
const map = browsableMounts()
// list unavailable → status quo (no lock claims we can't back);
// a mount the server doesn't list can't be browsed → locked;
// `browsable` absent (older server) → unknown, again no lock claim
if (!map) return false
if (!map.has(mount)) return true
return map.get(mount) === false
}

const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
const getParts = (msgId: string) => sync.data.part[msgId] ?? []
const busy = createMemo(() => (sync.data.session_status[props.sessionID]?.type ?? "idle") !== "idle")
Expand DownExpand Up@@ -134,7 +161,7 @@ function ContextTreeFrame(props: { sessionID: string }) {
setActive: (tab) => tabs().setActive(tab),
})
const onSelect = (node: ContextTreeSelection) => {
if (!node.path) return
if (!node.path || node.locked) return
const vaultRef = vaultRefFromPath(node.path)
if (vaultRef) {
vaultPanel.open({ mount: vaultRef.mount, path: vaultRef.rel })
Expand All@@ -161,7 +188,7 @@ function ContextTreeFrame(props: { sessionID: string }) {
window.addEventListener("amicode:brain-hover", onToolHover)
onCleanup(() => window.removeEventListener("amicode:brain-hover", onToolHover))

const tree = createMemo(() => buildContextTree(turns()))
const tree = createMemo(() => buildContextTree(turns(), { vaultLocked }))
createEffect(() => {
const brain = engine()
if (!brain) return
Expand All@@ -173,7 +200,8 @@ function ContextTreeFrame(props: { sessionID: string }) {
const flatNodes = createMemo(() => {
const out: ContextTreeSelection[] = []
const walk = (n: ContextTreeNodeInput) => {
if (n.kind !== "root") out.push({ id: n.id, label: n.label, kind: n.kind, path: n.path, vault: n.vault })
if (n.kind !== "root")
out.push({ id: n.id, label: n.label, kind: n.kind, path: n.path, vault: n.vault, locked: n.locked })
for (const c of n.children ?? []) walk(c)
}
walk(tree())
Expand All@@ -188,7 +216,11 @@ function ContextTreeFrame(props: { sessionID: string }) {
setKbIndex(next)
const node = list[next]
engine()?.focus(node.id)
setAnnounce(`${node.label} — ${node.kind}${node.path ? ", press Enter to open" : ""}`)
setAnnounce(
`${node.label} — ${node.kind}${
node.locked ? ", locked — this vault does not allow browsing" : node.path ? ", press Enter to open" : ""
}`,
)
}
const onCanvasKeyDown = (e: KeyboardEvent) => {
const list = flatNodes()
Expand Down
28 changes: 27 additions & 1 deletion packages/opencode/src/server/amicode/vaults.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFil
import { homedir } from "node:os"
import path from "node:path"
import { run } from "@/util/process"
import { browseAllowed, mountBrowseRefusal, mountDir } from "@/server/amicode/vault-browser"

const TIMEOUT_MS = 8_000
const CACHE_MS = 10_000
Expand DownExpand Up@@ -65,9 +66,34 @@ function resolveCli(): string | undefined {

let cache: { at: number; body: string } | undefined

/** Stamp each mount with `browsable`, computed by the vault-browser's
* fail-closed law (deployment gate + per-mount kind/marker rules), so the
* app can mark proprietary context locked UPFRONT — e.g. grey out context
* tree nodes — instead of discovering the refusal on click. Additive to the
* relayed wire shape; an unparseable body passes through untouched. */
export function annotateBrowsable(
body: string,
root: string = vaultsRoot(),
env: Record<string, string | undefined> = process.env,
): string {
try {
const parsed = JSON.parse(body) as { mounts?: { id?: unknown; browsable?: boolean }[] }
if (!Array.isArray(parsed.mounts)) return body
const allowed = browseAllowed(env)
for (const m of parsed.mounts) {
if (typeof m?.id !== "string") continue
const dir = allowed ? mountDir(m.id, root) : undefined
m.browsable = !!dir && !mountBrowseRefusal(m.id, dir, env)
}
return JSON.stringify(parsed)
} catch {
return body
}
}

export async function status(): Promise<string> {
if (cache && Date.now() - cache.at < CACHE_MS) return cache.body
const body = await statusUncached().catch((err) => synthesize("bad_output", String(err)))
const body = annotateBrowsable(await statusUncached().catch((err) => synthesize("bad_output", String(err))))
cache = { at: Date.now(), body }
return body
}
Expand Down
46 changes: 45 additions & 1 deletion packages/opencode/test/server/amicode-vaults.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,15 @@ import { describe, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, writeFileSync, existsSync, lstatSync } from "node:fs"
import { tmpdir, homedir } from "node:os"
import path from "node:path"
import { candidates, synthesize, normalizeRef, sanitizeVaultName, attachVault, scanMounts } from "@/server/amicode/vaults"
import {
annotateBrowsable,
candidates,
synthesize,
normalizeRef,
sanitizeVaultName,
attachVault,
scanMounts,
} from "@/server/amicode/vaults"

describe("synthesize", () => {
test("emits the plural failure shape the UI parser expects", () => {
Expand DownExpand Up@@ -118,3 +126,39 @@ describe("scanMounts (CLI-less fallback)", () => {
expect(out.mounts[1]).toMatchObject({ id: "armonissima", kind: "team", writable: false })
})
})

describe("annotateBrowsable", () => {
const root = mkdtempSync(path.join(tmpdir(), "vaults-annotate-"))
const mk = (name: string, marker: string) => {
mkdirSync(path.join(root, name), { recursive: true })
writeFileSync(path.join(root, name, ".amico-vault.toml"), marker)
}
mk("personal-v", 'kind = "personal"\nname = "personal-v"\n')
mk("team-dark", 'kind = "team"\nname = "team-dark"\n')
mk("team-open", 'kind = "team"\nname = "team-open"\nbrowse = true\n')
mk("personal-off", 'kind = "personal"\nname = "personal-off"\nbrowse = false\n')
const env = { AMICO_VAULT_BROWSER: "1" }

test("stamps browsable per the fail-closed law (kind default + browse override)", () => {
const out = JSON.parse(annotateBrowsable(scanMounts(root), root, env)) as {
mounts: { id: string; browsable: boolean }[]
}
const by = Object.fromEntries(out.mounts.map((m) => [m.id, m.browsable]))
expect(by["personal-v"]).toBe(true)
expect(by["team-dark"]).toBe(false)
expect(by["team-open"]).toBe(true)
expect(by["personal-off"]).toBe(false)
})
test("a mount the browser can't resolve is not browsable; junk passes through", () => {
const body = JSON.stringify({ ok: true, mounts: [{ id: "ghost", kind: "personal" }], error: null })
const out = JSON.parse(annotateBrowsable(body, root, env)) as { mounts: { browsable: boolean }[] }
expect(out.mounts[0].browsable).toBe(false)
expect(annotateBrowsable("not json", root, env)).toBe("not json")
})
test("deployment gate off (AMICO_VAULT_BROWSER=0) darkens every mount", () => {
const out = JSON.parse(annotateBrowsable(scanMounts(root), root, { AMICO_VAULT_BROWSER: "0" })) as {
mounts: { browsable: boolean }[]
}
for (const m of out.mounts) expect(m.browsable).toBe(false)
})
})
16 changes: 16 additions & 0 deletions packages/ui/src/amicode/context-tree-data.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,22 @@ describe("buildContextTree", () => {
])
expect(tree.children![0].children![0].vault).toBe(true)
})
test("vaultLocked marks non-browsable vault leaves locked; others untouched", () => {
const tree = buildContextTree(
[
turn("m1", [
{ label: "STRATEGY.md", type: "note", path: "/u/.amico/vaults/armonissima/STRATEGY.md" },
{ label: "notes.md", type: "note", path: "/u/.amico/vaults/armonia-kate/notes.md" },
{ label: "solve.jl", type: "package", path: "/p/solve.jl" },
]),
],
{ vaultLocked: (mount) => mount === "armonissima" },
)
const [team, personal, project] = tree.children![0].children!
expect(team.locked).toBe(true)
expect(personal.locked).toBe(false)
expect(project.locked).toBeUndefined() // not a vault file — predicate never consulted
})
test("marathon sessions fold old turns into one earlier branch", () => {
const turns = Array.from({ length: 30 }, (_, i) =>
turn(`m${i}`, [{ label: `f${i}.md`, type: "note", path: `/p/f${i}.md` }]),
Expand Down
16 changes: 12 additions & 4 deletions packages/ui/src/amicode/context-tree-data.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,7 +64,14 @@ export function vaultRefFromPath(path: string): { mount: string; rel: string } |
const dedupKey = (ref: ContextRef, kind: ContextTreeKind) =>
ref.path ? `p:${ref.path}` : `${kind}:${ref.label.toLowerCase()}`

export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: string } = {}): ContextTreeNodeInput {
export type ContextTreeOpts = {
rootLabel?: string
/** true when the mount refuses browsing (proprietary data/software) — its
* leaves render locked: dimmed, padlocked, not openable */
vaultLocked?: (mount: string) => boolean
}

export function buildContextTree(turns: ContextTurn[], opts: ContextTreeOpts = {}): ContextTreeNodeInput {
const root: ContextTreeNodeInput = {
id: "root",
label: opts.rootLabel ?? "amico",
Expand DownExpand Up@@ -95,7 +102,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
const key = dedupKey(ref, kind)
if (seen.has(key)) continue
seen.set(key, `ctx-${seen.size}`)
earlier.children!.push(leafOf(ref, kind, seen.get(key)!))
earlier.children!.push(leafOf(ref, kind, seen.get(key)!, opts))
}
}
const seen = seenOf(root)
Expand DownExpand Up@@ -123,7 +130,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
}
const id = `ctx-${seen.size}`
seen.set(key, id)
const leaf = leafOf(ref, kind, id)
const leaf = leafOf(ref, kind, id, opts)
node.children!.push(leaf)
lastLeaf = leaf
}
Expand All@@ -138,14 +145,15 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
return root
}

function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string): ContextTreeNodeInput {
function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string, opts: ContextTreeOpts): ContextTreeNodeInput {
const vault = ref.path ? vaultRefFromPath(ref.path) : undefined
return {
id,
label: ref.label.slice(0, 32),
kind,
path: ref.path,
vault: !!vault,
locked: vault && opts.vaultLocked ? opts.vaultLocked(vault.mount) : undefined,
}
}

Expand Down
43 changes: 35 additions & 8 deletions packages/ui/src/amicode/context-tree-engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,6 +44,9 @@ export type ContextTreeNodeInput = {
path?: string
/** the file lives in a vault mount (open via the Vault panel, not a tab) */
vault?: boolean
/** the vault refuses browsing (proprietary data/software) — the node renders
* dimmed with a padlock and is NOT openable, path or not */
locked?: boolean
/** where the agent currently works — wears the thought-color cursor */
active?: boolean
children?: ContextTreeNodeInput[]
Expand All@@ -57,6 +60,7 @@ export type ContextTreeSelection = {
kind: ContextTreeKind
path?: string
vault?: boolean
locked?: boolean
}

export interface ContextTreeEngineOptions {
Expand DownExpand Up@@ -181,6 +185,7 @@ interface TNode {
kind: ContextTreeKind
path?: string
vault?: boolean
locked?: boolean
active: boolean
depth: number
// world targets (tidy layout) and animated positions
Expand DownExpand Up@@ -333,6 +338,7 @@ export function createContextTreeEngine(
n.kind = input.kind
n.path = input.path
n.vault = input.vault
n.locked = input.locked
n.active = !!input.active
n.depth = depth
n.half = HALF[input.kind] ?? 4
Expand DownExpand Up@@ -456,7 +462,11 @@ export function createContextTreeEngine(
kind: n.kind,
path: n.path,
vault: n.vault,
locked: n.locked,
})
// openable = the click actually goes somewhere; everything else (turns,
// skills, agents, actions, locked vault files) must not wear the pointer
const openable = (n: TNode) => !!n.path && !n.locked
let dragging = false
let dragMoved = false
let lastPX = 0,
Expand DownExpand Up@@ -499,7 +509,8 @@ export function createContextTreeEngine(
const n = id ? (byId.get(id) ?? null) : null
if (n !== hovered) {
hovered = n
if (canvas.style) canvas.style.cursor = n && (n.path || n.kind === "turn") ? "pointer" : "grab"
// pointer only where a click opens something; a locked node says so
if (canvas.style) canvas.style.cursor = n && openable(n) ? "pointer" : n?.locked ? "not-allowed" : "grab"
opts.onHover?.(n ? selection(n) : null)
}
}
Expand All@@ -510,7 +521,9 @@ export function createContextTreeEngine(
const p = local(e)
const id = pick(p.x, p.y)
const n = id ? byId.get(id) : undefined
if (n) {
// only openable nodes acknowledge the click — a ring on a dead-end node
// would promise an action that never comes
if (n && openable(n)) {
n.ringT = beatNow
opts.onSelect?.(selection(n))
}
Expand DownExpand Up@@ -647,9 +660,11 @@ export function createContextTreeEngine(
ctx.lineWidth = 1
ctx.stroke()
} else {
ctx.fillStyle = rgba(color, hoveredNow ? 0.95 : 0.75)
// locked (non-browsable vault) leaves read clearly non-interactive:
// reduced emphasis, plus the padlock by the label (shape, not color)
ctx.fillStyle = rgba(color, n.locked ? 0.3 : hoveredNow ? 0.95 : 0.75)
ctx.fill()
ctx.strokeStyle = rgba(color, 0.9)
ctx.strokeStyle = rgba(color, n.locked ? 0.45 : 0.9)
ctx.lineWidth = 1
ctx.stroke()
}
Expand DownExpand Up@@ -690,13 +705,25 @@ export function createContextTreeEngine(
isRoot || n.kind === "turn" ? 0.85 : hoveredNow || glancedNow || n.active ? 1 : nearHover ? 0.85 : 0.6
ctx.font = `${n.kind === "turn" || isRoot ? "600 " : ""}10px JuliaMono, ui-monospace, SFMono-Regular, Menlo, monospace`
const text = n.label.length > 30 ? n.label.slice(0, 29) + "…" : n.label
const lockW = n.locked ? 10 : 0
const tw = ctx.measureText(text).width
const lx = x - tw / 2,
const lx = x - (tw + lockW) / 2,
ly = y + half + 10
ctx.fillStyle = css.labelHalo
ctx.fillRect(lx - 2, ly - 7, tw + 4, 14)
ctx.fillStyle = rgba(css.fg, Math.min(la, 1) * n.alpha)
ctx.fillText(text, lx, ly)
ctx.fillRect(lx - 2, ly - 7, tw + lockW + 4, 14)
const inkA = Math.min(la, 1) * n.alpha
if (n.locked) {
// padlock: shackle arc over a body — the non-color "cannot open" signal
ctx.strokeStyle = rgba(css.fg, inkA)
ctx.lineWidth = 1
ctx.beginPath()
ctx.arc(lx + 3, ly - 1.5, 2, Math.PI, 0)
ctx.stroke()
ctx.fillStyle = rgba(css.fg, inkA)
ctx.fillRect(lx, ly - 1.5, 6, 5)
}
ctx.fillStyle = rgba(css.fg, inkA)
ctx.fillText(text, lx + lockW, ly)
ctx.globalAlpha = 1
}

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions packages/app/src/pages/session/context-tree-panel.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,11 +7,13 @@
// the real file (project files in a session tab, vault files in the Vault
// panel). Hovering a tool row in the log still glances at its node here via
// the same amicode:brain-hover event the strip used.
import { createEffect, createMemo, createSignal, onCleanup, onMount, For, Show } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, For, Show } from "solid-js"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useSync } from "@/context/sync"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useServer } from "@/context/server"
import { amicodeGet } from "@/utils/amicode-fetch"
import { amicoBrainRef } from "@opencode-ai/ui/brain-ref"
import {
createContextTreeEngine,
Expand DownExpand Up@@ -66,8 +68,33 @@ function ContextTreeFrame(props: { sessionID: string }) {
const sync = useSync()
const file = useFile()
const language = useLanguage()
const server = useServer()
const { tabs, view } = useSessionLayout()

// per-mount browsability from GET /amicode/vaults (`browsable`, stamped by
// the server's fail-closed law) — proprietary mounts mark their nodes
// locked upfront instead of dead-ending in a Vault-panel refusal on click
const [vaultsRaw] = createResource(
() => server.current,
(conn) => amicodeGet(conn, "/amicode/vaults").catch(() => undefined),
)
const browsableMounts = createMemo<Map<string, boolean | undefined> | undefined>(() => {
const raw = vaultsRaw() as { mounts?: { id?: string; browsable?: boolean }[] } | undefined
if (!raw || !Array.isArray(raw.mounts)) return undefined
return new Map(
raw.mounts.filter((m) => typeof m?.id === "string").map((m) => [m.id as string, m.browsable]),
)
})
const vaultLocked = (mount: string) => {
const map = browsableMounts()
// list unavailable → status quo (no lock claims we can't back);
// a mount the server doesn't list can't be browsed → locked;
// `browsable` absent (older server) → unknown, again no lock claim
if (!map) return false
if (!map.has(mount)) return true
return map.get(mount) === false
}

const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
const getParts = (msgId: string) => sync.data.part[msgId] ?? []
const busy = createMemo(() => (sync.data.session_status[props.sessionID]?.type ?? "idle") !== "idle")
Expand DownExpand Up@@ -134,7 +161,7 @@ function ContextTreeFrame(props: { sessionID: string }) {
setActive: (tab) => tabs().setActive(tab),
})
const onSelect = (node: ContextTreeSelection) => {
if (!node.path) return
if (!node.path || node.locked) return
const vaultRef = vaultRefFromPath(node.path)
if (vaultRef) {
vaultPanel.open({ mount: vaultRef.mount, path: vaultRef.rel })
Expand All@@ -161,7 +188,7 @@ function ContextTreeFrame(props: { sessionID: string }) {
window.addEventListener("amicode:brain-hover", onToolHover)
onCleanup(() => window.removeEventListener("amicode:brain-hover", onToolHover))

const tree = createMemo(() => buildContextTree(turns()))
const tree = createMemo(() => buildContextTree(turns(), { vaultLocked }))
createEffect(() => {
const brain = engine()
if (!brain) return
Expand All@@ -173,7 +200,8 @@ function ContextTreeFrame(props: { sessionID: string }) {
const flatNodes = createMemo(() => {
const out: ContextTreeSelection[] = []
const walk = (n: ContextTreeNodeInput) => {
if (n.kind !== "root") out.push({ id: n.id, label: n.label, kind: n.kind, path: n.path, vault: n.vault })
if (n.kind !== "root")
out.push({ id: n.id, label: n.label, kind: n.kind, path: n.path, vault: n.vault, locked: n.locked })
for (const c of n.children ?? []) walk(c)
}
walk(tree())
Expand All@@ -188,7 +216,11 @@ function ContextTreeFrame(props: { sessionID: string }) {
setKbIndex(next)
const node = list[next]
engine()?.focus(node.id)
setAnnounce(`${node.label} — ${node.kind}${node.path ? ", press Enter to open" : ""}`)
setAnnounce(
`${node.label} — ${node.kind}${
node.locked ? ", locked — this vault does not allow browsing" : node.path ? ", press Enter to open" : ""
}`,
)
}
const onCanvasKeyDown = (e: KeyboardEvent) => {
const list = flatNodes()
Expand Down
28 changes: 27 additions & 1 deletion packages/opencode/src/server/amicode/vaults.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFil
import { homedir } from "node:os"
import path from "node:path"
import { run } from "@/util/process"
import { browseAllowed, mountBrowseRefusal, mountDir } from "@/server/amicode/vault-browser"

const TIMEOUT_MS = 8_000
const CACHE_MS = 10_000
Expand DownExpand Up@@ -65,9 +66,34 @@ function resolveCli(): string | undefined {

let cache: { at: number; body: string } | undefined

/** Stamp each mount with `browsable`, computed by the vault-browser's
* fail-closed law (deployment gate + per-mount kind/marker rules), so the
* app can mark proprietary context locked UPFRONT — e.g. grey out context
* tree nodes — instead of discovering the refusal on click. Additive to the
* relayed wire shape; an unparseable body passes through untouched. */
export function annotateBrowsable(
body: string,
root: string = vaultsRoot(),
env: Record<string, string | undefined> = process.env,
): string {
try {
const parsed = JSON.parse(body) as { mounts?: { id?: unknown; browsable?: boolean }[] }
if (!Array.isArray(parsed.mounts)) return body
const allowed = browseAllowed(env)
for (const m of parsed.mounts) {
if (typeof m?.id !== "string") continue
const dir = allowed ? mountDir(m.id, root) : undefined
m.browsable = !!dir && !mountBrowseRefusal(m.id, dir, env)
}
return JSON.stringify(parsed)
} catch {
return body
}
}

export async function status(): Promise<string> {
if (cache && Date.now() - cache.at < CACHE_MS) return cache.body
const body = await statusUncached().catch((err) => synthesize("bad_output", String(err)))
const body = annotateBrowsable(await statusUncached().catch((err) => synthesize("bad_output", String(err))))
cache = { at: Date.now(), body }
return body
}
Expand Down
46 changes: 45 additions & 1 deletion packages/opencode/test/server/amicode-vaults.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,15 @@ import { describe, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, writeFileSync, existsSync, lstatSync } from "node:fs"
import { tmpdir, homedir } from "node:os"
import path from "node:path"
import { candidates, synthesize, normalizeRef, sanitizeVaultName, attachVault, scanMounts } from "@/server/amicode/vaults"
import {
annotateBrowsable,
candidates,
synthesize,
normalizeRef,
sanitizeVaultName,
attachVault,
scanMounts,
} from "@/server/amicode/vaults"

describe("synthesize", () => {
test("emits the plural failure shape the UI parser expects", () => {
Expand DownExpand Up@@ -118,3 +126,39 @@ describe("scanMounts (CLI-less fallback)", () => {
expect(out.mounts[1]).toMatchObject({ id: "armonissima", kind: "team", writable: false })
})
})

describe("annotateBrowsable", () => {
const root = mkdtempSync(path.join(tmpdir(), "vaults-annotate-"))
const mk = (name: string, marker: string) => {
mkdirSync(path.join(root, name), { recursive: true })
writeFileSync(path.join(root, name, ".amico-vault.toml"), marker)
}
mk("personal-v", 'kind = "personal"\nname = "personal-v"\n')
mk("team-dark", 'kind = "team"\nname = "team-dark"\n')
mk("team-open", 'kind = "team"\nname = "team-open"\nbrowse = true\n')
mk("personal-off", 'kind = "personal"\nname = "personal-off"\nbrowse = false\n')
const env = { AMICO_VAULT_BROWSER: "1" }

test("stamps browsable per the fail-closed law (kind default + browse override)", () => {
const out = JSON.parse(annotateBrowsable(scanMounts(root), root, env)) as {
mounts: { id: string; browsable: boolean }[]
}
const by = Object.fromEntries(out.mounts.map((m) => [m.id, m.browsable]))
expect(by["personal-v"]).toBe(true)
expect(by["team-dark"]).toBe(false)
expect(by["team-open"]).toBe(true)
expect(by["personal-off"]).toBe(false)
})
test("a mount the browser can't resolve is not browsable; junk passes through", () => {
const body = JSON.stringify({ ok: true, mounts: [{ id: "ghost", kind: "personal" }], error: null })
const out = JSON.parse(annotateBrowsable(body, root, env)) as { mounts: { browsable: boolean }[] }
expect(out.mounts[0].browsable).toBe(false)
expect(annotateBrowsable("not json", root, env)).toBe("not json")
})
test("deployment gate off (AMICO_VAULT_BROWSER=0) darkens every mount", () => {
const out = JSON.parse(annotateBrowsable(scanMounts(root), root, { AMICO_VAULT_BROWSER: "0" })) as {
mounts: { browsable: boolean }[]
}
for (const m of out.mounts) expect(m.browsable).toBe(false)
})
})
16 changes: 16 additions & 0 deletions packages/ui/src/amicode/context-tree-data.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,22 @@ describe("buildContextTree", () => {
])
expect(tree.children![0].children![0].vault).toBe(true)
})
test("vaultLocked marks non-browsable vault leaves locked; others untouched", () => {
const tree = buildContextTree(
[
turn("m1", [
{ label: "STRATEGY.md", type: "note", path: "/u/.amico/vaults/armonissima/STRATEGY.md" },
{ label: "notes.md", type: "note", path: "/u/.amico/vaults/armonia-kate/notes.md" },
{ label: "solve.jl", type: "package", path: "/p/solve.jl" },
]),
],
{ vaultLocked: (mount) => mount === "armonissima" },
)
const [team, personal, project] = tree.children![0].children!
expect(team.locked).toBe(true)
expect(personal.locked).toBe(false)
expect(project.locked).toBeUndefined() // not a vault file — predicate never consulted
})
test("marathon sessions fold old turns into one earlier branch", () => {
const turns = Array.from({ length: 30 }, (_, i) =>
turn(`m${i}`, [{ label: `f${i}.md`, type: "note", path: `/p/f${i}.md` }]),
Expand Down
16 changes: 12 additions & 4 deletions packages/ui/src/amicode/context-tree-data.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,7 +64,14 @@ export function vaultRefFromPath(path: string): { mount: string; rel: string } |
const dedupKey = (ref: ContextRef, kind: ContextTreeKind) =>
ref.path ? `p:${ref.path}` : `${kind}:${ref.label.toLowerCase()}`

export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: string } = {}): ContextTreeNodeInput {
export type ContextTreeOpts = {
rootLabel?: string
/** true when the mount refuses browsing (proprietary data/software) — its
* leaves render locked: dimmed, padlocked, not openable */
vaultLocked?: (mount: string) => boolean
}

export function buildContextTree(turns: ContextTurn[], opts: ContextTreeOpts = {}): ContextTreeNodeInput {
const root: ContextTreeNodeInput = {
id: "root",
label: opts.rootLabel ?? "amico",
Expand DownExpand Up@@ -95,7 +102,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
const key = dedupKey(ref, kind)
if (seen.has(key)) continue
seen.set(key, `ctx-${seen.size}`)
earlier.children!.push(leafOf(ref, kind, seen.get(key)!))
earlier.children!.push(leafOf(ref, kind, seen.get(key)!, opts))
}
}
const seen = seenOf(root)
Expand DownExpand Up@@ -123,7 +130,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
}
const id = `ctx-${seen.size}`
seen.set(key, id)
const leaf = leafOf(ref, kind, id)
const leaf = leafOf(ref, kind, id, opts)
node.children!.push(leaf)
lastLeaf = leaf
}
Expand All@@ -138,14 +145,15 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
return root
}

function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string): ContextTreeNodeInput {
function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string, opts: ContextTreeOpts): ContextTreeNodeInput {
const vault = ref.path ? vaultRefFromPath(ref.path) : undefined
return {
id,
label: ref.label.slice(0, 32),
kind,
path: ref.path,
vault: !!vault,
locked: vault && opts.vaultLocked ? opts.vaultLocked(vault.mount) : undefined,
}
}

Expand Down
43 changes: 35 additions & 8 deletions packages/ui/src/amicode/context-tree-engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,6 +44,9 @@ export type ContextTreeNodeInput = {
path?: string
/** the file lives in a vault mount (open via the Vault panel, not a tab) */
vault?: boolean
/** the vault refuses browsing (proprietary data/software) — the node renders
* dimmed with a padlock and is NOT openable, path or not */
locked?: boolean
/** where the agent currently works — wears the thought-color cursor */
active?: boolean
children?: ContextTreeNodeInput[]
Expand All@@ -57,6 +60,7 @@ export type ContextTreeSelection = {
kind: ContextTreeKind
path?: string
vault?: boolean
locked?: boolean
}

export interface ContextTreeEngineOptions {
Expand DownExpand Up@@ -181,6 +185,7 @@ interface TNode {
kind: ContextTreeKind
path?: string
vault?: boolean
locked?: boolean
active: boolean
depth: number
// world targets (tidy layout) and animated positions
Expand DownExpand Up@@ -333,6 +338,7 @@ export function createContextTreeEngine(
n.kind = input.kind
n.path = input.path
n.vault = input.vault
n.locked = input.locked
n.active = !!input.active
n.depth = depth
n.half = HALF[input.kind] ?? 4
Expand DownExpand Up@@ -456,7 +462,11 @@ export function createContextTreeEngine(
kind: n.kind,
path: n.path,
vault: n.vault,
locked: n.locked,
})
// openable = the click actually goes somewhere; everything else (turns,
// skills, agents, actions, locked vault files) must not wear the pointer
const openable = (n: TNode) => !!n.path && !n.locked
let dragging = false
let dragMoved = false
let lastPX = 0,
Expand DownExpand Up@@ -499,7 +509,8 @@ export function createContextTreeEngine(
const n = id ? (byId.get(id) ?? null) : null
if (n !== hovered) {
hovered = n
if (canvas.style) canvas.style.cursor = n && (n.path || n.kind === "turn") ? "pointer" : "grab"
// pointer only where a click opens something; a locked node says so
if (canvas.style) canvas.style.cursor = n && openable(n) ? "pointer" : n?.locked ? "not-allowed" : "grab"
opts.onHover?.(n ? selection(n) : null)
}
}
Expand All@@ -510,7 +521,9 @@ export function createContextTreeEngine(
const p = local(e)
const id = pick(p.x, p.y)
const n = id ? byId.get(id) : undefined
if (n) {
// only openable nodes acknowledge the click — a ring on a dead-end node
// would promise an action that never comes
if (n && openable(n)) {
n.ringT = beatNow
opts.onSelect?.(selection(n))
}
Expand DownExpand Up@@ -647,9 +660,11 @@ export function createContextTreeEngine(
ctx.lineWidth = 1
ctx.stroke()
} else {
ctx.fillStyle = rgba(color, hoveredNow ? 0.95 : 0.75)
// locked (non-browsable vault) leaves read clearly non-interactive:
// reduced emphasis, plus the padlock by the label (shape, not color)
ctx.fillStyle = rgba(color, n.locked ? 0.3 : hoveredNow ? 0.95 : 0.75)
ctx.fill()
ctx.strokeStyle = rgba(color, 0.9)
ctx.strokeStyle = rgba(color, n.locked ? 0.45 : 0.9)
ctx.lineWidth = 1
ctx.stroke()
}
Expand DownExpand Up@@ -690,13 +705,25 @@ export function createContextTreeEngine(
isRoot || n.kind === "turn" ? 0.85 : hoveredNow || glancedNow || n.active ? 1 : nearHover ? 0.85 : 0.6
ctx.font = `${n.kind === "turn" || isRoot ? "600 " : ""}10px JuliaMono, ui-monospace, SFMono-Regular, Menlo, monospace`
const text = n.label.length > 30 ? n.label.slice(0, 29) + "…" : n.label
const lockW = n.locked ? 10 : 0
const tw = ctx.measureText(text).width
const lx = x - tw / 2,
const lx = x - (tw + lockW) / 2,
ly = y + half + 10
ctx.fillStyle = css.labelHalo
ctx.fillRect(lx - 2, ly - 7, tw + 4, 14)
ctx.fillStyle = rgba(css.fg, Math.min(la, 1) * n.alpha)
ctx.fillText(text, lx, ly)
ctx.fillRect(lx - 2, ly - 7, tw + lockW + 4, 14)
const inkA = Math.min(la, 1) * n.alpha
if (n.locked) {
// padlock: shackle arc over a body — the non-color "cannot open" signal
ctx.strokeStyle = rgba(css.fg, inkA)
ctx.lineWidth = 1
ctx.beginPath()
ctx.arc(lx + 3, ly - 1.5, 2, Math.PI, 0)
ctx.stroke()
ctx.fillStyle = rgba(css.fg, inkA)
ctx.fillRect(lx, ly - 1.5, 6, 5)
}
ctx.fillStyle = rgba(css.fg, inkA)
ctx.fillText(text, lx + lockW, ly)
ctx.globalAlpha = 1
}

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions packages/app/src/pages/session/context-tree-panel.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,11 +7,13 @@
// the real file (project files in a session tab, vault files in the Vault
// panel). Hovering a tool row in the log still glances at its node here via
// the same amicode:brain-hover event the strip used.
import { createEffect, createMemo, createSignal, onCleanup, onMount, For, Show } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, For, Show } from "solid-js"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useSync } from "@/context/sync"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useServer } from "@/context/server"
import { amicodeGet } from "@/utils/amicode-fetch"
import { amicoBrainRef } from "@opencode-ai/ui/brain-ref"
import {
createContextTreeEngine,
Expand DownExpand Up@@ -66,8 +68,33 @@ function ContextTreeFrame(props: { sessionID: string }) {
const sync = useSync()
const file = useFile()
const language = useLanguage()
const server = useServer()
const { tabs, view } = useSessionLayout()

// per-mount browsability from GET /amicode/vaults (`browsable`, stamped by
// the server's fail-closed law) — proprietary mounts mark their nodes
// locked upfront instead of dead-ending in a Vault-panel refusal on click
const [vaultsRaw] = createResource(
() => server.current,
(conn) => amicodeGet(conn, "/amicode/vaults").catch(() => undefined),
)
const browsableMounts = createMemo<Map<string, boolean | undefined> | undefined>(() => {
const raw = vaultsRaw() as { mounts?: { id?: string; browsable?: boolean }[] } | undefined
if (!raw || !Array.isArray(raw.mounts)) return undefined
return new Map(
raw.mounts.filter((m) => typeof m?.id === "string").map((m) => [m.id as string, m.browsable]),
)
})
const vaultLocked = (mount: string) => {
const map = browsableMounts()
// list unavailable → status quo (no lock claims we can't back);
// a mount the server doesn't list can't be browsed → locked;
// `browsable` absent (older server) → unknown, again no lock claim
if (!map) return false
if (!map.has(mount)) return true
return map.get(mount) === false
}

const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
const getParts = (msgId: string) => sync.data.part[msgId] ?? []
const busy = createMemo(() => (sync.data.session_status[props.sessionID]?.type ?? "idle") !== "idle")
Expand DownExpand Up@@ -134,7 +161,7 @@ function ContextTreeFrame(props: { sessionID: string }) {
setActive: (tab) => tabs().setActive(tab),
})
const onSelect = (node: ContextTreeSelection) => {
if (!node.path) return
if (!node.path || node.locked) return
const vaultRef = vaultRefFromPath(node.path)
if (vaultRef) {
vaultPanel.open({ mount: vaultRef.mount, path: vaultRef.rel })
Expand All@@ -161,7 +188,7 @@ function ContextTreeFrame(props: { sessionID: string }) {
window.addEventListener("amicode:brain-hover", onToolHover)
onCleanup(() => window.removeEventListener("amicode:brain-hover", onToolHover))

const tree = createMemo(() => buildContextTree(turns()))
const tree = createMemo(() => buildContextTree(turns(), { vaultLocked }))
createEffect(() => {
const brain = engine()
if (!brain) return
Expand All@@ -173,7 +200,8 @@ function ContextTreeFrame(props: { sessionID: string }) {
const flatNodes = createMemo(() => {
const out: ContextTreeSelection[] = []
const walk = (n: ContextTreeNodeInput) => {
if (n.kind !== "root") out.push({ id: n.id, label: n.label, kind: n.kind, path: n.path, vault: n.vault })
if (n.kind !== "root")
out.push({ id: n.id, label: n.label, kind: n.kind, path: n.path, vault: n.vault, locked: n.locked })
for (const c of n.children ?? []) walk(c)
}
walk(tree())
Expand All@@ -188,7 +216,11 @@ function ContextTreeFrame(props: { sessionID: string }) {
setKbIndex(next)
const node = list[next]
engine()?.focus(node.id)
setAnnounce(`${node.label} — ${node.kind}${node.path ? ", press Enter to open" : ""}`)
setAnnounce(
`${node.label} — ${node.kind}${
node.locked ? ", locked — this vault does not allow browsing" : node.path ? ", press Enter to open" : ""
}`,
)
}
const onCanvasKeyDown = (e: KeyboardEvent) => {
const list = flatNodes()
Expand Down
28 changes: 27 additions & 1 deletion packages/opencode/src/server/amicode/vaults.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFil
import { homedir } from "node:os"
import path from "node:path"
import { run } from "@/util/process"
import { browseAllowed, mountBrowseRefusal, mountDir } from "@/server/amicode/vault-browser"

const TIMEOUT_MS = 8_000
const CACHE_MS = 10_000
Expand DownExpand Up@@ -65,9 +66,34 @@ function resolveCli(): string | undefined {

let cache: { at: number; body: string } | undefined

/** Stamp each mount with `browsable`, computed by the vault-browser's
* fail-closed law (deployment gate + per-mount kind/marker rules), so the
* app can mark proprietary context locked UPFRONT — e.g. grey out context
* tree nodes — instead of discovering the refusal on click. Additive to the
* relayed wire shape; an unparseable body passes through untouched. */
export function annotateBrowsable(
body: string,
root: string = vaultsRoot(),
env: Record<string, string | undefined> = process.env,
): string {
try {
const parsed = JSON.parse(body) as { mounts?: { id?: unknown; browsable?: boolean }[] }
if (!Array.isArray(parsed.mounts)) return body
const allowed = browseAllowed(env)
for (const m of parsed.mounts) {
if (typeof m?.id !== "string") continue
const dir = allowed ? mountDir(m.id, root) : undefined
m.browsable = !!dir && !mountBrowseRefusal(m.id, dir, env)
}
return JSON.stringify(parsed)
} catch {
return body
}
}

export async function status(): Promise<string> {
if (cache && Date.now() - cache.at < CACHE_MS) return cache.body
const body = await statusUncached().catch((err) => synthesize("bad_output", String(err)))
const body = annotateBrowsable(await statusUncached().catch((err) => synthesize("bad_output", String(err))))
cache = { at: Date.now(), body }
return body
}
Expand Down
46 changes: 45 additions & 1 deletion packages/opencode/test/server/amicode-vaults.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,15 @@ import { describe, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, writeFileSync, existsSync, lstatSync } from "node:fs"
import { tmpdir, homedir } from "node:os"
import path from "node:path"
import { candidates, synthesize, normalizeRef, sanitizeVaultName, attachVault, scanMounts } from "@/server/amicode/vaults"
import {
annotateBrowsable,
candidates,
synthesize,
normalizeRef,
sanitizeVaultName,
attachVault,
scanMounts,
} from "@/server/amicode/vaults"

describe("synthesize", () => {
test("emits the plural failure shape the UI parser expects", () => {
Expand DownExpand Up@@ -118,3 +126,39 @@ describe("scanMounts (CLI-less fallback)", () => {
expect(out.mounts[1]).toMatchObject({ id: "armonissima", kind: "team", writable: false })
})
})

describe("annotateBrowsable", () => {
const root = mkdtempSync(path.join(tmpdir(), "vaults-annotate-"))
const mk = (name: string, marker: string) => {
mkdirSync(path.join(root, name), { recursive: true })
writeFileSync(path.join(root, name, ".amico-vault.toml"), marker)
}
mk("personal-v", 'kind = "personal"\nname = "personal-v"\n')
mk("team-dark", 'kind = "team"\nname = "team-dark"\n')
mk("team-open", 'kind = "team"\nname = "team-open"\nbrowse = true\n')
mk("personal-off", 'kind = "personal"\nname = "personal-off"\nbrowse = false\n')
const env = { AMICO_VAULT_BROWSER: "1" }

test("stamps browsable per the fail-closed law (kind default + browse override)", () => {
const out = JSON.parse(annotateBrowsable(scanMounts(root), root, env)) as {
mounts: { id: string; browsable: boolean }[]
}
const by = Object.fromEntries(out.mounts.map((m) => [m.id, m.browsable]))
expect(by["personal-v"]).toBe(true)
expect(by["team-dark"]).toBe(false)
expect(by["team-open"]).toBe(true)
expect(by["personal-off"]).toBe(false)
})
test("a mount the browser can't resolve is not browsable; junk passes through", () => {
const body = JSON.stringify({ ok: true, mounts: [{ id: "ghost", kind: "personal" }], error: null })
const out = JSON.parse(annotateBrowsable(body, root, env)) as { mounts: { browsable: boolean }[] }
expect(out.mounts[0].browsable).toBe(false)
expect(annotateBrowsable("not json", root, env)).toBe("not json")
})
test("deployment gate off (AMICO_VAULT_BROWSER=0) darkens every mount", () => {
const out = JSON.parse(annotateBrowsable(scanMounts(root), root, { AMICO_VAULT_BROWSER: "0" })) as {
mounts: { browsable: boolean }[]
}
for (const m of out.mounts) expect(m.browsable).toBe(false)
})
})
16 changes: 16 additions & 0 deletions packages/ui/src/amicode/context-tree-data.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,22 @@ describe("buildContextTree", () => {
])
expect(tree.children![0].children![0].vault).toBe(true)
})
test("vaultLocked marks non-browsable vault leaves locked; others untouched", () => {
const tree = buildContextTree(
[
turn("m1", [
{ label: "STRATEGY.md", type: "note", path: "/u/.amico/vaults/armonissima/STRATEGY.md" },
{ label: "notes.md", type: "note", path: "/u/.amico/vaults/armonia-kate/notes.md" },
{ label: "solve.jl", type: "package", path: "/p/solve.jl" },
]),
],
{ vaultLocked: (mount) => mount === "armonissima" },
)
const [team, personal, project] = tree.children![0].children!
expect(team.locked).toBe(true)
expect(personal.locked).toBe(false)
expect(project.locked).toBeUndefined() // not a vault file — predicate never consulted
})
test("marathon sessions fold old turns into one earlier branch", () => {
const turns = Array.from({ length: 30 }, (_, i) =>
turn(`m${i}`, [{ label: `f${i}.md`, type: "note", path: `/p/f${i}.md` }]),
Expand Down
16 changes: 12 additions & 4 deletions packages/ui/src/amicode/context-tree-data.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,7 +64,14 @@ export function vaultRefFromPath(path: string): { mount: string; rel: string } |
const dedupKey = (ref: ContextRef, kind: ContextTreeKind) =>
ref.path ? `p:${ref.path}` : `${kind}:${ref.label.toLowerCase()}`

export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: string } = {}): ContextTreeNodeInput {
export type ContextTreeOpts = {
rootLabel?: string
/** true when the mount refuses browsing (proprietary data/software) — its
* leaves render locked: dimmed, padlocked, not openable */
vaultLocked?: (mount: string) => boolean
}

export function buildContextTree(turns: ContextTurn[], opts: ContextTreeOpts = {}): ContextTreeNodeInput {
const root: ContextTreeNodeInput = {
id: "root",
label: opts.rootLabel ?? "amico",
Expand DownExpand Up@@ -95,7 +102,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
const key = dedupKey(ref, kind)
if (seen.has(key)) continue
seen.set(key, `ctx-${seen.size}`)
earlier.children!.push(leafOf(ref, kind, seen.get(key)!))
earlier.children!.push(leafOf(ref, kind, seen.get(key)!, opts))
}
}
const seen = seenOf(root)
Expand DownExpand Up@@ -123,7 +130,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
}
const id = `ctx-${seen.size}`
seen.set(key, id)
const leaf = leafOf(ref, kind, id)
const leaf = leafOf(ref, kind, id, opts)
node.children!.push(leaf)
lastLeaf = leaf
}
Expand All@@ -138,14 +145,15 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
return root
}

function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string): ContextTreeNodeInput {
function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string, opts: ContextTreeOpts): ContextTreeNodeInput {
const vault = ref.path ? vaultRefFromPath(ref.path) : undefined
return {
id,
label: ref.label.slice(0, 32),
kind,
path: ref.path,
vault: !!vault,
locked: vault && opts.vaultLocked ? opts.vaultLocked(vault.mount) : undefined,
}
}

Expand Down
43 changes: 35 additions & 8 deletions packages/ui/src/amicode/context-tree-engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,6 +44,9 @@ export type ContextTreeNodeInput = {
path?: string
/** the file lives in a vault mount (open via the Vault panel, not a tab) */
vault?: boolean
/** the vault refuses browsing (proprietary data/software) — the node renders
* dimmed with a padlock and is NOT openable, path or not */
locked?: boolean
/** where the agent currently works — wears the thought-color cursor */
active?: boolean
children?: ContextTreeNodeInput[]
Expand All@@ -57,6 +60,7 @@ export type ContextTreeSelection = {
kind: ContextTreeKind
path?: string
vault?: boolean
locked?: boolean
}

export interface ContextTreeEngineOptions {
Expand DownExpand Up@@ -181,6 +185,7 @@ interface TNode {
kind: ContextTreeKind
path?: string
vault?: boolean
locked?: boolean
active: boolean
depth: number
// world targets (tidy layout) and animated positions
Expand DownExpand Up@@ -333,6 +338,7 @@ export function createContextTreeEngine(
n.kind = input.kind
n.path = input.path
n.vault = input.vault
n.locked = input.locked
n.active = !!input.active
n.depth = depth
n.half = HALF[input.kind] ?? 4
Expand DownExpand Up@@ -456,7 +462,11 @@ export function createContextTreeEngine(
kind: n.kind,
path: n.path,
vault: n.vault,
locked: n.locked,
})
// openable = the click actually goes somewhere; everything else (turns,
// skills, agents, actions, locked vault files) must not wear the pointer
const openable = (n: TNode) => !!n.path && !n.locked
let dragging = false
let dragMoved = false
let lastPX = 0,
Expand DownExpand Up@@ -499,7 +509,8 @@ export function createContextTreeEngine(
const n = id ? (byId.get(id) ?? null) : null
if (n !== hovered) {
hovered = n
if (canvas.style) canvas.style.cursor = n && (n.path || n.kind === "turn") ? "pointer" : "grab"
// pointer only where a click opens something; a locked node says so
if (canvas.style) canvas.style.cursor = n && openable(n) ? "pointer" : n?.locked ? "not-allowed" : "grab"
opts.onHover?.(n ? selection(n) : null)
}
}
Expand All@@ -510,7 +521,9 @@ export function createContextTreeEngine(
const p = local(e)
const id = pick(p.x, p.y)
const n = id ? byId.get(id) : undefined
if (n) {
// only openable nodes acknowledge the click — a ring on a dead-end node
// would promise an action that never comes
if (n && openable(n)) {
n.ringT = beatNow
opts.onSelect?.(selection(n))
}
Expand DownExpand Up@@ -647,9 +660,11 @@ export function createContextTreeEngine(
ctx.lineWidth = 1
ctx.stroke()
} else {
ctx.fillStyle = rgba(color, hoveredNow ? 0.95 : 0.75)
// locked (non-browsable vault) leaves read clearly non-interactive:
// reduced emphasis, plus the padlock by the label (shape, not color)
ctx.fillStyle = rgba(color, n.locked ? 0.3 : hoveredNow ? 0.95 : 0.75)
ctx.fill()
ctx.strokeStyle = rgba(color, 0.9)
ctx.strokeStyle = rgba(color, n.locked ? 0.45 : 0.9)
ctx.lineWidth = 1
ctx.stroke()
}
Expand DownExpand Up@@ -690,13 +705,25 @@ export function createContextTreeEngine(
isRoot || n.kind === "turn" ? 0.85 : hoveredNow || glancedNow || n.active ? 1 : nearHover ? 0.85 : 0.6
ctx.font = `${n.kind === "turn" || isRoot ? "600 " : ""}10px JuliaMono, ui-monospace, SFMono-Regular, Menlo, monospace`
const text = n.label.length > 30 ? n.label.slice(0, 29) + "…" : n.label
const lockW = n.locked ? 10 : 0
const tw = ctx.measureText(text).width
const lx = x - tw / 2,
const lx = x - (tw + lockW) / 2,
ly = y + half + 10
ctx.fillStyle = css.labelHalo
ctx.fillRect(lx - 2, ly - 7, tw + 4, 14)
ctx.fillStyle = rgba(css.fg, Math.min(la, 1) * n.alpha)
ctx.fillText(text, lx, ly)
ctx.fillRect(lx - 2, ly - 7, tw + lockW + 4, 14)
const inkA = Math.min(la, 1) * n.alpha
if (n.locked) {
// padlock: shackle arc over a body — the non-color "cannot open" signal
ctx.strokeStyle = rgba(css.fg, inkA)
ctx.lineWidth = 1
ctx.beginPath()
ctx.arc(lx + 3, ly - 1.5, 2, Math.PI, 0)
ctx.stroke()
ctx.fillStyle = rgba(css.fg, inkA)
ctx.fillRect(lx, ly - 1.5, 6, 5)
}
ctx.fillStyle = rgba(css.fg, inkA)
ctx.fillText(text, lx + lockW, ly)
ctx.globalAlpha = 1
}

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions packages/app/src/pages/session/context-tree-panel.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,11 +7,13 @@
// the real file (project files in a session tab, vault files in the Vault
// panel). Hovering a tool row in the log still glances at its node here via
// the same amicode:brain-hover event the strip used.
import { createEffect, createMemo, createSignal, onCleanup, onMount, For, Show } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, For, Show } from "solid-js"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useSync } from "@/context/sync"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useServer } from "@/context/server"
import { amicodeGet } from "@/utils/amicode-fetch"
import { amicoBrainRef } from "@opencode-ai/ui/brain-ref"
import {
createContextTreeEngine,
Expand DownExpand Up@@ -66,8 +68,33 @@ function ContextTreeFrame(props: { sessionID: string }) {
const sync = useSync()
const file = useFile()
const language = useLanguage()
const server = useServer()
const { tabs, view } = useSessionLayout()

// per-mount browsability from GET /amicode/vaults (`browsable`, stamped by
// the server's fail-closed law) — proprietary mounts mark their nodes
// locked upfront instead of dead-ending in a Vault-panel refusal on click
const [vaultsRaw] = createResource(
() => server.current,
(conn) => amicodeGet(conn, "/amicode/vaults").catch(() => undefined),
)
const browsableMounts = createMemo<Map<string, boolean | undefined> | undefined>(() => {
const raw = vaultsRaw() as { mounts?: { id?: string; browsable?: boolean }[] } | undefined
if (!raw || !Array.isArray(raw.mounts)) return undefined
return new Map(
raw.mounts.filter((m) => typeof m?.id === "string").map((m) => [m.id as string, m.browsable]),
)
})
const vaultLocked = (mount: string) => {
const map = browsableMounts()
// list unavailable → status quo (no lock claims we can't back);
// a mount the server doesn't list can't be browsed → locked;
// `browsable` absent (older server) → unknown, again no lock claim
if (!map) return false
if (!map.has(mount)) return true
return map.get(mount) === false
}

const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
const getParts = (msgId: string) => sync.data.part[msgId] ?? []
const busy = createMemo(() => (sync.data.session_status[props.sessionID]?.type ?? "idle") !== "idle")
Expand DownExpand Up@@ -134,7 +161,7 @@ function ContextTreeFrame(props: { sessionID: string }) {
setActive: (tab) => tabs().setActive(tab),
})
const onSelect = (node: ContextTreeSelection) => {
if (!node.path) return
if (!node.path || node.locked) return
const vaultRef = vaultRefFromPath(node.path)
if (vaultRef) {
vaultPanel.open({ mount: vaultRef.mount, path: vaultRef.rel })
Expand All@@ -161,7 +188,7 @@ function ContextTreeFrame(props: { sessionID: string }) {
window.addEventListener("amicode:brain-hover", onToolHover)
onCleanup(() => window.removeEventListener("amicode:brain-hover", onToolHover))

const tree = createMemo(() => buildContextTree(turns()))
const tree = createMemo(() => buildContextTree(turns(), { vaultLocked }))
createEffect(() => {
const brain = engine()
if (!brain) return
Expand All@@ -173,7 +200,8 @@ function ContextTreeFrame(props: { sessionID: string }) {
const flatNodes = createMemo(() => {
const out: ContextTreeSelection[] = []
const walk = (n: ContextTreeNodeInput) => {
if (n.kind !== "root") out.push({ id: n.id, label: n.label, kind: n.kind, path: n.path, vault: n.vault })
if (n.kind !== "root")
out.push({ id: n.id, label: n.label, kind: n.kind, path: n.path, vault: n.vault, locked: n.locked })
for (const c of n.children ?? []) walk(c)
}
walk(tree())
Expand All@@ -188,7 +216,11 @@ function ContextTreeFrame(props: { sessionID: string }) {
setKbIndex(next)
const node = list[next]
engine()?.focus(node.id)
setAnnounce(`${node.label} — ${node.kind}${node.path ? ", press Enter to open" : ""}`)
setAnnounce(
`${node.label} — ${node.kind}${
node.locked ? ", locked — this vault does not allow browsing" : node.path ? ", press Enter to open" : ""
}`,
)
}
const onCanvasKeyDown = (e: KeyboardEvent) => {
const list = flatNodes()
Expand Down
28 changes: 27 additions & 1 deletion packages/opencode/src/server/amicode/vaults.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFil
import { homedir } from "node:os"
import path from "node:path"
import { run } from "@/util/process"
import { browseAllowed, mountBrowseRefusal, mountDir } from "@/server/amicode/vault-browser"

const TIMEOUT_MS = 8_000
const CACHE_MS = 10_000
Expand DownExpand Up@@ -65,9 +66,34 @@ function resolveCli(): string | undefined {

let cache: { at: number; body: string } | undefined

/** Stamp each mount with `browsable`, computed by the vault-browser's
* fail-closed law (deployment gate + per-mount kind/marker rules), so the
* app can mark proprietary context locked UPFRONT — e.g. grey out context
* tree nodes — instead of discovering the refusal on click. Additive to the
* relayed wire shape; an unparseable body passes through untouched. */
export function annotateBrowsable(
body: string,
root: string = vaultsRoot(),
env: Record<string, string | undefined> = process.env,
): string {
try {
const parsed = JSON.parse(body) as { mounts?: { id?: unknown; browsable?: boolean }[] }
if (!Array.isArray(parsed.mounts)) return body
const allowed = browseAllowed(env)
for (const m of parsed.mounts) {
if (typeof m?.id !== "string") continue
const dir = allowed ? mountDir(m.id, root) : undefined
m.browsable = !!dir && !mountBrowseRefusal(m.id, dir, env)
}
return JSON.stringify(parsed)
} catch {
return body
}
}

export async function status(): Promise<string> {
if (cache && Date.now() - cache.at < CACHE_MS) return cache.body
const body = await statusUncached().catch((err) => synthesize("bad_output", String(err)))
const body = annotateBrowsable(await statusUncached().catch((err) => synthesize("bad_output", String(err))))
cache = { at: Date.now(), body }
return body
}
Expand Down
46 changes: 45 additions & 1 deletion packages/opencode/test/server/amicode-vaults.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,15 @@ import { describe, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, writeFileSync, existsSync, lstatSync } from "node:fs"
import { tmpdir, homedir } from "node:os"
import path from "node:path"
import { candidates, synthesize, normalizeRef, sanitizeVaultName, attachVault, scanMounts } from "@/server/amicode/vaults"
import {
annotateBrowsable,
candidates,
synthesize,
normalizeRef,
sanitizeVaultName,
attachVault,
scanMounts,
} from "@/server/amicode/vaults"

describe("synthesize", () => {
test("emits the plural failure shape the UI parser expects", () => {
Expand DownExpand Up@@ -118,3 +126,39 @@ describe("scanMounts (CLI-less fallback)", () => {
expect(out.mounts[1]).toMatchObject({ id: "armonissima", kind: "team", writable: false })
})
})

describe("annotateBrowsable", () => {
const root = mkdtempSync(path.join(tmpdir(), "vaults-annotate-"))
const mk = (name: string, marker: string) => {
mkdirSync(path.join(root, name), { recursive: true })
writeFileSync(path.join(root, name, ".amico-vault.toml"), marker)
}
mk("personal-v", 'kind = "personal"\nname = "personal-v"\n')
mk("team-dark", 'kind = "team"\nname = "team-dark"\n')
mk("team-open", 'kind = "team"\nname = "team-open"\nbrowse = true\n')
mk("personal-off", 'kind = "personal"\nname = "personal-off"\nbrowse = false\n')
const env = { AMICO_VAULT_BROWSER: "1" }

test("stamps browsable per the fail-closed law (kind default + browse override)", () => {
const out = JSON.parse(annotateBrowsable(scanMounts(root), root, env)) as {
mounts: { id: string; browsable: boolean }[]
}
const by = Object.fromEntries(out.mounts.map((m) => [m.id, m.browsable]))
expect(by["personal-v"]).toBe(true)
expect(by["team-dark"]).toBe(false)
expect(by["team-open"]).toBe(true)
expect(by["personal-off"]).toBe(false)
})
test("a mount the browser can't resolve is not browsable; junk passes through", () => {
const body = JSON.stringify({ ok: true, mounts: [{ id: "ghost", kind: "personal" }], error: null })
const out = JSON.parse(annotateBrowsable(body, root, env)) as { mounts: { browsable: boolean }[] }
expect(out.mounts[0].browsable).toBe(false)
expect(annotateBrowsable("not json", root, env)).toBe("not json")
})
test("deployment gate off (AMICO_VAULT_BROWSER=0) darkens every mount", () => {
const out = JSON.parse(annotateBrowsable(scanMounts(root), root, { AMICO_VAULT_BROWSER: "0" })) as {
mounts: { browsable: boolean }[]
}
for (const m of out.mounts) expect(m.browsable).toBe(false)
})
})
16 changes: 16 additions & 0 deletions packages/ui/src/amicode/context-tree-data.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,22 @@ describe("buildContextTree", () => {
])
expect(tree.children![0].children![0].vault).toBe(true)
})
test("vaultLocked marks non-browsable vault leaves locked; others untouched", () => {
const tree = buildContextTree(
[
turn("m1", [
{ label: "STRATEGY.md", type: "note", path: "/u/.amico/vaults/armonissima/STRATEGY.md" },
{ label: "notes.md", type: "note", path: "/u/.amico/vaults/armonia-kate/notes.md" },
{ label: "solve.jl", type: "package", path: "/p/solve.jl" },
]),
],
{ vaultLocked: (mount) => mount === "armonissima" },
)
const [team, personal, project] = tree.children![0].children!
expect(team.locked).toBe(true)
expect(personal.locked).toBe(false)
expect(project.locked).toBeUndefined() // not a vault file — predicate never consulted
})
test("marathon sessions fold old turns into one earlier branch", () => {
const turns = Array.from({ length: 30 }, (_, i) =>
turn(`m${i}`, [{ label: `f${i}.md`, type: "note", path: `/p/f${i}.md` }]),
Expand Down
16 changes: 12 additions & 4 deletions packages/ui/src/amicode/context-tree-data.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,7 +64,14 @@ export function vaultRefFromPath(path: string): { mount: string; rel: string } |
const dedupKey = (ref: ContextRef, kind: ContextTreeKind) =>
ref.path ? `p:${ref.path}` : `${kind}:${ref.label.toLowerCase()}`

export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: string } = {}): ContextTreeNodeInput {
export type ContextTreeOpts = {
rootLabel?: string
/** true when the mount refuses browsing (proprietary data/software) — its
* leaves render locked: dimmed, padlocked, not openable */
vaultLocked?: (mount: string) => boolean
}

export function buildContextTree(turns: ContextTurn[], opts: ContextTreeOpts = {}): ContextTreeNodeInput {
const root: ContextTreeNodeInput = {
id: "root",
label: opts.rootLabel ?? "amico",
Expand DownExpand Up@@ -95,7 +102,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
const key = dedupKey(ref, kind)
if (seen.has(key)) continue
seen.set(key, `ctx-${seen.size}`)
earlier.children!.push(leafOf(ref, kind, seen.get(key)!))
earlier.children!.push(leafOf(ref, kind, seen.get(key)!, opts))
}
}
const seen = seenOf(root)
Expand DownExpand Up@@ -123,7 +130,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
}
const id = `ctx-${seen.size}`
seen.set(key, id)
const leaf = leafOf(ref, kind, id)
const leaf = leafOf(ref, kind, id, opts)
node.children!.push(leaf)
lastLeaf = leaf
}
Expand All@@ -138,14 +145,15 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
return root
}

function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string): ContextTreeNodeInput {
function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string, opts: ContextTreeOpts): ContextTreeNodeInput {
const vault = ref.path ? vaultRefFromPath(ref.path) : undefined
return {
id,
label: ref.label.slice(0, 32),
kind,
path: ref.path,
vault: !!vault,
locked: vault && opts.vaultLocked ? opts.vaultLocked(vault.mount) : undefined,
}
}

Expand Down
43 changes: 35 additions & 8 deletions packages/ui/src/amicode/context-tree-engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,6 +44,9 @@ export type ContextTreeNodeInput = {
path?: string
/** the file lives in a vault mount (open via the Vault panel, not a tab) */
vault?: boolean
/** the vault refuses browsing (proprietary data/software) — the node renders
* dimmed with a padlock and is NOT openable, path or not */
locked?: boolean
/** where the agent currently works — wears the thought-color cursor */
active?: boolean
children?: ContextTreeNodeInput[]
Expand All@@ -57,6 +60,7 @@ export type ContextTreeSelection = {
kind: ContextTreeKind
path?: string
vault?: boolean
locked?: boolean
}

export interface ContextTreeEngineOptions {
Expand DownExpand Up@@ -181,6 +185,7 @@ interface TNode {
kind: ContextTreeKind
path?: string
vault?: boolean
locked?: boolean
active: boolean
depth: number
// world targets (tidy layout) and animated positions
Expand DownExpand Up@@ -333,6 +338,7 @@ export function createContextTreeEngine(
n.kind = input.kind
n.path = input.path
n.vault = input.vault
n.locked = input.locked
n.active = !!input.active
n.depth = depth
n.half = HALF[input.kind] ?? 4
Expand DownExpand Up@@ -456,7 +462,11 @@ export function createContextTreeEngine(
kind: n.kind,
path: n.path,
vault: n.vault,
locked: n.locked,
})
// openable = the click actually goes somewhere; everything else (turns,
// skills, agents, actions, locked vault files) must not wear the pointer
const openable = (n: TNode) => !!n.path && !n.locked
let dragging = false
let dragMoved = false
let lastPX = 0,
Expand DownExpand Up@@ -499,7 +509,8 @@ export function createContextTreeEngine(
const n = id ? (byId.get(id) ?? null) : null
if (n !== hovered) {
hovered = n
if (canvas.style) canvas.style.cursor = n && (n.path || n.kind === "turn") ? "pointer" : "grab"
// pointer only where a click opens something; a locked node says so
if (canvas.style) canvas.style.cursor = n && openable(n) ? "pointer" : n?.locked ? "not-allowed" : "grab"
opts.onHover?.(n ? selection(n) : null)
}
}
Expand All@@ -510,7 +521,9 @@ export function createContextTreeEngine(
const p = local(e)
const id = pick(p.x, p.y)
const n = id ? byId.get(id) : undefined
if (n) {
// only openable nodes acknowledge the click — a ring on a dead-end node
// would promise an action that never comes
if (n && openable(n)) {
n.ringT = beatNow
opts.onSelect?.(selection(n))
}
Expand DownExpand Up@@ -647,9 +660,11 @@ export function createContextTreeEngine(
ctx.lineWidth = 1
ctx.stroke()
} else {
ctx.fillStyle = rgba(color, hoveredNow ? 0.95 : 0.75)
// locked (non-browsable vault) leaves read clearly non-interactive:
// reduced emphasis, plus the padlock by the label (shape, not color)
ctx.fillStyle = rgba(color, n.locked ? 0.3 : hoveredNow ? 0.95 : 0.75)
ctx.fill()
ctx.strokeStyle = rgba(color, 0.9)
ctx.strokeStyle = rgba(color, n.locked ? 0.45 : 0.9)
ctx.lineWidth = 1
ctx.stroke()
}
Expand DownExpand Up@@ -690,13 +705,25 @@ export function createContextTreeEngine(
isRoot || n.kind === "turn" ? 0.85 : hoveredNow || glancedNow || n.active ? 1 : nearHover ? 0.85 : 0.6
ctx.font = `${n.kind === "turn" || isRoot ? "600 " : ""}10px JuliaMono, ui-monospace, SFMono-Regular, Menlo, monospace`
const text = n.label.length > 30 ? n.label.slice(0, 29) + "…" : n.label
const lockW = n.locked ? 10 : 0
const tw = ctx.measureText(text).width
const lx = x - tw / 2,
const lx = x - (tw + lockW) / 2,
ly = y + half + 10
ctx.fillStyle = css.labelHalo
ctx.fillRect(lx - 2, ly - 7, tw + 4, 14)
ctx.fillStyle = rgba(css.fg, Math.min(la, 1) * n.alpha)
ctx.fillText(text, lx, ly)
ctx.fillRect(lx - 2, ly - 7, tw + lockW + 4, 14)
const inkA = Math.min(la, 1) * n.alpha
if (n.locked) {
// padlock: shackle arc over a body — the non-color "cannot open" signal
ctx.strokeStyle = rgba(css.fg, inkA)
ctx.lineWidth = 1
ctx.beginPath()
ctx.arc(lx + 3, ly - 1.5, 2, Math.PI, 0)
ctx.stroke()
ctx.fillStyle = rgba(css.fg, inkA)
ctx.fillRect(lx, ly - 1.5, 6, 5)
}
ctx.fillStyle = rgba(css.fg, inkA)
ctx.fillText(text, lx + lockW, ly)
ctx.globalAlpha = 1
}

Expand Down
Loading