chore: merge v2 into service channel config

This commit is contained in:
Dax Raad 2026-07-21 10:28:58 -04:00
commit e76b29c0b4
1174 changed files with 21121 additions and 336917 deletions

View file

@ -121,7 +121,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
await task
expect(stdout).toContain("Renamed session")
expect(stdout).toContain("opencode -s dummy")
expect(stdout).toContain("opencode2 -s dummy")
} finally {
process.stdout.write = originalWrite
if (!setup.renderer.isDestroyed) setup.renderer.destroy()

View file

@ -272,3 +272,27 @@ test("selects a repopulated option after removing the only option", async () =>
select.app.renderer.destroy()
}
})
test("keeps the cursor index while options are temporarily empty", async () => {
await using tmp = await tmpdir()
const options = ["first", "second", "third"].map((value) => ({ title: value, value }))
const select = await mountSelect(tmp.path, options)
try {
select.app.mockInput.pressArrow("down")
await select.app.waitFor(() => select.moved.at(-1) === "second")
select.app.mockInput.pressArrow("down")
await select.app.waitFor(() => select.moved.at(-1) === "third")
select.replaceOptions([])
await select.app.waitForFrame((frame) => frame.includes("No items available"))
select.replaceOptions(options)
await select.app.waitForFrame((frame) => frame.includes("third"))
select.app.mockInput.pressEnter()
await select.app.waitFor(() => select.selected.length === 1)
expect(select.selected).toEqual(["third"])
} finally {
select.app.renderer.destroy()
}
})

View file

