refactor(server): canonicalize service API (#31049)

This commit is contained in:
Dax
2026-06-06 23:27:28 -04:00
committed by GitHub
parent 53ff1b57c9
commit fe0c4f8c74
388 changed files with 7103 additions and 4092 deletions
@@ -1,72 +0,0 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`TUI inline tool wrapping snapshots consecutive grep, glob, and read rows at a narrow width 1`] = `
" ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
✱ Glob "**/*db*" in packages/opencode (6 matches)
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
Path\\.data|data =" in packages/opencode/src (115 matches)"
`;
exports[`TUI inline tool wrapping snapshots expanded tool errors under the tool text 1`] = `
" ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
✱ Glob "**/*db*" in packages/opencode (6 matches)
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
No LSP server available for this file type.
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
Path\\.data|data =" in packages/opencode/src (115 matches)"
`;
exports[`TUI inline tool wrapping keeps separation after a shell output block 1`] = `
"
# List files
$ ls
file.ts
✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
✱ Glob "**/*db*" in packages/opencode (6 matches)
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
Path\\.data|data =" in packages/opencode/src (115 matches)"
`;
exports[`TUI inline tool wrapping keeps separation after a padded user message 1`] = `
"
Check whether the next tool remains separated.
✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
✱ Glob "**/*db*" in packages/opencode (6 matches)
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
Path\\.data|data =" in packages/opencode/src (115 matches)"
`;
exports[`TUI inline tool wrapping separates a contiguous subagent group from inline tools 1`] = `
" ✱ Grep "Task" (2 matches)
⠙ Explore Task — Inspect active task spacing
✓ General Task — Confirm completed task spacing
↳ 1 toolcall · 501ms
→ Read src/cli/cmd/tui/routes/session/index.tsx"
`;
exports[`TUI inline tool wrapping separates a subagent group after an expanded read 1`] = `
" → Read src/cli/cmd/tui/routes/session/index.tsx
↳ Loaded src/cli/cmd/tui/routes/session/tools.tsx
✓ Explore Task — Inspect active task spacing
↳ 1 toolcall · 501ms"
`;
@@ -4,12 +4,13 @@ import { mkdir } from "node:fs/promises"
import path from "node:path"
import { tmpdir } from "../../fixture/fixture"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiPluginRuntime } from "../../../src/cli/cmd/tui/plugin/runtime"
import { tui, type TuiHandle } from "../../../src/cli/cmd/tui/app"
import { tui, type TuiHandle } from "@opencode-ai/tui"
import { createLegacyTuiHost } from "../../../src/cli/tui/host"
import { Global } from "@opencode-ai/core/global"
import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
import * as TuiAudio from "../../../src/cli/cmd/tui/util/audio"
import * as TuiKeymap from "../../../src/cli/cmd/tui/keymap"
import * as TuiAudio from "../../../src/cli/tui/audio"
import * as TuiKeymap from "@opencode-ai/tui/keymap"
import { createTuiBuildInfo, createTuiEnvironment } from "@opencode-ai/tui/runtime"
type TestRendererSetup = Awaited<ReturnType<typeof createTestRenderer>>
type TmpDir = Awaited<ReturnType<typeof tmpdir>>
@@ -39,7 +40,6 @@ afterEach(async () => {
current?.restore?.()
await Bun.sleep(20)
await current?.tmp?.[Symbol.asyncDispose]()
await TuiPluginRuntime.dispose().catch(() => {})
})
test("returns a handle immediately and resolves ready after async mount setup", async () => {
@@ -61,6 +61,23 @@ test("production can await done only and still receives mount failures", async (
expect(app.setup.renderer.isDestroyed).toBe(true)
})
test("plugin startup failure does not fail the app", async () => {
const error = spyOn(console, "error").mockImplementation(() => {})
try {
const app = await startTui({ rejectPlugins: new Error("plugins failed") })
app.theme.resolve("dark")
await expect(app.handle.ready).resolves.toBeUndefined()
await app.pluginHost.started
expect(app.setup.renderer.isDestroyed).toBe(false)
expect(app.pluginHost.starts).toBe(1)
await app.handle.exit()
await app.handle.done
} finally {
error.mockRestore()
}
})
test("exit destroys the renderer, resolves done, and runs cleanup once", async () => {
const beforeSighup = process.listenerCount("SIGHUP")
const app = await startTui()
@@ -73,7 +90,7 @@ test("exit destroys the renderer, resolves done, and runs cleanup once", async (
await app.handle.done
expect(app.setup.renderer.isDestroyed).toBe(true)
expect(process.listenerCount("SIGHUP")).toBe(beforeSighup)
expect(process.listenerCount("SIGHUP")).toBeLessThanOrEqual(beforeSighup)
})
test("exit preserves reason formatting and exit messages", async () => {
@@ -124,7 +141,7 @@ test("direct renderer destruction still cleans up and resolves done", async () =
app.setup.renderer.destroy()
await app.handle.done
expect(process.listenerCount("SIGHUP")).toBe(beforeSighup)
expect(process.listenerCount("SIGHUP")).toBeLessThanOrEqual(beforeSighup)
})
test("SIGHUP exits before ready and removes its listener", async () => {
@@ -135,7 +152,7 @@ test("SIGHUP exits before ready and removes its listener", async () => {
await app.handle.done
expect(app.setup.renderer.isDestroyed).toBe(true)
expect(process.listenerCount("SIGHUP")).toBe(beforeSighup)
expect(process.listenerCount("SIGHUP")).toBeLessThanOrEqual(beforeSighup)
})
test("SIGHUP exits after ready and removes its listener", async () => {
@@ -161,7 +178,6 @@ test("plugin, audio, and keymap cleanup run exactly once", async () => {
unregister()
}
})
const disposePlugins = spyOn(TuiPluginRuntime, "dispose")
const disposeAudio = spyOn(TuiAudio, "dispose")
try {
@@ -175,18 +191,37 @@ test("plugin, audio, and keymap cleanup run exactly once", async () => {
expect(registerKeymap).toHaveBeenCalledTimes(1)
expect(unregisterKeymapCalls).toBe(1)
expect(disposePlugins).toHaveBeenCalledTimes(1)
expect(app.pluginHost.disposes).toBe(1)
expect(disposeAudio).toHaveBeenCalledTimes(1)
} finally {
registerKeymap.mockRestore()
disposePlugins.mockRestore()
disposeAudio.mockRestore()
}
})
async function startTui(options: { rejectTheme?: Error } = {}) {
test("plugin disposal failure does not stop remaining cleanup", async () => {
const error = spyOn(console, "error").mockImplementation(() => {})
const disposeAudio = spyOn(TuiAudio, "dispose")
try {
const app = await startTui({ rejectPluginDispose: new Error("dispose failed") })
app.theme.resolve("dark")
await app.handle.ready
await app.handle.exit()
await app.handle.done
expect(app.pluginHost.disposes).toBe(1)
expect(disposeAudio).toHaveBeenCalledTimes(1)
expect(app.setup.renderer.isDestroyed).toBe(true)
} finally {
error.mockRestore()
disposeAudio.mockRestore()
}
})
async function startTui(options: { rejectTheme?: Error; rejectPlugins?: Error; rejectPluginDispose?: Error } = {}) {
const tmp = await tmpdir()
const restore = await isolateGlobalPaths(tmp.path)
const isolated = await isolateGlobalPaths(tmp.path)
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false, maxFps: Number.POSITIVE_INFINITY })
const theme = deferred<"dark" | "light" | null>()
const waitForThemeMode = spyOn(setup.renderer, "waitForThemeMode").mockImplementation(() => {
@@ -197,13 +232,48 @@ async function startTui(options: { rejectTheme?: Error } = {}) {
const calls = createFetch()
const events = createEventSource()
const pluginStarted = deferred<void>()
const pluginHost = {
starts: 0,
disposes: 0,
started: pluginStarted.promise,
async start() {
pluginHost.starts++
pluginStarted.resolve()
if (options.rejectPlugins) throw options.rejectPlugins
},
async dispose() {
pluginHost.disposes++
if (options.rejectPluginDispose) throw options.rejectPluginDispose
},
}
const environment = createTuiEnvironment({
cwd: tmp.path,
platform: "linux",
paths: { home: tmp.path, state: isolated.state, worktree: path.join(tmp.path, "worktree") },
capabilities: {
mouse: true,
copyOnSelect: true,
terminalTitle: false,
terminalSuspend: false,
workspaces: false,
showTimeToFirstDraw: false,
},
terminal: {},
editor: { zedTerminal: false },
skipInitialLoading: false,
})
const handle = tui({
environment,
build: createTuiBuildInfo({ version: "test", channel: "test" }),
url: "http://test",
renderer: setup.renderer,
host: createLegacyTuiHost(setup.renderer),
config: createTuiResolvedConfig({ plugin_enabled: disabledInternalPlugins }),
directory,
fetch: calls.fetch,
events: events.source,
pluginHost,
args: {},
})
active = {
@@ -212,27 +282,26 @@ async function startTui(options: { rejectTheme?: Error } = {}) {
tmp,
restore: () => {
waitForThemeMode.mockRestore()
restore()
isolated.restore()
},
}
return { handle, setup, theme }
return { handle, setup, theme, pluginHost }
}
async function isolateGlobalPaths(root: string) {
const previous = {
config: Global.Path.config,
state: Global.Path.state,
}
const previous = Global.Path.config
Global.Path.config = path.join(root, "config")
Global.Path.state = path.join(root, "state")
const state = path.join(root, "state")
await mkdir(Global.Path.config, { recursive: true })
await mkdir(Global.Path.state, { recursive: true })
await Bun.write(path.join(Global.Path.state, "kv.json"), JSON.stringify({ animations_enabled: false }))
await mkdir(state, { recursive: true })
await Bun.write(path.join(state, "kv.json"), JSON.stringify({ animations_enabled: false }))
return () => {
Global.Path.config = previous.config
Global.Path.state = previous.state
return {
state,
restore() {
Global.Path.config = previous
},
}
}
@@ -0,0 +1,12 @@
import { describe, expect, test } from "bun:test"
describe("tui attach", () => {
test("loads the public TUI API and legacy hosts lazily", async () => {
const source = await Bun.file(new URL("../../../src/cli/cmd/attach.ts", import.meta.url)).text()
expect(source).toMatch(/await import\(["']@opencode-ai\/tui["']\)/)
expect(source).toContain('await import("../tui/host")')
expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/)
expect(source).not.toContain('import("./app")')
})
})
@@ -1,146 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { TextareaRenderable } from "@opentui/core"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { onCleanup } from "solid-js"
import { tmpdir } from "../../fixture/fixture"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import type { TuiKeybind } from "../../../src/cli/cmd/tui/config/keybind"
async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
async function mountPrompt(input: {
root: string
keybinds: Partial<TuiKeybind.Keybinds>
onConfirm: (value: string) => void
}) {
const { Global } = await import("@opencode-ai/core/global")
const previous = {
config: Global.Path.config,
state: Global.Path.state,
}
Global.Path.config = path.join(input.root, "config")
Global.Path.state = path.join(input.root, "state")
await mkdir(Global.Path.config, { recursive: true })
await mkdir(Global.Path.state, { recursive: true })
await Bun.write(path.join(Global.Path.state, "kv.json"), "{}")
const [
{ DialogProvider },
{ DialogPrompt },
{ KVProvider },
{ ThemeProvider },
{ TuiConfigProvider },
{ ToastProvider },
{ OpencodeKeymapProvider, registerOpencodeKeymap },
] = await Promise.all([
import("../../../src/cli/cmd/tui/ui/dialog"),
import("../../../src/cli/cmd/tui/ui/dialog-prompt"),
import("../../../src/cli/cmd/tui/context/kv"),
import("../../../src/cli/cmd/tui/context/theme"),
import("../../../src/cli/cmd/tui/context/tui-config"),
import("../../../src/cli/cmd/tui/ui/toast"),
import("../../../src/cli/cmd/tui/keymap"),
])
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const resolvedConfig = createTuiResolvedConfig({
keybinds: input.keybinds,
leader_timeout: 1000,
})
const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig)
onCleanup(off)
return (
<OpencodeKeymapProvider keymap={keymap}>
<TuiConfigProvider config={resolvedConfig}>
<KVProvider>
<ThemeProvider mode="dark">
<ToastProvider>
<DialogProvider>
<DialogPrompt title="Rename Session" value="draft" onConfirm={input.onConfirm} />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</KVProvider>
</TuiConfigProvider>
</OpencodeKeymapProvider>
)
}
const app = await testRender(() => <Harness />, { kittyKeyboard: true })
return {
app,
async cleanup() {
app.renderer.destroy()
Global.Path.config = previous.config
Global.Path.state = previous.state
},
}
}
test("dialog prompt submit wins when return is also input newline", async () => {
await using tmp = await tmpdir()
const confirmed: string[] = []
const prompt = await mountPrompt({
root: tmp.path,
keybinds: {
input_submit: "super+return",
input_newline: "return,shift+return,alt+return,ctrl+j",
},
onConfirm: (value) => confirmed.push(value),
})
try {
await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
const textarea = prompt.app.renderer.currentFocusedEditor
if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea")
prompt.app.mockInput.pressEnter()
expect(confirmed).toEqual(["draft"])
expect(textarea.plainText).toBe("draft")
} finally {
await prompt.cleanup()
}
})
test("dialog prompt submit can be rebound separately from input submit", async () => {
await using tmp = await tmpdir()
const confirmed: string[] = []
const prompt = await mountPrompt({
root: tmp.path,
keybinds: {
input_submit: "return",
"dialog.prompt.submit": "ctrl+y",
},
onConfirm: (value) => confirmed.push(value),
})
try {
await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
const textarea = prompt.app.renderer.currentFocusedEditor
if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea")
prompt.app.mockInput.pressEnter()
expect(confirmed).toEqual([])
expect(textarea.plainText).toBe("draft")
prompt.app.mockInput.pressKey("y", { ctrl: true })
expect(confirmed).toEqual(["draft"])
} finally {
await prompt.cleanup()
}
})
@@ -1,323 +0,0 @@
import { describe, expect, test } from "bun:test"
import {
allExpandedFileTreeDirectories,
buildFileTree,
fileTreeFileSelection,
flattenFileTree,
moveFileTreeSelection,
moveFileTreeSelectionToFirstChild,
moveFileTreeSelectionToFile,
moveFileTreeSelectionToParent,
movePatchFileIndex,
orderedPatchFileIndexes,
setFileTreeDirectoryExpanded,
showDiffViewerFileTree,
singlePatchFileIndex,
toggleFileTreeDirectory,
} from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils"
describe("diff viewer file tree utilities", () => {
test("builds a nested tree with deduplicated directories and file indexes", () => {
const tree = buildFileTree([
{ file: "src/config/tui.ts" },
{ file: "src/config/keybind.ts" },
{ file: "src/session/index.ts" },
])
expect(tree.nodes.filter((node) => node.kind === "directory" && node.name === "src")).toHaveLength(1)
expect(tree.nodes.filter((node) => node.kind === "directory" && node.name === "config")).toHaveLength(1)
expect(tree.nodes.filter((node) => node.kind === "directory" && node.name === "session")).toHaveLength(1)
expect(
tree.nodes
.filter((node) => node.kind === "file")
.map((node) => ({ name: node.name, fileIndex: node.fileIndex, depth: node.depth })),
).toEqual([
{ name: "tui.ts", fileIndex: 0, depth: 2 },
{ name: "keybind.ts", fileIndex: 1, depth: 2 },
{ name: "index.ts", fileIndex: 2, depth: 2 },
])
})
test("sorts directories before files and alphabetically within each group", () => {
const rows = flattenFileTree(
buildFileTree([
{ file: "z-file.ts" },
{ file: "b/file.ts" },
{ file: "a/zeta.ts" },
{ file: "b/alpha.ts" },
{ file: "a/alpha.ts" },
]),
)
expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
"directory:a",
" file:alpha.ts",
" file:zeta.ts",
"directory:b",
" file:alpha.ts",
" file:file.ts",
"file:z-file.ts",
])
})
test("sorts root-level files without creating directories", () => {
const tree = buildFileTree([{ file: "zeta.ts" }, { file: "alpha.ts" }, { file: "beta.ts" }])
expect(tree.nodes.every((node) => node.kind === "file")).toBe(true)
expect(flattenFileTree(tree).map((row) => row.name)).toEqual(["alpha.ts", "beta.ts", "zeta.ts"])
})
test("collapses unary directory chains while flattening", () => {
const rows = flattenFileTree(
buildFileTree([{ file: "packages/opencode/src/cli/app.ts" }, { file: "packages/opencode/src/server/server.ts" }]),
)
expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
"directory:packages/opencode/src",
" directory:cli",
" file:app.ts",
" directory:server",
" file:server.ts",
])
})
test("does not collapse a directory into a file row", () => {
const rows = flattenFileTree(buildFileTree([{ file: "packages/opencode/src/app.ts" }]))
expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
"directory:packages/opencode/src",
" file:app.ts",
])
})
test("stops collapsing at branches", () => {
const rows = flattenFileTree(
buildFileTree([
{ file: "packages/opencode/src/cli/app.ts" },
{ file: "packages/opencode/src/server/server.ts" },
{ file: "packages/readme.md" },
]),
)
expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
"directory:packages",
" directory:opencode/src",
" directory:cli",
" file:app.ts",
" directory:server",
" file:server.ts",
" file:readme.md",
])
})
test("keeps same directory names under different parents separate", () => {
const rows = flattenFileTree(
buildFileTree([{ file: "components/button.ts" }, { file: "docs/components/usage.md" }]),
)
expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
"directory:components",
" file:button.ts",
"directory:docs/components",
" file:usage.md",
])
})
test("flattens all-expanded rows depth-first with depths and file references", () => {
const rows = flattenFileTree(
buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/config/keybind.ts" }, { file: "README.md" }]),
)
expect(rows.map((row) => ({ name: row.name, kind: row.kind, depth: row.depth, fileIndex: row.fileIndex }))).toEqual(
[
{ name: "src/config", kind: "directory", depth: 0, fileIndex: undefined },
{ name: "keybind.ts", kind: "file", depth: 1, fileIndex: 1 },
{ name: "tui.ts", kind: "file", depth: 1, fileIndex: 0 },
{ name: "README.md", kind: "file", depth: 0, fileIndex: 2 },
],
)
})
test("collapses expanded unary children under the first visible directory id", () => {
const tree = buildFileTree([
{ file: "packages/opencode/src/cli/app.ts" },
{ file: "packages/opencode/src/server/server.ts" },
])
const packages = tree.nodes.find((node) => node.kind === "directory" && node.name === "packages")!
expect(flattenFileTree(tree, new Set()).map((row) => row.name)).toEqual(["packages/opencode/src"])
expect(flattenFileTree(tree, new Set([packages.id])).map((row) => row.name)).toEqual([
"packages/opencode/src",
"cli",
"server",
])
})
test("flattens only expanded directory descendants when expansion is provided", () => {
const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }])
const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
const config = tree.nodes.find((node) => node.kind === "directory" && node.name === "config")!
expect(flattenFileTree(tree, new Set()).map((row) => row.name)).toEqual(["src", "README.md"])
expect(flattenFileTree(tree, new Set([src.id])).map((row) => row.name)).toEqual([
"src",
"config",
"session",
"README.md",
])
expect(flattenFileTree(tree, new Set([src.id, config.id])).map((row) => row.name)).toEqual([
"src",
"config",
"tui.ts",
"session",
"README.md",
])
})
test("moves selection across visible rows and clamps to bounds", () => {
const rows = flattenFileTree(buildFileTree([{ file: "src/config/tui.ts" }, { file: "README.md" }]))
expect(moveFileTreeSelection(rows, undefined, 1)).toBe(rows[0]!.id)
expect(moveFileTreeSelection(rows, rows[0]!.id, 1)).toBe(rows[1]!.id)
expect(moveFileTreeSelection(rows, rows[1]!.id, 99)).toBe(rows[rows.length - 1]!.id)
expect(moveFileTreeSelection(rows, rows[1]!.id, -99)).toBe(rows[0]!.id)
expect(moveFileTreeSelection([], undefined, 1)).toBeUndefined()
})
test("moves directory selection to first visible child", () => {
const rows = flattenFileTree(buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }]))
const src = rows.find((row) => row.kind === "directory" && row.name === "src")!
const config = rows.find((row) => row.kind === "directory" && row.name === "config")!
const tui = rows.find((row) => row.name === "tui.ts")!
expect(moveFileTreeSelectionToFirstChild(rows, src.id)).toBe(config.id)
expect(moveFileTreeSelectionToFirstChild(rows, tui.id)).toBe(tui.id)
expect(moveFileTreeSelectionToFirstChild(rows, undefined)).toBeUndefined()
})
test("moves collapsed chain selection to first visible child", () => {
const rows = flattenFileTree(
buildFileTree([{ file: "packages/opencode/src/cli/app.ts" }, { file: "packages/opencode/src/server/server.ts" }]),
)
const packages = rows.find((row) => row.kind === "directory" && row.name === "packages/opencode/src")!
const cli = rows.find((row) => row.kind === "directory" && row.name === "cli")!
expect(moveFileTreeSelectionToFirstChild(rows, packages.id)).toBe(cli.id)
})
test("moves file and collapsed directory selection to visible parent", () => {
const rows = flattenFileTree(
buildFileTree([{ file: "packages/opencode/src/cli/app.ts" }, { file: "packages/opencode/src/server/server.ts" }]),
)
const root = rows.find((row) => row.kind === "directory" && row.name === "packages/opencode/src")!
const cli = rows.find((row) => row.kind === "directory" && row.name === "cli")!
const app = rows.find((row) => row.name === "app.ts")!
expect(moveFileTreeSelectionToParent(rows, app.id)).toBe(cli.id)
expect(moveFileTreeSelectionToParent(rows, cli.id)).toBe(root.id)
expect(moveFileTreeSelectionToParent(rows, root.id)).toBe(root.id)
expect(moveFileTreeSelectionToParent(rows, undefined)).toBeUndefined()
})
test("moves file selection relative to the highlighted row", () => {
const rows = flattenFileTree(
buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }]),
)
const config = rows.find((row) => row.kind === "directory" && row.name === "config")!
const session = rows.find((row) => row.kind === "directory" && row.name === "session")!
const tui = rows.find((row) => row.name === "tui.ts")!
const index = rows.find((row) => row.name === "index.ts")!
const readme = rows.find((row) => row.name === "README.md")!
expect(moveFileTreeSelectionToFile(rows, undefined, 1)).toBe(tui.id)
expect(moveFileTreeSelectionToFile(rows, undefined, -1)).toBe(readme.id)
expect(moveFileTreeSelectionToFile(rows, config.id, 1)).toBe(tui.id)
expect(moveFileTreeSelectionToFile(rows, session.id, -1)).toBe(tui.id)
expect(moveFileTreeSelectionToFile(rows, tui.id, 1)).toBe(index.id)
expect(moveFileTreeSelectionToFile(rows, index.id, -1)).toBe(tui.id)
expect(moveFileTreeSelectionToFile(rows, readme.id, 1)).toBe(readme.id)
})
test("selects a file tree node and expands its parents for a patch file", () => {
const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }])
const selection = fileTreeFileSelection(tree, 1)
expect(selection?.highlightedNode).toBe(
tree.nodes.find((node) => node.kind === "file" && node.name === "index.ts")?.id,
)
expect([...selection!.expandedNodes].map((id) => tree.nodes[id]!.name)).toEqual(["session", "src"])
expect(fileTreeFileSelection(tree, 99)).toBeUndefined()
})
test("prefers the selected file when choosing the single patch file", () => {
expect(singlePatchFileIndex(2, 1, 0, 3)).toBe(2)
expect(singlePatchFileIndex(undefined, 1, 0, 3)).toBe(1)
expect(singlePatchFileIndex(undefined, undefined, 0, 3)).toBe(0)
expect(singlePatchFileIndex(undefined, undefined, undefined, 3)).toBe(3)
})
test("orders patches by the flattened file tree order", () => {
const rows = flattenFileTree(
buildFileTree([
{ file: "src/dir-8/juniper-4.ts" },
{ file: "src/dir-8/harbor-94.ts" },
{ file: "src/dir-8/cedar-16.ts" },
]),
)
expect(orderedPatchFileIndexes(rows)).toEqual([2, 1, 0])
})
test("shows the diff viewer file tree only when enabled and files exist", () => {
expect(showDiffViewerFileTree(true, 1)).toBe(true)
expect(showDiffViewerFileTree(true, 0)).toBe(false)
expect(showDiffViewerFileTree(false, 1)).toBe(false)
expect(showDiffViewerFileTree(false, 0)).toBe(false)
})
test("moves patch selection through the ordered patch file indexes", () => {
const fileIndexes = [2, 1, 0]
expect(movePatchFileIndex(fileIndexes, undefined, 1)).toBe(2)
expect(movePatchFileIndex(fileIndexes, undefined, -1)).toBe(2)
expect(movePatchFileIndex(fileIndexes, 2, 1)).toBe(1)
expect(movePatchFileIndex(fileIndexes, 1, -1)).toBe(2)
expect(movePatchFileIndex(fileIndexes, 0, 1)).toBe(0)
expect(movePatchFileIndex(fileIndexes, 99, 1)).toBe(2)
expect(movePatchFileIndex(fileIndexes, 99, -1)).toBe(2)
expect(movePatchFileIndex([], undefined, 1)).toBeUndefined()
})
test("toggles only selected directory expansion", () => {
const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "README.md" }])
const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
const readme = tree.nodes.find((node) => node.kind === "file" && node.name === "README.md")!
const expanded = allExpandedFileTreeDirectories(tree)
const collapsed = toggleFileTreeDirectory(tree, expanded, src.id)
expect(collapsed.has(src.id)).toBe(false)
expect(flattenFileTree(tree, collapsed).map((row) => row.name)).toEqual(["src/config", "README.md"])
const reopened = toggleFileTreeDirectory(tree, collapsed, src.id)
expect(reopened.has(src.id)).toBe(true)
expect(toggleFileTreeDirectory(tree, reopened, readme.id)).toBe(reopened)
expect(toggleFileTreeDirectory(tree, reopened, undefined)).toBe(reopened)
})
test("sets only selected directory expansion", () => {
const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "README.md" }])
const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
const readme = tree.nodes.find((node) => node.kind === "file" && node.name === "README.md")!
const expanded = allExpandedFileTreeDirectories(tree)
const collapsed = setFileTreeDirectoryExpanded(tree, expanded, src.id, false)
expect(collapsed.has(src.id)).toBe(false)
const reopened = setFileTreeDirectoryExpanded(tree, collapsed, src.id, true)
expect(reopened.has(src.id)).toBe(true)
expect(setFileTreeDirectoryExpanded(tree, reopened, readme.id, false)).toBe(reopened)
expect(setFileTreeDirectoryExpanded(tree, reopened, undefined, false)).toBe(reopened)
})
})
@@ -1,197 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { describe, expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { testRender } from "@opentui/solid"
import type { JSX } from "solid-js"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { KVProvider } from "../../../src/cli/cmd/tui/context/kv"
import { ThemeProvider } from "../../../src/cli/cmd/tui/context/theme"
import { TuiConfigProvider } from "../../../src/cli/cmd/tui/context/tui-config"
import { DiffViewerFileTree } from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree"
import {
allExpandedFileTreeDirectories,
buildFileTree,
} from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils"
const theme = {
background: RGBA.fromHex("#000000"),
backgroundPanel: RGBA.fromHex("#111111"),
backgroundElement: RGBA.fromHex("#333333"),
primary: RGBA.fromHex("#00ffff"),
secondary: RGBA.fromHex("#0088ff"),
selectedListItemText: RGBA.fromHex("#ffffff"),
text: RGBA.fromHex("#ffffff"),
textMuted: RGBA.fromHex("#888888"),
error: RGBA.fromHex("#ff0000"),
}
describe("DiffViewerFileTree", () => {
test.skip("renders sorted hierarchical file rows", async () => {
const app = await testRender(
() =>
withTheme(() => (
<DiffViewerFileTree
width={32}
files={[
{ file: "z-file.ts" },
{ file: "b/file.ts" },
{ file: "a/zeta.ts" },
{ file: "b/alpha.ts" },
{ file: "a/alpha.ts" },
]}
loading={false}
error={undefined}
theme={theme}
focused={true}
/>
)),
{ width: 40, height: 20 },
)
try {
await renderOnceSettled(app)
const lines = visibleLines(app.captureCharFrame())
expect(lines).toEqual([
"▾ a",
"│ ├─ alpha.ts ?",
"│ └─ zeta.ts ?",
"├─ ▾ b",
"│ ├─ alpha.ts ?",
"│ └─ file.ts ?",
])
} finally {
app.renderer.destroy()
}
})
test("keeps loading and error quiet while rendering an empty settled state", async () => {
const loading = await renderFrame(() => (
<DiffViewerFileTree width={32} files={[]} loading={true} error={undefined} theme={theme} />
))
const failed = await renderFrame(() => (
<DiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} theme={theme} />
))
const empty = await renderFrame(() => (
<DiffViewerFileTree width={32} files={[]} loading={false} error={undefined} theme={theme} />
))
expect(loading).not.toContain("Loading diff...")
expect(loading).not.toContain("No files")
expect(failed).not.toContain("Failed to load diff")
expect(failed).not.toContain("No files")
expect(empty).toContain("No files")
})
test("does not render text markers for highlighted rows", async () => {
const files = [{ file: "src/config/tui.ts" }, { file: "README.md" }]
const src = buildFileTree(files).nodes.find((node) => node.kind === "directory" && node.name === "src")!
const focused = visibleLines(
await renderFrame(() => (
<DiffViewerFileTree
width={32}
files={files}
loading={false}
error={undefined}
theme={theme}
focused
highlightedNode={src.id}
/>
)),
)
const unfocused = visibleLines(
await renderFrame(() => (
<DiffViewerFileTree width={32} files={files} loading={false} error={undefined} theme={theme} />
)),
)
expect(focused).toContain("▾ src/config")
expect(unfocused).toContain("▾ src/config")
expect(focused.some((line) => line.includes("*"))).toBe(false)
expect(unfocused.some((line) => line.includes("*"))).toBe(false)
})
test("renders collapsed and expanded directory rows", async () => {
const files = [{ file: "src/config/tui.ts" }, { file: "README.md" }]
const tree = buildFileTree(files)
const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
const collapsed = allExpandedFileTreeDirectories(tree)
collapsed.delete(src.id)
expect(
visibleLines(
await renderFrame(() => (
<DiffViewerFileTree
width={32}
files={files}
loading={false}
error={undefined}
theme={theme}
expandedNodes={collapsed}
/>
)),
),
).toEqual(["▸ src/config"])
expect(
visibleLines(
await renderFrame(() => (
<DiffViewerFileTree
files={files}
width={32}
loading={false}
error={undefined}
theme={theme}
expandedNodes={allExpandedFileTreeDirectories(tree)}
/>
)),
),
).toEqual(["▾ src/config", "│ └─ tui.ts ?"])
})
})
async function renderFrame(component: () => JSX.Element) {
const app = await testRender(() => withTheme(component), { width: 40, height: 10 })
try {
await renderOnceSettled(app)
return await captureSettledFrame(app)
} finally {
app.renderer.destroy()
}
}
async function renderOnceSettled(app: Awaited<ReturnType<typeof testRender>>) {
await app.renderOnce()
await new Promise((resolve) => setTimeout(resolve, 25))
await app.renderOnce()
}
async function captureSettledFrame(app: Awaited<ReturnType<typeof testRender>>) {
for (let attempt = 0; attempt < 5; attempt++) {
const frame = app.captureCharFrame()
if (frame.trim().length > 0) return frame
await new Promise((resolve) => setTimeout(resolve, 25))
await app.renderOnce()
}
return app.captureCharFrame()
}
function withTheme(component: () => JSX.Element) {
return (
<TuiConfigProvider config={createTuiResolvedConfig()}>
<KVProvider>
<ThemeProvider mode="dark">{component()}</ThemeProvider>
</KVProvider>
</TuiConfigProvider>
)
}
function visibleLines(frame: string) {
return frame
.split("\n")
.map((line) => line.trimEnd())
.map((line) => line.replace(/^ ?│ ?/, "").replace(/[ │]*$/, ""))
.map((line) => (line.startsWith(" ") ? line.slice(1) : line))
.filter((line) => line.length > 0 && !/^┌|^└|^─+$/.test(line))
}
@@ -1,230 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import path from "path"
import { mkdir } from "fs/promises"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import type { DiffRenderable, Renderable, ScrollBoxRenderable } from "@opentui/core"
import { testRender, useRenderer } from "@opentui/solid"
import { Global } from "@opencode-ai/core/global"
import type { TuiPluginApi, TuiPluginMeta, TuiRouteCurrent, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
import type { Session } from "@opencode-ai/sdk/v2"
import { KVProvider } from "../../../src/cli/cmd/tui/context/kv"
import { ThemeProvider } from "../../../src/cli/cmd/tui/context/theme"
import { TuiConfigProvider } from "../../../src/cli/cmd/tui/context/tui-config"
import { TuiKeybind } from "../../../src/cli/cmd/tui/config/keybind"
import { OpencodeKeymapProvider } from "../../../src/cli/cmd/tui/keymap"
import diffViewerPlugin from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
test("closing the diff viewer returns to the route it opened from", async () => {
const viewer = await renderDiffViewer([])
try {
expect(viewer.current()).toEqual({
name: "diff",
params: { mode: "git", sessionID: "session-1", returnRoute: startRoute },
})
expect(viewer.vcsDiffInput()).toEqual({ directory: "/repo/session", mode: "git", context: 12 })
expect(viewer.commands.has("diff.close")).toBe(true)
viewer.commands.get("diff.close")!.run?.({} as never)
expect(viewer.current()).toEqual(startRoute)
} finally {
viewer.app.renderer.destroy()
}
})
test("brackets navigate diff hunks", async () => {
const viewer = await renderDiffViewer(
[
{
file: "src/file.ts",
additions: 3,
deletions: 3,
status: "modified",
patch: `--- a/src/file.ts
+++ b/src/file.ts
@@ -1,3 +1,3 @@
const first = true
-const oldFirst = true
+const newFirst = true
const afterFirst = true
@@ -20,3 +20,3 @@
const second = true
-const oldSecond = true
+const newSecond = true
const afterSecond = true
@@ -40,3 +40,3 @@
const third = true
-const oldThird = true
+const newThird = true
const afterThird = true`,
},
],
12,
)
try {
await viewer.app.waitForFrame((frame) => frame.includes("const first"))
await viewer.app.waitFor(() => Boolean(findRenderable(viewer.app.renderer.root, "diff-viewer-patches")))
await viewer.app.flush()
const scroll = findRenderable(viewer.app.renderer.root, "diff-viewer-patches") as ScrollBoxRenderable
const diff = findRenderable(viewer.app.renderer.root, "diff-viewer-patch-0") as DiffRenderable
expect(diff.getHunkRowOffsets()).toEqual([0, 4, 8])
const initial = scroll.scrollTop
expect(TuiKeybind.defaultValue("diff_next_hunk")).toBe("]")
expect(TuiKeybind.defaultValue("diff_previous_hunk")).toBe("[")
viewer.commands.get("diff.next_hunk")!.run?.({} as never)
await viewer.app.renderOnce()
const first = scroll.scrollTop
expect(first).toBeGreaterThan(initial)
viewer.commands.get("diff.next_hunk")!.run?.({} as never)
await viewer.app.renderOnce()
const second = scroll.scrollTop
expect(second).toBeGreaterThan(first)
viewer.commands.get("diff.previous_hunk")!.run?.({} as never)
await viewer.app.renderOnce()
expect(scroll.scrollTop).toBe(first)
viewer.commands.get("diff.next_hunk")!.run?.({} as never)
await viewer.app.renderOnce()
expect(scroll.scrollTop).toBe(second)
scroll.scrollTo(initial)
viewer.commands.get("diff.next_hunk")!.run?.({} as never)
await viewer.app.renderOnce()
expect(scroll.scrollTop).toBe(first)
} finally {
viewer.app.renderer.destroy()
}
})
async function renderDiffViewer(vcsDiff: unknown[], height = 20) {
const commands = new Map<
string,
NonNullable<Parameters<TuiPluginApi["keymap"]["registerLayer"]>[0]["commands"]>[number]
>()
let current = startRoute
let renderDiff: TuiRouteDefinition["render"] | undefined
let vcsDiffInput: unknown
const config = createTuiResolvedConfig()
await mkdir(Global.Path.state, { recursive: true })
await Bun.write(path.join(Global.Path.state, "kv.json"), "{}")
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const registerLayer = keymap.registerLayer.bind(keymap)
keymap.registerLayer = (layer) => {
layer.commands?.forEach((command) => commands.set(command.name, command))
return registerLayer(layer)
}
const base = createTuiPluginApi({
keymap,
client: {
vcs: {
diff: async (input: unknown) => {
vcsDiffInput = input
return { data: vcsDiff }
},
},
session: { diff: async () => ({ data: [] }) },
} as unknown as TuiPluginApi["client"],
state: {
session: {
get: () => session,
},
},
})
const api = {
...base,
route: {
register(routes) {
renderDiff = routes.find((route) => route.name === "diff")?.render
return () => {}
},
navigate(name, params) {
current = params ? { name, params } : { name }
},
get current() {
return current
},
},
} satisfies TuiPluginApi
void diffViewerPlugin.tui(api, undefined, pluginMeta)
commands.get("diff.open")?.run?.({} as never)
return (
<OpencodeKeymapProvider keymap={keymap}>
<TuiConfigProvider config={config}>
<KVProvider>
<ThemeProvider mode="dark">
{renderDiff?.({ params: "params" in current ? current.params : undefined })}
</ThemeProvider>
</KVProvider>
</TuiConfigProvider>
</OpencodeKeymapProvider>
)
}
const app = await testRender(() => <Harness />, { width: 80, height })
await waitForCommand(app, commands, "diff.close")
return {
app,
commands,
current: () => current,
vcsDiffInput: () => vcsDiffInput,
}
}
const startRoute: TuiRouteCurrent = { name: "session", params: { sessionID: "session-1" } }
function findRenderable(root: Renderable, id: string): Renderable | undefined {
if (root.id === id) return root
return root
.getChildren()
.map((child) => findRenderable(child, id))
.find(Boolean)
}
const session = {
id: "session-1",
slug: "session-1",
projectID: "project-1",
directory: "/repo/session",
title: "Session",
version: "1",
time: {
created: 0,
updated: 0,
},
} satisfies Session
async function waitForCommand(
app: Awaited<ReturnType<typeof testRender>>,
commands: Map<string, unknown>,
command: string,
) {
for (let attempt = 0; attempt < 10; attempt++) {
await app.renderOnce()
if (commands.has(command)) return
await new Promise((resolve) => setTimeout(resolve, 25))
}
}
const pluginMeta = {
id: "diff-viewer",
source: "internal",
spec: "diff-viewer",
target: "diff-viewer",
first_time: 0,
last_time: 0,
time_changed: 0,
load_count: 1,
fingerprint: "test",
state: "same",
} satisfies TuiPluginMeta
@@ -8,7 +8,7 @@ import {
offsetToPosition,
resolveZedDbPath,
resolveZedSelection,
} from "../../../src/cli/cmd/tui/context/editor-zed"
} from "../../../src/cli/tui/editor-zed"
import { tmpdir } from "../../fixture/fixture"
const originalZedTerm = process.env.ZED_TERM
@@ -3,9 +3,12 @@ import os from "node:os"
import path from "node:path"
import { afterEach, expect, spyOn, test } from "bun:test"
import { createRoot } from "solid-js"
import { EditorContextProvider, useEditorContext } from "../../../src/cli/cmd/tui/context/editor"
import { EditorContextProvider, useEditorContext } from "@opencode-ai/tui/context/editor"
import { tmpdir } from "../../fixture/fixture"
import { FakeWebSocket } from "../../lib/websocket"
import { TestTuiEnvironmentProvider } from "../../fixture/tui-environment"
import { TuiPlatformProvider, type TuiPlatform } from "@opencode-ai/tui/platform"
import { discoverEditorConnection } from "../../../src/cli/tui/platform"
const originalClaudePort = process.env.CLAUDE_CODE_SSE_PORT
const originalOpencodePort = process.env.OPENCODE_EDITOR_SSE_PORT
@@ -31,10 +34,19 @@ function mountEditorContext(WebSocketImpl?: typeof WebSocket) {
return null
}
const value = process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT
return (
<EditorContextProvider WebSocketImpl={WebSocketImpl}>
<Consumer />
</EditorContextProvider>
<TestTuiEnvironmentProvider
cwd={process.cwd()}
paths={{ home: os.homedir() }}
editor={{ port: value ? Number.parseInt(value, 10) : undefined }}
>
<TuiPlatformProvider value={platform}>
<EditorContextProvider WebSocketImpl={WebSocketImpl}>
<Consumer />
</EditorContextProvider>
</TuiPlatformProvider>
</TestTuiEnvironmentProvider>
)
})
@@ -44,6 +56,18 @@ function mountEditorContext(WebSocketImpl?: typeof WebSocket) {
}
}
const platform: TuiPlatform = {
files: {
readText: (file) => Bun.file(file).text(),
readBytes: (file) => Bun.file(file).bytes(),
mime: () => Promise.resolve("application/octet-stream"),
},
editor: {
open: () => Promise.resolve(undefined),
connection: discoverEditorConnection,
},
}
function createWebSocketImpl(...sockets: FakeWebSocket[]) {
let index = 0
@@ -1,232 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import { createSignal, For, Show } from "solid-js"
import type { ScrollBoxRenderable } from "@opentui/core"
import { testRender, type JSX } from "@opentui/solid"
import {
formatCompletedSubagentDetail,
formatSubagentRetry,
formatSubagentTitle,
formatSubagentToolcalls,
InlineToolRow,
} from "../../../src/cli/cmd/tui/routes/session/index"
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined
afterEach(() => {
testSetup?.renderer.destroy()
testSetup = undefined
})
type ToolFixture = { icon: string; label: string; error?: string }
const tools: readonly ToolFixture[] = [
{
icon: "✱",
label:
'Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.*dir|xdg|APPDATA" in packages/opencode/src (151 matches)',
},
{
icon: "✱",
label: 'Glob "**/*db*" in packages/opencode (6 matches)',
},
{
icon: "→",
label: "Read packages/opencode/src/storage/db.ts [offset=1, limit=130]",
},
{
icon: "→",
label: "Read packages/opencode/src/index.ts [offset=1, limit=100]",
error: "No LSP server available for this file type.",
},
{
icon: "✱",
label:
'Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.Path\\.data|data =" in packages/opencode/src (115 matches)',
},
] as const
function ShellOutput() {
return (
<box id="tool-block-shell" marginTop={1} paddingTop={1} paddingBottom={1} paddingLeft={2} gap={1}>
<text paddingLeft={3}># List files</text>
<box gap={1}>
<text>$ ls</text>
<text>file.ts</text>
</box>
</box>
)
}
function UserMessage() {
return (
<box id="message-user">
<box paddingTop={1} paddingBottom={1} paddingLeft={2}>
<text>Check whether the next tool remains separated.</text>
</box>
</box>
)
}
function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" }) {
return (
<box flexDirection="column" width={72}>
<box flexDirection="column">
{props.before === "shell" && <ShellOutput />}
{props.before === "user" && <UserMessage />}
<For each={tools}>
{(item) => (
<InlineToolRow
icon={item.icon}
complete={true}
pending=""
failed={Boolean(item.error)}
error={item.error}
errorExpanded={props.errorExpanded}
separateAfter={(id) => id === "message-user"}
>
{item.label}
</InlineToolRow>
)}
</For>
</box>
</box>
)
}
function SubagentGroupFixture() {
return (
<box flexDirection="column" width={72}>
<InlineToolRow id="tool-inline-before" icon="✱" complete={true} pending="">
Grep "Task" (2 matches)
</InlineToolRow>
<InlineToolRow id="tool-inline-subagent-one" icon="⠙" complete={true} pending="" subagent={true}>
Explore Task Inspect active task spacing
</InlineToolRow>
<InlineToolRow id="tool-inline-subagent-two" icon="✓" complete={true} pending="" subagent={true}>
{"General Task — Confirm completed task spacing\n↳ 1 toolcall · 501ms"}
</InlineToolRow>
<InlineToolRow id="tool-inline-after" icon="→" complete={true} pending="">
Read src/cli/cmd/tui/routes/session/index.tsx
</InlineToolRow>
</box>
)
}
function LoadedReadBeforeSubagentFixture() {
return (
<box flexDirection="column" width={72}>
<InlineToolRow id="tool-inline-read" icon="→" complete={true} pending="">
Read src/cli/cmd/tui/routes/session/index.tsx
</InlineToolRow>
<box id="tool-inline-loaded-read-child" paddingLeft={3}>
<text paddingLeft={3}> Loaded src/cli/cmd/tui/routes/session/tools.tsx</text>
</box>
<InlineToolRow id="tool-inline-subagent-after-read" icon="✓" complete={true} pending="" subagent={true}>
{"Explore Task — Inspect active task spacing\n↳ 1 toolcall · 501ms"}
</InlineToolRow>
</box>
)
}
function StickyScrollFixture(props: { separated: boolean; scroll: (scroll: ScrollBoxRenderable) => void }) {
return (
<scrollbox ref={props.scroll} stickyScroll={true} stickyStart="bottom" height={3} width={72}>
<box height={1}>
<text>First row</text>
</box>
<box height={1}>
<text>Second row</text>
</box>
<Show when={props.separated}>
<box id="text-before-tool">
<text>Assistant text</text>
</box>
</Show>
<InlineToolRow icon="→" complete={true} pending="">
Read src/cli/cmd/tui/routes/session/index.tsx
</InlineToolRow>
</scrollbox>
)
}
async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) {
testSetup = await testRender(component, options)
await testSetup.renderOnce()
return testSetup
.captureCharFrame()
.split("\n")
.map((line) => line.trimEnd())
.join("\n")
.trimEnd()
}
describe("TUI inline tool wrapping", () => {
test("formats completed subagent toolcall details", () => {
expect(formatCompletedSubagentDetail(0, "501ms")).toBe("501ms")
expect(formatCompletedSubagentDetail(1, "501ms")).toBe("1 toolcall · 501ms")
expect(formatCompletedSubagentDetail(2, "501ms")).toBe("2 toolcalls · 501ms")
expect(formatSubagentToolcalls(0)).toBe("0 toolcalls")
})
test("keeps background state attached to the subagent identity", () => {
expect(formatSubagentTitle("Explore", "Inspect renderer", false)).toBe("Explore Task — Inspect renderer")
expect(formatSubagentTitle("Explore", "Inspect renderer", true)).toBe(
"Explore Task (background) — Inspect renderer",
)
})
test("keeps retry status ahead of wrapping messages", () => {
expect(formatSubagentRetry(2, "Rate limited by provider")).toBe("Retrying (attempt 2) · Rate limited by provider")
})
test("snapshots consecutive grep, glob, and read rows at a narrow width", async () => {
expect(await renderFrame(() => <Fixture />, { width: 72, height: 12 })).toMatchSnapshot()
})
test("snapshots expanded tool errors under the tool text", async () => {
expect(await renderFrame(() => <Fixture errorExpanded />, { width: 72, height: 12 })).toMatchSnapshot()
})
test("keeps separation after a shell output block", async () => {
expect(await renderFrame(() => <Fixture before="shell" />, { width: 72, height: 16 })).toMatchSnapshot()
})
test("keeps separation after a padded user message", async () => {
expect(await renderFrame(() => <Fixture before="user" />, { width: 72, height: 14 })).toMatchSnapshot()
})
test("separates a contiguous subagent group from inline tools", async () => {
expect(await renderFrame(() => <SubagentGroupFixture />, { width: 72, height: 10 })).toMatchSnapshot()
})
test("separates a subagent group after an expanded read", async () => {
expect(await renderFrame(() => <LoadedReadBeforeSubagentFixture />, { width: 72, height: 8 })).toMatchSnapshot()
})
test("updates sticky-bottom geometry when a text separator mounts and unmounts", async () => {
const [separated, setSeparated] = createSignal(false)
let scroll: ScrollBoxRenderable | undefined
testSetup = await testRender(
() => <StickyScrollFixture separated={separated()} scroll={(value) => (scroll = value)} />,
{
width: 72,
height: 3,
},
)
await testSetup.renderOnce()
expect(scroll?.scrollHeight).toBe(3)
expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
setSeparated(true)
await testSetup.renderOnce()
expect(scroll?.scrollHeight).toBe(5)
expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
setSeparated(false)
await testSetup.renderOnce()
expect(scroll?.scrollHeight).toBe(3)
expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
})
})
@@ -1,136 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test"
import { onCleanup } from "solid-js"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import {
getOpencodeModeStack,
OPENCODE_BASE_MODE,
OpencodeKeymapProvider,
registerOpencodeKeymap,
} from "@/cli/cmd/tui/keymap"
test("legacy page key aliases compile as page keys", async () => {
const sequences: Record<string, string[][]> = {}
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const config = createTuiResolvedConfig({
keybinds: {
messages_page_up: "pgup",
messages_page_down: "pgdown",
},
})
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
const offLayer = keymap.registerLayer({
bindings: config.keybinds.gather("session", ["session.page.up", "session.page.down"]),
})
const bindings = keymap.getCommandBindings({
visibility: "registered",
commands: ["session.page.up", "session.page.down"],
})
sequences.up =
bindings.get("session.page.up")?.map((binding) => binding.sequence.map((part) => part.stroke.name)) ?? []
sequences.down =
bindings.get("session.page.down")?.map((binding) => binding.sequence.map((part) => part.stroke.name)) ?? []
onCleanup(() => {
offLayer()
offKeymap()
})
return (
<OpencodeKeymapProvider keymap={keymap}>
<box />
</OpencodeKeymapProvider>
)
}
const app = await testRender(() => <Harness />)
try {
expect(sequences).toEqual({
up: [["pageup"]],
down: [["pagedown"]],
})
} finally {
app.renderer.destroy()
}
})
test("mode-less bindings stay active when opencode mode changes", async () => {
const counts: Record<string, Record<string, number>> = {}
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const config = createTuiResolvedConfig()
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
const offGlobal = keymap.registerLayer({
commands: [
{ name: "session.list", run() {} },
{ name: "session.new", run() {} },
{ name: "session.page.up", run() {} },
{ name: "session.first", run() {} },
],
bindings: config.keybinds.gather("test.global", [
"session.list",
"session.new",
"session.page.up",
"session.first",
]),
})
const offBase = keymap.registerLayer({
mode: OPENCODE_BASE_MODE,
commands: [{ name: "model.list", run() {} }],
bindings: config.keybinds.gather("test.base", ["model.list"]),
})
const activeCounts = () =>
Object.fromEntries(
Array.from(
keymap.getCommandBindings({
visibility: "active",
commands: ["session.list", "session.new", "session.page.up", "session.first", "model.list"],
}),
([command, bindings]) => [command, bindings.length],
),
)
counts.base = activeCounts()
const popQuestion = getOpencodeModeStack(keymap).push("question")
counts.question = activeCounts()
popQuestion()
const popAutocomplete = getOpencodeModeStack(keymap).push("autocomplete")
counts.autocomplete = activeCounts()
popAutocomplete()
onCleanup(() => {
offBase()
offGlobal()
offKeymap()
})
return (
<OpencodeKeymapProvider keymap={keymap}>
<box />
</OpencodeKeymapProvider>
)
}
const app = await testRender(() => <Harness />)
try {
expect(counts).toEqual({
base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 1 },
question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 0 },
autocomplete: {
"session.list": 1,
"session.new": 1,
"session.page.up": 2,
"session.first": 2,
"model.list": 0,
},
})
} finally {
app.renderer.destroy()
}
})
@@ -5,9 +5,9 @@ import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
import { TuiConfig } from "../../../src/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
test("adds tui plugin at runtime from spec", async () => {
await using tmp = await tmpdir({
@@ -5,9 +5,9 @@ import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
import { TuiConfig } from "../../../src/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
test("installs plugin without loading it", async () => {
await using tmp = await tmpdir({
@@ -6,7 +6,7 @@ import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { mockTuiRuntime } from "../../fixture/tui-runtime"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
test("runs onDispose callbacks with aborted signal and is idempotent", async () => {
await using tmp = await tmpdir({
@@ -5,10 +5,10 @@ import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
import { TuiConfig } from "../../../src/config/tui"
import { Npm } from "@opencode-ai/core/npm"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
test("loads npm tui plugin from package ./tui export", async () => {
await using tmp = await tmpdir({
@@ -5,9 +5,9 @@ import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
import { TuiConfig } from "../../../src/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
test("skips external tui plugins in pure mode", async () => {
await using tmp = await tmpdir({
@@ -8,12 +8,12 @@ import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig, mockTuiRuntime } from "../../fixture/tui-runtime"
import { Global } from "@opencode-ai/core/global"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
import { TuiConfig } from "../../../src/config/tui"
import { Filesystem } from "@/util/filesystem"
import { PluginLoader } from "../../../src/plugin/loader"
const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
const { allThemes, addTheme } = await import("@opencode-ai/tui/context/theme")
const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
type Row = Record<string, unknown>
@@ -5,9 +5,9 @@ import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
import { TuiConfig } from "../../../src/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
test("toggles plugin runtime state by exported id", async () => {
await using tmp = await tmpdir({
@@ -1,98 +0,0 @@
import { describe, expect, test } from "bun:test"
// Regression test for the prompt submit race in
// packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx (`submit`).
//
// Before the fix, two concurrent `submit()` calls (e.g. a double-pressed
// Enter, or the input's native onSubmit racing another dispatch) each
// passed the `if (!store.prompt.input) return false` guard, each
// `await sdk.client.session.create(...)`, and each only captured
// `inputText = store.prompt.input` AFTER that await. The first invocation
// finished, sent the prompt, and cleared the store; the second invocation,
// now past its await, read the cleared store and sent an empty prompt to a
// second freshly-created session - leaving an orphaned session with the
// user's actual text and a phantom session visible to the user containing
// only an assistant reply.
//
// `submitMirror` below has the exact shape of the production `submit()`
// after the fix: an in-flight `submitting` guard wraps the original body.
// Two concurrent invocations must result in exactly one submission carrying
// the user's text, with no empty-text submission.
type Store = { input: string }
type SubmitResult = { sessionID: string; text: string }
type Harness = {
store: Store
submissions: SubmitResult[]
createSession(): Promise<string>
sendPrompt(sessionID: string, text: string): Promise<void>
}
function createHarness(opts: { sessionCreateDelayMs: number }): Harness {
let sessionCounter = 0
const submissions: SubmitResult[] = []
return {
store: { input: "" },
submissions,
async createSession() {
sessionCounter += 1
const id = `ses_${sessionCounter}`
await Bun.sleep(opts.sessionCreateDelayMs)
return id
},
async sendPrompt(sessionID, text) {
submissions.push({ sessionID, text })
},
}
}
function createSubmit() {
let submitting = false
return async function submit(h: Harness) {
if (submitting) return false
submitting = true
try {
if (!h.store.input) return false
const sessionID = await h.createSession()
const inputText = h.store.input
await h.sendPrompt(sessionID, inputText)
h.store.input = ""
return true
} finally {
submitting = false
}
}
}
describe("Prompt.submit race", () => {
test("concurrent submits must not lose the user's text", async () => {
const submit = createSubmit()
const h = createHarness({ sessionCreateDelayMs: 5 })
h.store.input = "Hello there."
// Two invocations back-to-back, mimicking a double-Enter.
await Promise.all([submit(h), submit(h)])
// Every submission that did make it through must carry the actual user
// text, and no submission may have an empty text payload.
expect(h.submissions.every((s) => s.text === "Hello there.")).toBe(true)
expect(h.submissions.some((s) => s.text === "")).toBe(false)
})
test("a sequential second submit after clear is a no-op, not a phantom session", async () => {
const submit = createSubmit()
const h = createHarness({ sessionCreateDelayMs: 1 })
h.store.input = "Hello there."
await submit(h)
// After the first submission completes, the store is cleared; a second
// Enter on an empty input must not create a phantom session.
await submit(h)
expect(h.submissions).toHaveLength(1)
expect(h.submissions[0].text).toBe("Hello there.")
})
})
@@ -1,35 +0,0 @@
import { describe, expect, test } from "bun:test"
import { getRevertDiffFiles } from "../../../src/cli/cmd/tui/util/revert-diff"
describe("revert diff", () => {
test("prefers the actual file path over /dev/null for added and deleted files", () => {
const files = getRevertDiffFiles(`diff --git a/new.txt b/new.txt
new file mode 100644
index 0000000..3b18e51
--- /dev/null
+++ b/new.txt
@@ -0,0 +1 @@
+new content
diff --git a/old.txt b/old.txt
deleted file mode 100644
index 3b18e51..0000000
--- a/old.txt
+++ /dev/null
@@ -1 +0,0 @@
-old content
`)
expect(files).toEqual([
{
filename: "new.txt",
additions: 1,
deletions: 0,
},
{
filename: "old.txt",
additions: 0,
deletions: 1,
},
])
})
})
@@ -1,50 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { createSlot, createSolidSlotRegistry, testRender, useRenderer } from "@opentui/solid"
import { onMount } from "solid-js"
type Slots = {
prompt: {}
}
test("replace slot mounts plugin content once", async () => {
let mounts = 0
const Probe = () => {
onMount(() => {
mounts += 1
})
return <box />
}
const App = () => {
const renderer = useRenderer()
const reg = createSolidSlotRegistry<Slots>(renderer, {})
const Slot = createSlot(reg)
reg.register({
id: "plugin",
slots: {
prompt() {
return <Probe />
},
},
})
return (
<box>
<Slot name="prompt" mode="replace">
<box />
</Slot>
</box>
)
}
const app = await testRender(() => <App />)
try {
expect(mounts).toBe(1)
} finally {
app.renderer.destroy()
}
})
@@ -1,558 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
import { onMount } from "solid-js"
import { ProjectProvider } from "../../../src/cli/cmd/tui/context/project"
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
import { SyncProviderV2, useSyncV2 } from "../../../src/cli/cmd/tui/context/sync-v2"
import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk"
async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
function global(payload: Event): GlobalEvent {
return { directory, project: "proj_test", payload }
}
function emitTwice(events: ReturnType<typeof createEventSource>, payload: Event) {
const event = global(payload)
events.emit(event)
events.emit(event)
}
test("sync v2 settles pending tools when a live failure arrives", async () => {
const events = createEventSource()
const calls = createFetch()
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
emitTwice(events, {
id: "evt_agent_1",
type: "session.next.agent.switched",
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
})
emitTwice(events, {
id: "evt_model_1",
type: "session.next.model.switched",
properties: {
sessionID: "session-1",
messageID: "msg_model_1",
timestamp: 0,
model: { id: "model-1", providerID: "provider-1" },
},
})
emitTwice(events, {
id: "evt_step_started_1",
type: "session.next.step.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
timestamp: 1,
agent: "build",
model: { id: "model-1", providerID: "provider-1" },
},
})
emitTwice(events, {
id: "evt_input_1",
type: "session.next.tool.input.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
timestamp: 2,
callID: "call-1",
name: "bash",
},
})
emitTwice(events, {
id: "evt_called_1",
type: "session.next.tool.called",
properties: {
sessionID: "session-1",
timestamp: 2,
assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1",
tool: "bash",
input: {},
provider: { executed: false, metadata: { fake: { call: true } } },
},
})
emitTwice(events, {
id: "evt_failed_1",
type: "session.next.tool.failed",
properties: {
sessionID: "session-1",
timestamp: 3,
assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1",
error: { type: "unknown", message: "aborted" },
provider: { executed: false, metadata: { fake: { result: true } } },
},
})
await wait(() => {
const assistant = sync.session.message.fromSession("session-1")[0]
return (
assistant?.type === "assistant" &&
assistant.content[0]?.type === "tool" &&
assistant.content[0].state.status === "error"
)
})
const assistant = sync.session.message.fromSession("session-1")[0]
expect(assistant?.type).toBe("assistant")
if (assistant?.type !== "assistant") return
expect(assistant.id).toBe("msg_explicit_assistant_9")
const tool = assistant.content[0]
expect(tool?.type).toBe("tool")
if (tool?.type !== "tool") return
expect(tool.state.status).toBe("error")
if (tool.state.status !== "error") return
expect(tool.state.error).toEqual({ type: "unknown", message: "aborted" })
expect(tool.state.input).toEqual({})
expect(tool.state.structured).toEqual({})
expect(tool.state.content).toEqual([])
expect(tool.provider).toEqual({
executed: false,
metadata: { fake: { call: true } },
resultMetadata: { fake: { result: true } },
})
expect(sync.session.message.fromSession("session-1").map((message) => message.type)).toEqual([
"assistant",
"model-switched",
"agent-switched",
])
} finally {
app.renderer.destroy()
}
})
test("sync v2 renders admitted prompts only after promotion", async () => {
const events = createEventSource()
const calls = createFetch()
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
emitTwice(events, {
id: "evt_admitted_1",
type: "session.next.prompt.admitted",
properties: {
sessionID: "session-1",
messageID: "msg_user_1",
timestamp: 0,
prompt: { text: "hello" },
delivery: "steer",
},
})
expect(sync.session.message.fromSession("session-1")).toEqual([])
emitTwice(events, {
id: "evt_promoted_1",
type: "session.next.prompt.promoted",
properties: {
sessionID: "session-1",
messageID: "msg_user_1",
timestamp: 1,
prompt: { text: "hello" },
timeCreated: 0,
},
})
await wait(() => sync.session.message.fromSession("session-1").length === 1)
const message = sync.session.message.fromSession("session-1")[0]
expect(message?.type).toBe("user")
if (message?.type !== "user") return
expect(message).toMatchObject({ id: "msg_user_1", text: "hello" })
} finally {
app.renderer.destroy()
}
})
test("sync v2 renders a promoted prompt when admission was missed", async () => {
const events = createEventSource()
const calls = createFetch()
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
emitTwice(events, {
id: "evt_promoted_1",
type: "session.next.prompt.promoted",
properties: {
sessionID: "session-1",
messageID: "msg_user_1",
timestamp: 1,
prompt: { text: "hello" },
timeCreated: 0,
},
})
await wait(() => sync.session.message.fromSession("session-1").length === 1)
expect(sync.session.message.fromSession("session-1")[0]?.id).toBe("msg_user_1")
} finally {
app.renderer.destroy()
}
})
test("sync v2 projects live context updates with their message ID", async () => {
const events = createEventSource()
const calls = createFetch()
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
emitTwice(events, {
id: "evt_context_1",
type: "session.next.context.updated",
properties: {
sessionID: "session-1",
messageID: "msg_context_1",
timestamp: 1,
text: "Updated context",
},
})
await wait(() => sync.session.message.fromSession("session-1").length === 1)
expect(sync.session.message.fromSession("session-1")[0]).toMatchObject({
id: "msg_context_1",
type: "system",
text: "Updated context",
})
} finally {
app.renderer.destroy()
}
})
test("sync v2 preserves live events while snapshot hydration is in flight", async () => {
const events = createEventSource()
const response = Promise.withResolvers<Response>()
const calls = createFetch((url) => {
if (url.pathname === "/api/session/session-1/message") return response.promise
return undefined
})
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
const hydration = sync.session.message.sync("session-1")
emitTwice(events, {
id: "evt_agent_1",
type: "session.next.agent.switched",
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
})
response.resolve(json({ data: [] }))
await hydration
expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([
["msg_agent_1", "agent-switched"],
])
} finally {
app.renderer.destroy()
}
})
test("sync v2 replaces stale cached rows while preserving in-flight live rows", async () => {
const events = createEventSource()
const response = Promise.withResolvers<Response>()
const calls = createFetch((url) => {
if (url.pathname === "/api/session/session-1/message") return response.promise
return undefined
})
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
emitTwice(events, {
id: "evt_promoted_1",
type: "session.next.prompt.promoted",
properties: {
sessionID: "session-1",
messageID: "msg_user_1",
timestamp: 1,
prompt: { text: "stale" },
timeCreated: 0,
},
})
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_user_1")
const hydration = sync.session.message.sync("session-1")
emitTwice(events, {
id: "evt_agent_1",
type: "session.next.agent.switched",
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 2, agent: "build" },
})
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_agent_1")
response.resolve(
json({
data: [{ id: "msg_user_1", type: "user", text: "fresh", time: { created: 0 } }],
}),
)
await hydration
expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([
["msg_agent_1", "agent-switched"],
["msg_user_1", "user"],
])
expect(sync.session.message.fromSession("session-1")[1]).toMatchObject({ text: "fresh" })
} finally {
app.renderer.destroy()
}
})
test("sync v2 preserves snapshot order and metadata for in-flight updates", async () => {
const events = createEventSource()
const response = Promise.withResolvers<Response>()
const calls = createFetch((url) => {
if (url.pathname === "/api/session/session-1/message") return response.promise
return undefined
})
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
emitTwice(events, {
id: "evt_step_older",
type: "session.next.step.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_assistant_older",
timestamp: 0,
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
emitTwice(events, {
id: "evt_step_1",
type: "session.next.step.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_assistant_old",
timestamp: 1,
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_assistant_old")
const hydration = sync.session.message.sync("session-1")
emitTwice(events, {
id: "evt_text_1",
type: "session.next.text.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_assistant_old",
timestamp: 2,
textID: "text-1",
},
})
emitTwice(events, {
id: "evt_text_older",
type: "session.next.text.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_assistant_older",
timestamp: 2,
textID: "text-older",
},
})
await wait(() => {
const messages = sync.session.message.fromSession("session-1")
return messages.every((message) => message.type !== "assistant" || message.content[0]?.type === "text")
})
response.resolve(
json({
data: [
{
id: "msg_assistant_new",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
time: { created: 3 },
},
{
id: "msg_assistant_old",
type: "assistant",
metadata: { source: "snapshot" },
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
time: { created: 1 },
},
],
}),
)
await hydration
emitTwice(events, {
id: "evt_step_late_duplicate",
type: "session.next.step.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_assistant_old",
timestamp: 1,
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
expect(sync.session.message.fromSession("session-1").map((message) => message.id)).toEqual([
"msg_assistant_new",
"msg_assistant_old",
"msg_assistant_older",
])
expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[1]))).toMatchObject({
metadata: { source: "snapshot" },
content: [{ type: "text", id: "text-1", text: "" }],
})
expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[2]))).toMatchObject({
content: [{ type: "text", id: "text-older", text: "" }],
})
} finally {
app.renderer.destroy()
}
})
@@ -1,76 +0,0 @@
import { expect, test } from "bun:test"
import type { TerminalColors } from "@opentui/core"
const { DEFAULT_THEMES, allThemes, addTheme, hasTheme, resolveTheme, terminalMode } = await import(
"../../../src/cli/cmd/tui/context/theme"
)
test("addTheme writes into module theme store", () => {
const name = `plugin-theme-${Date.now()}`
expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true)
expect(allThemes()[name]).toBeDefined()
})
test("addTheme keeps first theme for duplicate names", () => {
const name = `plugin-theme-keep-${Date.now()}`
const one = structuredClone(DEFAULT_THEMES.opencode)
const two = structuredClone(DEFAULT_THEMES.opencode)
one.theme.primary = "#101010"
two.theme.primary = "#fefefe"
expect(addTheme(name, one)).toBe(true)
expect(addTheme(name, two)).toBe(false)
expect(allThemes()[name]).toBeDefined()
expect(allThemes()[name]!.theme.primary).toBe("#101010")
})
test("addTheme ignores entries without a theme object", () => {
const name = `plugin-theme-invalid-${Date.now()}`
expect(addTheme(name, { defs: { a: "#ffffff" } })).toBe(false)
expect(allThemes()[name]).toBeUndefined()
})
test("hasTheme checks theme presence", () => {
const name = `plugin-theme-has-${Date.now()}`
expect(hasTheme(name)).toBe(false)
expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true)
expect(hasTheme(name)).toBe(true)
})
test("resolveTheme rejects circular color refs", () => {
const item = structuredClone(DEFAULT_THEMES.opencode)
item.defs = {
...item.defs,
one: "two",
two: "one",
}
item.theme.primary = "one"
expect(() => resolveTheme(item, "dark")).toThrow("Circular color reference")
})
function terminalColors(defaultBackground: string | null, palette: Array<string | null> = []): TerminalColors {
return {
palette,
defaultForeground: null,
defaultBackground,
cursorColor: null,
mouseForeground: null,
mouseBackground: null,
tekForeground: null,
tekBackground: null,
highlightBackground: null,
highlightForeground: null,
}
}
test("terminalMode derives mode from refreshed background", () => {
expect(terminalMode(terminalColors("#fbf1c7"))).toBe("light")
expect(terminalMode(terminalColors("#1a1b26"))).toBe("dark")
})
test("terminalMode does not derive mode from ANSI slot zero", () => {
expect(terminalMode(terminalColors(null, ["#000000"]))).toBeUndefined()
})
@@ -1,36 +0,0 @@
import { describe, expect, test } from "bun:test"
import { reasoningSummary } from "../../../src/cli/cmd/tui/context/thinking"
describe("reasoningSummary", () => {
test("extracts a leading summary title and leaves markdown body", () => {
expect(reasoningSummary("**Continuing Quality Review**\n\nDetails.\n\n**Next section**\n\nMore.")).toEqual({
title: "Continuing Quality Review",
body: "Details.\n\n**Next section**\n\nMore.",
})
})
test("extracts a completed title before its streamed body arrives", () => {
expect(reasoningSummary("**Continuing Quality Review**")).toEqual({
title: "Continuing Quality Review",
body: "",
})
})
test("preserves markdown-significant indentation in the extracted body", () => {
expect(reasoningSummary("**Continuing Quality Review**\n\n const value = true\n")).toEqual({
title: "Continuing Quality Review",
body: " const value = true",
})
})
test("does not consume ordinary leading bold content", () => {
expect(reasoningSummary("**Important:** keep this in the body.")).toEqual({
title: null,
body: "**Important:** keep this in the body.",
})
})
test("leaves content without a leading title in its body", () => {
expect(reasoningSummary("Details only.")).toEqual({ title: null, body: "Details only." })
})
})
+10 -1
View File
@@ -2,9 +2,18 @@ import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { tmpdir } from "../../fixture/fixture"
import { resolveThreadDirectory } from "../../../src/cli/cmd/tui/thread"
import { resolveThreadDirectory } from "../../../src/cli/cmd/tui"
describe("tui thread", () => {
test("loads the public TUI API and legacy hosts lazily", async () => {
const source = await Bun.file(new URL("../../../src/cli/cmd/tui.ts", import.meta.url)).text()
expect(source).toMatch(/await import\(["']@opencode-ai\/tui["']\)/)
expect(source).toContain('await import("../tui/host")')
expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/)
expect(source).not.toContain('import("./app")')
})
async function check(project?: string) {
await using tmp = await tmpdir({ git: true })
const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link")
@@ -1,426 +0,0 @@
import { describe, expect, test } from "bun:test"
import {
formatAssistantHeader,
formatMessage,
formatPart,
formatTranscript,
} from "../../../src/cli/cmd/tui/util/transcript"
import type { AssistantMessage, Part, Provider, UserMessage } from "@opencode-ai/sdk/v2"
const providers: Provider[] = [
{
id: "anthropic",
name: "Anthropic",
source: "api",
env: [],
options: {},
models: {
"claude-sonnet-4-20250514": {
id: "claude-sonnet-4-20250514",
providerID: "anthropic",
api: {
id: "claude-sonnet-4-20250514",
url: "https://example.com/claude-sonnet-4-20250514",
npm: "@ai-sdk/anthropic",
},
name: "Claude Sonnet 4",
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: {
text: true,
audio: false,
image: true,
video: false,
pdf: true,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
limit: {
context: 200_000,
output: 8_192,
},
status: "active",
options: {},
headers: {},
release_date: "2025-05-14",
},
},
},
]
describe("transcript", () => {
describe("formatAssistantHeader", () => {
const baseMsg: AssistantMessage = {
id: "msg_123",
sessionID: "ses_123",
role: "assistant",
agent: "build",
modelID: "claude-sonnet-4-20250514",
providerID: "anthropic",
mode: "",
parentID: "msg_parent",
path: { cwd: "/test", root: "/test" },
cost: 0.001,
tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1000000, completed: 1005400 },
}
test("includes metadata when enabled", () => {
const result = formatAssistantHeader(baseMsg, true)
expect(result).toBe("## Assistant (Build · claude-sonnet-4-20250514 · 5.4s)\n\n")
})
test("uses model display name when available", () => {
const result = formatAssistantHeader(baseMsg, true, providers)
expect(result).toBe("## Assistant (Build · Claude Sonnet 4 · 5.4s)\n\n")
})
test("excludes metadata when disabled", () => {
const result = formatAssistantHeader(baseMsg, false)
expect(result).toBe("## Assistant\n\n")
})
test("handles missing completed time", () => {
const msg = { ...baseMsg, time: { created: 1000000 } }
const result = formatAssistantHeader(msg as AssistantMessage, true)
expect(result).toBe("## Assistant (Build · claude-sonnet-4-20250514)\n\n")
})
test("titlecases agent name", () => {
const msg = { ...baseMsg, agent: "plan" }
const result = formatAssistantHeader(msg, true)
expect(result).toContain("Plan")
})
})
describe("formatPart", () => {
const options = { thinking: true, toolDetails: true, assistantMetadata: true }
test("formats text part", () => {
const part: Part = {
id: "part_1",
sessionID: "ses_123",
messageID: "msg_123",
type: "text",
text: "Hello world",
}
const result = formatPart(part, options)
expect(result).toBe("Hello world\n\n")
})
test("skips synthetic text parts", () => {
const part: Part = {
id: "part_1",
sessionID: "ses_123",
messageID: "msg_123",
type: "text",
text: "Synthetic content",
synthetic: true,
}
const result = formatPart(part, options)
expect(result).toBe("")
})
test("formats reasoning when thinking enabled", () => {
const part: Part = {
id: "part_1",
sessionID: "ses_123",
messageID: "msg_123",
type: "reasoning",
text: "Let me think...",
time: { start: 1000 },
}
const result = formatPart(part, options)
expect(result).toBe("_Thinking:_\n\nLet me think...\n\n")
})
test("skips reasoning when thinking disabled", () => {
const part: Part = {
id: "part_1",
sessionID: "ses_123",
messageID: "msg_123",
type: "reasoning",
text: "Let me think...",
time: { start: 1000 },
}
const result = formatPart(part, { ...options, thinking: false })
expect(result).toBe("")
})
test("formats tool part with details", () => {
const part: Part = {
id: "part_1",
sessionID: "ses_123",
messageID: "msg_123",
type: "tool",
callID: "call_1",
tool: "bash",
state: {
status: "completed",
input: { command: "ls" },
output: "file1.txt\nfile2.txt",
title: "List files",
metadata: {},
time: { start: 1000, end: 1100 },
},
}
const result = formatPart(part, options)
expect(result).toContain("**Tool: bash**")
expect(result).toContain("**Input:**")
expect(result).toContain('"command": "ls"')
expect(result).toContain("**Output:**")
expect(result).toContain("file1.txt")
})
test("formats tool output containing triple backticks without breaking markdown", () => {
const part: Part = {
id: "part_1",
sessionID: "ses_123",
messageID: "msg_123",
type: "tool",
callID: "call_1",
tool: "bash",
state: {
status: "completed",
input: { command: "echo '```hello```'" },
output: "```hello```",
title: "Echo backticks",
metadata: {},
time: { start: 1000, end: 1100 },
},
}
const result = formatPart(part, options)
// The tool header should not be inside a code block
expect(result).toStartWith("**Tool: bash**\n")
// Input and output should each be in their own code blocks
expect(result).toContain("**Input:**\n```json")
expect(result).toContain("**Output:**\n```\n```hello```\n```")
})
test("formats tool part without details when disabled", () => {
const part: Part = {
id: "part_1",
sessionID: "ses_123",
messageID: "msg_123",
type: "tool",
callID: "call_1",
tool: "bash",
state: {
status: "completed",
input: { command: "ls" },
output: "file1.txt",
title: "List files",
metadata: {},
time: { start: 1000, end: 1100 },
},
}
const result = formatPart(part, { ...options, toolDetails: false })
expect(result).toContain("**Tool: bash**")
expect(result).not.toContain("**Input:**")
expect(result).not.toContain("**Output:**")
})
test("formats tool error", () => {
const part: Part = {
id: "part_1",
sessionID: "ses_123",
messageID: "msg_123",
type: "tool",
callID: "call_1",
tool: "bash",
state: {
status: "error",
input: { command: "invalid" },
error: "Command failed",
time: { start: 1000, end: 1100 },
},
}
const result = formatPart(part, options)
expect(result).toContain("**Error:**")
expect(result).toContain("Command failed")
})
})
describe("formatMessage", () => {
const options = { thinking: true, toolDetails: true, assistantMetadata: true, providers }
test("formats user message", () => {
const msg: UserMessage = {
id: "msg_123",
sessionID: "ses_123",
role: "user",
agent: "build",
model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
time: { created: 1000000 },
}
const parts: Part[] = [{ id: "p1", sessionID: "ses_123", messageID: "msg_123", type: "text", text: "Hello" }]
const result = formatMessage(msg, parts, options)
expect(result).toContain("## User")
expect(result).toContain("Hello")
})
test("formats assistant message with metadata", () => {
const msg: AssistantMessage = {
id: "msg_123",
sessionID: "ses_123",
role: "assistant",
agent: "build",
modelID: "claude-sonnet-4-20250514",
providerID: "anthropic",
mode: "",
parentID: "msg_parent",
path: { cwd: "/test", root: "/test" },
cost: 0.001,
tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1000000, completed: 1005400 },
}
const parts: Part[] = [{ id: "p1", sessionID: "ses_123", messageID: "msg_123", type: "text", text: "Hi there" }]
const result = formatMessage(msg, parts, options)
expect(result).toContain("## Assistant (Build · Claude Sonnet 4 · 5.4s)")
expect(result).toContain("Hi there")
})
})
describe("formatTranscript", () => {
test("formats complete transcript", () => {
const session = {
id: "ses_abc123",
title: "Test Session",
time: { created: 1000000000000, updated: 1000000001000 },
}
const messages = [
{
info: {
id: "msg_1",
sessionID: "ses_abc123",
role: "user" as const,
agent: "build",
model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
time: { created: 1000000000000 },
},
parts: [{ id: "p1", sessionID: "ses_abc123", messageID: "msg_1", type: "text" as const, text: "Hello" }],
},
{
info: {
id: "msg_2",
sessionID: "ses_abc123",
role: "assistant" as const,
agent: "build",
modelID: "claude-sonnet-4-20250514",
providerID: "anthropic",
mode: "",
parentID: "msg_1",
path: { cwd: "/test", root: "/test" },
cost: 0.001,
tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1000000000100, completed: 1000000000600 },
},
parts: [{ id: "p2", sessionID: "ses_abc123", messageID: "msg_2", type: "text" as const, text: "Hi!" }],
},
]
const options = {
thinking: false,
toolDetails: false,
assistantMetadata: true,
providers,
}
const result = formatTranscript(session, messages, options)
expect(result).toContain("# Test Session")
expect(result).toContain("**Session ID:** ses_abc123")
expect(result).toContain("## User")
expect(result).toContain("Hello")
expect(result).toContain("## Assistant (Build · Claude Sonnet 4 · 0.5s)")
expect(result).toContain("Hi!")
expect(result).toContain("---")
})
test("falls back to raw model id when provider data is missing", () => {
const session = {
id: "ses_abc123",
title: "Test Session",
time: { created: 1000000000000, updated: 1000000001000 },
}
const messages = [
{
info: {
id: "msg_1",
sessionID: "ses_abc123",
role: "assistant" as const,
agent: "build",
modelID: "claude-sonnet-4-20250514",
providerID: "anthropic",
mode: "",
parentID: "msg_0",
path: { cwd: "/test", root: "/test" },
cost: 0.001,
tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1000000000100, completed: 1000000000600 },
},
parts: [{ id: "p1", sessionID: "ses_abc123", messageID: "msg_1", type: "text" as const, text: "Response" }],
},
]
const result = formatTranscript(session, messages, {
thinking: false,
toolDetails: false,
assistantMetadata: true,
})
expect(result).toContain("## Assistant (Build · claude-sonnet-4-20250514 · 0.5s)")
})
test("formats transcript without assistant metadata", () => {
const session = {
id: "ses_abc123",
title: "Test Session",
time: { created: 1000000000000, updated: 1000000001000 },
}
const messages = [
{
info: {
id: "msg_1",
sessionID: "ses_abc123",
role: "assistant" as const,
agent: "build",
modelID: "claude-sonnet-4-20250514",
providerID: "anthropic",
mode: "",
parentID: "msg_0",
path: { cwd: "/test", root: "/test" },
cost: 0.001,
tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1000000000100, completed: 1000000000600 },
},
parts: [{ id: "p1", sessionID: "ses_abc123", messageID: "msg_1", type: "text" as const, text: "Response" }],
},
]
const options = { thinking: false, toolDetails: false, assistantMetadata: false }
const result = formatTranscript(session, messages, options)
expect(result).toContain("## Assistant\n\n")
expect(result).not.toContain("Build")
expect(result).not.toContain("claude-sonnet-4-20250514")
})
})
})
@@ -1,145 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { describe, expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
import { onMount } from "solid-js"
import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
import { useEvent } from "../../../src/cli/cmd/tui/context/event"
import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
const projectID = "proj_test"
async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
function event(payload: Event, input: { directory: string; project?: string; workspace?: string }): GlobalEvent {
return {
directory: input.directory,
project: input.project,
workspace: input.workspace,
payload,
}
}
function vcs(branch: string): Event {
return {
id: `evt_vcs_${branch}`,
type: "vcs.branch.updated",
properties: {
branch,
},
}
}
function update(version: string): Event {
return {
id: `evt_update_${version}`,
type: "installation.update-available",
properties: {
version,
},
}
}
async function mount() {
const events = createEventSource()
const calls = createFetch()
const seen: Event[] = []
const workspaces: Array<string | undefined> = []
let project!: ReturnType<typeof useProject>
let done!: () => void
const ready = new Promise<void>((resolve) => {
done = resolve
})
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<Probe
onReady={async (ctx) => {
project = ctx.project
await project.sync()
done()
}}
seen={seen}
workspaces={workspaces}
/>
</ProjectProvider>
</SDKProvider>
))
await ready
return { app, emit: events.emit, project, seen, workspaces }
}
function Probe(props: {
seen: Event[]
workspaces: Array<string | undefined>
onReady: (ctx: { project: ReturnType<typeof useProject> }) => void
}) {
const project = useProject()
const event = useEvent()
onMount(() => {
event.subscribe((evt, { workspace }) => {
props.seen.push(evt)
props.workspaces.push(workspace)
})
props.onReady({ project })
})
return <box />
}
describe("useEvent", () => {
test("delivers events for the current project", async () => {
const { app, emit, seen, workspaces } = await mount()
try {
emit(event(vcs("main"), { directory: "/tmp/other", project: projectID, workspace: "ws_a" }))
await wait(() => seen.length === 1)
expect(seen).toEqual([vcs("main")])
expect(workspaces).toEqual(["ws_a"])
} finally {
app.renderer.destroy()
}
})
test("delivers current project events regardless of active workspace", async () => {
const { app, emit, project, seen } = await mount()
try {
project.workspace.set("ws_a")
emit(event(vcs("ws"), { directory: "/tmp/other", project: projectID, workspace: "ws_b" }))
await wait(() => seen.length === 1)
expect(seen).toEqual([vcs("ws")])
} finally {
app.renderer.destroy()
}
})
test("delivers truly global events even when a workspace is active", async () => {
const { app, emit, project, seen } = await mount()
try {
project.workspace.set("ws_a")
emit(event(update("1.2.3"), { directory: "global" }))
await wait(() => seen.length === 1)
expect(seen).toEqual([update("1.2.3")])
} finally {
app.renderer.destroy()
}
})
})