fix(tui): surface editor open errors

This commit is contained in:
starptech 2026-06-02 15:02:24 +02:00
parent b3f83eb104
commit 2a392f76b8
4 changed files with 67 additions and 5 deletions

34
SUMMARY.md Normal file
View file

@ -0,0 +1,34 @@
# opencode
**Open source AI coding agent** — a monorepo for the `opencode` CLI/agent and its surrounding products.
## What it is
- AI-powered terminal coding agent (`opencode` CLI, npm: `opencode-ai`), plus a desktop app and web/console sites.
- Built on **Bun**, **TypeScript**, **Effect v4** (smol), **SolidJS**, **Hono**, **Drizzle** (SQLite), **OpenTUI/Solid** for the TUI, and the Vercel **AI SDK** for LMs.
- Two built-in agents: `build` (default, full-access) and `plan` (read-only), with a `general` subagent.
## Environment variables
- `OPENCODE_INSTALL_DIR`, `XDG_BIN_DIR` — installer location (see README).
## Repo layout (Bun workspaces + Turborepo)
- `packages/opencode` — main CLI/agent (`bun run dev`).
- `packages/app` — web app, `packages/web` — marketing/lander, `packages/desktop` — Electron desktop app.
- `packages/console` + `packages/console/app` + `packages/identity` — hosted console (auth, billing-ish UI) deployed via SST (`sst.config.ts`).
- `packages/stats` — stats service (Cloudflare Worker, see `wrangler.jsonc`).
- `packages/core` — shared core logic (config, sessions, tools, providers, MCP).
- `packages/llm` — model provider adapters on top of the AI SDK.
- `packages/plugin` — plugin/extension system (`@opencode-ai/plugin`).
- `packages/sdk` (+ `sdks/vscode`, `packages/slack`) — JS SDK and integrations.
- `packages/script` — build/CI scripts.
- Supporting libs: `packages/function`, `packages/ui`, `packages/containers`, `packages/extensions`, `packages/docs`, `packages/storybook`, `packages/enterprise`, `packages/cli`, `packages/effect-drizzle-sqlite`, `packages/effect-sqlite-node`, `packages/http-recorder`.
- `infra/` — SST infra; `github/` — GitHub workflow assets; `patches/` — patched deps; `nix/` + `flake.nix` — Nix packaging; `specs/` — specs; `install` — install script.
## Install
```bash
curl -fsSL https://opencode.ai/install | bash
# or: npm i -g opencode-ai@latest, brew install anomalyco/tap/opencode, etc.
```

View file

@ -999,8 +999,8 @@ export function Session() {
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()
},

View file

@ -5,6 +5,7 @@ import { join } from "node:path"
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> {
@ -48,10 +49,14 @@ async function createDraft(value: string) {
async function openPath(filepath: string, opts: { renderer: CliRenderer; cwd: string }) {
const editor = configuredEditor()
if (editor) {
await openEditor(filepath, opts, 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)
await systemOpen(filepath).catch((error) => {
throw new Error(`Failed to open file: ${errorMessage(error)}. Set $VISUAL or $EDITOR.`)
})
return "system" as const
}

View file

@ -9,9 +9,11 @@ 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)
},
}))
@ -24,6 +26,7 @@ afterEach(async () => {
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()
})
@ -156,6 +159,24 @@ test("openFile uses the platform application when no editor is configured", asyn
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)")
@ -171,7 +192,9 @@ test("openFile restores the renderer when the editor exits unsuccessfully", asyn
})
.then(() => undefined)
.catch(errorMessage)
expect(message).toBe("Editor exited with code 7")
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"])
})