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"]) + }) +})