diff --git a/packages/app/src/custom-elements.d.ts b/packages/app/src/custom-elements.d.ts
index e4ea0d6cebd..86cb284693e 120000
--- a/packages/app/src/custom-elements.d.ts
+++ b/packages/app/src/custom-elements.d.ts
@@ -1 +1 @@
-../../ui/src/custom-elements.d.ts
\ No newline at end of file
+///
diff --git a/packages/enterprise/src/custom-elements.d.ts b/packages/enterprise/src/custom-elements.d.ts
index e4ea0d6cebd..86cb284693e 120000
--- a/packages/enterprise/src/custom-elements.d.ts
+++ b/packages/enterprise/src/custom-elements.d.ts
@@ -1 +1 @@
-../../ui/src/custom-elements.d.ts
\ No newline at end of file
+///
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 4f7c94b1d39..11659a250e1 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -24,6 +24,7 @@ import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
import { KeybindProvider } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
+import { Memory } from "@tui/routes/memory"
import { Session } from "@tui/routes/session"
import { PromptHistoryProvider } from "./component/prompt/history"
import { FrecencyProvider } from "./component/prompt/frecency"
@@ -279,6 +280,11 @@ function App() {
// Truncate title to 40 chars max
const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
renderer.setTerminalTitle(`OC | ${title}`)
+ return
+ }
+
+ if (route.data.type === "memory") {
+ renderer.setTerminalTitle("OC | Memory")
}
})
@@ -563,6 +569,22 @@ function App() {
},
category: "System",
},
+ {
+ title: "View memory",
+ value: "memory.view",
+ category: "System",
+ slash: {
+ name: "memory",
+ },
+ onSelect: (dialog) => {
+ if (route.data.type === "memory") return
+ route.navigate({
+ type: "memory",
+ back: { ...route.data },
+ })
+ dialog.clear()
+ },
+ },
{
title: "Help",
value: "help.show",
@@ -774,6 +796,9 @@ function App() {
+
+
+
)
diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx
index 566d66ade50..a279056dd39 100644
--- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx
@@ -10,7 +10,16 @@ import { useTuiConfig } from "./tui-config"
export type KeybindKey = keyof NonNullable & string
-export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
+type KeybindContext = {
+ all: Record
+ leader: boolean
+ captureLeader(enabled: boolean): void
+ parse(evt: ParsedKey): Keybind.Info
+ match(key: KeybindKey, evt: ParsedKey): boolean | undefined
+ print(key: KeybindKey): string
+}
+
+export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
name: "Keybind",
init: () => {
const config = useTuiConfig()
@@ -22,8 +31,10 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
})
const [store, setStore] = createStore({
leader: false,
+ capture: 0,
})
const renderer = useRenderer()
+ const captured = () => store.capture > 0
let focus: Renderable | null
let timeout: NodeJS.Timeout
@@ -51,7 +62,7 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
}
useKeyboard(async (evt) => {
- if (!store.leader && result.match("leader", evt)) {
+ if (!store.leader && !captured() && result.match("leader", evt)) {
leader(true)
return
}
@@ -73,6 +84,9 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
get leader() {
return store.leader
},
+ captureLeader(enabled: boolean) {
+ setStore("capture", (count) => Math.max(0, count + (enabled ? -1 : 1)))
+ },
parse(evt: ParsedKey): Keybind.Info {
// Handle special case for Ctrl+Underscore (represented as \x1F)
if (evt.name === "\x1F") {
diff --git a/packages/opencode/src/cli/cmd/tui/context/route.tsx b/packages/opencode/src/cli/cmd/tui/context/route.tsx
index e96cd2c3a4a..75a0d4ee874 100644
--- a/packages/opencode/src/cli/cmd/tui/context/route.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/route.tsx
@@ -14,7 +14,12 @@ export type SessionRoute = {
initialPrompt?: PromptInfo
}
-export type Route = HomeRoute | SessionRoute
+export type MemoryRoute = {
+ type: "memory"
+ back: HomeRoute | SessionRoute
+}
+
+export type Route = HomeRoute | SessionRoute | MemoryRoute
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
diff --git a/packages/opencode/src/cli/cmd/tui/routes/memory.tsx b/packages/opencode/src/cli/cmd/tui/routes/memory.tsx
new file mode 100644
index 00000000000..ebb94b383b0
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/routes/memory.tsx
@@ -0,0 +1,430 @@
+import path from "path"
+import { mkdir } from "node:fs/promises"
+import os from "node:os"
+import { useKeyboard, useRenderer } from "@opentui/solid"
+import { useCommandDialog } from "@tui/component/dialog-command"
+import { useKeybind } from "@tui/context/keybind"
+import { useRoute, useRouteData } from "@tui/context/route"
+import { useSDK } from "@tui/context/sdk"
+import { useSync } from "@tui/context/sync"
+import { useTheme } from "@tui/context/theme"
+import { Toast, useToast } from "@tui/ui/toast"
+import { useDialog } from "@tui/ui/dialog"
+import { Filesystem } from "@/util/filesystem"
+import { Global } from "@/global"
+import { Flag } from "@/flag/flag"
+import { Glob } from "@/util/glob"
+import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
+import { Editor } from "@tui/util/editor"
+
+type Entry = {
+ path: string
+ kind: string
+ exists: boolean
+}
+
+type Group = {
+ kind: string
+ title: string
+ items: Entry[]
+}
+
+const FRONTMATTER = ["---", "applyTo: '**'", "---", ""].join("\n")
+const RULES = "**/{AGENTS.md,CLAUDE.md,CONTEXT.md}"
+
+async function collect(opts: {
+ directory: string
+ worktree: string
+ instructions?: string[]
+ skills: { location: string }[]
+}) {
+ const result = new Set()
+
+ if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
+ const matches = await Glob.scan(RULES, {
+ cwd: opts.worktree,
+ absolute: true,
+ include: "file",
+ dot: true,
+ }).catch(() => [])
+ matches.forEach((item) => result.add(path.resolve(item)))
+ }
+
+ for (const file of [
+ ...(Flag.OPENCODE_CONFIG_DIR ? [path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md")] : []),
+ path.join(Global.Path.config, "AGENTS.md"),
+ ...(!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [path.join(os.homedir(), ".claude", "CLAUDE.md")] : []),
+ ]) {
+ if (!(await Filesystem.exists(file))) continue
+ result.add(path.resolve(file))
+ break
+ }
+
+ for (let instruction of opts.instructions ?? []) {
+ if (instruction.startsWith("http://") || instruction.startsWith("https://")) continue
+ const file = instruction.startsWith("~/") ? path.join(Global.Path.home, instruction.slice(2)) : instruction
+ const matches = path.isAbsolute(file)
+ ? await Glob.scan(path.basename(file), {
+ cwd: path.dirname(file),
+ absolute: true,
+ include: "file",
+ dot: true,
+ }).catch(() => [])
+ : !Flag.OPENCODE_DISABLE_PROJECT_CONFIG
+ ? await Filesystem.globUp(file, opts.directory, opts.worktree).catch(() => [])
+ : !Flag.OPENCODE_CONFIG_DIR
+ ? []
+ : await Filesystem.globUp(file, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR).catch(() => [])
+ matches.forEach((item) => result.add(path.resolve(item)))
+ }
+
+ for (const skill of opts.skills) {
+ result.add(path.resolve(skill.location))
+ }
+
+ return Array.from(result).sort((a, b) => a.localeCompare(b))
+}
+
+function kind(file: string, opts: { worktree: string }) {
+ const full = path.resolve(file).replaceAll("\\", "/")
+ const home = Global.Path.home.replaceAll("\\", "/")
+ const config = Global.Path.config.replaceAll("\\", "/")
+ const worktree = path.resolve(opts.worktree).replaceAll("\\", "/")
+ const global_agents = [
+ ...(Flag.OPENCODE_CONFIG_DIR
+ ? [path.resolve(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md")).replaceAll("\\", "/")]
+ : []),
+ path.resolve(path.join(Global.Path.config, "AGENTS.md")).replaceAll("\\", "/"),
+ ]
+ const global_claude = path.resolve(path.join(home, ".claude", "CLAUDE.md")).replaceAll("\\", "/")
+ const root_agents = path.join(worktree, "AGENTS.md").replaceAll("\\", "/")
+ const root_claude = path.join(worktree, "CLAUDE.md").replaceAll("\\", "/")
+ const root_context = path.join(worktree, "CONTEXT.md").replaceAll("\\", "/")
+
+ if (full.includes("/.opencode/skills/") || full.startsWith(config + "/skills/")) return "opencode_skill"
+ if (full.includes("/.claude/skills/") || full.startsWith(home + "/.claude/skills/")) return "claude_skill"
+ if (full.includes("/.agents/skills/") || full.startsWith(home + "/.agents/skills/")) return "agents_skill"
+ if (file.endsWith("SKILL.md")) return "skill"
+ if (global_agents.includes(full)) return "global_agents"
+ if (full === root_agents) return "root_agents"
+ if (file.endsWith("AGENTS.md")) return "nested_agents"
+ if (full === global_claude) return "global_claude"
+ if (full === root_claude) return "root_claude"
+ if (file.endsWith("CLAUDE.md")) return "nested_claude"
+ if (full === root_context) return "root_context"
+ if (file.endsWith("CONTEXT.md")) return "nested_context"
+ if (file.endsWith("memory.instruction.md")) return "memory"
+ return "instruction"
+}
+
+function title(kind: string) {
+ if (kind === "global_agents") return "Global Agents"
+ if (kind === "root_agents") return "Project Root Agents"
+ if (kind === "nested_agents") return "Nested Agents"
+ if (kind === "global_claude") return "Global Claude"
+ if (kind === "root_claude") return "Project Root Claude"
+ if (kind === "nested_claude") return "Nested Claude"
+ if (kind === "root_context") return "Project Root Context"
+ if (kind === "nested_context") return "Nested Context"
+ if (kind === "opencode_skill") return "OpenCode Skills"
+ if (kind === "claude_skill") return "Claude Skills"
+ if (kind === "agents_skill") return "Agents Skills"
+ if (kind === "skill") return "Skills"
+ if (kind === "memory") return "Memory"
+ return "Instructions"
+}
+
+function rank(kind: string) {
+ if (kind === "memory") return 0
+ if (kind === "global_agents") return 1
+ if (kind === "root_agents") return 2
+ if (kind === "nested_agents") return 3
+ if (kind === "global_claude") return 4
+ if (kind === "root_claude") return 5
+ if (kind === "nested_claude") return 6
+ if (kind === "root_context") return 7
+ if (kind === "nested_context") return 8
+ if (kind === "opencode_skill") return 9
+ if (kind === "claude_skill") return 10
+ if (kind === "agents_skill") return 11
+ if (kind === "skill") return 12
+ return 13
+}
+
+export function Memory() {
+ const nav = useRoute()
+ const route = useRouteData("memory")
+ const sdk = useSDK()
+ const sync = useSync()
+ const keybind = useKeybind()
+ const command = useCommandDialog()
+ const dialog = useDialog()
+ const renderer = useRenderer()
+ const toast = useToast()
+ const { theme } = useTheme()
+ const [list, setList] = createSignal([])
+ const [index, setIndex] = createSignal(0)
+ const [state, setState] = createSignal<"loading" | "ready" | "error">("loading")
+ const [error, setError] = createSignal("")
+
+ const root = createMemo(() => sync.data.path.worktree || sync.data.path.directory || process.cwd())
+ const worktree = createMemo(() => sync.data.path.worktree || root())
+ const total = createMemo(() => list().length)
+ const current = createMemo(() => list()[index()])
+ const groups = createMemo(() => {
+ const map = new Map()
+ for (const item of list()) {
+ const arr = map.get(item.kind) ?? []
+ arr.push(item)
+ map.set(item.kind, arr)
+ }
+ return Array.from(map.entries())
+ .sort((a, b) => rank(a[0]) - rank(b[0]) || a[0].localeCompare(b[0]))
+ .map(([kind, items]) => ({
+ kind,
+ title: title(kind),
+ items,
+ }))
+ })
+ async function reload() {
+ setState("loading")
+ setError("")
+ try {
+ const result = await sdk.client.app.skills().catch(() => undefined)
+ const files = await collect({
+ directory: root(),
+ worktree: worktree(),
+ instructions: sync.data.config.instructions,
+ skills: result?.data ?? [],
+ })
+ const next = await Promise.all(
+ files.map(async (file) => ({
+ path: file,
+ kind: kind(file, { worktree: worktree() }),
+ exists: await Filesystem.exists(file),
+ })),
+ )
+ setList(next)
+ setIndex((value) => Math.min(value, Math.max(0, next.length - 1)))
+ setState("ready")
+ } catch (err) {
+ console.error("memory reload failed", err)
+ setState("error")
+ const message = err instanceof Error ? err.message : "Failed to load memory files"
+ setError(message)
+ toast.show({
+ variant: "error",
+ message,
+ })
+ }
+ }
+
+ async function ensure(file: string) {
+ if (!file.endsWith("memory.instruction.md")) return
+ if (await Filesystem.exists(file)) {
+ const text = await Filesystem.readText(file).catch(() => "")
+ if (text.trim()) return
+ }
+ await Filesystem.write(file, FRONTMATTER)
+ }
+
+ async function open(kind: "file" | "dir") {
+ const item = current()
+ if (!item) return
+ try {
+ if (kind === "file") {
+ await ensure(item.path)
+ await Editor.file({ path: item.path, renderer })
+ } else {
+ await mkdir(path.dirname(item.path), { recursive: true })
+ await Editor.dir(path.dirname(item.path))
+ }
+ await reload()
+ } catch (err) {
+ toast.show({
+ variant: "error",
+ message: err instanceof Error ? err.message : "Failed to open memory entry",
+ })
+ }
+ }
+
+ function move(dir: number) {
+ if (total() === 0) return
+ const next = index() + dir
+ if (next < 0) {
+ setIndex(total() - 1)
+ return
+ }
+ if (next >= total()) {
+ setIndex(0)
+ return
+ }
+ setIndex(next)
+ }
+
+ function back() {
+ nav.navigate(route.back)
+ }
+
+ createEffect(() => {
+ root()
+ void reload()
+ })
+
+ onMount(() => {
+ keybind.captureLeader(false)
+ })
+
+ onCleanup(() => {
+ keybind.captureLeader(true)
+ })
+
+ useKeyboard((evt) => {
+ if (dialog.stack.length > 0) return
+ if (evt.defaultPrevented) return
+
+ if (evt.name === "escape") {
+ evt.preventDefault()
+ evt.stopPropagation()
+ back()
+ return
+ }
+
+ if (evt.name === "up") {
+ evt.preventDefault()
+ move(-1)
+ return
+ }
+
+ if (evt.name === "down") {
+ evt.preventDefault()
+ move(1)
+ }
+ })
+
+ command.register(() => [
+ {
+ title: "Open selected memory file",
+ value: "memory.open",
+ keybind: "memory_open_file",
+ category: "Memory",
+ enabled: !!current(),
+ onSelect: (dialog) => {
+ dialog.clear()
+ void open("file")
+ },
+ },
+ {
+ title: "Open selected memory directory",
+ value: "memory.dir",
+ keybind: "memory_open_directory",
+ category: "Memory",
+ enabled: !!current(),
+ onSelect: (dialog) => {
+ dialog.clear()
+ void open("dir")
+ },
+ },
+ {
+ title: "Refresh memory files",
+ value: "memory.refresh",
+ category: "Memory",
+ onSelect: (dialog) => {
+ dialog.clear()
+ void reload()
+ },
+ },
+ ])
+
+ return (
+ <>
+
+
+
+
+ Memory
+ esc back
+
+
+ Loading memory files...}>
+ There are {total()} memory file(s) in use:
+
+
+
+ {keybind.print("memory_open_file")} open file
+
+
+ {keybind.print("memory_open_directory")} open directory
+
+
+
+ {error() || "Failed to load memory files."}
+
+
+
+
+ 0} fallback={No active memory files found.}>
+
+
+ {(group) => (
+
+
+ {group.title}
+ {` (${group.items.length})`}
+
+
+ {(item) => {
+ const active = createMemo(() => current()?.path === item.path)
+ return (
+
+
+ {item.path}
+
+ {item.exists ? "" : " [missing]"}
+
+
+
+ )
+ }}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ {root()}
+
+
+ {(item) => (
+
+ {keybind.print("memory_open_file")} {item().path}
+
+ )}
+
+
+ >
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/util/editor.ts b/packages/opencode/src/cli/cmd/tui/util/editor.ts
index 6d32c63c001..240718caaab 100644
--- a/packages/opencode/src/cli/cmd/tui/util/editor.ts
+++ b/packages/opencode/src/cli/cmd/tui/util/editor.ts
@@ -1,3 +1,4 @@
+import openApp from "open"
import { defer } from "@/util/defer"
import { rm } from "node:fs/promises"
import { tmpdir } from "node:os"
@@ -7,27 +8,60 @@ import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
export namespace Editor {
+ function editor() {
+ return process.env["VISUAL"] || process.env["EDITOR"]
+ }
+
+ function parse(cmd: string) {
+ return (cmd.match(/"[^"]*"|'[^']*'|[^\s]+/g) ?? []).map((part) => part.replace(/^("|')(.*)\1$/, "$2"))
+ }
+
+ async function launch(cmd: string, target: string, renderer?: CliRenderer) {
+ const parts = parse(cmd)
+ if (parts.length === 0) throw new Error("External editor command is empty")
+
+ renderer?.suspend()
+ renderer?.currentRenderBuffer.clear()
+
+ try {
+ const proc = Process.spawn([...parts, target], {
+ stdin: "inherit",
+ stdout: "inherit",
+ stderr: "inherit",
+ })
+ await proc.exited
+ } finally {
+ if (!renderer) return
+ renderer.currentRenderBuffer.clear()
+ renderer.resume()
+ renderer.requestRender()
+ }
+ }
+
+ export async function file(opts: { path: string; renderer?: CliRenderer }) {
+ const cmd = editor()
+ if (cmd) {
+ await launch(cmd, opts.path, opts.renderer)
+ return
+ }
+
+ await openApp(opts.path)
+ }
+
+ export async function dir(path: string) {
+ await openApp(path)
+ }
+
export async function open(opts: { value: string; renderer: CliRenderer }): Promise {
- const editor = process.env["VISUAL"] || process.env["EDITOR"]
- if (!editor) return
+ const cmd = editor()
+ if (!cmd) return
const filepath = join(tmpdir(), `${Date.now()}.md`)
await using _ = defer(async () => rm(filepath, { force: true }))
await Filesystem.write(filepath, opts.value)
- opts.renderer.suspend()
- opts.renderer.currentRenderBuffer.clear()
- const parts = editor.split(" ")
- const proc = Process.spawn([...parts, filepath], {
- stdin: "inherit",
- stdout: "inherit",
- stderr: "inherit",
- })
- await proc.exited
+ await launch(cmd, filepath, opts.renderer)
const content = await Filesystem.readText(filepath)
- opts.renderer.currentRenderBuffer.clear()
- opts.renderer.resume()
- opts.renderer.requestRender()
return content || undefined
}
}
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 2b8aa9e0301..5e9721ed972 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -780,6 +780,8 @@ export namespace Config {
leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
app_exit: z.string().optional().default("ctrl+c,ctrl+d,q").describe("Exit the application"),
editor_open: z.string().optional().default("e").describe("Open external editor"),
+ memory_open_file: z.string().optional().default("ctrl+x").describe("Open memory file in editor"),
+ memory_open_directory: z.string().optional().default("alt+o").describe("Open memory directory"),
theme_list: z.string().optional().default("t").describe("List available themes"),
sidebar_toggle: z.string().optional().default("b").describe("Toggle sidebar"),
scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"),
diff --git a/packages/opencode/test/cli/tui/editor.test.ts b/packages/opencode/test/cli/tui/editor.test.ts
new file mode 100644
index 00000000000..fe92e25aa8b
--- /dev/null
+++ b/packages/opencode/test/cli/tui/editor.test.ts
@@ -0,0 +1,60 @@
+import { beforeEach, describe, expect, mock, test } from "bun:test"
+
+const opens: string[] = []
+const spawns: string[][] = []
+
+mock.module("open", () => ({
+ default: async (target: string) => {
+ opens.push(target)
+ },
+}))
+
+mock.module("@/util/process", () => ({
+ Process: {
+ spawn: (cmd: string[]) => {
+ spawns.push(cmd)
+ return {
+ exited: Promise.resolve(0),
+ }
+ },
+ },
+}))
+
+const { Editor } = await import("../../../src/cli/cmd/tui/util/editor")
+
+describe("Editor.file", () => {
+ beforeEach(() => {
+ opens.length = 0
+ spawns.length = 0
+ delete process.env.EDITOR
+ delete process.env.VISUAL
+ })
+
+ test("falls back to the default app when no editor is configured", async () => {
+ await Editor.file({ path: "/tmp/demo.ts" })
+
+ expect(opens).toEqual(["/tmp/demo.ts"])
+ expect(spawns).toEqual([])
+ })
+
+ test("launches the configured editor command", async () => {
+ process.env.EDITOR = '"C:/Program Files/Code/code.cmd" --wait'
+
+ await Editor.file({ path: "/tmp/demo.ts" })
+
+ expect(spawns).toEqual([["C:/Program Files/Code/code.cmd", "--wait", "/tmp/demo.ts"]])
+ expect(opens).toEqual([])
+ })
+})
+
+describe("Editor.dir", () => {
+ beforeEach(() => {
+ opens.length = 0
+ })
+
+ test("opens the directory with the system default app", async () => {
+ await Editor.dir("/tmp/project")
+
+ expect(opens).toEqual(["/tmp/project"])
+ })
+})