Compare commits

...

5 Commits

Author SHA1 Message Date
starptech 047a8dc573 fix(cli): suppress replay footer type error 2026-06-02 15:13:38 +02:00
starptech a897a04700 docs: remove obsolete summary file 2026-06-02 15:03:30 +02:00
starptech 2a392f76b8 fix(tui): surface editor open errors 2026-06-02 15:02:24 +02:00
starptech b3f83eb104 chore(opencode): remove stale ts-expect-error 2026-06-02 14:45:29 +02:00
starptech 9fec5ee87d feat(tui): open tool files from session view 2026-06-02 14:43:46 +02:00
5 changed files with 352 additions and 41 deletions
@@ -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)
@@ -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() {
@@ -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,24 +983,24 @@ 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" })
} catch (error) {
toast.show({ message: `Failed to export session: ${errorMessage(error)}`, variant: "error" })
}
dialog.clear()
},
@@ -1744,6 +1741,20 @@ type ToolProps<T> = {
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<any>) {
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: {
<Show
when={props.spinner}
fallback={
<text paddingLeft={3} fg={theme.textMuted}>
<text
paddingLeft={3}
fg={hover() && props.onTitleClick ? theme.text : theme.textMuted}
onMouseOver={() => props.onTitleClick && setHover(true)}
onMouseOut={() => props.onTitleClick && setHover(false)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
props.onTitleClick?.()
}}
>
{props.title}
</text>
}
@@ -2067,6 +2088,8 @@ function Shell(props: ToolProps<typeof ShellTool>) {
function Write(props: ToolProps<typeof WriteTool>) {
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<typeof WriteTool>) {
return (
<Switch>
<Match when={props.metadata.diagnostics !== undefined}>
<BlockTool title={"# Wrote " + pathFormatter.format(props.input.filePath)} part={props.part}>
<BlockTool
title={"# Wrote " + pathFormatter.format(props.input.filePath)}
part={props.part}
onTitleClick={props.part.state.status === "completed" ? () => openFile(filePath()) : undefined}
>
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
<code
conceal={false}
@@ -2089,7 +2116,13 @@ function Write(props: ToolProps<typeof WriteTool>) {
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}>
<InlineTool
icon="←"
pending="Preparing write..."
complete={props.input.filePath}
part={props.part}
onClick={props.part.state.status === "completed" ? () => openFile(filePath()) : undefined}
>
Write {pathFormatter.format(props.input.filePath)}
</InlineTool>
</Match>
@@ -2287,6 +2320,8 @@ function Edit(props: ToolProps<typeof EditTool>) {
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<typeof EditTool>) {
return (
<Switch>
<Match when={props.metadata.diff !== undefined}>
<BlockTool title={"← Edit " + pathFormatter.format(props.input.filePath)} part={props.part}>
<BlockTool
title={"← Edit " + pathFormatter.format(props.input.filePath)}
part={props.part}
onTitleClick={props.part.state.status === "completed" ? () => openFile(filePath()) : undefined}
>
<box paddingLeft={1}>
<diff
diff={diffContent()}
@@ -2328,7 +2367,13 @@ function Edit(props: ToolProps<typeof EditTool>) {
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
<InlineTool
icon="←"
pending="Preparing edit..."
complete={props.input.filePath}
part={props.part}
onClick={props.part.state.status === "completed" ? () => openFile(filePath()) : undefined}
>
Edit {pathFormatter.format(props.input.filePath)} {input({ replaceAll: props.input.replaceAll })}
</InlineTool>
</Match>
@@ -2340,6 +2385,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
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<typeof ApplyPatchTool>) {
<Match when={files().length > 0}>
<For each={files()}>
{(file) => (
<BlockTool title={title(file)} part={props.part}>
<BlockTool
title={title(file)}
part={props.part}
onTitleClick={
props.part.state.status !== "completed" || file.type === "delete"
? undefined
: () => openFile(file.movePath ?? file.filePath)
}
>
<Show
when={file.type !== "delete"}
fallback={
@@ -1,23 +1,74 @@
import { defer } from "@/util/defer"
import { rm } from "node:fs/promises"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { CliRenderer } from "@opentui/core"
import type { CliRenderer } from "@opentui/core"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { errorMessage } from "@/util/error"
import systemOpen from "open"
export async function open(opts: { value: string; renderer: CliRenderer; cwd?: string }): Promise<string | undefined> {
const editor = process.env["VISUAL"] || process.env["EDITOR"]
export async function open(opts: { value: string; renderer: CliRenderer; cwd: string }): Promise<string | undefined> {
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).catch((error) => {
throw new Error(`Failed to open file with ${editor}: ${errorMessage(error)}. Check $VISUAL or $EDITOR.`)
})
return "editor" as const
}
await systemOpen(filepath).catch((error) => {
throw new Error(`Failed to open file: ${errorMessage(error)}. Set $VISUAL or $EDITOR.`)
})
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 +76,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()
@@ -0,0 +1,203 @@
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<string>()
let systemOpenError: Error | undefined
void mock.module("open", () => ({
default: async (filepath: string) => {
if (systemOpenError) throw systemOpenError
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
systemOpenError = undefined
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 suggests configuring an editor when the platform application fails", async () => {
await using tmp = await tmpdir()
delete process.env.VISUAL
delete process.env.EDITOR
systemOpenError = new Error("spawn xdg-open ENOENT")
const message = await Editor.openFile({
filepath: "target.md",
renderer: renderer().value,
cwd: tmp.path,
directory: tmp.path,
})
.then(() => undefined)
.catch(errorMessage)
expect(message).toBe("Failed to open file: spawn xdg-open ENOENT. Set $VISUAL or $EDITOR.")
})
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(
`Failed to open file with ${process.execPath} ${join(tmp.path, "editor.ts")}: Editor exited with code 7. Check $VISUAL or $EDITOR.`,
)
expect(render.events).toEqual(["suspend", "clear", "clear", "resume", "render"])
})
async function draftExists(marker: string) {
return Bun.file(await Bun.file(marker).text()).exists()
}