From 7331d2332084f3633323bdff07e2809599a926f2 Mon Sep 17 00:00:00 2001 From: Oerum <54005601+Oerum@users.noreply.github.com> Date: Tue, 10 Mar 2026 00:10:07 +0100 Subject: [PATCH 1/2] feat(tui): add mention-scoped file and directory opening in external editor Adds the ability to quickly open a highlighted file or directory from the mention autocomplete popup into the system's default editor/file manager. - Maps `Ctrl+X` to open the highlighted file in `VISUAL`/`EDITOR` - Maps `Alt+O` to open the highlighted directory in the OS file manager - Scopes these keybinds exclusively to the mention popup to prevent collisions - Adds inline keybind hints to the mention popup footer - Provides fallback to OS default opener if no external editor is configured --- .../src/cli/cmd/tui/component/tips.tsx | 1 + .../src/cli/cmd/tui/context/keybind.tsx | 18 +++++- .../opencode/src/cli/cmd/tui/util/editor.ts | 62 ++++++++++++++----- packages/opencode/src/config/config.ts | 2 + packages/opencode/test/cli/tui/editor.test.ts | 60 ++++++++++++++++++ 5 files changed, 127 insertions(+), 16 deletions(-) create mode 100644 packages/opencode/test/cli/tui/editor.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/tips.tsx b/packages/opencode/src/cli/cmd/tui/component/tips.tsx index 73d82248adb..fd6f3bc67f4 100644 --- a/packages/opencode/src/cli/cmd/tui/component/tips.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/tips.tsx @@ -50,6 +50,7 @@ export function Tips() { const TIPS = [ "Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files", + "While browsing {highlight}@{/highlight} mentions, press {highlight}Ctrl+X{/highlight} to open a file or {highlight}Alt+O{/highlight} to open its directory", "Start a message with {highlight}!{/highlight} to run shell commands directly (e.g., {highlight}!ls -la{/highlight})", "Press {highlight}Tab{/highlight} to cycle between Build and Plan agents", "Use {highlight}/undo{/highlight} to revert the last message and file changes", 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/util/editor.ts b/packages/opencode/src/cli/cmd/tui/util/editor.ts index 6d32c63c001..26c64430f40 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 6b4242a225a..7ad2e08d7ce 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -757,6 +757,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"), + mention_open_file: z.string().optional().default("ctrl+x").describe("Open highlighted mention file in editor"), + mention_open_directory: z.string().optional().default("alt+o").describe("Open highlighted mention 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"]) + }) +}) From 0f53764ff9ce139311e071b5e5ff308b0d094291 Mon Sep 17 00:00:00 2001 From: Oerum <54005601+Oerum@users.noreply.github.com> Date: Tue, 10 Mar 2026 00:14:42 +0100 Subject: [PATCH 2/2] chore: align auto-generated custom-elements.d.ts --- packages/app/src/custom-elements.d.ts | 18 +++++++++++++++++- packages/enterprise/src/custom-elements.d.ts | 18 +++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/app/src/custom-elements.d.ts b/packages/app/src/custom-elements.d.ts index e4ea0d6cebd..49ec4449fa2 120000 --- a/packages/app/src/custom-elements.d.ts +++ b/packages/app/src/custom-elements.d.ts @@ -1 +1,17 @@ -../../ui/src/custom-elements.d.ts \ No newline at end of file +import { DIFFS_TAG_NAME } from "@pierre/diffs" + +/** + * TypeScript declaration for the custom element. + * This tells TypeScript that is a valid JSX element in SolidJS. + * Required for using the @pierre/diffs web component in .tsx files. + */ + +declare module "solid-js" { + namespace JSX { + interface IntrinsicElements { + [DIFFS_TAG_NAME]: HTMLAttributes + } + } +} + +export {} diff --git a/packages/enterprise/src/custom-elements.d.ts b/packages/enterprise/src/custom-elements.d.ts index e4ea0d6cebd..49ec4449fa2 120000 --- a/packages/enterprise/src/custom-elements.d.ts +++ b/packages/enterprise/src/custom-elements.d.ts @@ -1 +1,17 @@ -../../ui/src/custom-elements.d.ts \ No newline at end of file +import { DIFFS_TAG_NAME } from "@pierre/diffs" + +/** + * TypeScript declaration for the custom element. + * This tells TypeScript that is a valid JSX element in SolidJS. + * Required for using the @pierre/diffs web component in .tsx files. + */ + +declare module "solid-js" { + namespace JSX { + interface IntrinsicElements { + [DIFFS_TAG_NAME]: HTMLAttributes + } + } +} + +export {}