feat(aircoding): AirCoding V2 baseline — deterministic multi-agent architecture
Forked from OpenCode v1.17.4 with multi-agent system: - 5 agents: aircoding, scheduler, worker, architect, reviewer - Deterministic DAG scheduling engine (coordinator_tick) - Tool whitelists as hard enforcement - AirCoding validation plugin - V1 requirements: C4 docs, ADR, AGENTS.md, debug-log.md - Design documents in docs/
This commit is contained in:
127
packages/tui/test/app-lifecycle.test.tsx
Normal file
127
packages/tui/test/app-lifecycle.test.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { expect, mock, test } from "bun:test"
|
||||
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { Effect } from "effect"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
||||
import { createEventSource, createFetch, directory, json } from "./fixture/tui-sdk"
|
||||
|
||||
test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
const titles: string[] = []
|
||||
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
|
||||
setup.renderer.setTerminalTitle = (title) => {
|
||||
titles.push(title)
|
||||
setTitle(title)
|
||||
}
|
||||
const listeners = new Set(process.listeners("SIGHUP"))
|
||||
const events = createEventSource()
|
||||
const calls = createFetch()
|
||||
let started!: () => void
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
started = resolve
|
||||
})
|
||||
let disposes = 0
|
||||
|
||||
try {
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
url: "http://test",
|
||||
directory,
|
||||
config: createTuiResolvedConfig({ plugin_enabled: {} }),
|
||||
fetch: calls.fetch,
|
||||
events: events.source,
|
||||
args: {},
|
||||
pluginHost: {
|
||||
async start() {
|
||||
started()
|
||||
},
|
||||
async dispose() {
|
||||
disposes++
|
||||
},
|
||||
},
|
||||
}).pipe(Effect.provide(Global.defaultLayer)),
|
||||
)
|
||||
await ready
|
||||
process.emit("SIGHUP")
|
||||
await task
|
||||
|
||||
expect(setup.renderer.isDestroyed).toBe(true)
|
||||
expect(titles.at(-1)).toBe("")
|
||||
expect(disposes).toBe(1)
|
||||
expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true)
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test("app.exit prints the session epilogue after scoped cleanup", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
const events = createEventSource()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/session")
|
||||
return json([
|
||||
{
|
||||
id: "dummy",
|
||||
title: "Demo session",
|
||||
slug: "dummy",
|
||||
projectID: "project",
|
||||
directory,
|
||||
version: "0.0.0-test",
|
||||
time: { created: 0, updated: 0 },
|
||||
},
|
||||
])
|
||||
})
|
||||
const originalWrite = process.stdout.write.bind(process.stdout)
|
||||
let stdout = ""
|
||||
let api: TuiPluginApi | undefined
|
||||
let started!: () => void
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
started = resolve
|
||||
})
|
||||
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
stdout += String(chunk)
|
||||
return true
|
||||
}) as typeof process.stdout.write
|
||||
|
||||
try {
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
url: "http://test",
|
||||
directory,
|
||||
config: createTuiResolvedConfig({ plugin_enabled: {} }),
|
||||
fetch: calls.fetch,
|
||||
events: events.source,
|
||||
args: { continue: true },
|
||||
pluginHost: {
|
||||
async start(input) {
|
||||
api = input.api
|
||||
started()
|
||||
},
|
||||
async dispose() {},
|
||||
},
|
||||
}).pipe(Effect.provide(Global.defaultLayer)),
|
||||
)
|
||||
|
||||
await ready
|
||||
await setup.renderOnce()
|
||||
await setup.renderOnce()
|
||||
api?.keymap.dispatchCommand("app.exit")
|
||||
await task
|
||||
|
||||
expect(stdout).toContain("Demo session")
|
||||
expect(stdout).toContain("opencode -s dummy")
|
||||
} finally {
|
||||
process.stdout.write = originalWrite
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
mock.restore()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { recentConnectedWorkspaces } from "../../../../src/component/dialog-workspace-create"
|
||||
|
||||
describe("recentConnectedWorkspaces", () => {
|
||||
test("returns connected workspaces sorted by time used", () => {
|
||||
const workspaces = [
|
||||
{ id: "wrk_a", name: "alpha", timeUsed: 700 },
|
||||
{ id: "wrk_b", name: "beta", timeUsed: 800 },
|
||||
{ id: "wrk_c", name: "gamma", timeUsed: 400 },
|
||||
{ id: "wrk_d", name: "delta", timeUsed: 300 },
|
||||
{ id: "wrk_e", name: "epsilon", timeUsed: 200 },
|
||||
]
|
||||
const status = {
|
||||
wrk_a: "connected",
|
||||
wrk_b: "disconnected",
|
||||
wrk_c: "error",
|
||||
wrk_d: "connected",
|
||||
wrk_e: "connected",
|
||||
} as const
|
||||
|
||||
const { recent } = recentConnectedWorkspaces({
|
||||
workspaces,
|
||||
status: (workspaceID) => status[workspaceID as keyof typeof status],
|
||||
})
|
||||
|
||||
expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_a", "wrk_d", "wrk_e"])
|
||||
})
|
||||
})
|
||||
30
packages/tui/test/cli/cmd/tui/model-options.test.ts
Normal file
30
packages/tui/test/cli/cmd/tui/model-options.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { sortModelOptions } from "../../../../src/component/dialog-model"
|
||||
|
||||
describe("sortModelOptions", () => {
|
||||
test("orders provider-scoped model choices by newest release first", () => {
|
||||
const sorted = sortModelOptions(
|
||||
[
|
||||
{ title: "GPT 5.2", releaseDate: "2025-12-11" },
|
||||
{ title: "GPT 5.4", releaseDate: "2026-03-05" },
|
||||
{ title: "GPT 5.1", releaseDate: "2025-11-13" },
|
||||
],
|
||||
true,
|
||||
)
|
||||
|
||||
expect(sorted.map((model) => model.title)).toEqual(["GPT 5.4", "GPT 5.2", "GPT 5.1"])
|
||||
})
|
||||
|
||||
test("preserves free-first alphabetical ordering for the regular picker", () => {
|
||||
const sorted = sortModelOptions(
|
||||
[
|
||||
{ title: "Beta", releaseDate: "2026-01-01" },
|
||||
{ title: "Alpha", releaseDate: "2025-01-01", footer: "Free" },
|
||||
{ title: "Gamma", releaseDate: "2024-01-01", footer: "Free" },
|
||||
],
|
||||
false,
|
||||
)
|
||||
|
||||
expect(sorted.map((model) => model.title)).toEqual(["Alpha", "Gamma", "Beta"])
|
||||
})
|
||||
})
|
||||
267
packages/tui/test/cli/cmd/tui/notifications.test.ts
Normal file
267
packages/tui/test/cli/cmd/tui/notifications.test.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import Notifications from "../../../../src/feature-plugins/system/notifications"
|
||||
import type { Event, PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2"
|
||||
import type { TuiAttentionNotifyInput } from "@opencode-ai/plugin/tui"
|
||||
import { createTuiPluginApi } from "../../../fixture/tui-plugin"
|
||||
|
||||
async function setup() {
|
||||
const notifications: TuiAttentionNotifyInput[] = []
|
||||
const handlers = new Map<Event["type"], ((event: Event) => void)[]>()
|
||||
const session = (id: string, title: string, parentID?: string): Session => ({
|
||||
id,
|
||||
title,
|
||||
slug: id,
|
||||
projectID: "project",
|
||||
directory: "/workspace",
|
||||
...(parentID && { parentID }),
|
||||
version: "0.0.0-test",
|
||||
time: { created: 0, updated: 0 },
|
||||
})
|
||||
const sessions: Record<string, Session> = {
|
||||
session: session("session", "Demo session"),
|
||||
subagent: session("subagent", "Subagent session", "session"),
|
||||
abort: session("abort", "Abort session"),
|
||||
timeout: session("timeout", "Timeout session"),
|
||||
}
|
||||
|
||||
await Notifications.tui(
|
||||
createTuiPluginApi({
|
||||
attention: {
|
||||
async notify(input) {
|
||||
notifications.push(input)
|
||||
return { ok: true, notification: true, sound: true }
|
||||
},
|
||||
},
|
||||
event: {
|
||||
on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => {
|
||||
const list = handlers.get(type) ?? []
|
||||
const wrapped = handler as (event: Event) => void
|
||||
list.push(wrapped)
|
||||
handlers.set(type, list)
|
||||
return () => {
|
||||
handlers.set(
|
||||
type,
|
||||
(handlers.get(type) ?? []).filter((item) => item !== wrapped),
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
state: {
|
||||
session: {
|
||||
get: (sessionID: string) => sessions[sessionID],
|
||||
},
|
||||
},
|
||||
}),
|
||||
undefined,
|
||||
{} as never,
|
||||
)
|
||||
|
||||
return {
|
||||
notifications,
|
||||
emit(event: Event) {
|
||||
for (const handler of handlers.get(event.type) ?? []) handler(event)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function question(id: string, sessionID = "session"): QuestionRequest {
|
||||
return {
|
||||
id,
|
||||
sessionID,
|
||||
questions: [],
|
||||
}
|
||||
}
|
||||
|
||||
function permission(id: string, sessionID = "session"): PermissionRequest {
|
||||
return {
|
||||
id,
|
||||
sessionID,
|
||||
permission: "edit",
|
||||
patterns: [],
|
||||
metadata: {},
|
||||
always: [],
|
||||
}
|
||||
}
|
||||
|
||||
const questionNotification: TuiAttentionNotifyInput = {
|
||||
title: "Demo session",
|
||||
message: "Question needs input",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "question", when: "always" },
|
||||
}
|
||||
|
||||
const permissionNotification: TuiAttentionNotifyInput = {
|
||||
title: "Demo session",
|
||||
message: "Permission needs input",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "permission", when: "always" },
|
||||
}
|
||||
|
||||
describe("internal notifications TUI plugin", () => {
|
||||
test("notifies for question and permission requests with blurred notifications and always-on sounds", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") })
|
||||
harness.emit({ id: "event-2", type: "permission.asked", properties: permission("permission-1") })
|
||||
|
||||
expect(harness.notifications).toEqual([questionNotification, permissionNotification])
|
||||
})
|
||||
|
||||
test("dedupes pending questions and permissions until they are resolved", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") })
|
||||
harness.emit({ id: "event-2", type: "question.asked", properties: question("question-1") })
|
||||
harness.emit({
|
||||
id: "event-3",
|
||||
type: "question.replied",
|
||||
properties: { sessionID: "session", requestID: "question-1", answers: [] },
|
||||
})
|
||||
harness.emit({ id: "event-4", type: "question.asked", properties: question("question-1") })
|
||||
|
||||
harness.emit({ id: "event-5", type: "permission.asked", properties: permission("permission-1") })
|
||||
harness.emit({ id: "event-6", type: "permission.asked", properties: permission("permission-1") })
|
||||
harness.emit({
|
||||
id: "event-7",
|
||||
type: "permission.replied",
|
||||
properties: { sessionID: "session", requestID: "permission-1", reply: "once" },
|
||||
})
|
||||
harness.emit({ id: "event-8", type: "permission.asked", properties: permission("permission-1") })
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
questionNotification,
|
||||
questionNotification,
|
||||
permissionNotification,
|
||||
permissionNotification,
|
||||
])
|
||||
})
|
||||
|
||||
test("notifies when an active session becomes idle and suppresses no-op idle", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({
|
||||
id: "event-1",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "idle" } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-2",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "busy" } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-3",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "idle" } },
|
||||
})
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
title: "Demo session",
|
||||
message: "Session done",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "done", when: "always" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1", "subagent") })
|
||||
harness.emit({
|
||||
id: "event-2",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "subagent", status: { type: "busy" } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-3",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "subagent", status: { type: "idle" } },
|
||||
})
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
title: "Subagent session",
|
||||
message: "Question needs input",
|
||||
notification: false,
|
||||
sound: { name: "question", when: "always" },
|
||||
},
|
||||
{
|
||||
title: "Subagent session",
|
||||
message: "Session done",
|
||||
notification: false,
|
||||
sound: { name: "subagent_done", when: "always" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("notifies session errors once and suppresses the following idle done notification", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({
|
||||
id: "event-1",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "busy" } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-2",
|
||||
type: "session.error",
|
||||
properties: { sessionID: "session", error: { name: "UnknownError", data: { message: "boom" } } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-3",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "idle" } },
|
||||
})
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
title: "Demo session",
|
||||
message: "Session error",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "error", when: "always" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("special-cases aborts and model response timeouts", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({
|
||||
id: "event-1",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "abort", status: { type: "busy" } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-2",
|
||||
type: "session.error",
|
||||
properties: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-3",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "timeout", status: { type: "busy" } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-4",
|
||||
type: "session.error",
|
||||
properties: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
|
||||
})
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
title: "Abort session",
|
||||
message: "Session aborted",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "error", when: "always" },
|
||||
},
|
||||
{
|
||||
title: "Timeout session",
|
||||
message: "Model stopped responding",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "error", when: "always" },
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
41
packages/tui/test/cli/cmd/tui/provider-options.test.ts
Normal file
41
packages/tui/test/cli/cmd/tui/provider-options.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { normalizeCustomProviderID, providerOptions } from "../../../../src/component/dialog-provider"
|
||||
|
||||
describe("providerOptions", () => {
|
||||
test("includes a synthetic Other option for custom providers", () => {
|
||||
expect(providerOptions([{ id: "openai", name: "OpenAI" }]).at(-1)).toMatchObject({
|
||||
title: "Other",
|
||||
description: "Custom provider",
|
||||
category: "Providers",
|
||||
})
|
||||
})
|
||||
|
||||
test("does not use Other as the generic provider category", () => {
|
||||
expect(providerOptions([{ id: "mistral", name: "Mistral" }])[0]?.category).toBe("Providers")
|
||||
})
|
||||
|
||||
test("keeps popular providers first and sorts the rest alphabetically", () => {
|
||||
expect(
|
||||
providerOptions([
|
||||
{ id: "openai", name: "OpenAI" },
|
||||
{ id: "custom-z", name: "Zebra Provider" },
|
||||
{ id: "anthropic", name: "Anthropic" },
|
||||
{ id: "mistral", name: "Mistral" },
|
||||
{ id: "aws", name: "AWS Bedrock" },
|
||||
]).map((option) => option.value),
|
||||
).toEqual(["openai", "anthropic", "aws", "mistral", "custom-z", "__opencode_custom_provider__"])
|
||||
})
|
||||
|
||||
test("does not collide with a configured provider named other", () => {
|
||||
const values = providerOptions([{ id: "other", name: "Other Provider" }]).map((option) => option.value)
|
||||
expect(new Set(values).size).toBe(values.length)
|
||||
})
|
||||
|
||||
test("normalizes and validates custom provider ids", () => {
|
||||
expect(normalizeCustomProviderID(" custom-provider ")).toBe("custom-provider")
|
||||
expect(normalizeCustomProviderID("custom_provider")).toBe("custom_provider")
|
||||
expect(normalizeCustomProviderID("@ai-sdk/custom-provider")).toBe("custom-provider")
|
||||
expect(normalizeCustomProviderID("-custom-provider")).toBeUndefined()
|
||||
expect(normalizeCustomProviderID("Custom Provider")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
64
packages/tui/test/cli/cmd/tui/sync-fixture.tsx
Normal file
64
packages/tui/test/cli/cmd/tui/sync-fixture.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { onMount } from "solid-js"
|
||||
import { ArgsProvider } from "../../../../src/context/args"
|
||||
import { KVProvider, useKV } from "../../../../src/context/kv"
|
||||
import { ProjectProvider, useProject } from "../../../../src/context/project"
|
||||
import { SDKProvider } from "../../../../src/context/sdk"
|
||||
import { SyncProvider, useSync } from "../../../../src/context/sync"
|
||||
import { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk"
|
||||
import { TestTuiContexts } from "../../../fixture/tui-environment"
|
||||
export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk"
|
||||
|
||||
export async function wait(fn: () => boolean, timeout = 2000) {
|
||||
const start = Date.now()
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
}
|
||||
|
||||
type Ctx = { kv: ReturnType<typeof useKV>; project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }
|
||||
|
||||
export async function mount(override?: FetchHandler, state?: string) {
|
||||
const calls = createFetch(override)
|
||||
const events = createEventSource()
|
||||
let sync!: ReturnType<typeof useSync>
|
||||
let project!: ReturnType<typeof useProject>
|
||||
let kv!: ReturnType<typeof useKV>
|
||||
let done!: () => void
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
done = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
const ctx: Ctx = { kv: useKV(), project: useProject(), sync: useSync() }
|
||||
onMount(() => {
|
||||
sync = ctx.sync
|
||||
project = ctx.project
|
||||
kv = ctx.kv
|
||||
done()
|
||||
})
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts paths={state ? { state } : undefined}>
|
||||
<ArgsProvider>
|
||||
<KVProvider>
|
||||
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={events.source}>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<Probe />
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</KVProvider>
|
||||
</ArgsProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
await ready
|
||||
await wait(() => sync.status === "complete")
|
||||
return { app, emit: events.emit, kv, project, sync, session: calls.session }
|
||||
}
|
||||
262
packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx
Normal file
262
packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import { tmpdir } from "../../../fixture/fixture"
|
||||
import { json, mount, wait } from "./sync-fixture"
|
||||
|
||||
const sessionID = "ses_hydration_race"
|
||||
const messageID = "msg_hydration_race"
|
||||
const partID = "prt_hydration_race"
|
||||
const session = {
|
||||
id: sessionID,
|
||||
title: "race",
|
||||
time: { created: 0, updated: 0 },
|
||||
version: "1.15.13",
|
||||
directory: "/tmp/opencode/packages/opencode",
|
||||
}
|
||||
const assistant = {
|
||||
id: messageID,
|
||||
sessionID,
|
||||
role: "assistant" as const,
|
||||
agent: "build",
|
||||
modelID: "model",
|
||||
providerID: "test",
|
||||
mode: "build",
|
||||
parentID: "msg_user",
|
||||
path: { cwd: session.directory, root: session.directory },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, completed: 2 },
|
||||
}
|
||||
|
||||
function global(payload: GlobalEvent["payload"]): GlobalEvent {
|
||||
return { directory: "/tmp/other", project: "proj_test", payload }
|
||||
}
|
||||
|
||||
test("stale session hydration does not overwrite live message parts", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Bun.write(`${tmp.path}/kv.json`, "{}")
|
||||
|
||||
let resolveMessages!: (response: Response) => void
|
||||
const messages = new Promise<Response>((resolve) => {
|
||||
resolveMessages = resolve
|
||||
})
|
||||
let requested = false
|
||||
const { app, emit, sync } = await mount((url) => {
|
||||
if (url.pathname === `/session/${sessionID}`) return json(session)
|
||||
if (url.pathname === `/session/${sessionID}/message`) {
|
||||
requested = true
|
||||
return messages
|
||||
}
|
||||
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
|
||||
return undefined
|
||||
}, tmp.path)
|
||||
|
||||
try {
|
||||
const hydrate = sync.session.sync(sessionID)
|
||||
await wait(() => requested)
|
||||
emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
|
||||
emit(
|
||||
global({
|
||||
id: "evt_part",
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID,
|
||||
time: 2,
|
||||
part: { id: partID, sessionID, messageID, type: "text", text: "visible live content" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await wait(() => sync.data.part[messageID]?.[0]?.type === "text")
|
||||
|
||||
resolveMessages(
|
||||
json([
|
||||
{
|
||||
info: assistant,
|
||||
parts: [{ id: partID, sessionID, messageID, type: "text", text: "" }],
|
||||
},
|
||||
]),
|
||||
)
|
||||
await hydrate
|
||||
|
||||
expect(sync.data.part[messageID][0]).toMatchObject({ text: "visible live content" })
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("orphan live deltas do not suppress hydrated parts", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Bun.write(`${tmp.path}/kv.json`, "{}")
|
||||
|
||||
let resolveMessages!: (response: Response) => void
|
||||
const messages = new Promise<Response>((resolve) => {
|
||||
resolveMessages = resolve
|
||||
})
|
||||
let requested = false
|
||||
const { app, emit, sync } = await mount((url) => {
|
||||
if (url.pathname === `/session/${sessionID}`) return json(session)
|
||||
if (url.pathname === `/session/${sessionID}/message`) {
|
||||
requested = true
|
||||
return messages
|
||||
}
|
||||
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
|
||||
return undefined
|
||||
}, tmp.path)
|
||||
|
||||
try {
|
||||
const hydrate = sync.session.sync(sessionID)
|
||||
await wait(() => requested)
|
||||
emit(
|
||||
global({
|
||||
id: "evt_delta",
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID, messageID, partID, field: "text", delta: "ignored until part exists" },
|
||||
}),
|
||||
)
|
||||
resolveMessages(
|
||||
json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "hydrated" }] }]),
|
||||
)
|
||||
await hydrate
|
||||
|
||||
expect(sync.data.part[messageID][0]).toMatchObject({ text: "hydrated" })
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("hydration does not clear text streamed before it starts", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Bun.write(`${tmp.path}/kv.json`, "{}")
|
||||
|
||||
let resolveMessages!: (response: Response) => void
|
||||
const messages = new Promise<Response>((resolve) => {
|
||||
resolveMessages = resolve
|
||||
})
|
||||
let requested = false
|
||||
const { app, emit, sync } = await mount((url) => {
|
||||
if (url.pathname === `/session/${sessionID}`) return json(session)
|
||||
if (url.pathname === `/session/${sessionID}/message`) {
|
||||
requested = true
|
||||
return messages
|
||||
}
|
||||
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
|
||||
return undefined
|
||||
}, tmp.path)
|
||||
|
||||
try {
|
||||
emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
|
||||
emit(
|
||||
global({
|
||||
id: "evt_part",
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID,
|
||||
time: 1,
|
||||
part: { id: partID, sessionID, messageID, type: "text", text: "" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
emit(
|
||||
global({
|
||||
id: "evt_delta",
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID, messageID, partID, field: "text", delta: "visible streamed content" },
|
||||
}),
|
||||
)
|
||||
await wait(() => sync.data.part[messageID]?.[0]?.type === "text" && sync.data.part[messageID][0].text !== "")
|
||||
const hydrate = sync.session.sync(sessionID)
|
||||
await wait(() => requested)
|
||||
resolveMessages(json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "" }] }]))
|
||||
await hydrate
|
||||
|
||||
expect(sync.data.part[messageID][0]).toMatchObject({ text: "visible streamed content" })
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("live messages merged during hydration retain the 100 message window", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Bun.write(`${tmp.path}/kv.json`, "{}")
|
||||
|
||||
let resolveMessages!: (response: Response) => void
|
||||
const messages = new Promise<Response>((resolve) => {
|
||||
resolveMessages = resolve
|
||||
})
|
||||
let requested = false
|
||||
const { app, emit, sync } = await mount((url) => {
|
||||
if (url.pathname === `/session/${sessionID}`) return json(session)
|
||||
if (url.pathname === `/session/${sessionID}/message`) {
|
||||
requested = true
|
||||
return messages
|
||||
}
|
||||
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
|
||||
return undefined
|
||||
}, tmp.path)
|
||||
|
||||
try {
|
||||
const hydrate = sync.session.sync(sessionID)
|
||||
await wait(() => requested)
|
||||
const live = { ...assistant, id: "msg_z_live" }
|
||||
emit(global({ id: "evt_live", type: "message.updated", properties: { sessionID, info: live } }))
|
||||
await wait(() => sync.data.message[sessionID]?.some((message) => message.id === live.id) ?? false)
|
||||
resolveMessages(
|
||||
json(
|
||||
Array.from({ length: 100 }, (_, index) => {
|
||||
const id = `msg_${String(index).padStart(3, "0")}`
|
||||
return {
|
||||
info: { ...assistant, id },
|
||||
parts: [{ id: `prt_${id}`, sessionID, messageID: id, type: "text", text: id }],
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
await hydrate
|
||||
|
||||
expect(sync.data.message[sessionID]).toHaveLength(100)
|
||||
expect(sync.data.message[sessionID].at(-1)?.id).toBe(live.id)
|
||||
expect(sync.data.message[sessionID].some((message) => message.id === "msg_000")).toBe(false)
|
||||
expect(sync.data.part.msg_000).toBeUndefined()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("a message removed during hydration does not regain stale parts", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Bun.write(`${tmp.path}/kv.json`, "{}")
|
||||
|
||||
let resolveMessages!: (response: Response) => void
|
||||
const messages = new Promise<Response>((resolve) => {
|
||||
resolveMessages = resolve
|
||||
})
|
||||
let requested = false
|
||||
const { app, emit, sync } = await mount((url) => {
|
||||
if (url.pathname === `/session/${sessionID}`) return json(session)
|
||||
if (url.pathname === `/session/${sessionID}/message`) {
|
||||
requested = true
|
||||
return messages
|
||||
}
|
||||
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
|
||||
return undefined
|
||||
}, tmp.path)
|
||||
|
||||
try {
|
||||
emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
|
||||
await wait(() => sync.data.message[sessionID]?.length === 1)
|
||||
const hydrate = sync.session.sync(sessionID)
|
||||
await wait(() => requested)
|
||||
emit(global({ id: "evt_removed", type: "message.removed", properties: { sessionID, messageID } }))
|
||||
await wait(() => sync.data.message[sessionID]?.length === 0)
|
||||
resolveMessages(
|
||||
json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "stale" }] }]),
|
||||
)
|
||||
await hydrate
|
||||
|
||||
expect(sync.data.message[sessionID]).toEqual([])
|
||||
expect(sync.data.part[messageID]).toBeUndefined()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
/**
|
||||
* Reproducer for #26560 — TUI crashes with
|
||||
* `TypeError: undefined is not an object (evaluating 'f.data.map')`
|
||||
* when entering a session whose messages endpoint returns a non-2xx.
|
||||
* The failure path is `sync.tsx#sync.session.sync` reading
|
||||
* `messages.data!` while the SDK leaves `data` undefined on error.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { tmpdir } from "../../../fixture/fixture"
|
||||
import { directory, json, mount } from "./sync-fixture"
|
||||
|
||||
const sessionID = "ses_undef"
|
||||
|
||||
describe("tui sync (#26560)", () => {
|
||||
test("entering a session whose messages endpoint errors does not crash sync", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Bun.write(`${tmp.path}/kv.json`, "{}")
|
||||
|
||||
const sessionPayload = {
|
||||
id: sessionID,
|
||||
title: "broken",
|
||||
time: { created: 0, updated: 0 },
|
||||
version: "1.14.42",
|
||||
directory,
|
||||
project_id: "proj_test",
|
||||
}
|
||||
const { app, sync } = await mount((url) => {
|
||||
if (url.pathname === `/session/${sessionID}`) return json(sessionPayload)
|
||||
if (url.pathname === `/session/${sessionID}/messages`) return json({}, { status: 500 })
|
||||
if (url.pathname === `/session/${sessionID}/todo`) return json([])
|
||||
if (url.pathname === `/session/${sessionID}/diff`) return json([])
|
||||
if (url.pathname === "/session") return json([sessionPayload])
|
||||
return undefined
|
||||
}, tmp.path)
|
||||
|
||||
try {
|
||||
await expect(sync.session.sync(sessionID)).resolves.toBeUndefined()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
63
packages/tui/test/cli/cmd/tui/sync.test.tsx
Normal file
63
packages/tui/test/cli/cmd/tui/sync.test.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { tmpdir } from "../../../fixture/fixture"
|
||||
import { mount, wait } from "./sync-fixture"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
|
||||
function branchEvent(branch: string, workspace?: string): GlobalEvent {
|
||||
return {
|
||||
directory: "/tmp/other",
|
||||
project: "proj_test",
|
||||
workspace,
|
||||
payload: {
|
||||
id: `evt_vcs_${branch}`,
|
||||
type: "vcs.branch.updated",
|
||||
properties: { branch },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("tui sync", () => {
|
||||
test("refresh scopes sessions by default and lists project sessions when disabled", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Bun.write(`${tmp.path}/kv.json`, "{}")
|
||||
const { app, kv, sync, session } = await mount(undefined, tmp.path)
|
||||
|
||||
try {
|
||||
expect(kv.get("session_directory_filter_enabled", true)).toBe(true)
|
||||
expect(session.at(-1)?.searchParams.get("scope")).toBeNull()
|
||||
expect(session.at(-1)?.searchParams.get("path")).toBe("packages/tui")
|
||||
|
||||
kv.set("session_directory_filter_enabled", false)
|
||||
await sync.session.refresh()
|
||||
|
||||
expect(session.at(-1)?.searchParams.get("scope")).toBe("project")
|
||||
expect(session.at(-1)?.searchParams.get("path")).toBeNull()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("vcs branch updates only apply for the active workspace", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Bun.write(`${tmp.path}/kv.json`, "{}")
|
||||
const { app, emit, project, sync } = await mount(undefined, tmp.path)
|
||||
|
||||
try {
|
||||
expect(sync.data.vcs?.branch).toBe("main")
|
||||
|
||||
project.workspace.set("ws_a")
|
||||
emit(branchEvent("other", "ws_b"))
|
||||
await Bun.sleep(30)
|
||||
|
||||
expect(sync.data.vcs?.branch).toBe("main")
|
||||
|
||||
emit(branchEvent("feature", "ws_a"))
|
||||
await wait(() => sync.data.vcs?.branch === "feature")
|
||||
|
||||
expect(sync.data.vcs?.branch).toBe("feature")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||
|
||||
exports[`TUI inline tool wrapping snapshots consecutive grep, glob, and read rows at a narrow width 1`] = `
|
||||
" ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
|
||||
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
|
||||
✱ Glob "**/*db*" in packages/opencode (6 matches)
|
||||
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
|
||||
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
|
||||
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
|
||||
Path\\.data|data =" in packages/opencode/src (115 matches)"
|
||||
`;
|
||||
|
||||
exports[`TUI inline tool wrapping snapshots expanded tool errors under the tool text 1`] = `
|
||||
" ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
|
||||
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
|
||||
✱ Glob "**/*db*" in packages/opencode (6 matches)
|
||||
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
|
||||
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
|
||||
No LSP server available for this file type.
|
||||
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
|
||||
Path\\.data|data =" in packages/opencode/src (115 matches)"
|
||||
`;
|
||||
|
||||
exports[`TUI inline tool wrapping keeps separation after a shell output block 1`] = `
|
||||
"
|
||||
|
||||
# List files
|
||||
|
||||
$ ls
|
||||
|
||||
file.ts
|
||||
|
||||
✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
|
||||
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
|
||||
✱ Glob "**/*db*" in packages/opencode (6 matches)
|
||||
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
|
||||
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
|
||||
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
|
||||
Path\\.data|data =" in packages/opencode/src (115 matches)"
|
||||
`;
|
||||
|
||||
exports[`TUI inline tool wrapping keeps separation after a padded user message 1`] = `
|
||||
"
|
||||
Check whether the next tool remains separated.
|
||||
|
||||
|
||||
✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
|
||||
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
|
||||
✱ Glob "**/*db*" in packages/opencode (6 matches)
|
||||
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
|
||||
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
|
||||
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
|
||||
Path\\.data|data =" in packages/opencode/src (115 matches)"
|
||||
`;
|
||||
|
||||
exports[`TUI inline tool wrapping separates a contiguous subagent group from inline tools 1`] = `
|
||||
" ✱ Grep "Task" (2 matches)
|
||||
|
||||
⠙ Explore Task — Inspect active task spacing
|
||||
✓ General Task — Confirm completed task spacing
|
||||
↳ 1 toolcall · 501ms
|
||||
|
||||
→ Read src/cli/cmd/tui/routes/session/index.tsx"
|
||||
`;
|
||||
|
||||
exports[`TUI inline tool wrapping separates a subagent group after an expanded read 1`] = `
|
||||
" → Read src/cli/cmd/tui/routes/session/index.tsx
|
||||
↳ Loaded src/cli/cmd/tui/routes/session/tools.tsx
|
||||
|
||||
✓ Explore Task — Inspect active task spacing
|
||||
↳ 1 toolcall · 501ms"
|
||||
`;
|
||||
488
packages/tui/test/cli/tui/data.test.tsx
Normal file
488
packages/tui/test/cli/tui/data.test.tsx
Normal file
@@ -0,0 +1,488 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import { onMount } from "solid-js"
|
||||
import { ProjectProvider } from "../../../src/context/project"
|
||||
import { SDKProvider } from "../../../src/context/sdk"
|
||||
import { DataProvider, useData } from "../../../src/context/data"
|
||||
import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
|
||||
async function wait(fn: () => boolean, timeout = 2000) {
|
||||
const start = Date.now()
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
}
|
||||
|
||||
function global(payload: Event): GlobalEvent {
|
||||
return { directory, project: "proj_test", payload }
|
||||
}
|
||||
|
||||
function emitEvent(events: ReturnType<typeof createEventSource>, payload: Event) {
|
||||
events.emit(global(payload))
|
||||
}
|
||||
|
||||
test("refreshes resources into reactive getters", async () => {
|
||||
const location = {
|
||||
directory,
|
||||
project: { id: "proj_test", directory },
|
||||
}
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session/ses_test")
|
||||
return json({
|
||||
data: {
|
||||
id: "ses_test",
|
||||
projectID: "proj_test",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
title: "Test session",
|
||||
location: { directory },
|
||||
},
|
||||
})
|
||||
if (url.pathname === "/api/agent")
|
||||
return json({
|
||||
location,
|
||||
data: [{ id: "build", request: { headers: {}, body: {} }, mode: "primary", hidden: false, permissions: [] }],
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
const events = createEventSource()
|
||||
let data!: ReturnType<typeof useData>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
expect(data.location.default()).toEqual({ directory })
|
||||
expect(data.session.get("ses_test")).toBeUndefined()
|
||||
expect(data.location.agent.list(location)).toBeUndefined()
|
||||
|
||||
await data.session.refresh("ses_test")
|
||||
await data.location.agent.refresh()
|
||||
|
||||
expect(data.session.get("ses_test")?.title).toBe("Test session")
|
||||
expect(data.location.default()).toEqual({ directory, workspaceID: undefined })
|
||||
expect(data.location.agent.list(location)?.map((agent) => agent.id)).toEqual(["build"])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("refreshes connectors after connector updates", async () => {
|
||||
const events = createEventSource()
|
||||
let requests = 0
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== "/api/connector") return
|
||||
requests++
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory } },
|
||||
data:
|
||||
requests === 1
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
methods: [{ id: "api-key", type: "key", label: "API Key" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
let data!: ReturnType<typeof useData>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
await wait(() => data.location.connector.list() !== undefined)
|
||||
expect(data.location.connector.list()).toEqual([])
|
||||
|
||||
emitEvent(events, { id: "evt_connector", type: "connector.updated", properties: {} })
|
||||
await wait(() => data.location.connector.list()?.length === 1)
|
||||
expect(data.location.connector.list()?.[0]).toMatchObject({ id: "openai", name: "OpenAI" })
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("refreshes references after updates", async () => {
|
||||
const events = createEventSource()
|
||||
let requests = 0
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== "/api/reference") return
|
||||
requests++
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory } },
|
||||
data: requests === 1 ? [] : [{ name: "docs", path: "/docs", source: { type: "local", path: "/docs" } }],
|
||||
})
|
||||
})
|
||||
let data!: ReturnType<typeof useData>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
await wait(() => requests === 1)
|
||||
emitEvent(events, { id: "evt_reference_1", type: "reference.updated", properties: {} })
|
||||
await wait(() => data.location.reference.list()?.length === 1)
|
||||
expect(data.location.reference.list()?.[0]?.name).toBe("docs")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("settles pending tools when a live failure arrives", async () => {
|
||||
const events = createEventSource()
|
||||
const calls = createFetch()
|
||||
let sync!: ReturnType<typeof useData>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useData()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitEvent(events, {
|
||||
id: "evt_agent_1",
|
||||
type: "session.next.agent.switched",
|
||||
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_model_1",
|
||||
type: "session.next.model.switched",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_model_1",
|
||||
timestamp: 0,
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_step_started_1",
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
timestamp: 1,
|
||||
agent: "build",
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_input_1",
|
||||
type: "session.next.tool.input.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
timestamp: 2,
|
||||
callID: "call-1",
|
||||
name: "bash",
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_called_1",
|
||||
type: "session.next.tool.called",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 2,
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
callID: "call-1",
|
||||
tool: "bash",
|
||||
input: {},
|
||||
provider: { executed: false, metadata: { fake: { call: true } } },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_failed_1",
|
||||
type: "session.next.tool.failed",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 3,
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
callID: "call-1",
|
||||
error: { type: "unknown", message: "aborted" },
|
||||
provider: { executed: false, metadata: { fake: { result: true } } },
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => {
|
||||
const assistant = sync.session.message.list("session-1")?.[0]
|
||||
return (
|
||||
assistant?.type === "assistant" &&
|
||||
assistant.content[0]?.type === "tool" &&
|
||||
assistant.content[0].state.status === "error"
|
||||
)
|
||||
})
|
||||
|
||||
const assistant = sync.session.message.list("session-1")?.[0]
|
||||
expect(assistant?.type).toBe("assistant")
|
||||
if (assistant?.type !== "assistant") return
|
||||
expect(assistant.id).toBe("msg_explicit_assistant_9")
|
||||
const tool = assistant.content[0]
|
||||
expect(tool?.type).toBe("tool")
|
||||
if (tool?.type !== "tool") return
|
||||
expect(tool.state.status).toBe("error")
|
||||
if (tool.state.status !== "error") return
|
||||
expect(tool.state.error).toEqual({ type: "unknown", message: "aborted" })
|
||||
expect(tool.state.input).toEqual({})
|
||||
expect(tool.state.structured).toEqual({})
|
||||
expect(tool.state.content).toEqual([])
|
||||
expect(tool.provider).toEqual({
|
||||
executed: false,
|
||||
metadata: { fake: { call: true } },
|
||||
resultMetadata: { fake: { result: true } },
|
||||
})
|
||||
expect((sync.session.message.list("session-1") ?? []).map((message) => message.type)).toEqual([
|
||||
"assistant",
|
||||
"model-switched",
|
||||
"agent-switched",
|
||||
])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("renders admitted prompts only after promotion", async () => {
|
||||
const events = createEventSource()
|
||||
const calls = createFetch()
|
||||
let sync!: ReturnType<typeof useData>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useData()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitEvent(events, {
|
||||
id: "evt_admitted_1",
|
||||
type: "session.next.prompt.admitted",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_user_1",
|
||||
timestamp: 0,
|
||||
prompt: { text: "hello" },
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
expect(sync.session.message.list("session-1") ?? []).toEqual([])
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_promoted_1",
|
||||
type: "session.next.prompt.promoted",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_user_1",
|
||||
timestamp: 1,
|
||||
prompt: { text: "hello" },
|
||||
timeCreated: 0,
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => sync.session.message.list("session-1")?.length === 1)
|
||||
const message = sync.session.message.list("session-1")?.[0]
|
||||
expect(message?.type).toBe("user")
|
||||
if (message?.type !== "user") return
|
||||
expect(message).toMatchObject({ id: "msg_user_1", text: "hello" })
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("renders a promoted prompt when admission was missed", async () => {
|
||||
const events = createEventSource()
|
||||
const calls = createFetch()
|
||||
let sync!: ReturnType<typeof useData>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useData()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitEvent(events, {
|
||||
id: "evt_promoted_1",
|
||||
type: "session.next.prompt.promoted",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_user_1",
|
||||
timestamp: 1,
|
||||
prompt: { text: "hello" },
|
||||
timeCreated: 0,
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => sync.session.message.list("session-1")?.length === 1)
|
||||
expect(sync.session.message.list("session-1")?.[0]?.id).toBe("msg_user_1")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("projects live context updates with their message ID", async () => {
|
||||
const events = createEventSource()
|
||||
const calls = createFetch()
|
||||
let sync!: ReturnType<typeof useData>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useData()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitEvent(events, {
|
||||
id: "evt_context_1",
|
||||
type: "session.next.context.updated",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_context_1",
|
||||
timestamp: 1,
|
||||
text: "Updated context",
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => sync.session.message.list("session-1")?.length === 1)
|
||||
expect(sync.session.message.list("session-1")?.[0]).toMatchObject({
|
||||
id: "msg_context_1",
|
||||
type: "system",
|
||||
text: "Updated context",
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
147
packages/tui/test/cli/tui/dialog-prompt.test.tsx
Normal file
147
packages/tui/test/cli/tui/dialog-prompt.test.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { TextareaRenderable } from "@opentui/core"
|
||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import { testRender, useRenderer } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import type { TuiKeybind } from "../../../src/config/keybind"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
|
||||
async function wait(fn: () => boolean, timeout = 2000) {
|
||||
const start = Date.now()
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
}
|
||||
|
||||
async function mountPrompt(input: {
|
||||
root: string
|
||||
keybinds: Partial<TuiKeybind.Keybinds>
|
||||
onConfirm: (value: string) => void
|
||||
}) {
|
||||
const state = path.join(input.root, "state")
|
||||
await mkdir(state, { recursive: true })
|
||||
await Bun.write(path.join(state, "kv.json"), "{}")
|
||||
|
||||
const [
|
||||
{ DialogProvider },
|
||||
{ DialogPrompt },
|
||||
{ KVProvider },
|
||||
{ ThemeProvider },
|
||||
{ TuiConfigProvider },
|
||||
{ ToastProvider },
|
||||
{ OpencodeKeymapProvider, registerOpencodeKeymap },
|
||||
] = await Promise.all([
|
||||
import("../../../src/ui/dialog"),
|
||||
import("../../../src/ui/dialog-prompt"),
|
||||
import("../../../src/context/kv"),
|
||||
import("../../../src/context/theme"),
|
||||
import("../../../src/config"),
|
||||
import("../../../src/ui/toast"),
|
||||
import("../../../src/keymap"),
|
||||
])
|
||||
|
||||
function Harness() {
|
||||
const renderer = useRenderer()
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
const resolvedConfig = createTuiResolvedConfig({
|
||||
keybinds: input.keybinds,
|
||||
leader_timeout: 1000,
|
||||
})
|
||||
const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig)
|
||||
onCleanup(off)
|
||||
|
||||
return (
|
||||
<TestTuiContexts
|
||||
directory={input.root}
|
||||
paths={{
|
||||
home: input.root,
|
||||
state,
|
||||
worktree: input.root,
|
||||
}}
|
||||
>
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<TuiConfigProvider config={resolvedConfig}>
|
||||
<KVProvider>
|
||||
<ThemeProvider mode="dark">
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<DialogPrompt title="Rename Session" value="draft" onConfirm={input.onConfirm} />
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</KVProvider>
|
||||
</TuiConfigProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
</TestTuiContexts>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => <Harness />, { kittyKeyboard: true })
|
||||
return {
|
||||
app,
|
||||
async cleanup() {
|
||||
app.renderer.destroy()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test("dialog prompt submit wins when return is also input newline", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const confirmed: string[] = []
|
||||
const prompt = await mountPrompt({
|
||||
root: tmp.path,
|
||||
keybinds: {
|
||||
input_submit: "super+return",
|
||||
input_newline: "return,shift+return,alt+return,ctrl+j",
|
||||
},
|
||||
onConfirm: (value) => confirmed.push(value),
|
||||
})
|
||||
|
||||
try {
|
||||
await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
|
||||
const textarea = prompt.app.renderer.currentFocusedEditor
|
||||
if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea")
|
||||
|
||||
prompt.app.mockInput.pressEnter()
|
||||
|
||||
expect(confirmed).toEqual(["draft"])
|
||||
expect(textarea.plainText).toBe("draft")
|
||||
} finally {
|
||||
await prompt.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test("dialog prompt submit can be rebound separately from input submit", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const confirmed: string[] = []
|
||||
const prompt = await mountPrompt({
|
||||
root: tmp.path,
|
||||
keybinds: {
|
||||
input_submit: "return",
|
||||
"dialog.prompt.submit": "ctrl+y",
|
||||
},
|
||||
onConfirm: (value) => confirmed.push(value),
|
||||
})
|
||||
|
||||
try {
|
||||
await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
|
||||
const textarea = prompt.app.renderer.currentFocusedEditor
|
||||
if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea")
|
||||
|
||||
prompt.app.mockInput.pressEnter()
|
||||
expect(confirmed).toEqual([])
|
||||
expect(textarea.plainText).toBe("draft")
|
||||
|
||||
prompt.app.mockInput.pressKey("y", { ctrl: true })
|
||||
|
||||
expect(confirmed).toEqual(["draft"])
|
||||
} finally {
|
||||
await prompt.cleanup()
|
||||
}
|
||||
})
|
||||
200
packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx
Normal file
200
packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { JSX } from "solid-js"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { KVProvider } from "../../../src/context/kv"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { TuiConfigProvider } from "../../../src/config"
|
||||
import { DiffViewerFileTree } from "../../../src/feature-plugins/system/diff-viewer-file-tree"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import {
|
||||
allExpandedFileTreeDirectories,
|
||||
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 app = await testRender(
|
||||
() =>
|
||||
withTheme(() => (
|
||||
<DiffViewerFileTree
|
||||
width={32}
|
||||
files={[
|
||||
{ file: "z-file.ts" },
|
||||
{ file: "b/file.ts" },
|
||||
{ file: "a/zeta.ts" },
|
||||
{ file: "b/alpha.ts" },
|
||||
{ file: "a/alpha.ts" },
|
||||
]}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
theme={theme}
|
||||
focused={true}
|
||||
/>
|
||||
)),
|
||||
{ width: 40, height: 20 },
|
||||
)
|
||||
|
||||
try {
|
||||
await renderOnceSettled(app)
|
||||
const lines = visibleLines(app.captureCharFrame())
|
||||
|
||||
expect(lines).toEqual([
|
||||
"▾ a",
|
||||
"│ ├─ alpha.ts ?",
|
||||
"│ └─ zeta.ts ?",
|
||||
"├─ ▾ b",
|
||||
"│ ├─ alpha.ts ?",
|
||||
"│ └─ file.ts ?",
|
||||
])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps loading and error quiet while rendering an empty settled state", async () => {
|
||||
const loading = await renderFrame(() => (
|
||||
<DiffViewerFileTree width={32} files={[]} loading={true} error={undefined} theme={theme} />
|
||||
))
|
||||
const failed = await renderFrame(() => (
|
||||
<DiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} theme={theme} />
|
||||
))
|
||||
const empty = await renderFrame(() => (
|
||||
<DiffViewerFileTree width={32} files={[]} loading={false} error={undefined} theme={theme} />
|
||||
))
|
||||
|
||||
expect(loading).not.toContain("Loading diff...")
|
||||
expect(loading).not.toContain("No files")
|
||||
expect(failed).not.toContain("Failed to load diff")
|
||||
expect(failed).not.toContain("No files")
|
||||
expect(empty).toContain("No files")
|
||||
})
|
||||
|
||||
test("does not render text markers for highlighted rows", async () => {
|
||||
const files = [{ file: "src/config/tui.ts" }, { file: "README.md" }]
|
||||
const src = buildFileTree(files).nodes.find((node) => node.kind === "directory" && node.name === "src")!
|
||||
|
||||
const focused = visibleLines(
|
||||
await renderFrame(() => (
|
||||
<DiffViewerFileTree
|
||||
width={32}
|
||||
files={files}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
theme={theme}
|
||||
focused
|
||||
highlightedNode={src.id}
|
||||
/>
|
||||
)),
|
||||
)
|
||||
const unfocused = visibleLines(
|
||||
await renderFrame(() => (
|
||||
<DiffViewerFileTree width={32} files={files} loading={false} error={undefined} theme={theme} />
|
||||
)),
|
||||
)
|
||||
|
||||
expect(focused).toContain("▾ src/config")
|
||||
expect(unfocused).toContain("▾ src/config")
|
||||
expect(focused.some((line) => line.includes("*"))).toBe(false)
|
||||
expect(unfocused.some((line) => line.includes("*"))).toBe(false)
|
||||
})
|
||||
|
||||
test("renders collapsed and expanded directory rows", async () => {
|
||||
const files = [{ file: "src/config/tui.ts" }, { file: "README.md" }]
|
||||
const tree = buildFileTree(files)
|
||||
const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
|
||||
const collapsed = allExpandedFileTreeDirectories(tree)
|
||||
collapsed.delete(src.id)
|
||||
|
||||
expect(
|
||||
visibleLines(
|
||||
await renderFrame(() => (
|
||||
<DiffViewerFileTree
|
||||
width={32}
|
||||
files={files}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
theme={theme}
|
||||
expandedNodes={collapsed}
|
||||
/>
|
||||
)),
|
||||
),
|
||||
).toEqual(["▸ src/config"])
|
||||
|
||||
expect(
|
||||
visibleLines(
|
||||
await renderFrame(() => (
|
||||
<DiffViewerFileTree
|
||||
files={files}
|
||||
width={32}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
theme={theme}
|
||||
expandedNodes={allExpandedFileTreeDirectories(tree)}
|
||||
/>
|
||||
)),
|
||||
),
|
||||
).toEqual(["▾ src/config", "│ └─ tui.ts ?"])
|
||||
})
|
||||
})
|
||||
|
||||
async function renderFrame(component: () => JSX.Element) {
|
||||
const app = await testRender(() => withTheme(component), { width: 40, height: 10 })
|
||||
try {
|
||||
await renderOnceSettled(app)
|
||||
return await captureSettledFrame(app)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
async function renderOnceSettled(app: Awaited<ReturnType<typeof testRender>>) {
|
||||
await app.renderOnce()
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
await app.renderOnce()
|
||||
}
|
||||
|
||||
async function captureSettledFrame(app: Awaited<ReturnType<typeof testRender>>) {
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const frame = app.captureCharFrame()
|
||||
if (frame.trim().length > 0) return frame
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
await app.renderOnce()
|
||||
}
|
||||
return app.captureCharFrame()
|
||||
}
|
||||
|
||||
function withTheme(component: () => JSX.Element) {
|
||||
return (
|
||||
<TestTuiContexts>
|
||||
<TuiConfigProvider config={createTuiResolvedConfig()}>
|
||||
<KVProvider>
|
||||
<ThemeProvider mode="dark">{component()}</ThemeProvider>
|
||||
</KVProvider>
|
||||
</TuiConfigProvider>
|
||||
</TestTuiContexts>
|
||||
)
|
||||
}
|
||||
|
||||
function visibleLines(frame: string) {
|
||||
return frame
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.map((line) => line.replace(/^ ?│ ?/, "").replace(/[ │]*$/, ""))
|
||||
.map((line) => (line.startsWith(" ") ? line.slice(1) : line))
|
||||
.filter((line) => line.length > 0 && !/^┌|^└|^─+$/.test(line))
|
||||
}
|
||||
225
packages/tui/test/cli/tui/diff-viewer.test.tsx
Normal file
225
packages/tui/test/cli/tui/diff-viewer.test.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import type { Renderable, ScrollBoxRenderable } from "@opentui/core"
|
||||
import { testRender, useRenderer } from "@opentui/solid"
|
||||
import type { TuiPluginApi, TuiPluginMeta, TuiRouteCurrent, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import { KVProvider } from "../../../src/context/kv"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { TuiConfigProvider } from "../../../src/config"
|
||||
import { TuiKeybind } from "../../../src/config/keybind"
|
||||
import { OpencodeKeymapProvider } from "../../../src/keymap"
|
||||
import diffViewerPlugin from "../../../src/feature-plugins/system/diff-viewer"
|
||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
|
||||
test("closing the diff viewer returns to the route it opened from", async () => {
|
||||
const viewer = await renderDiffViewer([])
|
||||
try {
|
||||
expect(viewer.current()).toEqual({
|
||||
name: "diff",
|
||||
params: { mode: "git", sessionID: "session-1", returnRoute: startRoute },
|
||||
})
|
||||
expect(viewer.vcsDiffInput()).toEqual({ directory: "/repo/session", mode: "git", context: 12 })
|
||||
|
||||
expect(viewer.commands.has("diff.close")).toBe(true)
|
||||
viewer.commands.get("diff.close")!.run?.({} as never)
|
||||
expect(viewer.current()).toEqual(startRoute)
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("brackets navigate diff hunks", async () => {
|
||||
const viewer = await renderDiffViewer(
|
||||
[
|
||||
{
|
||||
file: "src/file.ts",
|
||||
additions: 3,
|
||||
deletions: 3,
|
||||
status: "modified",
|
||||
patch: `--- a/src/file.ts
|
||||
+++ b/src/file.ts
|
||||
@@ -1,3 +1,3 @@
|
||||
const first = true
|
||||
-const oldFirst = true
|
||||
+const newFirst = true
|
||||
const afterFirst = true
|
||||
@@ -20,3 +20,3 @@
|
||||
const second = true
|
||||
-const oldSecond = true
|
||||
+const newSecond = true
|
||||
const afterSecond = true
|
||||
@@ -40,3 +40,3 @@
|
||||
const third = true
|
||||
-const oldThird = true
|
||||
+const newThird = true
|
||||
const afterThird = true`,
|
||||
},
|
||||
],
|
||||
12,
|
||||
)
|
||||
try {
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("const first"))
|
||||
await viewer.app.waitFor(() => Boolean(findRenderable(viewer.app.renderer.root, "diff-viewer-patches")))
|
||||
await viewer.app.flush()
|
||||
const scroll = findRenderable(viewer.app.renderer.root, "diff-viewer-patches") as ScrollBoxRenderable
|
||||
const initial = scroll.scrollTop
|
||||
|
||||
expect(TuiKeybind.defaultValue("diff_next_hunk")).toBe("]")
|
||||
expect(TuiKeybind.defaultValue("diff_previous_hunk")).toBe("[")
|
||||
|
||||
viewer.commands.get("diff.next_hunk")!.run?.({} as never)
|
||||
await viewer.app.renderOnce()
|
||||
const first = scroll.scrollTop
|
||||
expect(first).toBeGreaterThan(initial)
|
||||
|
||||
viewer.commands.get("diff.next_hunk")!.run?.({} as never)
|
||||
await viewer.app.renderOnce()
|
||||
const second = scroll.scrollTop
|
||||
expect(second).toBeGreaterThan(first)
|
||||
|
||||
viewer.commands.get("diff.previous_hunk")!.run?.({} as never)
|
||||
await viewer.app.renderOnce()
|
||||
expect(scroll.scrollTop).toBe(first)
|
||||
|
||||
viewer.commands.get("diff.next_hunk")!.run?.({} as never)
|
||||
await viewer.app.renderOnce()
|
||||
expect(scroll.scrollTop).toBe(second)
|
||||
|
||||
scroll.scrollTo(initial)
|
||||
viewer.commands.get("diff.next_hunk")!.run?.({} as never)
|
||||
await viewer.app.renderOnce()
|
||||
expect(scroll.scrollTop).toBe(first)
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
async function renderDiffViewer(vcsDiff: unknown[], height = 20) {
|
||||
const commands = new Map<
|
||||
string,
|
||||
NonNullable<Parameters<TuiPluginApi["keymap"]["registerLayer"]>[0]["commands"]>[number]
|
||||
>()
|
||||
let current = startRoute
|
||||
let renderDiff: TuiRouteDefinition["render"] | undefined
|
||||
let vcsDiffInput: unknown
|
||||
const config = createTuiResolvedConfig()
|
||||
function Harness() {
|
||||
const renderer = useRenderer()
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
const registerLayer = keymap.registerLayer.bind(keymap)
|
||||
keymap.registerLayer = (layer) => {
|
||||
layer.commands?.forEach((command) => commands.set(command.name, command))
|
||||
return registerLayer(layer)
|
||||
}
|
||||
const base = createTuiPluginApi({
|
||||
keymap,
|
||||
client: {
|
||||
vcs: {
|
||||
diff: async (input: unknown) => {
|
||||
vcsDiffInput = input
|
||||
return { data: vcsDiff }
|
||||
},
|
||||
},
|
||||
session: { diff: async () => ({ data: [] }) },
|
||||
} as unknown as TuiPluginApi["client"],
|
||||
state: {
|
||||
session: {
|
||||
get: () => session,
|
||||
},
|
||||
},
|
||||
})
|
||||
const api = {
|
||||
...base,
|
||||
route: {
|
||||
register(routes) {
|
||||
renderDiff = routes.find((route) => route.name === "diff")?.render
|
||||
return () => {}
|
||||
},
|
||||
navigate(name, params) {
|
||||
current = params ? { name, params } : { name }
|
||||
},
|
||||
get current() {
|
||||
return current
|
||||
},
|
||||
},
|
||||
} satisfies TuiPluginApi
|
||||
|
||||
void diffViewerPlugin.tui(api, undefined, pluginMeta)
|
||||
commands.get("diff.open")?.run?.({} as never)
|
||||
|
||||
return (
|
||||
<TestTuiContexts>
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<TuiConfigProvider config={config}>
|
||||
<KVProvider>
|
||||
<ThemeProvider mode="dark">
|
||||
{renderDiff?.({ params: "params" in current ? current.params : undefined })}
|
||||
</ThemeProvider>
|
||||
</KVProvider>
|
||||
</TuiConfigProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
</TestTuiContexts>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => <Harness />, { width: 80, height })
|
||||
await waitForCommand(app, commands, "diff.close")
|
||||
return {
|
||||
app,
|
||||
commands,
|
||||
current: () => current,
|
||||
vcsDiffInput: () => vcsDiffInput,
|
||||
}
|
||||
}
|
||||
|
||||
const startRoute: TuiRouteCurrent = { name: "session", params: { sessionID: "session-1" } }
|
||||
|
||||
function findRenderable(root: Renderable, id: string): Renderable | undefined {
|
||||
if (root.id === id) return root
|
||||
return root
|
||||
.getChildren()
|
||||
.map((child) => findRenderable(child, id))
|
||||
.find(Boolean)
|
||||
}
|
||||
|
||||
const session = {
|
||||
id: "session-1",
|
||||
slug: "session-1",
|
||||
projectID: "project-1",
|
||||
directory: "/repo/session",
|
||||
title: "Session",
|
||||
version: "1",
|
||||
time: {
|
||||
created: 0,
|
||||
updated: 0,
|
||||
},
|
||||
} satisfies Session
|
||||
|
||||
async function waitForCommand(
|
||||
app: Awaited<ReturnType<typeof testRender>>,
|
||||
commands: Map<string, unknown>,
|
||||
command: string,
|
||||
) {
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
await app.renderOnce()
|
||||
if (commands.has(command)) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
}
|
||||
}
|
||||
|
||||
const pluginMeta = {
|
||||
id: "diff-viewer",
|
||||
source: "internal",
|
||||
spec: "diff-viewer",
|
||||
target: "diff-viewer",
|
||||
first_time: 0,
|
||||
last_time: 0,
|
||||
time_changed: 0,
|
||||
load_count: 1,
|
||||
fingerprint: "test",
|
||||
state: "same",
|
||||
} satisfies TuiPluginMeta
|
||||
304
packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx
Normal file
304
packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { createSignal, For, Show } from "solid-js"
|
||||
import type { ScrollBoxRenderable } from "@opentui/core"
|
||||
import { testRender, type JSX } from "@opentui/solid"
|
||||
import {
|
||||
formatCompletedSubagentDetail,
|
||||
formatSubagentRetry,
|
||||
formatSubagentTitle,
|
||||
formatSubagentToolcalls,
|
||||
InlineToolRow,
|
||||
parseApplyPatchFiles,
|
||||
parseDiagnostics,
|
||||
parseQuestionAnswers,
|
||||
parseQuestions,
|
||||
parseTodos,
|
||||
toolDisplay,
|
||||
} from "../../../src/routes/session"
|
||||
|
||||
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined
|
||||
|
||||
afterEach(() => {
|
||||
testSetup?.renderer.destroy()
|
||||
testSetup = undefined
|
||||
})
|
||||
|
||||
type ToolFixture = { icon: string; label: string; error?: string }
|
||||
|
||||
const tools: readonly ToolFixture[] = [
|
||||
{
|
||||
icon: "✱",
|
||||
label:
|
||||
'Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.*dir|xdg|APPDATA" in packages/opencode/src (151 matches)',
|
||||
},
|
||||
{
|
||||
icon: "✱",
|
||||
label: 'Glob "**/*db*" in packages/opencode (6 matches)',
|
||||
},
|
||||
{
|
||||
icon: "→",
|
||||
label: "Read packages/opencode/src/storage/db.ts [offset=1, limit=130]",
|
||||
},
|
||||
{
|
||||
icon: "→",
|
||||
label: "Read packages/opencode/src/index.ts [offset=1, limit=100]",
|
||||
error: "No LSP server available for this file type.",
|
||||
},
|
||||
{
|
||||
icon: "✱",
|
||||
label:
|
||||
'Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.Path\\.data|data =" in packages/opencode/src (115 matches)',
|
||||
},
|
||||
] as const
|
||||
|
||||
function ShellOutput() {
|
||||
return (
|
||||
<box id="tool-block-shell" marginTop={1} paddingTop={1} paddingBottom={1} paddingLeft={2} gap={1}>
|
||||
<text paddingLeft={3}># List files</text>
|
||||
<box gap={1}>
|
||||
<text>$ ls</text>
|
||||
<text>file.ts</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function UserMessage() {
|
||||
return (
|
||||
<box id="message-user">
|
||||
<box paddingTop={1} paddingBottom={1} paddingLeft={2}>
|
||||
<text>Check whether the next tool remains separated.</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" }) {
|
||||
return (
|
||||
<box flexDirection="column" width={72}>
|
||||
<box flexDirection="column">
|
||||
{props.before === "shell" && <ShellOutput />}
|
||||
{props.before === "user" && <UserMessage />}
|
||||
<For each={tools}>
|
||||
{(item) => (
|
||||
<InlineToolRow
|
||||
icon={item.icon}
|
||||
complete={true}
|
||||
pending=""
|
||||
failed={Boolean(item.error)}
|
||||
error={item.error}
|
||||
errorExpanded={props.errorExpanded}
|
||||
separateAfter={(id) => id === "message-user"}
|
||||
>
|
||||
{item.label}
|
||||
</InlineToolRow>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function SubagentGroupFixture() {
|
||||
return (
|
||||
<box flexDirection="column" width={72}>
|
||||
<InlineToolRow id="tool-inline-before" icon="✱" complete={true} pending="">
|
||||
Grep "Task" (2 matches)
|
||||
</InlineToolRow>
|
||||
<InlineToolRow id="tool-inline-subagent-one" icon="⠙" complete={true} pending="" subagent={true}>
|
||||
Explore Task — Inspect active task spacing
|
||||
</InlineToolRow>
|
||||
<InlineToolRow id="tool-inline-subagent-two" icon="✓" complete={true} pending="" subagent={true}>
|
||||
{"General Task — Confirm completed task spacing\n↳ 1 toolcall · 501ms"}
|
||||
</InlineToolRow>
|
||||
<InlineToolRow id="tool-inline-after" icon="→" complete={true} pending="">
|
||||
Read src/cli/cmd/tui/routes/session/index.tsx
|
||||
</InlineToolRow>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadedReadBeforeSubagentFixture() {
|
||||
return (
|
||||
<box flexDirection="column" width={72}>
|
||||
<InlineToolRow id="tool-inline-read" icon="→" complete={true} pending="">
|
||||
Read src/cli/cmd/tui/routes/session/index.tsx
|
||||
</InlineToolRow>
|
||||
<box id="tool-inline-loaded-read-child" paddingLeft={3}>
|
||||
<text paddingLeft={3}>↳ Loaded src/cli/cmd/tui/routes/session/tools.tsx</text>
|
||||
</box>
|
||||
<InlineToolRow id="tool-inline-subagent-after-read" icon="✓" complete={true} pending="" subagent={true}>
|
||||
{"Explore Task — Inspect active task spacing\n↳ 1 toolcall · 501ms"}
|
||||
</InlineToolRow>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function StickyScrollFixture(props: { separated: boolean; scroll: (scroll: ScrollBoxRenderable) => void }) {
|
||||
return (
|
||||
<scrollbox ref={props.scroll} stickyScroll={true} stickyStart="bottom" height={3} width={72}>
|
||||
<box height={1}>
|
||||
<text>First row</text>
|
||||
</box>
|
||||
<box height={1}>
|
||||
<text>Second row</text>
|
||||
</box>
|
||||
<Show when={props.separated}>
|
||||
<box id="text-before-tool">
|
||||
<text>Assistant text</text>
|
||||
</box>
|
||||
</Show>
|
||||
<InlineToolRow icon="→" complete={true} pending="">
|
||||
Read src/cli/cmd/tui/routes/session/index.tsx
|
||||
</InlineToolRow>
|
||||
</scrollbox>
|
||||
)
|
||||
}
|
||||
|
||||
function FailedPendingToolFixture() {
|
||||
return (
|
||||
<InlineToolRow icon="%" complete={false} pending="Preparing patch..." failed={true} failure="Patch failed">
|
||||
Patch
|
||||
</InlineToolRow>
|
||||
)
|
||||
}
|
||||
|
||||
function FailedCompleteToolFixture() {
|
||||
return (
|
||||
<InlineToolRow icon="→" complete={true} pending="Reading file..." failed={true} failure="Read failed">
|
||||
Read src/index.ts
|
||||
</InlineToolRow>
|
||||
)
|
||||
}
|
||||
|
||||
async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) {
|
||||
testSetup = await testRender(component, options)
|
||||
await testSetup.renderOnce()
|
||||
|
||||
return testSetup
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.join("\n")
|
||||
.trimEnd()
|
||||
}
|
||||
|
||||
describe("TUI inline tool wrapping", () => {
|
||||
test("falls back for unknown tool names", () => {
|
||||
expect(toolDisplay("bash")).toBe("bash")
|
||||
expect(toolDisplay("plugin_tool")).toBe("generic")
|
||||
})
|
||||
|
||||
test("replaces pending copy when a tool fails before completion", async () => {
|
||||
const frame = await renderFrame(() => <FailedPendingToolFixture />, { width: 72, height: 3 })
|
||||
expect(frame).toContain("Patch failed")
|
||||
expect(frame).not.toContain("Preparing patch")
|
||||
})
|
||||
|
||||
test("preserves useful completed copy when a tool fails", async () => {
|
||||
const frame = await renderFrame(() => <FailedCompleteToolFixture />, { width: 72, height: 3 })
|
||||
expect(frame).toContain("Read src/index.ts")
|
||||
expect(frame).not.toContain("Read failed")
|
||||
})
|
||||
|
||||
test("filters malformed nested tool wire data", () => {
|
||||
expect(
|
||||
parseApplyPatchFiles([
|
||||
null,
|
||||
{ type: "add" },
|
||||
{ type: "add", relativePath: "a.ts", filePath: "a.ts", patch: "diff", deletions: 0 },
|
||||
]),
|
||||
).toEqual([
|
||||
{ type: "add", relativePath: "a.ts", filePath: "a.ts", patch: "diff", deletions: 0, movePath: undefined },
|
||||
])
|
||||
expect(parseTodos([null, { status: "pending" }, { status: "pending", content: "Safe" }])).toEqual([
|
||||
{ status: "pending", content: "Safe" },
|
||||
])
|
||||
expect(parseQuestions([{}, { question: 1 }, { question: "Continue?" }])).toEqual([{ question: "Continue?" }])
|
||||
expect(parseQuestionAnswers([null, ["yes", 1], "no"])).toEqual([[], ["yes"], []])
|
||||
expect(parseQuestionAnswers({})).toBeUndefined()
|
||||
})
|
||||
|
||||
test("ignores diagnostics with malformed nested ranges", () => {
|
||||
expect(
|
||||
parseDiagnostics(
|
||||
{
|
||||
"a.ts": [
|
||||
{ severity: 1, message: "missing range" },
|
||||
{ severity: 1, message: "bad line", range: { start: { line: "0", character: 1 } } },
|
||||
{ severity: 1, message: "valid", range: { start: { line: 2, character: 3 } } },
|
||||
],
|
||||
},
|
||||
"a.ts",
|
||||
),
|
||||
).toEqual([{ message: "valid", range: { start: { line: 2, character: 3 } } }])
|
||||
})
|
||||
|
||||
test("formats completed subagent toolcall details", () => {
|
||||
expect(formatCompletedSubagentDetail(0, "501ms")).toBe("501ms")
|
||||
expect(formatCompletedSubagentDetail(1, "501ms")).toBe("1 toolcall · 501ms")
|
||||
expect(formatCompletedSubagentDetail(2, "501ms")).toBe("2 toolcalls · 501ms")
|
||||
expect(formatSubagentToolcalls(0)).toBe("0 toolcalls")
|
||||
})
|
||||
|
||||
test("keeps background state attached to the subagent identity", () => {
|
||||
expect(formatSubagentTitle("Explore", "Inspect renderer", false)).toBe("Explore Task — Inspect renderer")
|
||||
expect(formatSubagentTitle("Explore", "Inspect renderer", true)).toBe(
|
||||
"Explore Task (background) — Inspect renderer",
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps retry status ahead of wrapping messages", () => {
|
||||
expect(formatSubagentRetry(2, "Rate limited by provider")).toBe("Retrying (attempt 2) · Rate limited by provider")
|
||||
})
|
||||
|
||||
test("snapshots consecutive grep, glob, and read rows at a narrow width", async () => {
|
||||
expect(await renderFrame(() => <Fixture />, { width: 72, height: 12 })).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test("snapshots expanded tool errors under the tool text", async () => {
|
||||
expect(await renderFrame(() => <Fixture errorExpanded />, { width: 72, height: 12 })).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test("keeps separation after a shell output block", async () => {
|
||||
expect(await renderFrame(() => <Fixture before="shell" />, { width: 72, height: 16 })).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test("keeps separation after a padded user message", async () => {
|
||||
expect(await renderFrame(() => <Fixture before="user" />, { width: 72, height: 14 })).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test("separates a contiguous subagent group from inline tools", async () => {
|
||||
expect(await renderFrame(() => <SubagentGroupFixture />, { width: 72, height: 10 })).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test("separates a subagent group after an expanded read", async () => {
|
||||
expect(await renderFrame(() => <LoadedReadBeforeSubagentFixture />, { width: 72, height: 8 })).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test("updates sticky-bottom geometry when a text separator mounts and unmounts", async () => {
|
||||
const [separated, setSeparated] = createSignal(false)
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
testSetup = await testRender(
|
||||
() => <StickyScrollFixture separated={separated()} scroll={(value) => (scroll = value)} />,
|
||||
{
|
||||
width: 72,
|
||||
height: 3,
|
||||
},
|
||||
)
|
||||
|
||||
await testSetup.renderOnce()
|
||||
expect(scroll?.scrollHeight).toBe(3)
|
||||
expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
|
||||
|
||||
setSeparated(true)
|
||||
await testSetup.renderOnce()
|
||||
expect(scroll?.scrollHeight).toBe(5)
|
||||
expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
|
||||
|
||||
setSeparated(false)
|
||||
await testSetup.renderOnce()
|
||||
expect(scroll?.scrollHeight).toBe(3)
|
||||
expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
|
||||
})
|
||||
})
|
||||
98
packages/tui/test/cli/tui/prompt-submit-race.test.ts
Normal file
98
packages/tui/test/cli/tui/prompt-submit-race.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
// Regression test for the prompt submit race in
|
||||
// packages/tui/src/component/prompt/index.tsx (`submit`).
|
||||
//
|
||||
// Before the fix, two concurrent `submit()` calls (e.g. a double-pressed
|
||||
// Enter, or the input's native onSubmit racing another dispatch) each
|
||||
// passed the `if (!store.prompt.input) return false` guard, each
|
||||
// `await sdk.client.session.create(...)`, and each only captured
|
||||
// `inputText = store.prompt.input` AFTER that await. The first invocation
|
||||
// finished, sent the prompt, and cleared the store; the second invocation,
|
||||
// now past its await, read the cleared store and sent an empty prompt to a
|
||||
// second freshly-created session - leaving an orphaned session with the
|
||||
// user's actual text and a phantom session visible to the user containing
|
||||
// only an assistant reply.
|
||||
//
|
||||
// `submitMirror` below has the exact shape of the production `submit()`
|
||||
// after the fix: an in-flight `submitting` guard wraps the original body.
|
||||
// Two concurrent invocations must result in exactly one submission carrying
|
||||
// the user's text, with no empty-text submission.
|
||||
|
||||
type Store = { input: string }
|
||||
|
||||
type SubmitResult = { sessionID: string; text: string }
|
||||
|
||||
type Harness = {
|
||||
store: Store
|
||||
submissions: SubmitResult[]
|
||||
createSession(): Promise<string>
|
||||
sendPrompt(sessionID: string, text: string): Promise<void>
|
||||
}
|
||||
|
||||
function createHarness(opts: { sessionCreateDelayMs: number }): Harness {
|
||||
let sessionCounter = 0
|
||||
const submissions: SubmitResult[] = []
|
||||
|
||||
return {
|
||||
store: { input: "" },
|
||||
submissions,
|
||||
async createSession() {
|
||||
sessionCounter += 1
|
||||
const id = `ses_${sessionCounter}`
|
||||
await Bun.sleep(opts.sessionCreateDelayMs)
|
||||
return id
|
||||
},
|
||||
async sendPrompt(sessionID, text) {
|
||||
submissions.push({ sessionID, text })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createSubmit() {
|
||||
let submitting = false
|
||||
return async function submit(h: Harness) {
|
||||
if (submitting) return false
|
||||
submitting = true
|
||||
try {
|
||||
if (!h.store.input) return false
|
||||
const sessionID = await h.createSession()
|
||||
const inputText = h.store.input
|
||||
await h.sendPrompt(sessionID, inputText)
|
||||
h.store.input = ""
|
||||
return true
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("Prompt.submit race", () => {
|
||||
test("concurrent submits must not lose the user's text", async () => {
|
||||
const submit = createSubmit()
|
||||
const h = createHarness({ sessionCreateDelayMs: 5 })
|
||||
h.store.input = "Hello there."
|
||||
|
||||
// Two invocations back-to-back, mimicking a double-Enter.
|
||||
await Promise.all([submit(h), submit(h)])
|
||||
|
||||
// Every submission that did make it through must carry the actual user
|
||||
// text, and no submission may have an empty text payload.
|
||||
expect(h.submissions.every((s) => s.text === "Hello there.")).toBe(true)
|
||||
expect(h.submissions.some((s) => s.text === "")).toBe(false)
|
||||
})
|
||||
|
||||
test("a sequential second submit after clear is a no-op, not a phantom session", async () => {
|
||||
const submit = createSubmit()
|
||||
const h = createHarness({ sessionCreateDelayMs: 1 })
|
||||
h.store.input = "Hello there."
|
||||
|
||||
await submit(h)
|
||||
// After the first submission completes, the store is cleared; a second
|
||||
// Enter on an empty input must not create a phantom session.
|
||||
await submit(h)
|
||||
|
||||
expect(h.submissions).toHaveLength(1)
|
||||
expect(h.submissions[0].text).toBe("Hello there.")
|
||||
})
|
||||
})
|
||||
36
packages/tui/test/cli/tui/thinking.test.ts
Normal file
36
packages/tui/test/cli/tui/thinking.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { reasoningSummary } from "../../../src/context/thinking"
|
||||
|
||||
describe("reasoningSummary", () => {
|
||||
test("extracts a leading summary title and leaves markdown body", () => {
|
||||
expect(reasoningSummary("**Continuing Quality Review**\n\nDetails.\n\n**Next section**\n\nMore.")).toEqual({
|
||||
title: "Continuing Quality Review",
|
||||
body: "Details.\n\n**Next section**\n\nMore.",
|
||||
})
|
||||
})
|
||||
|
||||
test("extracts a completed title before its streamed body arrives", () => {
|
||||
expect(reasoningSummary("**Continuing Quality Review**")).toEqual({
|
||||
title: "Continuing Quality Review",
|
||||
body: "",
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves markdown-significant indentation in the extracted body", () => {
|
||||
expect(reasoningSummary("**Continuing Quality Review**\n\n const value = true\n")).toEqual({
|
||||
title: "Continuing Quality Review",
|
||||
body: " const value = true",
|
||||
})
|
||||
})
|
||||
|
||||
test("does not consume ordinary leading bold content", () => {
|
||||
expect(reasoningSummary("**Important:** keep this in the body.")).toEqual({
|
||||
title: null,
|
||||
body: "**Important:** keep this in the body.",
|
||||
})
|
||||
})
|
||||
|
||||
test("leaves content without a leading title in its body", () => {
|
||||
expect(reasoningSummary("Details only.")).toEqual({ title: null, body: "Details only." })
|
||||
})
|
||||
})
|
||||
148
packages/tui/test/cli/tui/use-event.test.tsx
Normal file
148
packages/tui/test/cli/tui/use-event.test.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import { onMount } from "solid-js"
|
||||
import { ProjectProvider, useProject } from "../../../src/context/project"
|
||||
import { SDKProvider } from "../../../src/context/sdk"
|
||||
import { useEvent } from "../../../src/context/event"
|
||||
import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
|
||||
const projectID = "proj_test"
|
||||
|
||||
async function wait(fn: () => boolean, timeout = 2000) {
|
||||
const start = Date.now()
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
}
|
||||
|
||||
function event(payload: Event, input: { directory: string; project?: string; workspace?: string }): GlobalEvent {
|
||||
return {
|
||||
directory: input.directory,
|
||||
project: input.project,
|
||||
workspace: input.workspace,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
function vcs(branch: string): Event {
|
||||
return {
|
||||
id: `evt_vcs_${branch}`,
|
||||
type: "vcs.branch.updated",
|
||||
properties: {
|
||||
branch,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function update(version: string): Event {
|
||||
return {
|
||||
id: `evt_update_${version}`,
|
||||
type: "installation.update-available",
|
||||
properties: {
|
||||
version,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function mount() {
|
||||
const events = createEventSource()
|
||||
const calls = createFetch()
|
||||
const seen: Event[] = []
|
||||
const workspaces: Array<string | undefined> = []
|
||||
let project!: ReturnType<typeof useProject>
|
||||
let done!: () => void
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
done = resolve
|
||||
})
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<Probe
|
||||
onReady={async (ctx) => {
|
||||
project = ctx.project
|
||||
await project.sync()
|
||||
done()
|
||||
}}
|
||||
seen={seen}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
await ready
|
||||
return { app, emit: events.emit, project, seen, workspaces }
|
||||
}
|
||||
|
||||
function Probe(props: {
|
||||
seen: Event[]
|
||||
workspaces: Array<string | undefined>
|
||||
onReady: (ctx: { project: ReturnType<typeof useProject> }) => void
|
||||
}) {
|
||||
const project = useProject()
|
||||
const event = useEvent()
|
||||
|
||||
onMount(() => {
|
||||
event.subscribe((evt, { workspace }) => {
|
||||
props.seen.push(evt)
|
||||
props.workspaces.push(workspace)
|
||||
})
|
||||
props.onReady({ project })
|
||||
})
|
||||
|
||||
return <box />
|
||||
}
|
||||
|
||||
describe("useEvent", () => {
|
||||
test("delivers events for the current project", async () => {
|
||||
const { app, emit, seen, workspaces } = await mount()
|
||||
|
||||
try {
|
||||
emit(event(vcs("main"), { directory: "/tmp/other", project: projectID, workspace: "ws_a" }))
|
||||
|
||||
await wait(() => seen.length === 1)
|
||||
|
||||
expect(seen).toEqual([vcs("main")])
|
||||
expect(workspaces).toEqual(["ws_a"])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("delivers current project events regardless of active workspace", async () => {
|
||||
const { app, emit, project, seen } = await mount()
|
||||
|
||||
try {
|
||||
project.workspace.set("ws_a")
|
||||
emit(event(vcs("ws"), { directory: "/tmp/other", project: projectID, workspace: "ws_b" }))
|
||||
|
||||
await wait(() => seen.length === 1)
|
||||
|
||||
expect(seen).toEqual([vcs("ws")])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("delivers truly global events even when a workspace is active", async () => {
|
||||
const { app, emit, project, seen } = await mount()
|
||||
|
||||
try {
|
||||
project.workspace.set("ws_a")
|
||||
emit(event(update("1.2.3"), { directory: "global" }))
|
||||
|
||||
await wait(() => seen.length === 1)
|
||||
|
||||
expect(seen).toEqual([update("1.2.3")])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
19
packages/tui/test/clipboard.test.ts
Normal file
19
packages/tui/test/clipboard.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { copyCommand } from "../src/clipboard"
|
||||
|
||||
test("prefers Wayland clipboard when available", () => {
|
||||
expect(copyCommand("linux", true, (name) => name === "wl-copy")).toEqual(["wl-copy"])
|
||||
})
|
||||
|
||||
test("uses osascript on macOS", () => {
|
||||
expect(copyCommand("darwin", false, (name) => name === "osascript")).toEqual(["osascript"])
|
||||
})
|
||||
|
||||
test("falls back through X11 clipboard commands", () => {
|
||||
expect(copyCommand("linux", true, (name) => name === "xclip")).toEqual(["xclip", "-selection", "clipboard"])
|
||||
expect(copyCommand("linux", false, (name) => name === "xsel")).toEqual(["xsel", "--clipboard", "--input"])
|
||||
})
|
||||
|
||||
test("returns undefined when native clipboard is unavailable", () => {
|
||||
expect(copyCommand("linux", false, () => false)).toBeUndefined()
|
||||
})
|
||||
121
packages/tui/test/config.test.tsx
Normal file
121
packages/tui/test/config.test.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import {
|
||||
AttentionSoundName,
|
||||
Info,
|
||||
LeaderTimeoutDefault,
|
||||
PluginSpec,
|
||||
resolve,
|
||||
TuiConfigProvider,
|
||||
type Info as TuiConfigInfo,
|
||||
useTuiConfig,
|
||||
} from "../src/config"
|
||||
|
||||
const decodeInfo = Schema.decodeUnknownSync(Info)
|
||||
const decodePlugin = Schema.decodeUnknownSync(PluginSpec)
|
||||
|
||||
test("defines package-owned plugin specs and attention sound names", () => {
|
||||
expect(decodePlugin("example-plugin")).toBe("example-plugin")
|
||||
expect(decodePlugin(["example-plugin", { enabled: true }])).toEqual(["example-plugin", { enabled: true }])
|
||||
expect(() => decodePlugin(["example-plugin"])).toThrow()
|
||||
expect(AttentionSoundName.literals).toEqual(["default", "question", "permission", "error", "done", "subagent_done"])
|
||||
})
|
||||
|
||||
test("validates config constraints", () => {
|
||||
expect(
|
||||
decodeInfo({
|
||||
leader_timeout: 250,
|
||||
attention: { volume: 1, sounds: { done: "done.wav" } },
|
||||
prompt: { max_height: 10, max_width: "auto" },
|
||||
scroll_speed: 0.001,
|
||||
diff_style: "stacked",
|
||||
plugin: ["example-plugin"],
|
||||
}),
|
||||
).toMatchObject({ leader_timeout: 250, attention: { volume: 1 }, diff_style: "stacked" })
|
||||
expect(() => decodeInfo({ leader_timeout: 0 })).toThrow()
|
||||
expect(() => decodeInfo({ attention: { volume: 1.1 } })).toThrow()
|
||||
expect(() => decodeInfo({ prompt: { max_width: 0 } })).toThrow()
|
||||
expect(() => decodeInfo({ scroll_speed: 0 })).toThrow()
|
||||
expect(decodeInfo({ attention: { sounds: { unknown: "sound.wav" } } })).toEqual({ attention: { sounds: {} } })
|
||||
})
|
||||
|
||||
test("resolves host-neutral defaults", () => {
|
||||
const config = resolve({}, { terminalSuspend: true })
|
||||
|
||||
expect(config.attention).toEqual({
|
||||
enabled: false,
|
||||
notifications: true,
|
||||
sound: true,
|
||||
volume: 0.4,
|
||||
sound_pack: "opencode.default",
|
||||
sounds: {},
|
||||
})
|
||||
expect(config.leader_timeout).toBe(LeaderTimeoutDefault)
|
||||
expect(config.mouse).toBe(true)
|
||||
expect(config.keybinds.has("terminal.suspend")).toBe(true)
|
||||
expect(config.keybinds.has("session.list")).toBe(true)
|
||||
})
|
||||
|
||||
test("resolves overrides without mutating input", () => {
|
||||
const input: TuiConfigInfo = {
|
||||
theme: "custom",
|
||||
mouse: false,
|
||||
leader_timeout: 750,
|
||||
attention: {
|
||||
enabled: true,
|
||||
notifications: false,
|
||||
sound: false,
|
||||
volume: 0.8,
|
||||
sound_pack: "custom.pack",
|
||||
sounds: { question: "/sounds/question.wav" },
|
||||
},
|
||||
keybinds: { session_list: "ctrl+l" },
|
||||
}
|
||||
const config = resolve(input, { terminalSuspend: true })
|
||||
|
||||
expect(config).toMatchObject({ theme: "custom", mouse: false, leader_timeout: 750, attention: input.attention })
|
||||
expect(config.keybinds.get("session.list")).toHaveLength(1)
|
||||
expect(input.keybinds).toEqual({ session_list: "ctrl+l" })
|
||||
})
|
||||
|
||||
test("disables suspend and assigns ctrl+z to undo when unsupported", () => {
|
||||
const config = resolve({}, { terminalSuspend: false })
|
||||
|
||||
expect(config.keybinds.has("terminal.suspend")).toBe(false)
|
||||
expect(config.keybinds.get("input.undo")).toMatchObject([{ key: "ctrl+z,ctrl+-,super+z" }])
|
||||
})
|
||||
|
||||
test("preserves an explicit undo binding when suspend is unsupported", () => {
|
||||
const config = resolve({ keybinds: { input_undo: "ctrl+u", terminal_suspend: "ctrl+s" } }, { terminalSuspend: false })
|
||||
|
||||
expect(config.keybinds.has("terminal.suspend")).toBe(false)
|
||||
expect(config.keybinds.get("input.undo")).toHaveLength(1)
|
||||
expect(config.keybinds.get("input.undo")).toMatchObject([{ key: "ctrl+u" }])
|
||||
})
|
||||
|
||||
test("provides resolved config through Solid context", async () => {
|
||||
const config = resolve({ theme: "custom" }, { terminalSuspend: true })
|
||||
|
||||
function Consumer() {
|
||||
const value = useTuiConfig()
|
||||
return <text>{`${value.theme} ${value.mouse} ${value.leader_timeout}`}</text>
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TuiConfigProvider config={config}>
|
||||
<Consumer />
|
||||
</TuiConfigProvider>
|
||||
))
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain(`custom true ${LeaderTimeoutDefault}`)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("requires the config provider", () => {
|
||||
expect(() => useTuiConfig()).toThrow("TuiConfigProvider is missing")
|
||||
})
|
||||
22
packages/tui/test/context/local.test.ts
Normal file
22
packages/tui/test/context/local.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { parseModel, recentModels } from "../../src/context/local"
|
||||
|
||||
test("parses model IDs containing slashes", () => {
|
||||
expect(parseModel("provider/family/model")).toEqual({
|
||||
providerID: "provider",
|
||||
modelID: "family/model",
|
||||
})
|
||||
})
|
||||
|
||||
test("moves a model to the front, deduplicates, and limits recents", () => {
|
||||
const recent = Array.from({ length: 12 }, (_, index) => ({
|
||||
providerID: "provider",
|
||||
modelID: `model-${index}`,
|
||||
}))
|
||||
|
||||
expect(recentModels({ providerID: "provider", modelID: "model-5" }, recent)).toEqual([
|
||||
{ providerID: "provider", modelID: "model-5" },
|
||||
...recent.slice(0, 5),
|
||||
...recent.slice(6, 10),
|
||||
])
|
||||
})
|
||||
32
packages/tui/test/editor.test.ts
Normal file
32
packages/tui/test/editor.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { normalizePromptContent, openEditor } from "../src/editor"
|
||||
|
||||
const editor = process.env.EDITOR
|
||||
const visual = process.env.VISUAL
|
||||
|
||||
afterEach(() => {
|
||||
process.env.EDITOR = editor
|
||||
process.env.VISUAL = visual
|
||||
})
|
||||
|
||||
test("rejects when the external editor cannot start", async () => {
|
||||
delete process.env.VISUAL
|
||||
process.env.EDITOR = "opencode-editor-that-does-not-exist"
|
||||
const renderer = {
|
||||
suspend() {},
|
||||
resume() {},
|
||||
requestRender() {},
|
||||
currentRenderBuffer: { clear() {} },
|
||||
}
|
||||
|
||||
await expect(openEditor({ value: "original", renderer: renderer as never })).rejects.toThrow()
|
||||
})
|
||||
|
||||
test("normalizes a single trailing editor newline for one-line prompts", () => {
|
||||
expect(normalizePromptContent("hello\n")).toBe("hello")
|
||||
expect(normalizePromptContent("hello\r\n")).toBe("hello")
|
||||
})
|
||||
|
||||
test("preserves multiline prompts that end with a newline", () => {
|
||||
expect(normalizePromptContent("hello\nworld\n")).toBe("hello\nworld\n")
|
||||
})
|
||||
@@ -0,0 +1,323 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
allExpandedFileTreeDirectories,
|
||||
buildFileTree,
|
||||
fileTreeFileSelection,
|
||||
flattenFileTree,
|
||||
moveFileTreeSelection,
|
||||
moveFileTreeSelectionToFirstChild,
|
||||
moveFileTreeSelectionToFile,
|
||||
moveFileTreeSelectionToParent,
|
||||
movePatchFileIndex,
|
||||
orderedPatchFileIndexes,
|
||||
setFileTreeDirectoryExpanded,
|
||||
showDiffViewerFileTree,
|
||||
singlePatchFileIndex,
|
||||
toggleFileTreeDirectory,
|
||||
} from "../../src/feature-plugins/system/diff-viewer-file-tree-utils"
|
||||
|
||||
describe("diff viewer file tree utilities", () => {
|
||||
test("builds a nested tree with deduplicated directories and file indexes", () => {
|
||||
const tree = buildFileTree([
|
||||
{ file: "src/config/tui.ts" },
|
||||
{ file: "src/config/keybind.ts" },
|
||||
{ file: "src/session/index.ts" },
|
||||
])
|
||||
|
||||
expect(tree.nodes.filter((node) => node.kind === "directory" && node.name === "src")).toHaveLength(1)
|
||||
expect(tree.nodes.filter((node) => node.kind === "directory" && node.name === "config")).toHaveLength(1)
|
||||
expect(tree.nodes.filter((node) => node.kind === "directory" && node.name === "session")).toHaveLength(1)
|
||||
expect(
|
||||
tree.nodes
|
||||
.filter((node) => node.kind === "file")
|
||||
.map((node) => ({ name: node.name, fileIndex: node.fileIndex, depth: node.depth })),
|
||||
).toEqual([
|
||||
{ name: "tui.ts", fileIndex: 0, depth: 2 },
|
||||
{ name: "keybind.ts", fileIndex: 1, depth: 2 },
|
||||
{ name: "index.ts", fileIndex: 2, depth: 2 },
|
||||
])
|
||||
})
|
||||
|
||||
test("sorts directories before files and alphabetically within each group", () => {
|
||||
const rows = flattenFileTree(
|
||||
buildFileTree([
|
||||
{ file: "z-file.ts" },
|
||||
{ file: "b/file.ts" },
|
||||
{ file: "a/zeta.ts" },
|
||||
{ file: "b/alpha.ts" },
|
||||
{ file: "a/alpha.ts" },
|
||||
]),
|
||||
)
|
||||
|
||||
expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
|
||||
"directory:a",
|
||||
" file:alpha.ts",
|
||||
" file:zeta.ts",
|
||||
"directory:b",
|
||||
" file:alpha.ts",
|
||||
" file:file.ts",
|
||||
"file:z-file.ts",
|
||||
])
|
||||
})
|
||||
|
||||
test("sorts root-level files without creating directories", () => {
|
||||
const tree = buildFileTree([{ file: "zeta.ts" }, { file: "alpha.ts" }, { file: "beta.ts" }])
|
||||
|
||||
expect(tree.nodes.every((node) => node.kind === "file")).toBe(true)
|
||||
expect(flattenFileTree(tree).map((row) => row.name)).toEqual(["alpha.ts", "beta.ts", "zeta.ts"])
|
||||
})
|
||||
|
||||
test("collapses unary directory chains while flattening", () => {
|
||||
const rows = flattenFileTree(
|
||||
buildFileTree([{ file: "packages/opencode/src/cli/app.ts" }, { file: "packages/opencode/src/server/server.ts" }]),
|
||||
)
|
||||
|
||||
expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
|
||||
"directory:packages/opencode/src",
|
||||
" directory:cli",
|
||||
" file:app.ts",
|
||||
" directory:server",
|
||||
" file:server.ts",
|
||||
])
|
||||
})
|
||||
|
||||
test("does not collapse a directory into a file row", () => {
|
||||
const rows = flattenFileTree(buildFileTree([{ file: "packages/opencode/src/app.ts" }]))
|
||||
|
||||
expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
|
||||
"directory:packages/opencode/src",
|
||||
" file:app.ts",
|
||||
])
|
||||
})
|
||||
|
||||
test("stops collapsing at branches", () => {
|
||||
const rows = flattenFileTree(
|
||||
buildFileTree([
|
||||
{ file: "packages/opencode/src/cli/app.ts" },
|
||||
{ file: "packages/opencode/src/server/server.ts" },
|
||||
{ file: "packages/readme.md" },
|
||||
]),
|
||||
)
|
||||
|
||||
expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
|
||||
"directory:packages",
|
||||
" directory:opencode/src",
|
||||
" directory:cli",
|
||||
" file:app.ts",
|
||||
" directory:server",
|
||||
" file:server.ts",
|
||||
" file:readme.md",
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps same directory names under different parents separate", () => {
|
||||
const rows = flattenFileTree(
|
||||
buildFileTree([{ file: "components/button.ts" }, { file: "docs/components/usage.md" }]),
|
||||
)
|
||||
|
||||
expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
|
||||
"directory:components",
|
||||
" file:button.ts",
|
||||
"directory:docs/components",
|
||||
" file:usage.md",
|
||||
])
|
||||
})
|
||||
|
||||
test("flattens all-expanded rows depth-first with depths and file references", () => {
|
||||
const rows = flattenFileTree(
|
||||
buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/config/keybind.ts" }, { file: "README.md" }]),
|
||||
)
|
||||
|
||||
expect(rows.map((row) => ({ name: row.name, kind: row.kind, depth: row.depth, fileIndex: row.fileIndex }))).toEqual(
|
||||
[
|
||||
{ name: "src/config", kind: "directory", depth: 0, fileIndex: undefined },
|
||||
{ name: "keybind.ts", kind: "file", depth: 1, fileIndex: 1 },
|
||||
{ name: "tui.ts", kind: "file", depth: 1, fileIndex: 0 },
|
||||
{ name: "README.md", kind: "file", depth: 0, fileIndex: 2 },
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
test("collapses expanded unary children under the first visible directory id", () => {
|
||||
const tree = buildFileTree([
|
||||
{ file: "packages/opencode/src/cli/app.ts" },
|
||||
{ file: "packages/opencode/src/server/server.ts" },
|
||||
])
|
||||
const packages = tree.nodes.find((node) => node.kind === "directory" && node.name === "packages")!
|
||||
|
||||
expect(flattenFileTree(tree, new Set()).map((row) => row.name)).toEqual(["packages/opencode/src"])
|
||||
expect(flattenFileTree(tree, new Set([packages.id])).map((row) => row.name)).toEqual([
|
||||
"packages/opencode/src",
|
||||
"cli",
|
||||
"server",
|
||||
])
|
||||
})
|
||||
|
||||
test("flattens only expanded directory descendants when expansion is provided", () => {
|
||||
const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }])
|
||||
const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
|
||||
const config = tree.nodes.find((node) => node.kind === "directory" && node.name === "config")!
|
||||
|
||||
expect(flattenFileTree(tree, new Set()).map((row) => row.name)).toEqual(["src", "README.md"])
|
||||
expect(flattenFileTree(tree, new Set([src.id])).map((row) => row.name)).toEqual([
|
||||
"src",
|
||||
"config",
|
||||
"session",
|
||||
"README.md",
|
||||
])
|
||||
expect(flattenFileTree(tree, new Set([src.id, config.id])).map((row) => row.name)).toEqual([
|
||||
"src",
|
||||
"config",
|
||||
"tui.ts",
|
||||
"session",
|
||||
"README.md",
|
||||
])
|
||||
})
|
||||
|
||||
test("moves selection across visible rows and clamps to bounds", () => {
|
||||
const rows = flattenFileTree(buildFileTree([{ file: "src/config/tui.ts" }, { file: "README.md" }]))
|
||||
|
||||
expect(moveFileTreeSelection(rows, undefined, 1)).toBe(rows[0]!.id)
|
||||
expect(moveFileTreeSelection(rows, rows[0]!.id, 1)).toBe(rows[1]!.id)
|
||||
expect(moveFileTreeSelection(rows, rows[1]!.id, 99)).toBe(rows[rows.length - 1]!.id)
|
||||
expect(moveFileTreeSelection(rows, rows[1]!.id, -99)).toBe(rows[0]!.id)
|
||||
expect(moveFileTreeSelection([], undefined, 1)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("moves directory selection to first visible child", () => {
|
||||
const rows = flattenFileTree(buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }]))
|
||||
const src = rows.find((row) => row.kind === "directory" && row.name === "src")!
|
||||
const config = rows.find((row) => row.kind === "directory" && row.name === "config")!
|
||||
const tui = rows.find((row) => row.name === "tui.ts")!
|
||||
|
||||
expect(moveFileTreeSelectionToFirstChild(rows, src.id)).toBe(config.id)
|
||||
expect(moveFileTreeSelectionToFirstChild(rows, tui.id)).toBe(tui.id)
|
||||
expect(moveFileTreeSelectionToFirstChild(rows, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("moves collapsed chain selection to first visible child", () => {
|
||||
const rows = flattenFileTree(
|
||||
buildFileTree([{ file: "packages/opencode/src/cli/app.ts" }, { file: "packages/opencode/src/server/server.ts" }]),
|
||||
)
|
||||
const packages = rows.find((row) => row.kind === "directory" && row.name === "packages/opencode/src")!
|
||||
const cli = rows.find((row) => row.kind === "directory" && row.name === "cli")!
|
||||
|
||||
expect(moveFileTreeSelectionToFirstChild(rows, packages.id)).toBe(cli.id)
|
||||
})
|
||||
|
||||
test("moves file and collapsed directory selection to visible parent", () => {
|
||||
const rows = flattenFileTree(
|
||||
buildFileTree([{ file: "packages/opencode/src/cli/app.ts" }, { file: "packages/opencode/src/server/server.ts" }]),
|
||||
)
|
||||
const root = rows.find((row) => row.kind === "directory" && row.name === "packages/opencode/src")!
|
||||
const cli = rows.find((row) => row.kind === "directory" && row.name === "cli")!
|
||||
const app = rows.find((row) => row.name === "app.ts")!
|
||||
|
||||
expect(moveFileTreeSelectionToParent(rows, app.id)).toBe(cli.id)
|
||||
expect(moveFileTreeSelectionToParent(rows, cli.id)).toBe(root.id)
|
||||
expect(moveFileTreeSelectionToParent(rows, root.id)).toBe(root.id)
|
||||
expect(moveFileTreeSelectionToParent(rows, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("moves file selection relative to the highlighted row", () => {
|
||||
const rows = flattenFileTree(
|
||||
buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }]),
|
||||
)
|
||||
const config = rows.find((row) => row.kind === "directory" && row.name === "config")!
|
||||
const session = rows.find((row) => row.kind === "directory" && row.name === "session")!
|
||||
const tui = rows.find((row) => row.name === "tui.ts")!
|
||||
const index = rows.find((row) => row.name === "index.ts")!
|
||||
const readme = rows.find((row) => row.name === "README.md")!
|
||||
|
||||
expect(moveFileTreeSelectionToFile(rows, undefined, 1)).toBe(tui.id)
|
||||
expect(moveFileTreeSelectionToFile(rows, undefined, -1)).toBe(readme.id)
|
||||
expect(moveFileTreeSelectionToFile(rows, config.id, 1)).toBe(tui.id)
|
||||
expect(moveFileTreeSelectionToFile(rows, session.id, -1)).toBe(tui.id)
|
||||
expect(moveFileTreeSelectionToFile(rows, tui.id, 1)).toBe(index.id)
|
||||
expect(moveFileTreeSelectionToFile(rows, index.id, -1)).toBe(tui.id)
|
||||
expect(moveFileTreeSelectionToFile(rows, readme.id, 1)).toBe(readme.id)
|
||||
})
|
||||
|
||||
test("selects a file tree node and expands its parents for a patch file", () => {
|
||||
const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }])
|
||||
const selection = fileTreeFileSelection(tree, 1)
|
||||
|
||||
expect(selection?.highlightedNode).toBe(
|
||||
tree.nodes.find((node) => node.kind === "file" && node.name === "index.ts")?.id,
|
||||
)
|
||||
expect([...selection!.expandedNodes].map((id) => tree.nodes[id]!.name)).toEqual(["session", "src"])
|
||||
expect(fileTreeFileSelection(tree, 99)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("prefers the selected file when choosing the single patch file", () => {
|
||||
expect(singlePatchFileIndex(2, 1, 0, 3)).toBe(2)
|
||||
expect(singlePatchFileIndex(undefined, 1, 0, 3)).toBe(1)
|
||||
expect(singlePatchFileIndex(undefined, undefined, 0, 3)).toBe(0)
|
||||
expect(singlePatchFileIndex(undefined, undefined, undefined, 3)).toBe(3)
|
||||
})
|
||||
|
||||
test("orders patches by the flattened file tree order", () => {
|
||||
const rows = flattenFileTree(
|
||||
buildFileTree([
|
||||
{ file: "src/dir-8/juniper-4.ts" },
|
||||
{ file: "src/dir-8/harbor-94.ts" },
|
||||
{ file: "src/dir-8/cedar-16.ts" },
|
||||
]),
|
||||
)
|
||||
|
||||
expect(orderedPatchFileIndexes(rows)).toEqual([2, 1, 0])
|
||||
})
|
||||
|
||||
test("shows the diff viewer file tree only when enabled and files exist", () => {
|
||||
expect(showDiffViewerFileTree(true, 1)).toBe(true)
|
||||
expect(showDiffViewerFileTree(true, 0)).toBe(false)
|
||||
expect(showDiffViewerFileTree(false, 1)).toBe(false)
|
||||
expect(showDiffViewerFileTree(false, 0)).toBe(false)
|
||||
})
|
||||
|
||||
test("moves patch selection through the ordered patch file indexes", () => {
|
||||
const fileIndexes = [2, 1, 0]
|
||||
|
||||
expect(movePatchFileIndex(fileIndexes, undefined, 1)).toBe(2)
|
||||
expect(movePatchFileIndex(fileIndexes, undefined, -1)).toBe(2)
|
||||
expect(movePatchFileIndex(fileIndexes, 2, 1)).toBe(1)
|
||||
expect(movePatchFileIndex(fileIndexes, 1, -1)).toBe(2)
|
||||
expect(movePatchFileIndex(fileIndexes, 0, 1)).toBe(0)
|
||||
expect(movePatchFileIndex(fileIndexes, 99, 1)).toBe(2)
|
||||
expect(movePatchFileIndex(fileIndexes, 99, -1)).toBe(2)
|
||||
expect(movePatchFileIndex([], undefined, 1)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("toggles only selected directory expansion", () => {
|
||||
const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "README.md" }])
|
||||
const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
|
||||
const readme = tree.nodes.find((node) => node.kind === "file" && node.name === "README.md")!
|
||||
const expanded = allExpandedFileTreeDirectories(tree)
|
||||
|
||||
const collapsed = toggleFileTreeDirectory(tree, expanded, src.id)
|
||||
expect(collapsed.has(src.id)).toBe(false)
|
||||
expect(flattenFileTree(tree, collapsed).map((row) => row.name)).toEqual(["src/config", "README.md"])
|
||||
|
||||
const reopened = toggleFileTreeDirectory(tree, collapsed, src.id)
|
||||
expect(reopened.has(src.id)).toBe(true)
|
||||
|
||||
expect(toggleFileTreeDirectory(tree, reopened, readme.id)).toBe(reopened)
|
||||
expect(toggleFileTreeDirectory(tree, reopened, undefined)).toBe(reopened)
|
||||
})
|
||||
|
||||
test("sets only selected directory expansion", () => {
|
||||
const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "README.md" }])
|
||||
const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
|
||||
const readme = tree.nodes.find((node) => node.kind === "file" && node.name === "README.md")!
|
||||
const expanded = allExpandedFileTreeDirectories(tree)
|
||||
|
||||
const collapsed = setFileTreeDirectoryExpanded(tree, expanded, src.id, false)
|
||||
expect(collapsed.has(src.id)).toBe(false)
|
||||
|
||||
const reopened = setFileTreeDirectoryExpanded(tree, collapsed, src.id, true)
|
||||
expect(reopened.has(src.id)).toBe(true)
|
||||
|
||||
expect(setFileTreeDirectoryExpanded(tree, reopened, readme.id, false)).toBe(reopened)
|
||||
expect(setFileTreeDirectoryExpanded(tree, reopened, undefined, false)).toBe(reopened)
|
||||
})
|
||||
})
|
||||
13
packages/tui/test/fixture/fixture.ts
Normal file
13
packages/tui/test/fixture/fixture.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { mkdtemp, realpath, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import os from "node:os"
|
||||
|
||||
export async function tmpdir() {
|
||||
const directory = await realpath(await mkdtemp(path.join(os.tmpdir(), "opencode-tui-test-")))
|
||||
return {
|
||||
path: directory,
|
||||
async [Symbol.asyncDispose]() {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
32
packages/tui/test/fixture/tui-environment.tsx
Normal file
32
packages/tui/test/fixture/tui-environment.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import {
|
||||
TuiPathsProvider,
|
||||
TuiStartupProvider,
|
||||
TuiTerminalEnvironmentProvider,
|
||||
type TuiPaths,
|
||||
} from "../../src/context/runtime"
|
||||
import type { ParentProps } from "solid-js"
|
||||
|
||||
export function TestTuiContexts(
|
||||
props: ParentProps<{
|
||||
cwd?: string
|
||||
directory?: string
|
||||
paths?: Partial<TuiPaths>
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<TuiPathsProvider
|
||||
value={{
|
||||
cwd: props.cwd ?? props.directory ?? "/tmp/opencode/packages/tui",
|
||||
home: "/tmp/opencode/home",
|
||||
state: "/tmp/opencode/state",
|
||||
worktree: "/tmp/opencode",
|
||||
...props.paths,
|
||||
}}
|
||||
>
|
||||
<TuiTerminalEnvironmentProvider value={{ platform: "linux" }}>
|
||||
<TuiStartupProvider value={{ skipInitialLoading: false }}>{props.children}</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
</TuiPathsProvider>
|
||||
)
|
||||
}
|
||||
36
packages/tui/test/fixture/tui-plugin.ts
Normal file
36
packages/tui/test/fixture/tui-plugin.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { createTuiResolvedConfig } from "./tui-runtime"
|
||||
|
||||
type Opts = {
|
||||
client?: TuiPluginApi["client"]
|
||||
keymap?: TuiPluginApi["keymap"]
|
||||
attention?: Partial<TuiPluginApi["attention"]>
|
||||
event?: TuiPluginApi["event"]
|
||||
state?: { session?: Partial<TuiPluginApi["state"]["session"]> }
|
||||
}
|
||||
|
||||
export function createTuiPluginApi(opts: Opts = {}) {
|
||||
const values = new Map<string, unknown>()
|
||||
const color = RGBA.fromInts(200, 200, 200)
|
||||
const dialog = { clear() {}, replace() {}, setSize() {}, size: "medium" as const, depth: 0, open: false }
|
||||
return {
|
||||
attention: { notify: async () => ({ ok: false, notification: false, sound: false }), ...opts.attention },
|
||||
client: opts.client,
|
||||
event: opts.event,
|
||||
keymap: opts.keymap,
|
||||
kv: {
|
||||
get(name: string, fallback?: unknown) {
|
||||
return values.has(name) ? values.get(name) : fallback
|
||||
},
|
||||
set(name: string, value: unknown) {
|
||||
values.set(name, value)
|
||||
},
|
||||
ready: true,
|
||||
},
|
||||
state: { session: { get: () => undefined, ...opts.state?.session } },
|
||||
theme: { current: new Proxy({}, { get: () => color }) },
|
||||
tuiConfig: createTuiResolvedConfig(),
|
||||
ui: { dialog },
|
||||
} as unknown as TuiPluginApi
|
||||
}
|
||||
12
packages/tui/test/fixture/tui-runtime.ts
Normal file
12
packages/tui/test/fixture/tui-runtime.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { resolve, type Info, type Resolved } from "../../src/config"
|
||||
import { TuiKeybind } from "../../src/config/keybind"
|
||||
|
||||
type ResolvedInput = Omit<Info, "attention" | "keybinds" | "leader_timeout"> & {
|
||||
attention?: Partial<Resolved["attention"]>
|
||||
keybinds?: Partial<TuiKeybind.Keybinds>
|
||||
leader_timeout?: number
|
||||
}
|
||||
|
||||
export function createTuiResolvedConfig(input: ResolvedInput = {}) {
|
||||
return resolve(input, { terminalSuspend: process.platform !== "win32" })
|
||||
}
|
||||
81
packages/tui/test/fixture/tui-sdk.ts
Normal file
81
packages/tui/test/fixture/tui-sdk.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import type { EventSource } from "../../src/context/sdk"
|
||||
|
||||
export const worktree = "/tmp/opencode"
|
||||
export const directory = `${worktree}/packages/tui`
|
||||
|
||||
export function json(data: unknown, init?: ResponseInit) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
...init,
|
||||
headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
|
||||
})
|
||||
}
|
||||
|
||||
export function eventSource(): EventSource {
|
||||
return { subscribe: async () => () => {} }
|
||||
}
|
||||
|
||||
export function createEventSource() {
|
||||
let fn: ((event: GlobalEvent) => void) | undefined
|
||||
return {
|
||||
source: {
|
||||
subscribe: async (handler: (event: GlobalEvent) => void) => {
|
||||
fn = handler
|
||||
return () => {
|
||||
if (fn === handler) fn = undefined
|
||||
}
|
||||
},
|
||||
} satisfies EventSource,
|
||||
emit(event: GlobalEvent) {
|
||||
if (!fn) throw new Error("event source not ready")
|
||||
fn(event)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type FetchHandler = (url: URL) => Response | Promise<Response> | undefined
|
||||
|
||||
export function createFetch(override?: FetchHandler) {
|
||||
const session = [] as URL[]
|
||||
const fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input))
|
||||
if (url.pathname === "/session") session.push(url)
|
||||
const overridden = await override?.(url)
|
||||
if (overridden) return overridden
|
||||
|
||||
if (
|
||||
[
|
||||
"/agent",
|
||||
"/command",
|
||||
"/experimental/workspace",
|
||||
"/experimental/workspace/status",
|
||||
"/formatter",
|
||||
"/lsp",
|
||||
].includes(url.pathname)
|
||||
)
|
||||
return json([])
|
||||
if (["/config", "/experimental/resource", "/mcp", "/provider/auth", "/session/status"].includes(url.pathname))
|
||||
return json({})
|
||||
if (url.pathname === "/config/providers") return json({ providers: {}, default: {} })
|
||||
if (url.pathname === "/experimental/console") return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
|
||||
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
|
||||
if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } })
|
||||
if (
|
||||
["/api/agent", "/api/model", "/api/provider", "/api/connector", "/api/command", "/api/skill"].includes(
|
||||
url.pathname,
|
||||
)
|
||||
)
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory: worktree } },
|
||||
data: [],
|
||||
})
|
||||
if (url.pathname === "/project/current") return json({ id: "proj_test" })
|
||||
if (url.pathname === "/api/reference")
|
||||
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
|
||||
if (url.pathname === "/provider") return json({ all: [], default: {}, connected: [] })
|
||||
if (url.pathname === "/session") return json([])
|
||||
if (url.pathname === "/vcs") return json({ branch: "main" })
|
||||
throw new Error(`unexpected request: ${url.pathname}`)
|
||||
}) as typeof globalThis.fetch
|
||||
return { fetch, session }
|
||||
}
|
||||
6
packages/tui/test/index.test.tsx
Normal file
6
packages/tui/test/index.test.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { run } from "../src"
|
||||
|
||||
test("exports the canonical application lifecycle", () => {
|
||||
expect(typeof run).toBe("function")
|
||||
})
|
||||
141
packages/tui/test/keymap.test.tsx
Normal file
141
packages/tui/test/keymap.test.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import { createBindingLookup } from "@opentui/keymap/extras"
|
||||
import { testRender, useRenderer } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { TuiKeybind } from "../src/config/keybind"
|
||||
import { getOpencodeModeStack, OPENCODE_BASE_MODE, OpencodeKeymapProvider, registerOpencodeKeymap } from "../src/keymap"
|
||||
|
||||
function createResolvedKeymapConfig(input: TuiKeybind.KeybindOverrides = {}) {
|
||||
const keybinds = TuiKeybind.parse(input)
|
||||
return {
|
||||
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), {
|
||||
commandMap: TuiKeybind.CommandMap,
|
||||
bindingDefaults: TuiKeybind.bindingDefaults(),
|
||||
}),
|
||||
leader_timeout: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
test("legacy page key aliases compile as page keys", async () => {
|
||||
const sequences: Record<string, string[][]> = {}
|
||||
|
||||
function Harness() {
|
||||
const renderer = useRenderer()
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
const config = createResolvedKeymapConfig({
|
||||
messages_page_up: "pgup",
|
||||
messages_page_down: "pgdown",
|
||||
})
|
||||
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
|
||||
const offLayer = keymap.registerLayer({
|
||||
bindings: config.keybinds.gather("session", ["session.page.up", "session.page.down"]),
|
||||
})
|
||||
const bindings = keymap.getCommandBindings({
|
||||
visibility: "registered",
|
||||
commands: ["session.page.up", "session.page.down"],
|
||||
})
|
||||
sequences.up =
|
||||
bindings.get("session.page.up")?.map((binding) => binding.sequence.map((part) => part.stroke.name)) ?? []
|
||||
sequences.down =
|
||||
bindings.get("session.page.down")?.map((binding) => binding.sequence.map((part) => part.stroke.name)) ?? []
|
||||
onCleanup(() => {
|
||||
offLayer()
|
||||
offKeymap()
|
||||
})
|
||||
|
||||
return (
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<box />
|
||||
</OpencodeKeymapProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => <Harness />)
|
||||
try {
|
||||
expect(sequences).toEqual({
|
||||
up: [["pageup"]],
|
||||
down: [["pagedown"]],
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("mode-less bindings stay active when opencode mode changes", async () => {
|
||||
const counts: Record<string, Record<string, number>> = {}
|
||||
|
||||
function Harness() {
|
||||
const renderer = useRenderer()
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
const config = createResolvedKeymapConfig()
|
||||
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
|
||||
const offGlobal = keymap.registerLayer({
|
||||
commands: [
|
||||
{ name: "session.list", run() {} },
|
||||
{ name: "session.new", run() {} },
|
||||
{ name: "session.page.up", run() {} },
|
||||
{ name: "session.first", run() {} },
|
||||
],
|
||||
bindings: config.keybinds.gather("test.global", [
|
||||
"session.list",
|
||||
"session.new",
|
||||
"session.page.up",
|
||||
"session.first",
|
||||
]),
|
||||
})
|
||||
const offBase = keymap.registerLayer({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
commands: [{ name: "model.list", run() {} }],
|
||||
bindings: config.keybinds.gather("test.base", ["model.list"]),
|
||||
})
|
||||
const activeCounts = () =>
|
||||
Object.fromEntries(
|
||||
Array.from(
|
||||
keymap.getCommandBindings({
|
||||
visibility: "active",
|
||||
commands: ["session.list", "session.new", "session.page.up", "session.first", "model.list"],
|
||||
}),
|
||||
([command, bindings]) => [command, bindings.length],
|
||||
),
|
||||
)
|
||||
|
||||
counts.base = activeCounts()
|
||||
const popQuestion = getOpencodeModeStack(keymap).push("question")
|
||||
counts.question = activeCounts()
|
||||
popQuestion()
|
||||
const popAutocomplete = getOpencodeModeStack(keymap).push("autocomplete")
|
||||
counts.autocomplete = activeCounts()
|
||||
popAutocomplete()
|
||||
|
||||
onCleanup(() => {
|
||||
offBase()
|
||||
offGlobal()
|
||||
offKeymap()
|
||||
})
|
||||
|
||||
return (
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<box />
|
||||
</OpencodeKeymapProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => <Harness />)
|
||||
try {
|
||||
expect(counts).toEqual({
|
||||
base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 1 },
|
||||
question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 0 },
|
||||
autocomplete: {
|
||||
"session.list": 1,
|
||||
"session.new": 1,
|
||||
"session.page.up": 2,
|
||||
"session.first": 2,
|
||||
"model.list": 0,
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
50
packages/tui/test/plugin/runtime.test.ts
Normal file
50
packages/tui/test/plugin/runtime.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createPluginRuntime } from "../../src/plugin/runtime"
|
||||
|
||||
test("routes use the latest registration and restore previous registrations", () => {
|
||||
const runtime = createPluginRuntime()
|
||||
const first = () => "first"
|
||||
const second = () => "second"
|
||||
runtime.routes.register([{ name: "demo", render: first }])
|
||||
const dispose = runtime.routes.register([{ name: "demo", render: second }])
|
||||
|
||||
expect(runtime.routes.get("demo")).toBe(second)
|
||||
dispose()
|
||||
expect(runtime.routes.get("demo")).toBe(first)
|
||||
})
|
||||
|
||||
test("facade publishes and clears presentation state", async () => {
|
||||
const runtime = createPluginRuntime()
|
||||
runtime.update({
|
||||
commands: {
|
||||
async activate() {
|
||||
return true
|
||||
},
|
||||
async deactivate() {
|
||||
return true
|
||||
},
|
||||
async add() {
|
||||
return true
|
||||
},
|
||||
async install() {
|
||||
return { ok: true, dir: "/tmp", tui: true }
|
||||
},
|
||||
},
|
||||
status: [
|
||||
{
|
||||
id: "demo",
|
||||
source: "internal",
|
||||
spec: "demo",
|
||||
target: "demo",
|
||||
enabled: true,
|
||||
active: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(await runtime.commands().activate("demo")).toBe(true)
|
||||
expect(runtime.status()).toHaveLength(1)
|
||||
runtime.clear()
|
||||
expect(await runtime.commands().activate("demo")).toBe(false)
|
||||
expect(runtime.status()).toEqual([])
|
||||
})
|
||||
38
packages/tui/test/plugin/slots.test.tsx
Normal file
38
packages/tui/test/plugin/slots.test.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { createSlot, createSolidSlotRegistry, testRender, useRenderer } from "@opentui/solid"
|
||||
import { onMount } from "solid-js"
|
||||
|
||||
type Slots = {
|
||||
prompt: {}
|
||||
}
|
||||
|
||||
test("replace slot mounts plugin content once", async () => {
|
||||
let mounts = 0
|
||||
|
||||
const Probe = () => {
|
||||
onMount(() => {
|
||||
mounts += 1
|
||||
})
|
||||
return <box />
|
||||
}
|
||||
|
||||
const App = () => {
|
||||
const registry = createSolidSlotRegistry<Slots>(useRenderer(), {})
|
||||
const Slot = createSlot(registry)
|
||||
registry.register({ id: "plugin", slots: { prompt: () => <Probe /> } })
|
||||
|
||||
return (
|
||||
<Slot name="prompt" mode="replace">
|
||||
<box />
|
||||
</Slot>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => <App />)
|
||||
try {
|
||||
expect(mounts).toBe(1)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
33
packages/tui/test/prompt/display.test.ts
Normal file
33
packages/tui/test/prompt/display.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { displayCharAt, displaySlice, mentionTriggerIndex } from "../../src/prompt/display"
|
||||
|
||||
describe("prompt display", () => {
|
||||
test("uses display-width offsets for mentions", () => {
|
||||
expect(mentionTriggerIndex("@")).toBe(0)
|
||||
expect(mentionTriggerIndex("test @")).toBe(5)
|
||||
expect(mentionTriggerIndex("中文 @")).toBe(5)
|
||||
expect(mentionTriggerIndex("こんにちは @")).toBe(11)
|
||||
expect(mentionTriggerIndex("한국어 @")).toBe(7)
|
||||
expect(mentionTriggerIndex("🙂 @")).toBe(3)
|
||||
expect(mentionTriggerIndex("中文 @src file", Bun.stringWidth("中文 @src"))).toBe(5)
|
||||
expect(displayCharAt("中文 @src", Bun.stringWidth("中文 @"))).toBe("s")
|
||||
expect(displaySlice("中文 @src", 5, Bun.stringWidth("中文 @src"))).toBe("@src")
|
||||
expect(displaySlice("中文 @src", 6, Bun.stringWidth("中文 @src"))).toBe("src")
|
||||
expect(mentionTriggerIndex("👨👩👧👦 @src", Bun.stringWidth("👨👩👧👦 @src"))).toBe(3)
|
||||
expect(displayCharAt("👨👩👧👦 @src", Bun.stringWidth("👨👩👧👦 @"))).toBe("s")
|
||||
expect(displaySlice("👨👩👧👦 @src", 3, Bun.stringWidth("👨👩👧👦 @src"))).toBe("@src")
|
||||
expect(mentionTriggerIndex("@file1\n@file2", 13)).toBe(7)
|
||||
expect(displayCharAt("@file1\n@file2", 6)).toBe("\n")
|
||||
expect(displaySlice("@file1\n@file2", 8, 13)).toBe("file2")
|
||||
expect(mentionTriggerIndex("@file1\nfoo @file2", 17)).toBe(11)
|
||||
expect(mentionTriggerIndex("中文 @one\n@two", 14)).toBe(10)
|
||||
expect(displaySlice("中文 @one\n@two", 11, 14)).toBe("two")
|
||||
expect(mentionTriggerIndex("中文@")).toBeUndefined()
|
||||
expect(mentionTriggerIndex("こんにちは@")).toBeUndefined()
|
||||
expect(mentionTriggerIndex("한국어@")).toBeUndefined()
|
||||
expect(mentionTriggerIndex("🙂@")).toBeUndefined()
|
||||
expect(mentionTriggerIndex("hello@")).toBeUndefined()
|
||||
expect(mentionTriggerIndex("foo@bar.com")).toBeUndefined()
|
||||
expect(mentionTriggerIndex("中文 @src file")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
39
packages/tui/test/prompt/history.test.ts
Normal file
39
packages/tui/test/prompt/history.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { isDuplicateEntry, MAX_HISTORY_ENTRIES, parsePromptHistory, type PromptInfo } from "../../src/prompt/history"
|
||||
|
||||
const entry = (input: string, parts: PromptInfo["parts"] = []): PromptInfo => ({ input, parts })
|
||||
|
||||
describe("prompt history", () => {
|
||||
test("recovers valid JSONL entries around corruption", () => {
|
||||
expect(parsePromptHistory(`${JSON.stringify(entry("one"))}\nnot-json\n${JSON.stringify(entry("two"))}\n`)).toEqual([
|
||||
entry("one"),
|
||||
entry("two"),
|
||||
])
|
||||
})
|
||||
|
||||
test("retains only the newest entries", () => {
|
||||
const input = Array.from({ length: MAX_HISTORY_ENTRIES + 5 }, (_, index) =>
|
||||
JSON.stringify(entry(String(index))),
|
||||
).join("\n")
|
||||
const result = parsePromptHistory(input)
|
||||
expect(result).toHaveLength(MAX_HISTORY_ENTRIES)
|
||||
expect(result[0]?.input).toBe("5")
|
||||
})
|
||||
|
||||
test("dedupes only identical consecutive entries", () => {
|
||||
expect(isDuplicateEntry(undefined, entry("hello"))).toBe(false)
|
||||
expect(isDuplicateEntry(entry("hello"), entry("hello"))).toBe(true)
|
||||
expect(isDuplicateEntry(entry("foo"), entry("bar"))).toBe(false)
|
||||
expect(isDuplicateEntry({ ...entry("ls"), mode: "normal" }, { ...entry("ls"), mode: "shell" })).toBe(false)
|
||||
})
|
||||
|
||||
test("does not dedupe entries with different parts", () => {
|
||||
const a = entry("describe this", [
|
||||
{ type: "file", mime: "image/png", filename: "a.png", url: "data:image/png;base64,AAA" },
|
||||
])
|
||||
const b = entry("describe this", [
|
||||
{ type: "file", mime: "image/png", filename: "b.png", url: "data:image/png;base64,BBB" },
|
||||
])
|
||||
expect(isDuplicateEntry(a, b)).toBe(false)
|
||||
})
|
||||
})
|
||||
24
packages/tui/test/prompt/jsonl.test.ts
Normal file
24
packages/tui/test/prompt/jsonl.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { MAX_FRECENCY_ENTRIES, parseFrecency } from "../../src/prompt/frecency"
|
||||
import { MAX_STASH_ENTRIES, parsePromptStash } from "../../src/prompt/stash"
|
||||
|
||||
test("stash JSONL skips corruption and retains newest entries", () => {
|
||||
const entries = Array.from({ length: MAX_STASH_ENTRIES + 2 }, (_, index) =>
|
||||
JSON.stringify({ input: String(index), parts: [], timestamp: index }),
|
||||
)
|
||||
entries.splice(2, 0, "broken")
|
||||
const result = parsePromptStash(entries.join("\n"))
|
||||
expect(result).toHaveLength(MAX_STASH_ENTRIES)
|
||||
expect(result[0]?.input).toBe("2")
|
||||
})
|
||||
|
||||
test("frecency JSONL skips corruption, keeps latest path state, and limits entries", () => {
|
||||
const entries = Array.from({ length: MAX_FRECENCY_ENTRIES + 1 }, (_, index) =>
|
||||
JSON.stringify({ path: String(index), frequency: 1, lastOpen: index }),
|
||||
)
|
||||
entries.push("broken", JSON.stringify({ path: "1000", frequency: 2, lastOpen: 2000 }))
|
||||
const result = parseFrecency(entries.join("\n"))
|
||||
expect(result).toHaveLength(MAX_FRECENCY_ENTRIES)
|
||||
expect(result[0]).toEqual({ path: "1000", frequency: 2, lastOpen: 2000 })
|
||||
expect(result.some((entry) => entry.path === "0")).toBe(false)
|
||||
})
|
||||
43
packages/tui/test/prompt/local-attachment.test.ts
Normal file
43
packages/tui/test/prompt/local-attachment.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { readLocalAttachmentWith } from "../../src/component/prompt/local-attachment"
|
||||
import type { LocalFiles } from "../../src/component/prompt/local-attachment"
|
||||
|
||||
function files(input: { mime: string; text?: string; bytes?: Uint8Array }): LocalFiles {
|
||||
return {
|
||||
mime: async () => input.mime,
|
||||
readText: async () => input.text ?? "",
|
||||
readBytes: async () => input.bytes ?? new Uint8Array(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("prompt local attachments", () => {
|
||||
test("reads SVG attachments as text", async () => {
|
||||
expect(await readLocalAttachmentWith(files({ mime: "image/svg+xml", text: "<svg />" }), "/tmp/image.svg")).toEqual({
|
||||
type: "text",
|
||||
mime: "image/svg+xml",
|
||||
content: "<svg />",
|
||||
})
|
||||
})
|
||||
|
||||
test("reads image and PDF attachments as bytes", async () => {
|
||||
const content = new Uint8Array([1, 2, 3])
|
||||
expect(await readLocalAttachmentWith(files({ mime: "application/pdf", bytes: content }), "/tmp/file.pdf")).toEqual({
|
||||
type: "binary",
|
||||
mime: "application/pdf",
|
||||
content,
|
||||
})
|
||||
})
|
||||
|
||||
test("ignores unsupported and unreadable local files", async () => {
|
||||
expect(await readLocalAttachmentWith(files({ mime: "text/plain" }), "/tmp/file.txt")).toBeUndefined()
|
||||
expect(
|
||||
await readLocalAttachmentWith(
|
||||
{
|
||||
...files({ mime: "image/png" }),
|
||||
readBytes: async () => Promise.reject(new Error("missing")),
|
||||
},
|
||||
"/tmp/missing.png",
|
||||
),
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
53
packages/tui/test/prompt/part.test.ts
Normal file
53
packages/tui/test/prompt/part.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { expandTrackedPastedText, stripPromptPartIDs } from "../../src/prompt/part"
|
||||
|
||||
describe("prompt part", () => {
|
||||
test("strips persisted IDs from reused parts", () => {
|
||||
expect(
|
||||
stripPromptPartIDs({
|
||||
id: "prt_old",
|
||||
sessionID: "ses_old",
|
||||
messageID: "msg_old",
|
||||
type: "file" as const,
|
||||
mime: "image/png",
|
||||
filename: "tiny.png",
|
||||
url: "data:image/png;base64,abc",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
filename: "tiny.png",
|
||||
url: "data:image/png;base64,abc",
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves wide characters around pasted text", () => {
|
||||
const marker = "[Pasted ~3 lines]"
|
||||
const prefix = "你好你好\n"
|
||||
|
||||
expect(
|
||||
expandTrackedPastedText(prefix + marker + "\n阿斯顿法国红酒看来", [
|
||||
{
|
||||
start: Bun.stringWidth("你好你好") + 1,
|
||||
end: Bun.stringWidth("你好你好") + 1 + Bun.stringWidth(marker),
|
||||
text: "public:\n\tvoid ExecuteTask();\nprivate:",
|
||||
},
|
||||
]),
|
||||
).toBe("你好你好\npublic:\n\tvoid ExecuteTask();\nprivate:\n阿斯顿法国红酒看来")
|
||||
})
|
||||
|
||||
test("only expands the tracked placeholder occurrence", () => {
|
||||
const marker = "[Pasted ~3 lines]"
|
||||
const prefix = `keep ${marker} then `
|
||||
|
||||
expect(
|
||||
expandTrackedPastedText(prefix + marker + " tail", [
|
||||
{
|
||||
start: Bun.stringWidth(prefix),
|
||||
end: Bun.stringWidth(prefix + marker),
|
||||
text: "alpha\nbeta\ngamma",
|
||||
},
|
||||
]),
|
||||
).toBe(`keep ${marker} then alpha\nbeta\ngamma tail`)
|
||||
})
|
||||
})
|
||||
23
packages/tui/test/prompt/persistence.test.ts
Normal file
23
packages/tui/test/prompt/persistence.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { mkdtemp, rm } from "fs/promises"
|
||||
import { tmpdir } from "os"
|
||||
import { appendText, readJson, readText, writeJsonAtomic, writeText } from "../../src/util/persistence"
|
||||
|
||||
test("persistence creates parent directories and supports text, append, and JSON", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "opencode-tui-persistence-"))
|
||||
try {
|
||||
const textPath = path.join(root, "nested", "state.jsonl")
|
||||
await writeText(textPath, "one\n")
|
||||
await appendText(textPath, "two\n")
|
||||
expect(await readText(textPath)).toBe("one\ntwo\n")
|
||||
|
||||
const jsonPath = path.join(root, "other", "state.json")
|
||||
await writeJsonAtomic(jsonPath, { value: 1 })
|
||||
expect(await readJson<{ value: number }>(jsonPath)).toEqual({ value: 1 })
|
||||
await writeJsonAtomic(jsonPath, { value: 2 })
|
||||
expect(await readJson<{ value: number }>(jsonPath)).toEqual({ value: 2 })
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
25
packages/tui/test/prompt/traits.test.ts
Normal file
25
packages/tui/test/prompt/traits.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { computePromptTraits } from "../../src/prompt/traits"
|
||||
|
||||
describe("computePromptTraits", () => {
|
||||
test("normal mode without autocomplete only captures tab", () => {
|
||||
const traits = computePromptTraits({ mode: "normal", autocompleteVisible: false })
|
||||
expect(traits.capture).toEqual(["tab"])
|
||||
expect(traits.suspend).toBeUndefined()
|
||||
expect(traits.status).toBeUndefined()
|
||||
})
|
||||
|
||||
test("normal mode with autocomplete captures navigation keys", () => {
|
||||
const traits = computePromptTraits({ mode: "normal", autocompleteVisible: true })
|
||||
expect(traits.capture).toEqual(["escape", "navigate", "submit", "tab"])
|
||||
expect(traits.suspend).toBeUndefined()
|
||||
expect(traits.status).toBeUndefined()
|
||||
})
|
||||
|
||||
test("shell mode disables capture and labels the prompt without suspending", () => {
|
||||
const traits = computePromptTraits({ mode: "shell", autocompleteVisible: false })
|
||||
expect(traits.capture).toBeUndefined()
|
||||
expect(traits.suspend).toBeUndefined()
|
||||
expect(traits.status).toBe("SHELL")
|
||||
})
|
||||
})
|
||||
37
packages/tui/test/runtime.test.tsx
Normal file
37
packages/tui/test/runtime.test.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { abbreviateHome } from "../src/runtime"
|
||||
import { TuiPathsProvider, useTuiPaths } from "../src/context/runtime"
|
||||
|
||||
test("abbreviates paths within home boundaries", () => {
|
||||
expect(abbreviateHome("/home/test", "/home/test")).toBe("~")
|
||||
expect(abbreviateHome("/home/test/project", "/home/test")).toBe("~/project")
|
||||
expect(abbreviateHome("/home/tester/project", "/home/test")).toBe("/home/tester/project")
|
||||
expect(abbreviateHome("/tmp/project", "/home/test")).toBe("/tmp/project")
|
||||
})
|
||||
|
||||
test("provides focused immutable runtime inputs", async () => {
|
||||
let paths: ReturnType<typeof useTuiPaths>
|
||||
|
||||
function Runtime() {
|
||||
paths = useTuiPaths()
|
||||
return <text>{paths.cwd}</text>
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TuiPathsProvider value={{ cwd: "/work", home: "/home/test", state: "/state", worktree: "/worktree" }}>
|
||||
<Runtime />
|
||||
</TuiPathsProvider>
|
||||
),
|
||||
{ width: 40, height: 3 },
|
||||
)
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("/work")
|
||||
expect(Object.isFrozen(paths!)).toBe(true)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
81
packages/tui/test/theme.test.ts
Normal file
81
packages/tui/test/theme.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { mkdir, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import type { TerminalColors } from "@opentui/core"
|
||||
import { DEFAULT_THEMES, addTheme, allThemes, hasTheme, resolveTheme, terminalMode } from "../src/theme"
|
||||
import { discoverThemes } from "../src/context/theme"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
test("addTheme writes into module theme store", () => {
|
||||
const name = `plugin-theme-${Date.now()}`
|
||||
expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true)
|
||||
expect(allThemes()[name]).toBeDefined()
|
||||
})
|
||||
|
||||
test("addTheme keeps first theme for duplicate names", () => {
|
||||
const name = `plugin-theme-keep-${Date.now()}`
|
||||
const one = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const two = structuredClone(DEFAULT_THEMES.opencode)
|
||||
one.theme.primary = "#101010"
|
||||
two.theme.primary = "#fefefe"
|
||||
|
||||
expect(addTheme(name, one)).toBe(true)
|
||||
expect(addTheme(name, two)).toBe(false)
|
||||
expect(allThemes()[name]!.theme.primary).toBe("#101010")
|
||||
})
|
||||
|
||||
test("addTheme ignores entries without a theme object", () => {
|
||||
const name = `plugin-theme-invalid-${Date.now()}`
|
||||
expect(addTheme(name, { defs: { a: "#ffffff" } })).toBe(false)
|
||||
expect(allThemes()[name]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("hasTheme checks theme presence", () => {
|
||||
const name = `plugin-theme-has-${Date.now()}`
|
||||
expect(hasTheme(name)).toBe(false)
|
||||
expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true)
|
||||
expect(hasTheme(name)).toBe(true)
|
||||
})
|
||||
|
||||
test("resolveTheme rejects circular color refs", () => {
|
||||
const item = structuredClone(DEFAULT_THEMES.opencode)
|
||||
item.defs = { ...item.defs, one: "two", two: "one" }
|
||||
item.theme.primary = "one"
|
||||
expect(() => resolveTheme(item, "dark")).toThrow("Circular color reference")
|
||||
})
|
||||
|
||||
function terminalColors(defaultBackground: string | null, palette: Array<string | null> = []): TerminalColors {
|
||||
return {
|
||||
palette,
|
||||
defaultForeground: null,
|
||||
defaultBackground,
|
||||
cursorColor: null,
|
||||
mouseForeground: null,
|
||||
mouseBackground: null,
|
||||
tekForeground: null,
|
||||
tekBackground: null,
|
||||
highlightBackground: null,
|
||||
highlightForeground: null,
|
||||
}
|
||||
}
|
||||
|
||||
test("terminalMode derives mode from refreshed background", () => {
|
||||
expect(terminalMode(terminalColors("#fbf1c7"))).toBe("light")
|
||||
expect(terminalMode(terminalColors("#1a1b26"))).toBe("dark")
|
||||
})
|
||||
|
||||
test("terminalMode does not derive mode from ANSI slot zero", () => {
|
||||
expect(terminalMode(terminalColors(null, ["#000000"]))).toBeUndefined()
|
||||
})
|
||||
|
||||
test("custom theme precedence follows directory order", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
await mkdir(path.join(global, "themes"), { recursive: true })
|
||||
await mkdir(path.join(project, "themes"), { recursive: true })
|
||||
await writeFile(path.join(global, "themes", "custom.json"), JSON.stringify({ source: "global" }))
|
||||
await writeFile(path.join(project, "themes", "custom.json"), JSON.stringify({ source: "project" }))
|
||||
|
||||
await expect(discoverThemes([global, project])).resolves.toEqual({ custom: { source: "project" } })
|
||||
})
|
||||
49
packages/tui/test/util/error.test.ts
Normal file
49
packages/tui/test/util/error.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { errorData, errorFormat, errorMessage } from "../../src/util/error"
|
||||
|
||||
describe("util.error", () => {
|
||||
test("formats native Error instances", () => {
|
||||
const err = new Error("boom")
|
||||
expect(errorMessage(err)).toBe("boom")
|
||||
expect(errorFormat(err)).toContain("boom")
|
||||
|
||||
const data = errorData(err)
|
||||
expect(data.type).toBe("Error")
|
||||
expect(data.message).toBe("boom")
|
||||
expect(String(data.formatted)).toContain("boom")
|
||||
})
|
||||
|
||||
test("extracts message from record-like values", () => {
|
||||
const err = { message: "bad input", code: "E_BAD" }
|
||||
expect(errorMessage(err)).toBe("bad input")
|
||||
|
||||
const data = errorData(err)
|
||||
expect(data.message).toBe("bad input")
|
||||
expect(data.code).toBe("E_BAD")
|
||||
})
|
||||
|
||||
test("never returns bare {} for opaque object errors", () => {
|
||||
expect(errorFormat({})).not.toBe("{}")
|
||||
expect(errorFormat({})).toContain("no message")
|
||||
|
||||
class OpaqueError {}
|
||||
const opaque = new OpaqueError()
|
||||
Object.defineProperty(opaque, "secret", { value: "hidden", enumerable: false })
|
||||
expect(errorFormat(opaque)).not.toBe("{}")
|
||||
expect(errorFormat(opaque)).toContain("OpaqueError")
|
||||
})
|
||||
|
||||
test("handles opaque throwables with custom toString", () => {
|
||||
const err = {
|
||||
toString() {
|
||||
return "ResolveMessage: Cannot resolve module"
|
||||
},
|
||||
}
|
||||
|
||||
expect(errorMessage(err)).toBe("ResolveMessage: Cannot resolve module")
|
||||
|
||||
const data = errorData(err)
|
||||
expect(data.message).toBe("ResolveMessage: Cannot resolve module")
|
||||
expect(String(data.formatted)).toContain("ResolveMessage")
|
||||
})
|
||||
})
|
||||
16
packages/tui/test/util/filetype.test.ts
Normal file
16
packages/tui/test/util/filetype.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { filetype } from "../../src/util/filetype"
|
||||
|
||||
describe("util.filetype", () => {
|
||||
test("maps filenames to presentation languages", () => {
|
||||
expect(filetype("component.tsx")).toBe("typescript")
|
||||
expect(filetype("script.js")).toBe("typescript")
|
||||
expect(filetype("main.py")).toBe("python")
|
||||
expect(filetype("README.unknown")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("uses none for missing filenames", () => {
|
||||
expect(filetype()).toBe("none")
|
||||
expect(filetype("")).toBe("none")
|
||||
})
|
||||
})
|
||||
59
packages/tui/test/util/format.test.ts
Normal file
59
packages/tui/test/util/format.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { formatDuration } from "../../src/util/format"
|
||||
|
||||
describe("util.format", () => {
|
||||
describe("formatDuration", () => {
|
||||
test("returns empty string for zero or negative values", () => {
|
||||
expect(formatDuration(0)).toBe("")
|
||||
expect(formatDuration(-1)).toBe("")
|
||||
expect(formatDuration(-100)).toBe("")
|
||||
})
|
||||
|
||||
test("formats seconds under a minute", () => {
|
||||
expect(formatDuration(1)).toBe("1s")
|
||||
expect(formatDuration(30)).toBe("30s")
|
||||
expect(formatDuration(59)).toBe("59s")
|
||||
})
|
||||
|
||||
test("formats minutes under an hour", () => {
|
||||
expect(formatDuration(60)).toBe("1m")
|
||||
expect(formatDuration(61)).toBe("1m 1s")
|
||||
expect(formatDuration(90)).toBe("1m 30s")
|
||||
expect(formatDuration(120)).toBe("2m")
|
||||
expect(formatDuration(330)).toBe("5m 30s")
|
||||
expect(formatDuration(3599)).toBe("59m 59s")
|
||||
})
|
||||
|
||||
test("formats hours under a day", () => {
|
||||
expect(formatDuration(3600)).toBe("1h")
|
||||
expect(formatDuration(3660)).toBe("1h 1m")
|
||||
expect(formatDuration(7200)).toBe("2h")
|
||||
expect(formatDuration(8100)).toBe("2h 15m")
|
||||
expect(formatDuration(86399)).toBe("23h 59m")
|
||||
})
|
||||
|
||||
test("formats days under a week", () => {
|
||||
expect(formatDuration(86400)).toBe("~1 day")
|
||||
expect(formatDuration(172800)).toBe("~2 days")
|
||||
expect(formatDuration(259200)).toBe("~3 days")
|
||||
expect(formatDuration(604799)).toBe("~6 days")
|
||||
})
|
||||
|
||||
test("formats weeks", () => {
|
||||
expect(formatDuration(604800)).toBe("~1 week")
|
||||
expect(formatDuration(1209600)).toBe("~2 weeks")
|
||||
expect(formatDuration(1609200)).toBe("~2 weeks")
|
||||
})
|
||||
|
||||
test("handles boundary values correctly", () => {
|
||||
expect(formatDuration(59)).toBe("59s")
|
||||
expect(formatDuration(60)).toBe("1m")
|
||||
expect(formatDuration(3599)).toBe("59m 59s")
|
||||
expect(formatDuration(3600)).toBe("1h")
|
||||
expect(formatDuration(86399)).toBe("23h 59m")
|
||||
expect(formatDuration(86400)).toBe("~1 day")
|
||||
expect(formatDuration(604799)).toBe("~6 days")
|
||||
expect(formatDuration(604800)).toBe("~1 week")
|
||||
})
|
||||
})
|
||||
})
|
||||
9
packages/tui/test/util/model.test.ts
Normal file
9
packages/tui/test/util/model.test.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parse } from "../../src/util/model"
|
||||
|
||||
describe("util.model", () => {
|
||||
test("splits provider from a nested model identifier", () => {
|
||||
expect(parse("provider/org/model")).toEqual({ providerID: "provider", modelID: "org/model" })
|
||||
expect(parse("invalid")).toEqual({ providerID: "invalid", modelID: "" })
|
||||
})
|
||||
})
|
||||
8
packages/tui/test/util/presentation.test.ts
Normal file
8
packages/tui/test/util/presentation.test.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { expect, test } from "bun:test"
|
||||
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")
|
||||
})
|
||||
30
packages/tui/test/util/renderer.test.ts
Normal file
30
packages/tui/test/util/renderer.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { destroyRenderer } from "../../src/util/renderer"
|
||||
|
||||
test("clears the terminal title before destroying the renderer", () => {
|
||||
const calls: string[] = []
|
||||
destroyRenderer({
|
||||
isDestroyed: false,
|
||||
setTerminalTitle(title) {
|
||||
calls.push(`title:${title}`)
|
||||
},
|
||||
destroy() {
|
||||
calls.push("destroy")
|
||||
},
|
||||
})
|
||||
expect(calls).toEqual(["title:", "destroy"])
|
||||
})
|
||||
|
||||
test("still clears the title after renderer destruction", () => {
|
||||
const calls: string[] = []
|
||||
destroyRenderer({
|
||||
isDestroyed: true,
|
||||
setTerminalTitle(title) {
|
||||
calls.push(`title:${title}`)
|
||||
},
|
||||
destroy() {
|
||||
calls.push("destroy")
|
||||
},
|
||||
})
|
||||
expect(calls).toEqual(["title:"])
|
||||
})
|
||||
35
packages/tui/test/util/revert-diff.test.ts
Normal file
35
packages/tui/test/util/revert-diff.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { getRevertDiffFiles } from "../../src/util/revert-diff"
|
||||
|
||||
describe("revert diff", () => {
|
||||
test("prefers the actual file path over /dev/null for added and deleted files", () => {
|
||||
const files = getRevertDiffFiles(`diff --git a/new.txt b/new.txt
|
||||
new file mode 100644
|
||||
index 0000000..3b18e51
|
||||
--- /dev/null
|
||||
+++ b/new.txt
|
||||
@@ -0,0 +1 @@
|
||||
+new content
|
||||
diff --git a/old.txt b/old.txt
|
||||
deleted file mode 100644
|
||||
index 3b18e51..0000000
|
||||
--- a/old.txt
|
||||
+++ /dev/null
|
||||
@@ -1 +0,0 @@
|
||||
-old content
|
||||
`)
|
||||
|
||||
expect(files).toEqual([
|
||||
{
|
||||
filename: "new.txt",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
},
|
||||
{
|
||||
filename: "old.txt",
|
||||
additions: 0,
|
||||
deletions: 1,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
10
packages/tui/test/util/session.test.ts
Normal file
10
packages/tui/test/util/session.test.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { isDefaultTitle } from "../../src/util/session"
|
||||
|
||||
describe("util.session", () => {
|
||||
test("recognizes generated parent and child titles", () => {
|
||||
expect(isDefaultTitle("New session - 2026-06-06T12:34:56.789Z")).toBeTrue()
|
||||
expect(isDefaultTitle("Child session - 2026-06-06T12:34:56.789Z")).toBeTrue()
|
||||
expect(isDefaultTitle("New session - custom")).toBeFalse()
|
||||
})
|
||||
})
|
||||
40
packages/tui/test/util/tool-display.test.ts
Normal file
40
packages/tui/test/util/tool-display.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { toolDisplayMetadata, webSearchProviderLabel } from "../../src/util/tool-display"
|
||||
|
||||
describe("webSearchProviderLabel", () => {
|
||||
test("labels known providers", () => {
|
||||
expect(webSearchProviderLabel("parallel")).toBe("Parallel Web Search")
|
||||
expect(webSearchProviderLabel("exa")).toBe("Exa Web Search")
|
||||
})
|
||||
|
||||
for (const [name, provider] of [
|
||||
["undefined", undefined],
|
||||
["null", null],
|
||||
["an object", {}],
|
||||
["an array", []],
|
||||
["a number", 1],
|
||||
["an unexpected string", "other"],
|
||||
] as const) {
|
||||
test(`uses the generic label for ${name}`, () => {
|
||||
expect(webSearchProviderLabel(provider)).toBe("Web Search")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe("toolDisplayMetadata", () => {
|
||||
test("returns structured metadata for non-pending states", () => {
|
||||
const structured = { provider: "parallel", numResults: 3 }
|
||||
|
||||
expect(toolDisplayMetadata({ status: "running", structured })).toBe(structured)
|
||||
expect(toolDisplayMetadata({ status: "completed", structured })).toBe(structured)
|
||||
expect(toolDisplayMetadata({ status: "error", structured })).toBe(structured)
|
||||
})
|
||||
|
||||
test("does not expose pending or malformed metadata", () => {
|
||||
expect(toolDisplayMetadata({ status: "pending", structured: { provider: "exa" } })).toEqual({})
|
||||
expect(toolDisplayMetadata({ status: "completed" })).toEqual({})
|
||||
expect(toolDisplayMetadata({ status: "completed", structured: null })).toEqual({})
|
||||
expect(toolDisplayMetadata({ status: "completed", structured: [] })).toEqual({})
|
||||
expect(toolDisplayMetadata(undefined)).toEqual({})
|
||||
})
|
||||
})
|
||||
421
packages/tui/test/util/transcript.test.ts
Normal file
421
packages/tui/test/util/transcript.test.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { formatAssistantHeader, formatMessage, formatPart, formatTranscript } from "../../src/util/transcript"
|
||||
import type { AssistantMessage, Part, Provider, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
|
||||
const providers: Provider[] = [
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
source: "api",
|
||||
env: [],
|
||||
options: {},
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": {
|
||||
id: "claude-sonnet-4-20250514",
|
||||
providerID: "anthropic",
|
||||
api: {
|
||||
id: "claude-sonnet-4-20250514",
|
||||
url: "https://example.com/claude-sonnet-4-20250514",
|
||||
npm: "@ai-sdk/anthropic",
|
||||
},
|
||||
name: "Claude Sonnet 4",
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: true,
|
||||
attachment: true,
|
||||
toolcall: true,
|
||||
input: {
|
||||
text: true,
|
||||
audio: false,
|
||||
image: true,
|
||||
video: false,
|
||||
pdf: true,
|
||||
},
|
||||
output: {
|
||||
text: true,
|
||||
audio: false,
|
||||
image: false,
|
||||
video: false,
|
||||
pdf: false,
|
||||
},
|
||||
interleaved: false,
|
||||
},
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache: {
|
||||
read: 0,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
limit: {
|
||||
context: 200_000,
|
||||
output: 8_192,
|
||||
},
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2025-05-14",
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
describe("transcript", () => {
|
||||
describe("formatAssistantHeader", () => {
|
||||
const baseMsg: AssistantMessage = {
|
||||
id: "msg_123",
|
||||
sessionID: "ses_123",
|
||||
role: "assistant",
|
||||
agent: "build",
|
||||
modelID: "claude-sonnet-4-20250514",
|
||||
providerID: "anthropic",
|
||||
mode: "",
|
||||
parentID: "msg_parent",
|
||||
path: { cwd: "/test", root: "/test" },
|
||||
cost: 0.001,
|
||||
tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1000000, completed: 1005400 },
|
||||
}
|
||||
|
||||
test("includes metadata when enabled", () => {
|
||||
const result = formatAssistantHeader(baseMsg, true)
|
||||
expect(result).toBe("## Assistant (Build · claude-sonnet-4-20250514 · 5.4s)\n\n")
|
||||
})
|
||||
|
||||
test("uses model display name when available", () => {
|
||||
const result = formatAssistantHeader(baseMsg, true, providers)
|
||||
expect(result).toBe("## Assistant (Build · Claude Sonnet 4 · 5.4s)\n\n")
|
||||
})
|
||||
|
||||
test("excludes metadata when disabled", () => {
|
||||
const result = formatAssistantHeader(baseMsg, false)
|
||||
expect(result).toBe("## Assistant\n\n")
|
||||
})
|
||||
|
||||
test("handles missing completed time", () => {
|
||||
const msg = { ...baseMsg, time: { created: 1000000 } }
|
||||
const result = formatAssistantHeader(msg as AssistantMessage, true)
|
||||
expect(result).toBe("## Assistant (Build · claude-sonnet-4-20250514)\n\n")
|
||||
})
|
||||
|
||||
test("titlecases agent name", () => {
|
||||
const msg = { ...baseMsg, agent: "plan" }
|
||||
const result = formatAssistantHeader(msg, true)
|
||||
expect(result).toContain("Plan")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatPart", () => {
|
||||
const options = { thinking: true, toolDetails: true, assistantMetadata: true }
|
||||
|
||||
test("formats text part", () => {
|
||||
const part: Part = {
|
||||
id: "part_1",
|
||||
sessionID: "ses_123",
|
||||
messageID: "msg_123",
|
||||
type: "text",
|
||||
text: "Hello world",
|
||||
}
|
||||
const result = formatPart(part, options)
|
||||
expect(result).toBe("Hello world\n\n")
|
||||
})
|
||||
|
||||
test("skips synthetic text parts", () => {
|
||||
const part: Part = {
|
||||
id: "part_1",
|
||||
sessionID: "ses_123",
|
||||
messageID: "msg_123",
|
||||
type: "text",
|
||||
text: "Synthetic content",
|
||||
synthetic: true,
|
||||
}
|
||||
const result = formatPart(part, options)
|
||||
expect(result).toBe("")
|
||||
})
|
||||
|
||||
test("formats reasoning when thinking enabled", () => {
|
||||
const part: Part = {
|
||||
id: "part_1",
|
||||
sessionID: "ses_123",
|
||||
messageID: "msg_123",
|
||||
type: "reasoning",
|
||||
text: "Let me think...",
|
||||
time: { start: 1000 },
|
||||
}
|
||||
const result = formatPart(part, options)
|
||||
expect(result).toBe("_Thinking:_\n\nLet me think...\n\n")
|
||||
})
|
||||
|
||||
test("skips reasoning when thinking disabled", () => {
|
||||
const part: Part = {
|
||||
id: "part_1",
|
||||
sessionID: "ses_123",
|
||||
messageID: "msg_123",
|
||||
type: "reasoning",
|
||||
text: "Let me think...",
|
||||
time: { start: 1000 },
|
||||
}
|
||||
const result = formatPart(part, { ...options, thinking: false })
|
||||
expect(result).toBe("")
|
||||
})
|
||||
|
||||
test("formats tool part with details", () => {
|
||||
const part: Part = {
|
||||
id: "part_1",
|
||||
sessionID: "ses_123",
|
||||
messageID: "msg_123",
|
||||
type: "tool",
|
||||
callID: "call_1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "ls" },
|
||||
output: "file1.txt\nfile2.txt",
|
||||
title: "List files",
|
||||
metadata: {},
|
||||
time: { start: 1000, end: 1100 },
|
||||
},
|
||||
}
|
||||
const result = formatPart(part, options)
|
||||
expect(result).toContain("**Tool: bash**")
|
||||
expect(result).toContain("**Input:**")
|
||||
expect(result).toContain('"command": "ls"')
|
||||
expect(result).toContain("**Output:**")
|
||||
expect(result).toContain("file1.txt")
|
||||
})
|
||||
|
||||
test("formats tool output containing triple backticks without breaking markdown", () => {
|
||||
const part: Part = {
|
||||
id: "part_1",
|
||||
sessionID: "ses_123",
|
||||
messageID: "msg_123",
|
||||
type: "tool",
|
||||
callID: "call_1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "echo '```hello```'" },
|
||||
output: "```hello```",
|
||||
title: "Echo backticks",
|
||||
metadata: {},
|
||||
time: { start: 1000, end: 1100 },
|
||||
},
|
||||
}
|
||||
const result = formatPart(part, options)
|
||||
// The tool header should not be inside a code block
|
||||
expect(result).toStartWith("**Tool: bash**\n")
|
||||
// Input and output should each be in their own code blocks
|
||||
expect(result).toContain("**Input:**\n```json")
|
||||
expect(result).toContain("**Output:**\n```\n```hello```\n```")
|
||||
})
|
||||
|
||||
test("formats tool part without details when disabled", () => {
|
||||
const part: Part = {
|
||||
id: "part_1",
|
||||
sessionID: "ses_123",
|
||||
messageID: "msg_123",
|
||||
type: "tool",
|
||||
callID: "call_1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "ls" },
|
||||
output: "file1.txt",
|
||||
title: "List files",
|
||||
metadata: {},
|
||||
time: { start: 1000, end: 1100 },
|
||||
},
|
||||
}
|
||||
const result = formatPart(part, { ...options, toolDetails: false })
|
||||
expect(result).toContain("**Tool: bash**")
|
||||
expect(result).not.toContain("**Input:**")
|
||||
expect(result).not.toContain("**Output:**")
|
||||
})
|
||||
|
||||
test("formats tool error", () => {
|
||||
const part: Part = {
|
||||
id: "part_1",
|
||||
sessionID: "ses_123",
|
||||
messageID: "msg_123",
|
||||
type: "tool",
|
||||
callID: "call_1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "error",
|
||||
input: { command: "invalid" },
|
||||
error: "Command failed",
|
||||
time: { start: 1000, end: 1100 },
|
||||
},
|
||||
}
|
||||
const result = formatPart(part, options)
|
||||
expect(result).toContain("**Error:**")
|
||||
expect(result).toContain("Command failed")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatMessage", () => {
|
||||
const options = { thinking: true, toolDetails: true, assistantMetadata: true, providers }
|
||||
|
||||
test("formats user message", () => {
|
||||
const msg: UserMessage = {
|
||||
id: "msg_123",
|
||||
sessionID: "ses_123",
|
||||
role: "user",
|
||||
agent: "build",
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
|
||||
time: { created: 1000000 },
|
||||
}
|
||||
const parts: Part[] = [{ id: "p1", sessionID: "ses_123", messageID: "msg_123", type: "text", text: "Hello" }]
|
||||
const result = formatMessage(msg, parts, options)
|
||||
expect(result).toContain("## User")
|
||||
expect(result).toContain("Hello")
|
||||
})
|
||||
|
||||
test("formats assistant message with metadata", () => {
|
||||
const msg: AssistantMessage = {
|
||||
id: "msg_123",
|
||||
sessionID: "ses_123",
|
||||
role: "assistant",
|
||||
agent: "build",
|
||||
modelID: "claude-sonnet-4-20250514",
|
||||
providerID: "anthropic",
|
||||
mode: "",
|
||||
parentID: "msg_parent",
|
||||
path: { cwd: "/test", root: "/test" },
|
||||
cost: 0.001,
|
||||
tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1000000, completed: 1005400 },
|
||||
}
|
||||
const parts: Part[] = [{ id: "p1", sessionID: "ses_123", messageID: "msg_123", type: "text", text: "Hi there" }]
|
||||
const result = formatMessage(msg, parts, options)
|
||||
expect(result).toContain("## Assistant (Build · Claude Sonnet 4 · 5.4s)")
|
||||
expect(result).toContain("Hi there")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatTranscript", () => {
|
||||
test("formats complete transcript", () => {
|
||||
const session = {
|
||||
id: "ses_abc123",
|
||||
title: "Test Session",
|
||||
time: { created: 1000000000000, updated: 1000000001000 },
|
||||
}
|
||||
const messages = [
|
||||
{
|
||||
info: {
|
||||
id: "msg_1",
|
||||
sessionID: "ses_abc123",
|
||||
role: "user" as const,
|
||||
agent: "build",
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
|
||||
time: { created: 1000000000000 },
|
||||
},
|
||||
parts: [{ id: "p1", sessionID: "ses_abc123", messageID: "msg_1", type: "text" as const, text: "Hello" }],
|
||||
},
|
||||
{
|
||||
info: {
|
||||
id: "msg_2",
|
||||
sessionID: "ses_abc123",
|
||||
role: "assistant" as const,
|
||||
agent: "build",
|
||||
modelID: "claude-sonnet-4-20250514",
|
||||
providerID: "anthropic",
|
||||
mode: "",
|
||||
parentID: "msg_1",
|
||||
path: { cwd: "/test", root: "/test" },
|
||||
cost: 0.001,
|
||||
tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1000000000100, completed: 1000000000600 },
|
||||
},
|
||||
parts: [{ id: "p2", sessionID: "ses_abc123", messageID: "msg_2", type: "text" as const, text: "Hi!" }],
|
||||
},
|
||||
]
|
||||
const options = {
|
||||
thinking: false,
|
||||
toolDetails: false,
|
||||
assistantMetadata: true,
|
||||
providers,
|
||||
}
|
||||
|
||||
const result = formatTranscript(session, messages, options)
|
||||
|
||||
expect(result).toContain("# Test Session")
|
||||
expect(result).toContain("**Session ID:** ses_abc123")
|
||||
expect(result).toContain("## User")
|
||||
expect(result).toContain("Hello")
|
||||
expect(result).toContain("## Assistant (Build · Claude Sonnet 4 · 0.5s)")
|
||||
expect(result).toContain("Hi!")
|
||||
expect(result).toContain("---")
|
||||
})
|
||||
|
||||
test("falls back to raw model id when provider data is missing", () => {
|
||||
const session = {
|
||||
id: "ses_abc123",
|
||||
title: "Test Session",
|
||||
time: { created: 1000000000000, updated: 1000000001000 },
|
||||
}
|
||||
const messages = [
|
||||
{
|
||||
info: {
|
||||
id: "msg_1",
|
||||
sessionID: "ses_abc123",
|
||||
role: "assistant" as const,
|
||||
agent: "build",
|
||||
modelID: "claude-sonnet-4-20250514",
|
||||
providerID: "anthropic",
|
||||
mode: "",
|
||||
parentID: "msg_0",
|
||||
path: { cwd: "/test", root: "/test" },
|
||||
cost: 0.001,
|
||||
tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1000000000100, completed: 1000000000600 },
|
||||
},
|
||||
parts: [{ id: "p1", sessionID: "ses_abc123", messageID: "msg_1", type: "text" as const, text: "Response" }],
|
||||
},
|
||||
]
|
||||
|
||||
const result = formatTranscript(session, messages, {
|
||||
thinking: false,
|
||||
toolDetails: false,
|
||||
assistantMetadata: true,
|
||||
})
|
||||
|
||||
expect(result).toContain("## Assistant (Build · claude-sonnet-4-20250514 · 0.5s)")
|
||||
})
|
||||
|
||||
test("formats transcript without assistant metadata", () => {
|
||||
const session = {
|
||||
id: "ses_abc123",
|
||||
title: "Test Session",
|
||||
time: { created: 1000000000000, updated: 1000000001000 },
|
||||
}
|
||||
const messages = [
|
||||
{
|
||||
info: {
|
||||
id: "msg_1",
|
||||
sessionID: "ses_abc123",
|
||||
role: "assistant" as const,
|
||||
agent: "build",
|
||||
modelID: "claude-sonnet-4-20250514",
|
||||
providerID: "anthropic",
|
||||
mode: "",
|
||||
parentID: "msg_0",
|
||||
path: { cwd: "/test", root: "/test" },
|
||||
cost: 0.001,
|
||||
tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1000000000100, completed: 1000000000600 },
|
||||
},
|
||||
parts: [{ id: "p1", sessionID: "ses_abc123", messageID: "msg_1", type: "text" as const, text: "Response" }],
|
||||
},
|
||||
]
|
||||
const options = { thinking: false, toolDetails: false, assistantMetadata: false }
|
||||
|
||||
const result = formatTranscript(session, messages, options)
|
||||
|
||||
expect(result).toContain("## Assistant\n\n")
|
||||
expect(result).not.toContain("Build")
|
||||
expect(result).not.toContain("claude-sonnet-4-20250514")
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user