@ -1,6 +1,5 @@
/** @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 { onMount, type ParentProps } from "solid-js"
@ -14,18 +13,6 @@ import {
buildFileTree,
} from "../../../src/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 lines = visibleLines(
@ -41,7 +28,6 @@ describe("DiffViewerFileTree", () => {
]}
loading={false}
error={undefined}
theme={theme}
focused={true}
/>
)),
@ -59,13 +45,13 @@ describe("DiffViewerFileTree", () => {
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} />
<DiffViewerFileTree width={32} files={[]} loading={true} error={undefined} />
))
const failed = await renderFrame(() => (
<DiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} theme={theme} />
<DiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} />
))
const empty = await renderFrame(() => (
<DiffViewerFileTree width={32} files={[]} loading={false} error={undefined} theme={theme} />
<DiffViewerFileTree width={32} files={[]} loading={false} error={undefined} />
))
expect(loading).not.toContain("Loading diff...")
@ -86,16 +72,13 @@ describe("DiffViewerFileTree", () => {
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} />
)),
await renderFrame(() => <DiffViewerFileTree width={32} files={files} loading={false} error={undefined} />),
)
expect(focused).toContain("▾ src/config")
@ -114,14 +97,7 @@ describe("DiffViewerFileTree", () => {
expect(
visibleLines(
await renderFrame(() => (
<DiffViewerFileTree
width={32}
files={files}
loading={false}
error={undefined}
theme={theme}
expandedNodes={collapsed}
/>
<DiffViewerFileTree width={32} files={files} loading={false} error={undefined} expandedNodes={collapsed} />
)),
),
).toEqual(["▸ src/config"])
@ -134,7 +110,6 @@ describe("DiffViewerFileTree", () => {
width={32}
loading={false}
error={undefined}
theme={theme}
expandedNodes={allExpandedFileTreeDirectories(tree)}
/>
)),

View file

@ -0,0 +1,72 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { DEFAULT_THEMES } from "../../../src/theme"
import { ConfigProvider } from "../../../src/config"
import { ThemeProvider, useTheme } from "../../../src/context/theme"
async function wait(fn: () => boolean) {
const started = Date.now()
while (!fn()) {
if (Date.now() - started > 2000) throw new Error("timed out waiting for theme mode")
await Bun.sleep(10)
}
}
test("uses an available mode while retaining the pinned preference", async () => {
const lightOnly = structuredClone(DEFAULT_THEMES.opencode)
lightOnly.theme.background = "#eeeeee"
lightOnly.theme.text = "#111111"
const dual = structuredClone(DEFAULT_THEMES.opencode)
dual.theme.background = { light: "#eeeeee", dark: "#111111" }
dual.theme.text = { light: "#111111", dark: "#eeeeee" }
const darkOnly = structuredClone(DEFAULT_THEMES.opencode)
darkOnly.theme.background = "#111111"
darkOnly.theme.text = "#eeeeee"
let theme: ReturnType<typeof useTheme> | undefined
function Probe() {
const value = useTheme()
theme = value
return <text>{value.mode()}</text>
}
function current() {
if (!theme) throw new Error("Theme provider is not mounted")
return theme
}
const app = await testRender(
() => (
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "light-only", mode: "dark" } })}>
<ThemeProvider
mode="dark"
source={{ discover: () => Promise.resolve({ "light-only": lightOnly, "dark-only": darkOnly, dual }) }}
>
<Probe />
</ThemeProvider>
</ConfigProvider>
),
{ width: 20, height: 2 },
)
app.renderer.start()
try {
await wait(() => theme?.ready === true)
expect(current().mode()).toBe("light")
expect(current().modes()).toEqual(["light"])
expect(current().supports("dark")).toBeFalse()
expect(current().setMode("dark")).toBeFalse()
expect(current().set("dark-only")).toBeTrue()
await wait(() => current().mode() === "dark")
expect(current().modes()).toEqual(["dark"])
expect(current().set("light-only")).toBeTrue()
await wait(() => current().mode() === "light")
expect(current().set("dual")).toBeTrue()
await wait(() => current().mode() === "dark")
expect(current().modes()).toEqual(["light", "dark"])
} finally {
app.renderer.destroy()
}
})

View file

@ -1,6 +1,7 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client/promise"
import { loadRunReferences, runProviders, waitForDefaultModel } from "../../src/mini/catalog.shared"
import { catalogModel, catalogProvider } from "./fixture/catalog"
afterEach(() => {
mock.restore()
@ -58,45 +59,15 @@ describe("run catalog shared", () => {
test("merges current providers and models into the footer catalog shape", () => {
const providers = runProviders(
[catalogProvider("openai", "OpenAI")],
[
{
id: "openai",
name: "OpenAI",
package: "",
},
],
[
{
catalogModel({
id: "gpt-5",
modelID: "openai",
providerID: "openai",
name: "Little Frank",
capabilities: {
tools: true,
input: ["text"],
output: ["text"],
},
variants: [{ id: "high" }],
time: {
released: 1,
},
cost: [
{
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
],
status: "active",
enabled: true,
limit: {
context: 128000,
output: 8192,
},
},
variants: ["high"],
}),
],
)
@ -110,6 +81,9 @@ describe("run catalog shared", () => {
cost: {
input: 0,
},
limit: {
context: 128_000,
},
status: "active",
variants: {
high: {},

View file

@ -2,30 +2,12 @@ import { describe, expect, test } from "bun:test"
import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { entryBody, entryCanStream, entryDone } from "../../src/mini/entry.body"
import type { StreamCommit, ToolSnapshot } from "../../src/mini/types"
import { canonicalToolPart } from "./fixture/tool-part"
function commit(input: Partial<StreamCommit> & Pick<StreamCommit, "kind" | "text" | "phase" | "source">): StreamCommit {
return input
}
function toolPart(
name: string,
state: SessionMessageAssistantTool["state"],
id = `${name}-1`,
): SessionMessageAssistantTool {
return {
type: "tool",
id,
name,
state,
time:
state.status === "streaming"
? { created: 1 }
: state.status === "completed" || state.status === "error"
? { created: 1, ran: 1, completed: 2 }
: { created: 1, ran: 1 },
}
}
function toolCommit(input: {
tool: string
state: SessionMessageAssistantTool["state"]
@ -45,7 +27,7 @@ function toolCommit(input: {
input.toolState ??
(input.state.status === "error" ? "error" : input.state.status === "completed" ? "completed" : "running"),
messageID: input.messageID,
part: toolPart(input.tool, input.state, input.id),
part: canonicalToolPart(input.tool, input.state, input.id),
})
}

View file

@ -0,0 +1,67 @@
import { spyOn } from "bun:test"
import type {
LocationRef,
ModelListOutput,
OpenCodeClient,
ProviderListOutput,
} from "@opencode-ai/client/promise"
export function catalogProvider(id: string, name: string): ProviderListOutput["data"][number] {
return {
id,
name,
package: "",
}
}
export function catalogModel(input: {
id: string
modelID?: string
providerID: string
name?: string
context?: number
variants?: string[]
}): ModelListOutput["data"][number] {
return {
id: input.id,
modelID: input.modelID ?? input.id,
providerID: input.providerID,
name: input.name ?? input.id,
capabilities: {
tools: true,
input: ["text"],
output: ["text"],
},
variants: (input.variants ?? []).map((id) => ({ id })),
time: { released: 1 },
cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
status: "active",
enabled: true,
limit: { context: input.context ?? 128_000, output: 8_192 },
}
}
export function stubCatalogLists(
sdk: OpenCodeClient,
input: {
location?: LocationRef
providers?: ProviderListOutput["data"]
models?: ModelListOutput["data"]
} = {},
) {
const location = {
directory: input.location?.directory ?? "/tmp",
workspaceID: input.location?.workspaceID,
project: { id: "proj_1", directory: input.location?.directory ?? "/tmp" },
}
const empty = { location, data: [] }
return {
provider: spyOn(sdk.provider, "list").mockResolvedValue({ location, data: input.providers ?? [] } as never),
model: spyOn(sdk.model, "list").mockResolvedValue({ location, data: input.models ?? [] } as never),
agent: spyOn(sdk.agent, "list").mockResolvedValue(empty as never),
reference: spyOn(sdk.reference, "list").mockResolvedValue(empty as never),
command: spyOn(sdk.command, "list").mockResolvedValue(empty as never),
skill: spyOn(sdk.skill, "list").mockResolvedValue(empty as never),
}
}

View file

@ -0,0 +1,58 @@
import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "../../../src/mini/types"
export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?: StreamCommit[] } = {}) {
const prompts = new Set<(input: RunPrompt) => void>()
const closes = new Set<() => void>()
const events = input.events ?? []
const commits = input.commits ?? []
const calls: Array<{ type: "event"; value: FooterEvent } | { type: "commit"; value: StreamCommit }> = []
let closed = false
const api: FooterApi = {
get isClosed() {
return closed
},
onPrompt(fn) {
prompts.add(fn)
return () => prompts.delete(fn)
},
onClose(fn) {
if (closed) {
fn()
return () => {}
}
closes.add(fn)
return () => closes.delete(fn)
},
event(next) {
events.push(next)
calls.push({ type: "event", value: next })
},
append(next) {
commits.push(next)
calls.push({ type: "commit", value: next })
},
idle: () => Promise.resolve(),
close() {
if (closed) return
closed = true
for (const fn of [...closes]) fn()
},
destroy() {
api.close()
prompts.clear()
closes.clear()
},
}
return {
api,
events,
commits,
calls,
submit(text: string, mode?: RunPrompt["mode"]) {
const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
for (const fn of [...prompts]) fn(prompt)
},
}
}

View file

@ -0,0 +1,20 @@
import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"
export function canonicalToolPart(
name: string,
state: SessionMessageAssistantTool["state"],
id = `${name}-1`,
): SessionMessageAssistantTool {
return {
type: "tool",
id,
name,
state,
time:
state.status === "streaming"
? { created: 1 }
: state.status === "completed" || state.status === "error"
? { created: 1, ran: 1, completed: 2 }
: { created: 1, ran: 1 },
}
}

View file

@ -12,7 +12,6 @@ test("down opens subagents from an empty prompt", async () => {
const [state] = createSignal<FooterState>({
phase: "idle",
status: "",
queue: 0,
model: "gpt-5",
usage: "",
first: false,
@ -70,7 +69,6 @@ test("down opens subagents from an empty prompt", async () => {
onRows={() => {}}
onLayout={() => {}}
onStatus={() => {}}
onQueuedRemove={async () => true}
/>
</Keymap.Provider>
)

View file

@ -92,7 +92,6 @@ function footerState(input: Partial<FooterState> = {}) {
return createSignal<FooterState>({
phase: "idle",
status: "",
queue: 0,
model: "gpt-5",
usage: "",
first: false,
@ -158,7 +157,6 @@ async function renderFooter(
onRows={() => {}}
onLayout={() => {}}
onStatus={() => {}}
onQueuedRemove={async () => true}
/>
</Keymap.Provider>
)
@ -261,15 +259,9 @@ function footerComposerFrame(root: BoxRenderable | RootRenderable) {
function footerStatusline(root: BoxRenderable | RootRenderable) {
const status = (RUN_THEME_FALLBACK.footer.status as RGBA).toInts()
const accent = (RUN_THEME_FALLBACK.footer.statusAccent as RGBA).toInts()
const boxes = root.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable)
for (const box of boxes) {
const first = box.getChildren().find((item): item is BoxRenderable => item instanceof BoxRenderable)
if (
box.backgroundColor?.toInts().every((value, index) => value === status[index]) &&
first?.backgroundColor?.toInts().every((value, index) => value === accent[index])
)
return box
if (box.backgroundColor?.toInts().every((value, index) => value === status[index])) return box
boxes.push(...box.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable))
}
throw new Error("Footer statusline not found")
@ -422,6 +414,7 @@ test("direct skill panel renders searchable skill list", async () => {
command({ name: "internal", description: "Skill command", source: "skill" }),
command({ name: "formatter", description: "Apply formatter fixes", source: "skill" }),
])
const selected: string[] = []
const app = await testRender(
() => (
@ -430,7 +423,9 @@ test("direct skill panel renders searchable skill list", async () => {
theme={() => RUN_THEME_FALLBACK.footer}
commands={commands}
onClose={() => {}}
onSelect={() => {}}
onSelect={(name) => {
selected.push(name)
}}
/>
</box>
),
@ -451,6 +446,11 @@ test("direct skill panel renders searchable skill list", async () => {
expect(frame).toContain("formatter")
expect(frame).toContain("Apply formatter fixes")
expect(frame).not.toContain("review")
await app.mockInput.typeText("format")
await app.renderOnce()
expect(app.captureCharFrame()).not.toContain("internal")
app.mockInput.pressEnter()
expect(selected).toEqual(["formatter"])
} finally {
app.renderer.destroy()
}
@ -672,8 +672,15 @@ test("direct subagent panel closes when moving up from the first item", async ()
}
})
test("direct queued prompt panel renders pending prompt actions", async () => {
const [prompts] = createSignal([{ messageID: "m-1", prompt: { text: "fix the auth test", parts: [] } }])
test("direct pending panel shows durable delivery without edit actions", async () => {
const [prompts] = createSignal([
{
messageID: "m-1",
prompt: { text: "fix the auth test", parts: [] },
delivery: "queue" as const,
admittedSeq: 1,
},
])
const app = await testRender(
() => (
@ -682,8 +689,6 @@ test("direct queued prompt panel renders pending prompt actions", async () => {
theme={() => RUN_THEME_FALLBACK.footer}
prompts={prompts}
onClose={() => {}}
onEdit={() => {}}
onDelete={() => {}}
/>
</box>
),
@ -695,12 +700,14 @@ test("direct queued prompt panel renders pending prompt actions", async () => {
const frame = app.captureCharFrame()
const list = panelMenu(app.renderer.root)
expect(frame).toContain("Queued prompts")
expect(frame).toContain("Pending work")
expect(frame).toContain("fix the auth test")
expect(frame).toContain("queued")
expect(frame).toContain("queue")
expect(frame).not.toContain("┌")
expect(frame).not.toContain("┃")
expectPaletteList(list, 0)
expect(frame).not.toContain("edit")
expect(frame).not.toContain("remove")
} finally {
app.renderer.destroy()
}
@ -981,11 +988,10 @@ test.skip("direct footer clears the synthetic skills draft when the panel closes
}
})
test("direct footer shows editable prompts and additional queued work while running", async () => {
test("direct footer shows authoritative pending work while running", async () => {
const [state] = createSignal<FooterState>({
phase: "running",
status: "",
queue: 3,
model: "gpt-5",
usage: "",
first: false,
@ -1018,7 +1024,14 @@ test("direct footer shows editable prompts and additional queued work while runn
state={state}
view={view}
subagent={subagents}
queuedPrompts={() => [{ messageID: "m-queued", prompt: { text: "follow up", parts: [] } }]}
queuedPrompts={() => [
{
messageID: "m-queued",
prompt: { text: "follow up", parts: [] },
delivery: "queue",
admittedSeq: 1,
},
]}
theme={() => RUN_THEME_FALLBACK}
tuiConfig={tuiConfig}
onSubmit={() => true}
@ -1035,7 +1048,6 @@ test("direct footer shows editable prompts and additional queued work while runn
onRows={() => {}}
onLayout={() => {}}
onStatus={() => {}}
onQueuedRemove={async () => true}
/>
</Keymap.Provider>
)
@ -1058,30 +1070,25 @@ test("direct footer shows editable prompts and additional queued work while runn
const frame = app.captureCharFrame()
const transparent = RGBA.fromValues(0, 0, 0, 0).toInts()
const tinted = (RUN_THEME_FALLBACK.footer.status as RGBA).toInts()
const accent = (RUN_THEME_FALLBACK.footer.statusAccent as RGBA).toInts()
const statusline = footerStatusline(app.renderer.root)
const statusItems = statusline.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable)
const mode = statusItems[0]
const main = statusItems[1]
const main = statusItems[0]
const spinner = main.getChildren()[0]
const model = statusItems[2]
const queued = statusItems[3]
const background = statusItems[1]
const queued = statusItems[2]
const hint = statusItems.at(-1)!
expect(spinner).toBeDefined()
expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
expect(frame).toContain("3 queued")
expect(frame).toContain("1 pending")
expect(frame).toContain("ctrl+b background")
expect(frame).toContain("ctrl+x q 3 queued")
expect(frame).toContain("ctrl+x q 1 pending")
expect(frame).toContain("↓ subagents")
expect(frame).toContain("ctrl+p cmd")
expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
expect(frame).toContain("subagents · ctrl+p cmd")
expect(frame).not.toContain("1 agent")
expect(statusline.backgroundColor.toInts()).toEqual(tinted)
expect(mode.backgroundColor.toInts()).toEqual(accent)
expect(main.backgroundColor.toInts()).toEqual(transparent)
expect(model.backgroundColor.toInts()).toEqual(transparent)
expect(background.backgroundColor.toInts()).toEqual(transparent)
expect(queued.backgroundColor.toInts()).toEqual(transparent)
expect(hint.backgroundColor.toInts()).toEqual(transparent)
} finally {
@ -1109,9 +1116,7 @@ test("direct footer always offers backgrounding for a foreground subagent", asyn
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("GPT-5")
expect(frame).toContain("xhigh · ctrl+b background · ↓ subagents · ctrl+p cmd")
expect(frame).toContain("ctrl+b background")
expect(frame).toContain("ctrl+b background · ↓ subagents · ctrl+p cmd")
expect(frame).not.toContain("queued")
} finally {
app.cleanup()
@ -1136,8 +1141,7 @@ test("direct footer hides the subagent hint when only completed subagents remain
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("GPT-5")
expect(frame).toContain("xhigh · ctrl+p cmd")
expect(frame).toContain("ctrl+p cmd")
expect(frame).not.toContain("↓ subagents")
} finally {
app.cleanup()
@ -1176,7 +1180,7 @@ test("direct footer shows full usage metadata when room is available", async ()
}
})
test("direct footer mode label keeps left padding without a status pill", async () => {
test("direct footer does not label normal mode as build", async () => {
const app = await renderFooter()
try {
@ -1184,10 +1188,10 @@ test("direct footer mode label keeps left padding without a status pill", async
const statusline = app
.captureCharFrame()
.split("\n")
.find((line) => line.includes("BUILD") && line.includes("cmd"))
.find((line) => line.includes("cmd"))
expect(statusline).toBeDefined()
expect(statusline?.startsWith(" BUILD ")).toBe(true)
expect(statusline).not.toContain("BUILD")
} finally {
app.cleanup()
}

View file

@ -9,7 +9,6 @@ describe("run footer width", () => {
expect(narrow.statusline.showCommandHint).toBe(true)
expect(narrow.statusline.showContextHints).toBe(false)
expect(narrow.statusline.contextHintLimit).toBe(0)
expect(narrow.statusline.showModel).toBe(false)
const command = footerWidthPolicy(65)
expect(command.statusline.showCommandHint).toBe(false)
@ -22,14 +21,11 @@ describe("run footer width", () => {
expect(compact.statusline.showActivityMeta).toBe(true)
expect(compact.statusline.showContextHints).toBe(true)
expect(compact.statusline.contextHintLimit).toBe(1)
expect(compact.statusline.showModel).toBe(false)
const model = footerWidthPolicy(120)
expect(model.statusline.contextHintLimit).toBe(2)
expect(model.statusline.showModel).toBe(true)
const context = footerWidthPolicy(120)
expect(context.statusline.contextHintLimit).toBe(2)
const spacious = footerWidthPolicy(150)
expect(spacious.statusline.contextHintLimit).toBeUndefined()
expect(spacious.statusline.showModel).toBe(true)
})
})

View file

@ -9,6 +9,7 @@ import {
permissionRun,
} from "../../src/mini/permission.shared"
import type { MiniPermissionRequest } from "../../src/mini/types"
import { canonicalToolPart } from "./fixture/tool-part"
function req(input: Partial<MiniPermissionRequest> = {}): MiniPermissionRequest {
return {
@ -89,18 +90,16 @@ describe("run permission shared", () => {
req({
action: "shell",
source: { type: "tool", messageID: "msg-shell", callID: "call-shell" },
tool: {
type: "tool",
id: "call-shell",
name: "shell",
state: {
tool: canonicalToolPart(
"shell",
{
status: "running",
input: { command: "git status --short" },
structured: {},
content: [],
},
time: { created: 1, ran: 1 },
},
"call-shell",
),
}),
),
).toMatchObject({
@ -137,18 +136,16 @@ describe("run permission shared", () => {
action: "websearch",
metadata: { provider: "parallel" },
source: { type: "tool", messageID: "msg-search", callID: "call-search" },
tool: {
type: "tool",
id: "call-search",
name: "websearch",
state: {
tool: canonicalToolPart(
"websearch",
{
status: "running",
input: { query: "current releases" },
structured: { provider: "exa", retained: true },
content: [],
},
time: { created: 1, ran: 1 },
},
"call-search",
),
}),
),
).toMatchObject({
@ -164,18 +161,16 @@ describe("run permission shared", () => {
action: "edit",
resources: ["src/index.ts"],
source: { type: "tool", messageID: "msg-edit", callID: "call-edit" },
tool: {
type: "tool",
id: "call-edit",
name: "edit",
state: {
tool: canonicalToolPart(
"edit",
{
status: "running",
input: { patchText: "*** Begin Patch\n*** Update File: src/index.ts\n@@\n-old\n+new\n*** End Patch" },
structured: {},
content: [],
},
time: { created: 1, ran: 1 },
},
"call-edit",
),
}),
),
).toMatchObject({

View file

@ -98,4 +98,19 @@ describe("run prompt editor helpers", () => {
},
])
})
test("uses display offsets when realigning Mini parts", () => {
const part = {
type: "agent",
name: "helper",
source: { start: 0, end: 7, value: "@helper" },
} satisfies RunPromptPart
expect(realignEditorPromptParts("中文🙂\n@helper", [part])).toEqual([
{
...part,
source: { start: 7, end: 14, value: "@helper" },
},
])
})
})

View file

@ -2,67 +2,9 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client/promise"
import type { Resolved } from "../../src/config"
import { resolveModelInfo, resolveRunTuiConfig } from "../../src/mini/runtime.boot"
import { catalogModel, catalogProvider } from "./fixture/catalog"
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
function ok<T>(data: T) {
return Promise.resolve(data)
}
function provider(id: string, name: string) {
return {
id,
name,
api: { type: "native" as const, settings: {} },
request: { headers: {}, body: {} },
}
}
function model(id: string, providerID: string, context: number, variants: string[] = []) {
return {
id,
providerID,
api: {
id: providerID,
type: "native" as const,
settings: {},
},
name: id,
capabilities: {
tools: true,
input: ["text"],
output: ["text"],
},
request: {
headers: {},
body: {},
},
variants: variants.map((variant) => ({
id: variant,
headers: {},
body: {},
})),
time: {
released: 1,
},
cost: [
{
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
],
limit: {
context,
output: 8192,
},
status: "active" as const,
enabled: true,
}
}
function config(input?: {
leader?: string
leaderTimeout?: number
@ -165,10 +107,15 @@ describe("run runtime boot", () => {
test("loads v2 providers and models for model selector data", async () => {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const providers = [provider("openai", "OpenAI")]
const models = [model("gpt-5", "openai", 128000, ["high", "minimal"])]
const providerList = spyOn(sdk.provider, "list").mockImplementation(() => ok({ data: providers }) as never)
spyOn(sdk.model, "list").mockImplementation(() => ok({ data: models }) as never)
const location = { directory: "/workspace", project: { id: "proj_1", directory: "/workspace" } }
const providerList = spyOn(sdk.provider, "list").mockResolvedValue({
location,
data: [catalogProvider("openai", "OpenAI")],
} as never)
spyOn(sdk.model, "list").mockResolvedValue({
location,
data: [catalogModel({ id: "gpt-5", providerID: "openai", variants: ["high", "minimal"] })],
} as never)
await expect(resolveModelInfo(sdk, { directory: "/workspace" })).resolves.toEqual({
providers: [
@ -181,6 +128,9 @@ describe("run runtime boot", () => {
cost: {
input: 0,
},
limit: {
context: 128_000,
},
status: "active",
variants: {
high: {},

View file

@ -1,87 +1,19 @@
import { describe, expect, test } from "bun:test"
import { runPromptQueue } from "../../src/mini/runtime.queue"
import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "../../src/mini/types"
import { runPromptQueue as runPromptQueueBase, type QueueInput } from "../../src/mini/runtime.queue"
import type { RunPrompt } from "../../src/mini/types"
import { createFooterApiFixture } from "./fixture/footer-api"
function footer() {
const prompts = new Set<(input: RunPrompt) => void>()
const queuedRemoves = new Set<(messageID: string) => void>()
const closes = new Set<() => void>()
const events: FooterEvent[] = []
const commits: StreamCommit[] = []
let closed = false
const api: FooterApi = {
get isClosed() {
return closed
},
onPrompt(fn) {
prompts.add(fn)
return () => {
prompts.delete(fn)
}
},
onQueuedRemove(fn) {
queuedRemoves.add(fn)
return () => {
queuedRemoves.delete(fn)
}
},
onClose(fn) {
if (closed) {
fn()
return () => {}
}
closes.add(fn)
return () => {
closes.delete(fn)
}
},
event(next) {
events.push(next)
},
append(next) {
commits.push(next)
},
idle() {
return Promise.resolve()
},
close() {
if (closed) {
return
}
closed = true
for (const fn of [...closes]) {
fn()
}
},
destroy() {
api.close()
prompts.clear()
closes.clear()
},
}
return {
api,
events,
commits,
submit(text: string, mode?: RunPrompt["mode"]) {
const next = mode ? { text, parts: [] as RunPrompt["parts"], mode } : { text, parts: [] as RunPrompt["parts"] }
for (const fn of [...prompts]) {
fn(next)
}
},
removeQueued(messageID: string) {
for (const fn of [...queuedRemoves]) fn(messageID)
},
}
function runPromptQueue(input: Omit<QueueInput, "admit" | "settle"> & Partial<Pick<QueueInput, "admit" | "settle">>) {
return runPromptQueueBase({
admit: async () => {},
settle: async () => {},
...input,
})
}
describe("run runtime queue", () => {
test("ignores empty prompts", async () => {
const ui = footer()
const ui = createFooterApiFixture()
let calls = 0
const task = runPromptQueue({
@ -99,7 +31,7 @@ describe("run runtime queue", () => {
})
test("treats /exit as a close command", async () => {
const ui = footer()
const ui = createFooterApiFixture()
let calls = 0
const task = runPromptQueue({
@ -116,7 +48,7 @@ describe("run runtime queue", () => {
})
test("treats /new as a local session command", async () => {
const ui = footer()
const ui = createFooterApiFixture()
const seen: string[] = []
let created = 0
@ -149,7 +81,7 @@ describe("run runtime queue", () => {
})
test("shell mode submits /exit as a shell command", async () => {
const ui = footer()
const ui = createFooterApiFixture()
const seen: RunPrompt[] = []
const task = runPromptQueue({
@ -168,7 +100,7 @@ describe("run runtime queue", () => {
})
test("shell mode submits /new instead of creating a session", async () => {
const ui = footer()
const ui = createFooterApiFixture()
const seen: RunPrompt[] = []
let created = 0
@ -192,7 +124,7 @@ describe("run runtime queue", () => {
})
test("shell mode does not append a synthetic user row", async () => {
const ui = footer()
const ui = createFooterApiFixture()
const task = runPromptQueue({
footer: ui.api,
@ -207,7 +139,7 @@ describe("run runtime queue", () => {
})
test("shell mode does not emit a turn duration summary", async () => {
const ui = footer()
const ui = createFooterApiFixture()
const task = runPromptQueue({
footer: ui.api,
@ -223,7 +155,7 @@ describe("run runtime queue", () => {
})
test("preserves whitespace for initial input", async () => {
const ui = footer()
const ui = createFooterApiFixture()
const seen: string[] = []
await runPromptQueue({
@ -247,206 +179,90 @@ describe("run runtime queue", () => {
])
})
test("passes prompts to onSend", async () => {
const ui = footer()
const seen: string[] = []
await runPromptQueue({
footer: ui.api,
initialInput: " hello ",
onSend: (input) => {
seen.push(input.text)
},
run: async () => {
ui.api.close()
},
})
expect(seen).toEqual([" hello "])
})
test("appends the user row before the turn starts", async () => {
const ui = footer()
await runPromptQueue({
footer: ui.api,
initialInput: "/fmt bash",
run: async () => {
expect(ui.commits).toEqual([
{
kind: "user",
text: "/fmt bash",
phase: "start",
source: "system",
messageID: expect.any(String),
},
])
ui.api.close()
},
})
})
test("runs queued prompts in order", async () => {
const ui = footer()
const seen: string[] = []
let wake: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
wake = resolve
})
test("durably admits in-flight follow-ups in submission order", async () => {
const ui = createFooterApiFixture()
const admitted: string[] = []
const gate = Promise.withResolvers<void>()
const task = runPromptQueue({
footer: ui.api,
run: async (input) => {
seen.push(input.text)
if (seen.length === 1) {
await gate
return
}
ui.api.close()
run: async (input, _signal, onAdmitted) => {
admitted.push(`${input.text}:steer`)
onAdmitted()
await gate.promise
},
admit: async (input) => {
admitted.push(`${input.text}:queue`)
},
settle: async () => ui.api.close(),
})
ui.submit("one")
ui.submit("two")
await Promise.resolve()
expect(seen).toEqual(["one"])
wake?.()
await task
expect(seen).toEqual(["one", "two"])
})
test("exposes ordinary in-flight prompts for removal before sending", async () => {
const ui = footer()
const turns: RunPrompt[] = []
let wake: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
wake = resolve
})
const task = runPromptQueue({
footer: ui.api,
run: async (input) => {
turns.push(input)
await gate
},
})
ui.submit("one")
ui.submit("two")
await Promise.resolve()
await Promise.resolve()
expect(turns.map((item) => item.text)).toEqual(["one"])
expect(turns[0]?.messageID).toEqual(expect.any(String))
ui.submit("three")
while (admitted.length < 3) await Bun.sleep(0)
expect(admitted).toEqual(["one:steer", "two:queue", "three:queue"])
expect(ui.commits.map((item) => item.text)).toEqual(["one"])
const first = ui.events.find((item) => item.type === "queued.prompts")
const event = ui.events.findLast((item) => item.type === "queued.prompts")
expect(first?.type === "queued.prompts" ? first.prompts : []).toEqual([])
expect(
first?.type === "queued.prompts" && event?.type === "queued.prompts" ? first.prompts === event.prompts : true,
).toBe(false)
expect(ui.events.findLast((item) => item.type === "queue")).toEqual({ type: "queue", queue: 1 })
expect(event?.type === "queued.prompts" ? event.prompts.map((item) => item.prompt.text) : []).toEqual(["two"])
if (event?.type === "queued.prompts") ui.removeQueued(event.prompts[0]!.messageID)
await Promise.resolve()
wake?.()
ui.api.close()
gate.resolve()
await task
expect(turns.map((item) => item.text)).toEqual(["one"])
})
test("removing one managed queued prompt preserves the others", async () => {
const ui = footer()
const turns: string[] = []
let wake: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
wake = resolve
})
test("continues durable admission after one fails", async () => {
const ui = createFooterApiFixture()
const admitted: string[] = []
const errors: string[] = []
const gate = Promise.withResolvers<void>()
const task = runPromptQueue({
footer: ui.api,
run: async (input) => {
turns.push(input.text)
if (input.text === "active") await gate
if (input.text === "queued three") ui.api.close()
run: async (_input, _signal, admitted) => {
admitted()
await gate.promise
},
})
ui.submit("active")
ui.submit("queued one")
ui.submit("queued two")
ui.submit("queued three")
await Promise.resolve()
await Promise.resolve()
const event = ui.events.findLast((item) => item.type === "queued.prompts")
if (event?.type === "queued.prompts") {
const second = event.prompts.find((item) => item.prompt.text === "queued two")
if (second) ui.removeQueued(second.messageID)
}
wake?.()
await task
expect(turns).toEqual(["active", "queued one", "queued three"])
})
test("drains a prompt queued during an in-flight turn", async () => {
const ui = footer()
const seen: string[] = []
let wake: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
wake = resolve
})
const task = runPromptQueue({
footer: ui.api,
run: async (input) => {
seen.push(input.text)
if (seen.length === 1) {
await gate
return
}
ui.api.close()
admit: async (input) => {
if (input.text === "two") throw new Error("admission failed")
admitted.push(input.text)
},
onAdmissionError: (_prompt, error) => {
errors.push(error instanceof Error ? error.message : String(error))
},
settle: async () => ui.api.close(),
})
ui.submit("one")
await Promise.resolve()
expect(seen).toEqual(["one"])
wake?.()
await Promise.resolve()
ui.submit("two")
ui.submit("three")
while (admitted.length === 0) await Bun.sleep(0)
gate.resolve()
await task
expect(seen).toEqual(["one", "two"])
expect(errors).toEqual(["admission failed"])
expect(admitted).toEqual(["three"])
})
test("close aborts the active run and drops pending queued work", async () => {
const ui = footer()
const seen: string[] = []
let hit = false
test("close aborts an in-flight durable admission", async () => {
const ui = createFooterApiFixture()
let admissionHit = false
const admissionStarted = Promise.withResolvers<void>()
const task = runPromptQueue({
footer: ui.api,
run: async (input, signal) => {
seen.push(input.text)
run: async (_input, signal, admitted) => {
admitted()
await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }))
},
admit: async (_prompt, signal) => {
admissionStarted.resolve()
await new Promise<void>((resolve) => {
if (signal.aborted) {
hit = true
admissionHit = true
resolve()
return
}
signal.addEventListener(
"abort",
() => {
hit = true
admissionHit = true
resolve()
},
{ once: true },
@ -458,15 +274,15 @@ describe("run runtime queue", () => {
ui.submit("one")
await Promise.resolve()
ui.submit("two")
await admissionStarted.promise
ui.api.close()
await task
expect(hit).toBe(true)
expect(seen).toEqual(["one"])
expect(admissionHit).toBe(true)
})
test("propagates run errors", async () => {
const ui = footer()
const ui = createFooterApiFixture()
const task = runPromptQueue({
footer: ui.api,

View file

@ -2,7 +2,9 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client/promise"
import { runInteractiveDeferredMode } from "../../src/mini/runtime"
import type { LifecycleInput } from "../../src/mini/runtime.lifecycle"
import type { FooterApi, FooterEvent, MiniHost } from "../../src/mini/types"
import type { FooterEvent, MiniHost } from "../../src/mini/types"
import { catalogModel, catalogProvider, stubCatalogLists } from "./fixture/catalog"
import { createFooterApiFixture } from "./fixture/footer-api"
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
function defer<T>() {
@ -38,55 +40,8 @@ function host(): MiniHost {
}
}
function footer(events: FooterEvent[] = []): FooterApi {
let closed = false
const closes = new Set<() => void>()
const notify = () => {
for (const fn of closes) fn()
}
return {
get isClosed() {
return closed
},
onPrompt: () => () => {},
onQueuedRemove: () => () => {},
onClose(fn) {
if (closed) {
fn()
return () => {}
}
closes.add(fn)
return () => {
closes.delete(fn)
}
},
event(value) {
events.push(value)
},
append() {},
idle() {
return Promise.resolve()
},
close() {
if (closed) {
return
}
closed = true
notify()
},
destroy() {
if (closed) {
return
}
closed = true
notify()
},
}
function footer(events: FooterEvent[] = []) {
return createFooterApiFixture({ events }).api
}
afterEach(() => {
@ -100,12 +55,7 @@ describe("run interactive runtime", () => {
const streamStarted = defer<void>()
let lifecycle!: LifecycleInput
const settled: Array<{ sessionID: string; formID: string }> = []
spyOn(sdk.provider, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.model, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
stubCatalogLists(sdk)
const reply = spyOn(sdk.form, "reply").mockImplementation(() => ok(undefined))
const task = runInteractiveDeferredMode(
@ -143,6 +93,8 @@ describe("run interactive runtime", () => {
streamStarted.resolve()
return {
runPromptTurn: async () => {},
queuePromptTurn: async () => {},
waitForIdle: async () => {},
interruptActiveTurn: async () => {},
selectSubagent: () => {},
settleForm: (sessionID: string, formID: string) => settled.push({ sessionID, formID }),
@ -195,12 +147,7 @@ describe("run interactive runtime", () => {
const api = footer()
let resolved = 0
api.idle = () => painted.promise
spyOn(sdk.provider, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.model, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
stubCatalogLists(sdk)
const task = runInteractiveDeferredMode(
{
@ -279,38 +226,17 @@ describe("run interactive runtime", () => {
cursor: {},
}) as never,
)
spyOn(sdk.provider, "list").mockImplementation(
() =>
ok({
location: { directory: "/tmp" },
data: [{ id: "openai", name: "OpenAI", request: { headers: {}, body: {} } }],
}) as never,
)
spyOn(sdk.model, "list").mockImplementation(
() =>
ok({
location: { directory: "/tmp" },
data: [
{
id: "gpt-5",
providerID: "openai",
name: "Little Frank",
capabilities: { tools: true, input: ["text"], output: ["text"] },
request: { headers: {}, body: {} },
variants: [{ id: "high", settings: {}, headers: {}, body: {} }],
time: { released: 1 },
cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
status: "active",
enabled: true,
limit: { context: 128000, output: 8192 },
},
],
}) as never,
)
spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
stubCatalogLists(sdk, {
providers: [catalogProvider("openai", "OpenAI")],
models: [
catalogModel({
id: "gpt-5",
providerID: "openai",
name: "Little Frank",
variants: ["high"],
}),
],
})
const task = runInteractiveDeferredMode(
{
@ -391,13 +317,7 @@ describe("run interactive runtime", () => {
const session = spyOn(sdk.session, "get").mockImplementation(
(_request, options) => pending(options?.signal) as never,
)
const response = { location: { directory: "/tmp" }, data: [] }
spyOn(sdk.provider, "list").mockResolvedValue(response as never)
spyOn(sdk.model, "list").mockResolvedValue(response as never)
spyOn(sdk.agent, "list").mockResolvedValue(response as never)
spyOn(sdk.reference, "list").mockResolvedValue(response as never)
spyOn(sdk.command, "list").mockResolvedValue(response as never)
spyOn(sdk.skill, "list").mockResolvedValue(response as never)
stubCatalogLists(sdk)
const task = runInteractiveDeferredMode(
{
@ -457,13 +377,9 @@ describe("run interactive runtime", () => {
let getDirectory: (() => string) | undefined
let findFiles: ((query: string) => Promise<string[]>) | undefined
let transportLocation: unknown
const response = { location: { directory: "/session", workspaceID: "work-1" }, data: [] }
const providerList = spyOn(sdk.provider, "list").mockResolvedValue(response as never)
const modelList = spyOn(sdk.model, "list").mockResolvedValue(response as never)
const agentList = spyOn(sdk.agent, "list").mockResolvedValue(response as never)
const referenceList = spyOn(sdk.reference, "list").mockResolvedValue(response as never)
const commandList = spyOn(sdk.command, "list").mockResolvedValue(response as never)
const skillList = spyOn(sdk.skill, "list").mockResolvedValue(response as never)
const catalogs = stubCatalogLists(sdk, {
location: { directory: "/session", workspaceID: "work-1" },
})
const fileFind = spyOn(sdk.file, "find").mockResolvedValue({
location: {
directory: "/session",
@ -518,6 +434,8 @@ describe("run interactive runtime", () => {
setTimeout(() => input.footer.close(), 0)
return {
runPromptTurn: async () => {},
queuePromptTurn: async () => {},
waitForIdle: async () => {},
interruptActiveTurn: async () => {},
selectSubagent: () => {},
replayOnResize: async () => false,
@ -538,12 +456,12 @@ describe("run interactive runtime", () => {
const query = { location: { directory: "/session", workspace: "work-1" } }
expect(getDirectory?.()).toBe("/session")
expect(transportLocation).toMatchObject({ directory: "/session", workspaceID: "work-1" })
expect(providerList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(modelList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(agentList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(referenceList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(commandList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(skillList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(catalogs.provider).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(catalogs.model).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(catalogs.agent).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(catalogs.reference).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(catalogs.command).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(catalogs.skill).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(fileFind).toHaveBeenCalledWith({ query: "index", type: "file", ...query })
})
})

View file

@ -6,6 +6,7 @@ import { RunScrollbackStream } from "../../src/mini/scrollback.surface"
import { entryGroupKey } from "../../src/mini/scrollback.writer"
import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme"
import type { StreamCommit } from "../../src/mini/types"
import { canonicalToolPart } from "./fixture/tool-part"
type ClaimedCommit = {
snapshot: {
@ -220,21 +221,6 @@ function error(text: string): StreamCommit {
}
}
function toolPart(name: string, state: SessionMessageAssistantTool["state"], id: string): SessionMessageAssistantTool {
return {
type: "tool",
id,
name,
state,
time:
state.status === "streaming"
? { created: 1 }
: state.status === "completed" || state.status === "error"
? { created: 1, ran: 1, completed: 2 }
: { created: 1, ran: 1 },
}
}
function toolCommit(input: {
tool: string
phase: StreamCommit["phase"]
@ -256,7 +242,7 @@ function toolCommit(input: {
messageID,
tool: input.tool,
...(input.toolState ? { toolState: input.toolState } : {}),
...(input.state ? { part: toolPart(input.tool, input.state, id) } : {}),
...(input.state ? { part: canonicalToolPart(input.tool, input.state, id) } : {}),
}
}

View file

@ -11,7 +11,9 @@ import {
type PermissionV2Request,
} from "@opencode-ai/client/promise"
import { createSessionTransport } from "../../src/mini/stream-v2.transport"
import type { FooterApi, FooterEvent, StreamCommit } from "../../src/mini/types"
import type { StreamCommit } from "../../src/mini/types"
import { createFooterApiFixture } from "./fixture/footer-api"
import { canonicalToolPart } from "./fixture/tool-part"
import { tmpdir } from "../fixture/fixture"
type RunV2Event = EventSubscribeOutput
@ -91,31 +93,7 @@ function promptAdmission(input: Parameters<OpenCodeClient["session"]["prompt"]>[
}
function footer() {
const commits: StreamCommit[] = []
const events: FooterEvent[] = []
let closed = false
const api: FooterApi = {
get isClosed() {
return closed
},
onPrompt: () => () => {},
onQueuedRemove: () => () => {},
onClose: () => () => {},
event(value) {
events.push(value)
},
append(value) {
commits.push(value)
},
idle: () => Promise.resolve(),
close() {
closed = true
},
destroy() {
closed = true
},
}
return { api, commits, events }
return createFooterApiFixture()
}
type SessionMessages = MessageListOutput["data"]
@ -149,6 +127,8 @@ function sdk(input: {
globals?: FormInfo[]
globalLocation?: { directory: string; workspaceID?: string }
permissions?: Record<string, PermissionV2Request[]>
pending?: Record<string, Awaited<ReturnType<OpenCodeClient["session"]["pending"]["list"]>>>
wait?: () => Promise<void>
}) {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
let subscription = 0
@ -181,6 +161,8 @@ function sdk(input: {
}),
)
spyOn(client.session, "active").mockImplementation(() => ok(input.active?.() ?? {}))
spyOn(client.session.pending, "list").mockImplementation((request) => ok(input.pending?.[request.sessionID] ?? []))
spyOn(client.session, "wait").mockImplementation(() => input.wait?.() ?? ok(undefined))
spyOn(client.session, "message").mockImplementation((request) => {
const message = input.messages?.[request.sessionID]?.find((item) => item.id === request.messageID)
return message ? (ok(message) as never) : Promise.reject(new Error(`message not found: ${request.messageID}`))
@ -218,6 +200,50 @@ afterEach(() => {
})
describe("V2 mini transport", () => {
test("formats footer usage with compact tokens and context percentage", async () => {
const events = feed()
events.push(connected())
const ui = footer()
const transport = await createSessionTransport({
sdk: sdk({ streams: [events] }),
sessionID: "ses_1",
thinking: false,
footer: ui.api,
contextLimit: (model) =>
model.providerID === "test" && model.modelID === "model" ? 160_000 : undefined,
})
events.push({
id: "evt_step_started",
created: 1,
type: "session.step.started",
durable: durable("ses_1", 1),
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
agent: "build",
model: { providerID: "test", id: "model" },
},
})
events.push({
id: "evt_step_ended",
created: 2,
type: "session.step.ended",
durable: durable("ses_1", 2),
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
finish: "stop",
cost: 0,
tokens: { input: 7_000, output: 500, reasoning: 8, cache: { read: 0, write: 0 } },
},
})
while (!ui.events.some((event) => event.type === "stream.patch" && event.patch.usage)) await Bun.sleep(0)
expect(ui.events).toContainEqual({ type: "stream.patch", patch: { usage: "7.5K (5%)" } })
await transport.close()
})
test("recursively hydrates blockers for direct and transitive descendants", async () => {
const events = feed()
events.push(connected())
@ -268,18 +294,16 @@ describe("V2 mini transport", () => {
agent: "build",
model: { providerID: "test", id: "model" },
content: [
{
type: "tool" as const,
id: "call_child_source",
name: "shell",
state: {
canonicalToolPart(
"shell",
{
status: "running" as const,
input: { command: "git status --short" },
structured: {},
content: [],
},
time: { created: 1, ran: 1 },
},
"call_child_source",
),
],
time: { created: 1 },
}
@ -441,19 +465,23 @@ describe("V2 mini transport", () => {
await transport.close()
})
test("hydrates projection, reduces live output, and completes on settlement", async () => {
test("waits authoritatively and reconciles the projected terminal suffix", async () => {
const events = feed()
events.push(connected())
const client = sdk({ streams: [events] })
const settled = defer()
const messages: SessionMessages = []
const client = sdk({
streams: [events],
messages: { ses_1: messages },
wait: () => settled.promise,
})
const ui = footer()
const transport = await createSessionTransport({
sdk: client,
sessionID: "ses_1",
thinking: true,
replay: true,
thinking: false,
footer: ui.api,
})
expect(ui.commits.map((item) => item.text)).toEqual(["previous prompt"])
let admitted = false
spyOn(client.session, "prompt").mockImplementation((request) => {
@ -488,7 +516,7 @@ describe("V2 mini transport", () => {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
ordinal: 0,
delta: "answer",
delta: "ans",
},
})
events.push({
@ -498,18 +526,232 @@ describe("V2 mini transport", () => {
durable: durable("ses_1"),
data: { sessionID: "ses_1" },
})
let done = false
void turn.then(() => {
done = true
})
await Bun.sleep(0)
expect(done).toBe(false)
messages.push(
{ id: "msg_prompt", type: "user", text: "hello", time: { created: 2 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [{ type: "text", text: "answer" }],
time: { created: 3, completed: 4 },
},
)
settled.resolve()
await turn
expect(ui.commits.map((item) => item.text)).toEqual(["previous prompt", "answer"])
expect(ui.events).toContainEqual({ type: "stream.patch", patch: { phase: "idle", status: "" } })
expect(ui.commits.map((item) => item.text)).toEqual(["ans", "wer"])
await transport.close()
})
test("shows durable pending delivery and appends queued input on promotion", async () => {
const events = feed()
events.push(connected())
const client = sdk({
streams: [events],
pending: {
ses_1: [
{
admittedSeq: 1,
id: "msg_queued",
sessionID: "ses_1",
timeCreated: 1,
type: "user",
data: { text: "follow up" },
delivery: "queue",
},
],
},
})
const ui = footer()
const transport = await createSessionTransport({
sdk: client,
sessionID: "ses_1",
thinking: false,
footer: ui.api,
})
const pending = () =>
ui.events
.findLast((item) => item.type === "queued.prompts")
?.prompts.map((item) => [item.messageID, item.delivery, item.admittedSeq])
expect(pending()).toEqual([["msg_queued", "queue", 1]])
events.push({
id: "evt_promoted",
created: 2,
type: "session.input.promoted",
durable: durable("ses_1", 2),
data: { sessionID: "ses_1", inputID: "msg_queued" },
})
while (!ui.commits.some((item) => item.messageID === "msg_queued")) await Bun.sleep(0)
expect(ui.commits).toContainEqual(
expect.objectContaining({ kind: "user", messageID: "msg_queued", text: "follow up" }),
)
expect(pending()).toEqual([])
const prompt = spyOn(client.session, "prompt").mockImplementation((request) =>
ok({ ...promptAdmission(request), admittedSeq: 2 }) as never,
)
await transport.queuePromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { messageID: "msg_next", text: "another", parts: [] },
files: [],
includeFiles: false,
})
expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: "queue" }), expect.anything())
events.push({
id: "evt_earlier_admission",
created: 3,
type: "session.input.admitted",
durable: durable("ses_1", 1),
data: {
sessionID: "ses_1",
inputID: "msg_earlier",
input: { type: "user", data: { text: "earlier" }, delivery: "steer" },
},
})
while (true) {
const pending = ui.events.findLast((item) => item.type === "queued.prompts")
if (pending?.type === "queued.prompts" && pending.prompts.length >= 2) break
await Bun.sleep(0)
}
expect(pending()).toEqual([
["msg_earlier", "steer", 1],
["msg_next", "queue", 2],
])
await transport.close()
})
test("reports an observed execution failure before prompt promotion", async () => {
const events = feed()
events.push(connected())
const idle = defer()
const client = sdk({ streams: [events], messages: { ses_1: [] }, wait: () => idle.promise })
const ui = footer()
const transport = await createSessionTransport({
sdk: client,
sessionID: "ses_1",
thinking: false,
footer: ui.api,
})
let admitted = false
spyOn(client.session, "prompt").mockImplementation((request) => {
admitted = true
return ok(promptAdmission(request)) as never
})
const turn = transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { messageID: "msg_prompt", text: "hello", parts: [] },
files: [],
includeFiles: false,
})
while (!admitted) await Bun.sleep(0)
events.push({
id: "evt_failed",
created: 2,
type: "session.execution.failed",
durable: durable("ses_1", 2),
data: { sessionID: "ses_1", error: { type: "unknown", message: "instructions unavailable" } },
})
await Bun.sleep(0)
idle.resolve()
await turn
expect(ui.commits).toContainEqual(
expect.objectContaining({ kind: "error", messageID: "msg_prompt", text: "instructions unavailable" }),
)
await transport.close()
})
test("attributes an execution-only failure to the latest promoted prompt", async () => {
const events = feed()
events.push(connected())
const idle = defer()
const messages: SessionMessages = []
const client = sdk({ streams: [events], messages: { ses_1: messages }, wait: () => idle.promise })
const ui = footer()
const transport = await createSessionTransport({
sdk: client,
sessionID: "ses_1",
thinking: false,
footer: ui.api,
})
let admitted = false
spyOn(client.session, "prompt").mockImplementation((request) => {
admitted = true
return ok(promptAdmission(request)) as never
})
const turn = transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { messageID: "msg_prompt", text: "hello", parts: [] },
files: [],
includeFiles: false,
})
while (!admitted) await Bun.sleep(0)
events.push({
id: "evt_prompt_promoted",
created: 2,
type: "session.input.promoted",
durable: durable("ses_1", 2),
data: { sessionID: "ses_1", inputID: "msg_prompt" },
})
await transport.queuePromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { messageID: "msg_queued", text: "follow up", parts: [] },
files: [],
includeFiles: false,
})
events.push({
id: "evt_queued_promoted",
created: 3,
type: "session.input.promoted",
durable: durable("ses_1", 3),
data: { sessionID: "ses_1", inputID: "msg_queued" },
})
events.push({
id: "evt_failed",
created: 4,
type: "session.execution.failed",
durable: durable("ses_1", 4),
data: { sessionID: "ses_1", error: { type: "unknown", message: "model unavailable" } },
})
await Bun.sleep(0)
messages.push(
{ id: "msg_prompt", type: "user", text: "hello", time: { created: 2 } },
{ id: "msg_queued", type: "user", text: "follow up", time: { created: 3 } },
)
idle.resolve()
await turn
expect(ui.commits).toContainEqual(
expect.objectContaining({ kind: "error", messageID: "msg_queued", text: "model unavailable" }),
)
await transport.close()
})
test("sends local file and directory mentions as structured prompt files", async () => {
await using tmp = await tmpdir()
const filePath = path.join(tmp.path, "note.ts")
const contextPath = path.join(tmp.path, "context.txt")
const directoryPath = path.join(tmp.path, "docs")
await Bun.write(filePath, "export const answer = 42\n")
await Bun.write(contextPath, "context body")
await fs.mkdir(directoryPath)
await Bun.write(path.join(directoryPath, "README.md"), "# hello\n")
@ -573,12 +815,16 @@ describe("V2 mini transport", () => {
},
],
},
files: [],
files: [
{ type: "file", url: pathToFileURL(contextPath).href, filename: "context.txt", mime: "text/plain" },
{ type: "file", url: "file:///tmp/image.png", filename: "image.png", mime: "image/png" },
],
includeFiles: true,
})
expect(request?.text).toBe("Review @note.ts and @docs")
expect(request?.text).toBe('Review @note.ts and @docs\n\n<file name="context.txt">\ncontext body\n</file>')
expect(request?.files).toEqual([
{ uri: "file:///tmp/image.png", name: "image.png" },
{
uri: pathToFileURL(filePath).href,
name: "note.ts",
@ -788,11 +1034,12 @@ describe("V2 mini transport", () => {
await transport.close()
})
test("rebootstraps after disconnect and completes a promoted turn from idle active state", async () => {
test("reconnects and hydrates without completing before session.wait", async () => {
const first = feed()
const second = feed()
first.push(connected("evt_connected_1"))
second.push(connected("evt_connected_2"))
const idle = defer()
let running = true
const client = sdk({
streams: [first, second],
@ -801,6 +1048,7 @@ describe("V2 mini transport", () => {
if (running) active.ses_1 = { type: "running" }
return active
},
wait: () => idle.promise,
})
let projected = false
spyOn(client.message, "list").mockImplementation(() =>
@ -846,11 +1094,25 @@ describe("V2 mini transport", () => {
while (!admitted) await Bun.sleep(0)
projected = true
running = false
second.push({
id: "evt_prior_failed",
created: 1,
type: "session.execution.failed",
durable: durable("ses_1", 1),
data: { sessionID: "ses_1", error: { type: "unknown", message: "prior execution failed" } },
})
second.push({
id: "evt_prompted",
created: 2,
type: "session.input.promoted",
durable: durable("ses_1", 2),
data: { sessionID: "ses_1", inputID: "msg_prompt" },
})
first.close()
while (!ui.events.some((event) => event.type === "stream.patch" && event.patch.status === "reconnecting"))
await Bun.sleep(0)
idle.resolve()
await turn
expect(ui.events).toContainEqual({ type: "stream.patch", patch: { phase: "running", status: "reconnecting" } })
expect(ui.events).toContainEqual({ type: "stream.patch", patch: { phase: "idle", status: "" } })
await transport.close()
})
@ -1791,52 +2053,6 @@ describe("V2 mini transport", () => {
await transport.close()
})
test("resolves an interrupted turn even when promotion never arrived", async () => {
const events = feed()
events.push(connected())
const client = sdk({
streams: [events],
active: () => ({ ses_1: { type: "running" } }),
})
const ui = footer()
const transport = await createSessionTransport({
sdk: client,
sessionID: "ses_1",
thinking: false,
footer: ui.api,
})
let admitted = false
// The generated method has conditional return types for throwOnError; this mock represents the successful branch.
// @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(client.session, "prompt").mockImplementation((request) => {
admitted = true
return ok({ data: promptAdmission(request) })
})
const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined))
const turn = transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { messageID: "msg_prompt", text: "hello", parts: [] },
files: [],
includeFiles: true,
})
while (!admitted) await Bun.sleep(0)
await transport.interruptActiveTurn()
events.push({
id: "evt_settled",
created: 0,
type: "session.execution.interrupted",
durable: durable("ses_1"),
data: { sessionID: "ses_1", reason: "user" },
})
await turn
expect(interrupted).toHaveBeenCalledWith({ sessionID: "ses_1" })
await transport.close()
})
test("falls back to the default model when selecting a variant on a fresh session", async () => {
const events = feed()
events.push(connected())
@ -1903,7 +2119,8 @@ describe("V2 mini transport", () => {
test("interrupts the current Session when an active turn is aborted", async () => {
const events = feed()
events.push(connected())
const client = sdk({ streams: [events] })
const idle = defer()
const client = sdk({ streams: [events], wait: () => idle.promise })
const ui = footer()
const transport = await createSessionTransport({
sdk: client,
@ -1942,13 +2159,7 @@ describe("V2 mini transport", () => {
})
await Bun.sleep(0)
controller.abort()
events.push({
id: "evt_settled",
created: 0,
type: "session.execution.interrupted",
durable: durable("ses_1"),
data: { sessionID: "ses_1", reason: "user" },
})
idle.resolve()
await turn
expect(interrupted).toHaveBeenCalledWith({ sessionID: "ses_1" })
@ -2393,10 +2604,19 @@ describe("V2 mini transport", () => {
prompt: {
messageID: "msg_cmd",
text: "/deploy prod",
parts: [],
parts: [
{
type: "file",
url: "file:///tmp/mentioned.txt",
filename: "mentioned.txt",
source: { type: "file", text: { start: 8, end: 12, value: "prod" } },
},
],
command: { name: "deploy", arguments: "prod" },
},
files: [],
files: [
{ type: "file", url: "file:///tmp/context.txt", filename: "context.txt", mime: "text/plain" },
],
includeFiles: true,
})
@ -2407,6 +2627,14 @@ describe("V2 mini transport", () => {
arguments: "prod",
agent: "build",
model: { providerID: "test", id: "model" },
files: [
{ uri: "file:///tmp/context.txt", name: "context.txt" },
{
uri: "file:///tmp/mentioned.txt",
name: "mentioned.txt",
mention: { start: 8, end: 12, text: "prod" },
},
],
delivery: "steer",
})
// Selection rides the command payload; no separate client-side switch.
@ -2478,90 +2706,6 @@ describe("V2 mini transport", () => {
await transport.close()
})
test("does not resolve a skill turn before the matching activation is observed", async () => {
const events = feed()
events.push(connected())
const client = sdk({ streams: [events] })
const ui = footer()
const transport = await createSessionTransport({
sdk: client,
sessionID: "ses_1",
thinking: false,
footer: ui.api,
})
let sent = false
spyOn(client.session, "skill").mockImplementation(() => {
sent = true
return ok(undefined) as never
})
let done = false
const turn = transport
.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: {
messageID: "msg_skill",
text: "/tigerstyle",
parts: [],
command: { name: "tigerstyle", arguments: "", source: "skill" },
},
files: [],
includeFiles: true,
})
.then(() => {
done = true
})
while (!sent) await Bun.sleep(0)
events.push({
id: "evt_other",
created: 0,
type: "session.skill.activated",
durable: durable("ses_1"),
data: {
sessionID: "ses_1",
id: "other",
name: "other",
text: "other instructions",
},
})
events.push({
id: "evt_unrelated_settled",
created: 0,
type: "session.execution.succeeded",
durable: durable("ses_1"),
data: { sessionID: "ses_1" },
})
await Bun.sleep(0)
await Bun.sleep(0)
expect(done).toBe(false)
events.push({
id: "evt_skill",
created: 0,
type: "session.skill.activated",
durable: durable("ses_1"),
data: {
sessionID: "ses_1",
id: "tigerstyle",
name: "tigerstyle",
text: "skill instructions",
},
})
events.push({
id: "evt_skill_settled",
created: 0,
type: "session.execution.succeeded",
durable: durable("ses_1"),
data: { sessionID: "ses_1" },
})
await turn
expect(done).toBe(true)
await transport.close()
})
test("refreshes catalogs on connection and location-scoped invalidations", async () => {
const events = feed()
events.push(connected())
@ -2845,6 +2989,14 @@ describe("V2 mini transport", () => {
agents: [],
time: { created: 1 },
},
{
id: "msg_child_a",
type: "assistant" as const,
agent: "explore",
model: { providerID: "test", id: "model" },
content: [{ type: "text" as const, text: "child answer" }],
time: { created: 2 },
},
],
},
})
@ -2890,18 +3042,34 @@ describe("V2 mini transport", () => {
{ sessionID: "ses_child", label: "Explore", title: "Find files", status: "running" },
])
expect(states().at(-1)?.details.ses_child?.commits.filter((item) => item.text === "child answer")).toHaveLength(1)
events.push({
id: "evt_child_text",
id: "evt_child_text_replayed",
created: 0,
type: "session.text.delta",
data: {
sessionID: "ses_child",
assistantMessageID: "msg_child_a",
ordinal: 0,
delta: "child answer",
delta: "answer",
},
})
while (!states().some((state) => state.details.ses_child?.commits.some((item) => item.text === "child answer")))
await Bun.sleep(0)
expect(states().at(-1)?.details.ses_child?.commits.filter((item) => item.text === "child answer")).toHaveLength(1)
events.push({
id: "evt_child_text_suffix",
created: 0,
type: "session.text.delta",
data: {
sessionID: "ses_child",
assistantMessageID: "msg_child_a",
ordinal: 0,
delta: " suffix",
},
})
while (!states().some((state) => state.details.ses_child?.commits.some((item) => item.text === "child answer suffix")))
await Bun.sleep(0)
events.push({

View file

@ -1,33 +1,10 @@
import { describe, expect, test } from "bun:test"
import { writeSessionOutput } from "../../src/mini/stream"
import type { FooterApi, FooterEvent, StreamCommit } from "../../src/mini/types"
function footer() {
const events: FooterEvent[] = []
const commits: StreamCommit[] = []
const api: FooterApi = {
isClosed: false,
onPrompt: () => () => {},
onQueuedRemove: () => () => {},
onClose: () => () => {},
event: (next) => {
events.push(next)
},
append: (next) => {
commits.push(next)
},
idle: () => Promise.resolve(),
close: () => {},
destroy: () => {},
}
return { api, events, commits }
}
import { createFooterApiFixture } from "./fixture/footer-api"
describe("run stream bridge", () => {
test("defaults status patches to running phase", () => {
const out = footer()
const out = createFooterApiFixture()
writeSessionOutput(
{
@ -35,11 +12,7 @@ describe("run stream bridge", () => {
},
{
commits: [],
footer: {
patch: {
status: "assistant responding",
},
},
updates: [{ type: "stream.patch", patch: { status: "assistant responding" } }],
},
)
@ -53,4 +26,28 @@ describe("run stream bridge", () => {
},
])
})
test("delivers commits before ordered footer updates", () => {
const out = createFooterApiFixture()
writeSessionOutput(
{ footer: out.api },
{
commits: [{ kind: "assistant", source: "assistant", text: "answer", phase: "progress" }],
updates: [
{ type: "stream.patch", patch: { phase: "idle", status: "" } },
{ type: "stream.subagent", state: { tabs: [], details: {}, permissions: [], forms: [] } },
{ type: "stream.view", view: { type: "prompt" } },
],
},
)
expect(out.calls.map((call) => (call.type === "commit" ? "commit" : call.value.type))).toEqual([
"commit",
"stream.patch",
"stream.subagent",
"stream.view",
])
})
})

View file

@ -1,6 +1,7 @@
import { expect, test } from "bun:test"
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
import { RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "../../src/mini/theme"
import { DEFAULT_THEMES } from "../../src/theme"
const palette = ["#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5"] as const
@ -62,6 +63,18 @@ test("falls back when palette lookup fails", async () => {
expect(await resolveRunTheme(renderer({ fail: true }))).toBe(RUN_THEME_FALLBACK)
})
test("resolveTheme preserves Mini indexed color and result shape semantics", () => {
const item = structuredClone(DEFAULT_THEMES.opencode)
item.theme.primary = 6
delete item.theme.selectedListItemText
const theme = resolveTheme(item, "dark")
expect(theme.primary.intent).toBe("indexed")
expect(theme.primary.slot).toBe(6)
expect(theme.selectedListItemText).toBe(theme.background)
expect("_hasSelectedListItemText" in theme).toBe(false)
})
test("returns syntax styles and indexed splash colors", async () => {
const theme = await resolveRunTheme(renderer({ themeMode: "dark" }))

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { normalizeTool, toolOutputText } from "../../src/mini/tool"
import { normalizeTool, toolOutputText, toolPath } from "../../src/mini/tool"
describe("Mini tool presentation", () => {
test("uses V2 shell output without the model-facing status", () => {
@ -72,4 +72,9 @@ describe("Mini tool presentation", () => {
}),
).toMatchObject({ name: "subagent", state: { input: { agent: "explore" } } })
})
test("keeps segment-safe contained tool paths relative", () => {
expect(toolPath("..cache/result.txt", { directory: "/work/project" })).toBe("..cache/result.txt")
expect(toolPath("../shared/result.txt", { directory: "/work/project" })).toBe("/work/shared/result.txt")
})
})

View file

@ -23,12 +23,14 @@ const providers: RunProvider[] = [
describe("run variant shared", () => {
test("prefers cli then session then saved variants", () => {
expect(resolveVariant("max", "high", "low", ["low", "high"])).toBe("max")
expect(resolveVariant("default", "high", "low", ["low", "high"])).toBeUndefined()
expect(resolveVariant(undefined, "high", "low", ["low", "high"])).toBe("high")
expect(resolveVariant(undefined, "missing", "low", ["low", "high"])).toBe("low")
})
test("cycles through variants and back to default", () => {
expect(cycleVariant(undefined, ["low", "high"])).toBe("low")
expect(cycleVariant("default", ["low", "high"])).toBe("low")
expect(cycleVariant("low", ["low", "high"])).toBe("high")
expect(cycleVariant("high", ["low", "high"])).toBeUndefined()
expect(cycleVariant(undefined, [])).toBeUndefined()

View file

@ -0,0 +1,45 @@
import { expect, test } from "bun:test"
import path from "node:path"
import { createModelPreferenceRepository, decodeModelPreference } from "../src/model-preference"
import { tmpdir } from "./fixture/fixture"
test("repairs known model preferences and preserves unrelated fields", () => {
expect(
decodeModelPreference({
unrelated: { keep: true },
recent: [{ providerID: "openai", modelID: "gpt-5", ignored: true }, null],
favorite: "malformed",
variant: { "openai/gpt-5": "high", default: "default", invalid: 42 },
}),
).toEqual({
unrelated: { keep: true },
recent: [{ providerID: "openai", modelID: "gpt-5" }],
favorite: [],
variant: { "openai/gpt-5": "high" },
})
})
test("atomically serializes patches and variant updates", async () => {
await using tmp = await tmpdir()
const file = path.join(tmp.path, "model.json")
await Bun.write(file, JSON.stringify({ unrelated: "keep", favorite: [], variant: {} }))
const repository = createModelPreferenceRepository(file)
const openai = { providerID: "openai", modelID: "org/gpt-5" }
const anthropic = { providerID: "anthropic", modelID: "claude/sonnet" }
await Promise.all([
repository.patch({ recent: [openai] }),
repository.saveVariant(openai, "high"),
repository.saveVariant(anthropic, "low"),
])
expect(await Bun.file(file).json()).toEqual({
unrelated: "keep",
recent: [openai],
favorite: [],
variant: { "openai/org/gpt-5": "high", "anthropic/claude/sonnet": "low" },
})
await repository.saveVariant(openai, "default")
expect(await repository.resolveVariant(openai)).toBeUndefined()
expect((await Bun.file(file).json()).variant).toEqual({ "anthropic/claude/sonnet": "low" })
})

View file

@ -0,0 +1,61 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@opencode-ai/schema"
import { projectedPromptInput } from "../../src/prompt/codec"
describe("prompt codec", () => {
test("converts projected URI and inline attachments without mutation", () => {
const input = {
text: "Review @note.ts and image.png with @scan",
files: [
{
data: "",
mime: "text/plain",
source: { type: "uri", uri: "file:///tmp/note.ts" },
name: "note.ts",
mention: { start: 7, end: 15, text: "@note.ts" },
},
{
data: "YWJj",
mime: "image/png",
source: { type: "inline" },
name: "image.png",
description: "screenshot",
},
],
agents: [{ name: "scan", mention: { start: 35, end: 40, text: "@scan" } }],
} satisfies Prompt
const before = structuredClone(input)
const output = projectedPromptInput(input)
expect(output).toEqual({
text: input.text,
files: [
{
uri: "file:///tmp/note.ts",
name: "note.ts",
description: undefined,
mention: { start: 7, end: 15, text: "@note.ts" },
},
{
uri: "data:image/png;base64,YWJj",
name: "image.png",
description: "screenshot",
mention: undefined,
},
],
agents: [{ name: "scan", mention: { start: 35, end: 40, text: "@scan" } }],
})
expect(input).toEqual(before)
expect(output.files?.[0]?.mention).not.toBe(input.files[0].mention)
expect(output.agents?.[0]?.mention).not.toBe(input.agents[0].mention)
})
test("retains empty attachment keys for editable prompt replacement", () => {
expect(projectedPromptInput({ text: "plain" })).toEqual({
text: "plain",
files: undefined,
agents: undefined,
})
})
})

View file

@ -0,0 +1,91 @@
import { expect, test } from "bun:test"
import type { PromptInput } from "@opencode-ai/schema"
import {
expandPromptInputPastedText,
realignPromptInputMentions,
realignPromptMentions,
} from "../../src/prompt/mention"
test("realigns reordered, duplicate, deleted, and prefix-related mentions", () => {
const mentions = [
{ start: 0, end: 4, text: "@one" },
{ start: 5, end: 10, text: "@same" },
{ start: 11, end: 15, text: "@two" },
{ start: 16, end: 21, text: "@same" },
{ start: 22, end: 27, text: "@gone" },
]
const before = structuredClone(mentions)
expect(realignPromptMentions("@two @same @one @same", mentions)).toEqual([
{ start: 11, end: 15, text: "@one" },
{ start: 5, end: 10, text: "@same" },
{ start: 0, end: 4, text: "@two" },
{ start: 16, end: 21, text: "@same" },
undefined,
])
expect(mentions).toEqual(before)
expect(
realignPromptMentions("@foobar @foo", [
{ start: 0, end: 4, text: "@foo" },
{ start: 5, end: 12, text: "@foobar" },
]),
).toEqual([
{ start: 8, end: 12, text: "@foo" },
{ start: 0, end: 7, text: "@foobar" },
])
expect(
realignPromptMentions("@foobar @foobar", [
{ start: 0, end: 4, text: "@foo" },
{ start: 13, end: 20, text: "@foobar" },
]),
).toEqual([undefined, { start: 8, end: 15, text: "@foobar" }])
expect(
realignPromptMentions("@same @same", [
{ start: 100, end: 105, text: "@same" },
{ start: 4, end: 9, text: "@same" },
]),
).toEqual([
{ start: 6, end: 11, text: "@same" },
{ start: 0, end: 5, text: "@same" },
])
})
test("realigns mixed prompt attachments without mutation", () => {
const input = {
text: "@file @gone @agent",
files: [
{ uri: "file:///file", mention: { start: 0, end: 5, text: "@file" } },
{ uri: "data:image/png;base64,YWJj", name: "image.png" },
{ uri: "file:///gone", mention: { start: 6, end: 11, text: "@gone" } },
],
agents: [{ name: "agent", mention: { start: 12, end: 18, text: "@agent" } }],
} satisfies PromptInput.Prompt
const before = structuredClone(input)
const output = realignPromptInputMentions("@agent then @file", input)
expect(output).toEqual({
text: "@agent then @file",
files: [
{ uri: "file:///file", mention: { start: 12, end: 17, text: "@file" } },
{ uri: "data:image/png;base64,YWJj", name: "image.png", mention: undefined },
],
agents: [{ name: "agent", mention: { start: 0, end: 6, text: "@agent" } }],
})
expect(input).toEqual(before)
expect(output.files).not.toBe(input.files)
expect(output.agents).not.toBe(input.agents)
})
test("shifts mention hints when pasted placeholders expand", () => {
const input = {
text: "[Pasted text #1] @same @same",
files: [{ uri: "file:///same", mention: { start: 23, end: 28, text: "@same" } }],
} satisfies PromptInput.Prompt
const expanded = expandPromptInputPastedText(input, [
{ text: "a much longer pasted value", source: { start: 0, end: 16 } },
])
expect(expanded.files?.[0]?.mention).toEqual({ start: 33, end: 38, text: "@same" })
expect(realignPromptInputMentions(expanded.text, expanded).files?.[0]?.mention).toEqual({
start: 33,
end: 38,
text: "@same",
})
})

View file

@ -0,0 +1,24 @@
import { expect, test } from "bun:test"
import { parseFileLineRange, parseSlashHead } from "../../src/prompt/parse"
test("preserves file line-range parsing semantics", () => {
expect([
parseFileLineRange("src/app.ts#12-20"),
parseFileLineRange("src/app.ts#12-"),
parseFileLineRange("src/app.ts#12-12"),
parseFileLineRange("src/app.ts#bad"),
parseFileLineRange("src/app.ts"),
]).toEqual([
{ base: "src/app.ts", lineRange: { startLine: 12, endLine: 20 } },
{ base: "src/app.ts", lineRange: { startLine: 12, endLine: undefined } },
{ base: "src/app.ts", lineRange: { startLine: 12, endLine: undefined } },
{ base: "src/app.ts" },
{ base: "src/app.ts" },
])
})
test("keeps frontend-specific slash separators", () => {
expect(parseSlashHead("/editor\rfirst")).toEqual({ name: "editor\rfirst", arguments: "", end: 13 })
expect(parseSlashHead("/editor\rfirst", /\s/)).toEqual({ name: "editor", arguments: "first", end: 7 })
expect(parseSlashHead("editor")).toBeUndefined()
})

View file

@ -45,6 +45,17 @@ test("resolveTheme rejects circular color refs", () => {
expect(() => resolveTheme(item, "dark")).toThrow("Circular color reference")
})
test("resolveTheme preserves full theme numeric color and marker semantics", () => {
const item = structuredClone(DEFAULT_THEMES.opencode)
item.theme.primary = 6
delete item.theme.selectedListItemText
const theme = resolveTheme(item, "dark")
expect(theme.primary.intent).toBe("rgb")
expect(theme.selectedListItemText).toBe(theme.background)
expect(theme._hasSelectedListItemText).toBe(false)
})
function terminalColors(defaultBackground: string | null, palette: Array<string | null> = []): TerminalColors {
return {
palette,

View file

@ -7,86 +7,66 @@ import { resolveTheme } from "../../../src/theme/v2/resolve"
import { selectTheme } from "../../../src/theme/v2/select"
import type { ContextKey } from "../../../src/theme/v2"
test("provides reactive property, variant, state, and context accessors", () => {
test("provides reactive properties, states, contexts, and color operations", () => {
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
const [mode, setMode] = createSignal<"light" | "dark">("light")
const [context, setContext] = createSignal<ContextKey>()
const theme = createComponentTheme(() => {
const key = context()
return key ? resolved().contexts[key] ?? resolved() : resolved()
return key ? (resolved().contexts[key] ?? resolved()) : resolved()
}, mode)
expect(theme.text()).toBe(resolved().text.default)
expect(theme.hue.accent(500)).toBe(resolved().hue.accent[500])
expect(theme.hue.interactive(500)).toBe(resolved().hue.interactive[500])
expect(theme.hue.gray(200)).toBe(resolved().hue.gray[200])
expect(theme.increase(theme.background.surface.offset(), 1)).toBe(resolved().hue.neutral[300])
expect(theme.raise(theme.background.surface.offset())).toBe(resolved().hue.neutral[300])
expect(theme.decrease(theme.hue.red(300), 2)).toBe(resolved().hue.red[100])
expect(theme.increase(theme.hue.red(900), 3)).toBe(resolved().hue.red[900])
expect(theme.decrease(theme.hue.red(100), 3)).toBe(resolved().hue.red[100])
expect(theme.source(theme.background.surface.offset())).toEqual({ hue: "neutral", step: 200 })
expect(theme.text.default).toBe(resolved().text.default)
expect(theme.hue.accent[500]).toBe(resolved().hue.accent[500])
expect(theme.hue.interactive[500]).toBe(resolved().hue.interactive[500])
expect(theme.hue.gray[200]).toBe(resolved().hue.gray[200])
expect(theme.categorical.map((scale) => scale[500])).toEqual(resolved().categorical.map((scale) => scale[500]))
expect(theme.increase(theme.background.surface.offset, 1)).toBe(resolved().hue.neutral[400])
expect(theme.raise(theme.background.surface.offset)).toBe(resolved().hue.neutral[400])
expect(theme.decrease(theme.hue.red[300], 2)).toBe(resolved().hue.red[100])
expect(theme.increase(theme.hue.red[900], 3)).toBe(resolved().hue.red[900])
expect(theme.decrease(theme.hue.red[100], 3)).toBe(resolved().hue.red[100])
expect(theme.source(theme.background.surface.offset)).toEqual({ hue: "neutral", step: 300 })
const equivalent = RGBA.fromInts(...resolved().hue.green[500].toInts())
expect(theme.source(equivalent)).toBeUndefined()
expect(theme.increase(equivalent, 1)).toBe(equivalent)
const unmatched = RGBA.fromInts(1, 2, 3)
expect(theme.increase(unmatched, 1)).toBe(unmatched)
expect(theme.text.subdued()).toBe(resolved().text.subdued)
expect(theme.text.action()).toBe(resolved().text.action.primary.default)
expect(theme.text.action("hovered")).toBe(resolved().text.action.primary.hovered)
expect(theme.text.action("pressed")).toBe(resolved().text.action.primary.pressed)
expect(theme.text.action("selected")).toBe(resolved().text.action.primary.selected)
expect(theme.background.action("selected")).toBe(resolved().background.action.primary.selected)
expect(theme.background.action("hovered")).toBe(resolved().background.action.primary.hovered)
expect(theme.background.action({ selected: true })).toBe(resolved().background.action.primary.selected)
expect(theme.background.action({ selected: true, hovered: true })).toBe(
resolved().background.action.primary.selected,
)
expect(theme.background.action({ focused: true, selected: true })).toBe(
resolved().background.action.primary.focused,
)
expect(theme.background.action({ pressed: true, focused: true, selected: true })).toBe(
resolved().background.action.primary.pressed,
)
expect(
theme.background.action({ disabled: true, pressed: true, focused: true, selected: true, hovered: true }),
).toBe(
resolved().background.action.primary.disabled,
)
expect(theme.background.action({ disabled: false, selected: false })).toBe(
resolved().background.action.primary.default,
)
expect(theme.background.action.destructive("disabled")).toBe(
resolved().background.action.destructive.disabled,
)
expect(theme.background.formfield("hovered")).toBe(resolved().background.formfield.hovered)
expect(theme.background.formfield({ selected: true, hovered: true })).toBe(
resolved().background.formfield.selected,
)
expect(theme.background.formfield({ focused: true, selected: true, hovered: true })).toBe(
resolved().background.formfield.focused,
)
expect(
theme.background.formfield({ disabled: true, pressed: true, focused: true, selected: true, hovered: true }),
).toBe(resolved().background.formfield.disabled)
expect(theme.background.surface.offset()).toBe(resolved().background.surface.offset)
expect(theme.background.surface.overlay()).toBe(resolved().background.surface.overlay)
expect(theme.scrollbar()).toBe(resolved().scrollbar.default)
expect(theme.diff.text.added()).toBe(resolved().diff.text.added)
expect(theme.text.subdued).toBe(resolved().text.subdued)
expect(theme.text.action.primary.default).toBe(resolved().text.action.primary.default)
expect(theme.text.action.primary.hovered).toBe(resolved().text.action.primary.hovered)
expect(theme.text.action.primary.pressed).toBe(resolved().text.action.primary.pressed)
expect(theme.text.action.primary.selected).toBe(resolved().text.action.primary.selected)
expect(theme.background.action.primary.selected).toBe(resolved().background.action.primary.selected)
expect(theme.background.action.primary.hovered).toBe(resolved().background.action.primary.hovered)
expect(theme.background.action.primary.focused).toBe(resolved().background.action.primary.focused)
expect(theme.background.action.primary.pressed).toBe(resolved().background.action.primary.pressed)
expect(theme.background.action.primary.disabled).toBe(resolved().background.action.primary.disabled)
expect(theme.background.action.primary.default).toBe(resolved().background.action.primary.default)
expect(theme.background.action.destructive.disabled).toBe(resolved().background.action.destructive.disabled)
expect(theme.background.formfield.hovered).toBe(resolved().background.formfield.hovered)
expect(theme.background.formfield.selected).toBe(resolved().background.formfield.selected)
expect(theme.background.formfield.focused).toBe(resolved().background.formfield.focused)
expect(theme.background.formfield.disabled).toBe(resolved().background.formfield.disabled)
expect(theme.background.surface.offset).toBe(resolved().background.surface.offset)
expect(theme.background.surface.overlay).toBe(resolved().background.surface.overlay)
expect(theme.scrollbar.default).toBe(resolved().scrollbar.default)
expect(theme.diff.text.added).toBe(resolved().diff.text.added)
setContext("@context:elevated")
expect(theme.text()).toBe(resolved().contexts["@context:elevated"]!.text.default)
expect(theme.background.action("focused")).toBe(
expect(theme.categorical.map((scale) => scale[500])).toEqual(resolved().categorical.map((scale) => scale[500]))
expect(theme.text.default).toBe(resolved().contexts["@context:elevated"]!.text.default)
expect(theme.background.action.primary.focused).toBe(
resolved().contexts["@context:elevated"]!.background.action.primary.focused,
)
expect(theme.background.action("hovered")).toBe(resolved().background.surface.overlay)
expect(theme.background.formfield("selected")).toBe(
expect(theme.background.action.primary.hovered).toBe(resolved().background.surface.overlay)
expect(theme.background.formfield.selected).toBe(
resolved().contexts["@context:elevated"]!.background.formfield.selected,
)
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
setMode("dark")
expect(theme.text()).toBe(resolved().contexts["@context:elevated"]!.text.default)
expect(theme.decrease(theme.background.surface.offset(), 1)).toBe(resolved().hue.neutral[700])
expect(theme.raise(theme.background.surface.offset())).toBe(resolved().hue.neutral[700])
expect(theme.text.default).toBe(resolved().contexts["@context:elevated"]!.text.default)
expect(theme.decrease(theme.background.surface.offset, 1)).toBe(resolved().hue.neutral[600])
expect(theme.raise(theme.background.surface.offset)).toBe(resolved().hue.neutral[600])
})

View file

@ -8,6 +8,45 @@ import { selectTheme } from "../../../src/theme/v2/select"
const light = selectTheme(DEFAULT_THEME, "light")
const dark = selectTheme(DEFAULT_THEME, "dark")
test("resolves one-mode files with defaults for the available mode", () => {
const resolvedLight = resolveThemeFile({ version: 2, light: {} }, "dark")
const resolvedDark = resolveThemeFile({ version: 2, dark: {} }, "light")
expect(resolvedLight.background.default.equals(resolveTheme(light).background.default)).toBeTrue()
expect(resolvedDark.background.default.equals(resolveTheme(dark).background.default)).toBeTrue()
expect(resolvedLight.categorical.length).toBeGreaterThan(0)
expect(resolvedDark.categorical.length).toBeGreaterThan(0)
})
test("rejects theme files without a mode", () => {
// @ts-expect-error Runtime decoding also enforces the at-least-one-mode invariant.
expect(() => resolveThemeFile({ version: 2 })).toThrow("Invalid theme")
})
test("validates and resolves categorical hues in configured order", () => {
const theme = resolveThemeFile({ version: 2, light: { categorical: ["accent", "red", "interactive"] } }, "light")
expect(theme.categorical[0]).toBe(theme.hue.accent)
expect(theme.categorical[1]).toBe(theme.hue.red)
expect(theme.categorical[2]).toBe(theme.hue.interactive)
expect(theme.contexts["@context:elevated"]?.categorical).toBe(theme.categorical)
expect(() => resolveThemeFile({ version: 2, light: { categorical: [] } }, "light")).toThrow("Invalid theme")
expect(() =>
resolveThemeFile(
// @ts-expect-error Runtime decoding rejects unknown categorical hue names.
{ version: 2, light: { categorical: ["magenta"] } },
"light",
),
).toThrow("Invalid theme")
})
test("uses the default categorical order for direct definitions", () => {
const theme = resolveTheme({ ...light, categorical: undefined })
expect(theme.categorical[0]).toBe(theme.hue.blue)
expect(theme.categorical[1]).toBe(theme.hue.purple)
})
test("resolves independent definitions and hue aliases", () => {
const lightTheme = resolveTheme(light)
const darkTheme = resolveTheme(dark)
@ -18,46 +57,35 @@ test("resolves independent definitions and hue aliases", () => {
expect(lightTheme.hue.interactive[500].equals(lightTheme.hue.blue[500])).toBeTrue()
expect(lightTheme.hue.neutral).not.toBe(lightTheme.hue.gray)
expect(lightTheme.hue.neutral[500].equals(lightTheme.hue.gray[500])).toBeTrue()
expect(lightTheme.categorical[0]).toBe(lightTheme.hue.blue)
expect(lightTheme.source(lightTheme.hue.blue[500])).toEqual({ hue: "blue", step: 500 })
expect(lightTheme.source(lightTheme.hue.neutral[200])).toEqual({ hue: "neutral", step: 200 })
expect(lightTheme.source(lightTheme.background.surface.offset)).toEqual({ hue: "neutral", step: 200 })
expect(lightTheme.source(lightTheme.background.surface.offset)).toEqual({ hue: "neutral", step: 300 })
expect(lightTheme.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
expect(lightTheme.decrease(lightTheme.hue.red[200])).toBe(lightTheme.hue.red[100])
expect(lightTheme.contexts["@context:elevated"]?.increase(lightTheme.hue.red[100])).toBe(
lightTheme.hue.red[200],
)
expect(lightTheme.contexts["@context:elevated"]?.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
expect(lightTheme.text.default).toBeInstanceOf(RGBA)
expect(darkTheme.background.default).toBeInstanceOf(RGBA)
expect(lightTheme.background.surface.offset).toBe(lightTheme.hue.neutral[200])
expect(lightTheme.background.surface.overlay).toBe(lightTheme.hue.neutral[300])
expect(lightTheme.background.surface.offset).toBe(lightTheme.hue.neutral[300])
expect(lightTheme.background.surface.overlay).toBe(lightTheme.hue.neutral[400])
expect(lightTheme.syntax.keyword).toBeInstanceOf(RGBA)
expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[200])
expect(lightTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe(
lightTheme.hue.interactive[500],
)
expect(lightTheme.contexts["@context:elevated"]?.background.default).toBe(lightTheme.background.surface.offset)
expect(lightTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(
lightTheme.hue.neutral[100],
)
expect(lightTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
expect(lightTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(
lightTheme.hue.interactive[500],
)
expect(lightTheme.contexts["@context:overlay"]?.background.default).toBe(lightTheme.background.surface.overlay)
expect(lightTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(
lightTheme.hue.neutral[100],
)
expect(lightTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
expect(darkTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe(
darkTheme.hue.interactive[400],
)
expect(darkTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(
darkTheme.hue.neutral[100],
)
expect(darkTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(
darkTheme.hue.interactive[400],
)
expect(darkTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(
darkTheme.hue.neutral[900],
)
expect(darkTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(darkTheme.hue.neutral[200])
expect(darkTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(darkTheme.hue.interactive[400])
expect(darkTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(darkTheme.hue.neutral[200])
})
test("resolves base hue aliases and rejects circular hue aliases", () => {
@ -65,10 +93,7 @@ test("resolves base hue aliases and rejects circular hue aliases", () => {
...light,
hue: { ...light.hue, blue: "$hue.red", purple: "$hue.blue" },
})
const overridden = resolveThemeFile(
{ version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} },
"light",
)
const overridden = resolveThemeFile({ version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} }, "light")
expect(aliased.hue.blue).not.toBe(aliased.hue.red)
expect(aliased.hue.blue[500].equals(aliased.hue.red[500])).toBeTrue()
@ -195,9 +220,7 @@ test("resolves elevated hover surfaces from direct colors", () => {
)
expect(theme.contexts["@context:elevated"]?.background.default.toInts()).toEqual([18, 52, 86, 255])
expect(theme.contexts["@context:elevated"]?.background.action.primary.hovered.toInts()).toEqual([
35, 69, 103, 255,
])
expect(theme.contexts["@context:elevated"]?.background.action.primary.hovered.toInts()).toEqual([35, 69, 103, 255])
})
test("resolves transparent colors", () => {
@ -268,12 +291,10 @@ test("rejects missing, base, and contextual reference cycles", () => {
test("validates complete hues, resolved groups, and hue-only syntax", () => {
expect(() =>
resolveTheme(
{
...light,
hue: { ...light.hue, accent: "$hue.missing" },
} as unknown as ThemeDefinition,
),
resolveTheme({
...light,
hue: { ...light.hue, accent: "$hue.missing" },
} as unknown as ThemeDefinition),
).toThrow("$hue.missing")
expect(() =>
resolveTheme({

View file

@ -1,10 +1,14 @@
import { expect, test } from "bun:test"
import type { HueDefinition, ThemeDefinition, ThemeFile } from "../../../src/theme/v2"
import { selectTheme, selectThemeMode } from "../../../src/theme/v2/select"
import { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "../../../src/theme/v2/select"
const hue = {} as HueDefinition
const light = { hue, text: { default: "#111111", subdued: "#222222" } } satisfies ThemeDefinition
const dark = { hue, text: { default: "#eeeeee", subdued: "#dddddd" } } satisfies ThemeDefinition
const light = { hue, categorical: ["blue"], text: { default: "#111111", subdued: "#222222" } } satisfies ThemeDefinition
const dark = {
hue,
categorical: ["purple"],
text: { default: "#eeeeee", subdued: "#dddddd" },
} satisfies ThemeDefinition
test("requires and selects independent light and dark themes", () => {
const file = { version: 2, light, dark } satisfies ThemeFile
@ -27,6 +31,36 @@ test("merges an expanded mode override over the other mode", () => {
expect(selected.text?.subdued).toBe("$text.default")
})
test("replaces categorical order in a merge mode", () => {
const selected = selectTheme(
{ version: 2, light, dark: { mergeMode: true, categorical: ["accent", "cyan"] } },
"dark",
)
expect(selected.categorical).toEqual(["accent", "cyan"])
})
test("selects the available mode when the requested mode is missing", () => {
const lightOnly = { version: 2, light } satisfies ThemeFile
const darkOnly = { version: 2, dark } satisfies ThemeFile
expect(themeModes(lightOnly)).toEqual(["light"])
expect(themeModes(darkOnly)).toEqual(["dark"])
expect(supportsThemeMode(lightOnly, "light")).toBeTrue()
expect(supportsThemeMode(lightOnly, "dark")).toBeFalse()
expect(selectThemeMode(lightOnly, "dark")).toEqual({ theme: light, mode: "light", expanded: false })
expect(selectThemeMode(darkOnly, "light")).toEqual({ theme: dark, mode: "dark", expanded: false })
})
test("rejects a merge mode without its base mode", () => {
expect(() => selectThemeMode({ version: 2, light: { mergeMode: true } })).toThrow(
"light theme cannot merge without a dark theme",
)
expect(() => selectThemeMode({ version: 2, dark: { mergeMode: true } })).toThrow(
"dark theme cannot merge without a light theme",
)
})
test("rejects mutual mode merging", () => {
const file = {
version: 2,

View file

@ -40,6 +40,7 @@ const background = {
const definition = {
hue: {} as ThemeDefinition["hue"],
categorical: ["blue", "accent"],
text,
background,
border: { default: "$hue.neutral.300" },
@ -51,6 +52,10 @@ const definition = {
} satisfies ThemeDefinition
const file = { version: 2, light: definition, dark: definition } satisfies ThemeFile
const lightOnly = { version: 2, light: definition } satisfies ThemeFile
const darkOnly = { version: 2, dark: definition } satisfies ThemeFile
// @ts-expect-error A theme file must provide at least one mode.
const empty = { version: 2 } satisfies ThemeFile
test("supports property-first definitions, variants, states, and contexts", () => {
expect(text.action.primary.$hovered).toBe("$hue.neutral.200")
@ -62,5 +67,9 @@ test("supports property-first definitions, variants, states, and contexts", () =
expect(background.surface.offset).toBe("$hue.neutral.200")
expect(definition["@context:elevated"].text?.default).toBe("$hue.neutral.800")
expect(definition["@context:overlay"].background?.default).toBe("$hue.neutral.300")
expect(definition.categorical).toEqual(["blue", "accent"])
expect(file.light).toBe(definition)
expect(lightOnly.light).toBe(definition)
expect(darkOnly.dark).toBe(definition)
expect(empty.version).toBe(2)
})

View file

@ -1,57 +1,56 @@
import { expect, test } from "bun:test"
import { DEFAULT_THEMES, resolveTheme as resolveV1 } from "../../../src/theme"
import { resolveThemeFile } from "../../../src/theme/v2/resolve"
import { selectThemeMode, themeModes } from "../../../src/theme/v2/select"
import { migrateV1 } from "../../../src/theme/v2/v1-migrate"
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "../../../src/theme/v2/defaults"
test("migrates resolved V1 modes into literal V2 tokens", () => {
const migrated = migrateV1(DEFAULT_THEMES.opencode)
if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
const legacy = resolveV1(DEFAULT_THEMES.opencode, "light")
const resolved = resolveThemeFile(migrated, "light")
expect(migrated.standalone).toBeTrue()
expect(migrated.light.categorical?.length).toBeGreaterThan(0)
expect(migrated.dark.categorical?.length).toBeGreaterThan(0)
expect(migrated.light.hue?.accent).toBeObject()
expect(migrated.light.hue?.interactive).toBeObject()
if (typeof migrated.light.hue?.accent !== "object" || typeof migrated.light.hue.interactive !== "object") {
throw new Error("Expected concrete accent and interactive scales")
}
expect(migrated.light.hue.accent[900]).toBe(hex(legacy.accent))
expect(migrated.light.hue.interactive[900]).toBe(hex(legacy.primary))
expect(migrated.light.text?.default).toBe("$hue.neutral.900")
expect(migrated.light.text?.subdued).toBe("$hue.neutral.700")
expect(migrated.light.hue.accent[800]).toBe(hex(legacy.accent))
expect(migrated.light.hue.interactive[800]).toBe(hex(legacy.primary))
expect(migrated.light.text?.default).toBe("$hue.neutral.800")
expect(migrated.light.text?.subdued).toBe("$hue.neutral.600")
expect(migrated.light.background?.action?.primary?.default).toBe("transparent")
expect(migrated.light.background?.default).toBe("$hue.neutral.100")
expect(migrated.light.background?.surface?.offset).toBe("$hue.neutral.200")
expect(migrated.light.background?.surface?.overlay).toBe("$hue.neutral.300")
expect(migrated.dark.background?.default).toBe("$hue.neutral.900")
expect(migrated.dark.background?.surface?.offset).toBe("$hue.neutral.800")
expect(migrated.dark.background?.surface?.overlay).toBe("$hue.neutral.700")
expect(migrated.light.background?.default).toBe("$hue.neutral.200")
expect(migrated.light.background?.surface?.offset).toBe("$hue.neutral.300")
expect(migrated.light.background?.surface?.overlay).toBe("$hue.neutral.400")
expect(migrated.dark.background?.default).toBe("$hue.neutral.800")
expect(migrated.dark.background?.surface?.offset).toBe("$hue.neutral.700")
expect(migrated.dark.background?.surface?.overlay).toBe("$hue.neutral.600")
expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
expect(migrated.light.background?.action?.primary?.$selected).toBe("$hue.interactive.900")
expect(migrated.light.background?.action?.primary?.$selected).toBe("transparent")
expect(migrated.light.scrollbar?.default).toBe(hex(legacy.borderActive))
expect(migrated.light.diff?.lineNumber?.background?.removed).toBe(hex(legacy.diffRemovedLineNumberBg))
expect(migrated.light.markdown?.emphasis).toBe(hex(legacy.markdownEmph))
expect(resolved.background.surface.offset.toInts()).toEqual(legacy.backgroundPanel.toInts())
expect(resolved.background.surface.overlay.toInts()).toEqual(legacy.backgroundMenu.toInts())
expect(resolved.background.surface.overlay.toInts()).toEqual(legacy.backgroundElement.toInts())
expect(resolved.background.formfield.selected.toInts()).toEqual(legacy.background.toInts())
expect(resolved.background.formfield.focused.toInts()).toEqual(legacy.background.toInts())
expect(resolved.text.formfield.default.toInts()).toEqual(legacy.text.toInts())
expect(resolved.text.formfield.selected.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.text.formfield.focused.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.hue.accent[900].toInts()).toEqual(legacy.accent.toInts())
expect(resolved.hue.interactive[900].toInts()).toEqual(legacy.primary.toInts())
expect(resolved.background.action.primary.selected.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.hue.accent[800].toInts()).toEqual(legacy.accent.toInts())
expect(resolved.hue.interactive[800].toInts()).toEqual(legacy.primary.toInts())
expect(resolved.background.action.primary.selected.toInts()).toEqual([0, 0, 0, 0])
expect(resolved.text.action.primary.selected.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts())
expect(resolved.contexts["@context:elevated"]?.background.default.toInts()).toEqual(
legacy.backgroundPanel.toInts(),
)
expect(resolved.contexts["@context:elevated"]?.background.default.toInts()).toEqual(legacy.backgroundPanel.toInts())
expect(resolved.contexts["@context:elevated"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
expect(resolved.contexts["@context:elevated"]?.text.action.primary.default.toInts()).toEqual(
legacy.text.toInts(),
)
expect(resolved.contexts["@context:overlay"]?.background.default.toInts()).toEqual(
legacy.backgroundMenu.toInts(),
)
expect(resolved.contexts["@context:elevated"]?.text.action.primary.default.toInts()).toEqual(legacy.text.toInts())
expect(resolved.contexts["@context:overlay"]?.background.default.toInts()).toEqual(legacy.backgroundMenu.toInts())
expect(resolved.contexts["@context:overlay"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
})
@ -67,12 +66,15 @@ test("infers chromatic hues, anchors light and dark colors, and aliases ambiguou
source.theme.success = { light: "#ff6666", dark: "#450000" }
const migrated = migrateV1(source)
if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
const lightRed = migrated.light.hue?.red
const darkRed = migrated.dark.hue?.red
if (typeof lightRed !== "object" || typeof darkRed !== "object") throw new Error("Expected generated red scales")
expect(lightRed[900]).toBe("#ff6666")
expect(darkRed[100]).toBe("#450000")
expect(lightRed[800]).toBe("#ff6666")
expect(darkRed[200]).toBe("#450000")
expect(lightRed[900]).not.toBe(lightRed[800])
expect(darkRed[100]).not.toBe(darkRed[200])
expect(migrated.light.hue?.orange).toBe("$hue.gray")
expect(migrated.light.hue?.yellow).toBe("$hue.gray")
expect(migrated.light.hue?.green).toBe("$hue.gray")
@ -85,32 +87,78 @@ test("infers chromatic hues, anchors light and dark colors, and aliases ambiguou
expect(() => resolveThemeFile(migrated, "dark")).not.toThrow()
})
test("builds gray from V1 surfaces and text without using menus or borders", () => {
test("orders categorical hues by V1 semantic color mapping", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
const mapped = (name: "red" | "orange" | "yellow" | "green" | "blue" | "purple") => ({
light: DEFAULT_THEME.light.hue[name][700],
dark: DEFAULT_THEME.dark.hue[name][300],
})
source.theme.secondary = mapped("purple")
source.theme.accent = mapped("orange")
source.theme.success = mapped("green")
source.theme.warning = mapped("yellow")
source.theme.primary = mapped("blue")
source.theme.error = mapped("red")
const migrated = migrateV1(source)
expect(migrated.light?.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red"])
expect(migrated.dark?.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red"])
source.theme.accent = source.theme.secondary
expect(migrateV1(source).light?.categorical).toEqual(["purple", "green", "yellow", "blue", "red"])
})
test("uses default categorical hues when V1 semantic colors are ambiguous", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
source.theme.secondary = "transparent"
source.theme.accent = "transparent"
source.theme.success = "transparent"
source.theme.warning = "transparent"
source.theme.primary = "transparent"
source.theme.error = "transparent"
const migrated = migrateV1(source)
expect(migrated.light?.categorical).toEqual(DEFAULT_CATEGORICAL)
expect(migrated.dark?.categorical).toEqual(DEFAULT_CATEGORICAL)
})
test("builds and extrapolates gray from V1 surfaces and text without using menus or borders", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
source.theme.background = { light: "#eeeeee", dark: "#111111" }
source.theme.backgroundPanel = { light: "#dddddd", dark: "#222222" }
source.theme.backgroundElement = { light: "#cccccc", dark: "#333333" }
source.theme.textMuted = { light: "#777777", dark: "#999999" }
source.theme.text = { light: "#333333", dark: "#dddddd" }
source.theme.backgroundMenu = { light: "#ededed", dark: "#252525" }
const light = resolveV1(source, "light")
const dark = resolveV1(source, "dark")
const migrated = migrateV1(source)
if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
const lightGray = migrated.light.hue?.gray
const darkGray = migrated.dark.hue?.gray
if (typeof lightGray !== "object" || typeof darkGray !== "object") throw new Error("Expected concrete gray scales")
expect(lightGray[100]).toBe(hex(light.background))
expect(lightGray[200]).toBe(hex(light.backgroundPanel))
expect(lightGray[300]).toBe(hex(light.backgroundElement))
expect(lightGray[700]).toBe(hex(light.textMuted))
expect(lightGray[900]).toBe(hex(light.text))
expect(darkGray[100]).toBe(hex(dark.text))
expect(darkGray[300]).toBe(hex(dark.textMuted))
expect(darkGray[700]).toBe(hex(dark.backgroundElement))
expect(darkGray[800]).toBe(hex(dark.backgroundPanel))
expect(darkGray[900]).toBe(hex(dark.background))
expect(lightGray[100]).not.toBe(lightGray[200])
expect(lightGray[200]).toBe(hex(light.background))
expect(lightGray[300]).toBe(hex(light.backgroundPanel))
expect(lightGray[400]).toBe(hex(light.backgroundElement))
expect(lightGray[600]).toBe(hex(light.textMuted))
expect(lightGray[800]).toBe(hex(light.text))
expect(lightGray[900]).not.toBe(lightGray[800])
expect(darkGray[100]).not.toBe(darkGray[200])
expect(darkGray[200]).toBe(hex(dark.text))
expect(darkGray[400]).toBe(hex(dark.textMuted))
expect(darkGray[600]).toBe(hex(dark.backgroundElement))
expect(darkGray[700]).toBe(hex(dark.backgroundPanel))
expect(darkGray[800]).toBe(hex(dark.background))
expect(darkGray[900]).not.toBe(darkGray[800])
source.theme.borderSubtle = "#ff00ff"
source.theme.border = "#00ff00"
source.theme.borderActive = "#00ffff"
expect(migrateV1(source).light.hue?.gray).toEqual(lightGray)
expect(migrateV1(source).dark.hue?.gray).toEqual(darkGray)
const withBorders = migrateV1(source)
expect(withBorders.light?.hue?.gray).toEqual(lightGray)
expect(withBorders.dark?.hue?.gray).toEqual(darkGray)
})
test("uses the default text reference for primary actions on transparent backgrounds", () => {
@ -119,6 +167,7 @@ test("uses the default text reference for primary actions on transparent backgro
source.theme.primary = { light: "#ffffff", dark: "#000000" }
delete source.theme.selectedListItemText
const migrated = migrateV1(source)
if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
expect(migrated.dark.text?.action?.primary?.default).toBe("$text.default")
@ -132,14 +181,44 @@ test("retains V1 circular reference errors", () => {
expect(() => migrateV1(source)).toThrow("Circular color reference: one -> two -> one")
})
test("migrates every built-in V1 theme in both modes", () => {
test("migrates every built-in V1 theme in its supported modes", () => {
for (const source of Object.values(DEFAULT_THEMES)) {
const migrated = migrateV1(source)
expect(resolveThemeFile(migrated, "light").text.default).toBeDefined()
expect(resolveThemeFile(migrated, "dark").text.default).toBeDefined()
for (const mode of themeModes(migrated)) {
expect(resolveThemeFile(migrated, mode).text.default).toBeDefined()
}
}
})
test("collapses identical V1 backgrounds when both variants infer one mode", () => {
const dark = structuredClone(DEFAULT_THEMES.opencode)
dark.theme.background = "#111111"
dark.theme.text = "#eeeeee"
const migratedDark = migrateV1(dark)
expect(migratedDark.light).toBeUndefined()
expect(migratedDark.dark).toBeDefined()
expect(themeModes(migratedDark)).toEqual(["dark"])
expect(selectThemeMode(migratedDark, "light").mode).toBe("dark")
const light = structuredClone(DEFAULT_THEMES.opencode)
light.theme.background = "#eeeeee"
light.theme.text = "#111111"
const migratedLight = migrateV1(light)
expect(migratedLight.light).toBeDefined()
expect(migratedLight.dark).toBeUndefined()
expect(themeModes(migratedLight)).toEqual(["light"])
expect(selectThemeMode(migratedLight, "dark").mode).toBe("light")
})
test("keeps both modes when a shared background has different contrast", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
source.theme.background = "#808080"
source.theme.text = { light: "#111111", dark: "#eeeeee" }
const migrated = migrateV1(source)
expect(themeModes(migrated)).toEqual(["light", "dark"])
})
function hex(color: { toInts(): [number, number, number, number] }) {
const [r, g, b, a] = color.toInts()
const byte = (value: number) => value.toString(16).padStart(2, "0")

View file

@ -0,0 +1,35 @@
import { expect, test } from "bun:test"
import {
moveSelection,
moveSelectionOffset,
reconcileSelection,
revealSelectionOffset,
} from "../../src/ui/select-controller"
test("reconciles and moves selections with explicit boundary policy", () => {
expect([reconcileSelection(3, 0), reconcileSelection(4, 3), reconcileSelection(2, 6)]).toEqual([0, 2, 2])
expect([
moveSelection(0, { count: 3, delta: -1, policy: "clamp" }),
moveSelection(2, { count: 3, delta: 1, policy: "clamp" }),
moveSelection(0, { count: 3, delta: -1, policy: "wrap" }),
moveSelection(2, { count: 3, delta: 1, policy: "wrap" }),
]).toEqual([0, 2, 2, 0])
})
test("reveals selections within bounded windows", () => {
expect([
revealSelectionOffset(5, { count: 20, limit: 8, selected: 3 }),
revealSelectionOffset(3, { count: 20, limit: 8, selected: 11 }),
revealSelectionOffset(3, { count: 20, limit: 8, selected: 10 }),
revealSelectionOffset(20, { count: 20, limit: 8, selected: 19 }),
]).toEqual([3, 4, 3, 12])
})
test("keeps movement offsets and preview margins in bounds", () => {
expect([
moveSelectionOffset(0, { count: 20, limit: 8, selected: 6, direction: 1 }),
moveSelectionOffset(8, { count: 20, limit: 8, selected: 9, direction: -1 }),
moveSelectionOffset(12, { count: 20, limit: 8, selected: 19, direction: 1 }),
moveSelectionOffset(4, { count: 4, limit: 8, selected: 3, direction: 1 }),
]).toEqual([1, 7, 12, 0])
})

View file

@ -0,0 +1,103 @@
import { expect, test } from "bun:test"
import type { FormField, FormValue } from "@opencode-ai/client"
import {
formCustom,
formDisplayValue,
formInitialValues,
formLabel,
formRows,
formSelected,
formSetMultiselectCustom,
formTextual,
formToggleMultiselect,
formValidateValue,
isFormAnswerField,
} from "../../src/util/form"
import type { FormAnswerField } from "../../src/util/form"
const option = { key: "choice", type: "string", options: [{ value: "one", label: "One" }], custom: true } satisfies FormField
const selection = {
key: "tags",
type: "multiselect",
options: [
{ value: "one", label: "One" },
{ value: "two", label: "Two" },
],
custom: true,
} satisfies FormAnswerField
test("initializes configured and custom defaults", () => {
expect(
formInitialValues([
{ key: "mode", type: "string", options: [{ value: "fast", label: "Fast" }], default: "fast" },
{ ...option, key: "note", default: "detailed" },
{ ...option, key: "configured", default: "one" },
{ key: "count", type: "number", default: 0 },
{ key: "authorize", type: "external", url: "https://example.com" },
]),
).toEqual({
answers: { mode: "fast", note: "detailed", configured: "one", count: 0 },
custom: { note: "detailed" },
})
})
test("validates every supported field constraint", () => {
const validate = (field: FormAnswerField, value: FormValue | undefined, error: string | undefined) =>
expect(formValidateValue(field, value)).toBe(error)
const string = (extra: Partial<Extract<FormAnswerField, { type: "string" }>> = {}) =>
({ key: "value", type: "string", ...extra }) satisfies FormAnswerField
const multi = (extra: Partial<Extract<FormAnswerField, { type: "multiselect" }>> = {}) =>
({ key: "value", type: "multiselect", options: [], ...extra }) satisfies FormAnswerField
validate(string({ required: true }), undefined, "Answer required")
validate(multi({ required: true }), [], "Select at least one option")
validate(string(), true, "Expected text")
validate(string({ minLength: 3 }), "ab", "Must be at least 3 characters")
validate(string({ maxLength: 2 }), "abc", "Must be at most 2 characters")
validate(string({ pattern: "^a+$" }), "bbb", "Must match pattern: ^a+$")
validate(string({ pattern: "[" }), "value", "Invalid pattern: [")
validate(string({ format: "email" }), "invalid", "Expected an email address")
validate(string({ format: "uri" }), "not a URL", "Expected a URL")
validate(string({ format: "date" }), "2025-02-29", "Expected a date (YYYY-MM-DD)")
validate(string({ format: "date-time" }), "not a date", "Expected a date and time")
validate(string({ options: [{ value: "yes", label: "Yes" }] }), "no", "Select an available option")
validate({ key: "value", type: "number" }, Number.NaN, "Expected a number")
validate({ key: "value", type: "integer" }, 1.5, "Expected an integer")
validate({ key: "value", type: "number", minimum: 2 }, 1, "Must be at least 2")
validate({ key: "value", type: "number", maximum: 2 }, 3, "Must be at most 2")
validate({ key: "value", type: "boolean" }, "yes", "Expected yes or no")
validate(multi(), "yes", "Expected selections")
validate(multi({ minItems: 2 }), ["one"], "Select at least 2")
validate(multi({ maxItems: 1 }), ["one", "two"], "Select at most 1")
validate(multi({ options: [{ value: "one", label: "One" }] }), ["two"], "Select only available options")
validate(multi({ custom: true }), ["custom"], undefined)
})
test("shares field classification, rows, selection, and display", () => {
const text = { key: "name", type: "string", title: "Name" } satisfies FormField
const external = { key: "authorize", type: "external", url: "https://example.com" } satisfies FormField
expect([isFormAnswerField(text), isFormAnswerField(external)]).toEqual([true, false])
expect([formLabel(text), formLabel(external)]).toEqual(["Name", "https://example.com"])
expect([formTextual(text), formTextual(option), formCustom(option)]).toEqual([true, false, true])
expect(formRows({ key: "value", type: "boolean" })).toEqual([
{ value: true, label: "Yes" },
{ value: false, label: "No" },
])
expect(formRows({ ...option, options: [{ value: "one", label: "One", description: "First" }] })).toEqual([
{ value: "one", label: "One", description: "First" },
])
expect(formRows({ key: "value", type: "number" })).toEqual([])
expect([formSelected(selection, "two"), formSelected(selection, "custom"), formSelected(selection, undefined)]).toEqual([
1, 2, 0,
])
expect(formDisplayValue(selection, ["one", "custom"], "(none)")).toBe("One, custom")
expect([formDisplayValue(selection, [], ""), formDisplayValue(selection, [], "(none)")]).toEqual(["", "(none)"])
})
test("updates multiselects without mutating their source", () => {
const source = ["one", "custom"]
expect(formToggleMultiselect(source, "one")).toEqual(["custom"])
expect(formToggleMultiselect(source, "two")).toEqual(["one", "custom", "two"])
expect(formSetMultiselectCustom(source, "custom", "replacement")).toEqual(["one", "replacement"])
expect(source).toEqual(["one", "custom"])
})

View file

@ -0,0 +1,17 @@
import { expect, test } from "bun:test"
import { formatPath } from "../../src/util/path-format"
test("formats relative, home, and foreign paths", () => {
expect(formatPath(".", { base: "/work/project" })).toBe(".")
expect(formatPath("../shared/a.ts", { base: "/work/project" })).toBe("/work/shared/a.ts")
expect(formatPath("/home/test/project", { base: "/work", home: "/home/test" })).toBe("~/project")
expect(formatPath("src\\a.ts", { base: "/work", forwardSlashes: true })).toBe("src/a.ts")
expect(formatPath("C:/", { base: "/work" })).toBe("C:/")
expect(formatPath("C:\\Users\\tester", { base: "/work", forwardSlashes: true })).toBe("C:/Users/tester")
expect(formatPath("..\\shared\\a.ts", { base: "C:\\work\\project", forwardSlashes: true })).toBe(
"C:/work/shared/a.ts",
)
expect(
formatPath("C:\\Users\\test\\project", { base: "C:\\work", home: "C:\\Users\\test" }),
).toBe("~/project")
})

View file

@ -0,0 +1,19 @@
import { expect, test } from "bun:test"
import { permissionPresentation } from "../../src/util/permission"
test("preserves permission roots and self-contained metadata", () => {
expect(permissionPresentation({ action: "external_directory", resources: ["/*"] }).title).toBe(
"Access external directory /",
)
expect(permissionPresentation({ action: "external_directory", resources: ["C:/*"] }).title).toBe(
"Access external directory C:/",
)
expect(permissionPresentation({ action: "webfetch", resources: [], metadata: { url: "https://example.com" } })).toMatchObject({
title: "WebFetch https://example.com",
lines: ["URL: https://example.com"],
})
expect(permissionPresentation({ action: "websearch", resources: [], metadata: { query: "releases" } })).toMatchObject({
title: 'Web Search "releases"',
lines: ["Query: releases"],
})
})

View file

@ -4,5 +4,5 @@ import { sessionEpilogue } from "../../src/util/presentation"
test("formats session continuation summary", () => {
const epilogue = sessionEpilogue({ title: "A session", sessionID: "ses_123" })
expect(epilogue).toContain("A session")
expect(epilogue).toContain("opencode -s ses_123")
expect(epilogue).toContain("opencode2 -s ses_123")
})

View file

@ -1,5 +1,23 @@
import { describe, expect, test } from "bun:test"
import { toolDisplayMetadata, webSearchProviderLabel } from "../../src/util/tool-display"
import {
canonicalToolName,
finiteNumber,
primitiveInputSummary,
toolDisplayMetadata,
webSearchProviderLabel,
} from "../../src/util/tool-display"
test("normalizes shared tool primitives", () => {
expect(["bash", "task", "apply_patch", "plugin_tool"].map(canonicalToolName)).toEqual([
"shell",
"subagent",
"patch",
"plugin_tool",
])
expect([finiteNumber(-1.5), finiteNumber(Number.NaN), finiteNumber("1")]).toEqual([-1.5, undefined, undefined])
expect(primitiveInputSummary({ command: "pwd", count: 2, nested: {} })).toBe("[command=pwd, count=2]")
expect(primitiveInputSummary({ path: "src/a.ts", line: 2 }, ["path"])).toBe("[line=2]")
})
describe("webSearchProviderLabel", () => {
test("labels known providers", () => {