diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index f287ea25c5c..890d69c28b4 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -526,12 +526,9 @@ export function Prompt(props: PromptProps) { const content = await Editor.open({ value, renderer, - cwd: - (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || - project.instance.directory() || - process.cwd(), + cwd: project.instance.cwd(), }) - if (!content) return + if (content === undefined) return input.setText(content) diff --git a/packages/opencode/src/cli/cmd/tui/context/project.tsx b/packages/opencode/src/cli/cmd/tui/context/project.tsx index 22dd94bc828..07892708a4f 100644 --- a/packages/opencode/src/cli/cmd/tui/context/project.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/project.tsx @@ -79,6 +79,13 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex directory() { return store.instance.path.directory }, + cwd() { + return ( + (store.instance.path.worktree === "/" ? undefined : store.instance.path.worktree) || + store.instance.path.directory || + process.cwd() + ) + }, }, workspace: { current() { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index d8dbd689f1e..5cbfb347279 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -972,13 +972,10 @@ export function Session() { if (options.openWithoutSaving) { // Just open in editor without saving - await Editor.open({ + await Editor.openTemporary({ value: transcript, renderer, - cwd: - (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || - project.instance.directory() || - process.cwd(), + cwd: project.instance.cwd(), }) } else { const exportDir = process.cwd() @@ -986,21 +983,21 @@ export function Session() { const filepath = path.join(exportDir, filename) await Filesystem.write(filepath, transcript) - - // Open with EDITOR if available - const result = await Editor.open({ - value: transcript, + const error = await Editor.openFile({ + filepath, renderer, - cwd: - (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || - project.instance.directory() || - process.cwd(), + cwd: project.instance.cwd(), + directory: project.instance.directory() || process.cwd(), }) - if (result !== undefined) { - await Filesystem.write(filepath, result) - } + .then(() => undefined) + .catch(errorMessage) - toast.show({ message: `Session exported to ${filename}`, variant: "success" }) + toast.show({ + message: error + ? `Session exported to ${filename}, but failed to open file: ${error}` + : `Session exported to ${filename}`, + variant: error ? "warning" : "success", + }) } } catch { toast.show({ message: "Failed to export session", variant: "error" }) @@ -1744,6 +1741,20 @@ type ToolProps = { output?: string part: ToolPart } + +function useOpenFile() { + const project = useProject() + const toast = useToast() + const renderer = useRenderer() + return (filePath: string | undefined) => { + if (!filePath) return + const cwd = project.instance.cwd() + void Editor.openFile({ filepath: filePath, renderer, cwd, directory: project.instance.directory() || cwd }).catch( + (error) => toast.show({ message: `Failed to open file: ${errorMessage(error)}`, variant: "error" }), + ) + } +} + function GenericTool(props: ToolProps) { const { theme } = useTheme() const ctx = use() @@ -1962,6 +1973,7 @@ function BlockTool(props: { title: string children: JSX.Element onClick?: () => void + onTitleClick?: () => void part?: ToolPart spinner?: boolean }) { @@ -1991,7 +2003,16 @@ function BlockTool(props: { + props.onTitleClick && setHover(true)} + onMouseOut={() => props.onTitleClick && setHover(false)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + props.onTitleClick?.() + }} + > {props.title} } @@ -2067,6 +2088,8 @@ function Shell(props: ToolProps) { function Write(props: ToolProps) { const { theme, syntax } = useTheme() const pathFormatter = usePathFormatter() + const openFile = useOpenFile() + const filePath = createMemo(() => props.metadata.filepath ?? props.input.filePath) const code = createMemo(() => { if (!props.input.content) return "" return props.input.content @@ -2075,7 +2098,11 @@ function Write(props: ToolProps) { return ( - + openFile(filePath()) : undefined} + > ) { - + openFile(filePath()) : undefined} + > Write {pathFormatter.format(props.input.filePath)} @@ -2287,6 +2320,8 @@ function Edit(props: ToolProps) { const ctx = use() const { theme, syntax } = useTheme() const pathFormatter = usePathFormatter() + const openFile = useOpenFile() + const filePath = createMemo(() => props.metadata.filediff?.file ?? props.input.filePath) const view = createMemo(() => { const diffStyle = ctx.tui.diff_style @@ -2302,7 +2337,11 @@ function Edit(props: ToolProps) { return ( - + openFile(filePath()) : undefined} + > ) { - + openFile(filePath()) : undefined} + > Edit {pathFormatter.format(props.input.filePath)} {input({ replaceAll: props.input.replaceAll })} @@ -2340,6 +2385,7 @@ function ApplyPatch(props: ToolProps) { const ctx = use() const { theme, syntax } = useTheme() const pathFormatter = usePathFormatter() + const openFile = useOpenFile() const files = createMemo(() => props.metadata.files ?? []) @@ -2387,7 +2433,15 @@ function ApplyPatch(props: ToolProps) { 0}> {(file) => ( - + openFile(file.movePath ?? file.filePath) + } + > { - const editor = process.env["VISUAL"] || process.env["EDITOR"] +export async function open(opts: { value: string; renderer: CliRenderer; cwd: string }): Promise { + const editor = configuredEditor() if (!editor) return - const filepath = join(tmpdir(), `${Date.now()}.md`) - await using _ = defer(async () => rm(filepath, { force: true })) + const draft = await createDraft(opts.value) + await using _ = defer(draft.remove) + await openEditor(draft.filepath, opts, editor) + return Filesystem.readText(draft.filepath) +} - await Filesystem.write(filepath, opts.value) +export async function openTemporary(opts: { value: string; renderer: CliRenderer; cwd: string }) { + const draft = await createDraft(opts.value) + const mode = await openPath(draft.filepath, opts).catch(async (error) => { + await draft.remove() + throw error + }) + // System openers detach, so retain the draft in the OS temp dir for the application to read. + if (mode === "system") return + await draft.remove() +} + +export async function openFile(opts: { filepath: string; renderer: CliRenderer; cwd: string; directory: string }) { + await openPath(Filesystem.resolveFilePath(opts.directory, opts.filepath), opts) +} + +async function createDraft(value: string) { + const dir = await mkdtemp(join(tmpdir(), "opencode-editor-")) + const filepath = join(dir, "draft.md") + await Filesystem.write(filepath, value).catch(async (error) => { + await rm(dir, { force: true, recursive: true }) + throw error + }) + return { + filepath, + remove: () => rm(dir, { force: true, recursive: true }), + } +} + +async function openPath(filepath: string, opts: { renderer: CliRenderer; cwd: string }) { + const editor = configuredEditor() + if (editor) { + await openEditor(filepath, opts, editor) + return "editor" as const + } + await systemOpen(filepath) + return "system" as const +} + +function configuredEditor() { + return process.env["VISUAL"]?.trim() || process.env["EDITOR"]?.trim() +} + +async function openEditor(filepath: string, opts: { renderer: CliRenderer; cwd: string }, editor: string) { opts.renderer.suspend() opts.renderer.currentRenderBuffer.clear() try { - const parts = editor.split(" ") + const parts = editor.split(/\s+/) const proc = Process.spawn([...parts, filepath], { cwd: opts.cwd, stdin: "inherit", @@ -25,9 +71,8 @@ export async function open(opts: { value: string; renderer: CliRenderer; cwd?: s stderr: "inherit", shell: process.platform === "win32", }) - await proc.exited - const content = await Filesystem.readText(filepath) - return content || undefined + const code = await proc.exited + if (code !== 0) throw new Error(`Editor exited with code ${code}`) } finally { opts.renderer.currentRenderBuffer.clear() opts.renderer.resume() 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..74c21454f5d --- /dev/null +++ b/packages/opencode/test/cli/tui/editor.test.ts @@ -0,0 +1,180 @@ +import { afterEach, expect, mock, test } from "bun:test" +import { rm } from "node:fs/promises" +import { dirname, join } from "node:path" +import type { CliRenderer } from "@opentui/core" +import { errorMessage } from "../../../src/util/error" +import { tmpdir } from "../../fixture/fixture" + +const originalVisual = process.env.VISUAL +const originalEditor = process.env.EDITOR +const systemOpened: string[] = [] +const retained = new Set() + +void mock.module("open", () => ({ + default: async (filepath: string) => { + systemOpened.push(filepath) + }, +})) + +const Editor = await import("../../../src/cli/cmd/tui/util/editor") + +afterEach(async () => { + if (originalVisual === undefined) delete process.env.VISUAL + else process.env.VISUAL = originalVisual + if (originalEditor === undefined) delete process.env.EDITOR + else process.env.EDITOR = originalEditor + systemOpened.length = 0 + await Promise.all([...retained].map((dir) => rm(dir, { force: true, recursive: true }))) + retained.clear() +}) + +function renderer() { + const events: string[] = [] + return { + events, + value: { + suspend() { + events.push("suspend") + }, + currentRenderBuffer: { + clear() { + events.push("clear") + }, + }, + resume() { + events.push("resume") + }, + requestRender() { + events.push("render") + }, + } as unknown as CliRenderer, + } +} + +async function editor(dir: string, name: string, source: string) { + const filepath = join(dir, `${name}.ts`) + await Bun.write(filepath, source) + return `${process.execPath} ${filepath}` +} + +test("open returns without suspending the renderer when no editor is configured", async () => { + delete process.env.VISUAL + delete process.env.EDITOR + const render = renderer() + + expect(await Editor.open({ value: "secret", renderer: render.value, cwd: process.cwd() })).toBeUndefined() + expect(render.events).toEqual([]) +}) + +test("openFile prefers VISUAL and separates file resolution from editor cwd", async () => { + await using directory = await tmpdir() + await using cwd = await tmpdir() + const target = join(directory.path, "target.md") + const marker = join(directory.path, "marker.txt") + await Bun.write(target, "target") + process.env.VISUAL = await editor( + directory.path, + "visual", + `await Bun.write(${JSON.stringify(marker)}, process.cwd() + "\\n" + process.argv.at(-1))`, + ) + process.env.EDITOR = await editor(directory.path, "editor", "process.exit(7)") + const render = renderer() + + await Editor.openFile({ filepath: "target.md", renderer: render.value, cwd: cwd.path, directory: directory.path }) + + expect(await Bun.file(marker).text()).toBe(`${cwd.path}\n${target}`) + expect(render.events).toEqual(["suspend", "clear", "clear", "resume", "render"]) +}) + +test("openFile falls back to EDITOR when VISUAL is empty", async () => { + await using tmp = await tmpdir() + const target = join(tmp.path, "target.md") + const marker = join(tmp.path, "marker.txt") + await Bun.write(target, "target") + process.env.VISUAL = "" + process.env.EDITOR = await editor( + tmp.path, + "editor", + `await Bun.write(${JSON.stringify(marker)}, process.argv.at(-1)!)`, + ) + + await Editor.openFile({ filepath: target, renderer: renderer().value, cwd: tmp.path, directory: tmp.path }) + + expect(await Bun.file(marker).text()).toBe(target) +}) + +test("open returns empty edited content and removes its draft", async () => { + await using tmp = await tmpdir() + const marker = join(tmp.path, "marker.txt") + process.env.VISUAL = await editor( + tmp.path, + "editor", + `const filepath = process.argv.at(-1)!; await Bun.write(${JSON.stringify(marker)}, filepath); await Bun.write(filepath, "")`, + ) + + expect(await Editor.open({ value: "draft", renderer: renderer().value, cwd: tmp.path })).toBe("") + expect(await draftExists(marker)).toBeFalse() +}) + +test("openTemporary removes drafts after blocking editors exit", async () => { + await using tmp = await tmpdir() + const marker = join(tmp.path, "marker.txt") + process.env.VISUAL = await editor( + tmp.path, + "editor", + `await Bun.write(${JSON.stringify(marker)}, process.argv.at(-1)!)`, + ) + + await Editor.openTemporary({ value: "transcript", renderer: renderer().value, cwd: tmp.path }) + + expect(await draftExists(marker)).toBeFalse() +}) + +test("openTemporary retains drafts opened by the platform application", async () => { + delete process.env.VISUAL + delete process.env.EDITOR + + await Editor.openTemporary({ value: "transcript", renderer: renderer().value, cwd: process.cwd() }) + + expect(systemOpened).toHaveLength(1) + const filepath = systemOpened[0] + expect(filepath).toBeDefined() + if (!filepath) return + retained.add(dirname(filepath)) + expect(await Bun.file(filepath).text()).toBe("transcript") +}) + +test("openFile uses the platform application when no editor is configured", async () => { + await using tmp = await tmpdir() + delete process.env.VISUAL + delete process.env.EDITOR + const target = join(tmp.path, "target.md") + await Bun.write(target, "target") + + await Editor.openFile({ filepath: "target.md", renderer: renderer().value, cwd: tmp.path, directory: tmp.path }) + + expect(systemOpened).toEqual([target]) +}) + +test("openFile restores the renderer when the editor exits unsuccessfully", async () => { + await using tmp = await tmpdir() + process.env.VISUAL = await editor(tmp.path, "editor", "process.exit(7)") + const target = join(tmp.path, "target.md") + await Bun.write(target, "target") + const render = renderer() + + const message = await Editor.openFile({ + filepath: target, + renderer: render.value, + cwd: tmp.path, + directory: tmp.path, + }) + .then(() => undefined) + .catch(errorMessage) + expect(message).toBe("Editor exited with code 7") + expect(render.events).toEqual(["suspend", "clear", "clear", "resume", "render"]) +}) + +async function draftExists(marker: string) { + return Bun.file(await Bun.file(marker).text()).exists() +}