Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5acc917591 | ||
|
|
5bc6a7f6d0 | ||
|
|
7051796c38 | ||
|
|
b67f5d741f | ||
|
|
eec0843ce4 | ||
|
|
55baa16fbc | ||
|
|
c79a9634d3 | ||
|
|
8dd6448c90 | ||
|
|
18b9cec50d |
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
// Build a pre-compiled `opencode` binary for subprocess tests, then expose
|
||||||
|
// it at `dist/test-cli/bin/opencode` for the harness to consume.
|
||||||
|
//
|
||||||
|
// Why: each `bun run --conditions=browser src/index.ts <cmd>` spawn pays
|
||||||
|
// ~15s of JIT + plugin init + DB migration in isolation mode. The
|
||||||
|
// pre-compiled binary cuts that to ~5s — a 3x improvement on subprocess
|
||||||
|
// tests that touch the DB (mcp, providers list, etc.).
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// bun script/prebuild-test-cli.ts
|
||||||
|
// export OPENCODE_TEST_CLI_PATH="$PWD/dist/test-cli/bin/opencode"
|
||||||
|
// bun test test/cli/
|
||||||
|
//
|
||||||
|
// The harness (see test/lib/cli-process.ts) reads OPENCODE_TEST_CLI_PATH; if
|
||||||
|
// set, it spawns the binary directly instead of `bun run src/index.ts`. If
|
||||||
|
// unset, it falls back to dev mode — so this script is strictly opt-in.
|
||||||
|
//
|
||||||
|
// Build cost amortizes after ~1 spawn that touches the DB. Recommended for
|
||||||
|
// CI, manual `bun test test/cli/` runs, and any local iteration where the
|
||||||
|
// CLI surface itself isn't under change. Skip for normal src/* editing — the
|
||||||
|
// dev path picks up source changes without rebuild.
|
||||||
|
import { $ } from "bun"
|
||||||
|
import fs from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
const dir = path.resolve(import.meta.dirname, "..")
|
||||||
|
process.chdir(dir)
|
||||||
|
|
||||||
|
const platform = process.platform === "win32" ? "win32" : process.platform === "darwin" ? "darwin" : "linux"
|
||||||
|
const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : "x64"
|
||||||
|
const targetDir = path.join(dir, "dist", `opencode-${platform}-${arch}`)
|
||||||
|
const binaryName = process.platform === "win32" ? "opencode.exe" : "opencode"
|
||||||
|
const builtBinary = path.join(targetDir, "bin", binaryName)
|
||||||
|
const stableBinary = path.join(dir, "dist", "test-cli", "bin", binaryName)
|
||||||
|
|
||||||
|
const force = process.argv.includes("--force")
|
||||||
|
|
||||||
|
// Walk src/ and return the newest mtime seen. Faster than `git status` for
|
||||||
|
// the freshness check and works for uncommitted edits. Returns 0 on error
|
||||||
|
// so a missing src/ tree forces a rebuild via the comparison below.
|
||||||
|
async function newestMtimeMs(root: string): Promise<number> {
|
||||||
|
let max = 0
|
||||||
|
async function walk(p: string) {
|
||||||
|
let entries: { name: string; isDirectory: () => boolean; isFile: () => boolean }[]
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(p, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = path.join(p, entry.name)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (entry.name === "node_modules" || entry.name === "dist") continue
|
||||||
|
await walk(full)
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
const stat = await fs.stat(full).catch(() => null)
|
||||||
|
if (stat && stat.mtimeMs > max) max = stat.mtimeMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await walk(root)
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fresh(): Promise<boolean> {
|
||||||
|
const binStat = await fs.stat(builtBinary).catch(() => null)
|
||||||
|
if (!binStat) return false
|
||||||
|
const srcMs = await newestMtimeMs(path.join(dir, "src"))
|
||||||
|
return binStat.mtimeMs > srcMs
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force && (await fresh())) {
|
||||||
|
console.log(`Test CLI binary is up to date: ${builtBinary}`)
|
||||||
|
} else {
|
||||||
|
console.log(`Building test CLI binary for ${platform}-${arch}...`)
|
||||||
|
const start = Date.now()
|
||||||
|
await $`bun script/build.ts --single --skip-embed-web-ui --skip-install`
|
||||||
|
console.log(`Build complete in ${Date.now() - start}ms: ${builtBinary}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the binary exists and is executable before symlinking — catches
|
||||||
|
// a silently-failed build that left a stale or partial output behind.
|
||||||
|
await fs.access(builtBinary, fs.constants.X_OK).catch(() => {
|
||||||
|
throw new Error(`Built binary missing or not executable: ${builtBinary}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(stableBinary), { recursive: true })
|
||||||
|
await fs.rm(stableBinary, { force: true })
|
||||||
|
await fs.symlink(builtBinary, stableBinary)
|
||||||
|
console.log(`Symlinked stable path: ${stableBinary}`)
|
||||||
|
console.log(``)
|
||||||
|
console.log(`To use in tests:`)
|
||||||
|
console.log(` export OPENCODE_TEST_CLI_PATH="${stableBinary}"`)
|
||||||
|
console.log(` bun test test/cli/`)
|
||||||
@@ -230,6 +230,7 @@ Top-level API groups exposed to `tui(api, options, meta)`:
|
|||||||
- `api.attention.notify(input)`
|
- `api.attention.notify(input)`
|
||||||
- `api.keys.formatSequence(parts)`, `formatBindings(bindings)`
|
- `api.keys.formatSequence(parts)`, `formatBindings(bindings)`
|
||||||
- `api.keymap`
|
- `api.keymap`
|
||||||
|
- `api.mode.current()`, `api.mode.push(mode)`
|
||||||
- `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current`
|
- `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current`
|
||||||
- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Slot`, `Prompt`, `ui.toast`, `ui.dialog`
|
- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Slot`, `Prompt`, `ui.toast`, `ui.dialog`
|
||||||
- `api.tuiConfig`
|
- `api.tuiConfig`
|
||||||
@@ -255,6 +256,68 @@ Top-level API groups exposed to `tui(api, options, meta)`:
|
|||||||
- Disposers returned by `api.keymap` registrations and `acquireResource(...)` are automatically cleaned up when the plugin deactivates. You do not need to add those disposers to `api.lifecycle.onDispose(...)` yourself.
|
- Disposers returned by `api.keymap` registrations and `acquireResource(...)` are automatically cleaned up when the plugin deactivates. You do not need to add those disposers to `api.lifecycle.onDispose(...)` yourself.
|
||||||
- Built-in which-key shortcuts are resolved from flat `keybinds` command ids such as `which_key_toggle`, not plugin options.
|
- Built-in which-key shortcuts are resolved from flat `keybinds` command ids such as `which_key_toggle`, not plugin options.
|
||||||
|
|
||||||
|
#### Mode-aware layers
|
||||||
|
|
||||||
|
OpenCode registers a `mode` layer field on the host keymap. Plugins can use it to keep bindings active only in the relevant UI state.
|
||||||
|
|
||||||
|
Built-in modes:
|
||||||
|
|
||||||
|
- `base`: normal app, route, and prompt interaction.
|
||||||
|
- `modal`: host dialog stack is open, including dialogs rendered through `api.ui.dialog` and `api.ui.Dialog*` components.
|
||||||
|
- `autocomplete`: host prompt autocomplete is open.
|
||||||
|
- `api.mode.current()` returns the active top mode, or `base` when no pushed mode is active.
|
||||||
|
|
||||||
|
Example: register a command and shortcut that are active only in normal app mode:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
api.keymap.registerLayer({
|
||||||
|
mode: "base",
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
name: "demo.open",
|
||||||
|
title: "Demo",
|
||||||
|
category: "Plugin",
|
||||||
|
namespace: "palette",
|
||||||
|
run() {
|
||||||
|
api.route.navigate("demo")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bindings: [{ key: "ctrl+shift+m", cmd: "demo.open", desc: "Open demo" }],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Layers without `mode` are not mode-gated and can remain active while dialogs or autocomplete are open. Use that only for intentionally global commands or low-level keymap extensions.
|
||||||
|
|
||||||
|
Plugins that own a full-screen route or modal-like UI can temporarily push a plugin-specific mode with `api.mode.push(...)`. Use a plugin-scoped mode name. The returned disposer pops that specific stack entry and is idempotent, so popping an older mode while a newer mode is on top leaves the newer mode active.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { onCleanup } from "solid-js"
|
||||||
|
|
||||||
|
api.route.register([
|
||||||
|
{
|
||||||
|
name: "demo",
|
||||||
|
render: () => {
|
||||||
|
const popMode = api.mode.push("acme.demo")
|
||||||
|
onCleanup(popMode)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box>
|
||||||
|
<text>demo</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
api.keymap.registerLayer({
|
||||||
|
mode: "acme.demo",
|
||||||
|
bindings: [{ key: "escape", cmd: () => api.route.navigate("home"), desc: "Close demo" }],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Mode pushes are automatically tracked by the plugin runtime. If a plugin is disabled, fails during activation, or the TUI shuts down before the plugin calls the disposer, OpenCode pops the plugin's pushed modes during plugin cleanup. Calling the disposer yourself is still recommended for component lifetimes; cleanup remains idempotent.
|
||||||
|
|
||||||
### Keys
|
### Keys
|
||||||
|
|
||||||
- `api.keys` exposes host-formatted shortcut display helpers for plugin UI.
|
- `api.keys` exposes host-formatted shortcut display helpers for plugin UI.
|
||||||
|
|||||||
@@ -66,8 +66,15 @@ import { createTuiApi } from "@/cli/cmd/tui/plugin/api"
|
|||||||
import type { RouteMap } from "@/cli/cmd/tui/plugin/api"
|
import type { RouteMap } from "@/cli/cmd/tui/plugin/api"
|
||||||
import { createTuiAttention } from "@/cli/cmd/tui/attention"
|
import { createTuiAttention } from "@/cli/cmd/tui/attention"
|
||||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||||
import { CommandPaletteProvider, useCommandPalette } from "./context/command-palette"
|
import { CommandPaletteDialog } from "./component/command-palette"
|
||||||
import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencodeKeymap } from "./keymap"
|
import {
|
||||||
|
COMMAND_PALETTE_COMMAND,
|
||||||
|
OPENCODE_BASE_MODE,
|
||||||
|
OpencodeKeymapProvider,
|
||||||
|
registerOpencodeKeymap,
|
||||||
|
useBindings,
|
||||||
|
useOpencodeKeymap,
|
||||||
|
} from "./keymap"
|
||||||
|
|
||||||
import type { EventSource } from "./context/sdk"
|
import type { EventSource } from "./context/sdk"
|
||||||
import { DialogVariant } from "./component/dialog-variant"
|
import { DialogVariant } from "./component/dialog-variant"
|
||||||
@@ -227,17 +234,15 @@ export function tui(input: {
|
|||||||
<LocalProvider>
|
<LocalProvider>
|
||||||
<PromptStashProvider>
|
<PromptStashProvider>
|
||||||
<DialogProvider>
|
<DialogProvider>
|
||||||
<CommandPaletteProvider>
|
<FrecencyProvider>
|
||||||
<FrecencyProvider>
|
<PromptHistoryProvider>
|
||||||
<PromptHistoryProvider>
|
<PromptRefProvider>
|
||||||
<PromptRefProvider>
|
<EditorContextProvider>
|
||||||
<EditorContextProvider>
|
<App onSnapshot={input.onSnapshot} />
|
||||||
<App onSnapshot={input.onSnapshot} />
|
</EditorContextProvider>
|
||||||
</EditorContextProvider>
|
</PromptRefProvider>
|
||||||
</PromptRefProvider>
|
</PromptHistoryProvider>
|
||||||
</PromptHistoryProvider>
|
</FrecencyProvider>
|
||||||
</FrecencyProvider>
|
|
||||||
</CommandPaletteProvider>
|
|
||||||
</DialogProvider>
|
</DialogProvider>
|
||||||
</PromptStashProvider>
|
</PromptStashProvider>
|
||||||
</LocalProvider>
|
</LocalProvider>
|
||||||
@@ -267,7 +272,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const kv = useKV()
|
const kv = useKV()
|
||||||
const command = useCommandPalette()
|
|
||||||
const keymap = useOpencodeKeymap()
|
const keymap = useOpencodeKeymap()
|
||||||
const event = useEvent()
|
const event = useEvent()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
@@ -446,12 +450,12 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
const appCommands = createMemo(() =>
|
const appCommands = createMemo(() =>
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
name: "command.palette.show",
|
name: COMMAND_PALETTE_COMMAND,
|
||||||
title: "Show command palette",
|
title: "Show command palette",
|
||||||
category: "System",
|
category: "System",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
run: () => {
|
run: () => {
|
||||||
command.show()
|
dialog.replace(() => <CommandPaletteDialog />)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -801,14 +805,13 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
useBindings(() => ({
|
useBindings(() => ({
|
||||||
enabled: command.matcher,
|
mode: OPENCODE_BASE_MODE,
|
||||||
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
|
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
useBindings(() => ({
|
useBindings(() => ({
|
||||||
|
mode: OPENCODE_BASE_MODE,
|
||||||
enabled: () => {
|
enabled: () => {
|
||||||
const ok = command.matcher.get()
|
|
||||||
if (!ok) return false
|
|
||||||
const current = promptRef.current
|
const current = promptRef.current
|
||||||
if (!current?.focused) return true
|
if (!current?.focused) return true
|
||||||
return current.current.input === ""
|
return current.current.input === ""
|
||||||
@@ -817,7 +820,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
event.on(TuiEvent.CommandExecute.type, (evt) => {
|
event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||||
command.run(evt.properties.command)
|
keymap.dispatchCommand(evt.properties.command)
|
||||||
})
|
})
|
||||||
|
|
||||||
event.on(TuiEvent.ToastShow.type, (evt) => {
|
event.on(TuiEvent.ToastShow.type, (evt) => {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { createMemo } from "solid-js"
|
||||||
|
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
|
||||||
|
import { type DialogContext } from "@tui/ui/dialog"
|
||||||
|
import {
|
||||||
|
COMMAND_PALETTE_COMMAND,
|
||||||
|
formatKeyBindings,
|
||||||
|
type OpenTuiKeymap,
|
||||||
|
useKeymapSelector,
|
||||||
|
useOpencodeKeymap,
|
||||||
|
} from "../keymap"
|
||||||
|
import { useTuiConfig } from "../context/tui-config"
|
||||||
|
|
||||||
|
type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number]
|
||||||
|
|
||||||
|
function isVisiblePaletteCommand(command: PaletteCommandEntry["command"]) {
|
||||||
|
return command.hidden !== true && command.name !== COMMAND_PALETTE_COMMAND
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSuggestedPaletteCommand(entry: PaletteCommandEntry) {
|
||||||
|
const suggested = entry.command.suggested
|
||||||
|
if (typeof suggested === "boolean") return suggested
|
||||||
|
if (typeof suggested === "function") return suggested() === true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandPaletteDialog() {
|
||||||
|
const config = useTuiConfig()
|
||||||
|
const keymap = useOpencodeKeymap()
|
||||||
|
const entries = useKeymapSelector((keymap: OpenTuiKeymap) => {
|
||||||
|
const query = {
|
||||||
|
namespace: "palette",
|
||||||
|
}
|
||||||
|
const reachable = keymap.getCommandEntries({
|
||||||
|
...query,
|
||||||
|
visibility: "reachable",
|
||||||
|
filter: isVisiblePaletteCommand,
|
||||||
|
})
|
||||||
|
const registeredBindings = keymap.getCommandBindings({
|
||||||
|
visibility: "registered",
|
||||||
|
commands: reachable.map((entry) => entry.command.name),
|
||||||
|
})
|
||||||
|
|
||||||
|
return reachable.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
bindings: registeredBindings.get(entry.command.name) ?? entry.bindings,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
const options = createMemo(() =>
|
||||||
|
entries().map((entry) => ({
|
||||||
|
title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name,
|
||||||
|
description: typeof entry.command.desc === "string" ? entry.command.desc : undefined,
|
||||||
|
category: typeof entry.command.category === "string" ? entry.command.category : undefined,
|
||||||
|
footer: formatKeyBindings(entry.bindings, config),
|
||||||
|
value: entry.command.name,
|
||||||
|
suggested: isSuggestedPaletteCommand(entry),
|
||||||
|
onSelect: (dialog: DialogContext) => {
|
||||||
|
dialog.clear()
|
||||||
|
keymap.dispatchCommand(entry.command.name)
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
let ref: DialogSelectRef<string>
|
||||||
|
const list = () => {
|
||||||
|
if (ref?.filter) return options()
|
||||||
|
return [
|
||||||
|
...options()
|
||||||
|
.filter((option) => option.suggested)
|
||||||
|
.map((option) => ({
|
||||||
|
...option,
|
||||||
|
value: `suggested:${option.value}`,
|
||||||
|
category: "Suggested",
|
||||||
|
})),
|
||||||
|
...options(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DialogSelect ref={(value) => (ref = value)} title="Commands" options={list()} />
|
||||||
|
}
|
||||||
@@ -13,12 +13,11 @@ import { getScrollAcceleration } from "../../util/scroll"
|
|||||||
import { useTuiConfig } from "../../context/tui-config"
|
import { useTuiConfig } from "../../context/tui-config"
|
||||||
import { useTheme, selectedForeground } from "@tui/context/theme"
|
import { useTheme, selectedForeground } from "@tui/context/theme"
|
||||||
import { SplitBorder } from "@tui/component/border"
|
import { SplitBorder } from "@tui/component/border"
|
||||||
import { useCommandPalette } from "../../context/command-palette"
|
|
||||||
import { useTerminalDimensions } from "@opentui/solid"
|
import { useTerminalDimensions } from "@opentui/solid"
|
||||||
import { Locale } from "@/util/locale"
|
import { Locale } from "@/util/locale"
|
||||||
import type { PromptInfo } from "./history"
|
import type { PromptInfo } from "./history"
|
||||||
import { useFrecency } from "./frecency"
|
import { useFrecency } from "./frecency"
|
||||||
import { useBindings } from "../../keymap"
|
import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap"
|
||||||
import { Reference } from "@/reference/reference"
|
import { Reference } from "@/reference/reference"
|
||||||
import { ConfigReference } from "@/config/reference"
|
import { ConfigReference } from "@/config/reference"
|
||||||
import { displayCharAt, mentionTriggerIndex } from "@/cli/cmd/prompt-display"
|
import { displayCharAt, mentionTriggerIndex } from "@/cli/cmd/prompt-display"
|
||||||
@@ -87,7 +86,8 @@ export function Autocomplete(props: {
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const project = useProject()
|
const project = useProject()
|
||||||
const command = useCommandPalette()
|
const slashes = useCommandSlashes()
|
||||||
|
const modeStack = useOpencodeModeStack()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const frecency = useFrecency()
|
const frecency = useFrecency()
|
||||||
@@ -101,6 +101,12 @@ export function Autocomplete(props: {
|
|||||||
|
|
||||||
const [positionTick, setPositionTick] = createSignal(0)
|
const [positionTick, setPositionTick] = createSignal(0)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!store.visible) return
|
||||||
|
const popMode = modeStack.push("autocomplete")
|
||||||
|
onCleanup(popMode)
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (store.visible) {
|
if (store.visible) {
|
||||||
let lastPos = { x: 0, y: 0, width: 0 }
|
let lastPos = { x: 0, y: 0, width: 0 }
|
||||||
@@ -367,7 +373,6 @@ export function Autocomplete(props: {
|
|||||||
const { filename, part } = createFilePart(item, lineRange)
|
const { filename, part } = createFilePart(item, lineRange)
|
||||||
const index = store.visible === "@" ? store.index : props.input().cursorOffset
|
const index = store.visible === "@" ? store.index : props.input().cursorOffset
|
||||||
|
|
||||||
command.suspend(false)
|
|
||||||
setStore("visible", false)
|
setStore("visible", false)
|
||||||
setStore("index", index)
|
setStore("index", index)
|
||||||
insertPart(filename, part)
|
insertPart(filename, part)
|
||||||
@@ -539,7 +544,7 @@ export function Autocomplete(props: {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const commands = createMemo((): AutocompleteOption[] => {
|
const commands = createMemo((): AutocompleteOption[] => {
|
||||||
const results: AutocompleteOption[] = [...command.slashes()]
|
const results: AutocompleteOption[] = [...slashes()]
|
||||||
|
|
||||||
for (const serverCommand of sync.data.command) {
|
for (const serverCommand of sync.data.command) {
|
||||||
if (serverCommand.source === "skill") continue
|
if (serverCommand.source === "skill") continue
|
||||||
@@ -730,7 +735,6 @@ export function Autocomplete(props: {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
function show(mode: "@" | "/") {
|
function show(mode: "@" | "/") {
|
||||||
command.suspend(true)
|
|
||||||
setStore({
|
setStore({
|
||||||
visible: mode,
|
visible: mode,
|
||||||
index: props.input().cursorOffset,
|
index: props.input().cursorOffset,
|
||||||
@@ -747,7 +751,6 @@ export function Autocomplete(props: {
|
|||||||
draft.input = props.input().plainText
|
draft.input = props.input().plainText
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
command.suspend(false)
|
|
||||||
setStore("visible", false)
|
setStore("visible", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,8 +59,7 @@ import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
|
|||||||
import { useArgs } from "@tui/context/args"
|
import { useArgs } from "@tui/context/args"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { type WorkspaceStatus } from "../workspace-label"
|
import { type WorkspaceStatus } from "../workspace-label"
|
||||||
import { useCommandPalette } from "../../context/command-palette"
|
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap"
|
||||||
import { useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap"
|
|
||||||
import { useTuiConfig } from "../../context/tui-config"
|
import { useTuiConfig } from "../../context/tui-config"
|
||||||
|
|
||||||
export type PromptProps = {
|
export type PromptProps = {
|
||||||
@@ -152,7 +151,6 @@ export function Prompt(props: PromptProps) {
|
|||||||
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
|
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
|
||||||
const history = usePromptHistory()
|
const history = usePromptHistory()
|
||||||
const stash = usePromptStash()
|
const stash = usePromptStash()
|
||||||
const command = useCommandPalette()
|
|
||||||
const keymap = useOpencodeKeymap()
|
const keymap = useOpencodeKeymap()
|
||||||
const agentShortcut = useCommandShortcut("agent.cycle")
|
const agentShortcut = useCommandShortcut("agent.cycle")
|
||||||
const paletteShortcut = useCommandShortcut("command.palette.show")
|
const paletteShortcut = useCommandShortcut("command.palette.show")
|
||||||
@@ -629,7 +627,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
useBindings(() => ({
|
useBindings(() => ({
|
||||||
enabled: command.matcher,
|
mode: OPENCODE_BASE_MODE,
|
||||||
bindings: tuiConfig.keybinds.gather("prompt.palette", [
|
bindings: tuiConfig.keybinds.gather("prompt.palette", [
|
||||||
"prompt.submit",
|
"prompt.submit",
|
||||||
"prompt.editor",
|
"prompt.editor",
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
import { createContext, createMemo, createSignal, useContext, type Accessor, type ParentProps } from "solid-js"
|
|
||||||
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
|
|
||||||
import { useDialog, type DialogContext } from "@tui/ui/dialog"
|
|
||||||
import {
|
|
||||||
formatKeyBindings,
|
|
||||||
reactiveMatcherFromSignal,
|
|
||||||
type OpenTuiKeymap,
|
|
||||||
useKeymapSelector,
|
|
||||||
useOpencodeKeymap,
|
|
||||||
} from "../keymap"
|
|
||||||
import { useTuiConfig } from "./tui-config"
|
|
||||||
|
|
||||||
type SlashEntry = {
|
|
||||||
display: string
|
|
||||||
description?: string
|
|
||||||
aliases?: string[]
|
|
||||||
onSelect: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
type CommandPaletteContext = {
|
|
||||||
run(command: string): void
|
|
||||||
show(): void
|
|
||||||
slashes: Accessor<readonly SlashEntry[]>
|
|
||||||
suspend(enabled: boolean): void
|
|
||||||
readonly suspended: boolean
|
|
||||||
matcher: ReturnType<typeof reactiveMatcherFromSignal>
|
|
||||||
}
|
|
||||||
|
|
||||||
const COMMAND_PALETTE_DIALOG = "command.palette.show"
|
|
||||||
const ctx = createContext<CommandPaletteContext>()
|
|
||||||
type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number]
|
|
||||||
|
|
||||||
function isVisiblePaletteCommand(entry: PaletteCommandEntry) {
|
|
||||||
return entry.command.hidden !== true && entry.command.name !== COMMAND_PALETTE_DIALOG
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSuggestedPaletteCommand(entry: PaletteCommandEntry) {
|
|
||||||
const suggested = entry.command.suggested
|
|
||||||
if (typeof suggested === "boolean") return suggested
|
|
||||||
if (typeof suggested === "function") return suggested() === true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CommandPaletteProvider(props: ParentProps) {
|
|
||||||
const dialog = useDialog()
|
|
||||||
const keymap = useOpencodeKeymap()
|
|
||||||
const [suspendCount, setSuspendCount] = createSignal(0)
|
|
||||||
const entries = useKeymapSelector((keymap: OpenTuiKeymap) =>
|
|
||||||
keymap
|
|
||||||
.getCommandEntries({
|
|
||||||
visibility: "reachable",
|
|
||||||
namespace: "palette",
|
|
||||||
})
|
|
||||||
.filter(isVisiblePaletteCommand),
|
|
||||||
)
|
|
||||||
|
|
||||||
const run = (command: string) => {
|
|
||||||
keymap.dispatchCommand(command)
|
|
||||||
}
|
|
||||||
|
|
||||||
const slashes = createMemo<SlashEntry[]>(() =>
|
|
||||||
entries().flatMap((entry) => {
|
|
||||||
const slashName = entry.command.slashName
|
|
||||||
if (typeof slashName !== "string" || !slashName) return []
|
|
||||||
const slashAliases = entry.command.slashAliases
|
|
||||||
return {
|
|
||||||
display: `/${slashName}`,
|
|
||||||
description:
|
|
||||||
typeof entry.command.desc === "string"
|
|
||||||
? entry.command.desc
|
|
||||||
: typeof entry.command.title === "string"
|
|
||||||
? entry.command.title
|
|
||||||
: undefined,
|
|
||||||
aliases: Array.isArray(slashAliases)
|
|
||||||
? slashAliases.filter((alias): alias is string => typeof alias === "string").map((alias) => `/${alias}`)
|
|
||||||
: undefined,
|
|
||||||
onSelect: () => run(entry.command.name),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const value: CommandPaletteContext = {
|
|
||||||
run,
|
|
||||||
show() {
|
|
||||||
dialog.replace(() => <CommandPaletteDialog run={run} />)
|
|
||||||
},
|
|
||||||
slashes,
|
|
||||||
suspend(enabled: boolean) {
|
|
||||||
setSuspendCount((count) => Math.max(0, count + (enabled ? 1 : -1)))
|
|
||||||
},
|
|
||||||
get suspended() {
|
|
||||||
return suspendCount() > 0 || dialog.stack.length > 0
|
|
||||||
},
|
|
||||||
matcher: reactiveMatcherFromSignal(() => suspendCount() === 0 && dialog.stack.length === 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCommandPalette() {
|
|
||||||
const value = useContext(ctx)
|
|
||||||
if (!value) throw new Error("CommandPalette context must be used within a CommandPaletteProvider")
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandPaletteDialog(props: { run(command: string): void }) {
|
|
||||||
const config = useTuiConfig()
|
|
||||||
const entries = useKeymapSelector((keymap: OpenTuiKeymap) => {
|
|
||||||
const query = {
|
|
||||||
namespace: "palette",
|
|
||||||
}
|
|
||||||
const reachable = keymap
|
|
||||||
.getCommandEntries({
|
|
||||||
...query,
|
|
||||||
visibility: "reachable",
|
|
||||||
})
|
|
||||||
.filter(isVisiblePaletteCommand)
|
|
||||||
const registeredBindings = keymap.getCommandBindings({
|
|
||||||
visibility: "registered",
|
|
||||||
commands: reachable.map((entry) => entry.command.name),
|
|
||||||
})
|
|
||||||
|
|
||||||
return reachable.map((entry) => ({
|
|
||||||
...entry,
|
|
||||||
bindings: registeredBindings.get(entry.command.name) ?? entry.bindings,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
const options = createMemo(() =>
|
|
||||||
entries().map((entry) => ({
|
|
||||||
title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name,
|
|
||||||
description: typeof entry.command.desc === "string" ? entry.command.desc : undefined,
|
|
||||||
category: typeof entry.command.category === "string" ? entry.command.category : undefined,
|
|
||||||
footer: formatKeyBindings(entry.bindings, config),
|
|
||||||
value: entry.command.name,
|
|
||||||
suggested: isSuggestedPaletteCommand(entry),
|
|
||||||
onSelect: (dialog: DialogContext) => {
|
|
||||||
dialog.clear()
|
|
||||||
props.run(entry.command.name)
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
let ref: DialogSelectRef<string>
|
|
||||||
const list = () => {
|
|
||||||
if (ref?.filter) return options()
|
|
||||||
return [
|
|
||||||
...options()
|
|
||||||
.filter((option) => option.suggested)
|
|
||||||
.map((option) => ({
|
|
||||||
...option,
|
|
||||||
value: `suggested:${option.value}`,
|
|
||||||
category: "Suggested",
|
|
||||||
})),
|
|
||||||
...options(),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
return <DialogSelect ref={(value) => (ref = value)} title="Commands" options={list()} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCommandSlashes(): Accessor<readonly SlashEntry[]> {
|
|
||||||
return useCommandPalette().slashes
|
|
||||||
}
|
|
||||||
@@ -5,26 +5,97 @@ import {
|
|||||||
formatCommandBindings as formatCommandBindingsExtra,
|
formatCommandBindings as formatCommandBindingsExtra,
|
||||||
formatKeySequence as formatKeySequenceExtra,
|
formatKeySequence as formatKeySequenceExtra,
|
||||||
} from "@opentui/keymap/extras"
|
} from "@opentui/keymap/extras"
|
||||||
import {
|
import { KeymapProvider, useKeymap, useKeymapSelector, useBindings } from "@opentui/keymap/solid"
|
||||||
KeymapProvider,
|
import { createMemo, type Accessor } from "solid-js"
|
||||||
reactiveMatcherFromSignal,
|
|
||||||
useKeymap,
|
|
||||||
useKeymapSelector,
|
|
||||||
useBindings,
|
|
||||||
} from "@opentui/keymap/solid"
|
|
||||||
import type { Accessor } from "solid-js"
|
|
||||||
import type { TuiConfig } from "./config/tui"
|
import type { TuiConfig } from "./config/tui"
|
||||||
import { useTuiConfig } from "./context/tui-config"
|
import { useTuiConfig } from "./context/tui-config"
|
||||||
import { TuiKeybind } from "./config/keybind"
|
import { TuiKeybind } from "./config/keybind"
|
||||||
|
|
||||||
export const LEADER_TOKEN = "leader"
|
export const LEADER_TOKEN = "leader"
|
||||||
|
export const OPENCODE_BASE_MODE = "base"
|
||||||
|
export const COMMAND_PALETTE_COMMAND = "command.palette.show"
|
||||||
|
|
||||||
|
const OPENCODE_MODE_KEY = "opencode.mode"
|
||||||
|
|
||||||
export const OpencodeKeymapProvider = KeymapProvider
|
export const OpencodeKeymapProvider = KeymapProvider
|
||||||
export const useOpencodeKeymap = useKeymap
|
export const useOpencodeKeymap = useKeymap
|
||||||
|
|
||||||
export { reactiveMatcherFromSignal, useBindings, useKeymapSelector }
|
export { useBindings, useKeymapSelector }
|
||||||
|
|
||||||
export type OpenTuiKeymap = ReturnType<typeof useKeymap>
|
export type OpenTuiKeymap = ReturnType<typeof useKeymap>
|
||||||
|
type OpencodeModeStack = ReturnType<typeof createOpencodeModeStack>
|
||||||
|
type CommandSlashEntry = {
|
||||||
|
display: string
|
||||||
|
description?: string
|
||||||
|
aliases?: string[]
|
||||||
|
onSelect: () => void
|
||||||
|
}
|
||||||
|
type Command = ReturnType<OpenTuiKeymap["getCommands"]>[number]
|
||||||
|
|
||||||
|
const modeStacks = new WeakMap<OpenTuiKeymap, OpencodeModeStack>()
|
||||||
|
|
||||||
|
function isVisiblePaletteCommand(command: Command) {
|
||||||
|
return command.hidden !== true && command.name !== COMMAND_PALETTE_COMMAND
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOpencodeModeStack(keymap: OpenTuiKeymap) {
|
||||||
|
keymap.setData(OPENCODE_MODE_KEY, OPENCODE_BASE_MODE)
|
||||||
|
|
||||||
|
const offFields = keymap.registerLayerFields({
|
||||||
|
mode(value, ctx) {
|
||||||
|
ctx.require(OPENCODE_MODE_KEY, value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const stack: { id: symbol; mode: string }[] = []
|
||||||
|
let disposed = false
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
keymap.setData(OPENCODE_MODE_KEY, stack.at(-1)?.mode ?? OPENCODE_BASE_MODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stackApi = {
|
||||||
|
current() {
|
||||||
|
return stack.at(-1)?.mode ?? OPENCODE_BASE_MODE
|
||||||
|
},
|
||||||
|
push(mode: string) {
|
||||||
|
if (disposed) return () => {}
|
||||||
|
const id = Symbol(mode)
|
||||||
|
let active = true
|
||||||
|
stack.push({ id, mode })
|
||||||
|
update()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (!active) return
|
||||||
|
active = false
|
||||||
|
const index = stack.findIndex((item) => item.id === id)
|
||||||
|
if (index !== -1) stack.splice(index, 1)
|
||||||
|
update()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
if (disposed) return
|
||||||
|
disposed = true
|
||||||
|
stack.length = 0
|
||||||
|
offFields()
|
||||||
|
keymap.setData(OPENCODE_MODE_KEY, undefined)
|
||||||
|
modeStacks.delete(keymap)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
modeStacks.set(keymap, stackApi)
|
||||||
|
return stackApi
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpencodeModeStack() {
|
||||||
|
return getOpencodeModeStack(useOpencodeKeymap())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOpencodeModeStack(keymap: OpenTuiKeymap) {
|
||||||
|
const value = modeStacks.get(keymap)
|
||||||
|
if (!value) throw new Error("Opencode mode stack is not registered for this keymap")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
const KEY_ALIASES = {
|
const KEY_ALIASES = {
|
||||||
enter: "return",
|
enter: "return",
|
||||||
@@ -127,6 +198,7 @@ export function registerOpencodeKeymap(
|
|||||||
renderer: CliRenderer,
|
renderer: CliRenderer,
|
||||||
config: Pick<TuiConfig.Resolved, "keybinds" | "leader_timeout">,
|
config: Pick<TuiConfig.Resolved, "keybinds" | "leader_timeout">,
|
||||||
) {
|
) {
|
||||||
|
const modeStack = createOpencodeModeStack(keymap)
|
||||||
const offCommaBindings = addons.registerCommaBindings(keymap)
|
const offCommaBindings = addons.registerCommaBindings(keymap)
|
||||||
const offAliasExpander = registerKeyAliases(keymap)
|
const offAliasExpander = registerKeyAliases(keymap)
|
||||||
const offBaseLayout = addons.registerBaseLayoutFallback(keymap)
|
const offBaseLayout = addons.registerBaseLayoutFallback(keymap)
|
||||||
@@ -150,6 +222,7 @@ export function registerOpencodeKeymap(
|
|||||||
offAliasExpander()
|
offAliasExpander()
|
||||||
offBaseLayout()
|
offBaseLayout()
|
||||||
offCommaBindings()
|
offCommaBindings()
|
||||||
|
modeStack.dispose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,3 +239,35 @@ export function useCommandShortcut(command: string): Accessor<string> {
|
|||||||
export function useLeaderActive(): Accessor<boolean> {
|
export function useLeaderActive(): Accessor<boolean> {
|
||||||
return useKeymapSelector((keymap: OpenTuiKeymap) => keymap.getPendingSequence()[0]?.tokenName === LEADER_TOKEN)
|
return useKeymapSelector((keymap: OpenTuiKeymap) => keymap.getPendingSequence()[0]?.tokenName === LEADER_TOKEN)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCommandSlashes(): Accessor<readonly CommandSlashEntry[]> {
|
||||||
|
const keymap = useOpencodeKeymap()
|
||||||
|
const entries = useKeymapSelector((keymap: OpenTuiKeymap) =>
|
||||||
|
keymap.getCommandEntries({
|
||||||
|
visibility: "reachable",
|
||||||
|
namespace: "palette",
|
||||||
|
filter: isVisiblePaletteCommand,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return createMemo<CommandSlashEntry[]>(() =>
|
||||||
|
entries().flatMap((entry) => {
|
||||||
|
const slashName = entry.command.slashName
|
||||||
|
if (typeof slashName !== "string" || !slashName) return []
|
||||||
|
const slashAliases = entry.command.slashAliases
|
||||||
|
return {
|
||||||
|
display: `/${slashName}`,
|
||||||
|
description:
|
||||||
|
typeof entry.command.desc === "string"
|
||||||
|
? entry.command.desc
|
||||||
|
: typeof entry.command.title === "string"
|
||||||
|
? entry.command.title
|
||||||
|
: undefined,
|
||||||
|
aliases: Array.isArray(slashAliases)
|
||||||
|
? slashAliases.filter((alias): alias is string => typeof alias === "string").map((alias) => `/${alias}`)
|
||||||
|
: undefined,
|
||||||
|
onSelect: () => keymap.dispatchCommand(entry.command.name),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -219,6 +219,14 @@ export function createTuiApi(input: Input): TuiPluginApi {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
keymap: input.keymap,
|
keymap: input.keymap,
|
||||||
|
mode: {
|
||||||
|
current() {
|
||||||
|
return Keymap.getOpencodeModeStack(input.keymap).current()
|
||||||
|
},
|
||||||
|
push(mode) {
|
||||||
|
return Keymap.getOpencodeModeStack(input.keymap).push(mode)
|
||||||
|
},
|
||||||
|
},
|
||||||
route: {
|
route: {
|
||||||
register(list) {
|
register(list) {
|
||||||
return routeRegister(input.routes, list, input.bump)
|
return routeRegister(input.routes, list, input.bump)
|
||||||
|
|||||||
@@ -192,6 +192,17 @@ function createScopedAttention(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createScopedMode(mode: TuiPluginApi["mode"], scope: PluginScope): TuiPluginApi["mode"] {
|
||||||
|
return {
|
||||||
|
current() {
|
||||||
|
return mode.current()
|
||||||
|
},
|
||||||
|
push(value) {
|
||||||
|
return scope.track(mode.push(value))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
|
type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
|
||||||
|
|
||||||
function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
|
function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
|
||||||
@@ -616,6 +627,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
|
|||||||
command: createCommandShim(keymap, api.ui.dialog, api.tuiConfig.keybinds),
|
command: createCommandShim(keymap, api.ui.dialog, api.tuiConfig.keybinds),
|
||||||
keys: api.keys,
|
keys: api.keys,
|
||||||
keymap,
|
keymap,
|
||||||
|
mode: createScopedMode(api.mode, scope),
|
||||||
route,
|
route,
|
||||||
ui: api.ui,
|
ui: api.ui,
|
||||||
tuiConfig: api.tuiConfig,
|
tuiConfig: api.tuiConfig,
|
||||||
|
|||||||
@@ -89,8 +89,7 @@ import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
|
|||||||
import { DialogRetryAction } from "../../component/dialog-retry-action"
|
import { DialogRetryAction } from "../../component/dialog-retry-action"
|
||||||
import { SessionRetry } from "@/session/retry"
|
import { SessionRetry } from "@/session/retry"
|
||||||
import { getRevertDiffFiles } from "../../util/revert-diff"
|
import { getRevertDiffFiles } from "../../util/revert-diff"
|
||||||
import { useCommandPalette } from "../../context/command-palette"
|
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap"
|
||||||
import { useBindings, useCommandShortcut } from "../../keymap"
|
|
||||||
import { PathFormatterProvider, usePathFormatter } from "../../context/path-format"
|
import { PathFormatterProvider, usePathFormatter } from "../../context/path-format"
|
||||||
|
|
||||||
addDefaultParsers(parsers.parsers)
|
addDefaultParsers(parsers.parsers)
|
||||||
@@ -311,7 +310,7 @@ export function Session() {
|
|||||||
seeded = true
|
seeded = true
|
||||||
r.set(route.prompt)
|
r.set(route.prompt)
|
||||||
}
|
}
|
||||||
const command = useCommandPalette()
|
const keymap = useOpencodeKeymap()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
|
|
||||||
@@ -1056,7 +1055,7 @@ export function Session() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
useBindings(() => ({
|
useBindings(() => ({
|
||||||
enabled: command.matcher,
|
mode: OPENCODE_BASE_MODE,
|
||||||
bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands),
|
bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -1133,7 +1132,6 @@ export function Session() {
|
|||||||
<Switch>
|
<Switch>
|
||||||
<Match when={message.id === revert()?.messageID}>
|
<Match when={message.id === revert()?.messageID}>
|
||||||
{(function () {
|
{(function () {
|
||||||
const command = useCommandPalette()
|
|
||||||
const redoShortcut = useCommandShortcut("session.redo")
|
const redoShortcut = useCommandShortcut("session.redo")
|
||||||
const [hover, setHover] = createSignal(false)
|
const [hover, setHover] = createSignal(false)
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -1145,7 +1143,7 @@ export function Session() {
|
|||||||
"Are you sure you want to restore the reverted messages?",
|
"Are you sure you want to restore the reverted messages?",
|
||||||
)
|
)
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
command.run("session.redo")
|
keymap.dispatchCommand("session.redo")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,9 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
|||||||
import { Locale } from "@/util/locale"
|
import { Locale } from "@/util/locale"
|
||||||
import { ShellID } from "@/tool/shell/id"
|
import { ShellID } from "@/tool/shell/id"
|
||||||
import { webSearchProviderLabel } from "@/tool/websearch"
|
import { webSearchProviderLabel } from "@/tool/websearch"
|
||||||
import { useDialog } from "../../ui/dialog"
|
|
||||||
import { getScrollAcceleration } from "../../util/scroll"
|
import { getScrollAcceleration } from "../../util/scroll"
|
||||||
import { useTuiConfig } from "../../context/tui-config"
|
import { useTuiConfig } from "../../context/tui-config"
|
||||||
import { useBindings, useCommandShortcut } from "../../keymap"
|
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
|
||||||
import { usePathFormatter } from "../../context/path-format"
|
import { usePathFormatter } from "../../context/path-format"
|
||||||
|
|
||||||
type PermissionStage = "permission" | "always" | "reject"
|
type PermissionStage = "permission" | "always" | "reject"
|
||||||
@@ -448,9 +447,8 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
|
|||||||
const tuiConfig = useTuiConfig()
|
const tuiConfig = useTuiConfig()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const narrow = createMemo(() => dimensions().width < 80)
|
const narrow = createMemo(() => dimensions().width < 80)
|
||||||
const dialog = useDialog()
|
|
||||||
useBindings(() => ({
|
useBindings(() => ({
|
||||||
enabled: dialog.stack.length === 0,
|
mode: OPENCODE_BASE_MODE,
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "app.exit",
|
name: "app.exit",
|
||||||
@@ -542,11 +540,10 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
expanded: false,
|
expanded: false,
|
||||||
})
|
})
|
||||||
const narrow = createMemo(() => dimensions().width < 80)
|
const narrow = createMemo(() => dimensions().width < 80)
|
||||||
const dialog = useDialog()
|
|
||||||
const fullscreenHint = useCommandShortcut("permission.prompt.fullscreen")
|
const fullscreenHint = useCommandShortcut("permission.prompt.fullscreen")
|
||||||
|
|
||||||
useBindings(() => ({
|
useBindings(() => ({
|
||||||
enabled: dialog.stack.length === 0,
|
mode: OPENCODE_BASE_MODE,
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "app.exit",
|
name: "app.exit",
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import { selectedForeground, tint, useTheme } from "../../context/theme"
|
|||||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
import { useSDK } from "../../context/sdk"
|
import { useSDK } from "../../context/sdk"
|
||||||
import { SplitBorder } from "../../component/border"
|
import { SplitBorder } from "../../component/border"
|
||||||
import { useDialog } from "../../ui/dialog"
|
|
||||||
import { useTuiConfig } from "../../context/tui-config"
|
import { useTuiConfig } from "../../context/tui-config"
|
||||||
import { useBindings } from "../../keymap"
|
import { OPENCODE_BASE_MODE, useBindings } from "../../keymap"
|
||||||
|
|
||||||
export function QuestionPrompt(props: { request: QuestionRequest }) {
|
export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
@@ -120,9 +119,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
|||||||
pick(opt.label)
|
pick(opt.label)
|
||||||
}
|
}
|
||||||
|
|
||||||
const dialog = useDialog()
|
|
||||||
|
|
||||||
useBindings(() => ({
|
useBindings(() => ({
|
||||||
|
mode: OPENCODE_BASE_MODE,
|
||||||
enabled: store.editing && !confirm(),
|
enabled: store.editing && !confirm(),
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
@@ -203,7 +201,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
|||||||
const max = Math.min(total, 9)
|
const max = Math.min(total, 9)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: dialog.stack.length === 0 && !store.editing,
|
mode: OPENCODE_BASE_MODE,
|
||||||
|
enabled: !store.editing,
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "app.exit",
|
name: "app.exit",
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import { SplitBorder } from "@tui/component/border"
|
|||||||
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
|
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { Locale } from "@/util/locale"
|
import { Locale } from "@/util/locale"
|
||||||
import { useTerminalDimensions } from "@opentui/solid"
|
import { useTerminalDimensions } from "@opentui/solid"
|
||||||
import { useCommandPalette } from "../../context/command-palette"
|
import { useCommandShortcut, useOpencodeKeymap } from "../../keymap"
|
||||||
import { useCommandShortcut } from "../../keymap"
|
|
||||||
|
|
||||||
export function SubagentFooter() {
|
export function SubagentFooter() {
|
||||||
const route = useRouteData("session")
|
const route = useRouteData("session")
|
||||||
@@ -56,7 +55,7 @@ export function SubagentFooter() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const command = useCommandPalette()
|
const keymap = useOpencodeKeymap()
|
||||||
const parentShortcut = useCommandShortcut("session.parent")
|
const parentShortcut = useCommandShortcut("session.parent")
|
||||||
const previousShortcut = useCommandShortcut("session.child.previous")
|
const previousShortcut = useCommandShortcut("session.child.previous")
|
||||||
const nextShortcut = useCommandShortcut("session.child.next")
|
const nextShortcut = useCommandShortcut("session.child.next")
|
||||||
@@ -98,7 +97,7 @@ export function SubagentFooter() {
|
|||||||
<box
|
<box
|
||||||
onMouseOver={() => setHover("parent")}
|
onMouseOver={() => setHover("parent")}
|
||||||
onMouseOut={() => setHover(null)}
|
onMouseOut={() => setHover(null)}
|
||||||
onMouseUp={() => command.run("session.parent")}
|
onMouseUp={() => keymap.dispatchCommand("session.parent")}
|
||||||
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
|
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
|
||||||
>
|
>
|
||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
@@ -108,7 +107,7 @@ export function SubagentFooter() {
|
|||||||
<box
|
<box
|
||||||
onMouseOver={() => setHover("prev")}
|
onMouseOver={() => setHover("prev")}
|
||||||
onMouseOut={() => setHover(null)}
|
onMouseOut={() => setHover(null)}
|
||||||
onMouseUp={() => command.run("session.child.previous")}
|
onMouseUp={() => keymap.dispatchCommand("session.child.previous")}
|
||||||
backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
|
backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
|
||||||
>
|
>
|
||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
@@ -118,7 +117,7 @@ export function SubagentFooter() {
|
|||||||
<box
|
<box
|
||||||
onMouseOver={() => setHover("next")}
|
onMouseOver={() => setHover("next")}
|
||||||
onMouseOut={() => setHover(null)}
|
onMouseOut={() => setHover(null)}
|
||||||
onMouseUp={() => command.run("session.child.next")}
|
onMouseUp={() => keymap.dispatchCommand("session.child.next")}
|
||||||
backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
|
backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
|
||||||
>
|
>
|
||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||||
import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js"
|
import { batch, createContext, createEffect, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js"
|
||||||
import { useTheme } from "@tui/context/theme"
|
import { useTheme } from "@tui/context/theme"
|
||||||
import { MouseButton, Renderable, RGBA } from "@opentui/core"
|
import { MouseButton, Renderable, RGBA } from "@opentui/core"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useToast } from "./toast"
|
import { useToast } from "./toast"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import * as Selection from "@tui/util/selection"
|
import * as Selection from "@tui/util/selection"
|
||||||
import { useBindings } from "../keymap"
|
import { useBindings, useOpencodeModeStack } from "../keymap"
|
||||||
|
|
||||||
export function Dialog(
|
export function Dialog(
|
||||||
props: ParentProps<{
|
props: ParentProps<{
|
||||||
@@ -73,6 +73,13 @@ function init() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
|
const modeStack = useOpencodeModeStack()
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (store.stack.length === 0) return
|
||||||
|
const popMode = modeStack.push("modal")
|
||||||
|
onCleanup(popMode)
|
||||||
|
})
|
||||||
|
|
||||||
let focus: Renderable | null
|
let focus: Renderable | null
|
||||||
function refocus() {
|
function refocus() {
|
||||||
|
|||||||
@@ -145,9 +145,12 @@ export const layer: Layer.Layer<
|
|||||||
function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
|
function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
|
||||||
// Plugin tools still expose Zod args publicly; keep that compatibility
|
// Plugin tools still expose Zod args publicly; keep that compatibility
|
||||||
// boxed at the registry boundary and give the LLM the original JSON Schema.
|
// boxed at the registry boundary and give the LLM the original JSON Schema.
|
||||||
const entries = Object.entries(def.args)
|
// Normalize missing args to `{}` once — pre-1.14.49 the code was
|
||||||
|
// `z.object(def.args)` and Zod silently tolerated undefined (#27451, #27630).
|
||||||
|
const args = def.args ?? {}
|
||||||
|
const entries = Object.entries(args)
|
||||||
const allZod = entries.every((entry) => isZodType(entry[1]))
|
const allZod = entries.every((entry) => isZodType(entry[1]))
|
||||||
const zodParams = allZod ? z.object(def.args) : undefined
|
const zodParams = allZod ? z.object(args) : undefined
|
||||||
const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries)
|
const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries)
|
||||||
const parameters = zodParams
|
const parameters = zodParams
|
||||||
? Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success)
|
? Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success)
|
||||||
|
|||||||
@@ -0,0 +1,623 @@
|
|||||||
|
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode acp --help 1`] = `
|
||||||
|
"opencode acp
|
||||||
|
|
||||||
|
start ACP (Agent Client Protocol) server
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]
|
||||||
|
--port port to listen on [number] [default: 0]
|
||||||
|
--hostname hostname to listen on [string] [default: "127.0.0.1"]
|
||||||
|
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0)
|
||||||
|
[boolean] [default: false]
|
||||||
|
--mdns-domain custom domain name for mDNS service (default: opencode.local)
|
||||||
|
[string] [default: "opencode.local"]
|
||||||
|
--cors additional domains to allow for CORS [array] [default: []]
|
||||||
|
--cwd working directory [string] [default: "<HOME>"]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp --help 1`] = `
|
||||||
|
"opencode mcp
|
||||||
|
|
||||||
|
manage MCP (Model Context Protocol) servers
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
opencode mcp add add an MCP server
|
||||||
|
opencode mcp list list MCP servers and their status [aliases: ls]
|
||||||
|
opencode mcp auth [name] authenticate with an OAuth-enabled MCP server
|
||||||
|
opencode mcp logout [name] remove OAuth credentials for an MCP server
|
||||||
|
opencode mcp debug <name> debug OAuth connection for an MCP server
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode attach --help 1`] = `
|
||||||
|
"opencode attach <url>
|
||||||
|
|
||||||
|
attach to a running opencode server
|
||||||
|
|
||||||
|
Positionals:
|
||||||
|
url http://localhost:4096 [string] [required]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]
|
||||||
|
--dir directory to run in [string]
|
||||||
|
-c, --continue continue the last session [boolean]
|
||||||
|
-s, --session session id to continue [string]
|
||||||
|
--fork fork the session when continuing (use with --continue or --session) [boolean]
|
||||||
|
-p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string]
|
||||||
|
-u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')[string]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode run --help 1`] = `
|
||||||
|
"opencode run [message..]
|
||||||
|
|
||||||
|
run opencode with a message
|
||||||
|
|
||||||
|
Positionals:
|
||||||
|
message message to send [array] [default: []]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]
|
||||||
|
--command the command to run, use message for args [string]
|
||||||
|
-c, --continue continue the last session [boolean]
|
||||||
|
-s, --session session id to continue [string]
|
||||||
|
--fork fork the session before continuing (requires --continue or
|
||||||
|
--session) [boolean]
|
||||||
|
--share share the session [boolean]
|
||||||
|
-m, --model model to use in the format of provider/model [string]
|
||||||
|
--agent agent to use [string]
|
||||||
|
--format format: default (formatted) or json (raw JSON events)
|
||||||
|
[string] [choices: "default", "json"] [default: "default"]
|
||||||
|
-f, --file file(s) to attach to message [array]
|
||||||
|
--title title for the session (uses truncated prompt if no value
|
||||||
|
provided) [string]
|
||||||
|
--attach attach to a running opencode server (e.g.,
|
||||||
|
http://localhost:4096) [string]
|
||||||
|
-p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD)
|
||||||
|
[string]
|
||||||
|
-u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or
|
||||||
|
'opencode') [string]
|
||||||
|
--dir directory to run in, path on remote server if attaching
|
||||||
|
[string]
|
||||||
|
--port port for the local server (defaults to random port if no value
|
||||||
|
provided) [number]
|
||||||
|
--variant model variant (provider-specific reasoning effort, e.g., high,
|
||||||
|
max, minimal) [string]
|
||||||
|
--thinking show thinking blocks [boolean]
|
||||||
|
--replay replay visible session history on interactive resume
|
||||||
|
[boolean] [default: false]
|
||||||
|
--replay-limit cap visible interactive replay to the newest N messages
|
||||||
|
[number]
|
||||||
|
-i, --interactive run in direct interactive split-footer mode
|
||||||
|
[boolean] [default: false]
|
||||||
|
--dangerously-skip-permissions auto-approve permissions that are not explicitly denied
|
||||||
|
(dangerous!) [boolean] [default: false]
|
||||||
|
--demo enable direct interactive demo slash commands; pass one as the
|
||||||
|
message to run it immediately [boolean] [default: false]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode debug --help 1`] = `
|
||||||
|
"opencode debug
|
||||||
|
|
||||||
|
debugging and troubleshooting tools
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
opencode debug config show resolved configuration
|
||||||
|
opencode debug lsp LSP debugging utilities
|
||||||
|
opencode debug rg ripgrep debugging utilities
|
||||||
|
opencode debug file file system debugging utilities
|
||||||
|
opencode debug scrap list all known projects
|
||||||
|
opencode debug skill list all available skills
|
||||||
|
opencode debug snapshot snapshot debugging utilities
|
||||||
|
opencode debug startup print startup timing
|
||||||
|
opencode debug agent <name> show agent configuration details
|
||||||
|
opencode debug v2 debug v2 catalog and built-in plugins
|
||||||
|
opencode debug info show debug information
|
||||||
|
opencode debug paths show global paths (data, config, cache, state)
|
||||||
|
opencode debug wait wait indefinitely (for debugging)
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode providers --help 1`] = `
|
||||||
|
"opencode providers
|
||||||
|
|
||||||
|
manage AI providers and credentials
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
opencode providers list list providers and credentials [aliases: ls]
|
||||||
|
opencode providers login [url] log in to a provider
|
||||||
|
opencode providers logout log out from a configured provider
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode agent --help 1`] = `
|
||||||
|
"opencode agent
|
||||||
|
|
||||||
|
manage agents
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
opencode agent create create a new agent
|
||||||
|
opencode agent list list all available agents
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode upgrade --help 1`] = `
|
||||||
|
"opencode upgrade [target]
|
||||||
|
|
||||||
|
upgrade opencode to the latest or a specific version
|
||||||
|
|
||||||
|
Positionals:
|
||||||
|
target version to upgrade to, for ex '0.1.48' or 'v0.1.48' [string]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]
|
||||||
|
-m, --method installation method to use
|
||||||
|
[string] [choices: "curl", "npm", "pnpm", "bun", "brew", "choco", "scoop"]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode uninstall --help 1`] = `
|
||||||
|
"opencode uninstall
|
||||||
|
|
||||||
|
uninstall opencode and remove all related files
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]
|
||||||
|
-c, --keep-config keep configuration files [boolean] [default: false]
|
||||||
|
-d, --keep-data keep session data and snapshots [boolean] [default: false]
|
||||||
|
--dry-run show what would be removed without removing [boolean] [default: false]
|
||||||
|
-f, --force skip confirmation prompts [boolean] [default: false]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode serve --help 1`] = `
|
||||||
|
"opencode serve
|
||||||
|
|
||||||
|
starts a headless opencode server
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]
|
||||||
|
--port port to listen on [number] [default: 0]
|
||||||
|
--hostname hostname to listen on [string] [default: "127.0.0.1"]
|
||||||
|
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0)
|
||||||
|
[boolean] [default: false]
|
||||||
|
--mdns-domain custom domain name for mDNS service (default: opencode.local)
|
||||||
|
[string] [default: "opencode.local"]
|
||||||
|
--cors additional domains to allow for CORS [array] [default: []]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode web --help 1`] = `
|
||||||
|
"opencode web
|
||||||
|
|
||||||
|
start opencode server and open web interface
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]
|
||||||
|
--port port to listen on [number] [default: 0]
|
||||||
|
--hostname hostname to listen on [string] [default: "127.0.0.1"]
|
||||||
|
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0)
|
||||||
|
[boolean] [default: false]
|
||||||
|
--mdns-domain custom domain name for mDNS service (default: opencode.local)
|
||||||
|
[string] [default: "opencode.local"]
|
||||||
|
--cors additional domains to allow for CORS [array] [default: []]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode models --help 1`] = `
|
||||||
|
"opencode models [provider]
|
||||||
|
|
||||||
|
list all available models
|
||||||
|
|
||||||
|
Positionals:
|
||||||
|
provider provider ID to filter models by [string]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]
|
||||||
|
--verbose use more verbose model output (includes metadata like costs) [boolean]
|
||||||
|
--refresh refresh the models cache from models.dev [boolean]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode stats --help 1`] = `
|
||||||
|
"opencode stats
|
||||||
|
|
||||||
|
show token usage and cost statistics
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]
|
||||||
|
--days show stats for the last N days (default: all time) [number]
|
||||||
|
--tools number of tools to show (default: all) [number]
|
||||||
|
--models show model statistics (default: hidden). Pass a number to show top N, otherwise
|
||||||
|
shows all
|
||||||
|
--project filter by project (default: all projects, empty string: current project)[string]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode export --help 1`] = `
|
||||||
|
"opencode export [sessionID]
|
||||||
|
|
||||||
|
export session data as JSON
|
||||||
|
|
||||||
|
Positionals:
|
||||||
|
sessionID session id to export [string]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]
|
||||||
|
--sanitize redact sensitive transcript and file data [boolean]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode import --help 1`] = `
|
||||||
|
"opencode import <file>
|
||||||
|
|
||||||
|
import session data from JSON file or URL
|
||||||
|
|
||||||
|
Positionals:
|
||||||
|
file path to JSON file or share URL [string] [required]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode github --help 1`] = `
|
||||||
|
"opencode github
|
||||||
|
|
||||||
|
manage GitHub agent
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
opencode github install install the GitHub agent
|
||||||
|
opencode github run run the GitHub agent
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode pr --help 1`] = `
|
||||||
|
"opencode pr <number>
|
||||||
|
|
||||||
|
fetch and checkout a GitHub PR branch, then run opencode
|
||||||
|
|
||||||
|
Positionals:
|
||||||
|
number PR number to checkout [number] [required]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode session --help 1`] = `
|
||||||
|
"opencode session
|
||||||
|
|
||||||
|
manage sessions
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
opencode session list list sessions
|
||||||
|
opencode session delete <sessionID> delete a session
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode plugin --help 1`] = `
|
||||||
|
"opencode plugin <module>
|
||||||
|
|
||||||
|
install plugin and update config
|
||||||
|
|
||||||
|
Positionals:
|
||||||
|
module npm module name [string] [required]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]
|
||||||
|
-g, --global install in global config [boolean] [default: false]
|
||||||
|
-f, --force replace existing plugin version [boolean] [default: false]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode db --help 1`] = `
|
||||||
|
"opencode db
|
||||||
|
|
||||||
|
database tools
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
opencode db [query] open an interactive sqlite3 shell or run a query [default]
|
||||||
|
opencode db path print the database path
|
||||||
|
opencode db migrate migrate JSON data to SQLite (merges with existing data)
|
||||||
|
|
||||||
|
Positionals:
|
||||||
|
query SQL query to execute [string]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]
|
||||||
|
--format Output format [string] [choices: "json", "tsv"] [default: "tsv"]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp list --help 1`] = `
|
||||||
|
"opencode mcp list
|
||||||
|
|
||||||
|
list MCP servers and their status
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp add --help 1`] = `
|
||||||
|
"opencode mcp add
|
||||||
|
|
||||||
|
add an MCP server
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp auth --help 1`] = `
|
||||||
|
"opencode mcp auth [name]
|
||||||
|
|
||||||
|
authenticate with an OAuth-enabled MCP server
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
opencode mcp auth list list OAuth-capable MCP servers and their auth status [aliases: ls]
|
||||||
|
|
||||||
|
Positionals:
|
||||||
|
name name of the MCP server [string]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp logout --help 1`] = `
|
||||||
|
"opencode mcp logout [name]
|
||||||
|
|
||||||
|
remove OAuth credentials for an MCP server
|
||||||
|
|
||||||
|
Positionals:
|
||||||
|
name name of the MCP server [string]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode providers list --help 1`] = `
|
||||||
|
"opencode providers list
|
||||||
|
|
||||||
|
list providers and credentials
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode providers login --help 1`] = `
|
||||||
|
"opencode providers login [url]
|
||||||
|
|
||||||
|
log in to a provider
|
||||||
|
|
||||||
|
Positionals:
|
||||||
|
url opencode auth provider [string]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]
|
||||||
|
-p, --provider provider id or name to log in to (skips provider selection) [string]
|
||||||
|
-m, --method login method label (skips method selection) [string]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode providers logout --help 1`] = `
|
||||||
|
"opencode providers logout
|
||||||
|
|
||||||
|
log out from a configured provider
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode agent create --help 1`] = `
|
||||||
|
"opencode agent create
|
||||||
|
|
||||||
|
create a new agent
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]
|
||||||
|
--path directory path to generate the agent file [string]
|
||||||
|
--description what the agent should do [string]
|
||||||
|
--mode agent mode [string] [choices: "all", "primary", "subagent"]
|
||||||
|
--permissions, --tools comma-separated list of permissions to allow (default: all).
|
||||||
|
Available: "bash, read, edit, glob, grep, webfetch, task, todowrite,
|
||||||
|
websearch, lsp, skill" [string]
|
||||||
|
-m, --model model to use in the format of provider/model [string]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode agent list --help 1`] = `
|
||||||
|
"opencode agent list
|
||||||
|
|
||||||
|
list all available agents
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode session list --help 1`] = `
|
||||||
|
"opencode session list
|
||||||
|
|
||||||
|
list sessions
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]
|
||||||
|
-n, --max-count limit to N most recent sessions [number]
|
||||||
|
--format output format [string] [choices: "table", "json"] [default: "table"]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode session delete --help 1`] = `
|
||||||
|
"opencode session delete <sessionID>
|
||||||
|
|
||||||
|
delete a session
|
||||||
|
|
||||||
|
Positionals:
|
||||||
|
sessionID session ID to delete [string] [required]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode github install --help 1`] = `
|
||||||
|
"opencode github install
|
||||||
|
|
||||||
|
install the GitHub agent
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode github run --help 1`] = `
|
||||||
|
"opencode github run
|
||||||
|
|
||||||
|
run the GitHub agent
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]
|
||||||
|
--event GitHub mock event to run the agent for [string]
|
||||||
|
--token GitHub personal access token (github_pat_********) [string]"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode db path --help 1`] = `
|
||||||
|
"opencode db path
|
||||||
|
|
||||||
|
print the database path
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help show help [boolean]
|
||||||
|
-v, --version show version number [boolean]
|
||||||
|
--print-logs print logs to stderr [boolean]
|
||||||
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
|
--pure run without external plugins [boolean]"
|
||||||
|
`;
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// Help-text snapshots for every CLI command + key subcommand. Catches
|
||||||
|
// accidental flag removals, renames, and reordering in a single sweep —
|
||||||
|
// any change to the user-visible CLI surface shows up here as a diff.
|
||||||
|
//
|
||||||
|
// This is the broad coverage layer that makes the future Effect CLI
|
||||||
|
// migration (yargs → effect-smol/cli) safe to attempt: if a refactor
|
||||||
|
// preserves the surface, the snapshots stay green; if it doesn't, the
|
||||||
|
// diff tells you exactly which command(s) changed.
|
||||||
|
//
|
||||||
|
// Snapshots are taken at COLUMNS=120 so wrapping is stable across
|
||||||
|
// terminal sizes. The default opencode tui command is excluded —
|
||||||
|
// `opencode --help` includes an ASCII banner that pulls in the install
|
||||||
|
// version (changes per release), so we'd snapshot a moving target.
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { cliIt } from "../../lib/cli-process"
|
||||||
|
import { normalizeForSnapshot, PATH_SEP } from "../../lib/snapshot"
|
||||||
|
|
||||||
|
// Composes `normalizeForSnapshot` (CRLF + tmpdir) with two help-specific
|
||||||
|
// rules:
|
||||||
|
//
|
||||||
|
// 1. The harness's `oc-cli-XXX` subdir under TMPDIR collapses to `<HOME>`.
|
||||||
|
// `PATH_SEP` matches `/` and `\\` so the rule works on POSIX + Windows.
|
||||||
|
//
|
||||||
|
// 2. yargs wraps the `[string] [default: "..."]` clause based on the
|
||||||
|
// pre-normalized default's character length, so different random home
|
||||||
|
// path widths produce different leading-whitespace counts (or even
|
||||||
|
// line-wraps onto a fresh line on Windows). `\s+` matches both forms.
|
||||||
|
function normalize(text: string): string {
|
||||||
|
return normalizeForSnapshot(text, {
|
||||||
|
pathReplacements: [
|
||||||
|
[new RegExp(`<TMPDIR>${PATH_SEP}oc-cli-[a-z0-9]+`, "g"), "<HOME>"],
|
||||||
|
[/\s+\[string\] \[default: "<HOME>"\]/g, ' [string] [default: "<HOME>"]'],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-level commands. Order matches what `opencode --help` prints today;
|
||||||
|
// keep it in that order so the snapshot file reads as a table of contents.
|
||||||
|
// `completion` is intentionally excluded — it's a yargs built-in that emits
|
||||||
|
// top-level help on `--help` and exits 1; not a real opencode command.
|
||||||
|
const TOP_LEVEL = [
|
||||||
|
"acp",
|
||||||
|
"mcp",
|
||||||
|
"attach",
|
||||||
|
"run",
|
||||||
|
"debug",
|
||||||
|
"providers", // aliased to `auth`
|
||||||
|
"agent",
|
||||||
|
"upgrade",
|
||||||
|
"uninstall",
|
||||||
|
"serve",
|
||||||
|
"web",
|
||||||
|
"models",
|
||||||
|
"stats",
|
||||||
|
"export",
|
||||||
|
"import",
|
||||||
|
"github",
|
||||||
|
"pr",
|
||||||
|
"session",
|
||||||
|
"plugin",
|
||||||
|
"db",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
// Subcommands worth pinning. Not exhaustive — the goal is one snapshot per
|
||||||
|
// distinct argv shape, not every leaf. Add new entries when a subcommand
|
||||||
|
// gains user-visible flags that we want to lock in.
|
||||||
|
const SUBCOMMANDS = [
|
||||||
|
["mcp", "list"],
|
||||||
|
["mcp", "add"],
|
||||||
|
["mcp", "auth"],
|
||||||
|
["mcp", "logout"],
|
||||||
|
["providers", "list"],
|
||||||
|
["providers", "login"],
|
||||||
|
["providers", "logout"],
|
||||||
|
["agent", "create"],
|
||||||
|
["agent", "list"],
|
||||||
|
["session", "list"],
|
||||||
|
["session", "delete"],
|
||||||
|
["github", "install"],
|
||||||
|
["github", "run"],
|
||||||
|
["db", "path"],
|
||||||
|
] as const
|
||||||
|
|
||||||
|
// Fixed wrap width so a developer's terminal doesn't affect snapshots.
|
||||||
|
// yargs honors COLUMNS; CI runners typically default to 80 which produces
|
||||||
|
// different wraps from a 200-col local terminal.
|
||||||
|
const SNAPSHOT_ENV = { COLUMNS: "120" }
|
||||||
|
|
||||||
|
describe("opencode CLI help-text snapshots", () => {
|
||||||
|
// Single test, parallel spawns. Each command's help fires under
|
||||||
|
// `concurrency: 8` — wall-clock stays under ~10s even for ~35 commands,
|
||||||
|
// versus ~1 minute if we serialized.
|
||||||
|
cliIt.live(
|
||||||
|
"every documented command emits stable help text",
|
||||||
|
({ opencode }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const argvs: Array<readonly string[]> = [...TOP_LEVEL.map((c) => [c] as const), ...SUBCOMMANDS]
|
||||||
|
|
||||||
|
// Spawn in parallel, then assert in argv order so snapshot output is
|
||||||
|
// deterministic and per-command failures don't abort the rest of
|
||||||
|
// the sweep. `Effect.partition` is the canonical "run all, separate
|
||||||
|
// failures from successes" primitive — no mutable accumulator needed.
|
||||||
|
const [failures, results] = yield* Effect.partition(
|
||||||
|
argvs,
|
||||||
|
(argv) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const result = yield* opencode.spawn([...argv, "--help"], { env: SNAPSHOT_ENV })
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return yield* Effect.fail(`opencode ${argv.join(" ")}: exit ${result.exitCode}`)
|
||||||
|
}
|
||||||
|
return { argv, result }
|
||||||
|
}),
|
||||||
|
{ concurrency: 8 },
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const { argv, result } of results) {
|
||||||
|
// yargs writes --help to stderr, not stdout. Snapshotting stderr
|
||||||
|
// means our test catches the help body; stdout for these commands
|
||||||
|
// is expected to be empty.
|
||||||
|
expect(normalize(result.stderr)).toMatchSnapshot(`opencode ${argv.join(" ")} --help`)
|
||||||
|
}
|
||||||
|
if (failures.length > 0) {
|
||||||
|
// Keep the failure in the Effect channel — symmetric with the
|
||||||
|
// Effect.fail inside the partition above, not a defect.
|
||||||
|
yield* Effect.fail(new Error(`Help text failed for:\n ${failures.join("\n ")}`))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
180_000,
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -403,38 +403,82 @@ test("inserts spacers for new visible groups", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("renders replayed user, reasoning, and assistant output after completion", async () => {
|
// TODO(windows): Re-enable on Windows once the streaming CodeRenderable
|
||||||
const out = await setup()
|
// flush race is fixed. The reasoning commit is delivered as a `<code>`
|
||||||
|
// renderable with `filetype="markdown"`, `streaming=true`, and
|
||||||
|
// `drawUnstyledText=false`. On Windows the first paragraph of the reasoning
|
||||||
|
// body (here `_Thinking:_ **Plan**`) is dropped from the committed rows —
|
||||||
|
// the failing assertion shows only `Say hello.` survives, while Linux
|
||||||
|
// (where `useThread` is forced off in `@opentui/core/testing`) and macOS
|
||||||
|
// both pass.
|
||||||
|
//
|
||||||
|
// Investigation summary (see PR description for the link to this work):
|
||||||
|
// 1. `reasoning("Thinking: ...", "progress")` enters `entry.body.ts`
|
||||||
|
// `reasoningBody`, which becomes a `code` body with filetype="markdown".
|
||||||
|
// 2. `RunScrollbackStream.writeStreaming` sets `renderable.content = ...`
|
||||||
|
// while `streaming=true`. `CodeRenderable.set content` short-circuits
|
||||||
|
// (does NOT call `textBuffer.setText`) when streaming, drawUnstyledText
|
||||||
|
// is false, and a filetype is set — it relies on the next
|
||||||
|
// `startHighlight()` cycle to populate the buffer.
|
||||||
|
// 3. `ScrollbackSurface.settle()` renders the surface, kicks the
|
||||||
|
// highlight via `renderSelf` → `startHighlight`, waits on
|
||||||
|
// `highlightingDone`, and re-renders. With `MockTreeSitterClient`
|
||||||
|
// returning `{highlights: []}`, the final branch (`else
|
||||||
|
// this.textBuffer.setText(content)`) populates the buffer and
|
||||||
|
// `_shouldRenderTextBuffer = true`.
|
||||||
|
// 4. `flushActive` then commits rows `[0, surface.height - 1)` during
|
||||||
|
// streaming. On Windows the committed rows are blank for the first
|
||||||
|
// paragraph — suggesting the height/text-buffer state is observed
|
||||||
|
// before/after the highlight resolution in a way that drops rows on
|
||||||
|
// that platform.
|
||||||
|
//
|
||||||
|
// The Linux pass path takes `useThread = false` (see
|
||||||
|
// `@opentui/core/testing.js` line ~540) which serializes the FFI render
|
||||||
|
// thread. macOS passes despite `useThread = true`, so the divergence is
|
||||||
|
// likely either Bun's microtask scheduling on Windows or a Zig-side
|
||||||
|
// threading interaction during the second `renderSurface()` pass in
|
||||||
|
// `settleSurface`. A real fix probably belongs in opentui (either force
|
||||||
|
// `useThread=false` for testing on Windows, or eagerly call
|
||||||
|
// `textBuffer.setText` in `CodeRenderable.set content` when streaming
|
||||||
|
// updates a non-empty body).
|
||||||
|
//
|
||||||
|
// Skipping on win32 unblocks unrelated PRs; the assertion is still
|
||||||
|
// exercised on Linux and macOS in CI.
|
||||||
|
test.skipIf(process.platform === "win32")(
|
||||||
|
"renders replayed user, reasoning, and assistant output after completion",
|
||||||
|
async () => {
|
||||||
|
const out = await setup()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
const take = () => {
|
const take = () => {
|
||||||
const commits = claim(out.renderer)
|
const commits = claim(out.renderer)
|
||||||
try {
|
try {
|
||||||
lines.push(...commits.flatMap((commit) => renderRows(commit).flatMap((row) => row.split("\n"))))
|
lines.push(...commits.flatMap((commit) => renderRows(commit).flatMap((row) => row.split("\n"))))
|
||||||
} finally {
|
} finally {
|
||||||
destroy(commits)
|
destroy(commits)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await out.scrollback.append(user("Hello you"))
|
||||||
|
take()
|
||||||
|
await out.scrollback.append(reasoning("Thinking: **Plan**\n\nSay hello.", "progress"))
|
||||||
|
await out.scrollback.complete()
|
||||||
|
take()
|
||||||
|
await out.scrollback.append(assistant("Hello.", "progress"))
|
||||||
|
await out.scrollback.complete()
|
||||||
|
take()
|
||||||
|
|
||||||
|
const output = lines.join("\n")
|
||||||
|
expect(output).toContain("› Hello you")
|
||||||
|
expect(output).toContain("Thinking:")
|
||||||
|
expect(output).toContain("Plan")
|
||||||
|
expect(output).toContain("Hello.")
|
||||||
|
} finally {
|
||||||
|
out.scrollback.destroy()
|
||||||
}
|
}
|
||||||
|
},
|
||||||
await out.scrollback.append(user("Hello you"))
|
)
|
||||||
take()
|
|
||||||
await out.scrollback.append(reasoning("Thinking: **Plan**\n\nSay hello.", "progress"))
|
|
||||||
await out.scrollback.complete()
|
|
||||||
take()
|
|
||||||
await out.scrollback.append(assistant("Hello.", "progress"))
|
|
||||||
await out.scrollback.complete()
|
|
||||||
take()
|
|
||||||
|
|
||||||
const output = lines.join("\n")
|
|
||||||
expect(output).toContain("› Hello you")
|
|
||||||
expect(output).toContain("Thinking:")
|
|
||||||
expect(output).toContain("Plan")
|
|
||||||
expect(output).toContain("Hello.")
|
|
||||||
} finally {
|
|
||||||
out.scrollback.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("coalesces same-line tool progress into one snapshot", async () => {
|
test("coalesces same-line tool progress into one snapshot", async () => {
|
||||||
const out = await setup()
|
const out = await setup()
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Tier-A smoke tests for read-only commands. Each test asserts only that the
|
||||||
|
// command exits 0 and produces *some* output in the isolated harness env.
|
||||||
|
//
|
||||||
|
// These are not behavioral tests — they're the cheapest possible signal that
|
||||||
|
// the dependency-layer wiring (config load, DB init, server boot, provider
|
||||||
|
// resolution) doesn't crash for the broad class of "no inputs, no side
|
||||||
|
// effects" commands. A regression in any shared layer (an Effect.fail that
|
||||||
|
// propagates out of a service constructor, a renamed env var, a broken DB
|
||||||
|
// migration) will fail one or more of these tests.
|
||||||
|
//
|
||||||
|
// If a future change should make one of these commands intentionally fail in
|
||||||
|
// an empty env, update the assertion + add a note explaining the new contract.
|
||||||
|
//
|
||||||
|
// Speed: each test pays ~1.5s for bun startup. 7 tests serialize within this
|
||||||
|
// file. See script/prebuild-test-cli.ts for an opt-in pre-built binary that
|
||||||
|
// cuts per-spawn cost when this suite gets bigger.
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { cliIt } from "../../lib/cli-process"
|
||||||
|
|
||||||
|
describe("opencode read-only commands (smoke)", () => {
|
||||||
|
// `mcp list` reads MCP server config and pings each one. With the empty
|
||||||
|
// OPENCODE_CONFIG_CONTENT={} we provide, no servers should be configured
|
||||||
|
// and the command should report that cleanly.
|
||||||
|
cliIt.live(
|
||||||
|
"mcp list: exits 0",
|
||||||
|
({ opencode }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const r = yield* opencode.spawn(["mcp", "list"])
|
||||||
|
opencode.expectExit(r, 0, "mcp list")
|
||||||
|
}),
|
||||||
|
60_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
// `providers list` enumerates credentials + env-resolved providers.
|
||||||
|
// (Not config-injected ones — those don't appear here by design.) The
|
||||||
|
// Credentials header always renders; the Environment header only renders
|
||||||
|
// when at least one provider env var is set, which the isolation harness
|
||||||
|
// deliberately doesn't guarantee. Assert the always-present marker so the
|
||||||
|
// test passes on a clean CI runner without env-var leakage.
|
||||||
|
cliIt.live(
|
||||||
|
"providers list: exits 0 and prints the credentials section",
|
||||||
|
({ opencode }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const r = yield* opencode.spawn(["providers", "list"])
|
||||||
|
opencode.expectExit(r, 0, "providers list")
|
||||||
|
expect(r.stdout).toContain("Credentials")
|
||||||
|
}),
|
||||||
|
60_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
// `models` lists models from configured providers. Our test/test-model
|
||||||
|
// should appear because it's wired into the test provider config.
|
||||||
|
cliIt.live(
|
||||||
|
"models: exits 0 and lists the test model",
|
||||||
|
({ opencode }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const r = yield* opencode.spawn(["models"])
|
||||||
|
opencode.expectExit(r, 0, "models")
|
||||||
|
expect(r.stdout).toContain("test/test-model")
|
||||||
|
}),
|
||||||
|
60_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
// `agent list` walks the agent config. Empty config means no agents
|
||||||
|
// configured; the command should still exit 0 with a "no agents" line or
|
||||||
|
// similar. We don't pin the message — just exit cleanly.
|
||||||
|
cliIt.live(
|
||||||
|
"agent list: exits 0",
|
||||||
|
({ opencode }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const r = yield* opencode.spawn(["agent", "list"])
|
||||||
|
opencode.expectExit(r, 0, "agent list")
|
||||||
|
}),
|
||||||
|
60_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
// `session list` reads the session DB. Fresh OPENCODE_TEST_HOME means
|
||||||
|
// empty DB. Exit 0 with no sessions.
|
||||||
|
cliIt.live(
|
||||||
|
"session list: exits 0",
|
||||||
|
({ opencode }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const r = yield* opencode.spawn(["session", "list"])
|
||||||
|
opencode.expectExit(r, 0, "session list")
|
||||||
|
}),
|
||||||
|
60_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
// `stats` aggregates token usage from the session DB. Empty DB → all zeros.
|
||||||
|
cliIt.live(
|
||||||
|
"stats: exits 0",
|
||||||
|
({ opencode }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const r = yield* opencode.spawn(["stats"])
|
||||||
|
opencode.expectExit(r, 0, "stats")
|
||||||
|
}),
|
||||||
|
60_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
// `db path` prints the DB file location. Under harness isolation the DB
|
||||||
|
// resolves to SQLite's `:memory:` (no on-disk pollution between tests);
|
||||||
|
// in production it'd be a path under OPENCODE_TEST_HOME / XDG_DATA_HOME.
|
||||||
|
// Accept either form — both prove the resolver ran without crashing.
|
||||||
|
cliIt.live(
|
||||||
|
"db path: exits 0 and prints a path or :memory:",
|
||||||
|
({ opencode }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const r = yield* opencode.spawn(["db", "path"])
|
||||||
|
opencode.expectExit(r, 0, "db path")
|
||||||
|
expect(r.stdout.trim()).toMatch(/^(:memory:|[/\\].+\.(db|sqlite|sqlite3))$/i)
|
||||||
|
}),
|
||||||
|
60_000,
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -91,6 +91,70 @@ test("toggles plugin runtime state by exported id", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("deactivating plugin pops pushed mode", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const file = path.join(dir, "mode-plugin.ts")
|
||||||
|
const spec = pathToFileURL(file).href
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
file,
|
||||||
|
`export default {
|
||||||
|
id: "demo.mode",
|
||||||
|
tui: async (api) => {
|
||||||
|
api.mode.push("demo.mode")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return { spec }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const stack: { id: symbol; mode: string }[] = []
|
||||||
|
let popCount = 0
|
||||||
|
const api = createTuiPluginApi({
|
||||||
|
mode: {
|
||||||
|
current: () => stack.at(-1)?.mode ?? "base",
|
||||||
|
push(mode) {
|
||||||
|
const id = Symbol(mode)
|
||||||
|
let active = true
|
||||||
|
stack.push({ id, mode })
|
||||||
|
return () => {
|
||||||
|
if (!active) return
|
||||||
|
active = false
|
||||||
|
popCount += 1
|
||||||
|
const index = stack.findIndex((item) => item.id === id)
|
||||||
|
if (index !== -1) stack.splice(index, 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const config = createTuiResolvedConfig({
|
||||||
|
plugin: [tmp.extra.spec],
|
||||||
|
plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
|
||||||
|
})
|
||||||
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await TuiPluginRuntime.init({ api, config })
|
||||||
|
|
||||||
|
expect(api.mode.current()).toBe("demo.mode")
|
||||||
|
expect(popCount).toBe(0)
|
||||||
|
|
||||||
|
await expect(TuiPluginRuntime.deactivatePlugin("demo.mode")).resolves.toBe(true)
|
||||||
|
|
||||||
|
expect(api.mode.current()).toBe("base")
|
||||||
|
expect(popCount).toBe(1)
|
||||||
|
} finally {
|
||||||
|
await TuiPluginRuntime.dispose()
|
||||||
|
cwd.mockRestore()
|
||||||
|
wait.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("kv plugin_enabled overrides tui config on startup", async () => {
|
test("kv plugin_enabled overrides tui config on startup", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ type Opts = {
|
|||||||
renderer?: HostPluginApi["renderer"]
|
renderer?: HostPluginApi["renderer"]
|
||||||
attention?: AttentionOpts
|
attention?: AttentionOpts
|
||||||
event?: HostPluginApi["event"]
|
event?: HostPluginApi["event"]
|
||||||
|
mode?: HostPluginApi["mode"]
|
||||||
count?: Count
|
count?: Count
|
||||||
keymap?: HostPluginApi["keymap"]
|
keymap?: HostPluginApi["keymap"]
|
||||||
tuiConfig?: Partial<HostPluginApi["tuiConfig"]>
|
tuiConfig?: Partial<HostPluginApi["tuiConfig"]>
|
||||||
@@ -237,6 +238,10 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
keymap,
|
keymap,
|
||||||
|
mode: opts.mode ?? {
|
||||||
|
current: () => "base",
|
||||||
|
push: () => () => {},
|
||||||
|
},
|
||||||
route: {
|
route: {
|
||||||
register: () => {
|
register: () => {
|
||||||
if (count) count.route_add += 1
|
if (count) count.route_add += 1
|
||||||
|
|||||||
@@ -31,8 +31,42 @@ import { it } from "./effect"
|
|||||||
const opencodeRoot = path.resolve(import.meta.dir, "../../")
|
const opencodeRoot = path.resolve(import.meta.dir, "../../")
|
||||||
const cliEntry = path.join(opencodeRoot, "src/index.ts")
|
const cliEntry = path.join(opencodeRoot, "src/index.ts")
|
||||||
|
|
||||||
|
// Argv prefix for spawning the CLI. If OPENCODE_TEST_CLI_PATH is set,
|
||||||
|
// subprocess tests spawn the pre-built binary directly (~3x speedup on
|
||||||
|
// isolation-env spawns that hit DB migration; produced by
|
||||||
|
// `bun script/prebuild-test-cli.ts`). Otherwise falls back to dev mode —
|
||||||
|
// strictly opt-in, default behavior unchanged.
|
||||||
|
const prebuiltCli = process.env["OPENCODE_TEST_CLI_PATH"]
|
||||||
|
const cliArgv: readonly string[] = prebuiltCli
|
||||||
|
? [prebuiltCli]
|
||||||
|
: ["bun", "run", "--conditions=browser", cliEntry]
|
||||||
|
|
||||||
export const testModelID = "test/test-model"
|
export const testModelID = "test/test-model"
|
||||||
|
|
||||||
|
// Wrap a Bun subprocess pipe (or any ReadableStream<Uint8Array>) as a Stream.
|
||||||
|
// Centralizes the `evaluate` + `onError` boilerplate and tags errors with the
|
||||||
|
// stream name so a stderr/stdout failure is greppable in logs.
|
||||||
|
function fromBunStream(name: string, get: () => ReadableStream<Uint8Array>) {
|
||||||
|
return Stream.fromReadableStream({
|
||||||
|
evaluate: get,
|
||||||
|
onError: (cause) => new Error(`${name} stream error: ${String(cause)}`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long-lived processes (serve, acp) all want the same stderr drain: read every
|
||||||
|
// chunk, push to a tail buffer, swallow stream errors (the child closing the
|
||||||
|
// pipe is normal). `log: true` surfaces a real protocol error to logs so a
|
||||||
|
// regression doesn't silently disappear.
|
||||||
|
function forkStderrDrain(stream: ReadableStream<Uint8Array>, into: string[]) {
|
||||||
|
return Effect.forkScoped(
|
||||||
|
fromBunStream("stderr", () => stream).pipe(
|
||||||
|
Stream.decodeText(),
|
||||||
|
Stream.runForEach((chunk) => Effect.sync(() => into.push(chunk))),
|
||||||
|
Effect.ignore({ log: true }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function isolatedEnv(home: string, configJson: string): Record<string, string> {
|
function isolatedEnv(home: string, configJson: string): Record<string, string> {
|
||||||
return {
|
return {
|
||||||
OPENCODE_TEST_HOME: home,
|
OPENCODE_TEST_HOME: home,
|
||||||
@@ -172,7 +206,7 @@ export function withCliFixture<A, E>(
|
|||||||
Effect.promise(async () => {
|
Effect.promise(async () => {
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
// Process.run pipes stdout/stderr by default and returns them as Buffers.
|
// Process.run pipes stdout/stderr by default and returns them as Buffers.
|
||||||
const result = await Process.run(["bun", "run", "--conditions=browser", cliEntry, ...args], {
|
const result = await Process.run([...cliArgv, ...args], {
|
||||||
cwd: home,
|
cwd: home,
|
||||||
timeout: opts?.timeoutMs ?? 30_000,
|
timeout: opts?.timeoutMs ?? 30_000,
|
||||||
env: { ...process.env, ...env, ...opts?.env },
|
env: { ...process.env, ...env, ...opts?.env },
|
||||||
@@ -211,7 +245,7 @@ export function withCliFixture<A, E>(
|
|||||||
// as a finalizer error during test teardown.
|
// as a finalizer error during test teardown.
|
||||||
const proc = yield* Effect.acquireRelease(
|
const proc = yield* Effect.acquireRelease(
|
||||||
Effect.sync(() =>
|
Effect.sync(() =>
|
||||||
Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], {
|
Bun.spawn([...cliArgv, ...argv], {
|
||||||
cwd: home,
|
cwd: home,
|
||||||
env: { ...process.env, ...env, ...opts?.env },
|
env: { ...process.env, ...env, ...opts?.env },
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
@@ -225,20 +259,10 @@ export function withCliFixture<A, E>(
|
|||||||
}).pipe(Effect.ignore),
|
}).pipe(Effect.ignore),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Drain stderr in a scope-bound fork. Without this the OS pipe buffer
|
// Tail buffer so timeout failures can include stderr context. The fork
|
||||||
// eventually fills and the child blocks on its next log call. Kept as a
|
// also keeps the OS pipe buffer from filling and wedging the child.
|
||||||
// tail buffer so timeout failures can include context.
|
|
||||||
const stderrChunks: string[] = []
|
const stderrChunks: string[] = []
|
||||||
yield* Effect.forkScoped(
|
yield* forkStderrDrain(proc.stderr, stderrChunks)
|
||||||
Stream.fromReadableStream({
|
|
||||||
evaluate: () => proc.stderr,
|
|
||||||
onError: () => new Error("stderr stream error"),
|
|
||||||
}).pipe(
|
|
||||||
Stream.decodeText(),
|
|
||||||
Stream.runForEach((chunk) => Effect.sync(() => stderrChunks.push(chunk))),
|
|
||||||
Effect.ignore,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Watch stdout line-by-line for the listening sentinel. Format
|
// Watch stdout line-by-line for the listening sentinel. Format
|
||||||
// (see src/cli/cmd/serve.ts):
|
// (see src/cli/cmd/serve.ts):
|
||||||
@@ -246,17 +270,14 @@ export function withCliFixture<A, E>(
|
|||||||
const readyRe = /listening on (http:\/\/([^\s:]+):(\d+))/
|
const readyRe = /listening on (http:\/\/([^\s:]+):(\d+))/
|
||||||
const readyDeferred = yield* Deferred.make<{ url: string; hostname: string; port: number }>()
|
const readyDeferred = yield* Deferred.make<{ url: string; hostname: string; port: number }>()
|
||||||
yield* Effect.forkScoped(
|
yield* Effect.forkScoped(
|
||||||
Stream.fromReadableStream({
|
fromBunStream("stdout", () => proc.stdout).pipe(
|
||||||
evaluate: () => proc.stdout,
|
|
||||||
onError: () => new Error("stdout stream error"),
|
|
||||||
}).pipe(
|
|
||||||
Stream.decodeText(),
|
Stream.decodeText(),
|
||||||
Stream.splitLines,
|
Stream.splitLines,
|
||||||
Stream.runForEach((line) => {
|
Stream.runForEach((line) => {
|
||||||
const m = line.match(readyRe)
|
const m = line.match(readyRe)
|
||||||
return m ? Deferred.succeed(readyDeferred, { url: m[1], hostname: m[2], port: Number(m[3]) }) : Effect.void
|
return m ? Deferred.succeed(readyDeferred, { url: m[1], hostname: m[2], port: Number(m[3]) }) : Effect.void
|
||||||
}),
|
}),
|
||||||
Effect.ignore,
|
Effect.ignore({ log: true }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -295,7 +316,7 @@ export function withCliFixture<A, E>(
|
|||||||
// Either way we await proc.exited so the test scope doesn't leak.
|
// Either way we await proc.exited so the test scope doesn't leak.
|
||||||
const proc = yield* Effect.acquireRelease(
|
const proc = yield* Effect.acquireRelease(
|
||||||
Effect.sync(() =>
|
Effect.sync(() =>
|
||||||
Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], {
|
Bun.spawn([...cliArgv, ...argv], {
|
||||||
cwd: opts?.cwd ?? home,
|
cwd: opts?.cwd ?? home,
|
||||||
env: { ...process.env, ...env, ...opts?.env },
|
env: { ...process.env, ...env, ...opts?.env },
|
||||||
stdin: "pipe",
|
stdin: "pipe",
|
||||||
@@ -323,26 +344,14 @@ export function withCliFixture<A, E>(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const stderrChunks: string[] = []
|
const stderrChunks: string[] = []
|
||||||
yield* Effect.forkScoped(
|
yield* forkStderrDrain(proc.stderr, stderrChunks)
|
||||||
Stream.fromReadableStream({
|
|
||||||
evaluate: () => proc.stderr,
|
|
||||||
onError: () => new Error("stderr stream error"),
|
|
||||||
}).pipe(
|
|
||||||
Stream.decodeText(),
|
|
||||||
Stream.runForEach((chunk) => Effect.sync(() => stderrChunks.push(chunk))),
|
|
||||||
Effect.ignore,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Each ndjson line becomes one queue entry. JSON.parse failures are
|
// Each ndjson line becomes one queue entry. JSON.parse failures are
|
||||||
// surfaced as the raw string so a malformed protocol message doesn't
|
// surfaced as the raw string so a malformed protocol message doesn't
|
||||||
// silently wedge the test in `receive`.
|
// silently wedge the test in `receive`.
|
||||||
const responses = yield* Queue.unbounded<unknown>()
|
const responses = yield* Queue.unbounded<unknown>()
|
||||||
yield* Effect.forkScoped(
|
yield* Effect.forkScoped(
|
||||||
Stream.fromReadableStream({
|
fromBunStream("stdout", () => proc.stdout).pipe(
|
||||||
evaluate: () => proc.stdout,
|
|
||||||
onError: () => new Error("stdout stream error"),
|
|
||||||
}).pipe(
|
|
||||||
Stream.decodeText(),
|
Stream.decodeText(),
|
||||||
Stream.splitLines,
|
Stream.splitLines,
|
||||||
Stream.runForEach((line) => {
|
Stream.runForEach((line) => {
|
||||||
@@ -355,23 +364,23 @@ export function withCliFixture<A, E>(
|
|||||||
}
|
}
|
||||||
return Queue.offer(responses, parsed)
|
return Queue.offer(responses, parsed)
|
||||||
}),
|
}),
|
||||||
Effect.ignore,
|
Effect.ignore({ log: true }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// `proc.stdin.write` returns `number | Promise<number>`. The promise
|
||||||
|
// form is the backpressure signal — if we don't await it, rapid
|
||||||
|
// successive sends can interleave under pipe-buffer-full conditions
|
||||||
|
// and corrupt the ndjson framing.
|
||||||
send: (msg: object) =>
|
send: (msg: object) =>
|
||||||
Effect.sync(() => {
|
Effect.promise(async () => {
|
||||||
proc.stdin.write(JSON.stringify(msg) + "\n")
|
const ret = proc.stdin.write(JSON.stringify(msg) + "\n")
|
||||||
|
if (typeof ret !== "number") await ret
|
||||||
}),
|
}),
|
||||||
receive: Queue.take(responses),
|
receive: Queue.take(responses),
|
||||||
close: () => {
|
// proc.stdin.end() is idempotent in Bun; no try/catch needed.
|
||||||
try {
|
close: () => proc.stdin.end(),
|
||||||
proc.stdin.end()
|
|
||||||
} catch {
|
|
||||||
// already closed
|
|
||||||
}
|
|
||||||
},
|
|
||||||
exited: proc.exited as Promise<number>,
|
exited: proc.exited as Promise<number>,
|
||||||
} satisfies AcpHandle
|
} satisfies AcpHandle
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Shared normalization helpers for cross-OS-stable snapshot tests.
|
||||||
|
//
|
||||||
|
// Every snapshot test that captures subprocess output, file paths, or other
|
||||||
|
// OS-flavored strings hits the same two issues:
|
||||||
|
// 1. Bun emits CRLF line endings on Windows stderr; LF elsewhere.
|
||||||
|
// 2. Path separators differ (\ on Windows, / on POSIX), and macOS's
|
||||||
|
// /var/folders symlink resolves to /private/var/folders.
|
||||||
|
//
|
||||||
|
// These helpers exist so each test doesn't reinvent the same regexes.
|
||||||
|
//
|
||||||
|
// Use individually for fine-grained control, or compose them via
|
||||||
|
// `normalizeForSnapshot` for the common "snapshot subprocess output" path.
|
||||||
|
import fs from "node:fs"
|
||||||
|
import os from "node:os"
|
||||||
|
|
||||||
|
const TMP = os.tmpdir()
|
||||||
|
const REAL_TMP = fs.realpathSync(TMP)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapses CRLF to LF. Bun's subprocess pipes emit native line endings —
|
||||||
|
* snapshots captured on macOS/Linux contain LF, so a Windows run without
|
||||||
|
* this step always diffs.
|
||||||
|
*/
|
||||||
|
export function stripCrlf(text: string): string {
|
||||||
|
return text.replaceAll("\r\n", "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts Windows-style `\` separators to POSIX `/` so paths render
|
||||||
|
* identically across OSes. Use for path strings you want stable in a
|
||||||
|
* snapshot, not for filesystem operations.
|
||||||
|
*/
|
||||||
|
export function toPosixPath(p: string): string {
|
||||||
|
return p.replaceAll("\\", "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips both the OS-level `os.tmpdir()` and its realpath form (macOS
|
||||||
|
* `/var/folders` → `/private/var/folders`) from text, replacing each
|
||||||
|
* occurrence with `marker` (default `<TMPDIR>`).
|
||||||
|
*/
|
||||||
|
export function withTmpdirStripped(text: string, marker = "<TMPDIR>"): string {
|
||||||
|
return text.replaceAll(REAL_TMP, marker).replaceAll(TMP, marker)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Separator-agnostic match class for path-style strings. Use inside a
|
||||||
|
* larger regex when you want to match both `/` (POSIX) and `\` (Windows)
|
||||||
|
* boundaries — e.g. `<TMPDIR>${PATH_SEP}oc-cli-[a-z0-9]+`.
|
||||||
|
*/
|
||||||
|
export const PATH_SEP = "[/\\\\]"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot normalization for the common case: strip CRLF, strip tmpdir,
|
||||||
|
* then apply any caller-supplied path regex substitutions. Does NOT
|
||||||
|
* blanket-replace `\` with `/` — that would mangle non-path backslash
|
||||||
|
* content (regex literals in help text, etc.). Use `toPosixPath` or
|
||||||
|
* `PATH_SEP` in your own regex when you need separator agnosticism.
|
||||||
|
*/
|
||||||
|
export function normalizeForSnapshot(
|
||||||
|
text: string,
|
||||||
|
options?: {
|
||||||
|
readonly tmpdirMarker?: string
|
||||||
|
readonly pathReplacements?: ReadonlyArray<readonly [RegExp, string]>
|
||||||
|
},
|
||||||
|
): string {
|
||||||
|
let out = stripCrlf(text)
|
||||||
|
out = withTmpdirStripped(out, options?.tmpdirMarker)
|
||||||
|
for (const [pattern, replacement] of options?.pathReplacements ?? []) {
|
||||||
|
out = out.replace(pattern, replacement)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -40,11 +40,16 @@ const configLayer = TestConfig.layer({
|
|||||||
directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
|
directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
|
||||||
})
|
})
|
||||||
|
|
||||||
const registryLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
type RegistryLayerOptions = {
|
||||||
|
flags?: Partial<RuntimeFlags.Info>
|
||||||
|
plugin?: Layer.Layer<Plugin.Service>
|
||||||
|
}
|
||||||
|
|
||||||
|
const registryLayer = (opts: RegistryLayerOptions = {}) =>
|
||||||
ToolRegistry.layer
|
ToolRegistry.layer
|
||||||
.pipe(
|
.pipe(
|
||||||
Layer.provide(configLayer),
|
Layer.provide(configLayer),
|
||||||
Layer.provide(Plugin.defaultLayer),
|
Layer.provide(opts.plugin ?? Plugin.defaultLayer),
|
||||||
Layer.provide(Question.defaultLayer),
|
Layer.provide(Question.defaultLayer),
|
||||||
Layer.provide(Todo.defaultLayer),
|
Layer.provide(Todo.defaultLayer),
|
||||||
Layer.provide(Skill.defaultLayer),
|
Layer.provide(Skill.defaultLayer),
|
||||||
@@ -64,12 +69,41 @@ const registryLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|||||||
Layer.provide(Ripgrep.defaultLayer),
|
Layer.provide(Ripgrep.defaultLayer),
|
||||||
Layer.provide(Truncate.defaultLayer),
|
Layer.provide(Truncate.defaultLayer),
|
||||||
)
|
)
|
||||||
.pipe(Layer.provide(RuntimeFlags.layer(flags)))
|
.pipe(Layer.provide(RuntimeFlags.layer(opts.flags ?? {})))
|
||||||
|
|
||||||
|
// Fake Plugin.Service that returns a single plugin whose `tool` map contains
|
||||||
|
// one definition with `args: undefined`. Used to exercise the plugin entry
|
||||||
|
// point of `fromPlugin` for the #27451 / #27630 regression.
|
||||||
|
const brokenPluginLayer = Layer.succeed(
|
||||||
|
Plugin.Service,
|
||||||
|
Plugin.Service.of({
|
||||||
|
init: () => Effect.void,
|
||||||
|
trigger: ((_name: unknown, _input: unknown, output: unknown) =>
|
||||||
|
Effect.succeed(output)) as Plugin.Interface["trigger"],
|
||||||
|
list: () =>
|
||||||
|
Effect.succeed([
|
||||||
|
{
|
||||||
|
tool: {
|
||||||
|
broken_plugin_tool: {
|
||||||
|
description: "plugin tool with missing args",
|
||||||
|
args: undefined as unknown as Record<string, never>,
|
||||||
|
execute: async () => "ok",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer))
|
const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer))
|
||||||
const scout = testEffect(Layer.mergeAll(registryLayer({ experimentalScout: true }), node, Agent.defaultLayer))
|
const scout = testEffect(
|
||||||
|
Layer.mergeAll(registryLayer({ flags: { experimentalScout: true } }), node, Agent.defaultLayer),
|
||||||
|
)
|
||||||
const background = testEffect(
|
const background = testEffect(
|
||||||
Layer.mergeAll(registryLayer({ experimentalBackgroundSubagents: true }), node, Agent.defaultLayer),
|
Layer.mergeAll(registryLayer({ flags: { experimentalBackgroundSubagents: true } }), node, Agent.defaultLayer),
|
||||||
|
)
|
||||||
|
const withBrokenPlugin = testEffect(
|
||||||
|
Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -186,6 +220,57 @@ describe("tool.registry", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Regression for #27451 / #27630: a custom tool that omits `args` must not
|
||||||
|
// crash registry initialization with
|
||||||
|
// `Object.entries requires that input parameter not be null or undefined`.
|
||||||
|
// Pre-1.14.49 the code path was `z.object(def.args)`, and `z.object(undefined)`
|
||||||
|
// silently produced an empty schema — so the tool registered as no-args.
|
||||||
|
// Preserve that tolerance.
|
||||||
|
it.instance("tolerates a custom tool exporting null/undefined args (no-args fallback)", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const test = yield* TestInstance
|
||||||
|
const tool = path.join(test.directory, ".opencode", "tool")
|
||||||
|
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
Bun.write(
|
||||||
|
path.join(tool, "noargs.ts"),
|
||||||
|
[
|
||||||
|
"export default {",
|
||||||
|
" description: 'tool with no args',",
|
||||||
|
" args: undefined,",
|
||||||
|
" execute: async () => 'ok',",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const registry = yield* ToolRegistry.Service
|
||||||
|
const ids = yield* registry.ids()
|
||||||
|
// Built-in tools must still load — a single malformed custom tool must
|
||||||
|
// not poison the whole registry.
|
||||||
|
expect(ids).toContain("read")
|
||||||
|
const loaded = (yield* registry.all()).find((t) => t.id === "noargs")
|
||||||
|
if (!loaded) throw new Error("noargs tool was not loaded")
|
||||||
|
expect(loaded.jsonSchema).toMatchObject({ type: "object", properties: {} })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Same regression, plugin entry point. The original reports (#27451, #27630)
|
||||||
|
// came in through `plugin.list()` — `oh-my-opencode` was registering a tool
|
||||||
|
// with `args: undefined` and crashing every message submit. The file-scan
|
||||||
|
// and plugin-list loops both funnel through `fromPlugin`, but covering both
|
||||||
|
// entry points means a future refactor that splits them won't silently lose
|
||||||
|
// protection.
|
||||||
|
withBrokenPlugin.instance("tolerates a plugin tool registered with null/undefined args", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const registry = yield* ToolRegistry.Service
|
||||||
|
const ids = yield* registry.ids()
|
||||||
|
expect(ids).toContain("read")
|
||||||
|
expect(ids).toContain("broken_plugin_tool")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.instance("loads tools from .opencode/tools (plural)", () =>
|
it.instance("loads tools from .opencode/tools (plural)", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const test = yield* TestInstance
|
const test = yield* TestInstance
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ export type TuiKeys = {
|
|||||||
|
|
||||||
export type TuiKeymap = Keymap<Renderable, KeyEvent>
|
export type TuiKeymap = Keymap<Renderable, KeyEvent>
|
||||||
|
|
||||||
|
export type TuiModeApi = {
|
||||||
|
current: () => string
|
||||||
|
push: (mode: string) => () => void
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy `api.command` shape kept so v1 plugins can initialize. Remove in v2.
|
* Legacy `api.command` shape kept so v1 plugins can initialize. Remove in v2.
|
||||||
*
|
*
|
||||||
@@ -589,6 +594,7 @@ export type TuiPluginApi = {
|
|||||||
command?: TuiCommandApi
|
command?: TuiCommandApi
|
||||||
keys: TuiKeys
|
keys: TuiKeys
|
||||||
keymap: TuiKeymap
|
keymap: TuiKeymap
|
||||||
|
mode: TuiModeApi
|
||||||
route: {
|
route: {
|
||||||
register: (routes: TuiRouteDefinition[]) => () => void
|
register: (routes: TuiRouteDefinition[]) => () => void
|
||||||
navigate: (name: string, params?: Record<string, unknown>) => void
|
navigate: (name: string, params?: Record<string, unknown>) => void
|
||||||
|
|||||||
Reference in New Issue
Block a user