Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion packages/app/src/custom-elements.d.ts
18 changes: 17 additions & 1 deletion packages/enterprise/src/custom-elements.d.ts
1 change: 1 addition & 0 deletions packages/opencode/src/cli/cmd/tui/component/tips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 16 additions & 2 deletions packages/opencode/src/cli/cmd/tui/context/keybind.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,16 @@ import { useTuiConfig } from "./tui-config"

export type KeybindKey = keyof NonNullable<TuiConfig.Info["keybinds"]> & string

export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
type KeybindContext = {
all: Record<string, Keybind.Info[]>
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<KeybindContext, {}>({
name: "Keybind",
init: () => {
const config = useTuiConfig()
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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") {
Expand Down
62 changes: 48 additions & 14 deletions packages/opencode/src/cli/cmd/tui/util/editor.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import openApp from "open"
import { defer } from "@/util/defer"
import { rm } from "node:fs/promises"
import { tmpdir } from "node:os"
Expand All @@ -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<string | undefined> {
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
}
}
2 changes: 2 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,<leader>q").describe("Exit the application"),
editor_open: z.string().optional().default("<leader>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("<leader>t").describe("List available themes"),
sidebar_toggle: z.string().optional().default("<leader>b").describe("Toggle sidebar"),
scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"),
Expand Down
60 changes: 60 additions & 0 deletions packages/opencode/test/cli/tui/editor.test.ts
Original file line number Diff line number Diff line change
@@ -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"])
})
})
Loading